import "server-only"; import { and, eq } from "drizzle-orm"; import { normalizeReplacementResultImage, } from "@/app/(chat)/api/chat/_lib/detail-replacement"; import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence"; import { generateCanvasImage, type CanvasGenerateImageResult, } from "@/lib/canvas/generate-image"; import { buildCanvasLayoutReplacePrompt, rankCanvasLayoutReplaceModels, } from "@/lib/canvas/layout-replace-model-selection"; import { resolveCanvasLayoutReplaceSourceProfile } from "@/lib/canvas/layout-replace-source-profile"; import { prepareLayoutReplaceInputs } from "./layout-replace-inputs"; import { runValidatedLayoutReplacement } from "./layout-replace-validation"; import { verifyCanvasLayoutReplacement } from "./layout-replace-verifier"; import { db } from "@/lib/db"; import { getEnabledAiModelsWithProvider } from "@/lib/db/queries"; import { generatedImage } from "@/lib/db/schema"; export interface CanvasLayoutReplaceTaskRequest { taskKind: "canvas-layout-replace"; sourceImageUrl: string; referenceImageUrl: string; supplementalPrompt?: string; model?: string; imageSize?: string; sourceNodeId?: string; agentProjectId?: string; referenceLabel?: string; debugMeta?: Record; batchId?: string; batchItemId?: string; batchPosition?: number; batchTotal?: number; canvasProjectId?: string; } function normalizeHttpUrl(value: unknown) { const url = String(value || "").trim(); return /^https?:\/\//i.test(url) ? url : ""; } export function isCanvasLayoutReplaceTaskRequest( request: unknown, ): request is CanvasLayoutReplaceTaskRequest { return Boolean( request && typeof request === "object" && !Array.isArray(request) && (request as { taskKind?: unknown }).taskKind === "canvas-layout-replace", ); } function normalizeLayoutReplacePromptSupplement(value: unknown) { return String(value || "") .trim() .replace(/\s+/g, " ") .slice(0, 600); } async function saveLayoutReplaceResultToGallery(params: { request: CanvasLayoutReplaceTaskRequest; result: CanvasGenerateImageResult; userId: string; }) { const image = params.result.images[0]; const originalUrl = normalizeHttpUrl(image?.originalUrl || image?.thumbnailUrl); const thumbnailUrl = normalizeHttpUrl(image?.thumbnailUrl || image?.originalUrl); if (!originalUrl || !thumbnailUrl) return; const [existing] = await db .select({ id: generatedImage.id }) .from(generatedImage) .where( and( eq(generatedImage.userId, params.userId), eq(generatedImage.originalUrl, originalUrl), ), ) .limit(1); if (existing) return; const imageSize = params.request.imageSize || "2K"; const prompt = params.result.prompt || "AI 模版替换结果"; const modelId = params.result.modelRef || params.result.model || "canvas-layout-replace"; await saveGeneratedImages( `![thumb|||${originalUrl}|||${imageSize}](${thumbnailUrl})`, params.userId, null, null, prompt, modelId, { sourceImageUrls: [params.request.sourceImageUrl], relationType: "edited_from", sourceType: "canvas_layout_replace", projectId: params.request.canvasProjectId || null, userPrompt: params.request.supplementalPrompt || "AI 模版替换", optimizedPrompt: prompt, requestedImageCount: 1, imageSize, skipBilling: true, }, ); } export async function executeCanvasLayoutReplaceTask(params: { request: CanvasLayoutReplaceTaskRequest; userId: string; }): Promise { const sourceImageUrl = normalizeHttpUrl(params.request.sourceImageUrl); const referenceImageUrl = normalizeHttpUrl(params.request.referenceImageUrl); if (!sourceImageUrl) { throw new Error("缺少当前产品图片"); } if (!referenceImageUrl) { throw new Error("缺少排版参考图"); } const models = await getEnabledAiModelsWithProvider().catch((error) => { console.warn("[Canvas Layout Replace] Failed to load models:", error); return undefined; }); const rankedModels = rankCanvasLayoutReplaceModels( models || [], params.request.model, ); const selectedModel = rankedModels[0]; if (!selectedModel) { throw new Error("AI 模版替换未配置可用的非 FAL 多图编辑模型"); } const inputs = await prepareLayoutReplaceInputs(sourceImageUrl, referenceImageUrl); const referenceGeometry = inputs.referenceGeometry; const sourceProfile = await resolveCanvasLayoutReplaceSourceProfile(sourceImageUrl, inputs.source.buffer); const sourceProductAspectRatio = sourceProfile?.silhouetteAspectRatio || inputs.productAspectRatio; const finalPrompt = buildCanvasLayoutReplacePrompt({ referenceHeight: referenceGeometry.height, referenceWidth: referenceGeometry.width, sourceProductAspectRatio, sourceProductIdentity: inputs.sourceIdentity, supplementalPrompt: normalizeLayoutReplacePromptSupplement(params.request.supplementalPrompt), }); return runValidatedLayoutReplacement({ generate: async (attempt, feedback) => { // Correct a known semantic defect with the selected editor. Switching // model families can reintroduce the template product's structure. const candidate = selectedModel; return generateCanvasImage({ prompt: feedback ? `${finalPrompt}\n\n【上一结果的具体问题】${feedback}。本次必须纠正以上问题,仍严格以图1为模版底图、图2为唯一新商品。反馈与原图冲突时以原图为准,不得猜测部件数量或重新设计商品。` : finalPrompt, displayPrompt: "排版替换结果", model: candidate.model.id, ownerKey: params.userId, aspectRatio: referenceGeometry.aspectRatio, imageSize: params.request.imageSize || "2K", imageCount: 1, promptExtend: false, providerImageParams: { quality: "high" }, referenceImages: [inputs.template.url, inputs.source.url], requiredReferenceImageCount: 2, allowFailover: false, retryVariants: [], debugLabel: "canvas-layout-replace", debugMeta: { ...(params.request.debugMeta || {}), operation: "layout-replace", layoutReplaceServerAlgorithm: "template-base-product-identity/v3", layoutReplaceReferenceRoles: ["template", "new-product"], qualityAttempt: attempt + 1, sourceNodeId: params.request.sourceNodeId || null, referenceWidth: referenceGeometry.width, referenceHeight: referenceGeometry.height, referenceAspectRatio: referenceGeometry.aspectRatio, productReferenceWidth: inputs.source.width, productReferenceHeight: inputs.source.height, sourceProductAspectRatio, requestedModelRef: params.request.model || null, selectedProviderType: candidate.provider.type || null, }, }); }, normalize: async (generated) => { const firstImage = generated.images[0]; const normalizedImage = await normalizeReplacementResultImage({ generatedImageUrl: firstImage.originalUrl, generatedThumbnailUrl: firstImage.thumbnailUrl, referenceImageUrl: inputs.template.url, referenceGeometry, uploadPrefix: "canvas-layout-replace", }); return { ...generated, images: [{ originalUrl: normalizedImage.originalUrl, thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl }] }; }, verify: result => verifyCanvasLayoutReplacement({ sourceBuffer: inputs.sourceOriginalBuffer, templateBuffer: inputs.template.buffer, sourceIdentity: inputs.sourceIdentity, productBuffer: inputs.source.buffer, textBoundingBoxes: inputs.textBoundingBoxes, resultUrl: result.images[0].originalUrl, }), persist: result => saveLayoutReplaceResultToGallery({ request: params.request, result, userId: params.userId }) .catch(error => { console.error("[Canvas Layout Replace] Failed to save result to gallery:", { error: error instanceof Error ? error.message : String(error), batchId: params.request.batchId, }); }), }); }