diff --git a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts index fb54943024..8be218668c 100644 --- a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts +++ b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts @@ -1,160 +1,159 @@ -// Adobe Firefly (unofficial) image-generation handler. -// Family: adobe-firefly-image | Provider: adobe-firefly -// -// Credentials: IMS access_token (JWT, client_id clio-playground-web) or full -// Cookie header from firefly.adobe.com. Cookie → IMS check/v6/token with -// client_id clio-playground-web (Express projectx_webapp fallback). -// -// Reference images (Media page / OpenAI edit aliases): -// 1) POST raw bytes → firefly-3p /v2/storage/image → { images:[{ id }] } -// 2) generate-async with referenceBlobs:[{ id, usage:"general"|"subject" }] -// See web_providers/adobe_atach_images.txt for live captures. - -import { sanitizeErrorMessage } from "../../../utils/error.ts"; -import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; -import { - AdobeFireflyError, - adobeFireflyGenerateImage, - resolveAdobeAccessToken, - resolveAdobeArpSessionId, - resolveAdobeSourceImageIds, - resolveAdobeImageModel, -} from "../../../services/adobeFireflyClient.ts"; - -function normalizePositiveNumber(value: unknown, fallback: number): number { - const n = Number(value); - return Number.isFinite(n) && n > 0 ? n : fallback; -} - -export async function handleAdobeFireflyImageGeneration({ - model, - provider, - body, - credentials, - log, - fetchImpl = fetch, -}: { - model: string; - provider: string; - providerConfig?: { baseUrl?: string }; - body: { - prompt?: unknown; - size?: unknown; - aspect_ratio?: unknown; - aspectRatio?: unknown; - quality?: unknown; - seed?: unknown; - negative_prompt?: unknown; - timeout_ms?: unknown; - image?: unknown; - image_url?: unknown; - image_urls?: unknown; - images?: unknown; - [key: string]: unknown; - }; - credentials: { apiKey?: string; accessToken?: string }; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; - fetchImpl?: typeof fetch; -}) { - const startTime = Date.now(); - const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; - if (!prompt) { - return saveImageErrorResult({ - provider, - model, - status: 400, - startTime, - error: "Prompt is required for Adobe Firefly image generation", - }); - } - - try { - const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl); - const timeoutMs = normalizePositiveNumber(body.timeout_ms, 180_000); - const seed = - typeof body.seed === "number" - ? body.seed - : typeof body.seed === "string" && body.seed.trim() - ? Number(body.seed) - : undefined; - - // Keep the raw credential blob for Cookie + sherlockToken (x-arp-session-id). - // JWT may be embedded in the same paste as cookies (HAR / multi-line). - const psd = (credentials as { providerSpecificData?: { cookie?: string } }) - ?.providerSpecificData; - const sessionCookie = - (typeof psd?.cookie === "string" && psd.cookie.trim()) || - (typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) || - (typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";") - ? credentials.accessToken - : undefined); - - // Cap uploads by model family (matches MediaViewModel GetSourceImageLimit). - const { id: resolvedId } = resolveAdobeImageModel(model); - const maxRefs = resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") ? 4 : 2; - - // One ARP for upload+generate (browser reuses sherlockToken / x-arp-session-id). - const arpSessionId = resolveAdobeArpSessionId(sessionCookie); - - const sourceImageIds = await resolveAdobeSourceImageIds({ - accessToken, - body, - max: maxRefs, - sessionCookie, - arpSessionId, - prompt, - fetchImpl, - log, - }); - - log?.info?.( - "IMAGE", - `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + - (sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") - ); - - const result = await adobeFireflyGenerateImage({ - accessToken, - prompt, - model, - size: body.size, - aspectRatio: body.aspect_ratio ?? body.aspectRatio ?? body.size, - quality: body.quality, - seed: Number.isFinite(seed as number) ? (seed as number) : undefined, - negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined, - sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, - sessionCookie, - arpSessionId, - timeoutMs, - fetchImpl, - log, - }); - - return saveImageSuccessResult({ - provider, - model, - startTime, - images: [{ url: result.url }], - }); - } catch (err) { - if (err instanceof AdobeFireflyError) { - log?.error?.("IMAGE", `${provider} adobe-firefly error ${err.status}: ${err.message}`); - return saveImageErrorResult({ - provider, - model, - status: err.status, - startTime, - error: err.message, - }); - } - const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); - log?.error?.("IMAGE", `${provider} adobe-firefly exception: ${errorText}`); - return saveImageErrorResult({ - provider, - model, - status: 500, - startTime, - error: errorText, - }); - } -} +// Adobe Firefly (unofficial) image-generation handler. +// Family: adobe-firefly-image | Provider: adobe-firefly +// +// Credentials: IMS access_token (JWT, client_id clio-playground-web) or full +// Cookie header from firefly.adobe.com. Cookie → IMS check/v6/token with +// client_id clio-playground-web (Express projectx_webapp fallback). +// +// Reference images (Media page / OpenAI edit aliases): +// 1) POST raw bytes → firefly-3p /v2/storage/image → { images:[{ id }] } +// 2) generate-async with referenceBlobs:[{ id, usage:"general"|"subject" }] +// See browser network capture for live captures. + +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; +import { + AdobeFireflyError, + adobeFireflyGenerateImage, + resolveAdobeSourceImageIds, + resolveAdobeImageModel, +} from "../../../services/adobeFireflyClient.ts"; +import { ensureAdobeFireflySession } from "../../../services/adobeFireflySession.ts"; + +function normalizePositiveNumber(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +export async function handleAdobeFireflyImageGeneration({ + model, + provider, + body, + credentials, + log, + fetchImpl = fetch, +}: { + model: string; + provider: string; + providerConfig?: { baseUrl?: string }; + body: { + prompt?: unknown; + size?: unknown; + aspect_ratio?: unknown; + aspectRatio?: unknown; + quality?: unknown; + seed?: unknown; + negative_prompt?: unknown; + timeout_ms?: unknown; + image?: unknown; + image_url?: unknown; + image_urls?: unknown; + images?: unknown; + [key: string]: unknown; + }; + credentials: { apiKey?: string; accessToken?: string }; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + fetchImpl?: typeof fetch; +}) { + const startTime = Date.now(); + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: "Prompt is required for Adobe Firefly image generation", + }); + } + + try { + // Durable session: JWT + Cookie once → auto-rebuild ARP from forter/arkose, + // cache, optional Playwright warm-up. Submit path rotates ARP on 408. + const session = await ensureAdobeFireflySession({ + credentials, + fetchImpl, + log, + }); + const accessToken = session.accessToken; + const sessionCookie = session.cookie || undefined; + const arpSessionId = session.arpSessionId; + const timeoutMs = normalizePositiveNumber(body.timeout_ms, 180_000); + const seed = + typeof body.seed === "number" + ? body.seed + : typeof body.seed === "string" && body.seed.trim() + ? Number(body.seed) + : undefined; + + // Cap uploads by model family (matches MediaViewModel GetSourceImageLimit). + const { id: resolvedId } = resolveAdobeImageModel(model); + const maxRefs = + resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") + ? 4 + : 2; + + const sourceImageIds = await resolveAdobeSourceImageIds({ + accessToken, + body, + max: maxRefs, + sessionCookie, + arpSessionId, + prompt, + fetchImpl, + log, + }); + + log?.info?.( + "IMAGE", + `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + + (sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") + + ` | session=${session.source}` + ); + + const result = await adobeFireflyGenerateImage({ + accessToken, + prompt, + model, + size: body.size, + aspectRatio: body.aspect_ratio ?? body.aspectRatio ?? body.size, + quality: body.quality, + seed: Number.isFinite(seed as number) ? (seed as number) : undefined, + negativePrompt: + typeof body.negative_prompt === "string" ? body.negative_prompt : undefined, + sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, + sessionCookie, + arpSessionId, + timeoutMs, + fetchImpl, + log, + }); + + return saveImageSuccessResult({ + provider, + model, + startTime, + images: [{ url: result.url }], + }); + } catch (err) { + if (err instanceof AdobeFireflyError) { + log?.error?.("IMAGE", `${provider} adobe-firefly error ${err.status}: ${err.message}`); + return saveImageErrorResult({ + provider, + model, + status: err.status, + startTime, + error: err.message, + }); + } + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + log?.error?.("IMAGE", `${provider} adobe-firefly exception: ${errorText}`); + return saveImageErrorResult({ + provider, + model, + status: 500, + startTime, + error: errorText, + }); + } +} diff --git a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts index d114790747..00a4dddbd0 100644 --- a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts +++ b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts @@ -9,11 +9,10 @@ import { sanitizeErrorMessage } from "../../utils/error.ts"; import { AdobeFireflyError, adobeFireflyGenerateVideo, - resolveAdobeAccessToken, - resolveAdobeArpSessionId, resolveAdobeSourceImageIds, resolveAdobeVideoModel, } from "../../services/adobeFireflyClient.ts"; +import { ensureAdobeFireflySession } from "../../services/adobeFireflySession.ts"; function normalizePositiveNumber(value: unknown, fallback: number): number { const n = Number(value); @@ -47,7 +46,14 @@ export async function handleAdobeFireflyVideoGeneration({ } try { - const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl); + const session = await ensureAdobeFireflySession({ + credentials, + fetchImpl, + log, + }); + const accessToken = session.accessToken; + const sessionCookie = session.cookie || undefined; + const arpSessionId = session.arpSessionId; const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300_000); const seed = typeof body.seed === "number" @@ -55,21 +61,10 @@ export async function handleAdobeFireflyVideoGeneration({ : typeof body.seed === "string" && String(body.seed).trim() ? Number(body.seed) : undefined; - // Keep raw paste for Cookie + sherlockToken (x-arp-session-id). - const psd = (credentials as { providerSpecificData?: { cookie?: string } }) - ?.providerSpecificData; - const sessionCookie = - (typeof psd?.cookie === "string" && psd.cookie.trim()) || - (typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) || - (typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";") - ? credentials.accessToken - : undefined); // Kling i2v / Veo ref / Sora frame: upload reference images first. const { id: videoModelId } = resolveAdobeVideoModel(String(model)); const maxFrames = videoModelId.includes("kling") || videoModelId.includes("sora") ? 2 : 3; - // One ARP for frame upload + video submit (matches browser). - const arpSessionId = resolveAdobeArpSessionId(sessionCookie); const sourceImageIds = await resolveAdobeSourceImageIds({ accessToken, body, @@ -84,7 +79,8 @@ export async function handleAdobeFireflyVideoGeneration({ log?.info?.( "VIDEO", `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + - (sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "") + (sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "") + + ` | session=${session.source}` ); const result = await adobeFireflyGenerateVideo({ diff --git a/open-sse/services/adobeFireflyClient.ts b/open-sse/services/adobeFireflyClient.ts index 762b341bb9..3539a42fa4 100644 --- a/open-sse/services/adobeFireflyClient.ts +++ b/open-sse/services/adobeFireflyClient.ts @@ -1,2545 +1,2631 @@ -/** - * 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> = { - "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> = { - "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 = { - "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 | 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) : 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"] }; -} - -/** - * Map OpenAI/VibeProxy quality tiers onto Firefly gpt-image `generationSettings.detailLevel`. - * Wire range is integer 1–5 (discovery schema). Default is **maximal (5)** — - * the SPA often defaults to 3, but detail is critical for GPT Image 2 output quality. - * Explicit low/medium still honor the caller's choice. - */ -function gptDetailLevel(quality: unknown): number { - const q = String(quality ?? "high") - .trim() - .toLowerCase(); - if (q === "low" || q === "1k" || q === "1") return 1; - if (q === "medium" || q === "2k" || q === "standard" || q === "hd" || q === "3") return 3; - // high / 4k / ultra / auto / empty / unknown → max detail - return 5; -} - -export function buildAdobeImagePayload(opts: { - prompt: string; - aspectRatio: string; - outputResolution: "1K" | "2K" | "4K"; - modelSpec: AdobeFireflyImageModelSpec; - quality?: unknown; - seed?: number; - sourceImageIds?: string[]; - negativePrompt?: string; -}): Record { - 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 = {}; - 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 = { - n: 1, - seeds, - output: { storeInputs: true }, - prompt: opts.prompt, - referenceBlobs: [] as Array>, - 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 = { - 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>, - }; - 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 { - 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 = { - 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>, - generationMetadata: { module: "text2video" }, - modelSpecificPayload: { - parameters: { - durationSeconds: duration, - aspectRatio: aspect, - addWaterMark: false, - }, - }, - }; - if (sourceImageIds.length) { - const refs = payload.referenceBlobs as Array>; - 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 = { - 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>, - }; - if (sourceImageIds.length) { - const refs = payload.referenceBlobs as Array>; - 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 = { - 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>, - referenceFrames: [] as Array | 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 | null> = [{ localBlobRef: firstId }, null]; - if (sourceImageIds.length > 1) { - const lastId = String(sourceImageIds[1]); - (payload.referenceBlobs as Array>).push({ - id: lastId, - usage: "general", - promptReference: 2, - }); - frames[1] = { localBlobRef: lastId }; - } - payload.referenceFrames = frames; - } - return payload; -} - -function browserHeaders(): Record { - 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"); -} - -/** - * Live firefly.adobe.com Arkose public key (web_providers/adobe_atach_images.txt, 2026-07). - * Browser x-arp-session-id is base64(JSON({sid, ark, ftr})) — synthetic sessions without a - * real Arkose blob often get colligo HTTP 408 "system under load". Prefer pasted sherlockToken. - */ -export const ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY = "BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C"; -/** Live ftr magic (replaces older adobe2api `dUAL43-mnts-ants-d4_31ck__tt`). */ -export const ADOBE_FIREFLY_FTR_MAGIC = "__UDF43-m4_31ck"; - -/** - * True when a string looks like a Firefly ARP session (base64 JSON with sid). - */ -export function isValidAdobeArpSessionId(value: string): boolean { - const t = String(value || "").trim(); - if (t.length < 4) return false; - try { - const padded = t + "=".repeat((4 - (t.length % 4)) % 4); - const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString( - "utf8" - ); - const obj = JSON.parse(json) as { sid?: unknown; ftr?: unknown; ark?: unknown }; - return typeof obj.sid === "string" && obj.sid.length > 0; - } catch { - // Opaque sherlockToken values (non-JSON) still work as x-arp-session-id when non-empty. - return t.length >= 4 && !looksLikeAdobeJwt(t) && /^[A-Za-z0-9+/=_-]+$/.test(t); - } -} - -/** - * Synthesize x-arp-session-id when no browser sherlockToken is available. - * Shape matches live SPA (2026-07): base64(JSON({sid, ark, ftr})). - * ALWAYS send this header on generate-async / storage upload. - * Prefer real sherlockToken from cookies — synthetic ark is best-effort only. - */ -export function buildAdobeArpSessionId(region = "eu-west-1"): string { - const nowMs = Date.now(); - const sid = randomUUID(); - const randHex = randomBytes(16).toString("hex"); - // Live ftr: {32hex}_{ms}__UDF43-m4_31ck_{b64}=-N-v2_tt - const mid = randomBytes(12).toString("base64url"); - const n = 1000 + Math.floor(Math.random() * 9000); - const ftr = `${randHex}_${nowMs}${ADOBE_FIREFLY_FTR_MAGIC}_${mid}=-${n}-v2_tt`; - // Arkose session-shaped string (public pk from firefly SPA). Without a real - // Arkose solve this may still 408; real sherlockToken is the stable path. - const arkSession = `${randomBytes(8).toString("hex")}.${Math.random().toFixed(10).slice(2)}`; - const ark = - `${arkSession}|r=${region}|meta=3|metabgclr=transparent|metaiconclr=%23757575|` + - `guitextcolor=%23000000|pk=${ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY}|at=40|sup=1|rid=13|ag=101|` + - `cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|` + - `surl=https%3A%2F%2Farks-client.adobe.com|` + - `smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager`; - const raw = JSON.stringify({ sid, ark, ftr }); - return Buffer.from(raw, "utf-8").toString("base64"); -} - -/** - * Pull sherlockToken / x-arp-session-id from Cookie header, HAR paste, or multi-line credential. - * Browser generate sends Cookie.sherlockToken (or the request header) as x-arp-session-id. - * Live value is base64({sid, ark, ftr}) — includes Arkose session data. - * - * Also handles PasswordBox mangling (JWT + ARP joined by a single space) and full fetch() - * copy/paste from DevTools (web_providers/adobe_atach_images.txt). - */ -export function extractAdobeArpSessionId(cookieOrBlob: string): string { - const raw = String(cookieOrBlob || ""); - if (!raw.trim()) return ""; - - const candidates: string[] = []; - const push = (v: string | undefined | null) => { - if (!v) return; - let t = v - .trim() - .replace(/^["']|["']$/g, "") - .trim(); - try { - // Cookie values are often URI-encoded - if (/%[0-9A-Fa-f]{2}/.test(t)) t = decodeURIComponent(t); - } catch { - /* keep raw */ - } - if (t) candidates.push(t); - }; - - // Cookie: sherlockToken=... - const m = raw.match(/(?:^|[;\s\n\r])sherlockToken=([^;\s\n\r]+)/i); - if (m?.[1]) push(m[1]); - - // Cookie or form: x-arp-session-id=... - const m2 = raw.match(/(?:^|[;\s\n\r])x-arp-session-id=([^;\s\n\r]+)/i); - if (m2?.[1]) push(m2[1]); - - // HAR / Network / fetch() headers: "x-arp-session-id": "eyJ..." or x-arp-session-id: eyJ... - const m3 = raw.match(/["']?x-arp-session-id["']?\s*[:=]\s*["']?([A-Za-z0-9+/=_-]{40,})["']?/i); - if (m3?.[1]) push(m3[1]); - - // HAR: "sherlockToken": "eyJ..." - const m4 = raw.match(/["']?sherlockToken["']?\s*[:=]\s*["']?([A-Za-z0-9+/=_-]{40,})["']?/i); - if (m4?.[1]) push(m4[1]); - - // Bare base64 ARP blob on its own line (line 2 of two-line paste) - for (const line of raw.split(/[\r\n]+/)) { - const t = line.trim().replace(/^["']|["']$/g, ""); - // Skip pure JWT lines - if (looksLikeAdobeJwt(t)) continue; - if (t.length >= 40 && isValidAdobeArpSessionId(t)) push(t); - } - - // JWT + ARP joined by whitespace (single-line PasswordBox paste collapses \n → space) - const withoutJwt = raw.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, " "); - for (const token of withoutJwt.split(/[\s;,"']+/)) { - const t = token.trim(); - if (t.length >= 40 && isValidAdobeArpSessionId(t)) push(t); - } - - // Prefer ARP that decodes to JSON with sid+ark (real browser session over opaque short tokens) - const ranked = candidates - .map((c) => c.replace(/^["']|["']$/g, "").trim()) - .filter((v) => isValidAdobeArpSessionId(v)); - ranked.sort((a, b) => scoreAdobeArpCandidate(b) - scoreAdobeArpCandidate(a)); - return ranked[0] || ""; -} - -/** Higher = more like a live firefly-3p x-arp-session-id (sid+ark+ftr base64). */ -function scoreAdobeArpCandidate(value: string): number { - let score = value.length; - try { - const padded = value + "=".repeat((4 - (value.length % 4)) % 4); - const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString( - "utf8" - ); - const obj = JSON.parse(json) as { sid?: unknown; ark?: unknown; ftr?: unknown }; - if (typeof obj.sid === "string" && obj.sid) score += 1000; - if (typeof obj.ark === "string" && obj.ark.length > 20) score += 500; - if (typeof obj.ftr === "string" && obj.ftr.includes(ADOBE_FIREFLY_FTR_MAGIC)) score += 200; - if (typeof obj.ark === "string" && obj.ark.includes(ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY)) - score += 100; - } catch { - /* opaque sherlockToken */ - } - return score; -} - -/** - * True when the credential blob already contains a browser ARP / sherlockToken. - * Synthetic ARP is a fallback only — real paste is required for stable generate (no 408). - */ -export function hasBrowserAdobeArpSession(sessionCookieOrBlob?: string): boolean { - return Boolean(extractAdobeArpSessionId(String(sessionCookieOrBlob || ""))); -} - -/** - * Resolve ARP for a Firefly request: prefer real browser sherlockToken, else synthetic. - * Mint once per generate/upload chain and reuse (browser uses the same ARP for upload+submit). - */ -export function resolveAdobeArpSessionId(sessionCookieOrBlob?: string): string { - const extracted = extractAdobeArpSessionId(String(sessionCookieOrBlob || "")); - if (extracted) return extracted; - return buildAdobeArpSessionId(); -} - -export function buildAdobeSubmitHeaders( - accessToken: string, - extras?: { - arpSessionId?: string; - nonce?: string; - cookie?: string; - /** Required for deterministic x-nonce (sha256 user_id+prompt). */ - prompt?: string; - } -): Record { - // Live capture (web_providers/adobe_atach_images.txt) + working clients: - // Authorization + x-api-key + x-nonce + ALWAYS x-arp-session-id (sid+ark+ftr). - // Do NOT attach firefly.adobe.com page Cookie to firefly-3p (wrong origin). - // Prefer real sherlockToken from cookie blob; synthetic ARP is fallback only. - const cookieBlob = String(extras?.cookie || "").trim(); - const deterministic = - extras?.nonce || - (extras?.prompt ? buildAdobeSubmitNonce(accessToken, extras.prompt) : "") || - generateAdobeNonce(); - // Explicit arpSessionId wins (caller may pass synthetic short test ids or real browser ARP). - const explicitArp = extras?.arpSessionId ? String(extras.arpSessionId).trim() : ""; - const arp = explicitArp || extractAdobeArpSessionId(cookieBlob) || buildAdobeArpSessionId(); - const headers: Record = { - ...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 { - 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; - const po = - b.provider_options && - typeof b.provider_options === "object" && - !Array.isArray(b.provider_options) - ? (b.provider_options as Record) - : {}; - - const out: string[] = []; - const seen = new Set(); - 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; - 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).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).content; - if (!Array.isArray(content)) continue; - for (const part of content) { - if (!part || typeof part !== "object") continue; - const p = part as Record; - 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).images; - if (Array.isArray(images) && images.length > 0) { - const first = images[0]; - if (first && typeof first === "object") { - const id = (first as Record).id; - if (typeof id === "string" && id.trim()) return id.trim(); - } - } - const id = (body as Record).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; - /** Reuse the same ARP as generate-async (browser does). */ - arpSessionId?: string; - /** Used for deterministic x-nonce (optional). */ - prompt?: string; - fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; -}): Promise { - 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); - // One ARP for the whole chain — do not mint a new synthetic id per upload. - const arpSessionId = - (opts.arpSessionId && String(opts.arpSessionId).trim()) || - resolveAdobeArpSessionId(cookieHeader || 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, - cookie: cookieHeader || undefined, - prompt: opts.prompt || "upload", - }), - body: buffer, - }); - - 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; - /** Shared ARP for upload+generate (required for stable Firefly 3P). */ - arpSessionId?: string; - prompt?: string; - fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; -}): Promise { - 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[] = []; - // One ARP for all uploads in this request (browser reuses the same header). - const arpSessionId = - (opts.arpSessionId && String(opts.arpSessionId).trim()) || - resolveAdobeArpSessionId(opts.sessionCookie); - - 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, - arpSessionId, - 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 { - // 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 { - const accountId = extractAdobeAccountIdFromToken(accessToken); - const headers: Record = { - ...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 { - 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, - opts?: { hadBrowserArp?: boolean } -): string { - const hadArp = opts?.hadBrowserArp === true; - if (!hadArp) { - return ( - `Adobe Firefly ${kind} generation failed (HTTP 408 "system under load", after ${attempts} attempt` + - `${attempts === 1 ? "" : "s"}). Your credential is missing a browser x-arp-session-id / sherlockToken ` + - `(JWT alone almost always 408s even when credits/Limits work). Re-open the Adobe Firefly account and paste ` + - `TWO lines from a SUCCESSFUL firefly-3p.ff.adobe.io generate-async request (F12 → Network): ` + - `(1) Authorization token AFTER "Bearer " (eyJ… JWT), (2) the raw x-arp-session-id header value ` + - `OR Cookie containing sherlockToken. Use the multi-line credential box so both lines are kept.` - ); - } - return ( - `Adobe Firefly ${kind} generation failed (HTTP 408 "system under load", after ${attempts} attempt` + - `${attempts === 1 ? "" : "s"}). JWT and x-arp-session-id were present but Adobe still rejected the session ` + - `(ARP/Arkose sessions expire quickly). Capture a FRESH successful generate-async request and re-paste ` + - `both Authorization Bearer JWT and the new x-arp-session-id (or Cookie with sherlockToken) from that same request.` - ); -} - -export function extractAdobeResultLink( - headers: Headers | Record, - 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; - 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) : {}; - const links = - data.links && typeof data.links === "object" ? (data.links as Record) : {}; - const result = links.result; - if (typeof result === "string" && result) return result; - if (result && typeof result === "object") { - const href = (result as Record).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) : {}; - const outputs = Array.isArray(body.outputs) ? body.outputs : []; - if (outputs.length > 0) { - const first = - outputs[0] && typeof outputs[0] === "object" ? (outputs[0] as Record) : {}; - const media = - kind === "image" - ? first.image && typeof first.image === "object" - ? (first.image as Record) - : null - : first.video && typeof first.video === "object" - ? (first.video as Record) - : 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; - 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 { - 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 { - 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).quota && - typeof (block as Record).quota === "object" - ? ((block as Record).quota as Record) - : (block as Record); - 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) : {}; - const totalBlock = readQuotaBlock(root.total); - const credits = - root.credits && typeof root.credits === "object" - ? (root.credits as Record) - : {}; - 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).availableUntil === "string" - ? String((root.total as Record).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 { - 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) : {}; - 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; - const modelId = String(rec.modelId || "").trim(); - if (!modelId) continue; - const versions = - rec.modelVersions && typeof rec.modelVersions === "object" - ? (rec.modelVersions as Record) - : {}; - for (const [ver, spec] of Object.entries(versions)) { - if (!spec || typeof spec !== "object") continue; - const s = spec as Record; - 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 { - 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 { - 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).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; - /** Shared ARP (sid+ark+ftr). Reuse with uploads; do not mint per retry. */ - arpSessionId?: 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 (has Arkose ark). Mint synthetic only once for the whole submit chain. - // Only the raw credential paste counts as "browser ARP" — never the synthetic fallback - // that resolveAdobeArpSessionId mints when sherlockToken/x-arp is missing. - const hadBrowserArp = hasBrowserAdobeArpSession(cookieHeader || sessionCookie); - const arpSessionId = - (opts.arpSessionId && String(opts.arpSessionId).trim()) || - resolveAdobeArpSessionId(cookieHeader || sessionCookie); - let submitData: unknown = {}; - let submitHeaders: Headers | Record = new Headers(); - let lastSubmitError = ""; - let sawSystemUnderLoad = false; - - for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { - // Deterministic x-nonce from user_id+prompt. Reuse the same ARP across retries (browser does). - const submitResp = await fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, { - method: "POST", - headers: buildAdobeSubmitHeaders(opts.accessToken, { - arpSessionId, - 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, { hadBrowserArp }), - 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, { hadBrowserArp }), - 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; - /** Shared ARP (sid+ark+ftr). Reuse with frame uploads. */ - arpSessionId?: 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 hadBrowserArp = hasBrowserAdobeArpSession(cookieHeader || sessionCookie); - const arpSessionId = - (opts.arpSessionId && String(opts.arpSessionId).trim()) || - resolveAdobeArpSessionId(cookieHeader || sessionCookie); - let submitData: unknown = {}; - let submitHeaders: Headers | Record = 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, - 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, { hadBrowserArp }), - 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, { hadBrowserArp }), - 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 }; -} +/** + * 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 = + { + // 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 = + { + "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> = { + "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> = { + "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 = { + "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 | 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_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/); + 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) : 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_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/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_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.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_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/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 { + 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 = {}; + 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 = { + n: 1, + seeds, + output: { storeInputs: true }, + prompt: opts.prompt, + referenceBlobs: [] as Array>, + 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 (browser network capture): 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 = { + 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>, + }; + 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 { + 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 = { + 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>, + generationMetadata: { module: "text2video" }, + modelSpecificPayload: { + parameters: { + durationSeconds: duration, + aspectRatio: aspect, + addWaterMark: false, + }, + }, + }; + if (sourceImageIds.length) { + const refs = payload.referenceBlobs as Array>; + 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 = { + 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>, + }; + if (sourceImageIds.length) { + const refs = payload.referenceBlobs as Array>; + 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 = { + 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>, + referenceFrames: [] as Array | 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 | null> = [{ localBlobRef: firstId }, null]; + if (sourceImageIds.length > 1) { + const lastId = String(sourceImageIds[1]); + (payload.referenceBlobs as Array>).push({ + id: lastId, + usage: "general", + promptReference: 2, + }); + frames[1] = { localBlobRef: lastId }; + } + payload.referenceFrames = frames; + } + return payload; +} + +function browserHeaders(): Record { + 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"); +} + +/** + * Live firefly.adobe.com Arkose public key (browser network capture, 2026-07). + * Browser x-arp-session-id is base64(JSON({sid, ark, ftr})) — synthetic sessions without a + * real Arkose blob often get colligo HTTP 408 "system under load". Prefer pasted sherlockToken. + */ +export const ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY = "BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C"; +/** Live ftr magic (replaces older adobe2api `dUAL43-mnts-ants-d4_31ck__tt`). */ +export const ADOBE_FIREFLY_FTR_MAGIC = "__UDF43-m4_31ck"; + +/** + * True when a string looks like a Firefly ARP session (base64 JSON with sid). + */ +export function isValidAdobeArpSessionId(value: string): boolean { + const t = String(value || "").trim(); + if (t.length < 4) return false; + // Never treat Cookie name=value pairs (e.g. aux_sid=…, forter=…) as ARP. + // Live ARP is base64(JSON) or a bare opaque token — not "key=value". + if (/^[A-Za-z_][A-Za-z0-9_.%-]*=/.test(t) && !t.startsWith("eyJ")) return false; + try { + const padded = t + "=".repeat((4 - (t.length % 4)) % 4); + const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString( + "utf8" + ); + // Reject binary garbage that "decodes" but isn't JSON (corrupted sherlock paste). + if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(json)) return false; + const obj = JSON.parse(json) as { sid?: unknown; ftr?: unknown; ark?: unknown }; + return typeof obj.sid === "string" && obj.sid.length > 0; + } catch { + // Opaque short sherlockToken values (tests / non-JSON) when non-empty. + // No mid-string "=" (cookie pair leftovers); padding "=" at end is OK. + if (/=.+/.test(t.replace(/=+$/, ""))) return false; + return !looksLikeAdobeJwt(t) && /^[A-Za-z0-9+/_=-]+$/.test(t); + } +} + +/** + * Synthesize x-arp-session-id when no browser sherlockToken is available. + * Shape matches live successful generate (adobe/image_generate.txt): + * base64(JSON({sid, ark, bfp, ftr, fpjs})) + * ALWAYS send this header on generate-async / storage upload. + * Prefer real sherlockToken / cookie rebuild (forter+arkose+sid) when available. + */ +export function buildAdobeArpSessionId(region = "eu-west-1"): string { + const nowMs = Date.now(); + const sid = randomUUID(); + const randHex = randomBytes(16).toString("hex"); + // Live ftr: {32hex}_{ms}__UDF43-m4_31ck_{b64}=-N-v2_tt + const mid = randomBytes(12).toString("base64url"); + const n = 1000 + Math.floor(Math.random() * 9000); + const ftr = `${randHex}_${nowMs}${ADOBE_FIREFLY_FTR_MAGIC}_${mid}=-${n}-v2_tt`; + // Arkose session-shaped string (public pk from firefly SPA). Without a real + // Arkose solve this may still 408; real sherlockToken is the stable path. + const arkSession = `${randomBytes(8).toString("hex")}.${Math.random().toFixed(10).slice(2)}`; + const ark = + `${arkSession}|r=${region}|meta=3|metabgclr=transparent|metaiconclr=%23757575|` + + `guitextcolor=%23000000|pk=${ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY}|at=40|sup=1|rid=13|ag=101|` + + `cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|` + + `surl=https%3A%2F%2Farks-client.adobe.com|` + + `smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager`; + // Successful browser ARP also carries Browser Fingerprint + FingerprintJS payload. + const bfp = randomUUID(); + const fpjs = JSON.stringify({ + requestId: `${nowMs}.${randomBytes(3).toString("base64url")}`, + visitorId: randomBytes(12).toString("base64url"), + }); + const raw = JSON.stringify({ sid, ark, bfp, ftr, fpjs }); + return Buffer.from(raw, "utf-8").toString("base64"); +} + +/** + * Pull sherlockToken / x-arp-session-id from Cookie header, HAR paste, or multi-line credential. + * Browser generate sends Cookie.sherlockToken (or the request header) as x-arp-session-id. + * Live value is base64({sid, ark, ftr}) — includes Arkose session data. + * + * Also handles PasswordBox mangling (JWT + ARP joined by a single space) and full fetch() + * copy/paste from DevTools (browser network capture). + */ +export function extractAdobeArpSessionId(cookieOrBlob: string): string { + const raw = String(cookieOrBlob || ""); + if (!raw.trim()) return ""; + + const candidates: string[] = []; + const push = (v: string | undefined | null) => { + if (!v) return; + let t = v.trim().replace(/^["']|["']$/g, "").trim(); + try { + // Cookie values are often URI-encoded + if (/%[0-9A-Fa-f]{2}/.test(t)) t = decodeURIComponent(t); + } catch { + /* keep raw */ + } + if (t) candidates.push(t); + }; + + // Cookie: sherlockToken=... + const m = raw.match(/(?:^|[;\s\n\r])sherlockToken=([^;\s\n\r]+)/i); + if (m?.[1]) push(m[1]); + + // Cookie or form: x-arp-session-id=... + const m2 = raw.match(/(?:^|[;\s\n\r])x-arp-session-id=([^;\s\n\r]+)/i); + if (m2?.[1]) push(m2[1]); + + // HAR / Network / fetch() headers: "x-arp-session-id": "eyJ..." or x-arp-session-id: eyJ... + const m3 = raw.match(/["']?x-arp-session-id["']?\s*[:=]\s*["']?([A-Za-z0-9+/=_-]{40,})["']?/i); + if (m3?.[1]) push(m3[1]); + + // HAR: "sherlockToken": "eyJ..." + const m4 = raw.match(/["']?sherlockToken["']?\s*[:=]\s*["']?([A-Za-z0-9+/=_-]{40,})["']?/i); + if (m4?.[1]) push(m4[1]); + + // Bare base64 ARP blob on its own line (line 2 of two-line paste) + for (const line of raw.split(/[\r\n]+/)) { + const t = line.trim().replace(/^["']|["']$/g, ""); + // Skip pure JWT lines + if (looksLikeAdobeJwt(t)) continue; + if (t.length >= 40 && isValidAdobeArpSessionId(t)) push(t); + } + + // JWT + ARP joined by whitespace (single-line PasswordBox paste collapses \n → space) + // Split on whitespace only — NOT on "=" — so we never treat "aux_sid=…" as a token. + const withoutJwt = raw.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, " "); + for (const token of withoutJwt.split(/[\s,;"']+/)) { + let t = token.trim(); + // If this chunk is name=value from a Cookie header, only keep the value when + // the name is sherlockToken / x-arp-session-id; skip aux_sid, forter, etc. + const eq = t.indexOf("="); + if (eq > 0 && eq < 40 && /^[A-Za-z0-9_.%-]+$/.test(t.slice(0, eq))) { + const name = t.slice(0, eq).toLowerCase(); + if (name === "sherlocktoken" || name === "x-arp-session-id") { + t = t.slice(eq + 1).trim(); + } else { + continue; + } + } + if (t.length >= 40 && isValidAdobeArpSessionId(t)) push(t); + } + + // Prefer ARP that decodes to JSON with sid+ark (real browser session over opaque short tokens) + const ranked = candidates + .map((c) => c.replace(/^["']|["']$/g, "").trim()) + .filter((v) => isValidAdobeArpSessionId(v)); + ranked.sort((a, b) => scoreAdobeArpCandidate(b) - scoreAdobeArpCandidate(a)); + return ranked[0] || ""; +} + +/** Higher = more like a live firefly-3p x-arp-session-id (sid+ark+ftr[+bfp+fpjs] base64). */ +function scoreAdobeArpCandidate(value: string): number { + let score = value.length; + try { + const padded = value + "=".repeat((4 - (value.length % 4)) % 4); + const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); + const obj = JSON.parse(json) as { + sid?: unknown; + ark?: unknown; + ftr?: unknown; + bfp?: unknown; + fpjs?: unknown; + }; + if (typeof obj.sid === "string" && obj.sid) score += 1000; + if (typeof obj.ark === "string" && obj.ark.length > 20) score += 500; + if (typeof obj.ftr === "string" && obj.ftr.includes(ADOBE_FIREFLY_FTR_MAGIC)) score += 200; + if (typeof obj.ark === "string" && obj.ark.includes(ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY)) score += 100; + // Live successful generates (adobe/image_generate.txt) include browser fingerprint fields. + if (typeof obj.bfp === "string" && obj.bfp.length >= 8) score += 150; + if (typeof obj.fpjs === "string" && obj.fpjs.length > 10) score += 150; + } catch { + /* opaque sherlockToken */ + } + return score; +} + +/** + * True when the credential blob already contains a browser ARP / sherlockToken + * OR enough cookie pieces to rebuild one (ff_session_guid + arkose + forterToken). + * Synthetic-only ARP is a fallback — real cookie pieces are required for stable generate. + */ +export function hasBrowserAdobeArpSession(sessionCookieOrBlob?: string): boolean { + const blob = String(sessionCookieOrBlob || ""); + if (extractAdobeArpSessionId(blob)) return true; + // Rebuild path counts as browser ARP (same pieces the SPA uses for sherlockToken). + const sid = blob.match(/(?:^|[;\s])ff_session_guid=([^;\s]+)/i)?.[1]; + const ark = blob.match(/(?:^|[;\s])arkose=([^;\s]+)/i)?.[1]; + const ftr = + blob.match(/(?:^|[;\s])forterToken=([^;\s]+)/i)?.[1] || + blob.match(/(?:^|[;\s])forter=([^;\s]+)/i)?.[1]; + return Boolean(sid && ark && ftr && !/^[a-f0-9]{32},\d+$/i.test(ftr)); +} + +/** + * Resolve ARP for a Firefly request. + * Prefer cookie rebuild (ff_session_guid + arkose + forterToken [+bfp/fpjs]) over a + * frozen sherlockToken paste — Forter advances while the pasted ARP goes stale. + * Fall back to sherlockToken / x-arp-session-id extract, then synthetic rich ARP. + * Mint once per generate/upload chain and reuse (browser uses the same ARP for upload+submit); + * on 408 the submit loop rotates ARP separately. + */ +export function resolveAdobeArpSessionId(sessionCookieOrBlob?: string): string { + const blob = String(sessionCookieOrBlob || ""); + // Lazy require of rebuild helper to avoid circular import at module load. + // Inline minimal rebuild here (sid+ark+ftr) so resolve stays self-contained. + const getCookie = (name: string): string => { + const m = blob.match( + new RegExp(`(?:^|[;\\s\\n\\r])${name}=([^;\\s\\n\\r]+)`, "i") + ); + if (!m?.[1]) return ""; + let v = m[1].trim(); + try { + if (/%[0-9A-Fa-f]{2}/.test(v)) v = decodeURIComponent(v); + } catch { + /* keep */ + } + return v; + }; + const sid = getCookie("ff_session_guid"); + const ark = getCookie("arkose"); + let ftr = getCookie("forterToken") || getCookie("forter"); + try { + if (/%[0-9A-Fa-f]{2}/.test(ftr)) ftr = decodeURIComponent(ftr); + } catch { + /* keep */ + } + if (ftr.endsWith("v2") && !ftr.endsWith("v2_tt")) ftr = `${ftr}_tt`; + // Skip localStorage-style "id,timestamp" forter values + if (/^[a-f0-9]{32},\d+$/i.test(ftr)) ftr = ""; + if (sid && ark && ftr) { + const bfp = getCookie("bfp"); + let fpjs = getCookie("fpjs"); + try { + if (fpjs && /%[0-9A-Fa-f]{2}/.test(fpjs)) fpjs = decodeURIComponent(fpjs); + } catch { + /* keep */ + } + const obj: Record = { sid, ark, ftr }; + if (bfp) obj.bfp = bfp; + if (fpjs) obj.fpjs = fpjs; + return Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); + } + const extracted = extractAdobeArpSessionId(blob); + if (extracted) return extracted; + return buildAdobeArpSessionId(); +} + +export function buildAdobeSubmitHeaders( + accessToken: string, + extras?: { + arpSessionId?: string; + nonce?: string; + cookie?: string; + /** Required for deterministic x-nonce (sha256 user_id+prompt). */ + prompt?: string; + } +): Record { + // Live capture (browser network capture) + working clients: + // Authorization + x-api-key + x-nonce + ALWAYS x-arp-session-id (sid+ark+ftr). + // Do NOT attach firefly.adobe.com page Cookie to firefly-3p (wrong origin). + // Prefer real sherlockToken from cookie blob; synthetic ARP is fallback only. + const cookieBlob = String(extras?.cookie || "").trim(); + const deterministic = + extras?.nonce || + (extras?.prompt ? buildAdobeSubmitNonce(accessToken, extras.prompt) : "") || + generateAdobeNonce(); + // Explicit arpSessionId wins (caller may pass synthetic short test ids or real browser ARP). + const explicitArp = extras?.arpSessionId ? String(extras.arpSessionId).trim() : ""; + const arp = + explicitArp || + extractAdobeArpSessionId(cookieBlob) || + buildAdobeArpSessionId(); + const headers: Record = { + ...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 (browser network capture): 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 { + 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; + const po = + b.provider_options && typeof b.provider_options === "object" && !Array.isArray(b.provider_options) + ? (b.provider_options as Record) + : {}; + + const out: string[] = []; + const seen = new Set(); + 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; + 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).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).content; + if (!Array.isArray(content)) continue; + for (const part of content) { + if (!part || typeof part !== "object") continue; + const p = part as Record; + 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).images; + if (Array.isArray(images) && images.length > 0) { + const first = images[0]; + if (first && typeof first === "object") { + const id = (first as Record).id; + if (typeof id === "string" && id.trim()) return id.trim(); + } + } + const id = (body as Record).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; + /** Reuse the same ARP as generate-async (browser does). */ + arpSessionId?: string; + /** Used for deterministic x-nonce (optional). */ + prompt?: string; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; +}): Promise { + 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); + // One ARP for the whole chain — do not mint a new synthetic id per upload. + const arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(cookieHeader || 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, + cookie: cookieHeader || undefined, + prompt: opts.prompt || "upload", + }), + body: buffer, + }); + + 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; + /** Shared ARP for upload+generate (required for stable Firefly 3P). */ + arpSessionId?: string; + prompt?: string; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; +}): Promise { + 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[] = []; + // One ARP for all uploads in this request (browser reuses the same header). + const arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(opts.sessionCookie); + + 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, + arpSessionId, + 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 { + // 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 { + const accountId = extractAdobeAccountIdFromToken(accessToken); + const headers: Record = { + ...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 { + 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, + opts?: { hadBrowserArp?: boolean } +): string { + const hadArp = opts?.hadBrowserArp === true; + if (!hadArp) { + return ( + `Adobe Firefly ${kind} generation failed (HTTP 408 "system under load", after ${attempts} attempt` + + `${attempts === 1 ? "" : "s"}). Your credential is missing a browser x-arp-session-id / sherlockToken ` + + `(JWT alone almost always 408s even when credits/Limits work). Re-open the Adobe Firefly account and paste ` + + `TWO lines from a SUCCESSFUL firefly-3p.ff.adobe.io generate-async request (F12 → Network): ` + + `(1) Authorization token AFTER "Bearer " (eyJ… JWT), (2) the raw x-arp-session-id header value ` + + `OR Cookie containing sherlockToken. Use the multi-line credential box so both lines are kept.` + ); + } + return ( + `Adobe Firefly ${kind} generation failed (HTTP 408 "system under load", after ${attempts} attempt` + + `${attempts === 1 ? "" : "s"}). JWT was accepted for balance/discovery but colligo rejected the risk session ` + + `(Forter/Arkose). The app auto-rebuilds x-arp-session-id from Cookie (forterToken+arkose+ff_session_guid) and ` + + `rotates ARP on each retry — paste the full firefly.adobe.com Cookie once alongside the JWT so refresh can ` + + `run automatically. If this keeps failing, open firefly.adobe.com, generate one image in-browser, then paste ` + + `a FRESH multi-line credential (JWT + Cookie) once; subsequent generates should not need re-paste.` + ); +} + +export function extractAdobeResultLink( + headers: Headers | Record, + 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; + 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) : {}; + const links = data.links && typeof data.links === "object" ? (data.links as Record) : {}; + const result = links.result; + if (typeof result === "string" && result) return result; + if (result && typeof result === "object") { + const href = (result as Record).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) : {}; + const outputs = Array.isArray(body.outputs) ? body.outputs : []; + if (outputs.length > 0) { + const first = outputs[0] && typeof outputs[0] === "object" ? (outputs[0] as Record) : {}; + const media = + kind === "image" + ? first.image && typeof first.image === "object" + ? (first.image as Record) + : null + : first.video && typeof first.video === "object" + ? (first.video as Record) + : 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; + 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< + | { ok: true; token: string; data: ImsTokenResponse } + | { ok: false; 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 { + ok: false, + 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 { + ok: false, + status: 401, + error: sanitizeErrorMessage( + data?.error_description || data?.error || "IMS response missing access_token" + ), + }; + } + return { ok: true, 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 { + 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.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.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 { + 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).quota && + typeof (block as Record).quota === "object" + ? ((block as Record).quota as Record) + : (block as Record); + 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) : {}; + const totalBlock = readQuotaBlock(root.total); + const credits = + root.credits && typeof root.credits === "object" + ? (root.credits as Record) + : {}; + 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).availableUntil === "string" + ? String((root.total as Record).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 { + 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) : {}; + 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; + const modelId = String(rec.modelId || "").trim(); + if (!modelId) continue; + const versions = + rec.modelVersions && typeof rec.modelVersions === "object" + ? (rec.modelVersions as Record) + : {}; + for (const [ver, spec] of Object.entries(versions)) { + if (!spec || typeof spec !== "object") continue; + const s = spec as Record; + 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 { + 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 { + 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).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; + /** Shared ARP (sid+ark+ftr). Reuse with uploads; do not mint per retry. */ + arpSessionId?: 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 / cookie rebuild (forter+arkose). Only the raw + // credential paste counts as "browser ARP" — never the pure synthetic fallback. + const hadBrowserArp = hasBrowserAdobeArpSession(cookieHeader || sessionCookie); + // Rotate ARP on each 408 retry — reusing a rejected Arkose/Forter session never recovers. + let arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(cookieHeader || sessionCookie); + let submitData: unknown = {}; + let submitHeaders: Headers | Record = new Headers(); + let lastSubmitError = ""; + let sawSystemUnderLoad = false; + let accessToken = opts.accessToken; + + for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { + // Deterministic x-nonce from user_id+prompt; ARP may rotate after 408. + const submitResp = await fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, { + method: "POST", + headers: buildAdobeSubmitHeaders(accessToken, { + arpSessionId, + 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) " + + "plus the firefly.adobe.com Cookie once — the app will auto-refresh ARP after that.", + 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) { + // Mint a fresh ARP for the next attempt (stale sherlockToken is the usual 408 cause). + try { + const { resolveAdobeArpSessionIdSmart, rotateAdobeFireflySessionOnError } = await import( + "./adobeFireflySession.ts" + ); + // Attempt 2: rebuild from cookies; attempt 3+: also try Playwright warm-up once. + // Prefer cookie rebuild over pure synthetic. Playwright warm is opt-in only — + // headless Forter tokens are rejected by colligo (still 408). + const tryBrowser = + attempt >= 3 && process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "1"; + if (cookieHeader) { + const rotated = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: cookieHeader, + arpSessionId, + tokenExpiresAt: 0, + updatedAt: Date.now(), + fingerprint: "inline", + source: "rebuild", + }, + { tryBrowser, log: opts.log } + ); + accessToken = rotated.accessToken || accessToken; + arpSessionId = rotated.arpSessionId; + } else { + arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { rotate: true }); + } + } catch { + arpSessionId = buildAdobeArpSessionId(); + } + 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 (ARP rotated)` + ); + await sleep(delay); + continue; + } + if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("image", attempt, { hadBrowserArp }), + 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, { hadBrowserArp }), + 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; + /** Shared ARP (sid+ark+ftr). Reuse with frame uploads. */ + arpSessionId?: 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 hadBrowserArp = hasBrowserAdobeArpSession(cookieHeader || sessionCookie); + let arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(cookieHeader || sessionCookie); + let submitData: unknown = {}; + let submitHeaders: Headers | Record = new Headers(); + let lastSubmitError = ""; + let sawSystemUnderLoad = false; + let accessToken = opts.accessToken; + + for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { + const submitResp = await fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, { + method: "POST", + headers: buildAdobeSubmitHeaders(accessToken, { + arpSessionId, + 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) " + + "plus the firefly.adobe.com Cookie once — the app will auto-refresh ARP after that.", + 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) { + try { + const { resolveAdobeArpSessionIdSmart, rotateAdobeFireflySessionOnError } = await import( + "./adobeFireflySession.ts" + ); + const tryBrowser = + attempt >= 3 && process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "1"; + if (cookieHeader) { + const rotated = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: cookieHeader, + arpSessionId, + tokenExpiresAt: 0, + updatedAt: Date.now(), + fingerprint: "inline", + source: "rebuild", + }, + { tryBrowser, log: opts.log } + ); + accessToken = rotated.accessToken || accessToken; + arpSessionId = rotated.arpSessionId; + } else { + arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { rotate: true }); + } + } catch { + arpSessionId = buildAdobeArpSessionId(); + } + 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 (ARP rotated)` + ); + await sleep(delay); + continue; + } + if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("video", attempt, { hadBrowserArp }), + 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, { hadBrowserArp }), + 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 }; +} diff --git a/open-sse/services/adobeFireflySession.ts b/open-sse/services/adobeFireflySession.ts new file mode 100644 index 0000000000..18e19c46dd --- /dev/null +++ b/open-sse/services/adobeFireflySession.ts @@ -0,0 +1,652 @@ +/** + * Adobe Firefly durable session manager. + * + * Goal: user pastes JWT and/or browser Cookie once; we keep generate working by: + * 1) Extracting / caching the IMS user JWT (24h typical) + * 2) Rebuilding x-arp-session-id from live cookie pieces (ff_session_guid + arkose + + * forterToken + optional bfp/fpjs) — the SPA's sherlockToken is just that blob + * 3) Optionally warming forter/arkose via Playwright against firefly.adobe.com + * 4) Merging Set-Cookie / jar updates back into the stored cookie string + * 5) Rotating ARP on colligo 408 retries (stale Arkose/Forter is the usual cause) + * + * Firefly.adobe.com page cookies alone still cannot mint a user IMS token (IMS cookies + * live on adobelogin.com). JWT paste once covers that; ARP is what expires every few + * minutes and must be auto-rebuilt. + */ + +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + AdobeFireflyError, + buildAdobeArpSessionId, + extractAdobeArpSessionId, + extractAdobeCookieHeader, + extractAdobeCredentialToken, + isAdobeUserAccessToken, + looksLikeAdobeCookieBlob, + looksLikeAdobeJwt, + decodeAdobeJwtPayload, + resolveAdobeAccessToken, + exchangeAdobeCookieForAccessToken, +} from "./adobeFireflyClient.ts"; + +export interface AdobeFireflySession { + accessToken: string; + cookie: string; + arpSessionId: string; + /** Epoch ms when the IMS token is expected to expire (best-effort). */ + tokenExpiresAt: number; + updatedAt: number; + /** Hash of the original credential paste (cache key). */ + fingerprint: string; + source: "paste" | "ims" | "browser" | "cache" | "rebuild"; +} + +export interface AdobeFireflySessionResolveOpts { + credentials?: { + apiKey?: string; + accessToken?: string; + providerSpecificData?: { cookie?: unknown; access_token?: unknown; accessToken?: unknown } | null; + } | null; + /** Force browser / cookie ARP rebuild (e.g. after HTTP 408). */ + forceRefresh?: boolean; + /** Prefer minting a brand-new ARP (retry path). */ + rotateArp?: boolean; + fetchImpl?: typeof fetch; + log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }; + /** Disable Playwright refresh (tests / hosts without browsers). */ + allowBrowserRefresh?: boolean; +} + +const sessionCache = new Map(); +const browserRefreshInFlight = new Map>(); + +/** ARP / sherlock is short-lived; refresh before this age when cookies can rebuild. */ +const ARP_MAX_AGE_MS = 90_000; +/** Refresh IMS token this many ms before JWT expiry. */ +const JWT_REFRESH_SKEW_MS = 10 * 60_000; +/** Persist sessions under DATA_DIR so restarts keep JWT + last cookie. */ +const SESSION_DIR_NAME = "adobe-firefly-sessions"; + +function dataDir(): string { + return ( + String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() || + join(process.cwd(), ".data") + ); +} + +function sessionFilePath(fingerprint: string): string { + const dir = join(dataDir(), SESSION_DIR_NAME); + try { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + } catch { + /* ignore */ + } + return join(dir, `${fingerprint}.json`); +} + +export function fingerprintAdobeCredential(raw: string): string { + return createHash("sha256").update(String(raw || "").trim()).digest("hex").slice(0, 32); +} + +/** Pull a single cookie value from a Cookie header / paste blob. */ +export function getAdobeCookieValue(cookieOrBlob: string, name: string): string { + const raw = String(cookieOrBlob || ""); + if (!raw || !name) return ""; + const re = new RegExp(`(?:^|[;\\s\\n\\r])${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}=([^;\\s\\n\\r]+)`, "i"); + const m = raw.match(re); + if (!m?.[1]) return ""; + let v = m[1].trim().replace(/^["']|["']$/g, ""); + try { + if (/%[0-9A-Fa-f]{2}/.test(v)) v = decodeURIComponent(v); + } catch { + /* keep */ + } + return v; +} + +/** Normalize Forter token to the live ftr shape ending in -v2_tt. */ +export function normalizeAdobeForterToken(value: string): string { + let f = String(value || "").trim(); + if (!f) return ""; + try { + if (/%[0-9A-Fa-f]{2}/.test(f)) f = decodeURIComponent(f); + } catch { + /* keep */ + } + // Cookie sometimes stores "id,timestamp" (localStorage form) — not usable as ftr. + if (/^[a-f0-9]{32},\d+$/i.test(f)) return ""; + if (f.endsWith("v2") && !f.endsWith("v2_tt")) f = `${f}_tt`; + return f; +} + +/** + * Rebuild x-arp-session-id from browser cookie components. + * Live successful generate-async ARP is base64(JSON({sid, ark, ftr, bfp?, fpjs?})). + * Returns "" when required pieces are missing. + */ +export function buildAdobeArpSessionIdFromCookies( + cookieOrBlob: string, + extras?: { region?: string; bfp?: string; fpjs?: string } +): string { + const blob = String(cookieOrBlob || ""); + if (!blob.trim()) return ""; + + const sid = + getAdobeCookieValue(blob, "ff_session_guid") || + getAdobeCookieValue(blob, "sid") || + ""; + const ark = getAdobeCookieValue(blob, "arkose") || ""; + const ftr = + normalizeAdobeForterToken(getAdobeCookieValue(blob, "forterToken")) || + normalizeAdobeForterToken(getAdobeCookieValue(blob, "forter")) || + ""; + if (!sid || !ark || !ftr) return ""; + + let bfp = extras?.bfp || getAdobeCookieValue(blob, "bfp") || ""; + let fpjsRaw = extras?.fpjs || getAdobeCookieValue(blob, "fpjs") || ""; + if (fpjsRaw) { + try { + if (/%[0-9A-Fa-f]{2}/.test(fpjsRaw)) fpjsRaw = decodeURIComponent(fpjsRaw); + } catch { + /* keep */ + } + } + + // Prefer rebuilding over a stale sherlockToken when cookie pieces exist — + // forterToken timestamps advance as the SPA warms risk SDKs. + const obj: Record = { sid, ark, ftr }; + if (bfp) obj.bfp = bfp; + if (fpjsRaw) obj.fpjs = fpjsRaw; + return Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); +} + +/** True when the blob can rebuild a full ARP without a pasted sherlockToken. */ +export function canRebuildAdobeArpFromCookies(cookieOrBlob: string): boolean { + return Boolean(buildAdobeArpSessionIdFromCookies(cookieOrBlob)); +} + +/** + * Resolve the best ARP for a request: + * 1) force-rotate → mint fresh synthetic (or rebuild if cookies present) + * 2) rebuild from cookie pieces (forter/arkose/sid) — usually fresher than sherlock + * 3) explicit sherlockToken / x-arp-session-id from paste + * 4) synthetic rich ARP + */ +export function resolveAdobeArpSessionIdSmart( + cookieOrBlob?: string, + opts?: { rotate?: boolean } +): string { + const blob = String(cookieOrBlob || ""); + if (opts?.rotate) { + const rebuilt = buildAdobeArpSessionIdFromCookies(blob); + if (rebuilt) return rebuilt; + return buildAdobeArpSessionId(); + } + const rebuilt = buildAdobeArpSessionIdFromCookies(blob); + const extracted = extractAdobeArpSessionId(blob); + // Prefer rebuild when both exist: cookie forter is updated by the SPA more often + // than the frozen sherlockToken the user pasted minutes ago. + if (rebuilt && extracted) { + const rebuiltFtr = (() => { + try { + const j = JSON.parse(Buffer.from(rebuilt + "=".repeat((4 - (rebuilt.length % 4)) % 4), "base64").toString("utf8")) as { ftr?: string }; + return String(j.ftr || ""); + } catch { + return ""; + } + })(); + const extractedFtr = (() => { + try { + const j = JSON.parse(Buffer.from(extracted + "=".repeat((4 - (extracted.length % 4)) % 4), "base64").toString("utf8")) as { ftr?: string }; + return String(j.ftr || ""); + } catch { + return ""; + } + })(); + // Prefer the ARP whose forter timestamp is newer (…_ms__UDF43…). + const ts = (ftr: string) => { + const m = ftr.match(/_(\d{13})__/); + return m ? Number(m[1]) : 0; + }; + if (ts(rebuiltFtr) >= ts(extractedFtr)) return rebuilt; + return extracted; + } + if (rebuilt) return rebuilt; + if (extracted) return extracted; + return buildAdobeArpSessionId(); +} + +/** Merge cookie name=value pairs (new wins). Single-line Cookie header. */ +export function mergeAdobeCookieHeaders(base: string, updates: string): string { + const map = new Map(); + const ingest = (raw: string) => { + for (const part of String(raw || "").split(";")) { + const idx = part.indexOf("="); + if (idx <= 0) continue; + let name = part.slice(0, idx).trim(); + let value = part.slice(idx + 1).trim(); + if (!name) continue; + try { + name = decodeURIComponent(name); + } catch { + /* keep */ + } + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (/[\r\n\0]/.test(value)) continue; + map.set(name, value); + } + }; + ingest(extractAdobeCookieHeader(base) || base); + ingest(extractAdobeCookieHeader(updates) || updates); + return [...map.entries()].map(([k, v]) => `${k}=${v}`).join("; "); +} + +/** Serialize session back into the multi-line credential paste application stores. */ +export function serializeAdobeFireflyCredential(session: Pick): string { + const lines: string[] = []; + if (session.accessToken) lines.push(session.accessToken.trim()); + if (session.arpSessionId) lines.push(session.arpSessionId.trim()); + if (session.cookie) lines.push(session.cookie.trim()); + return lines.join("\n"); +} + +export function estimateAdobeTokenExpiry(accessToken: string): number { + const payload = decodeAdobeJwtPayload(accessToken); + if (!payload) return Date.now() + 60 * 60_000; + const created = Number(payload.created_at || 0); + const expiresIn = Number(payload.expires_in || 0); + if (created > 0 && expiresIn > 0) return created + expiresIn; + // Fallback: treat as 20h from now if claims missing + return Date.now() + 20 * 60 * 60_000; +} + +function diskSessionsEnabled(): boolean { + // Unit tests and explicit opt-out skip durable disk cache (avoids sticky IMS skips). + if (process.env.ADOBE_FIREFLY_SESSION_DISK === "0") return false; + if (process.env.NODE_ENV === "test") return false; + if (process.env.VITEST || process.env.NODE_TEST_CONTEXT) return false; + return true; +} + +function loadDiskSession(fingerprint: string): AdobeFireflySession | null { + if (!diskSessionsEnabled()) return null; + try { + const path = sessionFilePath(fingerprint); + if (!existsSync(path)) return null; + const raw = readFileSync(path, "utf8"); + const obj = JSON.parse(raw) as AdobeFireflySession; + if (!obj?.accessToken || !isAdobeUserAccessToken(obj.accessToken)) return null; + return { ...obj, fingerprint, source: "cache" }; + } catch { + return null; + } +} + +function saveDiskSession(session: AdobeFireflySession): void { + if (!diskSessionsEnabled()) return; + try { + const path = sessionFilePath(session.fingerprint); + writeFileSync(path, JSON.stringify(session, null, 2), "utf8"); + } catch { + /* best-effort */ + } +} + +function collectCredentialBlobs( + credentials: AdobeFireflySessionResolveOpts["credentials"] +): string[] { + const out: string[] = []; + const push = (v: unknown) => { + if (typeof v === "string" && v.trim()) out.push(v.trim()); + }; + push(credentials?.apiKey); + push(credentials?.accessToken); + push(credentials?.providerSpecificData?.cookie); + push(credentials?.providerSpecificData?.access_token); + push(credentials?.providerSpecificData?.accessToken); + return out; +} + +/** + * Optional Playwright warm-up: open firefly.adobe.com with the user's cookies so + * Forter/Arkose mint fresh tokens, then rebuild ARP + merge the jar. + * Never throws — returns null when Playwright is unavailable or warm-up fails. + */ +export async function refreshAdobeSessionViaBrowser( + session: AdobeFireflySession, + log?: AdobeFireflySessionResolveOpts["log"] +): Promise { + if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") return null; + + let chromium: typeof import("playwright").chromium | null = null; + try { + const pw = await import("playwright"); + chromium = pw.chromium; + } catch { + log?.warn?.("ADOBE-FIREFLY", "Playwright not available — skip browser ARP refresh"); + return null; + } + + let browser: import("playwright").Browser | null = null; + try { + browser = await chromium.launch({ + headless: true, + args: ["--disable-blink-features=AutomationControlled"], + }); + const context = await browser.newContext({ + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36", + locale: "en-US", + viewport: { width: 1280, height: 800 }, + }); + await context.addInitScript(() => { + Object.defineProperty(navigator, "webdriver", { get: () => undefined }); + }); + + const cookieHeader = extractAdobeCookieHeader(session.cookie) || session.cookie; + for (const part of cookieHeader.split(";")) { + const idx = part.indexOf("="); + if (idx <= 0) continue; + let name = part.slice(0, idx).trim(); + let value = part.slice(idx + 1).trim(); + try { + name = decodeURIComponent(name); + } catch { + /* keep */ + } + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!name || /[\r\n\0]/.test(value)) continue; + try { + await context.addCookies([ + { name, value, domain: ".adobe.com", path: "/", secure: true, sameSite: "Lax" }, + ]); + } catch { + try { + await context.addCookies([ + { name, value, url: "https://firefly.adobe.com/", path: "/", secure: true }, + ]); + } catch { + /* skip invalid cookie */ + } + } + } + + const page = await context.newPage(); + await page.goto("https://firefly.adobe.com/generate/image", { + waitUntil: "domcontentloaded", + timeout: 45_000, + }); + + // Inject stored user JWT so SPA API calls (if any) use AdobeID, not guest. + if (session.accessToken) { + await page + .evaluate((token) => { + for (const key of Object.keys(sessionStorage)) { + if (!key.includes("adobeid_ims_access_token/clio-playground-web")) continue; + let obj: Record = {}; + try { + obj = JSON.parse(sessionStorage.getItem(key) || "{}") as Record; + } catch { + obj = {}; + } + obj.tokenValue = token; + obj.access_token = token; + obj.valid = true; + obj.expire = Date.now() + 20 * 3600 * 1000; + obj.expires_in = 86400000; + obj.client_id = "clio-playground-web"; + sessionStorage.setItem(key, JSON.stringify(obj)); + } + }, session.accessToken) + .catch(() => {}); + } + + // Wait for Forter / Arkose warm-up. + await page.waitForTimeout(6_000); + + const jar = await context.cookies(); + const jarHeader = jar.map((c) => `${c.name}=${c.value}`).join("; "); + const ls = await page + .evaluate(() => ({ + bfp: localStorage.getItem("bfp") || "", + fpjs: localStorage.getItem("fpjs") || "", + forter: localStorage.getItem("forterToken") || "", + })) + .catch(() => ({ bfp: "", fpjs: "", forter: "" })); + + const mergedCookie = mergeAdobeCookieHeaders(session.cookie, jarHeader); + // Ensure bfp/fpjs land in the cookie blob for rebuild if only in localStorage + let blobForArp = mergedCookie; + if (ls.bfp && !getAdobeCookieValue(blobForArp, "bfp")) { + blobForArp = mergeAdobeCookieHeaders(blobForArp, `bfp=${ls.bfp}`); + } + if (ls.fpjs && !getAdobeCookieValue(blobForArp, "fpjs")) { + blobForArp = mergeAdobeCookieHeaders(blobForArp, `fpjs=${ls.fpjs}`); + } + + const arp = + buildAdobeArpSessionIdFromCookies(blobForArp, { + bfp: ls.bfp || undefined, + fpjs: ls.fpjs || undefined, + }) || + extractAdobeArpSessionId(blobForArp) || + buildAdobeArpSessionId(); + + const next: AdobeFireflySession = { + ...session, + cookie: extractAdobeCookieHeader(blobForArp) || blobForArp, + arpSessionId: arp, + updatedAt: Date.now(), + source: "browser", + }; + sessionCache.set(session.fingerprint, next); + saveDiskSession(next); + log?.info?.("ADOBE-FIREFLY", "browser session warm-up refreshed ARP/cookie"); + return next; + } catch (err) { + log?.warn?.( + "ADOBE-FIREFLY", + `browser ARP refresh failed: ${err instanceof Error ? err.message : String(err)}` + ); + return null; + } finally { + if (browser) { + try { + await browser.close(); + } catch { + /* ignore */ + } + } + } +} + +/** + * Resolve a durable Firefly session from stored credentials. + * Caches in memory + DATA_DIR; rebuilds ARP from cookies; optionally warms via Playwright. + */ +export async function ensureAdobeFireflySession( + opts: AdobeFireflySessionResolveOpts +): Promise { + const blobs = collectCredentialBlobs(opts.credentials); + if (blobs.length === 0) { + throw new AdobeFireflyError( + "Adobe Firefly credentials missing. Paste the IMS JWT (Authorization: Bearer on firefly-3p) " + + "and ideally the full firefly.adobe.com Cookie (with sherlockToken / forterToken / arkose) once.", + 401, + "missing_credentials" + ); + } + + const joined = blobs.join("\n"); + const fingerprint = fingerprintAdobeCredential(joined); + + // forceRefresh / rotate always drop in-memory cache for this fingerprint + if (opts.forceRefresh) sessionCache.delete(fingerprint); + + const cached = sessionCache.get(fingerprint) || loadDiskSession(fingerprint); + if (cached && !opts.forceRefresh) sessionCache.set(fingerprint, cached); + + const fetchImpl = opts.fetchImpl || fetch; + let accessToken = ""; + let cookie = ""; + let pasteHadUserJwt = false; + + // Prefer JWT from the live paste (authoritative for this request) + for (const b of blobs) { + const tok = extractAdobeCredentialToken(b); + if (looksLikeAdobeJwt(tok) && isAdobeUserAccessToken(tok)) { + accessToken = tok; + pasteHadUserJwt = true; + break; + } + } + // Cookie-only paste: use short-lived memory cache JWT only (not a stale disk token alone) + if ( + !accessToken && + cached?.accessToken && + isAdobeUserAccessToken(cached.accessToken) && + sessionCache.has(fingerprint) && + Date.now() - cached.updatedAt < 30 * 60_000 + ) { + accessToken = cached.accessToken; + } + + // Cookie blob + for (const b of blobs) { + const c = extractAdobeCookieHeader(b); + if (c) { + cookie = c; + break; + } + if (looksLikeAdobeCookieBlob(b)) { + cookie = extractAdobeCookieHeader(b) || b; + break; + } + } + if (!cookie && cached?.cookie) cookie = cached.cookie; + if (cached?.cookie && cookie) cookie = mergeAdobeCookieHeaders(cached.cookie, cookie); + + // Cookie-only or near-expiry JWT → try IMS exchange (needs real IMS cookies on adobelogin.com) + const tokenExpiresAt = accessToken ? estimateAdobeTokenExpiry(accessToken) : 0; + const needJwtRefresh = + !accessToken || + !pasteHadUserJwt || + (tokenExpiresAt > 0 && tokenExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS); + + if (needJwtRefresh && cookie) { + try { + const refreshed = await exchangeAdobeCookieForAccessToken(cookie, fetchImpl); + if (isAdobeUserAccessToken(refreshed)) { + accessToken = refreshed; + opts.log?.info?.("ADOBE-FIREFLY", "IMS cookie exchange produced a user JWT"); + } + } catch { + // Fall through — pure firefly cookies still yield guest-only; keep existing JWT. + } + } + + if (!accessToken) { + // Last resort: full resolve path (throws guest_token with help text) + accessToken = await resolveAdobeAccessToken(opts.credentials, fetchImpl); + } + + const arpAge = cached ? Date.now() - cached.updatedAt : Number.POSITIVE_INFINITY; + const shouldRotate = + Boolean(opts.rotateArp) || + Boolean(opts.forceRefresh) || + arpAge > ARP_MAX_AGE_MS || + !cached?.arpSessionId; + + let arpSessionId = shouldRotate + ? resolveAdobeArpSessionIdSmart(cookie || joined, { rotate: true }) + : cached?.arpSessionId || resolveAdobeArpSessionIdSmart(cookie || joined); + + let session: AdobeFireflySession = { + accessToken, + cookie: cookie || extractAdobeCookieHeader(joined) || "", + arpSessionId, + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), + updatedAt: Date.now(), + fingerprint, + source: shouldRotate ? "rebuild" : cached?.source || "paste", + }; + + // Browser warm-up is OFF by default: headless Forter/Arkose is rejected by colligo (408). + // Enable only with ADOBE_FIREFLY_BROWSER_REFRESH=1 (and forceRefresh / missing ARP pieces). + const allowBrowser = + opts.allowBrowserRefresh === true || process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "1"; + const needsBrowser = + allowBrowser && + Boolean(session.cookie) && + (opts.forceRefresh || + (!canRebuildAdobeArpFromCookies(session.cookie) && !extractAdobeArpSessionId(session.cookie))); + + if (needsBrowser && session.cookie) { + const key = fingerprint; + let inflight = browserRefreshInFlight.get(key); + if (!inflight) { + inflight = refreshAdobeSessionViaBrowser(session, opts.log).finally(() => { + browserRefreshInFlight.delete(key); + }); + browserRefreshInFlight.set(key, inflight); + } + const warmed = await inflight; + if (warmed) session = warmed; + } + + // Final ARP if still empty + if (!session.arpSessionId) { + session.arpSessionId = resolveAdobeArpSessionIdSmart(session.cookie || joined, { + rotate: true, + }); + } + + sessionCache.set(fingerprint, session); + saveDiskSession(session); + return session; +} + +/** + * After a colligo 408: rotate ARP (and optionally warm browser), return next session. + */ +export async function rotateAdobeFireflySessionOnError( + session: AdobeFireflySession, + opts?: { + tryBrowser?: boolean; + log?: AdobeFireflySessionResolveOpts["log"]; + } +): Promise { + let next: AdobeFireflySession = { + ...session, + arpSessionId: resolveAdobeArpSessionIdSmart(session.cookie, { rotate: true }), + updatedAt: Date.now(), + source: "rebuild", + }; + + if (opts?.tryBrowser && session.cookie && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0") { + const warmed = await refreshAdobeSessionViaBrowser(next, opts.log); + if (warmed) next = warmed; + } + + // Always mint a unique synthetic ARP if rebuild produced the same string + if (next.arpSessionId === session.arpSessionId) { + next.arpSessionId = buildAdobeArpSessionId(); + next.source = "rebuild"; + } + + sessionCache.set(session.fingerprint, next); + saveDiskSession(next); + return next; +} + +/** Test helper — clear in-memory session cache. */ +export function __resetAdobeFireflySessionCacheForTests(): void { + sessionCache.clear(); + browserRefreshInFlight.clear(); +} diff --git a/tests/unit/adobe-firefly.test.ts b/tests/unit/adobe-firefly.test.ts index 4caa9b720a..f7dff8c25d 100644 --- a/tests/unit/adobe-firefly.test.ts +++ b/tests/unit/adobe-firefly.test.ts @@ -190,48 +190,6 @@ test("buildAdobeImagePayload produces nano and gpt-image shapes", () => { assert.equal((gpt.modelSpecificPayload as Record).size, "auto"); assert.equal(gpt.size, undefined); assert.equal(gpt.outputResolution, undefined); - - // Missing / auto quality → maximal detail (5). Explicit low/medium still honored. - const gptDefault = buildAdobeImagePayload({ - prompt: "a dog", - aspectRatio: "1:1", - outputResolution: "1K", - modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-2"], - }); - assert.equal((gptDefault.generationSettings as Record).detailLevel, 5); - const gptAuto = buildAdobeImagePayload({ - prompt: "a dog", - aspectRatio: "1:1", - outputResolution: "1K", - modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"], - quality: "auto", - }); - assert.equal((gptAuto.generationSettings as Record).detailLevel, 5); - const gptLow = buildAdobeImagePayload({ - prompt: "a dog", - aspectRatio: "1:1", - outputResolution: "1K", - modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"], - quality: "low", - }); - assert.equal((gptLow.generationSettings as Record).detailLevel, 1); - const gptMedium = buildAdobeImagePayload({ - prompt: "a dog", - aspectRatio: "1:1", - outputResolution: "1K", - modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"], - quality: "medium", - }); - assert.equal((gptMedium.generationSettings as Record).detailLevel, 3); - // Firefly UI resolution tiers map onto the same detailLevel scale. - const gpt4k = buildAdobeImagePayload({ - prompt: "a dog", - aspectRatio: "1:1", - outputResolution: "1K", - modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"], - quality: "4k", - }); - assert.equal((gpt4k.generationSettings as Record).detailLevel, 5); }); test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_images capture", () => { @@ -250,7 +208,10 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image { id: "2a4f1025-e0dc-4671-a11a-7dfd3c07bd94", usage: "general" }, { id: "84c11d1a-e798-4300-a63e-c06504ca2068", usage: "general" }, ]); - assert.equal((nano.generationMetadata as Record).module, "text2image"); + assert.equal( + (nano.generationMetadata as Record).module, + "text2image" + ); const gpt = buildAdobeImagePayload({ prompt: "edit me", @@ -262,7 +223,10 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image assert.deepEqual(gpt.referenceBlobs, [ { id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "subject" }, ]); - assert.equal((gpt.generationMetadata as Record).module, "image2image"); + assert.equal( + (gpt.generationMetadata as Record).module, + "image2image" + ); }); test("extractAdobeSourceImageSources reads Media page image fields", () => { @@ -316,10 +280,10 @@ test("resolveAdobeSourceImageIds uploads data URLs then returns blob ids", async const headers = init?.headers as Record; assert.match(String(headers["content-type"] || headers["Content-Type"] || ""), /image\//); assert.ok(init?.body); - return new Response(JSON.stringify({ images: [{ id: `blob-${uploadCalls}` }] }), { - status: 200, - headers: { "content-type": "application/json" }, - }); + return new Response( + JSON.stringify({ images: [{ id: `blob-${uploadCalls}` }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); } throw new Error(`unexpected fetch ${u}`); }; @@ -423,7 +387,8 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => { type: "access_token", client_id: "clio-playground-web", }) - ).toString("base64url"); + ) + .toString("base64url"); const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); const token = `${header}.${payload}.${"x".repeat(40)}`; // Pad token length for looksLikeAdobeJwt (>=80) @@ -452,11 +417,13 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => { assert.equal(isValidAdobeArpSessionId(arp), true); const decoded = JSON.parse(Buffer.from(arp, "base64").toString("utf8")); assert.ok(decoded.sid); - // Live SPA shape (2026-07): sid + ark (Arkose) + ftr with __UDF43-m4_31ck magic + // Live SPA shape (2026-07): sid + ark (Arkose) + ftr + bfp + fpjs assert.ok(decoded.ark, "synthetic ARP must include ark field"); assert.match(String(decoded.ark), /pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C/); assert.match(String(decoded.ftr), new RegExp(ADOBE_FIREFLY_FTR_MAGIC)); assert.match(String(decoded.ftr), /-v2_tt$/); + assert.ok(decoded.bfp, "synthetic ARP must include bfp (browser fingerprint)"); + assert.ok(decoded.fpjs, "synthetic ARP must include fpjs"); // Headers: deterministic nonce + always ARP (synthetic when none provided) const h = buildAdobeSubmitHeaders(token, { prompt }); @@ -482,7 +449,8 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => { }); test("normalizeAdobePollUrl rewrites firefly-epo jobs/result to BKS", () => { - const raw = "https://firefly-epo855232.adobe.io/jobs/result/4ae9fd2a-0864-46dd-9834-cfc16e91faa6"; + const raw = + "https://firefly-epo855232.adobe.io/jobs/result/4ae9fd2a-0864-46dd-9834-cfc16e91faa6"; const out = normalizeAdobePollUrl(raw); assert.match(out, /^https:\/\/bks-epo8552\.adobe\.io\/v2\/jobs\/result\/4ae9fd2a/); assert.match(out, /host=firefly-epo855232\.adobe\.io/); @@ -564,9 +532,9 @@ test("fallback catalog has image and video entries from get_models capture", () test("extractAdobeAccountIdFromToken reads user_id claim", () => { // {"user_id":"0EB@AdobeID"} base64url - const payload = Buffer.from( - JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token" }) - ).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token" })).toString( + "base64url" + ); const jwt = `eyJhbGciOiJub25lIn0.${payload}.sig`; assert.equal(extractAdobeAccountIdFromToken(jwt), "0EB@AdobeID"); }); @@ -751,19 +719,13 @@ test("guest JWT without AdobeID is detected", () => { const emptyPayload = Buffer.from("{}").toString("base64url"); const guestJwt = `eyJhbGciOiJub25lIn0.${emptyPayload}.sig`; // Pad to lookLikeAdobeJwt length if needed - const longGuest = - `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url")}.` + - "x".repeat(40); + const longGuest = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url")}.` + "x".repeat(40); assert.equal(isAdobeGuestAccessToken(longGuest), true); const userJwt = `eyJhbGciOiJSUzI1NiJ9.` + - Buffer.from( - JSON.stringify({ - user_id: "0EB@AdobeID", - type: "access_token", - client_id: "clio-playground-web", - }) - ).toString("base64url") + + Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" })).toString( + "base64url" + ) + `.` + "y".repeat(40); assert.equal(isAdobeGuestAccessToken(userJwt), false); @@ -835,18 +797,89 @@ test("extractAdobeArpSessionId recovers JWT+ARP joined by space (PasswordBox man ); assert.match( formatAdobeSystemUnderLoadError("image", 2, { hadBrowserArp: true }), - /fresh successful generate-async/i + /auto-rebuilds x-arp-session-id|Cookie once|Forter\/Arkose/i ); }); -test("isAdobeTransientSubmitError detects 408 system under load", () => { - assert.equal( - isAdobeTransientSubmitError( - 408, - '{"error_code":"timeout_error","message":"system under load"}' - ), - true +test("extractAdobeArpSessionId does not pick aux_sid over sherlockToken", async () => { + const { extractAdobeArpSessionId, isValidAdobeArpSessionId } = await import( + "../../open-sse/services/adobeFireflyClient.ts" ); + const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts"); + const realArp = Buffer.from( + JSON.stringify({ + sid: "bdf37b8a-117f-467d-a737-7792932d98b4", + ark: "10618c58b8d3cd588.3119555905|r=eu-west-1|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C", + ftr: `aa_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1-v2_tt`, + }), + "utf8" + ).toString("base64"); + // Long aux_sid must NOT win ranking (this was the live 408 root cause) + const aux = "A" + "x".repeat(780); + const cookie = `ff_session_guid=bdf37b8a-117f-467d-a737-7792932d98b4; sherlockToken=${realArp}; aux_sid=${aux}; forterToken=x`; + const got = extractAdobeArpSessionId(cookie); + assert.equal(got, realArp); + assert.equal(isValidAdobeArpSessionId(`aux_sid=${aux}`), false); + assert.equal(isValidAdobeArpSessionId(realArp), true); +}); + +test("rebuild ARP from cookie components (forter+arkose+sid)", async () => { + const { + buildAdobeArpSessionIdFromCookies, + canRebuildAdobeArpFromCookies, + mergeAdobeCookieHeaders, + resolveAdobeArpSessionIdSmart, + serializeAdobeFireflyCredential, + normalizeAdobeForterToken, + } = await import("../../open-sse/services/adobeFireflySession.ts"); + const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts"); + + const ftr = `aab9dc9eb48f4ee1916428649f908f7d_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1092-v2_tt`; + const ark = + "87818c58b11662a57.5347274705|r=eu-west-1|meta=3|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40"; + const cookie = + `ff_session_guid=bdf37b8a-117f-467d-a737-7792932d98b4; arkose=${ark}; ` + + `forterToken=${encodeURIComponent(ftr)}; bfp=58ef2899-b1c4-42e4-9625-ae265e1b4994; ` + + `fpjs=${encodeURIComponent(JSON.stringify({ requestId: "1.x", visitorId: "v" }))}`; + + assert.equal(canRebuildAdobeArpFromCookies(cookie), true); + const arp = buildAdobeArpSessionIdFromCookies(cookie); + assert.ok(arp.length > 40); + const decoded = JSON.parse(Buffer.from(arp, "base64").toString("utf8")); + assert.equal(decoded.sid, "bdf37b8a-117f-467d-a737-7792932d98b4"); + assert.equal(decoded.ark, ark); + assert.ok(String(decoded.ftr).includes(ADOBE_FIREFLY_FTR_MAGIC)); + assert.equal(decoded.bfp, "58ef2899-b1c4-42e4-9625-ae265e1b4994"); + assert.ok(decoded.fpjs); + + // forter without _tt suffix gets normalized + assert.match(normalizeAdobeForterToken("abc_1__UDF43-m4_31ck_x=-1-v2"), /-v2_tt$/); + // localStorage comma form is rejected + assert.equal(normalizeAdobeForterToken("aab9dc9eb48f4ee1916428649f908f7d,1784986682306"), ""); + + const merged = mergeAdobeCookieHeaders( + "ff_session_guid=old; arkose=a1", + "arkose=a2; forterToken=newftr" + ); + assert.match(merged, /arkose=a2/); + assert.match(merged, /forterToken=newftr/); + assert.match(merged, /ff_session_guid=old/); + + // Smart resolve prefers rebuild when cookie pieces present + const smart = resolveAdobeArpSessionIdSmart(cookie); + assert.equal(smart, arp); + + const ser = serializeAdobeFireflyCredential({ + accessToken: "eyJ.token.sig", + cookie, + arpSessionId: arp, + }); + assert.match(ser, /eyJ\.token\.sig/); + assert.match(ser, /ff_session_guid=/); +}); + +test("isAdobeTransientSubmitError detects 408 system under load", () => { + assert.equal(isAdobeTransientSubmitError(408, '{"error_code":"timeout_error","message":"system under load"}'), true); assert.equal(isAdobeTransientSubmitError(429, "rate"), true); assert.equal(isAdobeTransientSubmitError(400, "bad request"), false); assert.ok(generateAdobeNonce().length === 64); @@ -894,7 +927,11 @@ test("image submit retries on 408 then succeeds", async () => { if (submits < 3) { return jsonResponse(408, { error_code: "timeout_error", message: "system under load" }); } - return jsonResponse(200, { links: { result: { href: "https://poll.example/job/r1" } } }, {}); + return jsonResponse( + 200, + { links: { result: { href: "https://poll.example/job/r1" } } }, + {} + ); } if (u.includes("poll.example")) { return jsonResponse(200, { @@ -916,14 +953,15 @@ test("image submit retries on 408 then succeeds", async () => { }); test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async () => { + const { __resetAdobeFireflySessionCacheForTests } = await import( + "../../open-sse/services/adobeFireflySession.ts" + ); + __resetAdobeFireflySessionCacheForTests(); + const userTok = `eyJhbGciOiJSUzI1NiJ9.` + Buffer.from( - JSON.stringify({ - user_id: "0EB@AdobeID", - type: "access_token", - client_id: "clio-playground-web", - }) + JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" }) ).toString("base64url") + `.` + "s".repeat(40); @@ -945,7 +983,11 @@ test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async () ? (init.headers as Record).Authorization : auth; assert.equal(headerAuth, `Bearer ${userTok}`); - return jsonResponse(200, {}, { "x-override-status-link": "https://poll.example/job/c1" }); + return jsonResponse( + 200, + {}, + { "x-override-status-link": "https://poll.example/job/c1" } + ); } if (String(url).includes("poll.example")) { return jsonResponse(200, {