mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-29 10:32:11 +03:00
refactor(quality): split adobeFireflyClient.ts (2,952 lines) into nine leaf modules (#11728)
Mechanical split of the 2952-line adobeFireflyClient.ts into nine leaf modules, each under the 1000-line cap — follows the existing Adobe Firefly family decomposition pattern (adobeFireflySecurity.ts, ModelSnapshot, References, Upscale, Models, Session, BrowserLogin). No behavior change. Thanks for the cleanup!
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- Split the 2,952-line `adobeFireflyClient.ts` into nine focused modules (catalog, credentials, payload, ARP session, upload, responses, IMS, account, poll) behind an unchanged barrel — no consumer edits and no public API change (#11728).
|
||||
157
open-sse/services/adobeFireflyAccount.ts
Normal file
157
open-sse/services/adobeFireflyAccount.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Adobe Firefly — credits balance and model discovery.
|
||||
*
|
||||
* Split out of adobeFireflyClient.ts.
|
||||
*/
|
||||
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import {
|
||||
type AdobeFireflyDiscoveredModel,
|
||||
parseAdobeModelsDiscovery as parseAdobeModelsDiscoveryContract,
|
||||
} from "./adobeFireflyModels.ts";
|
||||
import {
|
||||
ADOBE_FIREFLY_CREDITS_BALANCE_URL,
|
||||
ADOBE_FIREFLY_MODELS_DISCOVERY_URL,
|
||||
} from "./adobeFireflyCatalog.ts";
|
||||
import { AdobeFireflyError } from "./adobeFireflyCredentials.ts";
|
||||
import { buildAdobeBalanceHeaders, buildAdobeDiscoveryHeaders } from "./adobeFireflyResponses.ts";
|
||||
|
||||
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);
|
||||
}
|
||||
382
open-sse/services/adobeFireflyArp.ts
Normal file
382
open-sse/services/adobeFireflyArp.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Adobe Firefly — browser-session emulation: nonces, ARP session ids, request headers.
|
||||
*
|
||||
* Split out of adobeFireflyClient.ts.
|
||||
*/
|
||||
|
||||
import { stripAdobeJwts } from "./adobeFireflySecurity.ts";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import {
|
||||
DEFAULT_SEC_CH_UA,
|
||||
DEFAULT_USER_AGENT,
|
||||
FIREFLY_ORIGIN,
|
||||
FIREFLY_REFERER,
|
||||
} from "./adobeFireflyCatalog.ts";
|
||||
import {
|
||||
adobeFireflyApiKey,
|
||||
extractAdobeAccountIdFromToken,
|
||||
looksLikeAdobeJwt,
|
||||
} from "./adobeFireflyCredentials.ts";
|
||||
|
||||
export 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",
|
||||
};
|
||||
}
|
||||
282
open-sse/services/adobeFireflyCatalog.ts
Normal file
282
open-sse/services/adobeFireflyCatalog.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Adobe Firefly — endpoint URLs, tunables, and the image/video model catalog.
|
||||
*
|
||||
* Pure data: no I/O, no side effects. Split out of adobeFireflyClient.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";
|
||||
|
||||
export 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";
|
||||
export const DEFAULT_SEC_CH_UA = '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"';
|
||||
export const DEFAULT_POLL_INTERVAL_MS = 3000;
|
||||
export const DEFAULT_IMAGE_TIMEOUT_MS = 180_000;
|
||||
export const DEFAULT_VIDEO_TIMEOUT_MS = 300_000;
|
||||
export const FIREFLY_ORIGIN = "https://firefly.adobe.com";
|
||||
export 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",
|
||||
},
|
||||
};
|
||||
|
||||
export 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 },
|
||||
},
|
||||
};
|
||||
|
||||
export 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",
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
205
open-sse/services/adobeFireflyCredentials.ts
Normal file
205
open-sse/services/adobeFireflyCredentials.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Adobe Firefly — credential shapes: IMS token / cookie-blob recognition and extraction.
|
||||
*
|
||||
* Split out of adobeFireflyClient.ts.
|
||||
*/
|
||||
|
||||
import { resolvePublicCred } from "../utils/publicCreds.ts";
|
||||
import {
|
||||
decodeAdobeJwtPayload,
|
||||
findAllAdobeJwts,
|
||||
isExactAdobeJwt,
|
||||
stripAdobeJwts,
|
||||
} from "./adobeFireflySecurity.ts";
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
export 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.";
|
||||
256
open-sse/services/adobeFireflyIms.ts
Normal file
256
open-sse/services/adobeFireflyIms.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Adobe Firefly — Adobe IMS token exchange and access-token resolution.
|
||||
*
|
||||
* Split out of adobeFireflyClient.ts.
|
||||
*/
|
||||
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import {
|
||||
ADOBE_FIREFLY_IMS_REFRESH_URL,
|
||||
ADOBE_FIREFLY_IMS_SCOPE,
|
||||
DEFAULT_USER_AGENT,
|
||||
FIREFLY_ORIGIN,
|
||||
FIREFLY_REFERER,
|
||||
} from "./adobeFireflyCatalog.ts";
|
||||
import {
|
||||
AdobeFireflyError,
|
||||
GUEST_COOKIE_HELP,
|
||||
adobeFireflyApiKey,
|
||||
adobeFireflyExpressClientId,
|
||||
extractAdobeCredentialToken,
|
||||
isAdobeGuestAccessToken,
|
||||
isAdobeUserAccessToken,
|
||||
looksLikeAdobeJwt,
|
||||
} from "./adobeFireflyCredentials.ts";
|
||||
|
||||
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) ────────────────────────────────────────────────
|
||||
479
open-sse/services/adobeFireflyPayload.ts
Normal file
479
open-sse/services/adobeFireflyPayload.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* Adobe Firefly — model resolution and generate-request payload construction.
|
||||
*
|
||||
* Split out of adobeFireflyClient.ts.
|
||||
*/
|
||||
|
||||
import {
|
||||
ADOBE_FIREFLY_IMAGE_MODELS,
|
||||
ADOBE_FIREFLY_VIDEO_MODELS,
|
||||
type AdobeFireflyImageModelId,
|
||||
type AdobeFireflyImageModelSpec,
|
||||
type AdobeFireflyVideoModelId,
|
||||
type AdobeFireflyVideoModelSpec,
|
||||
NANO_SIZE_MAP,
|
||||
PIXEL_SIZE_TO_RATIO,
|
||||
} from "./adobeFireflyCatalog.ts";
|
||||
|
||||
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;
|
||||
}
|
||||
155
open-sse/services/adobeFireflyPoll.ts
Normal file
155
open-sse/services/adobeFireflyPoll.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Adobe Firefly — async job polling.
|
||||
*
|
||||
* Split out of adobeFireflyClient.ts.
|
||||
*/
|
||||
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { DEFAULT_POLL_INTERVAL_MS } from "./adobeFireflyCatalog.ts";
|
||||
import { AdobeFireflyError, isAdobeUserAccessToken } from "./adobeFireflyCredentials.ts";
|
||||
import {
|
||||
buildAdobePollHeaders,
|
||||
extractAdobeMediaUrl,
|
||||
isAdobeJobFailed,
|
||||
isAdobeTransientSubmitError,
|
||||
} from "./adobeFireflyResponses.ts";
|
||||
|
||||
export 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.
|
||||
230
open-sse/services/adobeFireflyResponses.ts
Normal file
230
open-sse/services/adobeFireflyResponses.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Adobe Firefly — upstream response parsing and job-status helpers.
|
||||
*
|
||||
* Split out of adobeFireflyClient.ts.
|
||||
*/
|
||||
|
||||
import { browserHeaders } from "./adobeFireflyArp.ts";
|
||||
import { DEFAULT_USER_AGENT, FIREFLY_REFERER } from "./adobeFireflyCatalog.ts";
|
||||
import {
|
||||
adobeFireflyApiKey,
|
||||
adobeFireflyBalanceApiKey,
|
||||
extractAdobeAccountIdFromToken,
|
||||
} from "./adobeFireflyCredentials.ts";
|
||||
|
||||
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";
|
||||
}
|
||||
347
open-sse/services/adobeFireflyUpload.ts
Normal file
347
open-sse/services/adobeFireflyUpload.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Adobe Firefly — source-image extraction and upload to Firefly storage.
|
||||
*
|
||||
* Split out of adobeFireflyClient.ts.
|
||||
*/
|
||||
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import {
|
||||
ADOBE_FIREFLY_MAX_UPLOAD_BYTES,
|
||||
buildAdobeUploadHeaders,
|
||||
resolveAdobeArpSessionId,
|
||||
} from "./adobeFireflyArp.ts";
|
||||
import { ADOBE_FIREFLY_IMAGE_UPLOAD_URL } from "./adobeFireflyCatalog.ts";
|
||||
import { AdobeFireflyError, extractAdobeCookieHeader } from "./adobeFireflyCredentials.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;
|
||||
}
|
||||
Reference in New Issue
Block a user