diff --git a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts index d4f6eb6da4..fb54943024 100644 --- a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts +++ b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts @@ -15,14 +15,16 @@ import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGenerat import { AdobeFireflyError, adobeFireflyGenerateImage, - adobeFireflyImageTimeoutMs, resolveAdobeAccessToken, - resolveAdobeSourceImageReferences, + resolveAdobeArpSessionId, + resolveAdobeSourceImageIds, resolveAdobeImageModel, } from "../../../services/adobeFireflyClient.ts"; -import { getAdobeReferenceUploadLimit } from "../../../services/adobeFireflyModels.ts"; -import { isAdobeFireflyUpscaleModel } from "../../../services/adobeFireflyUpscale.ts"; -import { handleAdobeFireflyImageUpscale } from "../../imageUpscale/adobeFirefly.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, @@ -56,19 +58,6 @@ export async function handleAdobeFireflyImageGeneration({ }) { const startTime = Date.now(); const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; - - // Topaz upscalers share adobe-firefly but use /v2/3p-images/upsample (no prompt). - if (isAdobeFireflyUpscaleModel(model)) { - return handleAdobeFireflyImageUpscale({ - model, - provider, - body: body as Record, - credentials, - log, - fetchImpl, - }); - } - if (!prompt) { return saveImageErrorResult({ provider, @@ -81,6 +70,7 @@ export async function handleAdobeFireflyImageGeneration({ try { const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl); + const timeoutMs = normalizePositiveNumber(body.timeout_ms, 180_000); const seed = typeof body.seed === "number" ? body.seed @@ -99,33 +89,28 @@ export async function handleAdobeFireflyImageGeneration({ ? credentials.accessToken : undefined); - const { spec } = resolveAdobeImageModel(model); - const references = await resolveAdobeSourceImageReferences({ + // 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: getAdobeReferenceUploadLimit(spec, "image"), + max: maxRefs, sessionCookie, + arpSessionId, prompt, fetchImpl, log, }); - const explicitTimeout = - typeof body.timeout_ms === "number" - ? body.timeout_ms - : typeof body.timeout_ms === "string" && body.timeout_ms.trim() - ? Number(body.timeout_ms) - : undefined; - const timeoutMs = adobeFireflyImageTimeoutMs({ - timeoutMs: explicitTimeout, - refCount: references.length, - }); - log?.info?.( "IMAGE", `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + - (references.length ? ` | refs: ${references.length}` : "") + - ` | pollTimeoutMs=${timeoutMs}` + (sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") ); const result = await adobeFireflyGenerateImage({ @@ -137,8 +122,9 @@ export async function handleAdobeFireflyImageGeneration({ quality: body.quality, seed: Number.isFinite(seed as number) ? (seed as number) : undefined, negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined, - references: references.length ? references : undefined, + sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, sessionCookie, + arpSessionId, timeoutMs, fetchImpl, log, diff --git a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts index b6f267e0bc..d114790747 100644 --- a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts +++ b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts @@ -10,10 +10,10 @@ import { AdobeFireflyError, adobeFireflyGenerateVideo, resolveAdobeAccessToken, - resolveAdobeSourceImageReferences, + resolveAdobeArpSessionId, + resolveAdobeSourceImageIds, resolveAdobeVideoModel, } from "../../services/adobeFireflyClient.ts"; -import { getAdobeReferenceUploadLimit } from "../../services/adobeFireflyModels.ts"; function normalizePositiveNumber(value: unknown, fallback: number): number { const n = Number(value); @@ -65,12 +65,17 @@ export async function handleAdobeFireflyVideoGeneration({ ? credentials.accessToken : undefined); - const { spec } = resolveAdobeVideoModel(String(model)); - const references = await resolveAdobeSourceImageReferences({ + // 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, - max: getAdobeReferenceUploadLimit(spec, "image"), + max: maxFrames, sessionCookie, + arpSessionId, prompt, fetchImpl, log, @@ -79,7 +84,7 @@ export async function handleAdobeFireflyVideoGeneration({ log?.info?.( "VIDEO", `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + - (references.length ? ` | refs: ${references.length}` : "") + (sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "") ); const result = await adobeFireflyGenerateVideo({ @@ -99,8 +104,9 @@ export async function handleAdobeFireflyVideoGeneration({ ? body.negativePrompt : undefined, generateAudio: body.generate_audio !== false && body.generateAudio !== false, - references: references.length ? references : undefined, + sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, sessionCookie, + arpSessionId, timeoutMs, fetchImpl, log, diff --git a/open-sse/services/adobeFireflyClient.ts b/open-sse/services/adobeFireflyClient.ts index d2bd3e3dc8..762b341bb9 100644 --- a/open-sse/services/adobeFireflyClient.ts +++ b/open-sse/services/adobeFireflyClient.ts @@ -24,22 +24,6 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { resolvePublicCred } from "../utils/publicCreds.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; -import { - ADOBE_FIREFLY_IMAGE_MODELS as DISCOVERED_IMAGE_MODELS, - ADOBE_FIREFLY_VIDEO_MODELS as DISCOVERED_VIDEO_MODELS, - parseAdobeModelsDiscovery, - resolveAdobeImageModel as resolveDiscoveredImageModel, - resolveAdobeVideoModel as resolveDiscoveredVideoModel, - type AdobeFireflyCatalogModel as DiscoveredCatalogModel, - type AdobeFireflyImageModelSpec as DiscoveredImageModelSpec, - type AdobeFireflyVideoModelSpec as DiscoveredVideoModelSpec, -} from "./adobeFireflyModels.ts"; - -export { - DISCOVERED_IMAGE_MODELS as ADOBE_FIREFLY_IMAGE_MODELS, - DISCOVERED_VIDEO_MODELS as ADOBE_FIREFLY_VIDEO_MODELS, - parseAdobeModelsDiscovery, -}; export const ADOBE_FIREFLY_IMAGE_SUBMIT_URL = "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async"; @@ -62,35 +46,171 @@ 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; -/** - * Poll budget for image generate-async. Multi-ref gpt-image / nano jobs commonly - * exceed 3 minutes (upload + colligo + render at detailLevel 5). 180s was the - * previous default and produced widespread 504s on listing assets with screenshots. - */ -export const DEFAULT_IMAGE_TIMEOUT_MS = 300_000; +const DEFAULT_IMAGE_TIMEOUT_MS = 180_000; const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; -/** Extra poll budget per uploaded reference blob (large screenshots + image2image). */ -export const ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS = 60_000; -export const ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS = 600_000; const FIREFLY_ORIGIN = "https://firefly.adobe.com"; const FIREFLY_REFERER = "https://firefly.adobe.com/"; -/** - * Resolve poll timeout: explicit body.timeout_ms wins; else base + per-ref budget. - * Covers multi-screenshot listing jobs without unbounded waits. - */ -export function adobeFireflyImageTimeoutMs(opts?: { - timeoutMs?: number; - refCount?: number; -}): number { - const explicit = Number(opts?.timeoutMs); - if (Number.isFinite(explicit) && explicit > 0) { - return Math.min(ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS, Math.floor(explicit)); - } - const refs = Math.max(0, Math.floor(Number(opts?.refCount) || 0)); - const budget = DEFAULT_IMAGE_TIMEOUT_MS + refs * ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS; - return Math.min(ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS, budget); +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 }, @@ -450,32 +570,112 @@ export function normalizeAdobeOutputResolution( return "2K"; } -export function resolveAdobeImageModel( - model: string -): ReturnType { - try { - return resolveDiscoveredImageModel(model); - } catch (error) { - throw new AdobeFireflyError( - error instanceof Error ? error.message : "Unknown Adobe Firefly image model", - 400, - "unknown_model" - ); +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 -): ReturnType { - try { - return resolveDiscoveredVideoModel(model); - } catch (error) { - throw new AdobeFireflyError( - error instanceof Error ? error.message : "Unknown Adobe Firefly video model", - 400, - "unknown_model" - ); +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"] }; } /** @@ -494,129 +694,19 @@ function gptDetailLevel(quality: unknown): number { return 5; } -export interface AdobeFireflyReferenceBlob { - id: string; - mediaType?: string; - usage?: string; - order?: number; -} - -function defaultAdobeReferenceUsage( - model: DiscoveredCatalogModel, - mediaType: string -): string | null { - const supported = model.capabilities.referenceInputs - .filter((capability) => capability.mediaType === mediaType) - .map((capability) => capability.usageType); - const priority = - model.modality === "video" - ? ["frame", "element", "style", "subject", "source", "general"] - : ["source", "general", "style", "element", "subject"]; - return priority.find((usage) => supported.includes(usage)) || supported[0] || null; -} - -/** Validate roles/counts against the resolved discovery schema and produce wire blobs. */ -export function normalizeAdobeReferenceBlobs( - model: DiscoveredCatalogModel, - references: AdobeFireflyReferenceBlob[] = [], - fallbackImageIds: string[] = [] -): Array> { - const requested = references.length - ? references - : fallbackImageIds.map((id) => ({ id, mediaType: "image" })); - const capabilities = model.capabilities.referenceInputs; - const totalLimit = model.capabilities.maxReferenceItems; - if (totalLimit !== null && requested.length > totalLimit) { - throw new AdobeFireflyError( - `${model.name} accepts at most ${totalLimit} total reference item(s)`, - 400, - "invalid_reference_count" - ); - } - - const counts = new Map(); - const normalized = requested.map((reference) => { - const id = String(reference.id || "").trim(); - if (!id) { - throw new AdobeFireflyError( - "Adobe Firefly reference id is required", - 400, - "invalid_reference" - ); - } - const mediaType = String(reference.mediaType || "image") - .trim() - .toLowerCase(); - const usage = - String(reference.usage || "") - .trim() - .toLowerCase() || defaultAdobeReferenceUsage(model, mediaType); - const capability = capabilities.find( - (candidate) => candidate.mediaType === mediaType && candidate.usageType === usage - ); - if (!usage || !capability) { - const allowed = capabilities - .filter((candidate) => candidate.mediaType === mediaType) - .map((candidate) => candidate.usageType) - .join(", "); - throw new AdobeFireflyError( - `${model.name} does not support ${mediaType} references with usage '${usage || "unspecified"}'` + - (allowed ? ` (allowed: ${allowed})` : ""), - 400, - "invalid_reference_usage" - ); - } - const key = `${mediaType}:${usage}`; - const count = (counts.get(key) || 0) + 1; - counts.set(key, count); - if (capability.maxItems !== null && count > capability.maxItems) { - throw new AdobeFireflyError( - `${model.name} accepts at most ${capability.maxItems} ${usage} ${mediaType} reference(s)`, - 400, - "invalid_reference_count" - ); - } - return { - id, - usage, - ...(usage === "frame" ? { order: reference.order ?? count } : {}), - }; - }); - - for (const capability of capabilities) { - if (capability.minItems <= 0) continue; - const key = `${capability.mediaType}:${capability.usageType}`; - const count = counts.get(key) || 0; - if (count < capability.minItems) { - throw new AdobeFireflyError( - `${model.name} requires at least ${capability.minItems} ${capability.usageType} ${capability.mediaType} reference(s)`, - 400, - "missing_required_reference" - ); - } - } - return normalized; -} - export function buildAdobeImagePayload(opts: { prompt: string; aspectRatio: string; outputResolution: "1K" | "2K" | "4K"; - modelSpec: DiscoveredImageModelSpec; + modelSpec: AdobeFireflyImageModelSpec; quality?: unknown; seed?: number; sourceImageIds?: string[]; - references?: AdobeFireflyReferenceBlob[]; 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 referenceBlobs = normalizeAdobeReferenceBlobs( - opts.modelSpec, - opts.references, - opts.sourceImageIds - ); const genSettings: Record = {}; if (negative) { genSettings.avoidKeywords = negative @@ -644,16 +734,19 @@ export function buildAdobeImagePayload(opts: { ...genSettings, }, }; - if (referenceBlobs.length) { - // gpt-image references use the roles/counts validated from discovery. + 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 = referenceBlobs; + payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ + id: String(id), + usage: "subject", + })); payload.modelSpecificPayload = {}; } return payload; } - // Gemini Flash + generic (Flux / Seedream / Runway image): same 3P image shape. + // 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"]; @@ -677,9 +770,12 @@ export function buildAdobeImagePayload(opts: { }; if (Object.keys(genSettings).length) payload.generationSettings = genSettings; - if (referenceBlobs.length) { - payload.referenceBlobs = referenceBlobs; - // Flux / Seedream / Runway image historically used image2image; Gemini keeps text2image. + 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" }; } @@ -696,125 +792,142 @@ function videoSize(aspectRatio: string, resolution: string): { width: number; he return { width: Math.round((short * 16) / 9), height: short }; } -function parseSize(value: string): { width: number; height: number } | null { - const match = String(value || "").match(/^(\d+)[x:](\d+)$/i); - if (!match) return null; - const width = Number(match[1]); - const height = Number(match[2]); - return width > 0 && height > 0 ? { width, height } : null; -} - -function selectAdobeVideoSize( - model: DiscoveredVideoModelSpec, - aspectRatio: string, - resolution: string -): { width: number; height: number } { - const supported = model.capabilities.supportedSizes.map(parseSize).filter(Boolean) as Array<{ - width: number; - height: number; - }>; - const requestedExact = parseSize(aspectRatio); - if (requestedExact) { - const exact = supported.find( - (size) => size.width === requestedExact.width && size.height === requestedExact.height - ); - if (exact) return exact; - } - if (supported.length === 0) return videoSize(aspectRatio, resolution); - - const [ratioWidth, ratioHeight] = String(aspectRatio || "16:9") - .split(":") - .map(Number); - const targetRatio = ratioWidth > 0 && ratioHeight > 0 ? ratioWidth / ratioHeight : 16 / 9; - const targetLongEdge = String(resolution).includes("1080") ? 1920 : 1280; - return [...supported].sort((left, right) => { - const leftScore = - Math.abs(left.width / left.height - targetRatio) * 10_000 + - Math.abs(Math.max(left.width, left.height) - targetLongEdge); - const rightScore = - Math.abs(right.width / right.height - targetRatio) * 10_000 + - Math.abs(Math.max(right.width, right.height) - targetLongEdge); - return leftScore - rightScore; - })[0]; -} - -function validateAdobeDuration(model: DiscoveredVideoModelSpec, requested: number): number { - const capabilities = model.capabilities; - const duration = Math.floor(Number.isFinite(requested) ? requested : model.defaultDuration); - if ( - capabilities.supportedDurations.length > 0 && - !capabilities.supportedDurations.includes(duration) - ) { - throw new AdobeFireflyError( - `${model.name} supports duration(s): ${capabilities.supportedDurations.join(", ")} seconds`, - 400, - "invalid_duration" - ); - } - if ( - (capabilities.durationMin !== null && duration < capabilities.durationMin) || - (capabilities.durationMax !== null && duration > capabilities.durationMax) - ) { - throw new AdobeFireflyError( - `${model.name} duration must be between ${capabilities.durationMin ?? "?"} and ${capabilities.durationMax ?? "?"} seconds`, - 400, - "invalid_duration" - ); - } - return duration; -} - export function buildAdobeVideoPayload(opts: { prompt: string; aspectRatio: string; duration: number; - modelSpec: DiscoveredVideoModelSpec; + modelSpec: AdobeFireflyVideoModelSpec; resolution?: string; seed?: number; sourceImageIds?: string[]; - references?: AdobeFireflyReferenceBlob[]; negativePrompt?: string; generateAudio?: boolean; }): Record { - const model = opts.modelSpec; - const properties = new Set(model.capabilities.schemaProperties); + const seedVal = typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999); const aspect = opts.aspectRatio === "auto" ? "16:9" : opts.aspectRatio || "16:9"; - if ( - model.capabilities.supportedAspectRatios.length > 0 && - !model.capabilities.supportedAspectRatios.includes(aspect) - ) { - throw new AdobeFireflyError( - `${model.name} supports aspect ratio(s): ${model.capabilities.supportedAspectRatios.join(", ")}`, - 400, - "invalid_aspect_ratio" - ); - } - const duration = validateAdobeDuration(model, opts.duration); - const referenceBlobs = normalizeAdobeReferenceBlobs(model, opts.references, opts.sourceImageIds); - const seed = typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999); - const resolution = opts.resolution || model.defaultResolution; - const payload: Record = { - modelId: model.upstreamModelId, - modelVersion: model.upstreamModelVersion, - prompt: opts.prompt, - generationMetadata: { - module: referenceBlobs.some((reference) => reference.usage === "frame") - ? "image2video" - : "text2video", - }, - }; + 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 (properties.has("n")) payload.n = 1; - if (properties.has("seeds")) payload.seeds = [seed]; - if (properties.has("output")) payload.output = { storeInputs: true }; - if (properties.has("size")) payload.size = selectAdobeVideoSize(model, aspect, resolution); - if (properties.has("duration")) payload.duration = duration; - if (properties.has("generationSettings")) payload.generationSettings = { aspectRatio: aspect }; - if (properties.has("generateAudio")) payload.generateAudio = opts.generateAudio !== false; - if (properties.has("modelSpecificPayload")) payload.modelSpecificPayload = {}; - if (properties.has("referenceBlobs")) payload.referenceBlobs = referenceBlobs; - const negativePrompt = String(opts.negativePrompt || "").trim(); - if (negativePrompt && properties.has("negativePrompt")) payload.negativePrompt = 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; } @@ -860,32 +973,163 @@ export function buildAdobeSubmitNonce(accessToken: string, prompt: string): stri } /** - * Synthesize x-arp-session-id when no sherlockToken cookie is available. - * Shape matches adobe2api / GPT2Image-Pro: base64(JSON({sid, ftr})). - * Working clients ALWAYS send this header on generate-async. + * 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 function buildAdobeArpSessionId(): string { +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 rand = randomBytes(16).toString("hex"); const sid = randomUUID(); - const pid = typeof process !== "undefined" && process.pid ? process.pid : 0; - // Magic suffix is part of the wire contract reverse-engineered by adobe2api. - const ftr = `${rand}_${nowMs}_${pid}_dUAL43-mnts-ants-d4_31ck__tt`; - const raw = JSON.stringify({ sid, ftr }); + 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 a Cookie header if present. - * Browser generate sends Cookie.sherlockToken as x-arp-session-id. + * 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 || ""); - const m = raw.match(/(?:^|[;\s])sherlockToken=([^;]+)/i); - if (m?.[1]) return decodeURIComponent(m[1].trim()); - const m2 = raw.match(/(?:^|[;\s])x-arp-session-id=([^;]+)/i); - if (m2?.[1]) return decodeURIComponent(m2[1].trim()); - return ""; + 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( @@ -898,17 +1142,18 @@ export function buildAdobeSubmitHeaders( prompt?: string; } ): Record { - // Live capture + working open-source clients (GPT2Image-Pro / adobe2api): - // Authorization + x-api-key + deterministic x-nonce + ALWAYS x-arp-session-id. - // Do NOT attach firefly.adobe.com page Cookie to firefly-3p (wrong origin / soft 408). - void extras?.cookie; + // 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(); - // Prefer pasted sherlockToken; otherwise mint a synthetic ARP session (required). - const arp = - (extras?.arpSessionId && String(extras.arpSessionId).trim()) || buildAdobeArpSessionId(); + // 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}`, @@ -1050,49 +1295,6 @@ export function extractAdobeSourceImageSources(body: unknown, max = 4): string[] return out.slice(0, max); } -export interface AdobeFireflySourceImageReference { - source: string; - usage?: string; - order?: number; -} - -/** Structured extension used when a caller needs style/element/frame semantics. */ -export function extractAdobeSourceImageReferences( - body: unknown, - max = 4 -): AdobeFireflySourceImageReference[] { - const record = body && typeof body === "object" ? (body as Record) : {}; - const explicit = record.adobe_reference_inputs ?? record.adobeReferenceInputs; - if (Array.isArray(explicit)) { - const references: AdobeFireflySourceImageReference[] = []; - for (const value of explicit) { - if (references.length >= max) break; - if (!value || typeof value !== "object") continue; - const item = value as Record; - const mediaType = String(item.media_type ?? item.mediaType ?? "image").toLowerCase(); - if (mediaType !== "image") continue; - const source = - typeof item.source === "string" - ? item.source - : typeof item.url === "string" - ? item.url - : typeof item.image_url === "string" - ? item.image_url - : ""; - if (!source.trim()) continue; - const usage = String(item.usage ?? item.usage_type ?? item.usageType ?? "").trim(); - const orderValue = Number(item.order); - references.push({ - source: source.trim(), - ...(usage ? { usage } : {}), - ...(Number.isInteger(orderValue) && orderValue > 0 ? { order: orderValue } : {}), - }); - } - return references; - } - return extractAdobeSourceImageSources(body, max).map((source) => ({ source })); -} - export function parseAdobeImageSourceBytes(source: string): { buffer: Buffer; contentType: string; @@ -1175,6 +1377,8 @@ export async function uploadAdobeFireflyImage(opts: { 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; @@ -1195,8 +1399,10 @@ export async function uploadAdobeFireflyImage(opts: { 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 = - extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie); + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(cookieHeader || sessionCookie); const contentType = (opts.contentType && opts.contentType.trim()) || (buffer[0] === 0xff && buffer[1] === 0xd8 @@ -1208,11 +1414,11 @@ export async function uploadAdobeFireflyImage(opts: { const resp = await fetchImpl(ADOBE_FIREFLY_IMAGE_UPLOAD_URL, { method: "POST", headers: buildAdobeUploadHeaders(opts.accessToken, contentType, { - arpSessionId: arpSessionId || undefined, + arpSessionId, cookie: cookieHeader || undefined, prompt: opts.prompt || "upload", }), - body: buffer as unknown as BodyInit, + body: buffer, }); const text = await resp.text().catch(() => ""); @@ -1260,16 +1466,22 @@ export async function resolveAdobeSourceImageIds(opts: { 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(32, opts.max ?? 4)); + 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) @@ -1310,6 +1522,7 @@ export async function resolveAdobeSourceImageIds(opts: { bytes: buffer, contentType, sessionCookie: opts.sessionCookie, + arpSessionId, prompt: opts.prompt, fetchImpl, log: opts.log, @@ -1320,31 +1533,6 @@ export async function resolveAdobeSourceImageIds(opts: { return ids; } -export async function resolveAdobeSourceImageReferences(opts: { - accessToken: string; - body: unknown; - max?: number; - sessionCookie?: string; - prompt?: string; - fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; -}): Promise { - const max = Math.max(1, Math.min(32, opts.max ?? 4)); - const references = extractAdobeSourceImageReferences(opts.body, max); - if (references.length === 0) return []; - const ids = await resolveAdobeSourceImageIds({ - ...opts, - max, - body: { images: references.map((reference) => reference.source) }, - }); - return ids.map((id, index) => ({ - id, - mediaType: "image", - ...(references[index]?.usage ? { usage: references[index].usage } : {}), - ...(references[index]?.order ? { order: references[index].order } : {}), - })); -} - /** 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) { @@ -1397,13 +1585,27 @@ export function buildAdobeDiscoveryHeaders(accessToken: string): 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), @@ -1913,7 +2165,7 @@ async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } -export async function pollAdobeJob(opts: { +async function pollAdobeJob(opts: { pollUrl: string; accessToken: string; kind: "image" | "video"; @@ -2013,10 +2265,11 @@ export async function adobeFireflyGenerateImage(opts: { quality?: unknown; seed?: number; sourceImageIds?: string[]; - references?: AdobeFireflyReferenceBlob[]; 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 }; @@ -2033,26 +2286,29 @@ export async function adobeFireflyGenerateImage(opts: { quality: opts.quality, seed: opts.seed, sourceImageIds: opts.sourceImageIds, - references: opts.references, negativePrompt: opts.negativePrompt, }); const sessionCookie = String(opts.sessionCookie || "").trim(); const cookieHeader = extractAdobeCookieHeader(sessionCookie); - // Prefer real browser sherlockToken; buildAdobeSubmitHeaders mints synthetic ARP if empty. + // 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 = - extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie); + (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 (adobe2api/GPT2Image-Pro). Fresh ARP each 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: arpSessionId || undefined, + arpSessionId, prompt: opts.prompt, cookie: cookieHeader || undefined, }), @@ -2095,7 +2351,7 @@ export async function adobeFireflyGenerateImage(opts: { } if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("image", attempt), + formatAdobeSystemUnderLoadError("image", attempt, { hadBrowserArp }), 408, "system_under_load" ); @@ -2115,7 +2371,7 @@ export async function adobeFireflyGenerateImage(opts: { if (!pollUrl) { if (sawSystemUnderLoad) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS), + formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }), 408, "system_under_load" ); @@ -2127,15 +2383,11 @@ export async function adobeFireflyGenerateImage(opts: { } pollUrl = normalizeAdobePollUrl(pollUrl); - const pollTimeoutMs = adobeFireflyImageTimeoutMs({ - timeoutMs: opts.timeoutMs, - refCount: opts.references?.length ?? opts.sourceImageIds?.length ?? 0, - }); const { mediaUrl, latest } = await pollAdobeJob({ pollUrl, accessToken: opts.accessToken, kind: "image", - timeoutMs: pollTimeoutMs, + timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_IMAGE_TIMEOUT_MS, fetchImpl, log: opts.log, }); @@ -2154,10 +2406,11 @@ export async function adobeFireflyGenerateVideo(opts: { resolution?: unknown; seed?: number; sourceImageIds?: string[]; - references?: AdobeFireflyReferenceBlob[]; 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 }; @@ -2186,15 +2439,16 @@ export async function adobeFireflyGenerateVideo(opts: { resolution, seed: opts.seed, sourceImageIds: opts.sourceImageIds, - references: opts.references, negativePrompt: opts.negativePrompt, generateAudio: opts.generateAudio, }); const sessionCookie = String(opts.sessionCookie || "").trim(); const cookieHeader = extractAdobeCookieHeader(sessionCookie); + const hadBrowserArp = hasBrowserAdobeArpSession(cookieHeader || sessionCookie); const arpSessionId = - extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie); + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(cookieHeader || sessionCookie); let submitData: unknown = {}; let submitHeaders: Headers | Record = new Headers(); let lastSubmitError = ""; @@ -2204,7 +2458,7 @@ export async function adobeFireflyGenerateVideo(opts: { const submitResp = await fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, { method: "POST", headers: buildAdobeSubmitHeaders(opts.accessToken, { - arpSessionId: arpSessionId || undefined, + arpSessionId, prompt: opts.prompt, cookie: cookieHeader || undefined, }), @@ -2246,7 +2500,7 @@ export async function adobeFireflyGenerateVideo(opts: { } if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("video", attempt), + formatAdobeSystemUnderLoadError("video", attempt, { hadBrowserArp }), 408, "system_under_load" ); @@ -2266,7 +2520,7 @@ export async function adobeFireflyGenerateVideo(opts: { if (!pollUrl) { if (sawSystemUnderLoad) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS), + formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }), 408, "system_under_load" ); diff --git a/tests/unit/adobe-firefly.test.ts b/tests/unit/adobe-firefly.test.ts index 258bea7036..4caa9b720a 100644 --- a/tests/unit/adobe-firefly.test.ts +++ b/tests/unit/adobe-firefly.test.ts @@ -4,13 +4,8 @@ import { resolvePublicCred } from "../../open-sse/utils/publicCreds.ts"; import { ADOBE_FIREFLY_IMAGE_MODELS, ADOBE_FIREFLY_VIDEO_MODELS, - ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS, - ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS, - DEFAULT_IMAGE_TIMEOUT_MS, adobeFireflyApiKey, adobeFireflyBalanceApiKey, - adobeFireflyImageTimeoutMs, - adobeFireflyMaxImageRefs, buildAdobeImagePayload, buildAdobePollHeaders, buildAdobeSubmitHeaders, @@ -78,11 +73,6 @@ test("adobe-firefly is registered in IMAGE_PROVIDERS with adobe-firefly-image fo assert.equal(entry.format, "adobe-firefly-image"); assert.match(entry.baseUrl, /firefly-3p\.ff\.adobe\.io/); assert.ok(Array.isArray(entry.models) && entry.models.length >= 4); - assert.equal( - entry.models.some((model: { id: string }) => model.id === "nano-banana-pro"), - false, - "routing-only compatibility aliases must not be advertised as discovered models" - ); }); test("adobe-firefly is registered in VIDEO_PROVIDERS with adobe-firefly-video format", () => { @@ -159,25 +149,20 @@ test("normalizeAdobeOutputResolution maps quality tiers", () => { assert.equal(normalizeAdobeOutputResolution(undefined, undefined), "2K"); }); -test("resolveAdobeImageModel maps valid aliases to exact discovery ids", () => { - assert.equal(resolveAdobeImageModel("nano-banana-pro").id, "gemini-flash-nano-banana-2"); - assert.equal( - resolveAdobeImageModel("adobe-firefly/nano-banana-2").id, - "gemini-flash-nano-banana-3" - ); - assert.equal(resolveAdobeImageModel("gpt-image").id, "gpt-image-2"); - assert.throws( - () => resolveAdobeImageModel("invented-image-model"), - /Unknown Adobe Firefly image model/ - ); +test("resolveAdobeImageModel maps catalog and long model ids", () => { + assert.equal(resolveAdobeImageModel("nano-banana-pro").id, "nano-banana-pro"); + assert.equal(resolveAdobeImageModel("adobe-firefly/nano-banana-2").id, "nano-banana-2"); + assert.equal(resolveAdobeImageModel("firefly-nano-banana-pro-2k-16x9").id, "nano-banana-pro"); + assert.equal(resolveAdobeImageModel("gpt-image").id, "gpt-image"); assert.ok(ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"].upstreamModelVersion); }); -test("resolveAdobeVideoModel maps only discovered video models", () => { - assert.equal(resolveAdobeVideoModel("veo-3.1-fast").id, "veo-3.1-fast-generate"); - assert.equal(resolveAdobeVideoModel("kling-3").id, "kling-kling-v3-standard-i2v"); - assert.throws(() => resolveAdobeVideoModel("sora-2"), /Unknown Adobe Firefly video model/); - assert.ok(ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"].defaultDuration > 0); +test("resolveAdobeVideoModel maps sora/veo/kling families", () => { + assert.equal(resolveAdobeVideoModel("sora-2").id, "sora-2"); + assert.equal(resolveAdobeVideoModel("firefly-sora2-pro-8s-16x9").id, "sora-2-pro"); + assert.equal(resolveAdobeVideoModel("veo-3.1-fast").id, "veo-3.1-fast"); + assert.equal(resolveAdobeVideoModel("kling-3").id, "kling-3"); + assert.ok(ADOBE_FIREFLY_VIDEO_MODELS["sora-2"].defaultDuration > 0); }); test("buildAdobeImagePayload produces nano and gpt-image shapes", () => { @@ -275,21 +260,11 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image sourceImageIds: ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"], }); assert.deepEqual(gpt.referenceBlobs, [ - { id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "source" }, + { id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "subject" }, ]); assert.equal((gpt.generationMetadata as Record).module, "image2image"); }); -test("adobeFireflyImageTimeoutMs scales boundedly with reference count", () => { - assert.equal(adobeFireflyImageTimeoutMs({ refCount: 0 }), DEFAULT_IMAGE_TIMEOUT_MS); - assert.equal( - adobeFireflyImageTimeoutMs({ refCount: 2 }), - DEFAULT_IMAGE_TIMEOUT_MS + 2 * ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS - ); - assert.equal(adobeFireflyImageTimeoutMs({ timeoutMs: 120_000, refCount: 5 }), 120_000); - assert.equal(adobeFireflyImageTimeoutMs({ refCount: 99 }), ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS); -}); - test("extractAdobeSourceImageSources reads Media page image fields", () => { const tinyPng = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; @@ -362,7 +337,16 @@ test("resolveAdobeSourceImageIds uploads data URLs then returns blob ids", async assert.equal(ADOBE_FIREFLY_IMAGE_UPLOAD_URL.includes("storage/image"), true); }); -test("buildAdobeVideoPayload follows discovered fields and reference roles", () => { +test("buildAdobeVideoPayload produces sora and veo shapes", () => { + const sora = buildAdobeVideoPayload({ + prompt: "ocean waves", + aspectRatio: "16:9", + duration: 8, + modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"], + }); + assert.equal(sora.modelId, "sora"); + assert.equal(sora.duration, 8); + const veo = buildAdobeVideoPayload({ prompt: "city flyover", aspectRatio: "9:16", @@ -371,30 +355,12 @@ test("buildAdobeVideoPayload follows discovered fields and reference roles", () }); assert.equal(veo.modelId, "veo"); assert.equal(veo.modelVersion, "3.1-generate"); - assert.equal(veo.duration, 6); - assert.equal(veo.generateAudio, true); - - const kling = buildAdobeVideoPayload({ - prompt: "ocean waves", - aspectRatio: "16:9", - duration: 5, - modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["kling-3"], - sourceImageIds: ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"], - }); - assert.equal(kling.modelVersion, "kling_v3_standard_i2v"); - assert.deepEqual(kling.referenceBlobs, [ - { id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "frame", order: 1 }, - ]); - assert.throws( - () => - buildAdobeVideoPayload({ - prompt: "bad duration", - aspectRatio: "16:9", - duration: 5, - modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"], - }), - /supports duration/ + assert.equal( + (veo.modelSpecificPayload as Record>).parameters + .durationSeconds, + 6 ); + assert.equal(veo.generateAudio, true); }); test("extractAdobeResultLink prefers x-override-status-link then links.result", () => { @@ -475,17 +441,44 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => { assert.notEqual(buildAdobeSubmitNonce(token, prompt + "!"), nonce); assert.equal(extractAdobeAccountIdFromToken(token), "0EB681AF6A5FF6C10A495FF2@AdobeID"); + const { + isValidAdobeArpSessionId, + resolveAdobeArpSessionId, + extractAdobeArpSessionId, + ADOBE_FIREFLY_FTR_MAGIC, + } = await import("../../open-sse/services/adobeFireflyClient.ts"); const arp = buildAdobeArpSessionId(); assert.ok(arp.length > 20); + assert.equal(isValidAdobeArpSessionId(arp), true); const decoded = JSON.parse(Buffer.from(arp, "base64").toString("utf8")); assert.ok(decoded.sid); - assert.match(String(decoded.ftr), /dUAL43-mnts-ants-d4_31ck__tt$/); + // Live SPA shape (2026-07): sid + ark (Arkose) + ftr with __UDF43-m4_31ck magic + 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$/); // Headers: deterministic nonce + always ARP (synthetic when none provided) const h = buildAdobeSubmitHeaders(token, { prompt }); assert.equal(h["x-nonce"], nonce); assert.ok(h["x-arp-session-id"]); assert.equal(h.cookie, undefined); + + // Prefer real sherlockToken / x-arp-session-id from paste over synthetic + const realArp = Buffer.from( + JSON.stringify({ + sid: "11111111-2222-3333-4444-555555555555", + ark: "sess.123|r=eu-west-1|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C", + ftr: "aa_1" + ADOBE_FIREFLY_FTR_MAGIC + "_x=-1-v2_tt", + }), + "utf8" + ).toString("base64"); + assert.equal(extractAdobeArpSessionId(`a=1; sherlockToken=${realArp}; b=2`), realArp); + assert.equal( + extractAdobeArpSessionId(`x-arp-session-id: ${realArp}\nAuthorization: Bearer x`), + realArp + ); + assert.equal(resolveAdobeArpSessionId(`sherlockToken=${realArp}`), realArp); }); test("normalizeAdobePollUrl rewrites firefly-epo jobs/result to BKS", () => { @@ -529,7 +522,7 @@ test("adobe-firefly is in USAGE_SUPPORTED_PROVIDERS for Limits", () => { assert.ok(USAGE_SUPPORTED_PROVIDERS.includes("firefly")); }); -test("parseAdobeModelsDiscovery preserves schemas and maps exact ids", () => { +test("parseAdobeModelsDiscovery extracts image/video versions", () => { const rows = parseAdobeModelsDiscovery({ models: [ { @@ -540,44 +533,16 @@ test("parseAdobeModelsDiscovery preserves schemas and maps exact ids", () => { outputModality: ["image"], modelDisplayName: "Gemini 3.0 (Nano Banana Pro)", healthStatus: "HEALTHY", - inputMediaUseCase: ["editing"], - bksGenerationModel: "firefly_3p:external:gemini_flash_2", - requestSchema: { - type: "object", - properties: { - prompt: { type: "string" }, - referenceBlobs: { - maxItems: 14, - "x-capabilities": [ - { - mediaType: "image", - usageConstraints: [{ usageType: "general", minItems: 0, maxItems: 14 }], - maxFileSizeBytes: 104857600, - }, - ], - }, - }, - }, }, }, }, { - modelId: "veo", + modelId: "sora", modelVersions: { - "3.1-generate": { + "sora-2": { enabled: true, outputModality: ["video"], - modelDisplayName: "Veo 3.1", - requestSchema: { - allOf: [ - { - properties: { - prompt: { type: "string" }, - duration: { anyOf: [{ type: "integer", enum: [4, 6, 8] }] }, - }, - }, - ], - }, + modelDisplayName: "Sora 2", }, }, }, @@ -587,35 +552,14 @@ test("parseAdobeModelsDiscovery preserves schemas and maps exact ids", () => { assert.equal(rows[0].modality, "image"); assert.equal(rows[1].modality, "video"); const catalog = mapDiscoveredToCatalog(rows); - assert.ok(catalog.some((m) => m.id === "gemini-flash-nano-banana-2")); - assert.ok(catalog.some((m) => m.id === "veo-3.1-generate")); - assert.equal(catalog[0].capabilities.referenceInputs[0].maxItems, 14); - assert.deepEqual(catalog[1].capabilities.supportedDurations, [4, 6, 8]); + assert.ok(catalog.some((m) => m.id === "nano-banana-pro")); + assert.ok(catalog.some((m) => m.id === "sora-2")); }); -test("fallback catalog is the verified discovery snapshot without invented Sora", () => { - assert.equal(ADOBE_FIREFLY_FALLBACK_MODELS.length, 52); - assert.equal(getAdobeFireflyFallbackCatalog("image").length, 17); - assert.equal(getAdobeFireflyFallbackCatalog("video").length, 35); - assert.equal( - ADOBE_FIREFLY_FALLBACK_MODELS.some((model) => model.id.includes("sora")), - false - ); - assert.equal( - ADOBE_FIREFLY_FALLBACK_MODELS.some( - (model) => model.id.includes("kling") && model.id.includes("omni") - ), - false - ); - assert.ok(ADOBE_FIREFLY_FALLBACK_MODELS.some((model) => model.id === "kling-kling-o3")); - assert.equal( - ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"].capabilities.referenceInputs[0].maxItems, - 14 - ); - assert.equal( - ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"].capabilities.referenceInputs[0].maxItems, - 16 - ); +test("fallback catalog has image and video entries from get_models capture", () => { + assert.ok(ADOBE_FIREFLY_FALLBACK_MODELS.length >= 10); + assert.ok(getAdobeFireflyFallbackCatalog("image").length >= 4); + assert.ok(getAdobeFireflyFallbackCatalog("video").length >= 4); }); test("extractAdobeAccountIdFromToken reads user_id claim", () => { @@ -630,7 +574,18 @@ test("extractAdobeAccountIdFromToken reads user_id claim", () => { // --- Handlers (mocked fetch) ---------------------------------------------- function jsonResponse(status: number, body: unknown, headerMap: Record = {}) { - return new Response(JSON.stringify(body) ?? null, { status, headers: headerMap }); + return { + ok: status >= 200 && status < 300, + status, + headers: { + get: (name: string) => { + const key = Object.keys(headerMap).find((k) => k.toLowerCase() === name.toLowerCase()); + return key ? headerMap[key] : null; + }, + }, + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response; } test("handleAdobeFireflyImageGeneration returns 400 when prompt is missing", async () => { @@ -755,7 +710,7 @@ test("adobeFireflyGenerateVideo submit+poll happy path (mocked)", async () => { const result = await adobeFireflyGenerateVideo({ accessToken: "tok", prompt: "drone over forest", - model: "veo-3.1", + model: "sora-2", duration: 4, aspectRatio: "16:9", fetchImpl: fetchImpl as typeof fetch, @@ -766,7 +721,7 @@ test("adobeFireflyGenerateVideo submit+poll happy path (mocked)", async () => { test("handleAdobeFireflyVideoGeneration returns 400 without prompt", async () => { const result = await handleAdobeFireflyVideoGeneration({ - model: "veo-3.1", + model: "sora-2", provider: "adobe-firefly", body: {}, credentials: { apiKey: "aaa.bbb.ccc" }, @@ -854,6 +809,36 @@ test("cookie exchange rejects guest IMS tokens", async () => { ); }); +test("extractAdobeArpSessionId recovers JWT+ARP joined by space (PasswordBox mangling)", async () => { + const { extractAdobeArpSessionId, hasBrowserAdobeArpSession, formatAdobeSystemUnderLoadError } = + await import("../../open-sse/services/adobeFireflyClient.ts"); + const realArp = Buffer.from( + JSON.stringify({ + sid: "bdf37b8a-117f-467d-a737-7792932d98b4", + ark: "60818c561473ddb23.0684402805|r=eu-west-1|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C", + ftr: "aab9dc9eb48f4ee1916428649f908f7d_1__UDF43-m4_31ck_x=-1-v2_tt", + }), + "utf8" + ).toString("base64"); + // Fake 3-segment JWT shape long enough for looksLikeAdobeJwt + const fakeJwt = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJ1c2VyX2lkIjoiMEVCNjgxQUY2QTVGRjZDMTBBNDk1RkYyQEFkb2JlSUQifQ." + + "sigABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop"; + const joined = `${fakeJwt} ${realArp}`; + assert.equal(extractAdobeArpSessionId(joined), realArp); + assert.equal(hasBrowserAdobeArpSession(joined), true); + assert.equal(hasBrowserAdobeArpSession(fakeJwt), false); + assert.match( + formatAdobeSystemUnderLoadError("image", 2, { hadBrowserArp: false }), + /missing a browser x-arp-session-id/ + ); + assert.match( + formatAdobeSystemUnderLoadError("image", 2, { hadBrowserArp: true }), + /fresh successful generate-async/i + ); +}); + test("isAdobeTransientSubmitError detects 408 system under load", () => { assert.equal( isAdobeTransientSubmitError(