import { createHash } from "node:crypto"; const DETAIL_REPLACEMENT_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** * Every id this feature exchanges - project, chat, task and request ids - is a * canonical 8-4-4-4-12 UUID. Keep the check in one place so the API route and * its tests can never drift apart again. */ export function isDetailReplacementUuid(value: unknown): value is string { return typeof value === "string" && DETAIL_REPLACEMENT_UUID_PATTERN.test(value.trim()); } export type DetailReplacementBatchReference = { id: string; label: string; imageUrl: string; candidateId?: string | null; passthrough?: boolean; rerunReferenceImages?: Array<{ url: string }>; }; export type DetailReplacementBatchRequest = { taskKind: "agent-detail-replacement-batch"; projectId: string; chatId: string; submissionKey?: string; model: string; prompt: string; userPrompt: string; imageSize: string; replacementStrategy: "product_only" | "scene_replace"; productImageUrls: string[]; modelImageUrl: string | null; references: DetailReplacementBatchReference[]; }; export type DetailReplacementBatchCard = { cardId: string; status: "completed" | "fallback" | "passthrough"; imageUrl: string; thumbnailUrl: string; text: string; error?: string; }; export type DetailReplacementBatchResult = { taskKind: "agent-detail-replacement-batch"; cards: DetailReplacementBatchCard[]; currentCardId: string | null; }; export function isDetailReplacementBatchRequest( value: unknown, ): value is DetailReplacementBatchRequest { return Boolean( value && typeof value === "object" && !Array.isArray(value) && (value as { taskKind?: unknown }).taskKind === "agent-detail-replacement-batch", ); } export function completedDetailReplacementCardIds( result: DetailReplacementBatchResult | null | undefined, ) { return new Set( (result?.cards || []) .filter((card) => card.status === "completed" || card.status === "passthrough") .map((card) => card.cardId), ); } /** * Identity of one user submission. Two clicks that ask for exactly the same * work produce the same key, so the second one is answered with the running * batch instead of submitting (and billing) the same images twice. A different * card selection - for example re-running a single finished card while a batch * is still going - produces a different key and is allowed to start. */ export function detailReplacementSubmissionKey(input: { projectId: string; model: string; prompt: string; userPrompt: string; imageSize: string; replacementStrategy: string; productImageUrls: string[]; modelImageUrl: string | null; references: DetailReplacementBatchReference[]; }) { const payload = JSON.stringify({ projectId: input.projectId, model: input.model, prompt: input.prompt, userPrompt: input.userPrompt, imageSize: input.imageSize, strategy: input.replacementStrategy, products: [...input.productImageUrls].sort(), modelImage: input.modelImageUrl || "", cards: [...input.references] .map((item) => [ item.id, item.imageUrl, item.passthrough ? 1 : 0, (item.rerunReferenceImages || []).map((entry) => entry.url).join(","), ]) .sort((left, right) => String(left[0]).localeCompare(String(right[0]))), }); return createHash("sha256").update(payload).digest("hex"); } export function detailReplacementCardMessageId(taskId: string, cardId: string) { const hex = createHash("sha256").update(`${taskId}:${cardId}`).digest("hex").slice(0, 32); return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20, 32)].join("-"); }