Files
OmniRoute/open-sse/services/adobeFireflyClient.ts
backryun dd34520ffe Train 1D: merge via --admin on .113 validation
Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
2026-07-27 11:29:23 -03:00

2317 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Adobe Firefly (unofficial) media client.
*
* Talks to the same Firefly 3P async APIs that firefly.adobe.com uses (live browser
* captures in repo `adobe/`):
* POST https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async
* POST https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async
* POST https://firefly-3p.ff.adobe.io/v2/models/discovery
* GET https://firefly.adobe.io/v1/credits/balance
* then polls BKS job result URLs rewritten from links.result.
*
* Auth is an Adobe IMS access token (Bearer, client_id = clio-playground-web).
* Callers may pass either:
* - a raw IMS access_token JWT (from Authorization: Bearer on Firefly), or
* - a browser Cookie header from firefly.adobe.com (exchanged via IMS check/v6/token
* with client_id clio-playground-web; Express projectx_webapp as fallback).
*
* x-api-key on generate/discovery MUST match the token's IMS client
* (`clio-playground-web`). Mismatch → HTTP 401 invalid token.
*
* Unofficial — tokens/cookies are short-lived; Adobe may change the wire contract.
*/
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { resolvePublicCred } from "../utils/publicCreds.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
export const ADOBE_FIREFLY_IMAGE_SUBMIT_URL =
"https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async";
export const ADOBE_FIREFLY_VIDEO_SUBMIT_URL =
"https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async";
export const ADOBE_FIREFLY_IMAGE_UPLOAD_URL =
"https://firefly-3p.ff.adobe.io/v2/storage/image";
export const ADOBE_FIREFLY_MODELS_DISCOVERY_URL =
"https://firefly-3p.ff.adobe.io/v2/models/discovery";
export const ADOBE_FIREFLY_CREDITS_BALANCE_URL =
"https://firefly.adobe.io/v1/credits/balance";
export const ADOBE_FIREFLY_IMS_REFRESH_URL =
"https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb";
/** Scope set observed on live firefly.adobe.com IMS access tokens. */
export const ADOBE_FIREFLY_IMS_SCOPE =
"AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext," +
"additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations," +
"additional_info.roles,account_cluster.read,creative_production,tk_platform," +
"tk_platform_sync,profile";
const DEFAULT_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
const DEFAULT_SEC_CH_UA =
'"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"';
const DEFAULT_POLL_INTERVAL_MS = 3000;
const DEFAULT_IMAGE_TIMEOUT_MS = 180_000;
const DEFAULT_VIDEO_TIMEOUT_MS = 300_000;
const FIREFLY_ORIGIN = "https://firefly.adobe.com";
const FIREFLY_REFERER = "https://firefly.adobe.com/";
export type AdobeFireflyImageModelId =
| "nano-banana-pro"
| "nano-banana"
| "nano-banana-2"
| "gpt-image"
| "gpt-image-2"
| "gpt-image-1.5"
| "flux-2"
| "flux-pro"
| "flux-ultra"
| "seedream-4"
| "seedream-5-lite"
| "runway-gen4-image";
export type AdobeFireflyVideoModelId =
| "sora-2"
| "sora-2-pro"
| "veo-3.1"
| "veo-3.1-fast"
| "veo-3.1-ref"
| "kling-3";
export interface AdobeFireflyImageModelSpec {
upstreamModelId: string;
upstreamModelVersion: string;
/** Payload builder family — nano uses Gemini-style size maps; gpt-image uses OpenAI detail levels. */
family: "nano" | "gpt-image" | "generic";
}
export interface AdobeFireflyVideoModelSpec {
engine: "sora2" | "sora2-pro" | "veo31-standard" | "veo31-fast" | "kling3";
upstreamModel: string;
modelId?: string;
modelVersion?: string;
referenceMode?: "frame" | "image";
defaultDuration: number;
defaultResolution: string;
}
/**
* Upstream modelId/modelVersion pairs from firefly-3p models/discovery
* (captured 2026-07 — see adobe/get_models.txt). Friendly catalog ids map here.
*/
export const ADOBE_FIREFLY_IMAGE_MODELS: Record<AdobeFireflyImageModelId, AdobeFireflyImageModelSpec> =
{
// Gemini 3.0 (Nano Banana Pro) — discovery: gemini-flash / nano-banana-2
"nano-banana-pro": {
upstreamModelId: "gemini-flash",
upstreamModelVersion: "nano-banana-2",
family: "nano",
},
// Gemini 2.5 (Nano Banana) — discovery: gemini-flash / nano-banana
"nano-banana": {
upstreamModelId: "gemini-flash",
upstreamModelVersion: "nano-banana",
family: "nano",
},
// Gemini 3.1 (Nano Banana 2) — discovery: gemini-flash / nano-banana-3
"nano-banana-2": {
upstreamModelId: "gemini-flash",
upstreamModelVersion: "nano-banana-3",
family: "nano",
},
// GPT Image 2 — discovery modelVersion "2" (get_models: modelDisplayName "GPT Image 2")
"gpt-image": {
upstreamModelId: "gpt-image",
upstreamModelVersion: "2",
family: "gpt-image",
},
// Explicit catalog alias so pickers show "gpt-image-2" distinctly
"gpt-image-2": {
upstreamModelId: "gpt-image",
upstreamModelVersion: "2",
family: "gpt-image",
},
"gpt-image-1.5": {
upstreamModelId: "gpt-image",
upstreamModelVersion: "1.5",
family: "gpt-image",
},
"flux-2": {
upstreamModelId: "flux",
upstreamModelVersion: "2",
family: "generic",
},
"flux-pro": {
upstreamModelId: "flux",
upstreamModelVersion: "fluxPro",
family: "generic",
},
"flux-ultra": {
upstreamModelId: "flux",
upstreamModelVersion: "fluxUltra",
family: "generic",
},
"seedream-4": {
upstreamModelId: "seedream",
upstreamModelVersion: "seedream_v4",
family: "generic",
},
"seedream-5-lite": {
upstreamModelId: "seedream",
upstreamModelVersion: "seedream_v5_lite",
family: "generic",
},
"runway-gen4-image": {
upstreamModelId: "runway-gen4-image",
upstreamModelVersion: "gen4_image",
family: "generic",
},
};
export const ADOBE_FIREFLY_VIDEO_MODELS: Record<AdobeFireflyVideoModelId, AdobeFireflyVideoModelSpec> =
{
"sora-2": {
engine: "sora2",
upstreamModel: "openai:firefly:colligo:sora2",
defaultDuration: 8,
defaultResolution: "720p",
},
"sora-2-pro": {
engine: "sora2-pro",
upstreamModel: "openai:firefly:colligo:sora2-pro",
defaultDuration: 8,
defaultResolution: "720p",
},
"veo-3.1": {
engine: "veo31-standard",
upstreamModel: "google:firefly:colligo:veo31",
modelId: "veo",
modelVersion: "3.1-generate",
defaultDuration: 6,
defaultResolution: "720p",
},
"veo-3.1-fast": {
engine: "veo31-fast",
upstreamModel: "google:firefly:colligo:veo31-fast",
modelId: "veo",
modelVersion: "3.1-fast-generate",
defaultDuration: 6,
defaultResolution: "720p",
},
"veo-3.1-ref": {
engine: "veo31-standard",
upstreamModel: "google:firefly:colligo:veo31",
modelId: "veo",
modelVersion: "3.1-generate",
referenceMode: "image",
defaultDuration: 6,
defaultResolution: "720p",
},
"kling-3": {
engine: "kling3",
upstreamModel: "kling:firefly:colligo:kling3",
modelId: "kling",
modelVersion: "kling_v3_standard_i2v",
defaultDuration: 5,
defaultResolution: "1080p",
},
};
const NANO_SIZE_MAP: Record<string, Record<string, { width: number; height: number }>> = {
"1K": {
"1:1": { width: 1024, height: 1024 },
"16:9": { width: 1360, height: 768 },
"9:16": { width: 768, height: 1360 },
"4:3": { width: 1152, height: 864 },
"3:4": { width: 864, height: 1152 },
"1:8": { width: 384, height: 3072 },
"1:4": { width: 512, height: 2048 },
"4:1": { width: 2048, height: 512 },
"8:1": { width: 3072, height: 384 },
},
"2K": {
"1:1": { width: 2048, height: 2048 },
"16:9": { width: 2752, height: 1536 },
"9:16": { width: 1536, height: 2752 },
"4:3": { width: 2048, height: 1536 },
"3:4": { width: 1536, height: 2048 },
"1:8": { width: 768, height: 6144 },
"1:4": { width: 1024, height: 4096 },
"4:1": { width: 4096, height: 1024 },
"8:1": { width: 6144, height: 768 },
},
"4K": {
"1:1": { width: 4096, height: 4096 },
"16:9": { width: 5504, height: 3072 },
"9:16": { width: 3072, height: 5504 },
"4:3": { width: 4096, height: 3072 },
"3:4": { width: 3072, height: 4096 },
"1:8": { width: 1536, height: 12288 },
"1:4": { width: 2048, height: 8192 },
"4:1": { width: 8192, height: 2048 },
"8:1": { width: 12288, height: 1536 },
},
};
const GPT_SIZE_MAP: Record<string, Record<string, { width: number; height: number }>> = {
"1K": {
"1:1": { width: 1024, height: 1024 },
"5:4": { width: 1120, height: 896 },
"9:16": { width: 720, height: 1280 },
"21:9": { width: 1456, height: 624 },
"16:9": { width: 1280, height: 720 },
"4:3": { width: 1152, height: 864 },
"3:2": { width: 1248, height: 832 },
"4:5": { width: 896, height: 1120 },
"3:4": { width: 864, height: 1152 },
"2:3": { width: 832, height: 1248 },
},
"2K": {
"1:1": { width: 2048, height: 2048 },
"5:4": { width: 2240, height: 1792 },
"9:16": { width: 1440, height: 2560 },
"21:9": { width: 3024, height: 1296 },
"16:9": { width: 2560, height: 1440 },
"4:3": { width: 2304, height: 1728 },
"3:2": { width: 2496, height: 1664 },
"4:5": { width: 1792, height: 2240 },
"3:4": { width: 1728, height: 2304 },
"2:3": { width: 1664, height: 2496 },
},
"4K": {
"1:1": { width: 2880, height: 2880 },
"5:4": { width: 3200, height: 2560 },
"9:16": { width: 2160, height: 3840 },
"21:9": { width: 3696, height: 1584 },
"16:9": { width: 3840, height: 2160 },
"4:3": { width: 3264, height: 2448 },
"3:2": { width: 3504, height: 2336 },
"4:5": { width: 2560, height: 3200 },
"3:4": { width: 2448, height: 3264 },
"2:3": { width: 2336, height: 3504 },
},
};
const PIXEL_SIZE_TO_RATIO: Record<string, string> = {
"1024x1024": "1:1",
"1536x1536": "1:1",
"2048x2048": "1:1",
"1024x1792": "9:16",
"1536x2752": "9:16",
"1792x1024": "16:9",
"2752x1536": "16:9",
"2048x1536": "4:3",
"1536x2048": "3:4",
"1280x720": "16:9",
"720x1280": "9:16",
"1920x1080": "16:9",
"1080x1920": "9:16",
};
export class AdobeFireflyError extends Error {
status: number;
code?: string;
constructor(message: string, status = 502, code?: string) {
super(message);
this.name = "AdobeFireflyError";
this.status = status;
this.code = code;
}
}
/** Public x-api-key + primary IMS client_id for firefly.adobe.com (`clio-playground-web`). */
export function adobeFireflyApiKey(): string {
return resolvePublicCred("adobe_firefly_api_key", "ADOBE_FIREFLY_API_KEY");
}
/** Express IMS client_id fallback for cookie exchange (`projectx_webapp`). */
export function adobeFireflyExpressClientId(): string {
return resolvePublicCred("adobe_firefly_express_client_id", "ADOBE_FIREFLY_EXPRESS_CLIENT_ID");
}
/** Public x-api-key for GET firefly.adobe.io/v1/credits/balance (`SunbreakWebUI1`). */
export function adobeFireflyBalanceApiKey(): string {
return resolvePublicCred("adobe_firefly_balance_api_key", "ADOBE_FIREFLY_BALANCE_API_KEY");
}
/** Decode IMS JWT payload (no signature verification — client-side claim read only). */
export function decodeAdobeJwtPayload(token: string): Record<string, unknown> | null {
try {
// Do not call extractAdobeCredentialToken here (would recurse via guest checks).
let raw = String(token || "").trim().replace(/^bearer\s+/i, "").trim();
// If a blob was passed, take the first JWT-shaped segment.
const m = raw.match(/eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/);
if (m) raw = m[0];
const part = raw.split(".")[1];
if (!part) return null;
const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
const obj = JSON.parse(json);
return obj && typeof obj === "object" ? (obj as Record<string, unknown>) : null;
} catch {
return null;
}
}
/** AdobeID subject for x-account-id on balance / account_cluster calls. */
export function extractAdobeAccountIdFromToken(token: string): string {
const payload = decodeAdobeJwtPayload(token);
if (!payload) return "";
const candidates = [payload.user_id, payload.aa_id, payload.sub, payload.id];
for (const c of candidates) {
if (typeof c === "string" && c.includes("@")) return c.trim();
}
for (const c of candidates) {
if (typeof c === "string" && c.trim()) return c.trim();
}
return "";
}
export function looksLikeAdobeJwt(value: string): boolean {
const raw = value.trim();
if (!raw) return false;
// Avoid treating cookie blobs that happen to have two dots as JWT.
if (raw.includes(";") || (raw.includes("=") && !raw.startsWith("eyJ"))) return false;
// Allow a single space after optional Bearer prefix (stripped earlier).
if (/\s/.test(raw) && !/^bearer\s+/i.test(raw)) return false;
const token = raw.replace(/^bearer\s+/i, "").trim();
const parts = token.split(".");
if (parts.length !== 3) return false;
// Adobe IMS access tokens are sizable; reject tiny accidental 3-segment strings.
if (token.length < 80) return false;
return parts.every((p) => p.length > 0 && /^[A-Za-z0-9_-]+$/.test(p));
}
/**
* True when IMS issued a guest token (no signed-in AdobeID).
* Live repro: firefly.adobe.com page cookies alone → account_type=guest → generate 401 /
* balance 403 ErrMismatchOauthToken.
*/
export function isAdobeGuestAccessToken(token: string): boolean {
const payload = decodeAdobeJwtPayload(token);
if (!payload) return false;
const userId = typeof payload.user_id === "string" ? payload.user_id : "";
const aaId = typeof payload.aa_id === "string" ? payload.aa_id : "";
const type = typeof payload.type === "string" ? payload.type.toLowerCase() : "";
// Authenticated Firefly tokens always carry an @AdobeID (or similar) subject.
if (userId.includes("@AdobeID") || aaId.includes("@AdobeID")) return false;
if (userId.includes("@GuestID") || aaId.includes("@GuestID")) return true;
if (type === "guest" || type.includes("guest")) return true;
// Guest tokens from ims/check often omit type/user_id entirely.
if (!userId && !aaId) return true;
return false;
}
export function isAdobeUserAccessToken(token: string): boolean {
return looksLikeAdobeJwt(token) && !isAdobeGuestAccessToken(token);
}
/**
* Pull an IMS JWT out of free-form paste: raw JWT, Bearer …, access_token=…,
* IMS sessionStorage JSON (`tokenValue`), multi-line Network/HAR dumps.
* Prefer the longest user (non-guest) eyJ… JWT found.
*/
export function extractAdobeCredentialToken(raw: string): string {
const value = String(raw || "").trim();
if (!value) return "";
if (/^bearer\s+/i.test(value)) {
const bare = value.replace(/^bearer\s+/i, "").trim().split(/\s+/)[0] || "";
if (looksLikeAdobeJwt(bare)) return bare;
}
// access_token=... in cookie-ish or form paste
const accessMatch = value.match(/(?:^|[;\s&])access_token=([^;\s&]+)/i);
if (accessMatch?.[1]) {
const t = decodeURIComponent(accessMatch[1].trim());
if (looksLikeAdobeJwt(t)) return t;
}
// IMS sessionStorage / localStorage JSON: "tokenValue":"eyJ..."
const tokenValueMatch = value.match(/"tokenValue"\s*:\s*"(eyJ[^"]+)"/i);
if (tokenValueMatch?.[1] && looksLikeAdobeJwt(tokenValueMatch[1])) {
return tokenValueMatch[1];
}
// Authorization: Bearer eyJ...
const authMatch = value.match(/Authorization\s*:\s*Bearer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i);
if (authMatch?.[1] && looksLikeAdobeJwt(authMatch[1])) return authMatch[1];
// Any eyJ… JWT in the blob (HAR / multi-line). Prefer user AdobeID tokens.
const jwtMatches = value.match(/eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/g);
if (jwtMatches && jwtMatches.length > 0) {
const sorted = [...jwtMatches].sort((a, b) => b.length - a.length);
const user = sorted.find((t) => looksLikeAdobeJwt(t) && isAdobeUserAccessToken(t));
if (user) return user;
const best = sorted[0];
if (looksLikeAdobeJwt(best)) return best;
}
// Pure JWT
if (looksLikeAdobeJwt(value)) return value.replace(/^bearer\s+/i, "").trim();
// Cookie / other blob unchanged for IMS exchange
return value;
}
/**
* True when the paste still looks like a Cookie header (not a bare JWT).
* Used to attach Cookie + sherlockToken → x-arp-session-id on generate.
*/
export function looksLikeAdobeCookieBlob(value: string): boolean {
const raw = String(value || "").trim();
if (!raw || looksLikeAdobeJwt(raw)) return false;
if (raw.includes(";") && raw.includes("=")) return true;
if (/(?:^|[;\s])(?:aux_sid|ff_session|sherlockToken|forterToken|arkose)=/i.test(raw)) {
return true;
}
return false;
}
/**
* Strip JWTs / Authorization lines from a mixed paste so only Cookie pairs remain.
* Undici Headers.append rejects multi-line Cookie values (throws Headers.append: "eyJ…").
*/
export function extractAdobeCookieHeader(raw: string): string {
const value = String(raw || "").trim();
if (!value) return "";
if (looksLikeAdobeJwt(value)) return "";
// Drop pure JWT lines and Authorization: Bearer lines
const cleaned = value
.split(/[\r\n]+/)
.map((line) => line.trim())
.filter((line) => {
if (!line) return false;
if (/^authorization\s*:/i.test(line)) return false;
if (/^bearer\s+/i.test(line)) return false;
if (looksLikeAdobeJwt(line)) return false;
// Drop standalone eyJ… segments
if (/^eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}$/.test(line)) return false;
return true;
})
.join("; ");
// Also strip inline eyJ JWT tokens that may sit inside a cookie string
const noJwt = cleaned
.replace(/eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/g, "")
.replace(/;\s*;/g, ";")
.replace(/^;\s*|\s*;$/g, "")
.trim();
if (!noJwt || !looksLikeAdobeCookieBlob(noJwt)) return "";
// Final safety: Cookie header must be single-line
return noJwt.replace(/[\r\n]+/g, "; ").trim();
}
const GUEST_COOKIE_HELP =
"Firefly page cookies alone only mint a GUEST IMS token (no AdobeID) — generate returns 401 and Limits 403. " +
"Fix: open firefly.adobe.com signed-in → F12 → Network → click a request to firefly-3p.ff.adobe.io " +
"(generate-async or models/discovery) → Request Headers → Authorization → copy the token AFTER 'Bearer ' " +
"(starts with eyJ…). Paste that JWT as the credential. " +
"Cookie-only works only if you also export IMS session cookies from adobelogin.com / adobeid-na1 " +
"(Cookie-Editor → export all Adobe domains); firefly.adobe.com cookies by themselves are not enough.";
export function normalizeAdobeAspectRatio(sizeOrRatio: unknown, fallback = "1:1"): string {
if (typeof sizeOrRatio !== "string" || !sizeOrRatio.trim()) return fallback;
let raw = sizeOrRatio.trim().replace(/_/g, ":");
if (raw.toLowerCase() === "auto") return fallback;
if (/^\d+:\d+$/.test(raw)) return raw;
// Short ratio forms like 16x9 / 9x16
const short = raw.match(/^(\d+)x(\d+)$/i);
if (short) {
const a = Number(short[1]);
const b = Number(short[2]);
if (a > 0 && b > 0 && a < 100 && b < 100) return `${a}:${b}`;
}
const lower = raw.toLowerCase();
if (PIXEL_SIZE_TO_RATIO[lower]) return PIXEL_SIZE_TO_RATIO[lower];
// Generic WxH pixel sizes → closest common ratio
const pixel = lower.match(/^(\d+)x(\d+)$/);
if (pixel) {
const w = Number(pixel[1]);
const h = Number(pixel[2]);
if (w > 0 && h > 0) {
if (Math.abs(w - h) / Math.max(w, h) < 0.08) return "1:1";
if (w > h * 1.5) return "16:9";
if (h > w * 1.5) return "9:16";
if (w > h) return "4:3";
return "3:4";
}
}
return fallback;
}
export function normalizeAdobeOutputResolution(quality: unknown, size: unknown): "1K" | "2K" | "4K" {
const q = String(quality ?? "").trim().toLowerCase();
if (q === "4k" || q === "ultra" || q === "high") return "4K";
if (q === "2k" || q === "hd" || q === "standard" || q === "medium") return "2K";
if (q === "1k" || q === "low") return "1K";
const s = String(size ?? "").toLowerCase();
if (s.includes("4k") || /4096|5504|3840/.test(s)) return "4K";
if (s.includes("1k") || /1024x1024|768x1360|1360x768/.test(s)) return "1K";
return "2K";
}
export function resolveAdobeImageModel(model: string): {
id: AdobeFireflyImageModelId;
spec: AdobeFireflyImageModelSpec;
} {
const raw = String(model || "")
.trim()
.toLowerCase()
.replace(/^adobe-firefly\//, "")
.replace(/^firefly\//, "");
// Accept long catalog ids like firefly-nano-banana-pro-2k-16x9
if (raw.includes("nano-banana2") || raw.includes("nano-banana-2") || raw.includes("nano-banana-3")) {
return { id: "nano-banana-2", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-2"] };
}
if (raw.includes("nano-banana-pro")) {
return { id: "nano-banana-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"] };
}
if (raw.includes("nano-banana")) {
return { id: "nano-banana", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana"] };
}
if (raw.includes("gpt-image-1.5") || raw.includes("gpt-image1.5")) {
return { id: "gpt-image-1.5", spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"] };
}
// Prefer explicit "2" / "gpt-image-2" before generic gpt-image
if (
raw === "gpt-image-2" ||
raw.includes("gpt-image-2") ||
raw.includes("gptimage2") ||
raw === "gpt-image" ||
raw.includes("gpt-image")
) {
// Bare gpt-image and gpt-image-2 both map to upstream version "2" (GPT Image 2).
if (raw.includes("1.5")) {
return { id: "gpt-image-1.5", spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"] };
}
const id = raw.includes("gpt-image-2") || raw.includes("gptimage2") ? "gpt-image-2" : "gpt-image";
return { id: id as AdobeFireflyImageModelId, spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"] };
}
if (raw.includes("flux-ultra") || raw.includes("fluxultra")) {
return { id: "flux-ultra", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-ultra"] };
}
if (raw.includes("flux-pro") || raw.includes("fluxpro")) {
return { id: "flux-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-pro"] };
}
if (raw.includes("flux")) {
return { id: "flux-2", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-2"] };
}
if (raw.includes("seedream-5") || raw.includes("seedream_v5")) {
return { id: "seedream-5-lite", spec: ADOBE_FIREFLY_IMAGE_MODELS["seedream-5-lite"] };
}
if (raw.includes("seedream")) {
return { id: "seedream-4", spec: ADOBE_FIREFLY_IMAGE_MODELS["seedream-4"] };
}
if (raw.includes("runway") && raw.includes("image")) {
return { id: "runway-gen4-image", spec: ADOBE_FIREFLY_IMAGE_MODELS["runway-gen4-image"] };
}
if (raw in ADOBE_FIREFLY_IMAGE_MODELS) {
const id = raw as AdobeFireflyImageModelId;
return { id, spec: ADOBE_FIREFLY_IMAGE_MODELS[id] };
}
// Default to Nano Banana Pro (most common Firefly image path).
return { id: "nano-banana-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"] };
}
export function resolveAdobeVideoModel(model: string): {
id: AdobeFireflyVideoModelId;
spec: AdobeFireflyVideoModelSpec;
} {
const raw = String(model || "")
.trim()
.toLowerCase()
.replace(/^adobe-firefly\//, "")
.replace(/^firefly\//, "");
if (raw.includes("sora2-pro") || raw.includes("sora-2-pro") || raw.includes("sora2_pro")) {
return { id: "sora-2-pro", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2-pro"] };
}
if (raw.includes("sora2") || raw.includes("sora-2") || raw.includes("sora")) {
return { id: "sora-2", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"] };
}
if (raw.includes("veo31-ref") || raw.includes("veo-3.1-ref") || raw.includes("veo31_ref")) {
return { id: "veo-3.1-ref", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-ref"] };
}
if (raw.includes("veo31-fast") || raw.includes("veo-3.1-fast") || raw.includes("veo31_fast")) {
return { id: "veo-3.1-fast", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-fast"] };
}
if (raw.includes("veo31") || raw.includes("veo-3.1") || raw.includes("veo")) {
return { id: "veo-3.1", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"] };
}
if (raw.includes("kling")) {
return { id: "kling-3", spec: ADOBE_FIREFLY_VIDEO_MODELS["kling-3"] };
}
if (raw in ADOBE_FIREFLY_VIDEO_MODELS) {
const id = raw as AdobeFireflyVideoModelId;
return { id, spec: ADOBE_FIREFLY_VIDEO_MODELS[id] };
}
return { id: "sora-2", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"] };
}
function gptDetailLevel(quality: unknown): number {
// Live firefly.adobe.com default for gpt-image is detailLevel 3 (medium).
const q = String(quality ?? "medium").trim().toLowerCase();
if (q === "high" || q === "4k" || q === "ultra") return 5;
if (q === "low" || q === "1k") return 1;
if (q === "medium" || q === "2k" || q === "standard" || q === "hd" || q === "auto") return 3;
return 3;
}
export function buildAdobeImagePayload(opts: {
prompt: string;
aspectRatio: string;
outputResolution: "1K" | "2K" | "4K";
modelSpec: AdobeFireflyImageModelSpec;
quality?: unknown;
seed?: number;
sourceImageIds?: string[];
negativePrompt?: string;
}): Record<string, unknown> {
const ratio = opts.aspectRatio === "auto" ? "1:1" : opts.aspectRatio || "1:1";
const seeds = [typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999)];
const negative = String(opts.negativePrompt || "").trim();
const genSettings: Record<string, unknown> = {};
if (negative) {
genSettings.avoidKeywords = negative
.replace(/;/g, ",")
.split(",")
.map((w) => w.trim())
.filter(Boolean);
}
if (opts.modelSpec.family === "gpt-image") {
// Live firefly.adobe.com body (adobe/image_generate.txt) — no top-level size /
// outputResolution; modelSpecificPayload.size is "auto".
const payload: Record<string, unknown> = {
n: 1,
seeds,
output: { storeInputs: true },
prompt: opts.prompt,
referenceBlobs: [] as Array<Record<string, unknown>>,
modelSpecificPayload: { size: "auto" },
modelId: opts.modelSpec.upstreamModelId,
modelVersion: opts.modelSpec.upstreamModelVersion,
generationMetadata: { module: "text2image", submodule: "ff-image-generate" },
generationSettings: {
detailLevel: gptDetailLevel(opts.quality),
...genSettings,
},
};
if (opts.sourceImageIds?.length) {
// gpt-image subject references (mask path uses separate mask blob when present).
payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" };
payload.referenceBlobs = opts.sourceImageIds.map((id) => ({
id: String(id),
usage: "subject",
}));
payload.modelSpecificPayload = {};
}
return payload;
}
// nano (Gemini Flash) + generic (Flux / Seedream / Runway image): same 3P image shape.
// Live capture (web_providers/adobe_atach_images.txt): referenceBlobs with usage "general"
// keep module "text2image" (not image2image) for nano multi-ref composition.
const sizeMap = NANO_SIZE_MAP[opts.outputResolution] || NANO_SIZE_MAP["2K"];
const pixel = sizeMap[ratio] || sizeMap["1:1"];
const payload: Record<string, unknown> = {
modelId: opts.modelSpec.upstreamModelId,
modelVersion: opts.modelSpec.upstreamModelVersion,
n: 1,
prompt: opts.prompt,
size: pixel,
seeds,
groundSearch: false,
skipCai: false,
output: { storeInputs: true },
generationMetadata: { module: "text2image", submodule: "ff-image-generate" },
modelSpecificPayload: {
parameters: { addWatermark: false },
aspectRatio: ratio,
},
referenceBlobs: [] as Array<Record<string, unknown>>,
};
if (Object.keys(genSettings).length) payload.generationSettings = genSettings;
if (opts.sourceImageIds?.length) {
payload.referenceBlobs = opts.sourceImageIds.map((id) => ({
id: String(id),
usage: "general",
}));
// Flux / Seedream / Runway image historically used image2image; nano keeps text2image.
if (opts.modelSpec.family === "generic") {
payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" };
}
}
return payload;
}
function videoSize(aspectRatio: string, resolution: string): { width: number; height: number } {
const res = String(resolution || "720p").toLowerCase();
const short = res.includes("1080") ? 1080 : res.includes("480") ? 480 : 720;
const ratio = aspectRatio === "9:16" ? "9:16" : aspectRatio === "1:1" ? "1:1" : "16:9";
if (ratio === "1:1") return { width: short, height: short };
if (ratio === "9:16") return { width: Math.round((short * 9) / 16), height: short };
return { width: Math.round((short * 16) / 9), height: short };
}
export function buildAdobeVideoPayload(opts: {
prompt: string;
aspectRatio: string;
duration: number;
modelSpec: AdobeFireflyVideoModelSpec;
resolution?: string;
seed?: number;
sourceImageIds?: string[];
negativePrompt?: string;
generateAudio?: boolean;
}): Record<string, unknown> {
const seedVal = typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999);
const aspect = opts.aspectRatio === "auto" ? "16:9" : opts.aspectRatio || "16:9";
const duration = Math.max(1, Math.min(30, Math.floor(opts.duration || opts.modelSpec.defaultDuration)));
const resolution = opts.resolution || opts.modelSpec.defaultResolution;
const vidSize = videoSize(aspect, resolution);
const engine = opts.modelSpec.engine;
const sourceImageIds = opts.sourceImageIds || [];
const negative = String(opts.negativePrompt || "");
if (engine === "veo31-standard" || engine === "veo31-fast") {
const payload: Record<string, unknown> = {
n: 1,
seeds: [seedVal],
modelId: "veo",
modelVersion:
opts.modelSpec.modelVersion ||
(engine === "veo31-fast" ? "3.1-fast-generate" : "3.1-generate"),
output: { storeInputs: true },
prompt: opts.prompt,
size: vidSize,
generateAudio: opts.generateAudio !== false,
referenceBlobs: [] as Array<Record<string, unknown>>,
generationMetadata: { module: "text2video" },
modelSpecificPayload: {
parameters: {
durationSeconds: duration,
aspectRatio: aspect,
addWaterMark: false,
},
},
};
if (sourceImageIds.length) {
const refs = payload.referenceBlobs as Array<Record<string, unknown>>;
if (opts.modelSpec.referenceMode === "image") {
for (const imageId of sourceImageIds.slice(0, 3)) {
refs.push({ id: String(imageId), usage: "asset" });
}
} else {
sourceImageIds.slice(0, 2).forEach((imageId, idx) => {
refs.push({ id: String(imageId), usage: "general", order: idx + 1 });
});
}
payload.generationMetadata = { module: "image2video" };
}
if (negative) payload.negativePrompt = negative;
return payload;
}
if (engine === "kling3") {
const payload: Record<string, unknown> = {
n: 1,
seeds: [seedVal],
modelId: "kling",
modelVersion: "kling_v3_standard_i2v",
output: { storeInputs: true },
prompt: opts.prompt,
size: vidSize,
generationMetadata: {
module: sourceImageIds.length ? "image2video" : "text2video",
},
duration,
generationSettings: { aspectRatio: aspect },
referenceBlobs: [] as Array<Record<string, unknown>>,
};
if (sourceImageIds.length) {
const refs = payload.referenceBlobs as Array<Record<string, unknown>>;
sourceImageIds.slice(0, 2).forEach((imageId, idx) => {
refs.push({ id: String(imageId), usage: "frame", order: idx + 1 });
});
}
if (negative) payload.negativePrompt = negative;
return payload;
}
// Sora 2 / Sora 2 Pro
const promptJson = JSON.stringify({
prompt: opts.prompt,
duration,
...(negative ? { negative_prompt: negative } : {}),
});
const payload: Record<string, unknown> = {
n: 1,
seeds: [seedVal],
modelId: "sora",
modelVersion: engine === "sora2-pro" ? "sora-2-pro" : "sora-2",
size: vidSize,
duration,
fps: 24,
prompt: promptJson,
generationMetadata: { module: sourceImageIds.length ? "image2video" : "text2video" },
model: opts.modelSpec.upstreamModel,
generateLoop: false,
transparentBackground: false,
seed: String(seedVal),
locale: "en-US",
camera: { angle: "none", shotSize: "none", motion: null, promptStyle: null },
negativePrompt: negative,
jobMode: "standard",
debugGenerationEndpoint: "",
referenceBlobs: [] as Array<Record<string, unknown>>,
referenceFrames: [] as Array<Record<string, unknown> | null>,
referenceVideo: null,
cameraMotionReferenceVideo: null,
characterReference: null,
editReferenceVideo: null,
output: { storeInputs: true },
};
if (sourceImageIds.length) {
const firstId = String(sourceImageIds[0]);
payload.referenceBlobs = [{ id: firstId, usage: "general", promptReference: 1 }];
const frames: Array<Record<string, unknown> | null> = [{ localBlobRef: firstId }, null];
if (sourceImageIds.length > 1) {
const lastId = String(sourceImageIds[1]);
(payload.referenceBlobs as Array<Record<string, unknown>>).push({
id: lastId,
usage: "general",
promptReference: 2,
});
frames[1] = { localBlobRef: lastId };
}
payload.referenceFrames = frames;
}
return payload;
}
function browserHeaders(): Record<string, string> {
return {
"user-agent": DEFAULT_USER_AGENT,
origin: FIREFLY_ORIGIN,
referer: FIREFLY_REFERER,
"accept-language": "en-US,en;q=0.9",
"sec-ch-ua": DEFAULT_SEC_CH_UA,
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-site": "cross-site",
"sec-fetch-mode": "cors",
"sec-fetch-dest": "empty",
};
}
/** Random 64-char hex fallback when token/prompt are missing for deterministic nonce. */
export function generateAdobeNonce(): string {
const bytes = new Uint8Array(32);
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
}
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
/**
* Deterministic x-nonce used by working open-source Firefly clients
* (adobe2api / GPT2Image-Pro / image2api):
* sha256(`${user_id}-${prompt.slice(0, 256)}`)
*
* Random nonces (browser-looking) still get colligo 408 on many accounts when
* the request is not from the SPA. Deterministic nonce is what unblocks generate.
*/
export function buildAdobeSubmitNonce(accessToken: string, prompt: string): string {
const userId = extractAdobeAccountIdFromToken(accessToken);
const promptPrefix = String(prompt || "").slice(0, 256);
if (!userId || !promptPrefix) return "";
return createHash("sha256").update(`${userId}-${promptPrefix}`, "utf8").digest("hex");
}
/**
* Synthesize x-arp-session-id when no sherlockToken cookie is available.
* Shape matches adobe2api / GPT2Image-Pro: base64(JSON({sid, ftr})).
* Working clients ALWAYS send this header on generate-async.
*/
export function buildAdobeArpSessionId(): string {
const nowMs = Date.now();
const rand = randomBytes(16).toString("hex");
const sid = randomUUID();
const pid = typeof process !== "undefined" && process.pid ? process.pid : 0;
// Magic suffix is part of the wire contract reverse-engineered by adobe2api.
const ftr = `${rand}_${nowMs}_${pid}_dUAL43-mnts-ants-d4_31ck__tt`;
const raw = JSON.stringify({ sid, ftr });
return Buffer.from(raw, "utf-8").toString("base64");
}
/**
* Pull sherlockToken / x-arp-session-id from a Cookie header if present.
* Browser generate sends Cookie.sherlockToken as x-arp-session-id.
*/
export function extractAdobeArpSessionId(cookieOrBlob: string): string {
const raw = String(cookieOrBlob || "");
const m = raw.match(/(?:^|[;\s])sherlockToken=([^;]+)/i);
if (m?.[1]) return decodeURIComponent(m[1].trim());
const m2 = raw.match(/(?:^|[;\s])x-arp-session-id=([^;]+)/i);
if (m2?.[1]) return decodeURIComponent(m2[1].trim());
return "";
}
export function buildAdobeSubmitHeaders(
accessToken: string,
extras?: {
arpSessionId?: string;
nonce?: string;
cookie?: string;
/** Required for deterministic x-nonce (sha256 user_id+prompt). */
prompt?: string;
}
): Record<string, string> {
// Live capture + working open-source clients (GPT2Image-Pro / adobe2api):
// Authorization + x-api-key + deterministic x-nonce + ALWAYS x-arp-session-id.
// Do NOT attach firefly.adobe.com page Cookie to firefly-3p (wrong origin / soft 408).
void extras?.cookie;
const deterministic =
extras?.nonce ||
(extras?.prompt ? buildAdobeSubmitNonce(accessToken, extras.prompt) : "") ||
generateAdobeNonce();
// Prefer pasted sherlockToken; otherwise mint a synthetic ARP session (required).
const arp =
(extras?.arpSessionId && String(extras.arpSessionId).trim()) || buildAdobeArpSessionId();
const headers: Record<string, string> = {
...browserHeaders(),
Authorization: `Bearer ${accessToken}`,
// Must be clio-playground-web — same client_id that minted the IMS token.
"x-api-key": adobeFireflyApiKey(),
"content-type": "application/json",
accept: "*/*",
"cache-control": "no-cache",
pragma: "no-cache",
priority: "u=1, i",
"x-nonce": deterministic,
"x-arp-session-id": arp,
};
return headers;
}
/** Max reference image size for Firefly storage upload (20 MiB). */
export const ADOBE_FIREFLY_MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
/**
* Headers for POST /v2/storage/image (raw image body).
* Live capture (web_providers/adobe_atach_images.txt): Bearer + x-api-key + x-arp + x-nonce
* + content-type image/png|jpeg (not application/json).
*/
export function buildAdobeUploadHeaders(
accessToken: string,
contentType: string,
extras?: {
arpSessionId?: string;
nonce?: string;
cookie?: string;
prompt?: string;
}
): Record<string, string> {
const base = buildAdobeSubmitHeaders(accessToken, {
arpSessionId: extras?.arpSessionId,
nonce: extras?.nonce,
cookie: extras?.cookie,
prompt: extras?.prompt || "upload",
});
const ct = String(contentType || "image/png").trim().toLowerCase() || "image/png";
return {
...base,
"content-type": ct.startsWith("image/") ? ct : "image/png",
};
}
/**
* Collect reference image sources from an OpenAI-style / Media-page image|video body.
* Supports: image_url, image, images[], image_urls[], input_image(s), reference_images,
* provider_options.*, and prompt_image fields used by the WinUI Media page.
*/
export function extractAdobeSourceImageSources(body: unknown, max = 4): string[] {
if (!body || typeof body !== "object") return [];
const b = body as Record<string, unknown>;
const po =
b.provider_options && typeof b.provider_options === "object" && !Array.isArray(b.provider_options)
? (b.provider_options as Record<string, unknown>)
: {};
const out: string[] = [];
const seen = new Set<string>();
const push = (v: unknown) => {
if (out.length >= max) return;
if (typeof v === "string") {
const t = v.trim();
if (!t || seen.has(t)) return;
// Skip empty / clearly non-image
if (t === "null" || t === "undefined") return;
seen.add(t);
out.push(t);
return;
}
if (Array.isArray(v)) {
for (const item of v) {
if (out.length >= max) break;
push(item);
}
return;
}
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
if (typeof o.url === "string") push(o.url);
else if (typeof o.image_url === "string") push(o.image_url);
else if (o.image_url && typeof o.image_url === "object") {
const inner = (o.image_url as Record<string, unknown>).url;
if (typeof inner === "string") push(inner);
} else if (typeof o.b64_json === "string") {
push(`data:image/png;base64,${o.b64_json}`);
} else if (typeof o.base64 === "string") {
push(`data:image/png;base64,${o.base64}`);
}
}
};
// Order matches MediaViewModel / OpenAI edit aliases (primary single fields first).
const keys = [
"image_url",
"imageUrl",
"input_image",
"source_image",
"promptImage",
"prompt_image",
"image",
"images",
"image_urls",
"imageUrls",
"input_images",
"reference_images",
"referenceImages",
"reference_image",
];
for (const k of keys) {
push(b[k]);
push(po[k]);
}
// OpenAI chat-style content parts (rare on /v1/images but harmless).
if (Array.isArray(b.messages)) {
for (const msg of b.messages) {
if (!msg || typeof msg !== "object") continue;
const content = (msg as Record<string, unknown>).content;
if (!Array.isArray(content)) continue;
for (const part of content) {
if (!part || typeof part !== "object") continue;
const p = part as Record<string, unknown>;
if (p.type === "image_url" || p.type === "image") {
push(p.image_url ?? p.image ?? p.url);
}
}
}
}
return out.slice(0, max);
}
export function parseAdobeImageSourceBytes(source: string): {
buffer: Buffer;
contentType: string;
} {
const trimmed = String(source || "").trim();
if (!trimmed) {
throw new AdobeFireflyError("Empty image reference", 400, "bad_image");
}
const dataUri = /^data:([^;,]+)?(?:;charset=[^;,]+)?(;base64)?,([\s\S]+)$/i.exec(trimmed);
if (dataUri) {
const mime = (dataUri[1] || "image/png").trim().toLowerCase() || "image/png";
const isB64 = Boolean(dataUri[2]);
const payload = dataUri[3] || "";
if (!isB64) {
throw new AdobeFireflyError(
"Image data URL must be base64-encoded (data:image/...;base64,...)",
400,
"bad_image"
);
}
const buffer = Buffer.from(payload.replace(/\s/g, ""), "base64");
if (!buffer.length) {
throw new AdobeFireflyError("Image data URL decoded to empty bytes", 400, "bad_image");
}
if (buffer.length > ADOBE_FIREFLY_MAX_UPLOAD_BYTES) {
throw new AdobeFireflyError(
`Image reference too large (${buffer.length} bytes; max ${ADOBE_FIREFLY_MAX_UPLOAD_BYTES})`,
400,
"bad_image"
);
}
return { buffer, contentType: mime.startsWith("image/") ? mime : "image/png" };
}
// Raw base64 without data: prefix
if (!/^https?:\/\//i.test(trimmed) && /^[A-Za-z0-9+/=\s]+$/.test(trimmed) && trimmed.length > 64) {
const buffer = Buffer.from(trimmed.replace(/\s/g, ""), "base64");
if (buffer.length > 0 && buffer.length <= ADOBE_FIREFLY_MAX_UPLOAD_BYTES) {
return { buffer, contentType: "image/png" };
}
}
throw new AdobeFireflyError(
"Unsupported image reference (need data:image/...;base64,... or raw base64). " +
"HTTP(S) URLs are resolved by the caller before upload.",
400,
"bad_image"
);
}
/**
* Parse Firefly storage upload response: {"images":[{"id":"uuid"}]}.
*/
export function parseAdobeStorageUploadResponse(body: unknown): string {
if (!body || typeof body !== "object") return "";
const images = (body as Record<string, unknown>).images;
if (Array.isArray(images) && images.length > 0) {
const first = images[0];
if (first && typeof first === "object") {
const id = (first as Record<string, unknown>).id;
if (typeof id === "string" && id.trim()) return id.trim();
}
}
const id = (body as Record<string, unknown>).id;
if (typeof id === "string" && id.trim()) return id.trim();
return "";
}
/**
* Upload one image to Firefly storage → blob id for referenceBlobs.
* Wire: POST https://firefly-3p.ff.adobe.io/v2/storage/image (raw bytes).
*/
export async function uploadAdobeFireflyImage(opts: {
accessToken: string;
bytes: Buffer | Uint8Array;
contentType?: string;
sessionCookie?: string;
/** Used for deterministic x-nonce (optional). */
prompt?: string;
fetchImpl?: typeof fetch;
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
}): Promise<string> {
const fetchImpl = opts.fetchImpl || fetch;
const buffer = Buffer.isBuffer(opts.bytes) ? opts.bytes : Buffer.from(opts.bytes);
if (!buffer.length) {
throw new AdobeFireflyError("Cannot upload empty image", 400, "bad_image");
}
if (buffer.length > ADOBE_FIREFLY_MAX_UPLOAD_BYTES) {
throw new AdobeFireflyError(
`Image reference too large (${buffer.length} bytes; max ${ADOBE_FIREFLY_MAX_UPLOAD_BYTES})`,
400,
"bad_image"
);
}
const sessionCookie = String(opts.sessionCookie || "").trim();
const cookieHeader = extractAdobeCookieHeader(sessionCookie);
const arpSessionId =
extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie);
const contentType =
(opts.contentType && opts.contentType.trim()) ||
(buffer[0] === 0xff && buffer[1] === 0xd8
? "image/jpeg"
: buffer[0] === 0x89 && buffer[1] === 0x50
? "image/png"
: "image/png");
const resp = await fetchImpl(ADOBE_FIREFLY_IMAGE_UPLOAD_URL, {
method: "POST",
headers: buildAdobeUploadHeaders(opts.accessToken, contentType, {
arpSessionId: arpSessionId || undefined,
cookie: cookieHeader || undefined,
prompt: opts.prompt || "upload",
}),
body: buffer as unknown as BodyInit,
});
const text = await resp.text().catch(() => "");
if (resp.status === 401 || resp.status === 403) {
throw new AdobeFireflyError(
"Adobe Firefly image upload unauthorized — paste a fresh IMS JWT",
401,
"auth"
);
}
if (!resp.ok) {
throw new AdobeFireflyError(
`Adobe Firefly image upload failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`,
resp.status >= 400 && resp.status < 500 ? resp.status : 502,
"upload"
);
}
let json: unknown = {};
try {
json = text ? JSON.parse(text) : {};
} catch {
throw new AdobeFireflyError(
"Adobe Firefly image upload returned non-JSON body",
502,
"upload"
);
}
const id = parseAdobeStorageUploadResponse(json);
if (!id) {
throw new AdobeFireflyError(
"Adobe Firefly image upload succeeded but no images[].id was returned",
502,
"upload"
);
}
opts.log?.info?.("ADOBE-FIREFLY", `uploaded reference image id=${id} (${buffer.length} bytes)`);
return id;
}
/**
* Resolve Media/OpenAI body image fields → Firefly storage blob ids.
* - data: URLs / raw base64 → upload
* - http(s) URLs → fetch then upload
* - already looks like a UUID blob id → use as-is (advanced)
*/
export async function resolveAdobeSourceImageIds(opts: {
accessToken: string;
body: unknown;
max?: number;
sessionCookie?: string;
prompt?: string;
fetchImpl?: typeof fetch;
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
}): Promise<string[]> {
const max = Math.max(1, Math.min(8, opts.max ?? 4));
const sources = extractAdobeSourceImageSources(opts.body, max);
if (!sources.length) return [];
const fetchImpl = opts.fetchImpl || fetch;
const ids: string[] = [];
for (const src of sources) {
// Already a Firefly storage id (uuid)
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(src)) {
ids.push(src);
continue;
}
let buffer: Buffer;
let contentType = "image/png";
if (/^https?:\/\//i.test(src)) {
const r = await fetchImpl(src, {
method: "GET",
headers: { accept: "image/*,*/*" },
});
if (!r.ok) {
throw new AdobeFireflyError(
`Failed to download reference image (${r.status}): ${src.slice(0, 120)}`,
400,
"bad_image"
);
}
const ab = await r.arrayBuffer();
buffer = Buffer.from(ab);
const ct = r.headers.get("content-type") || "";
if (ct.toLowerCase().startsWith("image/")) {
contentType = ct.split(";")[0]!.trim();
}
} else {
const parsed = parseAdobeImageSourceBytes(src);
buffer = parsed.buffer;
contentType = parsed.contentType;
}
const id = await uploadAdobeFireflyImage({
accessToken: opts.accessToken,
bytes: buffer,
contentType,
sessionCookie: opts.sessionCookie,
prompt: opts.prompt,
fetchImpl,
log: opts.log,
});
ids.push(id);
}
return ids;
}
/** Transient Adobe 3P overload / rate / edge errors worth retrying. */
export function isAdobeTransientSubmitError(status: number, bodyText: string): boolean {
if (status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
return true;
}
const t = (bodyText || "").toLowerCase();
return (
t.includes("timeout_error") ||
t.includes("system under load") ||
t.includes("try again") ||
t.includes("temporarily") ||
t.includes("overloaded")
);
}
export function buildAdobePollHeaders(accessToken: string): Record<string, string> {
// Live adobe/status_check.txt: Bearer + accept only (no x-api-key, no Cookie).
return {
Authorization: `Bearer ${accessToken}`,
accept: "*/*",
"cache-control": "no-cache",
pragma: "no-cache",
"user-agent": DEFAULT_USER_AGENT,
referer: FIREFLY_REFERER,
};
}
export function buildAdobeBalanceHeaders(accessToken: string): Record<string, string> {
const accountId = extractAdobeAccountIdFromToken(accessToken);
const headers: Record<string, string> = {
...browserHeaders(),
Authorization: `Bearer ${accessToken}`,
accept: "application/json",
"content-type": "application/json",
"x-api-key": adobeFireflyBalanceApiKey(),
};
if (accountId) headers["x-account-id"] = accountId;
return headers;
}
export function buildAdobeDiscoveryHeaders(accessToken: string): Record<string, string> {
return {
...browserHeaders(),
Authorization: `Bearer ${accessToken}`,
"x-api-key": adobeFireflyApiKey(),
"content-type": "application/json",
// Missing Accept → HTTP 406 "Unsupported Accept Type or not allowed".
accept: "*/*",
};
}
/** User-facing message when Adobe colligo returns 408 "system under load". */
export function formatAdobeSystemUnderLoadError(kind: "image" | "video", attempts: number): string {
return (
`Adobe Firefly ${kind} generation is currently unavailable (HTTP 408 "system under load", ` +
`${attempts} attempt${attempts === 1 ? "" : "s"}). This is Adobe-side capacity/rate limiting ` +
`— not an invalid token (credits/models may still work). Wait 12 minutes and retry, or paste a ` +
`fresh IMS JWT from a browser request that just succeeded: firefly.adobe.com → F12 → Network → ` +
`firefly-3p generate-async → Authorization → copy token after "Bearer ".`
);
}
export function extractAdobeResultLink(
headers: Headers | Record<string, string | null | undefined>,
body: unknown
): string {
const get = (name: string): string => {
if (typeof (headers as Headers).get === "function") {
return String((headers as Headers).get(name) || "").trim();
}
const rec = headers as Record<string, string | null | undefined>;
const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase());
return String((key ? rec[key] : "") || "").trim();
};
const override = get("x-override-status-link");
if (override) return override;
const data = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const links = data.links && typeof data.links === "object" ? (data.links as Record<string, unknown>) : {};
const result = links.result;
if (typeof result === "string" && result) return result;
if (result && typeof result === "object") {
const href = (result as Record<string, unknown>).href;
if (typeof href === "string" && href) return href;
}
if (typeof data.statusUrl === "string" && data.statusUrl) return data.statusUrl;
if (typeof data.resultUrl === "string" && data.resultUrl) return data.resultUrl;
return "";
}
/**
* Rewrite Firefly EPO result links to the BKS poll endpoint used by the SPA.
*
* Live capture (adobe/status_check.txt):
* links.result = https://firefly-epo855232.adobe.io/jobs/result/{jobId}
* poll URL = https://bks-epo8552.adobe.io/v2/jobs/result/{jobId}?host=firefly-epo855232.adobe.io
*
* BKS host uses the first 4 digits of the EPO id when the id is longer (855232 → 8552).
*/
export function normalizeAdobePollUrl(rawUrl: string): string {
const url = String(rawUrl || "").trim();
if (!url) return url;
try {
const parsed = new URL(url);
const host = parsed.hostname.toLowerCase();
if (!host.startsWith("firefly-epo")) return url;
const path = parsed.pathname || "";
const isJobPath =
path.includes("/jobs/result/") ||
path.includes("/v2/status") ||
path.includes("/status/");
if (!isJobPath) return url;
const jobId = path.split("/").filter(Boolean).pop() || "";
if (!jobId || jobId === "status" || jobId === "result") return url;
const epoId = host.slice("firefly-epo".length).split(".")[0] || "";
// 855232 → 8552 (browser BKS host); short ids kept as-is.
const bksId = epoId.length > 4 ? epoId.slice(0, 4) : epoId;
return `https://bks-epo${bksId}.adobe.io/v2/jobs/result/${jobId}?host=${host}`;
} catch {
return url;
}
}
export function extractAdobeMediaUrl(
latest: unknown,
kind: "image" | "video"
): string | null {
const body = latest && typeof latest === "object" ? (latest as Record<string, unknown>) : {};
const outputs = Array.isArray(body.outputs) ? body.outputs : [];
if (outputs.length > 0) {
const first = outputs[0] && typeof outputs[0] === "object" ? (outputs[0] as Record<string, unknown>) : {};
const media =
kind === "image"
? first.image && typeof first.image === "object"
? (first.image as Record<string, unknown>)
: null
: first.video && typeof first.video === "object"
? (first.video as Record<string, unknown>)
: null;
const url = media && typeof media.presignedUrl === "string" ? media.presignedUrl : null;
if (url) return url;
}
// Fallback recursive search for a presigned URL.
const found = findPresignedUrl(latest, kind === "image" ? [".png", ".jpg", ".jpeg", ".webp"] : [".mp4", ".webm"]);
return found;
}
function findPresignedUrl(obj: unknown, exts: string[]): string | null {
if (!obj) return null;
if (typeof obj === "string") {
const s = obj.trim();
if (/^https?:\/\//i.test(s) && (exts.some((e) => s.toLowerCase().includes(e)) || s.includes("presigned") || s.includes("X-Amz"))) {
return s;
}
return null;
}
if (Array.isArray(obj)) {
for (const item of obj) {
const found = findPresignedUrl(item, exts);
if (found) return found;
}
return null;
}
if (typeof obj === "object") {
const rec = obj as Record<string, unknown>;
if (typeof rec.presignedUrl === "string" && rec.presignedUrl) return rec.presignedUrl;
for (const value of Object.values(rec)) {
const found = findPresignedUrl(value, exts);
if (found) return found;
}
}
return null;
}
export function isAdobeJobInProgress(status: string): boolean {
const s = String(status || "").toUpperCase();
return (
!s ||
s === "IN_PROGRESS" ||
s === "PENDING" ||
s === "RUNNING" ||
s === "QUEUED" ||
s === "PROCESSING" ||
s === "SUBMITTED"
);
}
export function isAdobeJobFailed(status: string): boolean {
const s = String(status || "").toUpperCase();
return s === "FAILED" || s === "CANCELLED" || s === "ERROR" || s === "CANCELED";
}
type ImsTokenResponse = {
access_token?: string;
account_type?: string;
guestId?: string;
token_type?: string;
error?: string;
error_description?: string;
};
async function imsCheckToken(opts: {
cookie: string;
clientId: string;
guestAllowed: boolean;
fetchImpl: typeof fetch;
}): Promise<
| { state: "ok"; token: string; data: ImsTokenResponse }
| { state: "failed"; status: number; error: string }
> {
const form = new URLSearchParams({
client_id: opts.clientId,
scope: ADOBE_FIREFLY_IMS_SCOPE,
guest_allowed: opts.guestAllowed ? "true" : "false",
});
const resp = await opts.fetchImpl(ADOBE_FIREFLY_IMS_REFRESH_URL, {
method: "POST",
headers: {
Accept: "*/*",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
Cookie: opts.cookie,
Origin: FIREFLY_ORIGIN,
Referer: FIREFLY_REFERER,
"User-Agent": DEFAULT_USER_AGENT,
},
body: form.toString(),
});
const text = await resp.text().catch(() => "");
let data: ImsTokenResponse | null = null;
try {
data = JSON.parse(text) as ImsTokenResponse;
} catch {
data = null;
}
if (!resp.ok) {
return {
state: "failed",
status: resp.status,
error: sanitizeErrorMessage(
data?.error_description || data?.error || text.slice(0, 200) || `HTTP ${resp.status}`
),
};
}
const token = String(data?.access_token || "").trim();
if (!token) {
return {
state: "failed",
status: 401,
error: sanitizeErrorMessage(
data?.error_description || data?.error || "IMS response missing access_token"
),
};
}
return { state: "ok", token, data: data || {} };
}
/**
* Exchange a browser Cookie header for an Adobe IMS **user** access_token.
*
* Live repro (user firefly.adobe.com Cookie export):
* - guest_allowed=true → account_type=guest (no AdobeID) → generate 401 / balance 403
* - guest_allowed=false → "All session cookies are empty" (IMS cookies live on adobelogin.com)
*
* Reliable path: paste Authorization Bearer JWT from a live firefly-3p request.
*/
export async function exchangeAdobeCookieForAccessToken(
cookieHeader: string,
fetchImpl: typeof fetch = fetch
): Promise<string> {
const cookie = String(cookieHeader || "").trim();
if (!cookie) {
throw new AdobeFireflyError("Adobe Firefly cookie is empty", 401, "missing_cookie");
}
// HAR / mixed paste that already contains a user JWT
const embedded = extractAdobeCredentialToken(cookie);
if (embedded !== cookie && looksLikeAdobeJwt(embedded)) {
if (isAdobeGuestAccessToken(embedded)) {
throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token");
}
return embedded;
}
const clientIds = [adobeFireflyApiKey(), adobeFireflyExpressClientId()].filter(
(id, i, arr) => id && arr.indexOf(id) === i
);
let sawEmptySession = false;
let lastError = "";
let lastStatus = 502;
let guestTokenSeen = false;
for (const clientId of clientIds) {
// 1) Authenticated session only (needs IMS cookies from adobelogin.com)
const authed = await imsCheckToken({
cookie,
clientId,
guestAllowed: false,
fetchImpl,
});
if (authed.state === "ok") {
if (
isAdobeGuestAccessToken(authed.token) ||
authed.data.account_type === "guest" ||
authed.data.guestId
) {
guestTokenSeen = true;
} else {
return authed.token;
}
} else {
lastStatus = authed.status;
lastError = authed.error;
if (/session cookies are empty/i.test(authed.error)) sawEmptySession = true;
}
// 2) Guest path — never accept guest tokens for Firefly media/limits
const guest = await imsCheckToken({
cookie,
clientId,
guestAllowed: true,
fetchImpl,
});
if (guest.state === "ok") {
if (
guest.data.account_type === "guest" ||
guest.data.guestId ||
isAdobeGuestAccessToken(guest.token)
) {
guestTokenSeen = true;
lastError = "IMS returned a guest token (no AdobeID session)";
lastStatus = 401;
continue;
}
return guest.token;
}
lastStatus = guest.status;
lastError = guest.error;
if (/session cookies are empty/i.test(guest.error)) sawEmptySession = true;
}
if (guestTokenSeen || sawEmptySession) {
throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token");
}
throw new AdobeFireflyError(
`Adobe IMS token exchange failed (${lastStatus}): ${lastError || "no access_token"}. ${GUEST_COOKIE_HELP}`,
lastStatus === 401 || lastStatus === 403 ? 401 : 502,
"ims_refresh_failed"
);
}
/**
* Resolve credentials into a usable **user** IMS access token (rejects guest tokens).
*/
export async function resolveAdobeAccessToken(
credentials:
| {
apiKey?: string;
accessToken?: string;
providerSpecificData?: { cookie?: unknown; access_token?: unknown; accessToken?: unknown } | null;
}
| null
| undefined,
fetchImpl: typeof fetch = fetch
): Promise<string> {
const psd = credentials?.providerSpecificData;
const candidates: string[] = [];
const push = (v: unknown) => {
if (typeof v === "string" && v.trim()) candidates.push(v.trim());
};
push(credentials?.apiKey);
push(credentials?.accessToken);
push(psd?.access_token);
push(psd?.accessToken);
push(psd?.cookie);
if (candidates.length === 0) {
throw new AdobeFireflyError(
"Adobe Firefly credentials missing. " + GUEST_COOKIE_HELP,
401,
"missing_credentials"
);
}
for (const c of candidates) {
const extracted = extractAdobeCredentialToken(c);
if (looksLikeAdobeJwt(extracted) && isAdobeUserAccessToken(extracted)) {
return extracted;
}
}
for (const c of candidates) {
const extracted = extractAdobeCredentialToken(c);
if (looksLikeAdobeJwt(extracted) && isAdobeGuestAccessToken(extracted)) {
throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token");
}
}
const cookieBlob =
candidates.find(
(c) =>
c.includes(";") ||
c.toLowerCase().includes("aux_sid") ||
c.toLowerCase().includes("ff_session")
) || candidates[0];
const token = await exchangeAdobeCookieForAccessToken(cookieBlob, fetchImpl);
if (isAdobeGuestAccessToken(token)) {
throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token");
}
return token;
}
// ── Credits balance (Limits) ────────────────────────────────────────────────
export interface AdobeFireflyCreditsBalance {
total: number;
used: number;
remaining: number;
availableUntil: string | null;
freeTotal: number;
freeUsed: number;
freeRemaining: number;
planTotal: number;
planUsed: number;
planRemaining: number;
raw?: unknown;
}
function readQuotaBlock(block: unknown): { total: number; used: number; available: number } {
if (!block || typeof block !== "object") return { total: 0, used: 0, available: 0 };
const q =
(block as Record<string, unknown>).quota &&
typeof (block as Record<string, unknown>).quota === "object"
? ((block as Record<string, unknown>).quota as Record<string, unknown>)
: (block as Record<string, unknown>);
const total = Number(q.total ?? 0);
const used = Number(q.used ?? 0);
const available = Number(q.available ?? Math.max(0, total - used));
return {
total: Number.isFinite(total) ? total : 0,
used: Number.isFinite(used) ? used : 0,
available: Number.isFinite(available) ? available : 0,
};
}
/**
* Parse GET /v1/credits/balance JSON (adobe/balance.txt Response).
* total.quota = aggregate; credits.firefly_* = free + plan buckets.
*/
export function parseAdobeCreditsBalance(body: unknown): AdobeFireflyCreditsBalance {
const root = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const totalBlock = readQuotaBlock(root.total);
const credits =
root.credits && typeof root.credits === "object"
? (root.credits as Record<string, unknown>)
: {};
const free = readQuotaBlock(credits.firefly_free_credit);
const plan = readQuotaBlock(credits.firefly_plan_credit);
// Prefer top-level total; fall back to free+plan sum when total missing.
let total = totalBlock.total;
let used = totalBlock.used;
let remaining = totalBlock.available;
if (total <= 0 && (free.total > 0 || plan.total > 0)) {
total = free.total + plan.total;
used = free.used + plan.used;
remaining = free.available + plan.available;
}
if (remaining <= 0 && total > 0) remaining = Math.max(0, total - used);
const availableUntil =
root.total &&
typeof root.total === "object" &&
typeof (root.total as Record<string, unknown>).availableUntil === "string"
? String((root.total as Record<string, unknown>).availableUntil)
: null;
return {
total,
used,
remaining,
availableUntil,
freeTotal: free.total,
freeUsed: free.used,
freeRemaining: free.available,
planTotal: plan.total,
planUsed: plan.used,
planRemaining: plan.available,
raw: body,
};
}
export async function fetchAdobeCreditsBalance(
accessToken: string,
fetchImpl: typeof fetch = fetch
): Promise<AdobeFireflyCreditsBalance> {
const resp = await fetchImpl(ADOBE_FIREFLY_CREDITS_BALANCE_URL, {
method: "GET",
headers: buildAdobeBalanceHeaders(accessToken),
});
if (resp.status === 401 || resp.status === 403) {
throw new AdobeFireflyError("Adobe Firefly balance: token invalid or expired", 401, "auth");
}
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new AdobeFireflyError(
`Adobe Firefly balance failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 200))}`,
502
);
}
const data = await resp.json().catch(() => ({}));
return parseAdobeCreditsBalance(data);
}
// ── Models discovery ────────────────────────────────────────────────────────
export interface AdobeFireflyDiscoveredModel {
modelId: string;
modelVersion: string;
displayName: string;
modality: "image" | "video" | "audio" | "unknown";
enabled: boolean;
healthStatus?: string;
}
/**
* Parse POST /v2/models/discovery response into flat model/version rows.
*/
export function parseAdobeModelsDiscovery(body: unknown): AdobeFireflyDiscoveredModel[] {
const root = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const models = Array.isArray(root.models) ? root.models : [];
const out: AdobeFireflyDiscoveredModel[] = [];
for (const m of models) {
if (!m || typeof m !== "object") continue;
const rec = m as Record<string, unknown>;
const modelId = String(rec.modelId || "").trim();
if (!modelId) continue;
const versions =
rec.modelVersions && typeof rec.modelVersions === "object"
? (rec.modelVersions as Record<string, unknown>)
: {};
for (const [ver, spec] of Object.entries(versions)) {
if (!spec || typeof spec !== "object") continue;
const s = spec as Record<string, unknown>;
if (s.enabled === false) continue;
const mods = Array.isArray(s.outputModality)
? s.outputModality.map((x) => String(x).toLowerCase())
: [];
let modality: AdobeFireflyDiscoveredModel["modality"] = "unknown";
if (mods.includes("image")) modality = "image";
else if (mods.includes("video")) modality = "video";
else if (mods.includes("audio")) modality = "audio";
out.push({
modelId,
modelVersion: ver,
displayName: String(s.modelDisplayName || s.modelCaiDisplayName || ver),
modality,
enabled: s.enabled !== false,
healthStatus: typeof s.healthStatus === "string" ? s.healthStatus : undefined,
});
}
}
return out;
}
export async function discoverAdobeFireflyModels(
accessToken: string,
fetchImpl: typeof fetch = fetch
): Promise<AdobeFireflyDiscoveredModel[]> {
const resp = await fetchImpl(ADOBE_FIREFLY_MODELS_DISCOVERY_URL, {
method: "POST",
headers: buildAdobeDiscoveryHeaders(accessToken),
body: JSON.stringify({ filters: { resolveSchema: true } }),
});
if (resp.status === 401 || resp.status === 403) {
throw new AdobeFireflyError("Adobe Firefly model discovery: token invalid or expired", 401, "auth");
}
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new AdobeFireflyError(
`Adobe Firefly model discovery failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 200))}`,
502
);
}
const data = await resp.json().catch(() => ({}));
return parseAdobeModelsDiscovery(data);
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function pollAdobeJob(opts: {
pollUrl: string;
accessToken: string;
kind: "image" | "video";
timeoutMs: number;
pollIntervalMs?: number;
fetchImpl?: typeof fetch;
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
}): Promise<{ mediaUrl: string; latest: unknown }> {
const fetchImpl = opts.fetchImpl || fetch;
const deadline = Date.now() + opts.timeoutMs;
const interval = opts.pollIntervalMs && opts.pollIntervalMs > 0 ? opts.pollIntervalMs : DEFAULT_POLL_INTERVAL_MS;
let attempt = 0;
let latest: unknown = {};
while (Date.now() < deadline) {
attempt += 1;
const pollResp = await fetchImpl(opts.pollUrl, {
method: "GET",
headers: buildAdobePollHeaders(opts.accessToken),
});
if (pollResp.status === 401 || pollResp.status === 403) {
const accessError = pollResp.headers.get("x-access-error") || "";
if (accessError === "taste_exhausted") {
throw new AdobeFireflyError("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted");
}
throw new AdobeFireflyError("Adobe Firefly token invalid or expired", 401, "auth");
}
if (!pollResp.ok) {
const text = await pollResp.text().catch(() => "");
if (
pollResp.status === 408 ||
pollResp.status === 429 ||
pollResp.status === 451 ||
pollResp.status >= 500 ||
isAdobeTransientSubmitError(pollResp.status, text)
) {
opts.log?.info?.("ADOBE-FIREFLY", `poll temporary ${pollResp.status}, attempt #${attempt}`);
await sleep(interval);
continue;
}
throw new AdobeFireflyError(
`Adobe Firefly poll failed (${pollResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`,
502
);
}
latest = await pollResp.json().catch(() => ({}));
const statusHeader = String(pollResp.headers.get("x-task-status") || "").toUpperCase();
const statusVal = String(
(latest && typeof latest === "object" ? (latest as Record<string, unknown>).status : "") ||
statusHeader ||
""
).toUpperCase();
const mediaUrl = extractAdobeMediaUrl(latest, opts.kind);
if (mediaUrl) {
return { mediaUrl, latest };
}
if (isAdobeJobFailed(statusVal)) {
throw new AdobeFireflyError(
`Adobe Firefly ${opts.kind} job failed: ${sanitizeErrorMessage(JSON.stringify(latest).slice(0, 300))}`,
502,
"job_failed"
);
}
opts.log?.info?.("ADOBE-FIREFLY", `${opts.kind} pending #${attempt} status=${statusVal || "unknown"}`);
await sleep(interval);
}
throw new AdobeFireflyError(`Adobe Firefly ${opts.kind} generation timed out`, 504, "timeout");
}
// Colligo often returns instant 408 with x-colligo-timeout:0.0 under load.
// Keep retries short: hammering Adobe with 8 long waits makes the Media page
// look broken while balance still works. SPA succeeds on a healthy queue/token.
const SUBMIT_MAX_ATTEMPTS = 4;
const SUBMIT_BASE_DELAY_MS = 1200;
export async function adobeFireflyGenerateImage(opts: {
accessToken: string;
prompt: string;
model: string;
size?: unknown;
aspectRatio?: unknown;
quality?: unknown;
seed?: number;
sourceImageIds?: string[];
negativePrompt?: string;
/** Optional Cookie blob — used only to lift sherlockToken → x-arp-session-id */
sessionCookie?: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
}): Promise<{ url: string; b64_json?: string; latest: unknown }> {
const fetchImpl = opts.fetchImpl || fetch;
const { spec } = resolveAdobeImageModel(opts.model);
const aspectRatio = normalizeAdobeAspectRatio(opts.aspectRatio ?? opts.size, "1:1");
const outputResolution = normalizeAdobeOutputResolution(opts.quality, opts.size);
const payload = buildAdobeImagePayload({
prompt: opts.prompt,
aspectRatio,
outputResolution,
modelSpec: spec,
quality: opts.quality,
seed: opts.seed,
sourceImageIds: opts.sourceImageIds,
negativePrompt: opts.negativePrompt,
});
const sessionCookie = String(opts.sessionCookie || "").trim();
const cookieHeader = extractAdobeCookieHeader(sessionCookie);
// Prefer real browser sherlockToken; buildAdobeSubmitHeaders mints synthetic ARP if empty.
const arpSessionId =
extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie);
let submitData: unknown = {};
let submitHeaders: Headers | Record<string, string | null | undefined> = new Headers();
let lastSubmitError = "";
let sawSystemUnderLoad = false;
for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) {
// Deterministic x-nonce from user_id+prompt (adobe2api/GPT2Image-Pro). Fresh ARP each attempt.
const submitResp = await fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, {
method: "POST",
headers: buildAdobeSubmitHeaders(opts.accessToken, {
arpSessionId: arpSessionId || undefined,
prompt: opts.prompt,
cookie: cookieHeader || undefined,
}),
body: JSON.stringify(payload),
});
if (submitResp.status === 401 || submitResp.status === 403) {
const accessError = submitResp.headers.get("x-access-error") || "";
if (accessError === "taste_exhausted") {
throw new AdobeFireflyError("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted");
}
throw new AdobeFireflyError(
"Adobe Firefly token invalid or expired. Paste a fresh IMS JWT (Authorization: Bearer on firefly-3p), not page cookies alone.",
401,
"auth"
);
}
if (!submitResp.ok) {
const text = await submitResp.text().catch(() => "");
if (isAdobeTransientSubmitError(submitResp.status, text)) {
sawSystemUnderLoad = true;
}
lastSubmitError = `Adobe Firefly image submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`;
if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) {
// Exponential backoff: 2s, 4s, 8s, 16s… capped at 45s (+ jitter)
const delay =
Math.min(45_000, SUBMIT_BASE_DELAY_MS * Math.pow(2, attempt - 1)) +
Math.floor(Math.random() * 750);
opts.log?.info?.(
"ADOBE-FIREFLY",
`image submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms`
);
await sleep(delay);
continue;
}
if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) {
throw new AdobeFireflyError(
formatAdobeSystemUnderLoadError("image", attempt),
408,
"system_under_load"
);
}
throw new AdobeFireflyError(
lastSubmitError,
submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502
);
}
submitData = await submitResp.json().catch(() => ({}));
submitHeaders = submitResp.headers;
break;
}
let pollUrl = extractAdobeResultLink(submitHeaders, submitData);
if (!pollUrl) {
if (sawSystemUnderLoad) {
throw new AdobeFireflyError(
formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS),
408,
"system_under_load"
);
}
throw new AdobeFireflyError(
lastSubmitError || "Adobe Firefly image submit succeeded but no poll URL was returned",
502
);
}
pollUrl = normalizeAdobePollUrl(pollUrl);
const { mediaUrl, latest } = await pollAdobeJob({
pollUrl,
accessToken: opts.accessToken,
kind: "image",
timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_IMAGE_TIMEOUT_MS,
fetchImpl,
log: opts.log,
});
return { url: mediaUrl, latest };
}
export async function adobeFireflyGenerateVideo(opts: {
accessToken: string;
prompt: string;
model: string;
size?: unknown;
aspectRatio?: unknown;
duration?: unknown;
quality?: unknown;
resolution?: unknown;
seed?: number;
sourceImageIds?: string[];
negativePrompt?: string;
generateAudio?: boolean;
sessionCookie?: string;
timeoutMs?: number;
fetchImpl?: typeof fetch;
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
}): Promise<{ url: string; b64_json?: string; format: string; latest: unknown }> {
const fetchImpl = opts.fetchImpl || fetch;
const { spec } = resolveAdobeVideoModel(opts.model);
const aspectRatio = normalizeAdobeAspectRatio(opts.aspectRatio ?? opts.size, "16:9");
const duration =
typeof opts.duration === "number"
? opts.duration
: typeof opts.duration === "string" && opts.duration.trim()
? Number(opts.duration)
: spec.defaultDuration;
const resolution =
typeof opts.resolution === "string" && opts.resolution.trim()
? opts.resolution
: typeof opts.quality === "string" && /p$/i.test(opts.quality)
? opts.quality
: spec.defaultResolution;
const payload = buildAdobeVideoPayload({
prompt: opts.prompt,
aspectRatio,
duration: Number.isFinite(duration) ? Number(duration) : spec.defaultDuration,
modelSpec: spec,
resolution,
seed: opts.seed,
sourceImageIds: opts.sourceImageIds,
negativePrompt: opts.negativePrompt,
generateAudio: opts.generateAudio,
});
const sessionCookie = String(opts.sessionCookie || "").trim();
const cookieHeader = extractAdobeCookieHeader(sessionCookie);
const arpSessionId =
extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie);
let submitData: unknown = {};
let submitHeaders: Headers | Record<string, string | null | undefined> = new Headers();
let lastSubmitError = "";
let sawSystemUnderLoad = false;
for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) {
const submitResp = await fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, {
method: "POST",
headers: buildAdobeSubmitHeaders(opts.accessToken, {
arpSessionId: arpSessionId || undefined,
prompt: opts.prompt,
cookie: cookieHeader || undefined,
}),
body: JSON.stringify(payload),
});
if (submitResp.status === 401 || submitResp.status === 403) {
const accessError = submitResp.headers.get("x-access-error") || "";
if (accessError === "taste_exhausted") {
throw new AdobeFireflyError("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted");
}
throw new AdobeFireflyError(
"Adobe Firefly token invalid or expired. Paste a fresh IMS JWT (Authorization: Bearer on firefly-3p), not page cookies alone.",
401,
"auth"
);
}
if (!submitResp.ok) {
const text = await submitResp.text().catch(() => "");
if (isAdobeTransientSubmitError(submitResp.status, text)) {
sawSystemUnderLoad = true;
}
lastSubmitError = `Adobe Firefly video submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`;
if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) {
const delay =
Math.min(45_000, SUBMIT_BASE_DELAY_MS * Math.pow(2, attempt - 1)) +
Math.floor(Math.random() * 750);
opts.log?.info?.(
"ADOBE-FIREFLY",
`video submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms`
);
await sleep(delay);
continue;
}
if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) {
throw new AdobeFireflyError(
formatAdobeSystemUnderLoadError("video", attempt),
408,
"system_under_load"
);
}
throw new AdobeFireflyError(
lastSubmitError,
submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502
);
}
submitData = await submitResp.json().catch(() => ({}));
submitHeaders = submitResp.headers;
break;
}
let pollUrl = extractAdobeResultLink(submitHeaders, submitData);
if (!pollUrl) {
if (sawSystemUnderLoad) {
throw new AdobeFireflyError(
formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS),
408,
"system_under_load"
);
}
throw new AdobeFireflyError(
lastSubmitError || "Adobe Firefly video submit succeeded but no poll URL was returned",
502
);
}
pollUrl = normalizeAdobePollUrl(pollUrl);
const { mediaUrl, latest } = await pollAdobeJob({
pollUrl,
accessToken: opts.accessToken,
kind: "video",
timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_VIDEO_TIMEOUT_MS,
fetchImpl,
log: opts.log,
});
return { url: mediaUrl, format: "mp4", latest };
}