mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
2953 lines
102 KiB
TypeScript
2953 lines
102 KiB
TypeScript
/**
|
||
* 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";
|
||
import {
|
||
decodeAdobeJwtPayload,
|
||
findAllAdobeJwts,
|
||
isExactAdobeJwt,
|
||
stripAdobeJwts,
|
||
} from "./adobeFireflySecurity.ts";
|
||
import {
|
||
parseAdobeModelsDiscovery as parseAdobeModelsDiscoveryContract,
|
||
type AdobeFireflyDiscoveredModel,
|
||
} from "./adobeFireflyModels.ts";
|
||
|
||
export { decodeAdobeJwtPayload } from "./adobeFireflySecurity.ts";
|
||
export type { AdobeFireflyDiscoveredModel } from "./adobeFireflyModels.ts";
|
||
|
||
export const ADOBE_FIREFLY_IMAGE_SUBMIT_URL =
|
||
"https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async";
|
||
export const ADOBE_FIREFLY_VIDEO_SUBMIT_URL =
|
||
"https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async";
|
||
export const ADOBE_FIREFLY_IMAGE_UPLOAD_URL = "https://firefly-3p.ff.adobe.io/v2/storage/image";
|
||
export const ADOBE_FIREFLY_MODELS_DISCOVERY_URL =
|
||
"https://firefly-3p.ff.adobe.io/v2/models/discovery";
|
||
export const ADOBE_FIREFLY_CREDITS_BALANCE_URL = "https://firefly.adobe.io/v1/credits/balance";
|
||
export const ADOBE_FIREFLY_IMS_REFRESH_URL =
|
||
"https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb";
|
||
/** Scope set observed on live firefly.adobe.com IMS access tokens. */
|
||
export const ADOBE_FIREFLY_IMS_SCOPE =
|
||
"AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext," +
|
||
"additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations," +
|
||
"additional_info.roles,account_cluster.read,creative_production,tk_platform," +
|
||
"tk_platform_sync,profile";
|
||
|
||
const DEFAULT_USER_AGENT =
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
|
||
const DEFAULT_SEC_CH_UA = '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"';
|
||
const DEFAULT_POLL_INTERVAL_MS = 3000;
|
||
const DEFAULT_IMAGE_TIMEOUT_MS = 180_000;
|
||
const DEFAULT_VIDEO_TIMEOUT_MS = 300_000;
|
||
const FIREFLY_ORIGIN = "https://firefly.adobe.com";
|
||
const FIREFLY_REFERER = "https://firefly.adobe.com/";
|
||
|
||
export type AdobeFireflyImageModelId =
|
||
| "nano-banana-pro"
|
||
| "nano-banana"
|
||
| "nano-banana-2"
|
||
| "gpt-image"
|
||
| "gpt-image-2"
|
||
| "gpt-image-1.5"
|
||
| "flux-2"
|
||
| "flux-pro"
|
||
| "flux-ultra"
|
||
| "seedream-4"
|
||
| "seedream-5-lite"
|
||
| "runway-gen4-image";
|
||
|
||
export type AdobeFireflyVideoModelId =
|
||
"sora-2" | "sora-2-pro" | "veo-3.1" | "veo-3.1-fast" | "veo-3.1-ref" | "kling-3";
|
||
|
||
export interface AdobeFireflyImageModelSpec {
|
||
upstreamModelId: string;
|
||
upstreamModelVersion: string;
|
||
/** Payload builder family — nano uses Gemini-style size maps; gpt-image uses OpenAI detail levels. */
|
||
family: "nano" | "gpt-image" | "generic";
|
||
}
|
||
|
||
export interface AdobeFireflyVideoModelSpec {
|
||
engine: "sora2" | "sora2-pro" | "veo31-standard" | "veo31-fast" | "kling3";
|
||
upstreamModel: string;
|
||
modelId?: string;
|
||
modelVersion?: string;
|
||
referenceMode?: "frame" | "image";
|
||
defaultDuration: number;
|
||
defaultResolution: string;
|
||
}
|
||
|
||
/**
|
||
* Upstream modelId/modelVersion pairs from firefly-3p models/discovery
|
||
* (captured 2026-07 — see adobe/get_models.txt). Friendly catalog ids map here.
|
||
*/
|
||
export const ADOBE_FIREFLY_IMAGE_MODELS: Record<
|
||
AdobeFireflyImageModelId,
|
||
AdobeFireflyImageModelSpec
|
||
> = {
|
||
// Gemini 3.0 (Nano Banana Pro) — discovery: gemini-flash / nano-banana-2
|
||
"nano-banana-pro": {
|
||
upstreamModelId: "gemini-flash",
|
||
upstreamModelVersion: "nano-banana-2",
|
||
family: "nano",
|
||
},
|
||
// Gemini 2.5 (Nano Banana) — discovery: gemini-flash / nano-banana
|
||
"nano-banana": {
|
||
upstreamModelId: "gemini-flash",
|
||
upstreamModelVersion: "nano-banana",
|
||
family: "nano",
|
||
},
|
||
// Gemini 3.1 (Nano Banana 2) — discovery: gemini-flash / nano-banana-3
|
||
"nano-banana-2": {
|
||
upstreamModelId: "gemini-flash",
|
||
upstreamModelVersion: "nano-banana-3",
|
||
family: "nano",
|
||
},
|
||
// GPT Image 2 — discovery modelVersion "2" (get_models: modelDisplayName "GPT Image 2")
|
||
"gpt-image": {
|
||
upstreamModelId: "gpt-image",
|
||
upstreamModelVersion: "2",
|
||
family: "gpt-image",
|
||
},
|
||
// Explicit catalog alias so pickers show "gpt-image-2" distinctly
|
||
"gpt-image-2": {
|
||
upstreamModelId: "gpt-image",
|
||
upstreamModelVersion: "2",
|
||
family: "gpt-image",
|
||
},
|
||
"gpt-image-1.5": {
|
||
upstreamModelId: "gpt-image",
|
||
upstreamModelVersion: "1.5",
|
||
family: "gpt-image",
|
||
},
|
||
"flux-2": {
|
||
upstreamModelId: "flux",
|
||
upstreamModelVersion: "2",
|
||
family: "generic",
|
||
},
|
||
"flux-pro": {
|
||
upstreamModelId: "flux",
|
||
upstreamModelVersion: "fluxPro",
|
||
family: "generic",
|
||
},
|
||
"flux-ultra": {
|
||
upstreamModelId: "flux",
|
||
upstreamModelVersion: "fluxUltra",
|
||
family: "generic",
|
||
},
|
||
"seedream-4": {
|
||
upstreamModelId: "seedream",
|
||
upstreamModelVersion: "seedream_v4",
|
||
family: "generic",
|
||
},
|
||
"seedream-5-lite": {
|
||
upstreamModelId: "seedream",
|
||
upstreamModelVersion: "seedream_v5_lite",
|
||
family: "generic",
|
||
},
|
||
"runway-gen4-image": {
|
||
upstreamModelId: "runway-gen4-image",
|
||
upstreamModelVersion: "gen4_image",
|
||
family: "generic",
|
||
},
|
||
};
|
||
|
||
export const ADOBE_FIREFLY_VIDEO_MODELS: Record<
|
||
AdobeFireflyVideoModelId,
|
||
AdobeFireflyVideoModelSpec
|
||
> = {
|
||
"sora-2": {
|
||
engine: "sora2",
|
||
upstreamModel: "openai:firefly:colligo:sora2",
|
||
defaultDuration: 8,
|
||
defaultResolution: "720p",
|
||
},
|
||
"sora-2-pro": {
|
||
engine: "sora2-pro",
|
||
upstreamModel: "openai:firefly:colligo:sora2-pro",
|
||
defaultDuration: 8,
|
||
defaultResolution: "720p",
|
||
},
|
||
"veo-3.1": {
|
||
engine: "veo31-standard",
|
||
upstreamModel: "google:firefly:colligo:veo31",
|
||
modelId: "veo",
|
||
modelVersion: "3.1-generate",
|
||
defaultDuration: 6,
|
||
defaultResolution: "720p",
|
||
},
|
||
"veo-3.1-fast": {
|
||
engine: "veo31-fast",
|
||
upstreamModel: "google:firefly:colligo:veo31-fast",
|
||
modelId: "veo",
|
||
modelVersion: "3.1-fast-generate",
|
||
defaultDuration: 6,
|
||
defaultResolution: "720p",
|
||
},
|
||
"veo-3.1-ref": {
|
||
engine: "veo31-standard",
|
||
upstreamModel: "google:firefly:colligo:veo31",
|
||
modelId: "veo",
|
||
modelVersion: "3.1-generate",
|
||
referenceMode: "image",
|
||
defaultDuration: 6,
|
||
defaultResolution: "720p",
|
||
},
|
||
"kling-3": {
|
||
engine: "kling3",
|
||
upstreamModel: "kling:firefly:colligo:kling3",
|
||
modelId: "kling",
|
||
modelVersion: "kling_v3_standard_i2v",
|
||
defaultDuration: 5,
|
||
defaultResolution: "1080p",
|
||
},
|
||
};
|
||
|
||
const NANO_SIZE_MAP: Record<string, Record<string, { width: number; height: number }>> = {
|
||
"1K": {
|
||
"1:1": { width: 1024, height: 1024 },
|
||
"16:9": { width: 1360, height: 768 },
|
||
"9:16": { width: 768, height: 1360 },
|
||
"4:3": { width: 1152, height: 864 },
|
||
"3:4": { width: 864, height: 1152 },
|
||
"1:8": { width: 384, height: 3072 },
|
||
"1:4": { width: 512, height: 2048 },
|
||
"4:1": { width: 2048, height: 512 },
|
||
"8:1": { width: 3072, height: 384 },
|
||
},
|
||
"2K": {
|
||
"1:1": { width: 2048, height: 2048 },
|
||
"16:9": { width: 2752, height: 1536 },
|
||
"9:16": { width: 1536, height: 2752 },
|
||
"4:3": { width: 2048, height: 1536 },
|
||
"3:4": { width: 1536, height: 2048 },
|
||
"1:8": { width: 768, height: 6144 },
|
||
"1:4": { width: 1024, height: 4096 },
|
||
"4:1": { width: 4096, height: 1024 },
|
||
"8:1": { width: 6144, height: 768 },
|
||
},
|
||
"4K": {
|
||
"1:1": { width: 4096, height: 4096 },
|
||
"16:9": { width: 5504, height: 3072 },
|
||
"9:16": { width: 3072, height: 5504 },
|
||
"4:3": { width: 4096, height: 3072 },
|
||
"3:4": { width: 3072, height: 4096 },
|
||
"1:8": { width: 1536, height: 12288 },
|
||
"1:4": { width: 2048, height: 8192 },
|
||
"4:1": { width: 8192, height: 2048 },
|
||
"8:1": { width: 12288, height: 1536 },
|
||
},
|
||
};
|
||
|
||
const GPT_SIZE_MAP: Record<string, Record<string, { width: number; height: number }>> = {
|
||
"1K": {
|
||
"1:1": { width: 1024, height: 1024 },
|
||
"5:4": { width: 1120, height: 896 },
|
||
"9:16": { width: 720, height: 1280 },
|
||
"21:9": { width: 1456, height: 624 },
|
||
"16:9": { width: 1280, height: 720 },
|
||
"4:3": { width: 1152, height: 864 },
|
||
"3:2": { width: 1248, height: 832 },
|
||
"4:5": { width: 896, height: 1120 },
|
||
"3:4": { width: 864, height: 1152 },
|
||
"2:3": { width: 832, height: 1248 },
|
||
},
|
||
"2K": {
|
||
"1:1": { width: 2048, height: 2048 },
|
||
"5:4": { width: 2240, height: 1792 },
|
||
"9:16": { width: 1440, height: 2560 },
|
||
"21:9": { width: 3024, height: 1296 },
|
||
"16:9": { width: 2560, height: 1440 },
|
||
"4:3": { width: 2304, height: 1728 },
|
||
"3:2": { width: 2496, height: 1664 },
|
||
"4:5": { width: 1792, height: 2240 },
|
||
"3:4": { width: 1728, height: 2304 },
|
||
"2:3": { width: 1664, height: 2496 },
|
||
},
|
||
"4K": {
|
||
"1:1": { width: 2880, height: 2880 },
|
||
"5:4": { width: 3200, height: 2560 },
|
||
"9:16": { width: 2160, height: 3840 },
|
||
"21:9": { width: 3696, height: 1584 },
|
||
"16:9": { width: 3840, height: 2160 },
|
||
"4:3": { width: 3264, height: 2448 },
|
||
"3:2": { width: 3504, height: 2336 },
|
||
"4:5": { width: 2560, height: 3200 },
|
||
"3:4": { width: 2448, height: 3264 },
|
||
"2:3": { width: 2336, height: 3504 },
|
||
},
|
||
};
|
||
|
||
const PIXEL_SIZE_TO_RATIO: Record<string, string> = {
|
||
"1024x1024": "1:1",
|
||
"1536x1536": "1:1",
|
||
"2048x2048": "1:1",
|
||
"1024x1792": "9:16",
|
||
"1536x2752": "9:16",
|
||
"1792x1024": "16:9",
|
||
"2752x1536": "16:9",
|
||
"2048x1536": "4:3",
|
||
"1536x2048": "3:4",
|
||
"1280x720": "16:9",
|
||
"720x1280": "9:16",
|
||
"1920x1080": "16:9",
|
||
"1080x1920": "9:16",
|
||
};
|
||
|
||
export class AdobeFireflyError extends Error {
|
||
status: number;
|
||
code?: string;
|
||
|
||
constructor(message: string, status = 502, code?: string) {
|
||
super(message);
|
||
this.name = "AdobeFireflyError";
|
||
this.status = status;
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
/** Public x-api-key + primary IMS client_id for firefly.adobe.com (`clio-playground-web`). */
|
||
export function adobeFireflyApiKey(): string {
|
||
return resolvePublicCred("adobe_firefly_api_key", "ADOBE_FIREFLY_API_KEY");
|
||
}
|
||
|
||
/** Express IMS client_id fallback for cookie exchange (`projectx_webapp`). */
|
||
export function adobeFireflyExpressClientId(): string {
|
||
return resolvePublicCred("adobe_firefly_express_client_id", "ADOBE_FIREFLY_EXPRESS_CLIENT_ID");
|
||
}
|
||
|
||
/** Public x-api-key for GET firefly.adobe.io/v1/credits/balance (`SunbreakWebUI1`). */
|
||
export function adobeFireflyBalanceApiKey(): string {
|
||
return resolvePublicCred("adobe_firefly_balance_api_key", "ADOBE_FIREFLY_BALANCE_API_KEY");
|
||
}
|
||
|
||
/** Decode IMS JWT payload (no signature verification — client-side claim read only). */
|
||
/** 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 = findAllAdobeJwts(value);
|
||
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 (isExactAdobeJwt(line)) return false;
|
||
return true;
|
||
})
|
||
.join("; ");
|
||
|
||
// Also strip inline eyJ JWT tokens that may sit inside a cookie string
|
||
const noJwt = stripAdobeJwts(cleaned)
|
||
.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 {
|
||
// Live firefly.adobe.com default for gpt-image is detailLevel 3 (medium).
|
||
const q = String(quality ?? "medium")
|
||
.trim()
|
||
.toLowerCase();
|
||
if (q === "high" || q === "4k" || q === "ultra") return 5;
|
||
if (q === "low" || q === "1k") return 1;
|
||
if (q === "medium" || q === "2k" || q === "standard" || q === "hd" || q === "auto") return 3;
|
||
return 3;
|
||
}
|
||
|
||
export function buildAdobeImagePayload(opts: {
|
||
prompt: string;
|
||
aspectRatio: string;
|
||
outputResolution: "1K" | "2K" | "4K";
|
||
modelSpec: AdobeFireflyImageModelSpec;
|
||
quality?: unknown;
|
||
seed?: number;
|
||
sourceImageIds?: string[];
|
||
negativePrompt?: string;
|
||
}): Record<string, unknown> {
|
||
const ratio = opts.aspectRatio === "auto" ? "1:1" : opts.aspectRatio || "1:1";
|
||
const seeds = [typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999)];
|
||
const negative = String(opts.negativePrompt || "").trim();
|
||
const genSettings: Record<string, unknown> = {};
|
||
if (negative) {
|
||
genSettings.avoidKeywords = negative
|
||
.replace(/;/g, ",")
|
||
.split(",")
|
||
.map((w) => w.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
if (opts.modelSpec.family === "gpt-image") {
|
||
// Live firefly.adobe.com body (adobe/image_generate.txt) — no top-level size /
|
||
// outputResolution; modelSpecificPayload.size is "auto".
|
||
const payload: Record<string, unknown> = {
|
||
n: 1,
|
||
seeds,
|
||
output: { storeInputs: true },
|
||
prompt: opts.prompt,
|
||
referenceBlobs: [] as Array<Record<string, unknown>>,
|
||
modelSpecificPayload: { size: "auto" },
|
||
modelId: opts.modelSpec.upstreamModelId,
|
||
modelVersion: opts.modelSpec.upstreamModelVersion,
|
||
generationMetadata: {
|
||
module: "text2image",
|
||
submodule: "ff-image-generate",
|
||
},
|
||
generationSettings: {
|
||
detailLevel: gptDetailLevel(opts.quality),
|
||
...genSettings,
|
||
},
|
||
};
|
||
if (opts.sourceImageIds?.length) {
|
||
// gpt-image subject references (mask path uses separate mask blob when present).
|
||
payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" };
|
||
payload.referenceBlobs = opts.sourceImageIds.map((id) => ({
|
||
id: String(id),
|
||
usage: "subject",
|
||
}));
|
||
payload.modelSpecificPayload = {};
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
// nano (Gemini Flash) + generic (Flux / Seedream / Runway image): same 3P image shape.
|
||
// Live capture (web_providers/adobe_atach_images.txt): referenceBlobs with usage "general"
|
||
// keep module "text2image" (not image2image) for nano multi-ref composition.
|
||
const sizeMap = NANO_SIZE_MAP[opts.outputResolution] || NANO_SIZE_MAP["2K"];
|
||
const pixel = sizeMap[ratio] || sizeMap["1:1"];
|
||
const payload: Record<string, unknown> = {
|
||
modelId: opts.modelSpec.upstreamModelId,
|
||
modelVersion: opts.modelSpec.upstreamModelVersion,
|
||
n: 1,
|
||
prompt: opts.prompt,
|
||
size: pixel,
|
||
seeds,
|
||
groundSearch: false,
|
||
skipCai: false,
|
||
output: { storeInputs: true },
|
||
generationMetadata: {
|
||
module: "text2image",
|
||
submodule: "ff-image-generate",
|
||
},
|
||
modelSpecificPayload: {
|
||
parameters: { addWatermark: false },
|
||
aspectRatio: ratio,
|
||
},
|
||
referenceBlobs: [] as Array<Record<string, unknown>>,
|
||
};
|
||
if (Object.keys(genSettings).length) payload.generationSettings = genSettings;
|
||
|
||
if (opts.sourceImageIds?.length) {
|
||
payload.referenceBlobs = opts.sourceImageIds.map((id) => ({
|
||
id: String(id),
|
||
usage: "general",
|
||
}));
|
||
// Flux / Seedream / Runway image historically used image2image; nano keeps text2image.
|
||
if (opts.modelSpec.family === "generic") {
|
||
payload.generationMetadata = {
|
||
module: "image2image",
|
||
submodule: "ff-image-generate",
|
||
};
|
||
}
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function videoSize(aspectRatio: string, resolution: string): { width: number; height: number } {
|
||
const res = String(resolution || "720p").toLowerCase();
|
||
const short = res.includes("1080") ? 1080 : res.includes("480") ? 480 : 720;
|
||
const ratio = aspectRatio === "9:16" ? "9:16" : aspectRatio === "1:1" ? "1:1" : "16:9";
|
||
if (ratio === "1:1") return { width: short, height: short };
|
||
if (ratio === "9:16") return { width: Math.round((short * 9) / 16), height: short };
|
||
return { width: Math.round((short * 16) / 9), height: short };
|
||
}
|
||
|
||
export function buildAdobeVideoPayload(opts: {
|
||
prompt: string;
|
||
aspectRatio: string;
|
||
duration: number;
|
||
modelSpec: AdobeFireflyVideoModelSpec;
|
||
resolution?: string;
|
||
seed?: number;
|
||
sourceImageIds?: string[];
|
||
negativePrompt?: string;
|
||
generateAudio?: boolean;
|
||
}): Record<string, unknown> {
|
||
const seedVal = typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999);
|
||
const aspect = opts.aspectRatio === "auto" ? "16:9" : opts.aspectRatio || "16:9";
|
||
const duration = Math.max(
|
||
1,
|
||
Math.min(30, Math.floor(opts.duration || opts.modelSpec.defaultDuration))
|
||
);
|
||
const resolution = opts.resolution || opts.modelSpec.defaultResolution;
|
||
const vidSize = videoSize(aspect, resolution);
|
||
const engine = opts.modelSpec.engine;
|
||
const sourceImageIds = opts.sourceImageIds || [];
|
||
const negative = String(opts.negativePrompt || "");
|
||
|
||
if (engine === "veo31-standard" || engine === "veo31-fast") {
|
||
const payload: Record<string, unknown> = {
|
||
n: 1,
|
||
seeds: [seedVal],
|
||
modelId: "veo",
|
||
modelVersion:
|
||
opts.modelSpec.modelVersion ||
|
||
(engine === "veo31-fast" ? "3.1-fast-generate" : "3.1-generate"),
|
||
output: { storeInputs: true },
|
||
prompt: opts.prompt,
|
||
size: vidSize,
|
||
generateAudio: opts.generateAudio !== false,
|
||
referenceBlobs: [] as Array<Record<string, unknown>>,
|
||
generationMetadata: { module: "text2video" },
|
||
modelSpecificPayload: {
|
||
parameters: {
|
||
durationSeconds: duration,
|
||
aspectRatio: aspect,
|
||
addWaterMark: false,
|
||
},
|
||
},
|
||
};
|
||
if (sourceImageIds.length) {
|
||
const refs = payload.referenceBlobs as Array<Record<string, unknown>>;
|
||
if (opts.modelSpec.referenceMode === "image") {
|
||
for (const imageId of sourceImageIds.slice(0, 3)) {
|
||
refs.push({ id: String(imageId), usage: "asset" });
|
||
}
|
||
} else {
|
||
sourceImageIds.slice(0, 2).forEach((imageId, idx) => {
|
||
refs.push({ id: String(imageId), usage: "general", order: idx + 1 });
|
||
});
|
||
}
|
||
payload.generationMetadata = { module: "image2video" };
|
||
}
|
||
if (negative) payload.negativePrompt = negative;
|
||
return payload;
|
||
}
|
||
|
||
if (engine === "kling3") {
|
||
const payload: Record<string, unknown> = {
|
||
n: 1,
|
||
seeds: [seedVal],
|
||
modelId: "kling",
|
||
modelVersion: "kling_v3_standard_i2v",
|
||
output: { storeInputs: true },
|
||
prompt: opts.prompt,
|
||
size: vidSize,
|
||
generationMetadata: {
|
||
module: sourceImageIds.length ? "image2video" : "text2video",
|
||
},
|
||
duration,
|
||
generationSettings: { aspectRatio: aspect },
|
||
referenceBlobs: [] as Array<Record<string, unknown>>,
|
||
};
|
||
if (sourceImageIds.length) {
|
||
const refs = payload.referenceBlobs as Array<Record<string, unknown>>;
|
||
sourceImageIds.slice(0, 2).forEach((imageId, idx) => {
|
||
refs.push({ id: String(imageId), usage: "frame", order: idx + 1 });
|
||
});
|
||
}
|
||
if (negative) payload.negativePrompt = negative;
|
||
return payload;
|
||
}
|
||
|
||
// Sora 2 / Sora 2 Pro
|
||
const promptJson = JSON.stringify({
|
||
prompt: opts.prompt,
|
||
duration,
|
||
...(negative ? { negative_prompt: negative } : {}),
|
||
});
|
||
const payload: Record<string, unknown> = {
|
||
n: 1,
|
||
seeds: [seedVal],
|
||
modelId: "sora",
|
||
modelVersion: engine === "sora2-pro" ? "sora-2-pro" : "sora-2",
|
||
size: vidSize,
|
||
duration,
|
||
fps: 24,
|
||
prompt: promptJson,
|
||
generationMetadata: {
|
||
module: sourceImageIds.length ? "image2video" : "text2video",
|
||
},
|
||
model: opts.modelSpec.upstreamModel,
|
||
generateLoop: false,
|
||
transparentBackground: false,
|
||
seed: String(seedVal),
|
||
locale: "en-US",
|
||
camera: {
|
||
angle: "none",
|
||
shotSize: "none",
|
||
motion: null,
|
||
promptStyle: null,
|
||
},
|
||
negativePrompt: negative,
|
||
jobMode: "standard",
|
||
debugGenerationEndpoint: "",
|
||
referenceBlobs: [] as Array<Record<string, unknown>>,
|
||
referenceFrames: [] as Array<Record<string, unknown> | null>,
|
||
referenceVideo: null,
|
||
cameraMotionReferenceVideo: null,
|
||
characterReference: null,
|
||
editReferenceVideo: null,
|
||
output: { storeInputs: true },
|
||
};
|
||
if (sourceImageIds.length) {
|
||
const firstId = String(sourceImageIds[0]);
|
||
payload.referenceBlobs = [{ id: firstId, usage: "general", promptReference: 1 }];
|
||
const frames: Array<Record<string, unknown> | null> = [{ localBlobRef: firstId }, null];
|
||
if (sourceImageIds.length > 1) {
|
||
const lastId = String(sourceImageIds[1]);
|
||
(payload.referenceBlobs as Array<Record<string, unknown>>).push({
|
||
id: lastId,
|
||
usage: "general",
|
||
promptReference: 2,
|
||
});
|
||
frames[1] = { localBlobRef: lastId };
|
||
}
|
||
payload.referenceFrames = frames;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function browserHeaders(): Record<string, string> {
|
||
return {
|
||
"user-agent": DEFAULT_USER_AGENT,
|
||
origin: FIREFLY_ORIGIN,
|
||
referer: FIREFLY_REFERER,
|
||
"accept-language": "en-US,en;q=0.9",
|
||
"sec-ch-ua": DEFAULT_SEC_CH_UA,
|
||
"sec-ch-ua-mobile": "?0",
|
||
"sec-ch-ua-platform": '"Windows"',
|
||
"sec-fetch-site": "cross-site",
|
||
"sec-fetch-mode": "cors",
|
||
"sec-fetch-dest": "empty",
|
||
};
|
||
}
|
||
|
||
/** Random 64-char hex fallback when token/prompt are missing for deterministic nonce. */
|
||
export function generateAdobeNonce(): string {
|
||
const bytes = new Uint8Array(32);
|
||
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
||
crypto.getRandomValues(bytes);
|
||
} else {
|
||
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||
}
|
||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||
}
|
||
|
||
/**
|
||
* Deterministic x-nonce used by working open-source Firefly clients
|
||
* (adobe2api / GPT2Image-Pro / image2api):
|
||
* sha256(`${user_id}-${prompt.slice(0, 256)}`)
|
||
*
|
||
* Random nonces (browser-looking) still get colligo 408 on many accounts when
|
||
* the request is not from the SPA. Deterministic nonce is what unblocks generate.
|
||
*/
|
||
export function buildAdobeSubmitNonce(accessToken: string, prompt: string): string {
|
||
const userId = extractAdobeAccountIdFromToken(accessToken);
|
||
const promptPrefix = String(prompt || "").slice(0, 256);
|
||
if (!userId || !promptPrefix) return "";
|
||
return createHash("sha256").update(`${userId}-${promptPrefix}`, "utf8").digest("hex");
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
// 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 (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)
|
||
// Split on whitespace only — NOT on "=" — so we never treat "aux_sid=…" as a token.
|
||
const withoutJwt = stripAdobeJwts(raw, " ");
|
||
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<string, string> = { 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<string, string> {
|
||
// 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<string, string> = {
|
||
...browserHeaders(),
|
||
Authorization: `Bearer ${accessToken}`,
|
||
// Must be clio-playground-web — same client_id that minted the IMS token.
|
||
"x-api-key": adobeFireflyApiKey(),
|
||
"content-type": "application/json",
|
||
accept: "*/*",
|
||
"cache-control": "no-cache",
|
||
pragma: "no-cache",
|
||
priority: "u=1, i",
|
||
"x-nonce": deterministic,
|
||
"x-arp-session-id": arp,
|
||
};
|
||
return headers;
|
||
}
|
||
|
||
/** Max reference image size for Firefly storage upload (20 MiB). */
|
||
export const ADOBE_FIREFLY_MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||
|
||
/**
|
||
* Headers for POST /v2/storage/image (raw image body).
|
||
* Live capture (web_providers/adobe_atach_images.txt): Bearer + x-api-key + x-arp + x-nonce
|
||
* + content-type image/png|jpeg (not application/json).
|
||
*/
|
||
export function buildAdobeUploadHeaders(
|
||
accessToken: string,
|
||
contentType: string,
|
||
extras?: {
|
||
arpSessionId?: string;
|
||
nonce?: string;
|
||
cookie?: string;
|
||
prompt?: string;
|
||
}
|
||
): Record<string, string> {
|
||
const base = buildAdobeSubmitHeaders(accessToken, {
|
||
arpSessionId: extras?.arpSessionId,
|
||
nonce: extras?.nonce,
|
||
cookie: extras?.cookie,
|
||
prompt: extras?.prompt || "upload",
|
||
});
|
||
const ct =
|
||
String(contentType || "image/png")
|
||
.trim()
|
||
.toLowerCase() || "image/png";
|
||
return {
|
||
...base,
|
||
"content-type": ct.startsWith("image/") ? ct : "image/png",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Collect reference image sources from an OpenAI-style / Media-page image|video body.
|
||
* Supports: image_url, image, images[], image_urls[], input_image(s), reference_images,
|
||
* provider_options.*, and prompt_image fields used by the WinUI Media page.
|
||
*/
|
||
export {
|
||
extractAdobeSourceImageReferences,
|
||
normalizeAdobeReferenceBlobs,
|
||
} from "./adobeFireflyReferences.ts";
|
||
|
||
export function extractAdobeSourceImageSources(body: unknown, max = 4): string[] {
|
||
if (!body || typeof body !== "object") return [];
|
||
const b = body as Record<string, unknown>;
|
||
const po =
|
||
b.provider_options &&
|
||
typeof b.provider_options === "object" &&
|
||
!Array.isArray(b.provider_options)
|
||
? (b.provider_options as Record<string, unknown>)
|
||
: {};
|
||
|
||
const out: string[] = [];
|
||
const seen = new Set<string>();
|
||
const push = (v: unknown) => {
|
||
if (out.length >= max) return;
|
||
if (typeof v === "string") {
|
||
const t = v.trim();
|
||
if (!t || seen.has(t)) return;
|
||
// Skip empty / clearly non-image
|
||
if (t === "null" || t === "undefined") return;
|
||
seen.add(t);
|
||
out.push(t);
|
||
return;
|
||
}
|
||
if (Array.isArray(v)) {
|
||
for (const item of v) {
|
||
if (out.length >= max) break;
|
||
push(item);
|
||
}
|
||
return;
|
||
}
|
||
if (v && typeof v === "object") {
|
||
const o = v as Record<string, unknown>;
|
||
if (typeof o.url === "string") push(o.url);
|
||
else if (typeof o.image_url === "string") push(o.image_url);
|
||
else if (o.image_url && typeof o.image_url === "object") {
|
||
const inner = (o.image_url as Record<string, unknown>).url;
|
||
if (typeof inner === "string") push(inner);
|
||
} else if (typeof o.b64_json === "string") {
|
||
push(`data:image/png;base64,${o.b64_json}`);
|
||
} else if (typeof o.base64 === "string") {
|
||
push(`data:image/png;base64,${o.base64}`);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Order matches MediaViewModel / OpenAI edit aliases (primary single fields first).
|
||
const keys = [
|
||
"image_url",
|
||
"imageUrl",
|
||
"input_image",
|
||
"source_image",
|
||
"promptImage",
|
||
"prompt_image",
|
||
"image",
|
||
"images",
|
||
"image_urls",
|
||
"imageUrls",
|
||
"input_images",
|
||
"reference_images",
|
||
"referenceImages",
|
||
"reference_image",
|
||
];
|
||
for (const k of keys) {
|
||
push(b[k]);
|
||
push(po[k]);
|
||
}
|
||
|
||
// OpenAI chat-style content parts (rare on /v1/images but harmless).
|
||
if (Array.isArray(b.messages)) {
|
||
for (const msg of b.messages) {
|
||
if (!msg || typeof msg !== "object") continue;
|
||
const content = (msg as Record<string, unknown>).content;
|
||
if (!Array.isArray(content)) continue;
|
||
for (const part of content) {
|
||
if (!part || typeof part !== "object") continue;
|
||
const p = part as Record<string, unknown>;
|
||
if (p.type === "image_url" || p.type === "image") {
|
||
push(p.image_url ?? p.image ?? p.url);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return out.slice(0, max);
|
||
}
|
||
|
||
export function parseAdobeImageSourceBytes(source: string): {
|
||
buffer: Buffer;
|
||
contentType: string;
|
||
} {
|
||
const trimmed = String(source || "").trim();
|
||
if (!trimmed) {
|
||
throw new AdobeFireflyError("Empty image reference", 400, "bad_image");
|
||
}
|
||
|
||
const dataUri = /^data:([^;,]+)?(?:;charset=[^;,]+)?(;base64)?,([\s\S]+)$/i.exec(trimmed);
|
||
if (dataUri) {
|
||
const mime = (dataUri[1] || "image/png").trim().toLowerCase() || "image/png";
|
||
const isB64 = Boolean(dataUri[2]);
|
||
const payload = dataUri[3] || "";
|
||
if (!isB64) {
|
||
throw new AdobeFireflyError(
|
||
"Image data URL must be base64-encoded (data:image/...;base64,...)",
|
||
400,
|
||
"bad_image"
|
||
);
|
||
}
|
||
const buffer = Buffer.from(payload.replace(/\s/g, ""), "base64");
|
||
if (!buffer.length) {
|
||
throw new AdobeFireflyError("Image data URL decoded to empty bytes", 400, "bad_image");
|
||
}
|
||
if (buffer.length > ADOBE_FIREFLY_MAX_UPLOAD_BYTES) {
|
||
throw new AdobeFireflyError(
|
||
`Image reference too large (${buffer.length} bytes; max ${ADOBE_FIREFLY_MAX_UPLOAD_BYTES})`,
|
||
400,
|
||
"bad_image"
|
||
);
|
||
}
|
||
return {
|
||
buffer,
|
||
contentType: mime.startsWith("image/") ? mime : "image/png",
|
||
};
|
||
}
|
||
|
||
// Raw base64 without data: prefix
|
||
if (
|
||
!/^https?:\/\//i.test(trimmed) &&
|
||
/^[A-Za-z0-9+/=\s]+$/.test(trimmed) &&
|
||
trimmed.length > 64
|
||
) {
|
||
const buffer = Buffer.from(trimmed.replace(/\s/g, ""), "base64");
|
||
if (buffer.length > 0 && buffer.length <= ADOBE_FIREFLY_MAX_UPLOAD_BYTES) {
|
||
return { buffer, contentType: "image/png" };
|
||
}
|
||
}
|
||
|
||
throw new AdobeFireflyError(
|
||
"Unsupported image reference (need data:image/...;base64,... or raw base64). " +
|
||
"HTTP(S) URLs are resolved by the caller before upload.",
|
||
400,
|
||
"bad_image"
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Parse Firefly storage upload response: {"images":[{"id":"uuid"}]}.
|
||
*/
|
||
export function parseAdobeStorageUploadResponse(body: unknown): string {
|
||
if (!body || typeof body !== "object") return "";
|
||
const images = (body as Record<string, unknown>).images;
|
||
if (Array.isArray(images) && images.length > 0) {
|
||
const first = images[0];
|
||
if (first && typeof first === "object") {
|
||
const id = (first as Record<string, unknown>).id;
|
||
if (typeof id === "string" && id.trim()) return id.trim();
|
||
}
|
||
}
|
||
const id = (body as Record<string, unknown>).id;
|
||
if (typeof id === "string" && id.trim()) return id.trim();
|
||
return "";
|
||
}
|
||
|
||
/**
|
||
* Upload one image to Firefly storage → blob id for referenceBlobs.
|
||
* Wire: POST https://firefly-3p.ff.adobe.io/v2/storage/image (raw bytes).
|
||
*/
|
||
export async function uploadAdobeFireflyImage(opts: {
|
||
accessToken: string;
|
||
bytes: Buffer | Uint8Array;
|
||
contentType?: string;
|
||
sessionCookie?: string;
|
||
/** 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<string> {
|
||
const fetchImpl = opts.fetchImpl || fetch;
|
||
const buffer = Buffer.isBuffer(opts.bytes) ? opts.bytes : Buffer.from(opts.bytes);
|
||
if (!buffer.length) {
|
||
throw new AdobeFireflyError("Cannot upload empty image", 400, "bad_image");
|
||
}
|
||
if (buffer.length > ADOBE_FIREFLY_MAX_UPLOAD_BYTES) {
|
||
throw new AdobeFireflyError(
|
||
`Image reference too large (${buffer.length} bytes; max ${ADOBE_FIREFLY_MAX_UPLOAD_BYTES})`,
|
||
400,
|
||
"bad_image"
|
||
);
|
||
}
|
||
|
||
const sessionCookie = String(opts.sessionCookie || "").trim();
|
||
const cookieHeader = extractAdobeCookieHeader(sessionCookie);
|
||
// 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: Uint8Array.from(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<string[]> {
|
||
const max = Math.max(1, Math.min(8, opts.max ?? 4));
|
||
const sources = extractAdobeSourceImageSources(opts.body, max);
|
||
if (!sources.length) return [];
|
||
|
||
const fetchImpl = opts.fetchImpl || fetch;
|
||
const ids: string[] = [];
|
||
// 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<string, string> {
|
||
// Live adobe/status_check.txt: Bearer + accept only (no x-api-key, no Cookie).
|
||
return {
|
||
Authorization: `Bearer ${accessToken}`,
|
||
accept: "*/*",
|
||
"cache-control": "no-cache",
|
||
pragma: "no-cache",
|
||
"user-agent": DEFAULT_USER_AGENT,
|
||
referer: FIREFLY_REFERER,
|
||
};
|
||
}
|
||
|
||
export function buildAdobeBalanceHeaders(accessToken: string): Record<string, string> {
|
||
const accountId = extractAdobeAccountIdFromToken(accessToken);
|
||
const headers: Record<string, string> = {
|
||
...browserHeaders(),
|
||
Authorization: `Bearer ${accessToken}`,
|
||
accept: "application/json",
|
||
"content-type": "application/json",
|
||
"x-api-key": adobeFireflyBalanceApiKey(),
|
||
};
|
||
if (accountId) headers["x-account-id"] = accountId;
|
||
return headers;
|
||
}
|
||
|
||
export function buildAdobeDiscoveryHeaders(accessToken: string): Record<string, string> {
|
||
return {
|
||
...browserHeaders(),
|
||
Authorization: `Bearer ${accessToken}`,
|
||
"x-api-key": adobeFireflyApiKey(),
|
||
"content-type": "application/json",
|
||
// Missing Accept → HTTP 406 "Unsupported Accept Type or not allowed".
|
||
accept: "*/*",
|
||
};
|
||
}
|
||
|
||
/** User-facing message when Adobe colligo returns 408 "system under load". */
|
||
export function formatAdobeSystemUnderLoadError(
|
||
kind: "image" | "video",
|
||
attempts: number,
|
||
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 stale or rate-limited). The app spaces submits, sticks to the last working x-arp-session-id, ` +
|
||
`and on 408 auto-warms Forter/ARP via off-screen Chrome CDP (true headless is rejected by colligo — set ADOBE_FIREFLY_CHROME_HEADLESS=1 only for debug). ` +
|
||
`Paste the full firefly.adobe.com Cookie once with the JWT so recovery can run. If it still fails after that, ` +
|
||
`open firefly.adobe.com, generate one image in-browser, then paste a FRESH multi-line credential (JWT + Cookie) once.`
|
||
);
|
||
}
|
||
|
||
export function extractAdobeResultLink(
|
||
headers: Headers | Record<string, string | null | undefined>,
|
||
body: unknown
|
||
): string {
|
||
const get = (name: string): string => {
|
||
if (typeof (headers as Headers).get === "function") {
|
||
return String((headers as Headers).get(name) || "").trim();
|
||
}
|
||
const rec = headers as Record<string, string | null | undefined>;
|
||
const key = Object.keys(rec).find((k) => k.toLowerCase() === name.toLowerCase());
|
||
return String((key ? rec[key] : "") || "").trim();
|
||
};
|
||
|
||
const override = get("x-override-status-link");
|
||
if (override) return override;
|
||
|
||
const data = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||
const links =
|
||
data.links && typeof data.links === "object" ? (data.links as Record<string, unknown>) : {};
|
||
const result = links.result;
|
||
if (typeof result === "string" && result) return result;
|
||
if (result && typeof result === "object") {
|
||
const href = (result as Record<string, unknown>).href;
|
||
if (typeof href === "string" && href) return href;
|
||
}
|
||
if (typeof data.statusUrl === "string" && data.statusUrl) return data.statusUrl;
|
||
if (typeof data.resultUrl === "string" && data.resultUrl) return data.resultUrl;
|
||
return "";
|
||
}
|
||
|
||
/**
|
||
* Rewrite Firefly EPO result links to the BKS poll endpoint used by the SPA.
|
||
*
|
||
* Live capture (adobe/status_check.txt):
|
||
* links.result = https://firefly-epo855232.adobe.io/jobs/result/{jobId}
|
||
* poll URL = https://bks-epo8552.adobe.io/v2/jobs/result/{jobId}?host=firefly-epo855232.adobe.io
|
||
*
|
||
* BKS host uses the first 4 digits of the EPO id when the id is longer (855232 → 8552).
|
||
*/
|
||
export function normalizeAdobePollUrl(rawUrl: string): string {
|
||
const url = String(rawUrl || "").trim();
|
||
if (!url) return url;
|
||
try {
|
||
const parsed = new URL(url);
|
||
const host = parsed.hostname.toLowerCase();
|
||
if (!host.startsWith("firefly-epo")) return url;
|
||
|
||
const path = parsed.pathname || "";
|
||
const isJobPath =
|
||
path.includes("/jobs/result/") || path.includes("/v2/status") || path.includes("/status/");
|
||
if (!isJobPath) return url;
|
||
|
||
const jobId = path.split("/").filter(Boolean).pop() || "";
|
||
if (!jobId || jobId === "status" || jobId === "result") return url;
|
||
|
||
const epoId = host.slice("firefly-epo".length).split(".")[0] || "";
|
||
// 855232 → 8552 (browser BKS host); short ids kept as-is.
|
||
const bksId = epoId.length > 4 ? epoId.slice(0, 4) : epoId;
|
||
return `https://bks-epo${bksId}.adobe.io/v2/jobs/result/${jobId}?host=${host}`;
|
||
} catch {
|
||
return url;
|
||
}
|
||
}
|
||
|
||
export function extractAdobeMediaUrl(latest: unknown, kind: "image" | "video"): string | null {
|
||
const body = latest && typeof latest === "object" ? (latest as Record<string, unknown>) : {};
|
||
const outputs = Array.isArray(body.outputs) ? body.outputs : [];
|
||
if (outputs.length > 0) {
|
||
const first =
|
||
outputs[0] && typeof outputs[0] === "object" ? (outputs[0] as Record<string, unknown>) : {};
|
||
const media =
|
||
kind === "image"
|
||
? first.image && typeof first.image === "object"
|
||
? (first.image as Record<string, unknown>)
|
||
: null
|
||
: first.video && typeof first.video === "object"
|
||
? (first.video as Record<string, unknown>)
|
||
: null;
|
||
const url = media && typeof media.presignedUrl === "string" ? media.presignedUrl : null;
|
||
if (url) return url;
|
||
}
|
||
|
||
// Fallback recursive search for a presigned URL.
|
||
const found = findPresignedUrl(
|
||
latest,
|
||
kind === "image" ? [".png", ".jpg", ".jpeg", ".webp"] : [".mp4", ".webm"]
|
||
);
|
||
return found;
|
||
}
|
||
|
||
function findPresignedUrl(obj: unknown, exts: string[]): string | null {
|
||
if (!obj) return null;
|
||
if (typeof obj === "string") {
|
||
const s = obj.trim();
|
||
if (
|
||
/^https?:\/\//i.test(s) &&
|
||
(exts.some((e) => s.toLowerCase().includes(e)) ||
|
||
s.includes("presigned") ||
|
||
s.includes("X-Amz"))
|
||
) {
|
||
return s;
|
||
}
|
||
return null;
|
||
}
|
||
if (Array.isArray(obj)) {
|
||
for (const item of obj) {
|
||
const found = findPresignedUrl(item, exts);
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
}
|
||
if (typeof obj === "object") {
|
||
const rec = obj as Record<string, unknown>;
|
||
if (typeof rec.presignedUrl === "string" && rec.presignedUrl) return rec.presignedUrl;
|
||
for (const value of Object.values(rec)) {
|
||
const found = findPresignedUrl(value, exts);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function isAdobeJobInProgress(status: string): boolean {
|
||
const s = String(status || "").toUpperCase();
|
||
return (
|
||
!s ||
|
||
s === "IN_PROGRESS" ||
|
||
s === "PENDING" ||
|
||
s === "RUNNING" ||
|
||
s === "QUEUED" ||
|
||
s === "PROCESSING" ||
|
||
s === "SUBMITTED"
|
||
);
|
||
}
|
||
|
||
export function isAdobeJobFailed(status: string): boolean {
|
||
const s = String(status || "").toUpperCase();
|
||
return s === "FAILED" || s === "CANCELLED" || s === "ERROR" || s === "CANCELED";
|
||
}
|
||
|
||
type ImsTokenResponse = {
|
||
access_token?: string;
|
||
account_type?: string;
|
||
guestId?: string;
|
||
token_type?: string;
|
||
error?: string;
|
||
error_description?: string;
|
||
};
|
||
|
||
async function imsCheckToken(opts: {
|
||
cookie: string;
|
||
clientId: string;
|
||
guestAllowed: boolean;
|
||
fetchImpl: typeof fetch;
|
||
}): Promise<
|
||
{ 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<string> {
|
||
const cookie = String(cookieHeader || "").trim();
|
||
if (!cookie) {
|
||
throw new AdobeFireflyError("Adobe Firefly cookie is empty", 401, "missing_cookie");
|
||
}
|
||
|
||
// HAR / mixed paste that already contains a user JWT
|
||
const embedded = extractAdobeCredentialToken(cookie);
|
||
if (embedded !== cookie && looksLikeAdobeJwt(embedded)) {
|
||
if (isAdobeGuestAccessToken(embedded)) {
|
||
throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token");
|
||
}
|
||
return embedded;
|
||
}
|
||
|
||
const clientIds = [adobeFireflyApiKey(), adobeFireflyExpressClientId()].filter(
|
||
(id, i, arr) => id && arr.indexOf(id) === i
|
||
);
|
||
|
||
let sawEmptySession = false;
|
||
let lastError = "";
|
||
let lastStatus = 502;
|
||
let guestTokenSeen = false;
|
||
|
||
for (const clientId of clientIds) {
|
||
// 1) Authenticated session only (needs IMS cookies from adobelogin.com)
|
||
const authed = await imsCheckToken({
|
||
cookie,
|
||
clientId,
|
||
guestAllowed: false,
|
||
fetchImpl,
|
||
});
|
||
if (authed.ok === true) {
|
||
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 === true) {
|
||
if (
|
||
guest.data.account_type === "guest" ||
|
||
guest.data.guestId ||
|
||
isAdobeGuestAccessToken(guest.token)
|
||
) {
|
||
guestTokenSeen = true;
|
||
lastError = "IMS returned a guest token (no AdobeID session)";
|
||
lastStatus = 401;
|
||
continue;
|
||
}
|
||
return guest.token;
|
||
}
|
||
lastStatus = guest.status;
|
||
lastError = guest.error;
|
||
if (/session cookies are empty/i.test(guest.error)) sawEmptySession = true;
|
||
}
|
||
|
||
if (guestTokenSeen || sawEmptySession) {
|
||
throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token");
|
||
}
|
||
|
||
throw new AdobeFireflyError(
|
||
`Adobe IMS token exchange failed (${lastStatus}): ${lastError || "no access_token"}. ${GUEST_COOKIE_HELP}`,
|
||
lastStatus === 401 || lastStatus === 403 ? 401 : 502,
|
||
"ims_refresh_failed"
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Resolve credentials into a usable **user** IMS access token (rejects guest tokens).
|
||
*/
|
||
export async function resolveAdobeAccessToken(
|
||
credentials:
|
||
| {
|
||
apiKey?: string;
|
||
accessToken?: string;
|
||
providerSpecificData?: {
|
||
cookie?: unknown;
|
||
access_token?: unknown;
|
||
accessToken?: unknown;
|
||
} | null;
|
||
}
|
||
| null
|
||
| undefined,
|
||
fetchImpl: typeof fetch = fetch
|
||
): Promise<string> {
|
||
const psd = credentials?.providerSpecificData;
|
||
const candidates: string[] = [];
|
||
const push = (v: unknown) => {
|
||
if (typeof v === "string" && v.trim()) candidates.push(v.trim());
|
||
};
|
||
push(credentials?.apiKey);
|
||
push(credentials?.accessToken);
|
||
push(psd?.access_token);
|
||
push(psd?.accessToken);
|
||
push(psd?.cookie);
|
||
|
||
if (candidates.length === 0) {
|
||
throw new AdobeFireflyError(
|
||
"Adobe Firefly credentials missing. " + GUEST_COOKIE_HELP,
|
||
401,
|
||
"missing_credentials"
|
||
);
|
||
}
|
||
|
||
for (const c of candidates) {
|
||
const extracted = extractAdobeCredentialToken(c);
|
||
if (looksLikeAdobeJwt(extracted) && isAdobeUserAccessToken(extracted)) {
|
||
return extracted;
|
||
}
|
||
}
|
||
|
||
for (const c of candidates) {
|
||
const extracted = extractAdobeCredentialToken(c);
|
||
if (looksLikeAdobeJwt(extracted) && isAdobeGuestAccessToken(extracted)) {
|
||
throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token");
|
||
}
|
||
}
|
||
|
||
const cookieBlob =
|
||
candidates.find(
|
||
(c) =>
|
||
c.includes(";") ||
|
||
c.toLowerCase().includes("aux_sid") ||
|
||
c.toLowerCase().includes("ff_session")
|
||
) || candidates[0];
|
||
|
||
const token = await exchangeAdobeCookieForAccessToken(cookieBlob, fetchImpl);
|
||
if (isAdobeGuestAccessToken(token)) {
|
||
throw new AdobeFireflyError(GUEST_COOKIE_HELP, 401, "guest_token");
|
||
}
|
||
return token;
|
||
}
|
||
|
||
// ── Credits balance (Limits) ────────────────────────────────────────────────
|
||
|
||
export interface AdobeFireflyCreditsBalance {
|
||
total: number;
|
||
used: number;
|
||
remaining: number;
|
||
availableUntil: string | null;
|
||
freeTotal: number;
|
||
freeUsed: number;
|
||
freeRemaining: number;
|
||
planTotal: number;
|
||
planUsed: number;
|
||
planRemaining: number;
|
||
raw?: unknown;
|
||
}
|
||
|
||
function readQuotaBlock(block: unknown): {
|
||
total: number;
|
||
used: number;
|
||
available: number;
|
||
} {
|
||
if (!block || typeof block !== "object") return { total: 0, used: 0, available: 0 };
|
||
const q =
|
||
(block as Record<string, unknown>).quota &&
|
||
typeof (block as Record<string, unknown>).quota === "object"
|
||
? ((block as Record<string, unknown>).quota as Record<string, unknown>)
|
||
: (block as Record<string, unknown>);
|
||
const total = Number(q.total ?? 0);
|
||
const used = Number(q.used ?? 0);
|
||
const available = Number(q.available ?? Math.max(0, total - used));
|
||
return {
|
||
total: Number.isFinite(total) ? total : 0,
|
||
used: Number.isFinite(used) ? used : 0,
|
||
available: Number.isFinite(available) ? available : 0,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Parse GET /v1/credits/balance JSON (adobe/balance.txt Response).
|
||
* total.quota = aggregate; credits.firefly_* = free + plan buckets.
|
||
*/
|
||
export function parseAdobeCreditsBalance(body: unknown): AdobeFireflyCreditsBalance {
|
||
const root = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||
const totalBlock = readQuotaBlock(root.total);
|
||
const credits =
|
||
root.credits && typeof root.credits === "object"
|
||
? (root.credits as Record<string, unknown>)
|
||
: {};
|
||
const free = readQuotaBlock(credits.firefly_free_credit);
|
||
const plan = readQuotaBlock(credits.firefly_plan_credit);
|
||
|
||
// Prefer top-level total; fall back to free+plan sum when total missing.
|
||
let total = totalBlock.total;
|
||
let used = totalBlock.used;
|
||
let remaining = totalBlock.available;
|
||
if (total <= 0 && (free.total > 0 || plan.total > 0)) {
|
||
total = free.total + plan.total;
|
||
used = free.used + plan.used;
|
||
remaining = free.available + plan.available;
|
||
}
|
||
if (remaining <= 0 && total > 0) remaining = Math.max(0, total - used);
|
||
|
||
const availableUntil =
|
||
root.total &&
|
||
typeof root.total === "object" &&
|
||
typeof (root.total as Record<string, unknown>).availableUntil === "string"
|
||
? String((root.total as Record<string, unknown>).availableUntil)
|
||
: null;
|
||
|
||
return {
|
||
total,
|
||
used,
|
||
remaining,
|
||
availableUntil,
|
||
freeTotal: free.total,
|
||
freeUsed: free.used,
|
||
freeRemaining: free.available,
|
||
planTotal: plan.total,
|
||
planUsed: plan.used,
|
||
planRemaining: plan.available,
|
||
raw: body,
|
||
};
|
||
}
|
||
|
||
export async function fetchAdobeCreditsBalance(
|
||
accessToken: string,
|
||
fetchImpl: typeof fetch = fetch
|
||
): Promise<AdobeFireflyCreditsBalance> {
|
||
const resp = await fetchImpl(ADOBE_FIREFLY_CREDITS_BALANCE_URL, {
|
||
method: "GET",
|
||
headers: buildAdobeBalanceHeaders(accessToken),
|
||
});
|
||
if (resp.status === 401 || resp.status === 403) {
|
||
throw new AdobeFireflyError("Adobe Firefly balance: token invalid or expired", 401, "auth");
|
||
}
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => "");
|
||
throw new AdobeFireflyError(
|
||
`Adobe Firefly balance failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 200))}`,
|
||
502
|
||
);
|
||
}
|
||
const data = await resp.json().catch(() => ({}));
|
||
return parseAdobeCreditsBalance(data);
|
||
}
|
||
|
||
// ── Models discovery ────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Parse POST /v2/models/discovery response into flat model/version rows.
|
||
*/
|
||
export function parseAdobeModelsDiscovery(body: unknown): AdobeFireflyDiscoveredModel[] {
|
||
return parseAdobeModelsDiscoveryContract(body);
|
||
}
|
||
|
||
export async function discoverAdobeFireflyModels(
|
||
accessToken: string,
|
||
fetchImpl: typeof fetch = fetch
|
||
): Promise<AdobeFireflyDiscoveredModel[]> {
|
||
const resp = await fetchImpl(ADOBE_FIREFLY_MODELS_DISCOVERY_URL, {
|
||
method: "POST",
|
||
headers: buildAdobeDiscoveryHeaders(accessToken),
|
||
body: JSON.stringify({ filters: { resolveSchema: true } }),
|
||
});
|
||
if (resp.status === 401 || resp.status === 403) {
|
||
throw new AdobeFireflyError(
|
||
"Adobe Firefly model discovery: token invalid or expired",
|
||
401,
|
||
"auth"
|
||
);
|
||
}
|
||
if (!resp.ok) {
|
||
const text = await resp.text().catch(() => "");
|
||
throw new AdobeFireflyError(
|
||
`Adobe Firefly model discovery failed (${resp.status}): ${sanitizeErrorMessage(text.slice(0, 200))}`,
|
||
502
|
||
);
|
||
}
|
||
const data = await resp.json().catch(() => ({}));
|
||
return parseAdobeModelsDiscovery(data);
|
||
}
|
||
|
||
async function sleep(ms: number): Promise<void> {
|
||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
export async function pollAdobeJob(opts: {
|
||
pollUrl: string;
|
||
accessToken: string;
|
||
kind: "image" | "video";
|
||
timeoutMs: number;
|
||
pollIntervalMs?: number;
|
||
/** Optional session cookie so a mid-poll 401 can renew JWT once via CDP. */
|
||
sessionCookie?: string;
|
||
sessionFingerprint?: string;
|
||
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 = {};
|
||
let accessToken = opts.accessToken;
|
||
let authRefreshAttempted = false;
|
||
|
||
while (Date.now() < deadline) {
|
||
attempt += 1;
|
||
const pollResp = await fetchImpl(opts.pollUrl, {
|
||
method: "GET",
|
||
headers: buildAdobePollHeaders(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"
|
||
);
|
||
}
|
||
// One CDP JWT renewal mid-poll (long jobs can outlive a near-expiry IMS token).
|
||
if (!authRefreshAttempted && opts.sessionCookie) {
|
||
authRefreshAttempted = true;
|
||
try {
|
||
const {
|
||
rotateAdobeFireflySessionOnError,
|
||
fingerprintAdobeCredential,
|
||
estimateAdobeTokenExpiry,
|
||
} = await import("./adobeFireflySession.ts");
|
||
const fp =
|
||
String(opts.sessionFingerprint || "").trim() ||
|
||
fingerprintAdobeCredential(
|
||
[accessToken, opts.sessionCookie].filter(Boolean).join("\n")
|
||
);
|
||
const refreshed = await rotateAdobeFireflySessionOnError(
|
||
{
|
||
accessToken,
|
||
cookie: opts.sessionCookie,
|
||
arpSessionId: "",
|
||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||
updatedAt: Date.now(),
|
||
fingerprint: fp,
|
||
source: "rebuild",
|
||
},
|
||
{ attempt: 3, authFailure: true, tryBrowser: true, log: opts.log }
|
||
);
|
||
if (refreshed?.accessToken && isAdobeUserAccessToken(refreshed.accessToken)) {
|
||
accessToken = refreshed.accessToken;
|
||
opts.log?.info?.(
|
||
"ADOBE-FIREFLY",
|
||
`poll auth ${pollResp.status}; retrying once with renewed JWT`
|
||
);
|
||
continue;
|
||
}
|
||
} catch {
|
||
/* fall through to auth error */
|
||
}
|
||
}
|
||
throw new AdobeFireflyError("Adobe Firefly token invalid or expired", 401, "auth");
|
||
}
|
||
|
||
if (!pollResp.ok) {
|
||
const text = await pollResp.text().catch(() => "");
|
||
if (
|
||
pollResp.status === 408 ||
|
||
pollResp.status === 429 ||
|
||
pollResp.status === 451 ||
|
||
pollResp.status >= 500 ||
|
||
isAdobeTransientSubmitError(pollResp.status, text)
|
||
) {
|
||
opts.log?.info?.("ADOBE-FIREFLY", `poll temporary ${pollResp.status}, attempt #${attempt}`);
|
||
await sleep(interval);
|
||
continue;
|
||
}
|
||
throw new AdobeFireflyError(
|
||
`Adobe Firefly poll failed (${pollResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`,
|
||
502
|
||
);
|
||
}
|
||
|
||
latest = await pollResp.json().catch(() => ({}));
|
||
const statusHeader = String(pollResp.headers.get("x-task-status") || "").toUpperCase();
|
||
const statusVal = String(
|
||
(latest && typeof latest === "object" ? (latest as Record<string, unknown>).status : "") ||
|
||
statusHeader ||
|
||
""
|
||
).toUpperCase();
|
||
|
||
const mediaUrl = extractAdobeMediaUrl(latest, opts.kind);
|
||
if (mediaUrl) {
|
||
return { mediaUrl, latest };
|
||
}
|
||
|
||
if (isAdobeJobFailed(statusVal)) {
|
||
throw new AdobeFireflyError(
|
||
`Adobe Firefly ${opts.kind} job failed: ${sanitizeErrorMessage(JSON.stringify(latest).slice(0, 300))}`,
|
||
502,
|
||
"job_failed"
|
||
);
|
||
}
|
||
|
||
opts.log?.info?.(
|
||
"ADOBE-FIREFLY",
|
||
`${opts.kind} pending #${attempt} status=${statusVal || "unknown"}`
|
||
);
|
||
await sleep(interval);
|
||
}
|
||
|
||
throw new AdobeFireflyError(`Adobe Firefly ${opts.kind} generation timed out`, 504, "timeout");
|
||
}
|
||
|
||
// Colligo often returns instant 408 with x-colligo-timeout:0.0 under load OR when
|
||
// generate-async is hammered in a batch. Space submits (gate) + reuse sticky ARP;
|
||
// do NOT thrash synthetic rebuilds on every retry (identical forter → no-op).
|
||
// More attempts: 1–2 reuse sticky ARP when forter is fresh; stale forter / attempt 3+ → off-screen Chrome warm.
|
||
const SUBMIT_MAX_ATTEMPTS = 5;
|
||
/** Base backoff after 408; combined with withAdobeFireflySubmitGate (~12s min gap). */
|
||
function submitBaseDelayMs(): number {
|
||
if (
|
||
process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS != null &&
|
||
process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS !== ""
|
||
) {
|
||
return Math.max(0, Number(process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS) || 0);
|
||
}
|
||
if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT)
|
||
return 20;
|
||
return 8000;
|
||
}
|
||
|
||
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;
|
||
/** Session cache key from ensureAdobeFireflySession — sticky ARP across batch jobs. */
|
||
sessionFingerprint?: string;
|
||
/** Chrome profile key (provider connection id) for CDP warm/login isolation. */
|
||
sessionBrowserKey?: 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();
|
||
let activeCookie = extractAdobeCookieHeader(sessionCookie) || 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(activeCookie);
|
||
let arpSessionId =
|
||
(opts.arpSessionId && String(opts.arpSessionId).trim()) ||
|
||
resolveAdobeArpSessionId(activeCookie);
|
||
let submitData: unknown = {};
|
||
let submitHeaders: Headers | Record<string, string | null | undefined> = new Headers();
|
||
let lastSubmitError = "";
|
||
let sawSystemUnderLoad = false;
|
||
let accessToken = opts.accessToken;
|
||
let authRefreshAttempted = false;
|
||
|
||
const {
|
||
withAdobeFireflySubmitGate,
|
||
markAdobeFireflyArpSuccess,
|
||
noteAdobeFireflySubmitFailure,
|
||
rotateAdobeFireflySessionOnError,
|
||
resolveAdobeArpSessionIdSmart,
|
||
fingerprintAdobeCredential,
|
||
estimateAdobeTokenExpiry,
|
||
} = await import("./adobeFireflySession.ts");
|
||
|
||
// Stable sticky key — do NOT include arpSessionId (it changes and would break sticky).
|
||
const fingerprint =
|
||
String(opts.sessionFingerprint || "").trim() ||
|
||
fingerprintAdobeCredential([accessToken, activeCookie].filter(Boolean).join("\n"));
|
||
const browserSessionKey = String(opts.sessionBrowserKey || "").trim() || fingerprint;
|
||
|
||
// Gate ONLY the actual generate-async HTTP call (min gap). CDP warm / backoff run
|
||
// outside so interactive browser login and other Firefly submits are not blocked for minutes.
|
||
let submitOk = false;
|
||
for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) {
|
||
const submitResp = await withAdobeFireflySubmitGate(() =>
|
||
fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, {
|
||
method: "POST",
|
||
headers: buildAdobeSubmitHeaders(accessToken, {
|
||
arpSessionId,
|
||
prompt: opts.prompt,
|
||
cookie: activeCookie || undefined,
|
||
}),
|
||
body: JSON.stringify(payload),
|
||
})
|
||
);
|
||
|
||
if (submitResp.status === 401 || submitResp.status === 403) {
|
||
noteAdobeFireflySubmitFailure();
|
||
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"
|
||
);
|
||
}
|
||
if (!authRefreshAttempted && attempt < SUBMIT_MAX_ATTEMPTS) {
|
||
authRefreshAttempted = true;
|
||
const refreshed = await rotateAdobeFireflySessionOnError(
|
||
{
|
||
accessToken,
|
||
cookie: activeCookie,
|
||
arpSessionId,
|
||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||
updatedAt: Date.now(),
|
||
fingerprint,
|
||
browserSessionKey,
|
||
source: "rebuild",
|
||
},
|
||
{ attempt, authFailure: true, tryBrowser: true, log: opts.log }
|
||
).catch(() => null);
|
||
if (refreshed?.accessToken && refreshed?.arpSessionId) {
|
||
accessToken = refreshed.accessToken;
|
||
activeCookie = refreshed.cookie || activeCookie;
|
||
arpSessionId = refreshed.arpSessionId;
|
||
opts.log?.info?.(
|
||
"ADOBE-FIREFLY",
|
||
`image submit auth ${submitResp.status}; retrying once with renewed CDP session`
|
||
);
|
||
continue;
|
||
}
|
||
}
|
||
throw new AdobeFireflyError(
|
||
"Adobe Firefly session is no longer authenticated and automatic browser renewal failed. " +
|
||
"Sign in once through the Adobe Firefly browser login to restore durable renewal.",
|
||
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) {
|
||
const { getAdobeForterAgeMs: forterAgeMsFn, extractAdobeForterTimestampMs: forterTsFn } =
|
||
await import("./adobeFireflySession.ts");
|
||
// Only treat as known-stale when the cookie embeds a parseable forter timestamp.
|
||
// Missing timestamp (tests / synthetic ARP) must keep the full retry ladder.
|
||
const forterTs = forterTsFn(activeCookie || "");
|
||
const forterAgeBefore = forterAgeMsFn(activeCookie || "");
|
||
const forterKnownStale =
|
||
forterTs > 0 && Number.isFinite(forterAgeBefore) && forterAgeBefore > 4 * 60_000;
|
||
// Stale risk session: at most 2 attempts (warm once + one retry). Avoid ~600s thrash.
|
||
if (forterKnownStale && attempt >= 2) {
|
||
noteAdobeFireflySubmitFailure();
|
||
throw new AdobeFireflyError(
|
||
formatAdobeSystemUnderLoadError("image", attempt, { hadBrowserArp }) +
|
||
" Risk session looks expired — open Providers → Adobe Firefly → Sign in with browser once.",
|
||
408,
|
||
"system_under_load"
|
||
);
|
||
}
|
||
try {
|
||
if (activeCookie) {
|
||
const rotated = await rotateAdobeFireflySessionOnError(
|
||
{
|
||
accessToken,
|
||
cookie: activeCookie,
|
||
arpSessionId,
|
||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||
updatedAt: Date.now(),
|
||
fingerprint,
|
||
browserSessionKey,
|
||
source: "rebuild",
|
||
},
|
||
{
|
||
// Stale forter warms immediately; fresh forter quiet-reuses on 1–2 then warms.
|
||
attempt,
|
||
tryBrowser: process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0",
|
||
log: opts.log,
|
||
}
|
||
);
|
||
accessToken = rotated.accessToken || accessToken;
|
||
activeCookie = rotated.cookie || activeCookie;
|
||
arpSessionId = rotated.arpSessionId;
|
||
} else {
|
||
arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, {
|
||
rotate: true,
|
||
});
|
||
}
|
||
} catch {
|
||
// Keep prior ARP — synthetic thrash rarely recovers colligo 408.
|
||
}
|
||
const base = submitBaseDelayMs();
|
||
const delay =
|
||
base <= 50
|
||
? base
|
||
: Math.min(90_000, base * Math.pow(2, attempt - 1)) + Math.floor(Math.random() * 1500);
|
||
opts.log?.info?.(
|
||
"ADOBE-FIREFLY",
|
||
`image submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (recovery attempt=${attempt})`
|
||
);
|
||
await sleep(delay);
|
||
continue;
|
||
}
|
||
noteAdobeFireflySubmitFailure();
|
||
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;
|
||
// Sticky: remember ARP that colligo accepted so the next batch image reuses it.
|
||
markAdobeFireflyArpSuccess(fingerprint, arpSessionId);
|
||
submitOk = true;
|
||
break;
|
||
}
|
||
if (!submitOk && !lastSubmitError) {
|
||
throw new AdobeFireflyError(
|
||
formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }),
|
||
408,
|
||
"system_under_load"
|
||
);
|
||
}
|
||
|
||
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,
|
||
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;
|
||
/** Session cache key from ensureAdobeFireflySession — sticky ARP across batch jobs. */
|
||
sessionFingerprint?: string;
|
||
/** Chrome profile key (provider connection id) for CDP warm/login isolation. */
|
||
sessionBrowserKey?: 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();
|
||
let activeCookie = extractAdobeCookieHeader(sessionCookie) || sessionCookie;
|
||
const hadBrowserArp = hasBrowserAdobeArpSession(activeCookie);
|
||
let arpSessionId =
|
||
(opts.arpSessionId && String(opts.arpSessionId).trim()) ||
|
||
resolveAdobeArpSessionId(activeCookie);
|
||
let submitData: unknown = {};
|
||
let submitHeaders: Headers | Record<string, string | null | undefined> = new Headers();
|
||
let lastSubmitError = "";
|
||
let sawSystemUnderLoad = false;
|
||
let accessToken = opts.accessToken;
|
||
let authRefreshAttempted = false;
|
||
|
||
const {
|
||
withAdobeFireflySubmitGate,
|
||
markAdobeFireflyArpSuccess,
|
||
noteAdobeFireflySubmitFailure,
|
||
rotateAdobeFireflySessionOnError,
|
||
resolveAdobeArpSessionIdSmart,
|
||
fingerprintAdobeCredential,
|
||
estimateAdobeTokenExpiry,
|
||
} = await import("./adobeFireflySession.ts");
|
||
|
||
const fingerprint =
|
||
String(opts.sessionFingerprint || "").trim() ||
|
||
fingerprintAdobeCredential([accessToken, activeCookie].filter(Boolean).join("\n"));
|
||
const browserSessionKey = String(opts.sessionBrowserKey || "").trim() || fingerprint;
|
||
|
||
let videoSubmitOk = false;
|
||
for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) {
|
||
const submitResp = await withAdobeFireflySubmitGate(() =>
|
||
fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, {
|
||
method: "POST",
|
||
headers: buildAdobeSubmitHeaders(accessToken, {
|
||
arpSessionId,
|
||
prompt: opts.prompt,
|
||
cookie: activeCookie || undefined,
|
||
}),
|
||
body: JSON.stringify(payload),
|
||
})
|
||
);
|
||
|
||
if (submitResp.status === 401 || submitResp.status === 403) {
|
||
noteAdobeFireflySubmitFailure();
|
||
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"
|
||
);
|
||
}
|
||
if (!authRefreshAttempted && attempt < SUBMIT_MAX_ATTEMPTS) {
|
||
authRefreshAttempted = true;
|
||
const refreshed = await rotateAdobeFireflySessionOnError(
|
||
{
|
||
accessToken,
|
||
cookie: activeCookie,
|
||
arpSessionId,
|
||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||
updatedAt: Date.now(),
|
||
fingerprint,
|
||
browserSessionKey,
|
||
source: "rebuild",
|
||
},
|
||
{ attempt, authFailure: true, tryBrowser: true, log: opts.log }
|
||
).catch(() => null);
|
||
if (refreshed?.accessToken && refreshed?.arpSessionId) {
|
||
accessToken = refreshed.accessToken;
|
||
activeCookie = refreshed.cookie || activeCookie;
|
||
arpSessionId = refreshed.arpSessionId;
|
||
opts.log?.info?.(
|
||
"ADOBE-FIREFLY",
|
||
`video submit auth ${submitResp.status}; retrying once with renewed CDP session`
|
||
);
|
||
continue;
|
||
}
|
||
}
|
||
throw new AdobeFireflyError(
|
||
"Adobe Firefly session is no longer authenticated and automatic browser renewal failed. " +
|
||
"Sign in once through the Adobe Firefly browser login to restore durable renewal.",
|
||
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 { getAdobeForterAgeMs: forterAgeMsFn, extractAdobeForterTimestampMs: forterTsFn } =
|
||
await import("./adobeFireflySession.ts");
|
||
const forterTs = forterTsFn(activeCookie || "");
|
||
const forterAgeBefore = forterAgeMsFn(activeCookie || "");
|
||
const forterKnownStale =
|
||
forterTs > 0 && Number.isFinite(forterAgeBefore) && forterAgeBefore > 4 * 60_000;
|
||
if (forterKnownStale && attempt >= 2) {
|
||
noteAdobeFireflySubmitFailure();
|
||
throw new AdobeFireflyError(
|
||
formatAdobeSystemUnderLoadError("video", attempt, { hadBrowserArp }) +
|
||
" Risk session looks expired — open Providers → Adobe Firefly → Sign in with browser once.",
|
||
408,
|
||
"system_under_load"
|
||
);
|
||
}
|
||
try {
|
||
if (activeCookie) {
|
||
const rotated = await rotateAdobeFireflySessionOnError(
|
||
{
|
||
accessToken,
|
||
cookie: activeCookie,
|
||
arpSessionId,
|
||
tokenExpiresAt: estimateAdobeTokenExpiry(accessToken),
|
||
updatedAt: Date.now(),
|
||
fingerprint,
|
||
browserSessionKey,
|
||
source: "rebuild",
|
||
},
|
||
{
|
||
attempt,
|
||
tryBrowser: process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0",
|
||
log: opts.log,
|
||
}
|
||
);
|
||
accessToken = rotated.accessToken || accessToken;
|
||
activeCookie = rotated.cookie || activeCookie;
|
||
arpSessionId = rotated.arpSessionId;
|
||
} else {
|
||
arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, {
|
||
rotate: true,
|
||
});
|
||
}
|
||
} catch {
|
||
/* keep prior ARP */
|
||
}
|
||
const base = submitBaseDelayMs();
|
||
const delay =
|
||
base <= 50
|
||
? base
|
||
: Math.min(90_000, base * Math.pow(2, attempt - 1)) + Math.floor(Math.random() * 1500);
|
||
opts.log?.info?.(
|
||
"ADOBE-FIREFLY",
|
||
`video submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (recovery attempt=${attempt})`
|
||
);
|
||
await sleep(delay);
|
||
continue;
|
||
}
|
||
noteAdobeFireflySubmitFailure();
|
||
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;
|
||
markAdobeFireflyArpSuccess(fingerprint, arpSessionId);
|
||
videoSubmitOk = true;
|
||
break;
|
||
}
|
||
if (!videoSubmitOk && !lastSubmitError) {
|
||
throw new AdobeFireflyError(
|
||
formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }),
|
||
408,
|
||
"system_under_load"
|
||
);
|
||
}
|
||
|
||
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,
|
||
kind: "video",
|
||
timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_VIDEO_TIMEOUT_MS,
|
||
sessionCookie: activeCookie || sessionCookie || undefined,
|
||
sessionFingerprint: fingerprint,
|
||
fetchImpl,
|
||
log: opts.log,
|
||
});
|
||
|
||
return { url: mediaUrl, format: "mp4", latest };
|
||
}
|