From c27a7077760c7951ab67995fcdf17bbdc04e7f02 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 18 Aug 2026 18:43:17 +0800 Subject: [PATCH] fix world labs task polling fallback for dynamic response payloads --- app/api/canvas/world/tasks/[taskId]/route.ts | 165 ++++- components/canvas/AiWashWorkspace.tsx | 7 +- components/canvas/CanvasChatPanel.tsx | 7 +- components/canvas/nodes/ImageNode.tsx | 7 +- components/style-pro/StyleProWorkspace.tsx | 7 +- lib/canvas/layout-replace-model-selection.ts | 190 ++++++ lib/canvas/layout-replace-source-profile.ts | 70 +++ lib/canvas/layout-replace-task.ts | 578 ++---------------- lib/client/canvas-image-download.ts | 155 ++++- lib/security/rate-limiter.ts | 3 + proxy.ts | 70 ++- scripts/prepare-official-model-avatars.mjs | 57 ++ scripts/upload-official-model-avatars.ts | 36 ++ .../verify-canvas-layout-replace-quality.ts | 164 +++++ .../routes/canvas-download-rate-limit.test.ts | 107 ++++ .../canvas-layout-replace-entry.test.ts | 90 +++ 16 files changed, 1126 insertions(+), 587 deletions(-) create mode 100644 lib/canvas/layout-replace-model-selection.ts create mode 100644 lib/canvas/layout-replace-source-profile.ts create mode 100644 scripts/prepare-official-model-avatars.mjs create mode 100644 scripts/upload-official-model-avatars.ts create mode 100644 scripts/verify-canvas-layout-replace-quality.ts create mode 100644 tests/routes/canvas-download-rate-limit.test.ts diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts index a96b2948f..e2c99b272 100644 --- a/app/api/canvas/world/tasks/[taskId]/route.ts +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -368,33 +368,96 @@ async function updatePersistedWorldAsset(params: { function extractWorldId(operation: WorldLabsOperation) { const response = getObjectValue(operation.response); const metadata = getObjectValue(operation.metadata); - return getStringValue( - response?.world_id, - response?.worldId, - metadata?.world_id, - metadata?.worldId - ); + const nestedWorldId = + getStringValue(response?.world_id, response?.worldId) || + findNestedString(response, ["world_id", "worldId"]); + const metadataWorldId = + getStringValue(metadata?.world_id, metadata?.worldId) || + findNestedString(metadata, ["world_id", "worldId"]); + + return getStringValue(nestedWorldId, metadataWorldId); +} + +function isWorldLikePayload(value: unknown): value is WorldLabsWorld { + const record = getObjectValue(value); + if (!record) return false; + + const hasCoreWorldField = + typeof record.world_id === "string" || + typeof record.world_marble_url === "string" || + typeof record.display_name === "string" || + typeof record.worldId === "string"; + const hasPossibleWorldContainerFields = record.assets || record.permission || record.tags || record.world_prompt; + const hasModelAliasOnly = + typeof record.model === "string" && /world/i.test(record.model); + + return Boolean(hasCoreWorldField || hasPossibleWorldContainerFields || hasModelAliasOnly); +} + +function extractWorldCandidate(value: unknown, depth = 0): WorldLabsWorld | null { + if (depth > 8) return null; + const record = getObjectValue(value); + if (!record) return null; + + if (isWorldLikePayload(record)) return record; + + const childCandidates: Array | null> = [ + getObjectValue(record.world), + getObjectValue(record.result), + getObjectValue(record.output), + getObjectValue(record.data), + getObjectValue(record.payload), + ]; + + for (const child of childCandidates) { + if (!child) continue; + if (isWorldLikePayload(child)) return child; + const nested = extractWorldCandidate(child, depth + 1); + if (nested) return nested; + } + + for (const child of Object.values(record)) { + const nested = extractWorldCandidate(child, depth + 1); + if (nested) return nested; + } + + return null; } function extractWorldFromOperation(operation: WorldLabsOperation) { - const response = operation.response; - if (response && typeof response === "object" && "world_marble_url" in (response as Record)) { - return response as WorldLabsWorld; - } - return null; + return extractWorldCandidate(operation.response); } function hasUsableWorldAssets(world?: WorldLabsWorld | null) { const assets = world?.assets || null; if (!assets) return false; - const spzUrls = assets.splats?.spz_urls; + const meshRecord = getObjectValue(assets.mesh); + const imageryRecord = getObjectValue(assets.imagery); + const splatsRecord = getObjectValue(assets.splats); + const spzUrls = + splatsRecord?.spz_urls || splatsRecord?.spzUrls || splatsRecord?.splat_urls; return Boolean( getStringValue(assets.thumbnail_url) || - getStringValue(assets.imagery?.pano_url) || - getStringValue(assets.mesh?.collider_mesh_url) || + getStringValue( + imageryRecord?.pano_url, + imageryRecord?.panoUrl, + imageryRecord?.panorama_url, + imageryRecord?.panoramaUrl + ) || + getStringValue( + meshRecord?.collider_mesh_url, + meshRecord?.colliderMeshUrl, + meshRecord?.collider_mesh, + meshRecord?.mesh_url, + meshRecord?.meshUrl, + meshRecord?.model_url, + meshRecord?.modelUrl + ) || (spzUrls && typeof spzUrls === "object" && - Object.values(spzUrls).some((value) => typeof value === "string" && value.trim())) + Object.values(spzUrls).some( + (value) => typeof value === "string" && value.trim() + )) ); } @@ -908,22 +971,68 @@ export async function GET(request: Request, { params }: RouteParams) { let world = extractWorldFromOperation(operation); const worldId = extractWorldId(operation); if ((!world || !hasUsableWorldAssets(world)) && worldId) { - const detailedWorld = await worldLabsRequest(config, { - method: "GET", - path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`, - timeoutMs: resolveWorldLabsStatusTimeoutMs(), - }); - world = world - ? { - ...world, - ...detailedWorld, - assets: detailedWorld.assets || world.assets || null, - } - : detailedWorld; + try { + const detailedWorld = await worldLabsRequest(config, { + method: "GET", + path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`, + timeoutMs: resolveWorldLabsStatusTimeoutMs(), + }); + world = world + ? { + ...world, + ...detailedWorld, + assets: detailedWorld.assets || world.assets || null, + } + : detailedWorld; + } catch (error) { + logger.warn("world detail fetch failed, retry with running status", { + elapsedMs: Date.now() - startedAt, + taskIdLength: normalizedTaskId.length, + providerId: config.providerId || null, + worldId, + error: error instanceof Error ? error.message : "unknown", + }); + } } if (!world) { - throw new Error("World Labs 任务已完成,但未返回 world 数据"); + const pendingPayload = { + ...(getObjectValue(persistedWorldAsset?.assetPayload) || {}), + providerId: config.providerId || null, + providerName: config.providerName || null, + worldId: worldId || null, + worldUrl: null, + displayName: null, + model: null, + assetsPending: true, + operationMetadata: operation.metadata ?? null, + }; + await updatePersistedWorldAsset({ + id: persistedWorldAsset?.id, + status: "running", + progress: 95, + assetPayload: pendingPayload, + }); + logger.warn("task completed but world payload is missing", { + elapsedMs: Date.now() - startedAt, + taskIdLength: normalizedTaskId.length, + providerId: config.providerId || null, + worldId: worldId || null, + }); + return NextResponse.json({ + success: true, + data: { + taskId: normalizedTaskId, + type: "world", + status: "running", + progress: 95, + output: operation.response ?? null, + worldId: worldId || null, + worldUrl: null, + previewImageUrl: null, + assetPayload: pendingPayload, + }, + }); } if (!hasUsableWorldAssets(world)) { diff --git a/components/canvas/AiWashWorkspace.tsx b/components/canvas/AiWashWorkspace.tsx index 07526d58a..e2f2f83f3 100644 --- a/components/canvas/AiWashWorkspace.tsx +++ b/components/canvas/AiWashWorkspace.tsx @@ -46,7 +46,10 @@ import { type AiWashControlLevel, type AiWashPreset, } from "@/lib/ai-wash/config"; -import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download"; +import { + downloadCanvasImageAsPng, + getCanvasImageDownloadErrorMessage, +} from "@/lib/client/canvas-image-download"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { cn } from "@/lib/utils"; import { imageGenerationService } from "@/services/canvas"; @@ -1019,7 +1022,7 @@ export function AiWashWorkspace({ }); toast.success("图片已开始下载"); } catch (error) { - toast.error(error instanceof Error ? error.message : "下载失败"); + toast.error(getCanvasImageDownloadErrorMessage(error)); } finally { setDownloadingResultId((current) => current === downloadId ? null : current diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 430d16b31..71d3d8b5e 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -64,6 +64,7 @@ import { } from "@/lib/client/upload-with-retry"; import { downloadCanvasImageAsPng, + getCanvasImageDownloadErrorMessage, type CanvasImageDownloadProgress, } from "@/lib/client/canvas-image-download"; import { canvasService } from "@/services/canvas"; @@ -4312,11 +4313,7 @@ export function CanvasChatPanel({ toast.message("已取消下载"); } else { console.error("下载原图失败:", error); - toast.error( - error instanceof Error - ? `${error.message},请稍后重试或重新生成图片` - : "图片源文件暂时不可访问,请稍后重试或重新生成图片" - ); + toast.error(getCanvasImageDownloadErrorMessage(error)); } } finally { if (downloadAbortControllerRef.current === controller) { diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index faa13559a..d8323cd81 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -170,6 +170,7 @@ import { import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { downloadCanvasImageAsPng, + getCanvasImageDownloadErrorMessage, type CanvasImageDownloadProgress, } from "@/lib/client/canvas-image-download"; import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2"; @@ -8331,11 +8332,7 @@ export function ImageNode({ toast.message("已取消下载"); } else { console.error("下载失败:", error); - toast.error( - error instanceof Error - ? `${error.message},源图可能已失效,请稍后重试或重新生成图片` - : "图片源文件暂时不可访问,请稍后重试或重新生成图片" - ); + toast.error(getCanvasImageDownloadErrorMessage(error)); } } finally { if (downloadAbortControllerRef.current === controller) { diff --git a/components/style-pro/StyleProWorkspace.tsx b/components/style-pro/StyleProWorkspace.tsx index 5fc2d065f..42ce88349 100644 --- a/components/style-pro/StyleProWorkspace.tsx +++ b/components/style-pro/StyleProWorkspace.tsx @@ -29,7 +29,10 @@ import { SelectTrigger, } from "@/components/ui/select"; import { imageGenerationService } from "@/services/canvas"; -import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download"; +import { + downloadCanvasImageAsPng, + getCanvasImageDownloadErrorMessage, +} from "@/lib/client/canvas-image-download"; import { cn, fetcher } from "@/lib/utils"; import type { PublicStyleProCategory, @@ -1513,7 +1516,7 @@ export function StyleProWorkspace({ }); toast.success("图片已开始下载"); } catch (error) { - toast.error(error instanceof Error ? error.message : "下载失败"); + toast.error(getCanvasImageDownloadErrorMessage(error)); } finally { setDownloadingImageId((current) => current === downloadId ? null : current diff --git a/lib/canvas/layout-replace-model-selection.ts b/lib/canvas/layout-replace-model-selection.ts new file mode 100644 index 000000000..98ed1deb1 --- /dev/null +++ b/lib/canvas/layout-replace-model-selection.ts @@ -0,0 +1,190 @@ +type LayoutReplaceModelDescriptor = { + capabilities?: unknown; + displayName?: string | null; + healthStatus?: string | null; + id: string; + imageGenConfig?: unknown; + isDefault?: boolean | null; + isSubModel?: boolean | null; + modelId?: string | null; + supportsImageGen?: boolean | null; + supportsVision?: boolean | null; +}; + +type LayoutReplaceProviderDescriptor = { + type?: string | null; +}; + +export type CanvasLayoutReplaceModelCandidate = { + model: LayoutReplaceModelDescriptor; + provider: LayoutReplaceProviderDescriptor; +}; + +const MODEL_PREFERENCE_PATTERNS: RegExp[] = [ + /^snapgen\/gpt-image-2$/i, + /^openai\/gpt-image-2-developer\/edit$/i, + /^openai\/gpt-image-2\/edit$/i, + /^bytedance\/seedream-v5(?:\.0)?-pro\/edit$/i, + /^qwen\/qwen-image-2(?:\.0)?-pro\/edit$/i, + /gpt-image.*\/edit/i, + /seedream.*\/edit/i, + /qwen-image.*\/edit/i, + /(?:^|[\s/_-])edit(?:$|[\s/_-])/i, +]; + +const PROVIDER_PREFERENCE = new Map([ + ["vip98k", 30], + ["atlascloud", 25], + ["openai-image", 20], + ["volcano-vision", 15], + ["snapgen-image", 10], +]); + +function readPositiveNumber(source: unknown, keys: string[]) { + if (!source || typeof source !== "object") return null; + const record = source as Record; + for (const key of keys) { + const parsed = Number(record[key]); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return null; +} + +function getConfiguredReferenceImageLimit(model: LayoutReplaceModelDescriptor) { + return ( + readPositiveNumber(model.imageGenConfig, [ + "maxReferenceImages", + "maxInputImages", + ]) || + readPositiveNumber(model.capabilities, [ + "maxReferenceImages", + "maxInputImages", + ]) + ); +} + +function getModelDescriptor(candidate: CanvasLayoutReplaceModelCandidate) { + return JSON.stringify([ + candidate.model.modelId, + candidate.model.displayName, + candidate.model.capabilities, + candidate.model.imageGenConfig, + ]).toLowerCase(); +} + +export function isCanvasLayoutReplaceModelEligible( + candidate: CanvasLayoutReplaceModelCandidate, +) { + if (String(candidate.provider.type || "").trim() === "fal-image") return false; + if (!candidate.model.supportsImageGen || candidate.model.isSubModel) return false; + if (candidate.model.supportsVision === false) return false; + if (candidate.model.healthStatus === "auto_disabled") return false; + + const referenceImageLimit = getConfiguredReferenceImageLimit(candidate.model); + return referenceImageLimit === null || referenceImageLimit >= 2; +} + +function getCanvasLayoutReplaceModelScore( + candidate: CanvasLayoutReplaceModelCandidate, +) { + const modelId = String(candidate.model.modelId || "").trim(); + const descriptor = getModelDescriptor(candidate); + const preferenceIndex = MODEL_PREFERENCE_PATTERNS.findIndex( + (pattern) => pattern.test(modelId) || pattern.test(descriptor), + ); + const modelScore = + preferenceIndex >= 0 ? (MODEL_PREFERENCE_PATTERNS.length - preferenceIndex) * 100 : 0; + const healthScore = candidate.model.healthStatus === "healthy" ? 50 : 0; + const providerScore = + PROVIDER_PREFERENCE.get(candidate.provider.type || "") || 0; + const defaultScore = candidate.model.isDefault ? 5 : 0; + return modelScore + healthScore + providerScore + defaultScore; +} + +export function rankCanvasLayoutReplaceModels( + models: CanvasLayoutReplaceModelCandidate[], + requestedModel?: string | null, +) { + const eligible = models.filter(isCanvasLayoutReplaceModelEligible); + const requested = String(requestedModel || "").trim(); + + return eligible.sort((left, right) => { + if (requested && requested !== "auto") { + const leftRequested = + left.model.id === requested || left.model.modelId === requested; + const rightRequested = + right.model.id === requested || right.model.modelId === requested; + if (leftRequested !== rightRequested) return leftRequested ? -1 : 1; + } + + const scoreDifference = + getCanvasLayoutReplaceModelScore(right) - + getCanvasLayoutReplaceModelScore(left); + if (scoreDifference !== 0) return scoreDifference; + return left.model.id.localeCompare(right.model.id); + }); +} + +export function buildCanvasLayoutReplacePrompt(params: { + referenceHeight?: number; + referenceWidth?: number; + sourceProductAspectRatio?: number; + supplementalPrompt?: string; +}) { + const geometry = + params.referenceWidth && params.referenceHeight + ? `${params.referenceWidth}x${params.referenceHeight}` + : "与图2完全相同"; + const supplement = String(params.supplementalPrompt || "").trim(); + const sourceProductAspectRatio = + Number.isFinite(params.sourceProductAspectRatio) && + Number(params.sourceProductAspectRatio) > 0 + ? Number(params.sourceProductAspectRatio) + : null; + const sourceProductRatioConstraint = sourceProductAspectRatio + ? `【商品外框比例硬约束】图1透明轮廓测得商品有效外框宽高比为 ${sourceProductAspectRatio.toFixed(3)}:1。结果中完整商品外框的宽高比必须保持在 ${(sourceProductAspectRatio * 0.96).toFixed(3)} 至 ${(sourceProductAspectRatio * 1.04).toFixed(3)} 之间。若与图2原商品占位冲突,应缩短商品整体高度或扩大整体宽度,绝对不能压窄商品宽度或纵向拉长。生成前必须测量外框复核。` + : ""; + const targetCanvasAspectRatio = + params.referenceWidth && params.referenceHeight + ? params.referenceWidth / params.referenceHeight + : null; + const targetProductHeightCoverage = 0.76; + const targetProductWidthCoverage = + sourceProductAspectRatio && targetCanvasAspectRatio + ? Math.max( + 0.52, + Math.min( + 0.86, + (sourceProductAspectRatio / targetCanvasAspectRatio) * + targetProductHeightCoverage, + ), + ) + : null; + const targetProductLeftEdge = targetProductWidthCoverage + ? (1 - targetProductWidthCoverage) / 2 + : null; + const targetProductRightEdge = targetProductLeftEdge + ? 1 - targetProductLeftEdge + : null; + const targetProductCoverageConstraint = targetProductWidthCoverage + ? `【商品画布占比硬约束】新商品完整外框应占最终画布宽度约 ${Math.round(targetProductWidthCoverage * 100)}%,允许范围 ${Math.round((targetProductWidthCoverage - 0.03) * 100)}% 至 ${Math.round((targetProductWidthCoverage + 0.03) * 100)}%;左外沿应到达画布 x≈${Math.round(Number(targetProductLeftEdge) * 100)}% 处,右外沿应到达 x≈${Math.round(Number(targetProductRightEdge) * 100)}% 处,两侧都不得向中间收窄;外框高度约占画布 ${Math.round(targetProductHeightCoverage * 100)}%。图2旧商品原本偏窄的占位不得继承,这是唯一允许突破图2原商品边界的地方。扩大新商品时,边柜、台灯、花瓶、摆件和椅子保持原位,并继自然遮挡在新商品前方。` + : ""; + + return [ + "执行单一商品换模版任务。图1是新商品身份图,图2是目标场景与构图模版,最终只输出一张完整成图。", + "【两图职责】以图2为最终画面底图和唯一场景真值;识别并完整移除图2中的原商品,只在原商品占据的合理空间内放入图1的新商品。图1只定义新商品本身,不得把图1的纯色/透明背景带入结果。", + "【商品刚性身份锁定,最高优先级】把图1商品视为已经抠好的真实商品像素层,优先完整复用,不要重新绘制或重新设计。只允许对整个商品做等比缩放和整体平移;禁止透视拉伸、视角重构、横向或纵向拉伸、压扁、加宽、缩短、局部缩放、局部重绘、拼接、融合旧商品、改款、简化或增删部件。宁可保留图1的原始轻微侧视角,也不得把它强行改成正视图。", + "【结构逐项核对】结果必须保持图1商品的完整外轮廓和长宽高比例,以及每块板件、柜门、玻璃、滑轨、把手、层板、洞洞板、抽屉、圆形拉手、桌面、横梁和四条桌腿的数量、形状、位置、尺寸关系、颜色及材质。尤其必须保留桌面下左侧带圆形拉手的窄抽屉及其右侧抽屉分区,不得合并成两个大抽屉。商品完整出现一次,不得裁切、复制或被旧商品残影穿插。", + sourceProductRatioConstraint, + targetProductCoverageConstraint, + "【场景与前景物件锁定】逐像素保留图2的相机机位、画幅、墙面、地面、边柜、台灯、花瓶、椅子、光影和空间关系,也必须保留兔子摆件、书、玩偶、相框、杯子及桌面小物的原样、数量和位置。先把这些独立前景物件与椅子视为不可编辑覆盖层,只移除其后方的旧商品,再让它们按原遮挡关系覆盖到新商品前方;不得把摆件当作旧商品的一部分清除。", + `【尺寸与放置】输出画布为 ${geometry}。根据图2原商品的地面接触点和整体占位,对图1商品只做统一等比缩放与整体平移;不做局部透视变形。宁可让边缘占位略有差异,也要优先保持图1商品的真实比例和结构。`, + "【融合要求】清除图2旧商品后补全被遮挡背景,再放入图1商品;添加与图2一致的自然接触阴影、环境反射、景深和色温,但这些融合不得改变商品结构、材质纹理或深浅配色。", + supplement + ? `【用户补充】${supplement}。补充要求只能约束替换细节,不得放宽上述商品刚性身份锁定与场景锁定。` + : "", + "【生成前自检】逐项比较图1与结果中的商品轮廓、比例、柜体分区、孔洞、门轨、抽屉和腿;任一结构发生拉伸、缺失、增生、错位或与旧商品融合,都必须先修正再输出。不要输出对比图、拼图、说明文字、边框、水印或多个方案。", + ] + .filter(Boolean) + .join("\n\n"); +} diff --git a/lib/canvas/layout-replace-source-profile.ts b/lib/canvas/layout-replace-source-profile.ts new file mode 100644 index 000000000..4c578c6fd --- /dev/null +++ b/lib/canvas/layout-replace-source-profile.ts @@ -0,0 +1,70 @@ +import "server-only"; + +import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; + +export type CanvasLayoutReplaceSourceProfile = { + boundingHeight: number; + boundingWidth: number; + silhouetteAspectRatio: number; + sourceHeight: number; + sourceWidth: number; + transparentPixelRatio: number; +}; + +export async function resolveCanvasLayoutReplaceSourceProfile( + sourceImageUrl: string, +): Promise { + try { + const sharp = (await import("sharp")).default; + const sourceBuffer = await fetchImageBufferFromUrl(sourceImageUrl); + const { data, info } = await sharp(sourceBuffer, { failOn: "none" }) + .rotate() + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const pixelCount = info.width * info.height; + if (pixelCount <= 0 || info.channels < 4) return null; + + let transparentPixelCount = 0; + let minX = info.width; + let minY = info.height; + let maxX = -1; + let maxY = -1; + + for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) { + const alpha = data[pixelIndex * info.channels + 3] || 0; + if (alpha < 250) transparentPixelCount += 1; + if (alpha < 24) continue; + + const x = pixelIndex % info.width; + const y = Math.floor(pixelIndex / info.width); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + + const transparentPixelRatio = transparentPixelCount / pixelCount; + if (transparentPixelRatio < 0.01 || maxX < minX || maxY < minY) { + return null; + } + + const boundingWidth = maxX - minX + 1; + const boundingHeight = maxY - minY + 1; + if (boundingWidth < 2 || boundingHeight < 2) return null; + + return { + boundingHeight, + boundingWidth, + silhouetteAspectRatio: boundingWidth / boundingHeight, + sourceHeight: info.height, + sourceWidth: info.width, + transparentPixelRatio, + }; + } catch (error) { + console.warn("[Canvas Layout Replace] Failed to resolve source profile:", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} diff --git a/lib/canvas/layout-replace-task.ts b/lib/canvas/layout-replace-task.ts index 28e4d3d1c..72a1af647 100644 --- a/lib/canvas/layout-replace-task.ts +++ b/lib/canvas/layout-replace-task.ts @@ -2,25 +2,22 @@ import "server-only"; import { and, eq } from "drizzle-orm"; import { - analyzeReplacementExecutionPlan, - buildAgentReplacementPrompt, - createAgentImageThumbnailBuffer, normalizeReplacementResultImage, - resolveDirectReplacementModelRef, resolveReplacementReferenceGeometry, - resolveReplacementScenePlanningRuntime, - type AgentDetailReplacementStrategy, } from "@/app/(chat)/api/chat/_lib/detail-replacement"; import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence"; -import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; -import { db } from "@/lib/db"; -import { getEnabledAiModelsWithProvider } from "@/lib/db/queries"; -import { generatedImage } from "@/lib/db/schema"; import { generateCanvasImage, type CanvasGenerateImageResult, } from "@/lib/canvas/generate-image"; -import { uploadFile } from "@/lib/storage/service"; +import { + buildCanvasLayoutReplacePrompt, + rankCanvasLayoutReplaceModels, +} from "@/lib/canvas/layout-replace-model-selection"; +import { resolveCanvasLayoutReplaceSourceProfile } from "@/lib/canvas/layout-replace-source-profile"; +import { db } from "@/lib/db"; +import { getEnabledAiModelsWithProvider } from "@/lib/db/queries"; +import { generatedImage } from "@/lib/db/schema"; export interface CanvasLayoutReplaceTaskRequest { taskKind: "canvas-layout-replace"; @@ -63,13 +60,6 @@ function normalizeLayoutReplacePromptSupplement(value: unknown) { .slice(0, 600); } -function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { - return buffer.buffer.slice( - buffer.byteOffset, - buffer.byteOffset + buffer.byteLength, - ) as ArrayBuffer; -} - async function saveLayoutReplaceResultToGallery(params: { request: CanvasLayoutReplaceTaskRequest; result: CanvasGenerateImageResult; @@ -86,15 +76,16 @@ async function saveLayoutReplaceResultToGallery(params: { .where( and( eq(generatedImage.userId, params.userId), - eq(generatedImage.originalUrl, originalUrl) - ) + 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"; + const modelId = + params.result.modelRef || params.result.model || "canvas-layout-replace"; await saveGeneratedImages( `![thumb|||${originalUrl}|||${imageSize}](${thumbnailUrl})`, params.userId, @@ -112,413 +103,10 @@ async function saveLayoutReplaceResultToGallery(params: { requestedImageCount: 1, imageSize, skipBilling: true, - } + }, ); } -function selectLayoutPatchMask(params: { - generated: Buffer; - height: number; - reference: Buffer; - width: number; -}) { - const { generated, height, reference, width } = params; - const pixelCount = width * height; - const rawMask = new Uint8Array(pixelCount); - const diffThreshold = 34; - - for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) { - const offset = pixelIndex * 3; - const diff = Math.max( - Math.abs((generated[offset] || 0) - (reference[offset] || 0)), - Math.abs((generated[offset + 1] || 0) - (reference[offset + 1] || 0)), - Math.abs((generated[offset + 2] || 0) - (reference[offset + 2] || 0)), - ); - if (diff >= diffThreshold) { - rawMask[pixelIndex] = 1; - } - } - - const visited = new Uint8Array(pixelCount); - const queue = new Int32Array(pixelCount); - const components: Array<{ - area: number; - maxX: number; - maxY: number; - minX: number; - minY: number; - }> = []; - - for (let start = 0; start < pixelCount; start += 1) { - if (!rawMask[start] || visited[start]) continue; - let head = 0; - let tail = 0; - let area = 0; - let minX = width; - let minY = height; - let maxX = 0; - let maxY = 0; - queue[tail] = start; - tail += 1; - visited[start] = 1; - - while (head < tail) { - const current = queue[head]; - head += 1; - area += 1; - const x = current % width; - const y = Math.floor(current / width); - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - - const left = current - 1; - const right = current + 1; - const up = current - width; - const down = current + width; - if (x > 0 && rawMask[left] && !visited[left]) { - visited[left] = 1; - queue[tail] = left; - tail += 1; - } - if (x < width - 1 && rawMask[right] && !visited[right]) { - visited[right] = 1; - queue[tail] = right; - tail += 1; - } - if (y > 0 && rawMask[up] && !visited[up]) { - visited[up] = 1; - queue[tail] = up; - tail += 1; - } - if (y < height - 1 && rawMask[down] && !visited[down]) { - visited[down] = 1; - queue[tail] = down; - tail += 1; - } - } - - components.push({ area, maxX, maxY, minX, minY }); - } - - const minArea = Math.max(900, Math.round(pixelCount * 0.0015)); - const minBoxArea = Math.max(3200, Math.round(pixelCount * 0.008)); - const candidates = components - .filter((component) => { - const boxArea = - (component.maxX - component.minX + 1) * - (component.maxY - component.minY + 1); - return component.area >= minArea && boxArea >= minBoxArea; - }) - .sort((left, right) => right.area - left.area); - const primary = candidates[0]; - if (!primary) { - return null; - } - - const selected = candidates.filter((component, index) => { - if (index === 0) return true; - if (component.area >= primary.area * 0.18) return true; - const expandedPrimary = { - maxX: Math.min(width - 1, primary.maxX + Math.round(width * 0.05)), - maxY: Math.min(height - 1, primary.maxY + Math.round(height * 0.05)), - minX: Math.max(0, primary.minX - Math.round(width * 0.05)), - minY: Math.max(0, primary.minY - Math.round(height * 0.05)), - }; - return !( - component.maxX < expandedPrimary.minX || - component.minX > expandedPrimary.maxX || - component.maxY < expandedPrimary.minY || - component.minY > expandedPrimary.maxY - ); - }); - - const selectedMask = new Uint8Array(pixelCount); - for (const component of selected) { - for (let y = component.minY; y <= component.maxY; y += 1) { - const rowOffset = y * width; - for (let x = component.minX; x <= component.maxX; x += 1) { - const index = rowOffset + x; - if (rawMask[index]) selectedMask[index] = 255; - } - } - } - - return { - mask: selectedMask, - maskSource: "generated-diff", - selectedComponentCount: selected.length, - selectedPixelCount: selected.reduce((sum, component) => sum + component.area, 0), - }; -} - -function selectReferenceProductMask(params: { - height: number; - reference: Buffer; - width: number; -}) { - const { height, reference, width } = params; - const pixelCount = width * height; - const rawMask = new Uint8Array(pixelCount); - - for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) { - const offset = pixelIndex * 3; - const red = reference[offset] || 0; - const green = reference[offset + 1] || 0; - const blue = reference[offset + 2] || 0; - const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; - const channelSpan = Math.max(red, green, blue) - Math.min(red, green, blue); - if (luminance <= 222 || channelSpan >= 38) { - rawMask[pixelIndex] = 1; - } - } - - const visited = new Uint8Array(pixelCount); - const queue = new Int32Array(pixelCount); - const components: Array<{ - area: number; - boxArea: number; - maxX: number; - maxY: number; - minX: number; - minY: number; - }> = []; - - for (let start = 0; start < pixelCount; start += 1) { - if (!rawMask[start] || visited[start]) continue; - let head = 0; - let tail = 0; - let area = 0; - let minX = width; - let minY = height; - let maxX = 0; - let maxY = 0; - queue[tail] = start; - tail += 1; - visited[start] = 1; - - while (head < tail) { - const current = queue[head]; - head += 1; - area += 1; - const x = current % width; - const y = Math.floor(current / width); - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - - const left = current - 1; - const right = current + 1; - const up = current - width; - const down = current + width; - if (x > 0 && rawMask[left] && !visited[left]) { - visited[left] = 1; - queue[tail] = left; - tail += 1; - } - if (x < width - 1 && rawMask[right] && !visited[right]) { - visited[right] = 1; - queue[tail] = right; - tail += 1; - } - if (y > 0 && rawMask[up] && !visited[up]) { - visited[up] = 1; - queue[tail] = up; - tail += 1; - } - if (y < height - 1 && rawMask[down] && !visited[down]) { - visited[down] = 1; - queue[tail] = down; - tail += 1; - } - } - - const boxArea = (maxX - minX + 1) * (maxY - minY + 1); - components.push({ area, boxArea, maxX, maxY, minX, minY }); - } - - const minArea = Math.max(1400, Math.round(pixelCount * 0.002)); - const minBoxArea = Math.max(6000, Math.round(pixelCount * 0.012)); - const candidates = components - .filter((component) => { - const boxWidth = component.maxX - component.minX + 1; - const boxHeight = component.maxY - component.minY + 1; - const aspect = boxWidth / Math.max(1, boxHeight); - const centerY = (component.minY + component.maxY) / 2; - const looksLikeCtaButton = aspect >= 6 && boxHeight <= height * 0.08; - const looksLikeBottomUtility = centerY >= height * 0.78 && aspect >= 3.5; - return ( - component.area >= minArea && - component.boxArea >= minBoxArea && - !looksLikeCtaButton && - !looksLikeBottomUtility - ); - }) - .sort((left, right) => right.area - left.area); - const primary = candidates[0]; - if (!primary) { - return null; - } - - const selected = candidates.filter((component, index) => { - if (index === 0) return true; - if (component.area >= primary.area * 0.22) return true; - const expandedPrimary = { - maxX: Math.min(width - 1, primary.maxX + Math.round(width * 0.08)), - maxY: Math.min(height - 1, primary.maxY + Math.round(height * 0.08)), - minX: Math.max(0, primary.minX - Math.round(width * 0.08)), - minY: Math.max(0, primary.minY - Math.round(height * 0.08)), - }; - return !( - component.maxX < expandedPrimary.minX || - component.minX > expandedPrimary.maxX || - component.maxY < expandedPrimary.minY || - component.minY > expandedPrimary.maxY - ); - }); - - const selectedMask = new Uint8Array(pixelCount); - for (const component of selected) { - for (let y = component.minY; y <= component.maxY; y += 1) { - const rowOffset = y * width; - for (let x = component.minX; x <= component.maxX; x += 1) { - const index = rowOffset + x; - if (rawMask[index]) selectedMask[index] = 255; - } - } - } - - return { - mask: selectedMask, - maskSource: "reference-product", - selectedComponentCount: selected.length, - selectedPixelCount: selected.reduce((sum, component) => sum + component.area, 0), - }; -} - -async function buildLayoutLockedCompositeImage(params: { - generatedImageUrl: string; - referenceGeometry: { height: number; width: number }; - referenceImageUrl: string; -}) { - const sharp = (await import("sharp")).default; - const [referenceSourceBuffer, generatedSourceBuffer] = await Promise.all([ - fetchImageBufferFromUrl(params.referenceImageUrl), - fetchImageBufferFromUrl(params.generatedImageUrl), - ]); - const targetWidth = params.referenceGeometry.width; - const targetHeight = params.referenceGeometry.height; - const referenceBuffer = await sharp(referenceSourceBuffer, { failOn: "none" }) - .rotate() - .resize(targetWidth, targetHeight, { - fit: "fill", - withoutEnlargement: false, - }) - .png() - .toBuffer(); - const generatedBuffer = await sharp(generatedSourceBuffer, { failOn: "none" }) - .rotate() - .resize(targetWidth, targetHeight, { - background: { r: 255, g: 255, b: 255, alpha: 1 }, - fit: "contain", - position: "centre", - withoutEnlargement: false, - }) - .png() - .toBuffer(); - const [referenceRaw, generatedRaw] = await Promise.all([ - sharp(referenceBuffer, { failOn: "none" }) - .removeAlpha() - .raw() - .toBuffer({ resolveWithObject: true }), - sharp(generatedBuffer, { failOn: "none" }) - .removeAlpha() - .raw() - .toBuffer({ resolveWithObject: true }), - ]); - if ( - referenceRaw.info.width !== generatedRaw.info.width || - referenceRaw.info.height !== generatedRaw.info.height - ) { - return null; - } - - const selectedMask = - selectReferenceProductMask({ - height: referenceRaw.info.height, - reference: referenceRaw.data, - width: referenceRaw.info.width, - }) || - selectLayoutPatchMask({ - generated: generatedRaw.data, - height: referenceRaw.info.height, - reference: referenceRaw.data, - width: referenceRaw.info.width, - }); - if (!selectedMask) { - return null; - } - - const usesReferenceProductMask = selectedMask.maskSource === "reference-product"; - const firstBlurRadius = usesReferenceProductMask ? 14 : 10; - const maskThreshold = usesReferenceProductMask ? 12 : 2; - const pixelCount = targetWidth * targetHeight; - const alphaMask = await sharp(selectedMask.mask, { - raw: { - channels: 1, - height: targetHeight, - width: targetWidth, - }, - }) - .blur(firstBlurRadius) - .threshold(maskThreshold) - .blur(4) - .extractChannel(0) - .raw() - .toBuffer(); - if (alphaMask.length !== pixelCount) { - throw new Error( - `排版替换蒙版通道异常: expected ${pixelCount}, got ${alphaMask.length}`, - ); - } - const generatedPatchRaw = Buffer.alloc(pixelCount * 4); - const sourceChannels = generatedRaw.info.channels; - for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) { - const sourceOffset = pixelIndex * sourceChannels; - const targetOffset = pixelIndex * 4; - generatedPatchRaw[targetOffset] = generatedRaw.data[sourceOffset] || 0; - generatedPatchRaw[targetOffset + 1] = - generatedRaw.data[sourceOffset + 1] || 0; - generatedPatchRaw[targetOffset + 2] = - generatedRaw.data[sourceOffset + 2] || 0; - generatedPatchRaw[targetOffset + 3] = alphaMask[pixelIndex] || 0; - } - const generatedPatch = await sharp(generatedPatchRaw, { - raw: { - channels: 4, - height: targetHeight, - width: targetWidth, - }, - }) - .png() - .toBuffer(); - const compositeBuffer = await sharp(referenceBuffer, { failOn: "none" }) - .composite([{ input: generatedPatch }]) - .png() - .toBuffer(); - - return { - buffer: compositeBuffer, - height: targetHeight, - maskSource: selectedMask.maskSource, - selectedComponentCount: selectedMask.selectedComponentCount, - selectedPixelCount: selectedMask.selectedPixelCount, - width: targetWidth, - }; -} - export async function executeCanvasLayoutReplaceTask(params: { request: CanvasLayoutReplaceTaskRequest; userId: string; @@ -536,85 +124,59 @@ export async function executeCanvasLayoutReplaceTask(params: { console.warn("[Canvas Layout Replace] Failed to load models:", error); return undefined; }); - const resolvedModelRef = await resolveDirectReplacementModelRef({ - selectedChatModel: params.request.model, - finalModelId: params.request.model, - models, - }); - if (!resolvedModelRef || resolvedModelRef === "auto") { - throw new Error("当前风格化未配置可用的生图模型"); + const rankedModels = rankCanvasLayoutReplaceModels( + models || [], + params.request.model, + ); + const selectedModel = rankedModels[0]; + if (!selectedModel) { + throw new Error("AI 模版替换未配置可用的非 FAL 多图编辑模型"); } - const replacementStrategy: AgentDetailReplacementStrategy = "product_only"; - const referenceLabel = - String(params.request.referenceLabel || "").trim() || "画布排版参考图"; - const referenceGeometry = - await resolveReplacementReferenceGeometry(referenceImageUrl); - const planningRuntime = await resolveReplacementScenePlanningRuntime({ models }); - const replacementExecutionPlan = await analyzeReplacementExecutionPlan({ - modeType: "detail_replacement", - replacementStrategy, - referenceImageUrl, - referenceLabel, - productImageUrls: [sourceImageUrl], - modelImageUrl: null, - workflowPrompt: null, - planningRuntime, - models, + const [referenceGeometry, sourceProfile] = await Promise.all([ + resolveReplacementReferenceGeometry(referenceImageUrl), + resolveCanvasLayoutReplaceSourceProfile(sourceImageUrl), + ]); + const finalPrompt = buildCanvasLayoutReplacePrompt({ + referenceHeight: referenceGeometry?.height, + referenceWidth: referenceGeometry?.width, + sourceProductAspectRatio: sourceProfile?.silhouetteAspectRatio, + supplementalPrompt: normalizeLayoutReplacePromptSupplement( + params.request.supplementalPrompt, + ), }); - - const basePrompt = "仅替换参考详情图中的产品主体"; - const replacementPrompt = buildAgentReplacementPrompt({ - basePrompt, - modeType: "detail_replacement", - replacementStrategy, - marketplaceProductDetailReference: false, - referenceLabel, - referenceIndex: 0, - totalReferences: 1, - hasModelImage: false, - productImageCount: 1, - originalProductImageCount: 1, - rerunReferenceCount: 0, - templateReferenceCount: 0, - scenePlan: replacementExecutionPlan.scenePlan, - referenceGeometry, - }); - const supplement = normalizeLayoutReplacePromptSupplement( - params.request.supplementalPrompt, - ); - const finalPrompt = [ - replacementPrompt, - supplement - ? `【画布排版替换补充】${supplement}。这段只用于理解新产品材质、颜色、结构或替换细节;不得改写、移动、删除、重排参考图中的任何文字、图标、按钮、标签、分割线、装饰、留白或页面结构。` - : "", - "【最终验收标准】输出是一张与最后一张参考图相同页面比例的单页结果;文字、图标、按钮、标签、分割线、品牌区、留白和信息层级必须视觉上与参考图一致,只允许旧产品主体/产品展示区被当前产品替换。", - ] - .filter(Boolean) - .join("\n\n"); + const retryVariants = rankedModels.slice(1, 3).map((candidate, index) => ({ + allowFailover: false, + label: `non-fal-model-${index + 2}`, + model: candidate.model.id, + providerImageParams: { quality: "high" }, + })); const generated = await generateCanvasImage({ prompt: finalPrompt, displayPrompt: "排版替换结果", - model: resolvedModelRef, + model: selectedModel.model.id, ownerKey: params.userId, aspectRatio: referenceGeometry?.aspectRatio || "auto", imageSize: params.request.imageSize || "2K", imageCount: 1, promptExtend: false, + providerImageParams: { quality: "high" }, referenceImages: [sourceImageUrl, referenceImageUrl], + allowFailover: false, + retryVariants, debugLabel: "canvas-layout-replace", debugMeta: { ...(params.request.debugMeta || {}), operation: "layout-replace", - layoutReplaceServerAlgorithm: "agent-detail-replacement/product_only", + layoutReplaceServerAlgorithm: "rigid-product-scene-replace/v2", sourceNodeId: params.request.sourceNodeId || null, referenceWidth: referenceGeometry?.width, referenceHeight: referenceGeometry?.height, referenceAspectRatio: referenceGeometry?.aspectRatio, - pageKind: replacementExecutionPlan.scenePlan.pageKind, - generationRoute: replacementExecutionPlan.scenePlan.generationRoute, - promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy, + sourceProductAspectRatio: sourceProfile?.silhouetteAspectRatio, + requestedModelRef: params.request.model || null, + selectedProviderType: selectedModel.provider.type || null, }, }); const firstImage = generated.images[0]; @@ -622,64 +184,20 @@ export async function executeCanvasLayoutReplaceTask(params: { throw new Error("排版替换未返回结果图片"); } - let normalizedImage = await normalizeReplacementResultImage({ + const normalizedImage = await normalizeReplacementResultImage({ generatedImageUrl: firstImage.originalUrl, generatedThumbnailUrl: firstImage.thumbnailUrl, referenceImageUrl, referenceGeometry, - scenePlan: replacementExecutionPlan.scenePlan, uploadPrefix: "canvas-layout-replace", }); - if (referenceGeometry) { - const lockedComposite = await buildLayoutLockedCompositeImage({ - generatedImageUrl: firstImage.originalUrl, - referenceGeometry, - referenceImageUrl, - }).catch((error) => { - console.warn("[Canvas Layout Replace] Layout locked composite failed:", { - error: error instanceof Error ? error.message : String(error), - referenceImageUrl, - resultImageUrl: firstImage.originalUrl, - }); - return null; - }); - - if (lockedComposite?.buffer) { - const thumbnailBuffer = await createAgentImageThumbnailBuffer( - lockedComposite.buffer, - ); - const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const [originalUpload, thumbnailUpload] = await Promise.all([ - uploadFile( - `canvas-layout-replace-locked-orig-${suffix}.png`, - bufferToArrayBuffer(lockedComposite.buffer), - ), - uploadFile( - `canvas-layout-replace-locked-thumb-${suffix}.jpg`, - bufferToArrayBuffer(thumbnailBuffer), - ), - ]); - normalizedImage = { - originalUrl: originalUpload.url, - thumbnailUrl: thumbnailUpload.url || originalUpload.url, - imageSize: `${lockedComposite.width}x${lockedComposite.height}`, - }; - console.info("[Canvas Layout Replace] Applied layout locked composite:", { - maskSource: lockedComposite.maskSource, - referenceWidth: lockedComposite.width, - referenceHeight: lockedComposite.height, - selectedComponentCount: lockedComposite.selectedComponentCount, - selectedPixelCount: lockedComposite.selectedPixelCount, - }); - } - } - const result: CanvasGenerateImageResult = { images: [ { originalUrl: normalizedImage.originalUrl, - thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl, + thumbnailUrl: + normalizedImage.thumbnailUrl || normalizedImage.originalUrl, }, ], model: generated.model, diff --git a/lib/client/canvas-image-download.ts b/lib/client/canvas-image-download.ts index 72b4f4da5..4676f644c 100644 --- a/lib/client/canvas-image-download.ts +++ b/lib/client/canvas-image-download.ts @@ -16,6 +16,46 @@ export type CanvasImageDownloadResult = { bytes: number; }; +type CanvasImageDownloadErrorCode = + | "rate_limited" + | "source_unavailable" + | "request_failed"; + +export class CanvasImageDownloadError extends Error { + code: CanvasImageDownloadErrorCode; + retryAfterSeconds?: number; + status?: number; + + constructor( + message: string, + options: { + code: CanvasImageDownloadErrorCode; + retryAfterSeconds?: number; + status?: number; + } + ) { + super(message); + this.name = "CanvasImageDownloadError"; + this.code = options.code; + this.retryAfterSeconds = options.retryAfterSeconds; + this.status = options.status; + } +} + +export function getCanvasImageDownloadErrorMessage(error: unknown) { + if (error instanceof CanvasImageDownloadError) { + if (error.code === "rate_limited") { + return error.retryAfterSeconds + ? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试` + : "图片下载请求过于频繁,请稍后重试"; + } + if (error.code === "source_unavailable") { + return `${error.message},源图可能已失效,请稍后重试或重新生成图片`; + } + } + return error instanceof Error ? error.message : "图片下载失败,请稍后重试"; +} + type DownloadCanvasImageParams = { url: string; name?: string; @@ -24,6 +64,56 @@ type DownloadCanvasImageParams = { onProgress?: (progress: CanvasImageDownloadProgress) => void; }; +type DownloadErrorPayload = { + error?: string; + retryAfter?: number; +}; + +const MAX_RATE_LIMIT_RETRIES = 1; + +function parseRetryAfterSeconds( + response: Response, + payload: DownloadErrorPayload | null +) { + const headerValue = response.headers.get("Retry-After")?.trim() || ""; + const numericHeader = Number(headerValue); + if (Number.isFinite(numericHeader) && numericHeader >= 0) { + return Math.min(60, Math.ceil(numericHeader)); + } + + if (headerValue) { + const retryAt = Date.parse(headerValue); + if (Number.isFinite(retryAt)) { + return Math.min(60, Math.max(0, Math.ceil((retryAt - Date.now()) / 1000))); + } + } + + const payloadValue = Number(payload?.retryAfter); + return Number.isFinite(payloadValue) && payloadValue >= 0 + ? Math.min(60, Math.ceil(payloadValue)) + : 1; +} + +function waitForRetry(delayMs: number, signal?: AbortSignal) { + if (delayMs <= 0) return Promise.resolve(); + return new Promise((resolve, reject) => { + const handleAbort = () => { + clearTimeout(timer); + reject(signal?.reason || new DOMException("已取消下载", "AbortError")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", handleAbort); + resolve(); + }, delayMs); + + if (signal?.aborted) { + handleAbort(); + return; + } + signal?.addEventListener("abort", handleAbort, { once: true }); + }); +} + function parseAttachmentFilename(contentDisposition: string | null) { if (!contentDisposition) return ""; const encodedMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); @@ -155,20 +245,59 @@ export async function downloadCanvasImageAsPng({ percent: 8, }); - const response = await fetch("/api/canvas/download-image", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: urls[0], - urls, - name: filenameBase, - }), - signal, - }); + let response: Response | null = null; + let errorPayload: DownloadErrorPayload | null = null; - if (!response.ok) { - const payload = (await response.json().catch(() => null)) as { error?: string } | null; - throw new Error(payload?.error || `下载失败(${response.status})`); + for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt++) { + response = await fetch("/api/canvas/download-image", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: urls[0], + urls, + name: filenameBase, + }), + signal, + }); + + if (response.ok) break; + errorPayload = (await response.json().catch(() => null)) as + | DownloadErrorPayload + | null; + if (response.status !== 429) break; + + const retryAfterSeconds = parseRetryAfterSeconds(response, errorPayload); + if (attempt >= MAX_RATE_LIMIT_RETRIES) { + throw new CanvasImageDownloadError( + errorPayload?.error || "图片下载请求过于频繁", + { + code: "rate_limited", + retryAfterSeconds, + status: response.status, + } + ); + } + + onProgress?.({ + phase: "preparing", + message: + retryAfterSeconds > 0 + ? `请求较多,${retryAfterSeconds} 秒后自动重试...` + : "请求较多,正在自动重试...", + percent: 8, + }); + await waitForRetry(retryAfterSeconds * 1000, signal); + } + + if (!response?.ok) { + const status = response?.status; + throw new CanvasImageDownloadError( + errorPayload?.error || `下载失败(${status || "网络异常"})`, + { + code: status === 502 ? "source_unavailable" : "request_failed", + status, + } + ); } const filename = diff --git a/lib/security/rate-limiter.ts b/lib/security/rate-limiter.ts index d4fdf7683..dd08c8066 100644 --- a/lib/security/rate-limiter.ts +++ b/lib/security/rate-limiter.ts @@ -83,6 +83,9 @@ export const rateLimiters = { // 文件上传接口:每分钟60次请求(独立于通用API限流) upload: new RateLimiter(60000, 60), + // 原图下载需要服务端转码:按登录用户独立限流,避免共享出口 IP 互相影响 + download: new RateLimiter(60000, 60), + // 图片代理接口:每分钟3000次请求(图片列表/画布高频加载) imageProxy: new RateLimiter(60000, 3000), diff --git a/proxy.ts b/proxy.ts index d25015407..ab17ebb98 100644 --- a/proxy.ts +++ b/proxy.ts @@ -45,14 +45,27 @@ const manualIpBlockCache = new Map< function isHighFrequencyReadApi(pathname: string, method: string) { if (method !== "GET") return false; if (/^\/api\/canvas\/[^/]+\/detail-archives$/.test(pathname)) return true; + if (/^\/api\/canvas\/[^/]+\/revision$/.test(pathname)) return true; + if (/^\/api\/canvas\/layout-replace\/batches\/[^/]+$/.test(pathname)) { + return true; + } if (/^\/api\/canvas\/[^/]+$/.test(pathname)) return true; return [ + "/api/ai-wash/config", "/api/models", "/api/canvas", + "/api/canvas/model-aliases", "/api/canvas-skill-tags", + "/api/canvas/clawd-chat/status", + "/api/enhance/pro/config", + "/api/featured", + "/api/featured/ranking", + "/api/home-animation", "/api/nav-links", + "/api/scene-fusion/config", "/api/site-settings", + "/api/style-pro/config", "/api/messages", "/api/history", "/api/vote", @@ -61,6 +74,7 @@ function isHighFrequencyReadApi(pathname: string, method: string) { "/api/tutorial-videos", "/api/user/membership-summary", "/api/user/account-center", + "/api/video-canvas/config", ].includes(pathname); } @@ -502,6 +516,51 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) { } ); } + } else if ( + pathname === "/api/canvas/download-image" && + request.method === "POST" + ) { + const token = await resolveToken(); + const userId = + typeof token?.sub === "string" && token.sub.length > 0 + ? token.sub + : null; + const downloadIdentifier = userId + ? `user:${userId}:canvas-download` + : `ip:${clientIP}:canvas-download`; + const downloadRateLimit = checkRateLimitByIdentifier( + downloadIdentifier, + rateLimiters.download + ); + if (!downloadRateLimit.allowed) { + const retryAfter = Math.max( + 1, + Math.ceil((downloadRateLimit.resetTime - Date.now()) / 1000) + ); + console.warn(`[Security] Download rate limit exceeded for IP: ${clientIP}`, { + pathname, + userId, + resetTime: new Date(downloadRateLimit.resetTime).toISOString(), + }); + return withAccessLog( + NextResponse.json( + { + error: `图片下载请求过于频繁,请 ${retryAfter} 秒后重试`, + retryAfter, + }, + { + status: 429, + headers: { "Retry-After": String(retryAfter) }, + } + ), + { + outcome: "rate_limited", + severity: "high", + reasons: ["图片下载限流"], + userId, + } + ); + } } else if (pathname.startsWith("/api/canvas/") && request.method === "PUT") { const canvasRateLimit = checkRateLimit(request, rateLimiters.canvas); if (!canvasRateLimit.allowed) { @@ -623,14 +682,21 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) { } else if (pathname.startsWith("/api/")) { const rateLimit = checkRateLimit(request, rateLimiters.api); if (!rateLimit.allowed) { + const retryAfter = Math.max( + 1, + Math.ceil((rateLimit.resetTime - Date.now()) / 1000) + ); console.warn(`[Security] Rate limit exceeded for IP: ${clientIP}`, { pathname, resetTime: new Date(rateLimit.resetTime).toISOString(), }); return withAccessLog( NextResponse.json( - { error: "Too many requests", retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) }, - { status: 429 } + { error: "Too many requests", retryAfter }, + { + status: 429, + headers: { "Retry-After": String(retryAfter) }, + } ), { outcome: "rate_limited", diff --git a/scripts/prepare-official-model-avatars.mjs b/scripts/prepare-official-model-avatars.mjs new file mode 100644 index 000000000..7838e7e67 --- /dev/null +++ b/scripts/prepare-official-model-avatars.mjs @@ -0,0 +1,57 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import sharp from 'sharp'; + +const sourceDir = process.argv[2] || '/tmp/official-model-logos'; +const outputDir = process.argv[3] || '/tmp/official-model-avatars'; + +const sources = { + google: path.join(sourceDir, 'google.png'), + openai: path.join(sourceDir, 'openai.svg'), + qwen: path.join(sourceDir, 'qwen.png'), + runway: path.join(sourceDir, 'runway.png'), + bfl: path.join(sourceDir, 'bfl.png'), + stepfun: path.join(sourceDir, 'stepfun.png'), + volcengine: path.join(sourceDir, 'volcengine.png'), + doubao: path.join(sourceDir, 'doubao.png'), +}; + +const background = Buffer.from(` + + + + +`); + +async function openAiMark() { + const officialSvg = await fs.readFile(sources.openai, 'utf8'); + const blossom = officialSvg.match(//); + if (!blossom) throw new Error('OpenAI Blossom path was not found in the official asset'); + + return Buffer.from(` + + + + `); +} + +async function makeAvatar(name, input) { + const mark = await sharp(input) + .resize(88, 88, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .grayscale() + .modulate({ brightness: 0.62 }) + .png() + .toBuffer(); + + await sharp(background) + .composite([{ input: mark, gravity: 'centre' }]) + .png({ compressionLevel: 9 }) + .toFile(path.join(outputDir, `${name}.png`)); +} + +await fs.mkdir(outputDir, { recursive: true }); +for (const [name, input] of Object.entries(sources)) { + await makeAvatar(name, name === 'openai' ? await openAiMark() : input); +} + +console.log(`Prepared ${Object.keys(sources).length} monochrome official model avatars in ${outputDir}`); diff --git a/scripts/upload-official-model-avatars.ts b/scripts/upload-official-model-avatars.ts new file mode 100644 index 000000000..b94ae3401 --- /dev/null +++ b/scripts/upload-official-model-avatars.ts @@ -0,0 +1,36 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { uploadFile } from '../lib/storage/service'; + +const inputDir = process.argv[2]; +if (!inputDir) { + throw new Error('Usage: tsx scripts/upload-official-model-avatars.ts '); +} + +const brands = [ + 'google', + 'openai', + 'qwen', + 'runway', + 'bfl', + 'stepfun', + 'volcengine', + 'doubao', +] as const; + +async function main() { + const uploaded: Record = {}; + for (const brand of brands) { + const file = await readFile(path.join(inputDir, `${brand}.png`)); + const arrayBuffer = file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength) as ArrayBuffer; + const result = await uploadFile(`official-model-avatar-${brand}.png`, arrayBuffer); + uploaded[brand] = result.url; + } + + console.log(JSON.stringify(uploaded, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/verify-canvas-layout-replace-quality.ts b/scripts/verify-canvas-layout-replace-quality.ts new file mode 100644 index 000000000..878c26cdd --- /dev/null +++ b/scripts/verify-canvas-layout-replace-quality.ts @@ -0,0 +1,164 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import dotenv from "dotenv"; +import postgres from "postgres"; +import { createAtlasCloud } from "@/lib/ai/providers/atlascloud"; +import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image"; +import { createVip98k } from "@/lib/ai/providers/vip98k"; +import { resolveModelRuntimeId } from "@/lib/ai/model-runtime"; +import { buildCanvasLayoutReplacePrompt } from "@/lib/canvas/layout-replace-model-selection"; +import { resolveCanvasLayoutReplaceSourceProfile } from "@/lib/canvas/layout-replace-source-profile"; + +dotenv.config({ path: ".env.local" }); +dotenv.config(); + +function getArgument(name: string) { + const prefix = `--${name}=`; + return process.argv.find((argument) => argument.startsWith(prefix))?.slice(prefix.length) || ""; +} + +function requireHttpArgument(name: string) { + const value = getArgument(name).trim(); + if (!/^https?:\/\//i.test(value)) { + throw new Error(`缺少有效参数 --${name}=https://...`); + } + return value; +} + +function extractOriginalImageUrl(markdown: string) { + const match = markdown.match(/!\[thumb\|\|\|([^|\]]+)(?:\|\|\|[^\]]*)?\]\([^)]+\)/); + if (match?.[1] && /^https?:\/\//i.test(match[1])) return match[1]; + const direct = markdown.match(/!\[[^\]]*\]\((https?:\/\/[^)]+)\)/); + return direct?.[1] || ""; +} + +async function main() { + const databaseUrl = process.env.POSTGRES_URL; + if (!databaseUrl) throw new Error("POSTGRES_URL 未配置"); + + const sourceImageUrl = requireHttpArgument("source"); + const referenceImageUrl = requireHttpArgument("reference"); + const modelRef = getArgument("model-ref").trim(); + if (!modelRef) throw new Error("缺少 --model-ref"); + const outputDirectory = resolve( + getArgument("output-dir") || "/tmp/canvas-layout-replace-quality", + ); + + const sql = postgres(databaseUrl, { max: 1 }); + try { + const [candidate] = await sql< + Array<{ + apiKey: string; + baseUrl: string; + capabilities: Record | null; + imageGenConfig: Record | null; + modelId: string; + providerType: string; + }> + >` + select + p."apiKey" as "apiKey", + p."baseUrl" as "baseUrl", + p.type as "providerType", + m."modelId" as "modelId", + m."imageGenConfig" as "imageGenConfig", + m.capabilities as "capabilities" + from "AiModel" m + join "AiProvider" p on p.id = m."providerId" + where m.id = ${modelRef} + and m."isEnabled" = true + and p."isEnabled" = true + limit 1 + `; + if (!candidate) throw new Error("找不到启用的测试模型"); + if (!["atlascloud", "snapgen-image", "vip98k"].includes(candidate.providerType)) { + throw new Error(`本地质量脚本不支持供应商 ${candidate.providerType}`); + } + + const sourceProfile = + await resolveCanvasLayoutReplaceSourceProfile(sourceImageUrl); + const prompt = buildCanvasLayoutReplacePrompt({ + referenceHeight: 726, + referenceWidth: 543, + sourceProductAspectRatio: sourceProfile?.silhouetteAspectRatio, + }); + const runtimeModelId = resolveModelRuntimeId({ modelId: candidate.modelId }); + const generationOptions = { + messages: [ + { + role: "user", + content: [ + { type: "text", text: prompt }, + { type: "image_url", image_url: { url: sourceImageUrl } }, + { type: "image_url", image_url: { url: referenceImageUrl } }, + ], + }, + ], + imageParams: { + aspectRatio: "3:4", + imageCount: 1, + imageSize: "2K", + promptExtend: false, + quality: "high", + }, + }; + let result: { text?: string }; + if (candidate.providerType === "atlascloud") { + const model = createAtlasCloud({ + apiKey: candidate.apiKey, + baseURL: candidate.baseUrl, + }).chat(runtimeModelId); + result = await model.doGenerate({ + ...generationOptions, + imageGenConfig: + candidate.imageGenConfig as Parameters< + typeof model.doGenerate + >[0]["imageGenConfig"], + }); + } else if (candidate.providerType === "snapgen-image") { + const model = createSnapgenImage({ + apiKey: candidate.apiKey, + baseURL: candidate.baseUrl, + }).chat(runtimeModelId); + result = await model.doGenerate({ + ...generationOptions, + imageGenConfig: + candidate.imageGenConfig as Parameters< + typeof model.doGenerate + >[0]["imageGenConfig"], + }); + } else { + const model = createVip98k({ + apiKey: candidate.apiKey, + baseURL: candidate.baseUrl, + }).chat(runtimeModelId, { + capabilities: candidate.capabilities, + imageGenConfig: candidate.imageGenConfig, + }); + result = await model.doGenerate({ + ...generationOptions, + imageGenConfig: candidate.imageGenConfig, + }); + } + const resultUrl = extractOriginalImageUrl(result.text || ""); + if (!resultUrl) throw new Error("模型返回中没有结果图片"); + + const response = await fetch(resultUrl, { + signal: AbortSignal.timeout(120_000), + }); + if (!response.ok) throw new Error(`下载测试结果失败(HTTP ${response.status})`); + await mkdir(outputDirectory, { recursive: true }); + const outputPath = resolve(outputDirectory, `result-${Date.now()}.png`); + await writeFile(outputPath, Buffer.from(await response.arrayBuffer())); + process.stdout.write( + `${JSON.stringify({ modelId: runtimeModelId, outputPath, resultUrl })}\n`, + ); + } finally { + await sql.end(); + } +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/tests/routes/canvas-download-rate-limit.test.ts b/tests/routes/canvas-download-rate-limit.test.ts new file mode 100644 index 000000000..442e10193 --- /dev/null +++ b/tests/routes/canvas-download-rate-limit.test.ts @@ -0,0 +1,107 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + CanvasImageDownloadError, + downloadCanvasImageAsPng, + getCanvasImageDownloadErrorMessage, +} from "@/lib/client/canvas-image-download"; + +function readSource(path: string) { + return readFileSync(resolve(process.cwd(), path), "utf8"); +} + +test.describe("canvas image download rate limiting", () => { + test("isolates downloads by authenticated user and moves polling reads out of the generic bucket", () => { + const proxySource = readSource("proxy.ts"); + const limiterSource = readSource("lib/security/rate-limiter.ts"); + const downloadBranchIndex = proxySource.indexOf( + 'pathname === "/api/canvas/download-image"' + ); + const genericBranchIndex = proxySource.indexOf( + '} else if (pathname.startsWith("/api/")) {' + ); + + expect(limiterSource).toContain("download: new RateLimiter(60000, 60)"); + expect(proxySource).toContain( + "`user:" + ["$", "{userId}"].join("") + ":canvas-download`" + ); + expect(proxySource).toContain("rateLimiters.download"); + expect(downloadBranchIndex).toBeGreaterThan(0); + expect(genericBranchIndex).toBeGreaterThan(downloadBranchIndex); + expect(proxySource).toContain( + 'if (/^\\/api\\/canvas\\/[^/]+\\/revision$/.test(pathname)) return true;' + ); + expect(proxySource).toContain('"/api/ai-wash/config"'); + expect(proxySource).toContain('"/api/canvas/model-aliases"'); + expect(proxySource).toContain('"/api/featured"'); + expect(proxySource).toContain('"/api/scene-fusion/config"'); + expect(proxySource).toContain('headers: { "Retry-After": String(retryAfter) }'); + }); + + test("retries one 429 response and keeps rate limiting separate from source expiry", async () => { + const originalFetch = globalThis.fetch; + let requestCount = 0; + globalThis.fetch = async () => { + requestCount++; + return new Response( + JSON.stringify({ error: "Too many requests", retryAfter: 0 }), + { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": "0", + }, + } + ); + }; + + try { + await expect( + downloadCanvasImageAsPng({ + url: "https://example.com/image.png", + }) + ).rejects.toMatchObject({ + code: "rate_limited", + status: 429, + }); + expect(requestCount).toBe(2); + } finally { + globalThis.fetch = originalFetch; + } + + const rateLimitMessage = getCanvasImageDownloadErrorMessage( + new CanvasImageDownloadError("Too many requests", { + code: "rate_limited", + status: 429, + }) + ); + const sourceMessage = getCanvasImageDownloadErrorMessage( + new CanvasImageDownloadError("图片源文件暂时不可访问", { + code: "source_unavailable", + status: 502, + }) + ); + + expect(rateLimitMessage).toBe("图片下载请求过于频繁,请稍后重试"); + expect(rateLimitMessage).not.toContain("源图"); + expect(sourceMessage).toContain("源图可能已失效"); + }); + + test("uses the shared error formatter in every canvas image download entry", () => { + for (const path of [ + "components/canvas/nodes/ImageNode.tsx", + "components/canvas/CanvasChatPanel.tsx", + "components/canvas/AiWashWorkspace.tsx", + "components/style-pro/StyleProWorkspace.tsx", + ]) { + const source = readSource(path); + expect(source, path).toContain("getCanvasImageDownloadErrorMessage(error)"); + } + + const imageNodeSource = readSource("components/canvas/nodes/ImageNode.tsx"); + expect(imageNodeSource).not.toContain( + "`" + ["$", "{error.message}"].join("") + ",源图可能已失效" + ); + }); +}); diff --git a/tests/routes/canvas-layout-replace-entry.test.ts b/tests/routes/canvas-layout-replace-entry.test.ts index cee4f1a68..bd55b8c3c 100644 --- a/tests/routes/canvas-layout-replace-entry.test.ts +++ b/tests/routes/canvas-layout-replace-entry.test.ts @@ -8,6 +8,10 @@ import { type LayoutReplaceSource, mergeUniqueLayoutReplaceSources, } from "@/lib/canvas/layout-replace-source-selection"; +import { + buildCanvasLayoutReplacePrompt, + rankCanvasLayoutReplaceModels, +} from "@/lib/canvas/layout-replace-model-selection"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode } from "@/types/canvas"; @@ -81,11 +85,97 @@ test.describe("canvas AI layout replacement entry", () => { expect(taskQueue).toContain("ensureCanvasImageTaskActive"); expect(taskExecutor).toContain("saveLayoutReplaceResultToGallery"); expect(taskExecutor).toContain('sourceType: "canvas_layout_replace"'); + expect(taskExecutor).toContain( + 'layoutReplaceServerAlgorithm: "rigid-product-scene-replace/v2"' + ); + expect(taskExecutor).toContain("allowFailover: false"); + expect(taskExecutor).not.toContain("buildLayoutLockedCompositeImage({"); + expect(taskExecutor).not.toContain("analyzeReplacementExecutionPlan({"); expect(migration).toContain('CREATE TABLE IF NOT EXISTS "CanvasLayoutReplaceBatch"'); expect(migration).toContain('CREATE TABLE IF NOT EXISTS "CanvasLayoutReplaceBatchItem"'); }); + test("always excludes FAL and prefers a healthy multi-image edit model", () => { + const candidates = [ + { + model: { + id: "snapgen-gpt-image", + modelId: "snapgen/gpt-image-2", + supportsImageGen: true, + supportsVision: true, + healthStatus: "healthy", + capabilities: { maxReferenceImages: 8 }, + }, + provider: { type: "snapgen-image" }, + }, + { + model: { + id: "fal-default", + modelId: "fal-ai/nano-banana-2", + supportsImageGen: true, + supportsVision: true, + healthStatus: "healthy", + isDefault: true, + }, + provider: { type: "fal-image" }, + }, + { + model: { + id: "seedream-edit", + modelId: "bytedance/seedream-v5.0-pro/edit", + supportsImageGen: true, + supportsVision: true, + healthStatus: "healthy", + capabilities: { maxReferenceImages: 8 }, + }, + provider: { type: "vip98k" }, + }, + { + model: { + id: "gpt-image-edit", + modelId: "openai/gpt-image-2/edit", + supportsImageGen: true, + supportsVision: true, + healthStatus: "healthy", + capabilities: { maxReferenceImages: 10 }, + }, + provider: { type: "atlascloud" }, + }, + ]; + + const ranked = rankCanvasLayoutReplaceModels(candidates, "fal-default"); + expect(ranked.map((item) => item.model.id)).toEqual([ + "snapgen-gpt-image", + "gpt-image-edit", + "seedream-edit", + ]); + expect(ranked.every((item) => item.provider.type !== "fal-image")).toBe(true); + }); + + test("uses a rigid product identity prompt instead of detail-page copy rules", () => { + const prompt = buildCanvasLayoutReplacePrompt({ + referenceHeight: 726, + referenceWidth: 543, + sourceProductAspectRatio: 0.76, + supplementalPrompt: "保留深胡桃木颜色", + }); + + expect(prompt).toContain("图1是新商品身份图"); + expect(prompt).toContain("图2是目标场景与构图模版"); + expect(prompt).toContain("真实商品像素层"); + expect(prompt).toContain("横向或纵向拉伸"); + expect(prompt).toContain("543x726"); + expect(prompt).toContain("0.760:1"); + expect(prompt).toContain("绝对不能压窄商品宽度"); + expect(prompt).toContain("画布宽度约 77%"); + expect(prompt).toContain("画布 x≈11% 处"); + expect(prompt).toContain("x≈89% 处"); + expect(prompt).toContain("图2旧商品原本偏窄的占位不得继承"); + expect(prompt).toContain("保留深胡桃木颜色"); + expect(prompt).not.toContain("按钮、标签、分割线、品牌区"); + }); + test("selects eligible canvas images without exceeding the remaining slots", () => { const store = useCanvasStore.getState(); store.endCanvasImageSelection("layout-owner");