fix: speed up detail replacement downloads
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-14 14:36:37 +08:00
parent 5864e9f4cf
commit 000ac13da4
7 changed files with 656 additions and 256 deletions

View File

@ -245,7 +245,10 @@ import { generateTitleFromUserMessage } from "../../actions";
import { type PostRequestBody, postRequestBodySchema } from "./schema";
import { isAiModelMultimodal } from "@/lib/db/ai-model-flags";
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = 4;
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
1,
Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6),
);
/**
* 将图片 URL 转换为 base64 格式
@ -1280,7 +1283,9 @@ async function resolveReplacementReferenceGeometry(
async function normalizeReplacementResultImage(params: {
generatedImageUrl: string;
generatedThumbnailUrl?: string;
referenceImageUrl: string;
referenceGeometry?: ReplacementReferenceGeometry | null;
uploadPrefix: string;
}): Promise<{
originalUrl: string;
@ -1288,21 +1293,34 @@ async function normalizeReplacementResultImage(params: {
imageSize: string;
}> {
const sharp = (await import("sharp")).default;
const [generatedBuffer, referenceBuffer] = await Promise.all([
fetchImageBufferFromUrl(params.generatedImageUrl),
fetchImageBufferFromUrl(params.referenceImageUrl),
]);
const generatedBuffer = await fetchImageBufferFromUrl(params.generatedImageUrl);
let targetWidth = params.referenceGeometry?.width;
let targetHeight = params.referenceGeometry?.height;
const referenceMetadata = await sharp(referenceBuffer, { failOn: "none" })
.rotate()
.metadata();
const targetWidth = referenceMetadata.width;
const targetHeight = referenceMetadata.height;
if (!targetWidth || !targetHeight) {
const referenceBuffer = await fetchImageBufferFromUrl(params.referenceImageUrl);
const referenceMetadata = await sharp(referenceBuffer, { failOn: "none" })
.rotate()
.metadata();
targetWidth = referenceMetadata.width;
targetHeight = referenceMetadata.height;
}
if (!targetWidth || !targetHeight) {
throw new Error("无法读取参考图尺寸");
}
const generatedMetadata = await sharp(generatedBuffer, { failOn: "none" })
.rotate()
.metadata();
if (generatedMetadata.width === targetWidth && generatedMetadata.height === targetHeight) {
return {
originalUrl: params.generatedImageUrl,
thumbnailUrl: params.generatedThumbnailUrl || params.generatedImageUrl,
imageSize: `${targetWidth}x${targetHeight}`,
};
}
const normalizedBuffer = await sharp(generatedBuffer, { failOn: "none" })
.rotate()
.resize(targetWidth, targetHeight, {
@ -3330,6 +3348,7 @@ function buildAgentSceneWorkflowPrompt(params: {
type AgentReplacementModePayload = NonNullable<PostRequestBody["agentModePayload"]>;
type AgentModeSourceImagePayload = NonNullable<AgentReplacementModePayload["sourceImages"]>;
type AgentDetailReplacementStrategy = "scene_replace" | "product_only";
type NormalizedAgentModeSourceImage = {
url: string;
@ -3386,6 +3405,12 @@ type ReplacementScenePlanningRuntime =
const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i;
function normalizeDetailReplacementStrategy(
value: unknown,
): AgentDetailReplacementStrategy {
return value === "product_only" ? "product_only" : "scene_replace";
}
function truncateReplacementAnalysisText(value: unknown, maxLength = 240) {
return String(value || "").trim().replace(/\s+/g, " ").slice(0, maxLength);
}
@ -3482,7 +3507,9 @@ function buildHeuristicReplacementScenePlan(params: {
hasModelImage: boolean;
shouldReplace: boolean;
reason: string;
replacementStrategy?: AgentDetailReplacementStrategy;
}): ReplacementScenePlan {
const isProductOnly = params.replacementStrategy === "product_only";
return {
selectedProductImageIndexes: Array.from(
{ length: Math.min(Math.max(params.productImageCount, 1), 2) },
@ -3494,19 +3521,32 @@ function buildHeuristicReplacementScenePlan(params: {
: "默认使用当前唯一产品图作为主体参考。",
useModelImage: false,
modelImageReason: params.hasModelImage
? "前置分析未明确当前参考图存在人物展示需求,兜底阶段默认不使用模特图。"
? isProductOnly
? "仅替换产品模式不使用模特图,默认锁定参考图原有人物与场景。"
: "前置分析未明确当前参考图存在人物展示需求,兜底阶段默认不使用模特图。"
: "当前未提供模特图。",
sceneSummary: params.shouldReplace
? "保留参考图版式骨架、构图和主体位置,但把旧场景视觉内容按工作流目标风格重建。"
? isProductOnly
? "严格保留参考图的场景、文案和版式,只把产品展示区中的旧产品替换成输入产品。"
: "保留参考图版式骨架、构图和主体位置,但把旧场景视觉内容按工作流目标风格重建。"
: "当前参考图不满足替换条件。",
cameraSummary: "保持参考图原有机位、构图和景别,不做整体重画。",
placementSummary: "产品应落在参考图当前主体所在区域,并围绕该区域同步重建新的场景内容。",
preserveSummary: "文字排版、标题区、分割线、留白和信息层级必须保留。",
styleSummary: "除版式骨架外,墙面、地面、背景家具、软装、灯光、摆件和空间氛围都要按工作流风格重建。",
avoidSummary:
"不要只换产品不换场景,不要把参考图原有背景物件和空间氛围原样保留下来,不要把所有输入产品角度图同时强行出现在一张图中。",
placementSummary: isProductOnly
? "产品必须落在参考图原有产品所在区域,保持原位置、透视和占比。"
: "产品应落在参考图当前主体所在区域,并围绕该区域同步重建新的场景内容。",
preserveSummary: isProductOnly
? "除产品主体外,文字排版、标题区、分割线、留白、背景、灯光和装饰都必须保留。"
: "文字排版、标题区、分割线、留白和信息层级必须保留。",
styleSummary: isProductOnly
? "延续参考图原有风格、背景和氛围,不引入新的场景改造要求。"
: "除版式骨架外,墙面、地面、背景家具、软装、灯光、摆件和空间氛围都要按工作流风格重建。",
avoidSummary: isProductOnly
? "不要改动背景、文案、按钮、图标、排版和装饰,不要重建整个场景,不要把多个产品角度同时塞进一张图。"
: "不要只换产品不换场景,不要把参考图原有背景物件和空间氛围原样保留下来,不要把所有输入产品角度图同时强行出现在一张图中。",
generationPrompt: params.shouldReplace
? `以工作流目标风格重建当前这一屏的场景,只保留版式、机位、文字区与留白结构。${params.reason}`
? isProductOnly
? `仅替换参考图中的产品主体,严格保留其余背景、文案、排版与光影关系。${params.reason}`
: `以工作流目标风格重建当前这一屏的场景,只保留版式、机位、文字区与留白结构。${params.reason}`
: params.reason,
analysisModelName: null,
analysisSource: "heuristic",
@ -3577,6 +3617,12 @@ function normalizeAgentModeSourceImages(
};
}
function normalizeAgentModeDetailReplacementStrategy(
payload?: PostRequestBody["agentModePayload"],
): AgentDetailReplacementStrategy {
return normalizeDetailReplacementStrategy(payload?.replacementStrategy);
}
function resolveImagePartsByReferenceExclusion(params: {
uploadedImageParts: AgentPresetMessageImagePart[];
referenceImageUrls: string[];
@ -3749,9 +3795,21 @@ async function analyzeReplacementReferenceImage(params: {
imageUrl: string;
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
modeType?: "detail_replacement" | "sku_replacement";
replacementStrategy?: AgentDetailReplacementStrategy;
}): Promise<ReplacementReferenceDecision> {
const isDetailReplacement = params.modeType === "detail_replacement";
const detectionPrompt = isDetailReplacement
const isProductOnlyDetailReplacement =
isDetailReplacement && params.replacementStrategy === "product_only";
const detectionPrompt = isProductOnlyDetailReplacement
? [
"你是电商详情图产品主体替换判定器。",
"请判断这张详情参考图里,是否存在清晰、主要、适合被新产品替换的商品主体、产品展示区或产品照片区域。",
"你的任务不是重建整张场景,也不是替换文字排版;只判断是否有明确的产品替换目标。",
"文字、标题、卖点标签、按钮、UI图标、分割线、品牌标识、留白和背景装饰不属于替换对象。",
"只有当图片几乎是纯文字页、参数表、品牌介绍页、没有明确产品展示区时,shouldReplace 才能为 false。",
'只输出 JSON,不要输出额外解释。格式:{"shouldReplace":true,"containsFurnitureProduct":true,"reason":"不超过30字"}',
].join("\n")
: isDetailReplacement
? [
"你是电商详情参考图背景替换判定器。",
"请判断这张图片里,是否存在适合被重建/替换的背景图、产品场景图、商品主体图或大面积视觉图片区。",
@ -3908,6 +3966,7 @@ async function resolveReplacementScenePlanningRuntime(params: {
async function analyzeReplacementExecutionPlan(params: {
modeType?: "detail_replacement" | "sku_replacement";
replacementStrategy?: AgentDetailReplacementStrategy;
referenceImageUrl: string;
referenceLabel: string;
productImageUrls: string[];
@ -3917,6 +3976,8 @@ async function analyzeReplacementExecutionPlan(params: {
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}): Promise<ReplacementExecutionPlan> {
const isDetailReplacement = params.modeType === "detail_replacement";
const isProductOnlyDetailReplacement =
isDetailReplacement && params.replacementStrategy === "product_only";
if (params.planningRuntime) {
try {
const replacementScenePlanningTimeoutMs =
@ -3934,39 +3995,55 @@ async function analyzeReplacementExecutionPlan(params: {
});
const effectiveVisionImages =
inlineDataUrls.length > 0 ? inlineDataUrls : analysisImageUrls;
const workflowPromptExcerpt = String(params.workflowPrompt || "")
.trim()
.replace(/\s+/g, " ")
.slice(0, 2200);
const workflowPromptExcerpt = isProductOnlyDetailReplacement
? ""
: String(params.workflowPrompt || "")
.trim()
.replace(/\s+/g, " ")
.slice(0, 2200);
const analysisPrompt = [
isDetailReplacement
isProductOnlyDetailReplacement
? "你是电商详情参考图中的“产品主体替换规划器”。"
: isDetailReplacement
? "你是电商详情参考图背景替换流程中的“版式与背景区域规划器”。"
: "你是电商详情替换流程中的“场景理解规划器”。",
`当前要分析的参考图:${params.referenceLabel}`,
"你现在只分析这一张参考图本身,不分析产品图,不分析模特图。",
"产品图和模特图会在后续生图阶段单独提供给生图模型使用,你这里不要尝试理解它们的具体外观,也不要要求必须使用所有输入图。",
params.modelImageUrl
params.modelImageUrl && !isProductOnlyDetailReplacement
? "你只需要根据当前参考图判断:这一屏是否存在真实的人物展示需求;如果没有人物需求,useModelImage 必须为 false。"
: "本轮没有模特图,useModelImage 必须为 false。",
params.modelImageUrl
params.modelImageUrl && !isProductOnlyDetailReplacement
? "如果当前参考图存在人物展示需求,后续生图必须尽量复用提供模特图中的同一人物身份,不要明显换脸、换发型、换体型。"
: "",
"这张图片是唯一的详情参考图底板,它决定当前这一屏的版式、文字排版、UI图标、按钮、机位、构图、留白和信息层级。",
isDetailReplacement
isProductOnlyDetailReplacement
? "这张参考图要求最大程度原样保留。后续生图只允许替换其中的产品主体/产品展示区域,背景、文案、按钮、图标、分割线、品牌标识、留白、装饰和光影氛围都必须保留。"
: isDetailReplacement
? "这张参考图不是要求原样保留的场景照片。后续生图只允许替换/重建背景图、产品场景图、商品主体图或人物展示图区域;文字、标题、卖点标签、UI图标、按钮、分割线、品牌标识、留白和排版必须保留。"
: "这张参考图不是要求原样保留的场景照片。原图里的沙发、床、柜体、墙面、地面、窗帘、灯具、摆件、桌面道具、背景家具和环境光氛围,都可以在后续生图阶段按前置工作流风格重建。",
"请完成以下任务:",
isDetailReplacement
isProductOnlyDetailReplacement
? "1. 判断 shouldReplace:当前参考图里是否存在清晰、主要、适合被新产品替换的商品主体、产品照片区域或产品展示区。纯文字/纯参数/纯品牌介绍且没有产品展示区时才为 false。"
: isDetailReplacement
? "1. 判断 shouldReplace:当前参考图里是否存在适合被替换/重建的背景图、产品场景图、商品主体图、人物展示图或大面积视觉图片区。纯文字/纯参数/纯品牌介绍且没有视觉图片区时才为 false。"
: "1. 判断 shouldReplace:当前参考图里是否存在清晰、主要、适合被替换的家具商品主体。",
"2. 判断 useModelImage,并说明原因。",
"3. 提炼 sceneSummary、cameraSummary、placementSummary、preserveSummary、styleSummary、avoidSummary。",
isDetailReplacement
isProductOnlyDetailReplacement
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,要求只替换产品主体,保留其余背景、文案、排版、装饰和光影完全不变。"
: isDetailReplacement
? "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,描述需要替换的背景/场景图片区、产品放置方式、模特使用规则,以及必须保留的文字/UI/图标/排版。"
: "4. 输出 generationPrompt:这是一段给后续生图模型的本屏生成指令,描述这一屏需要的场景、机位、主体位置、周边环境重建方向以及必须保留的内容。",
"5. sceneSummary、styleSummary 和 generationPrompt 必须优先描述“按前置工作流重建后的目标场景”,而不是只复述参考图当前已有场景。",
"6. 如果前置工作流风格与当前参考图原场景冲突,必须保留版式骨架,但放弃原场景物件本身,以前置工作流指定的空间风格、软装、灯光和氛围为准。",
"7. avoidSummary 里要明确避免“只替换产品、不重建场景”这类结果。",
isProductOnlyDetailReplacement
? "5. sceneSummary、styleSummary 和 generationPrompt 必须强调‘只替换产品,不动其他内容’,不要引导重建场景。"
: "5. sceneSummary、styleSummary 和 generationPrompt 必须优先描述“按前置工作流重建后的目标场景”,而不是只复述参考图当前已有场景。",
isProductOnlyDetailReplacement
? "6. avoidSummary 里要明确避免改背景、改文字、改按钮、改图标、改版式、改留白、改装饰、改光影氛围。"
: "6. 如果前置工作流风格与当前参考图原场景冲突,必须保留版式骨架,但放弃原场景物件本身,以前置工作流指定的空间风格、软装、灯光和氛围为准。",
isProductOnlyDetailReplacement
? "7. 不要根据用户提示词或工作流风格改造场景,只服务于产品主体替换。"
: "7. avoidSummary 里要明确避免“只替换产品、不重建场景”这类结果。",
workflowPromptExcerpt
? `当前风格与场景限制(来自前置工作流与模式模板):\n${workflowPromptExcerpt}`
: "",
@ -4004,7 +4081,9 @@ async function analyzeReplacementExecutionPlan(params: {
const parsedDecision = parseReplacementReferenceDecision(text);
const parsedScenePlan = parseReplacementScenePlan(text, {
productImageCount: params.productImageUrls.length,
hasModelImage: Boolean(params.modelImageUrl),
hasModelImage: isProductOnlyDetailReplacement
? false
: Boolean(params.modelImageUrl),
});
if (parsedDecision && parsedScenePlan) {
@ -4038,14 +4117,18 @@ async function analyzeReplacementExecutionPlan(params: {
imageUrl: params.referenceImageUrl,
models: params.models,
modeType: params.modeType,
replacementStrategy: params.replacementStrategy,
});
return {
...fallbackDecision,
scenePlan: buildHeuristicReplacementScenePlan({
productImageCount: params.productImageUrls.length,
hasModelImage: Boolean(params.modelImageUrl),
hasModelImage: isProductOnlyDetailReplacement
? false
: Boolean(params.modelImageUrl),
shouldReplace: fallbackDecision.shouldReplace,
reason: fallbackDecision.reason,
replacementStrategy: params.replacementStrategy,
}),
};
}
@ -4053,6 +4136,7 @@ async function analyzeReplacementExecutionPlan(params: {
function buildAgentReplacementPrompt(params: {
basePrompt: string;
modeType: "detail_replacement" | "sku_replacement";
replacementStrategy?: AgentDetailReplacementStrategy;
referenceLabel: string;
referenceIndex: number;
totalReferences: number;
@ -4144,41 +4228,65 @@ function buildAgentReplacementPrompt(params: {
const instructionLines =
params.modeType === "detail_replacement"
? params.replacementStrategy === "product_only"
? [
"【执行模式】详情替换 / 仅替换产品",
`当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张详情参考图:${params.referenceLabel}`,
"【图片顺序规则】",
...imageOrderLines,
...scenePlanLines,
"你现在做的是‘仅替换产品’,不是重建场景,也不是重新设计整张详情图。",
"忽略用户补充提示词里的场景风格、氛围、空间改造要求;这一次只服务于产品主体替换。",
"最后一张参考图是唯一底板,除产品主体/产品展示区外,其余所有内容都必须保持不变。",
"必须严格保留:背景、人物、手部姿态、桌面摆件、灯光氛围、阴影关系、文字排版、标题、卖点标签、按钮、UI图标、分割线、品牌标识、留白、画布比例和信息层级。",
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个页面画布比例。替换商品时只能等比缩放、透视匹配和自然融合,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。`
: "输出必须保持当前参考图页面画布比例。替换商品时只能等比缩放、透视匹配和自然融合,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。",
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前产品区域。",
params.productImageCount > 1
? `前面的 ${params.productImageCount} 张商品输入图都属于同一产品的多角度/多细节白底图,请综合这些图片理解产品结构、材质、颜色、比例、边角、开孔、五金与细节,不可只参考第一张。`
: "前面的商品输入图就是当前要替换进去的产品白底图,请准确保持该产品的结构、材质、颜色、比例和细节。",
"你的任务只有一个:识别参考图中的旧产品主体或产品展示区,用输入产品替换它,并让新产品与原场景自然融合。",
"不要重建背景,不要重画空间,不要调整文案,不要修改按钮、图标、标签、装饰元素,也不要改变人物、道具和环境光氛围。",
"如果参考图中没有清晰可替换的产品主体或产品展示区,应直接沿用参考图,不要硬生成。",
"不要把多个产品角度同时塞进一张结果图,不要新增无关主体,不要删除任何关键版式或 UI 元素。",
]
: [
"【执行模式】详情替换",
`当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张详情参考图:${params.referenceLabel}`,
"【图片顺序规则】",
...imageOrderLines,
...scenePlanLines,
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
"要把最后一张参考图理解为“页面版式底板 + 背景图/场景图区参考”,不是“旧场景照片必须原样保留”。",
"前面“工作流设定”里的场景组合、空间风格、硬装方案、软装方案、灯光氛围、人群设定,必须作为当前这一屏的强制场景改造要求生效。",
"场景风格必须优先执行前面用户提示词与工作流设定中给出的空间风格、材质、灯光和氛围描述,不要被参考图原有旧场景绑死。",
"执行时要基于这些场景组合要求,重建参考图中的背景图/产品场景图区域,使当前这一屏呈现出与第一步已选风格一致的新背景画面。",
"允许替换/重建的范围是参考图里的背景图、产品场景图、商品主体图和必要的人物展示图区;不允许改动版式层。",
"如果前面商品图、模特图与最后一张参考图底板存在冲突,始终以最后一张参考图底板的版式、机位、构图和信息层级为最高优先级。",
"最后一张参考图是唯一底板,必须完整保留这张图的文字排版、品牌字样、标题区、卖点区、UI图标、按钮、标签、装饰元素、分割线、留白、画布比例和信息层级。",
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。`
: "输出必须保持当前参考图页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。",
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前版式,不要擅自改成完全不同的视角。",
"前面的商品图和模特图用于生成新的背景图/产品场景图内容,并按工作流设定替换参考图中的旧背景画面,但不能改变参考图的版式设计。",
"可被替换重建的内容包括但不限于:背景照片、产品展示照片、墙面、地面材质、沙发、床、背景柜、窗帘、灯具、地毯、挂画、摆件、桌面道具、空间色调、环境光氛围。",
params.productImageCount > 1
? `前面的 ${params.productImageCount} 张商品输入图都属于同一产品的多角度/多细节白底图,请综合这些图片理解产品结构、材质、颜色、比例、边角、开孔、五金与细节,不可只参考第一张。`
: "前面的商品输入图就是当前要替换进去的产品白底图,请准确保持该产品的结构、材质、颜色、比例和细节。",
"请先识别最后一张参考图中需要被替换的背景图/场景图区域,并在同一版式骨架下生成新的背景画面;不要只做局部抠图式换产品。",
"请严格保留最后一张参考图的版式结构、镜头关系、构图、文字排版、UI图标、标签、按钮、留白位置和信息层级;但旧背景图里的家具、背景物件、材质、灯光氛围、软装小物、空间质感需要按第一步工作流设定进行明显重建。",
"如果第一步工作流风格与参考图原场景冲突,必须保留版式骨架和UI层,但放弃原背景图物件本身,以第一步工作流风格为准。",
"前面的输入商品图用于提供商品主体、材质、颜色和细节特征;如有模特图,请只在参考图本身包含人物展示时再参考模特图。",
"如果提供了模特图且这一屏确实有人物展示需求,必须尽量复用同一人物身份与体态关系,不要明显换脸、换发型、换体型,服装风格也尽量保持一致。",
"绝对不要忽略最后一张参考图去生成一张无关的新场景图。",
"如果生成结果仍然保留了参考图原有的大部分背景图物件,只是把产品换掉,则视为执行失败;你必须让背景图/场景图发生清晰可见的变化。",
"绝对不要移动、删除、改写、重排参考图里的任何文字、UI图标、按钮、标签、装饰元素与排版元素。",
"输出结果必须保持最后一张参考图底板的原始纵横比与画布尺寸,不允许擅自改成长图、横图或裁掉任何版面区域。",
"如果参考图中没有可替换/重建的背景图、产品场景图或视觉图片区,应直接沿用参考图,不要硬生成。",
"不要改动参考图的整体布局,不要新增无关主体,不要删除关键版式或UI元素。",
]
? [
"【执行模式】详情替换",
`当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张详情参考图:${params.referenceLabel}`,
"【图片顺序规则】",
...imageOrderLines,
...scenePlanLines,
"你现在做的是“详情参考图背景/场景图替换”,不是重新设计整张详情图。",
"要把最后一张参考图理解为“页面版式底板 + 背景图/场景图区参考”,不是“旧场景照片必须原样保留”。",
"前面“工作流设定”里的场景组合、空间风格、硬装方案、软装方案、灯光氛围、人群设定,必须作为当前这一屏的强制场景改造要求生效。",
"场景风格必须优先执行前面用户提示词与工作流设定中给出的空间风格、材质、灯光和氛围描述,不要被参考图原有旧场景绑死。",
"执行时要基于这些场景组合要求,重建参考图中的背景图/产品场景图区域,使当前这一屏呈现出与第一步已选风格一致的新背景画面。",
"允许替换/重建的范围是参考图里的背景图、产品场景图、商品主体图和必要的人物展示图区;不允许改动版式层。",
"如果前面商品图、模特图与最后一张参考图底板存在冲突,始终以最后一张参考图底板的版式、机位、构图和信息层级为最高优先级。",
"最后一张参考图是唯一底板,必须完整保留这张图的文字排版、品牌字样、标题区、卖点区、UI图标、按钮、标签、装饰元素、分割线、留白、画布比例和信息层级。",
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。`
: "输出必须保持当前参考图页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。",
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前版式,不要擅自改成完全不同的视角。",
"前面的商品图和模特图用于生成新的背景图/产品场景图内容,并按工作流设定替换参考图中的旧背景画面,但不能改变参考图的版式设计。",
"可被替换重建的内容包括但不限于:背景照片、产品展示照片、墙面、地面材质、沙发、床、背景柜、窗帘、灯具、地毯、挂画、摆件、桌面道具、空间色调、环境光氛围。",
params.productImageCount > 1
? `前面的 ${params.productImageCount} 张商品输入图都属于同一产品的多角度/多细节白底图,请综合这些图片理解产品结构、材质、颜色、比例、边角、开孔、五金与细节,不可只参考第一张。`
: "前面的商品输入图就是当前要替换进去的产品白底图,请准确保持该产品的结构、材质、颜色、比例和细节。",
"请先识别最后一张参考图中需要被替换的背景图/场景图区域,并在同一版式骨架下生成新的背景画面;不要只做局部抠图式换产品。",
"请严格保留最后一张参考图的版式结构、镜头关系、构图、文字排版、UI图标、标签、按钮、留白位置和信息层级;但旧背景图里的家具、背景物件、材质、灯光氛围、软装小物、空间质感需要按第一步工作流设定进行明显重建。",
"如果第一步工作流风格与参考图原场景冲突,必须保留版式骨架和UI层,但放弃原背景图物件本身,以第一步工作流风格为准。",
"前面的输入商品图用于提供商品主体、材质、颜色和细节特征;如有模特图,请只在参考图本身包含人物展示时再参考模特图。",
"如果提供了模特图且这一屏确实有人物展示需求,必须尽量复用同一人物身份与体态关系,不要明显换脸、换发型、换体型,服装风格也尽量保持一致。",
"绝对不要忽略最后一张参考图去生成一张无关的新场景图。",
"如果生成结果仍然保留了参考图原有的大部分背景图物件,只是把产品换掉,则视为执行失败;你必须让背景图/场景图发生清晰可见的变化。",
"绝对不要移动、删除、改写、重排参考图里的任何文字、UI图标、按钮、标签、装饰元素与排版元素。",
"输出结果必须保持最后一张参考图底板的原始纵横比与画布尺寸,不允许擅自改成长图、横图或裁掉任何版面区域。",
"如果参考图中没有可替换/重建的背景图、产品场景图或视觉图片区,应直接沿用参考图,不要硬生成。",
"不要改动参考图的整体布局,不要新增无关主体,不要删除关键版式或UI元素。",
]
: [
"【执行模式】SKU替换",
`当前要处理第 ${params.referenceIndex + 1} / ${params.totalReferences} 张 SKU 参考图:${params.referenceLabel}`,
@ -4204,7 +4312,13 @@ function buildAgentReplacementPrompt(params: {
instructionLines.push("如有模特图,请在需要人物展示时保持模特体态、穿搭和出镜关系一致。");
}
return [params.basePrompt, instructionLines.join("\n")]
return [
params.modeType === "detail_replacement" &&
params.replacementStrategy === "product_only"
? ""
: params.basePrompt,
instructionLines.join("\n"),
]
.filter(Boolean)
.join("\n\n")
.trim();
@ -6955,6 +7069,11 @@ export async function POST(request: Request) {
if (!agentPresetId && agentModePayload?.modeType === "detail_replacement") {
const replacementReferences = normalizeAgentModeReferences(agentModePayload);
const detailReplacementStrategy = isDetailReplacementPreset
? normalizeAgentModeDetailReplacementStrategy(agentModePayload)
: "scene_replace";
const detailReplacementStrategy =
normalizeAgentModeDetailReplacementStrategy(agentModePayload);
if (replacementReferences.length === 0) {
return NextResponse.json(
{
@ -7039,16 +7158,18 @@ export async function POST(request: Request) {
"详情参考图替换",
);
const baseReplacementPrompt =
buildAgentSlotPrompt({
workflowPrompt: hiddenPromptText || null,
slotPrompt: "",
userSupplementPrompt: sanitizedUserSupplementPrompt || null,
includeSlotTitle: false,
modeType: "detail_replacement",
}) ||
hiddenPromptText ||
sanitizedUserSupplementPrompt ||
"执行详情参考图背景替换";
detailReplacementStrategy === "product_only"
? "仅替换参考详情图中的产品主体"
: buildAgentSlotPrompt({
workflowPrompt: hiddenPromptText || null,
slotPrompt: "",
userSupplementPrompt: sanitizedUserSupplementPrompt || null,
includeSlotTitle: false,
modeType: "detail_replacement",
}) ||
hiddenPromptText ||
sanitizedUserSupplementPrompt ||
"执行详情参考图背景替换";
const resolvedAspectRatio = "auto";
const resolvedImageSize =
effectiveImageParams?.imageSize || defaultImageQualityParams.imageSize;
@ -7226,11 +7347,15 @@ export async function POST(request: Request) {
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
modeType: "detail_replacement",
replacementStrategy: detailReplacementStrategy,
referenceImageUrl: reference.imageUrl,
referenceLabel: reference.label,
productImageUrls: productInputImageUrls,
modelImageUrl: modelInputImageUrl,
workflowPrompt: baseReplacementPrompt || hiddenPromptText || null,
workflowPrompt:
detailReplacementStrategy === "product_only"
? null
: baseReplacementPrompt || hiddenPromptText || null,
planningRuntime: replacementScenePlanningRuntime,
models: replacementVisionModels,
});
@ -7269,22 +7394,26 @@ export async function POST(request: Request) {
: productInputImageUrls.slice(0, Math.min(productInputImageUrls.length, 2));
const sourceImageUrls = Array.from(
new Set([
...selectedProductContextImageUrls,
...(replacementExecutionPlan.scenePlan.useModelImage && modelInputImageUrl
? [modelInputImageUrl]
: []),
reference.imageUrl,
...selectedProductContextImageUrls,
...(replacementExecutionPlan.scenePlan.useModelImage &&
detailReplacementStrategy !== "product_only" &&
modelInputImageUrl
? [modelInputImageUrl]
: []),
reference.imageUrl,
]),
);
const replacementPrompt = buildAgentReplacementPrompt({
basePrompt: baseReplacementPrompt,
modeType: "detail_replacement",
referenceLabel: reference.label,
referenceIndex,
totalReferences: replacementReferences.length,
hasModelImage:
replacementExecutionPlan.scenePlan.useModelImage &&
Boolean(modelInputImageUrl),
const replacementPrompt = buildAgentReplacementPrompt({
basePrompt: baseReplacementPrompt,
modeType: "detail_replacement",
replacementStrategy: detailReplacementStrategy,
referenceLabel: reference.label,
referenceIndex,
totalReferences: replacementReferences.length,
hasModelImage:
replacementExecutionPlan.scenePlan.useModelImage &&
detailReplacementStrategy !== "product_only" &&
Boolean(modelInputImageUrl),
productImageCount: selectedProductContextImageUrls.length,
originalProductImageCount: productInputImageUrls.length,
templateReferenceCount: 0,
@ -7321,7 +7450,9 @@ export async function POST(request: Request) {
try {
const normalizedImage = await normalizeReplacementResultImage({
generatedImageUrl: firstImage.originalUrl,
generatedThumbnailUrl: firstImage.thumbnailUrl,
referenceImageUrl: reference.imageUrl,
referenceGeometry,
uploadPrefix: "agent-detail-replacement",
});
finalImage = {
@ -7574,6 +7705,7 @@ export async function POST(request: Request) {
isDetailReplacementPreset || isSkuReplacementPreset
? {
modeType: agentModePayload?.modeType || null,
replacementStrategy: agentModePayload?.replacementStrategy || null,
query: agentModePayload?.query || null,
queryType: agentModePayload?.queryType || null,
selectedCandidateId: agentModePayload?.selectedCandidateId || null,
@ -7659,11 +7791,22 @@ export async function POST(request: Request) {
),
);
const baseReplacementPrompt = buildAgentSlotPrompt({
workflowPrompt: hiddenPromptText || null,
workflowPrompt:
isDetailReplacementPreset &&
detailReplacementStrategy === "product_only"
? null
: hiddenPromptText || null,
slotPrompt:
replacementSlot?.promptTemplate || selectedAgentPreset.promptTemplate,
isDetailReplacementPreset &&
detailReplacementStrategy === "product_only"
? ""
: replacementSlot?.promptTemplate || selectedAgentPreset.promptTemplate,
slotTitle: selectedAgentPreset.name,
userSupplementPrompt: sanitizedUserSupplementPrompt || null,
userSupplementPrompt:
isDetailReplacementPreset &&
detailReplacementStrategy === "product_only"
? null
: sanitizedUserSupplementPrompt || null,
includeSlotTitle: false,
modeType: isDetailReplacementPreset
? "detail_replacement"
@ -7890,11 +8033,17 @@ export async function POST(request: Request) {
: productInputImageUrls;
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
modeType: isDetailReplacementPreset ? "detail_replacement" : "sku_replacement",
replacementStrategy:
isDetailReplacementPreset ? detailReplacementStrategy : undefined,
referenceImageUrl: reference.imageUrl,
referenceLabel: reference.label,
productImageUrls: orderedProductContextImageUrls,
modelImageUrl: modelInputImageUrl,
workflowPrompt: baseReplacementPrompt || hiddenPromptText || null,
workflowPrompt:
isDetailReplacementPreset &&
detailReplacementStrategy === "product_only"
? null
: baseReplacementPrompt || hiddenPromptText || null,
planningRuntime: replacementScenePlanningRuntime,
models: replacementVisionModels,
});
@ -7987,6 +8136,8 @@ export async function POST(request: Request) {
[
...selectedProductContextImageUrls,
...(replacementExecutionPlan.scenePlan.useModelImage &&
!(isDetailReplacementPreset &&
detailReplacementStrategy === "product_only") &&
modelInputImageUrl
? [modelInputImageUrl]
: []),
@ -8002,11 +8153,15 @@ export async function POST(request: Request) {
modeType: isDetailReplacementPreset
? "detail_replacement"
: "sku_replacement",
replacementStrategy:
isDetailReplacementPreset ? detailReplacementStrategy : undefined,
referenceLabel: reference.label,
referenceIndex,
totalReferences: replacementReferences.length,
hasModelImage:
replacementExecutionPlan.scenePlan.useModelImage &&
!(isDetailReplacementPreset &&
detailReplacementStrategy === "product_only") &&
Boolean(modelInputImageUrl),
productImageCount: selectedProductContextImageUrls.length,
originalProductImageCount: productInputImageUrls.length,
@ -8066,7 +8221,9 @@ export async function POST(request: Request) {
try {
const normalizedImage = await normalizeReplacementResultImage({
generatedImageUrl: firstImage.originalUrl,
generatedThumbnailUrl: firstImage.thumbnailUrl,
referenceImageUrl: reference.imageUrl,
referenceGeometry,
uploadPrefix: isDetailReplacementPreset
? "agent-detail-replacement"
: "agent-sku-replacement",

View File

@ -91,6 +91,7 @@ const agentModeSourceImageSchema = z.object({
const agentModePayloadSchema = z
.object({
modeType: z.enum(["detail_replacement", "sku_replacement"]),
replacementStrategy: z.enum(["scene_replace", "product_only"]).optional(),
query: z.string().max(64).optional(),
queryType: z.enum(["sku", "spu", "productId"]).optional(),
selectedCandidateId: z.string().min(1).max(128).nullable().optional(),

View File

@ -3,15 +3,18 @@ import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth";
export const runtime = "nodejs";
export const maxDuration = 120;
export const maxDuration = 300;
type DownloadFileItem = {
url: string;
name?: string;
};
const FETCH_TIMEOUT_MS = 15000;
const MAX_CONCURRENCY = 6;
const FETCH_TIMEOUT_MS = 30000;
const FETCH_RETRY_COUNT = 3;
const MAX_CONCURRENCY = 8;
const MAX_DOWNLOAD_FILES = 200;
const RETRYABLE_STATUS_CODES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
function sanitizeFileName(value: string, fallback: string): string {
const cleaned = String(value || "")
@ -52,6 +55,20 @@ function sanitizeFolderName(value: string): string {
return cleaned || `canvas-images-${Date.now()}`;
}
function buildContentDisposition(folderName: string): string {
const encodedName = encodeURIComponent(`${folderName}.zip`);
const asciiName =
folderName
.replace(/[^\x20-\x7E]+/g, "")
.replace(/"/g, "")
.trim() || "canvas-images";
return `attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodedName}`;
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
@ -96,7 +113,94 @@ function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
} as DownloadFileItem;
})
.filter((item): item is DownloadFileItem => Boolean(item))
.slice(0, 100);
.slice(0, MAX_DOWNLOAD_FILES);
}
function buildRemoteFetchHeaders(fileUrl: string, requestOrigin: string, cookie: string): HeadersInit {
const headers: Record<string, string> = {
Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"User-Agent":
"Mozilla/5.0 (compatible; BuyiCanvasDownloader/1.0; +https://buyi.ai)",
Referer: requestOrigin,
};
try {
const targetOrigin = new URL(fileUrl).origin;
if (targetOrigin === requestOrigin && cookie) {
headers.cookie = cookie;
}
} catch (_error) {
// URL has already been normalized before this point; keep this defensive.
}
return headers;
}
function normalizeDownloadError(error: unknown): string {
if (error instanceof Error) return error.message || "download failed";
if (typeof error === "string") return error;
return "download failed";
}
async function fetchDownloadFile(params: {
file: DownloadFileItem;
index: number;
requestOrigin: string;
cookie: string;
}): Promise<
| { ok: true; filename: string; bytes: Buffer }
| { ok: false; url: string; name?: string; error: string }
> {
let lastError = "";
for (let attempt = 1; attempt <= FETCH_RETRY_COUNT; attempt += 1) {
try {
const response = await fetchWithTimeout(
params.file.url,
{
method: "GET",
cache: "no-store",
redirect: "follow",
headers: buildRemoteFetchHeaders(params.file.url, params.requestOrigin, params.cookie),
},
FETCH_TIMEOUT_MS
);
if (!response.ok) {
lastError = `HTTP ${response.status}`;
if (attempt < FETCH_RETRY_COUNT && RETRYABLE_STATUS_CODES.has(response.status)) {
await sleep(350 * attempt);
continue;
}
break;
}
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length === 0) {
throw new Error("empty response");
}
const ext = inferExt(params.file.url, response.headers.get("content-type") || "");
const baseName = sanitizeFileName(
params.file.name || `canvas-image-${params.index + 1}`,
`canvas-image-${params.index + 1}`
);
const filename = `${baseName}-${params.index + 1}.${ext}`;
return { ok: true, filename, bytes };
} catch (error) {
lastError = normalizeDownloadError(error);
if (attempt < FETCH_RETRY_COUNT) {
await sleep(350 * attempt);
}
}
}
return {
ok: false,
url: params.file.url,
name: params.file.name,
error: lastError || "图片下载失败",
};
}
export async function POST(request: NextRequest) {
@ -132,7 +236,11 @@ export async function POST(request: NextRequest) {
const zip = new JSZip();
const folder = zip.folder(folderName) || zip;
const cookie = request.headers.get("cookie") || "";
const results: Array<{ filename: string; bytes: Buffer } | null> = new Array(files.length).fill(null);
const results: Array<
| { ok: true; filename: string; bytes: Buffer }
| { ok: false; url: string; name?: string; error: string }
| null
> = new Array(files.length).fill(null);
let cursor = 0;
const workerCount = Math.min(MAX_CONCURRENCY, files.length);
@ -143,53 +251,47 @@ export async function POST(request: NextRequest) {
cursor += 1;
if (index >= files.length) break;
const file = files[index]!;
try {
const targetOrigin = new URL(file.url).origin;
const headers =
targetOrigin === requestOrigin && cookie
? ({
cookie,
} as HeadersInit)
: undefined;
const response = await fetchWithTimeout(
file.url,
{
method: "GET",
cache: "no-store",
headers,
},
FETCH_TIMEOUT_MS
);
if (!response.ok) continue;
const ext = inferExt(file.url, response.headers.get("content-type") || "");
const baseName = sanitizeFileName(file.name || `canvas-image-${index + 1}`, `canvas-image-${index + 1}`);
const filename = `${baseName}-${index + 1}.${ext}`;
const bytes = Buffer.from(await response.arrayBuffer());
results[index] = { filename, bytes };
} catch (_error) {
continue;
}
results[index] = await fetchDownloadFile({
file,
index,
requestOrigin,
cookie,
});
}
})
);
let successCount = 0;
let failedCount = 0;
for (const item of results) {
if (!item) {
const failures: Array<{ index: number; url: string; name?: string; error: string }> = [];
for (const [index, item] of results.entries()) {
if (!item || !item.ok) {
failedCount += 1;
failures.push({
index: index + 1,
url: item?.ok === false ? item.url : files[index]?.url || "",
name: item?.ok === false ? item.name : files[index]?.name,
error: item?.ok === false ? item.error : "未知错误",
});
continue;
}
folder.file(item.filename, item.bytes);
successCount += 1;
}
if (successCount === 0) {
return NextResponse.json(
{ success: false, error: "图片下载失败,请检查图片链接是否可访问" },
{ status: 502 }
if (failures.length > 0) {
folder.file(
"_download-failures.txt",
[
"以下图片暂时无法下载。可稍后重试,或复制链接检查源图是否仍可访问。",
"",
...failures.map((item) => {
const label = item.name ? `${item.index}. ${item.name}` : `${item.index}. image`;
return `${label}\nURL: ${item.url}\n原因: ${item.error}`;
}),
].join("\n\n")
);
console.warn("[CanvasDownloadZip] Partial download failures:", failures);
}
const zipBuffer = await zip.generateAsync({
@ -202,7 +304,7 @@ export async function POST(request: NextRequest) {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename=\"${folderName}.zip\"`,
"Content-Disposition": buildContentDisposition(folderName),
"Cache-Control": "no-store",
"X-Download-Success-Count": String(successCount),
"X-Download-Failed-Count": String(failedCount),

View File

@ -26,6 +26,8 @@ import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
const MERGED_IMAGE_MAX_WIDTH = 1600;
const MIN_SLICE_HEIGHT = 120;
const MERGED_IMAGE_MAX_PIXELS = 42_000_000;
const STITCH_IMAGE_LOAD_CONCURRENCY = 6;
const STITCH_SLICE_UPLOAD_CONCURRENCY = 4;
type LoadedReferenceImage = {
card: AgentDetailReplacementReferenceItem;
@ -47,6 +49,38 @@ function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
async function runIndexedParallel<T>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<void>
) {
const workerCount = Math.max(1, Math.min(concurrency, items.length));
let cursor = 0;
let firstError: unknown = null;
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (cursor < items.length) {
const index = cursor;
cursor += 1;
if (firstError) {
return;
}
try {
await worker(items[index]!, index);
} catch (error) {
firstError = firstError || error;
}
}
})
);
if (firstError) {
throw firstError;
}
}
function createImageElement(src: string, useCrossOrigin = true) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
@ -338,32 +372,39 @@ export function AgentDetailReferenceStitchDialog({
const failedCards: string[] = [];
let processedCount = 0;
for (const [index, card] of setupCards.entries()) {
try {
const loaded = await loadImage(card.imageUrl);
loadedImages.push({
card,
image: loaded.image,
width: loaded.image.naturalWidth,
height: loaded.image.naturalHeight,
scaledHeight: loaded.image.naturalHeight,
revokeObjectUrl: loaded.revokeObjectUrl,
} satisfies LoadedReferenceImage);
} catch (error) {
failedCards.push(card.label || `第 ${index + 1} 张参考图`);
console.error("[AgentDetailReferenceStitchDialog] Failed to load reference image:", {
cardId: card.id,
label: card.label,
imageUrl: card.imageUrl,
error,
});
} finally {
processedCount += 1;
if (!cancelled) {
setLoadingProgressText(`正在加载参考图 ${processedCount}/${setupCards.length}`);
const loadedResults: Array<LoadedReferenceImage | null> = new Array(setupCards.length).fill(null);
await runIndexedParallel(
setupCards,
STITCH_IMAGE_LOAD_CONCURRENCY,
async (card, index) => {
if (cancelled) return;
try {
const loaded = await loadImage(card.imageUrl);
loadedResults[index] = {
card,
image: loaded.image,
width: loaded.image.naturalWidth,
height: loaded.image.naturalHeight,
scaledHeight: loaded.image.naturalHeight,
revokeObjectUrl: loaded.revokeObjectUrl,
} satisfies LoadedReferenceImage;
} catch (error) {
failedCards.push(card.label || `第 ${index + 1} 张参考图`);
console.error("[AgentDetailReferenceStitchDialog] Failed to load reference image:", {
cardId: card.id,
label: card.label,
imageUrl: card.imageUrl,
error,
});
} finally {
processedCount += 1;
if (!cancelled) {
setLoadingProgressText(`正在加载参考图 ${processedCount}/${setupCards.length}`);
}
}
}
}
);
loadedImages = loadedResults.filter((item): item is LoadedReferenceImage => Boolean(item));
if (cancelled) return;
@ -575,80 +616,92 @@ export function AgentDetailReferenceStitchDialog({
}
setIsApplying(true);
setApplyProgressText(`正在上传切片 1/${sliceSegments.length}`);
setApplyProgressText(`正在上传切片 0/${sliceSegments.length}`);
try {
const nextCards: AgentDetailReplacementReferenceItem[] = [];
const nextCards: Array<AgentDetailReplacementReferenceItem | null> = new Array(sliceSegments.length).fill(null);
const sourceCanvas = mergedCanvasRef.current;
const sourceCandidateId = validCards[0]?.candidateId || null;
const timestamp = Date.now();
let completedCount = 0;
for (const segment of sliceSegments) {
setApplyProgressText(`正在上传切片 ${segment.index + 1}/${sliceSegments.length}`);
await runIndexedParallel(
sliceSegments,
STITCH_SLICE_UPLOAD_CONCURRENCY,
async (segment, index) => {
const sliceCanvas = document.createElement("canvas");
sliceCanvas.width = snapshot.width;
sliceCanvas.height = segment.height;
const context = sliceCanvas.getContext("2d");
if (!context) {
throw new Error("切片画布初始化失败");
}
const sliceCanvas = document.createElement("canvas");
sliceCanvas.width = snapshot.width;
sliceCanvas.height = segment.height;
const context = sliceCanvas.getContext("2d");
if (!context) {
throw new Error("切片画布初始化失败");
}
context.fillStyle = "#ffffff";
context.fillRect(0, 0, sliceCanvas.width, sliceCanvas.height);
context.drawImage(
sourceCanvas,
0,
segment.top,
snapshot.width,
segment.height,
0,
0,
snapshot.width,
segment.height
);
const blob = await new Promise<Blob>((resolve, reject) => {
sliceCanvas.toBlob(
(value) => {
if (value) {
resolve(value);
return;
}
reject(new Error("切片导出失败"));
},
"image/jpeg",
0.92
context.fillStyle = "#ffffff";
context.fillRect(0, 0, sliceCanvas.width, sliceCanvas.height);
context.drawImage(
sourceCanvas,
0,
segment.top,
snapshot.width,
segment.height,
0,
0,
snapshot.width,
segment.height
);
});
const formData = new FormData();
formData.append(
"file",
new File([blob], `detail-reference-slice-${timestamp}-${segment.index + 1}.jpg`, {
type: "image/jpeg",
})
);
const blob = await new Promise<Blob>((resolve, reject) => {
sliceCanvas.toBlob(
(value) => {
if (value) {
resolve(value);
return;
}
reject(new Error("切片导出失败"));
},
"image/jpeg",
0.92
);
});
const response = await fetchUploadWithRetry(formData);
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.url) {
throw new Error(result?.error || `第 ${segment.index + 1} 张切片上传失败`);
const formData = new FormData();
formData.append(
"file",
new File([blob], `detail-reference-slice-${timestamp}-${segment.index + 1}.jpg`, {
type: "image/jpeg",
})
);
const response = await fetchUploadWithRetry(formData);
const result = await response.json().catch(() => ({}));
if (!response.ok || !result?.url) {
throw new Error(result?.error || `第 ${segment.index + 1} 张切片上传失败`);
}
nextCards[index] = {
id: `detail-stitch-slice-${timestamp}-${segment.index + 1}`,
label: `拼合切片 ${segment.index + 1}`,
imageUrl: String(result.url),
candidateId: sourceCandidateId,
sortOrder: segment.index,
passthrough: passthroughSegmentIndexSet.has(segment.index),
};
completedCount += 1;
setApplyProgressText(`正在上传切片 ${completedCount}/${sliceSegments.length}`);
}
);
nextCards.push({
id: `detail-stitch-slice-${timestamp}-${segment.index + 1}`,
label: `拼合切片 ${segment.index + 1}`,
imageUrl: String(result.url),
candidateId: sourceCandidateId,
sortOrder: segment.index,
passthrough: passthroughSegmentIndexSet.has(segment.index),
});
const orderedCards = nextCards.filter(
(item): item is AgentDetailReplacementReferenceItem => Boolean(item)
);
if (orderedCards.length !== sliceSegments.length) {
throw new Error("部分切片上传失败,请稍后重试");
}
onApply(nextCards);
onApply(orderedCards);
onOpenChange(false);
toast.success(`已生成 ${nextCards.length} 张切片参考图`);
toast.success(`已生成 ${orderedCards.length} 张切片参考图`);
} catch (error) {
console.error("[AgentDetailReferenceStitchDialog] Failed to apply slices:", error);
toast.error(error instanceof Error ? error.message : "切割参考图失败");

View File

@ -14,7 +14,6 @@ import {
} from "react";
import useSWR from "swr";
import { motion, AnimatePresence } from "framer-motion";
import JSZip from "jszip";
import {
ArrowUp,
CheckCircle2,
@ -98,6 +97,7 @@ import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark
import type {
AgentDetailBriefForm,
AgentDetailGenerationProgressState,
AgentDetailReplacementStrategy,
AgentGenerationProjectWorkspaceState,
AgentGenerationProjectResultCollections,
AgentGenerationResultModeType,
@ -285,6 +285,8 @@ interface AgentModeReferenceSearchResponse {
const AI_DETAIL_GENERATION_DISABLED_MESSAGE = "开发中";
const IS_AI_DETAIL_GENERATION_DISABLED = true;
const DEFAULT_DETAIL_REPLACEMENT_STRATEGY: AgentDetailReplacementStrategy =
"scene_replace";
function getTaskDetailReferenceSourceLabel(source: AgentTaskDetailReferenceSource) {
return source === "main" ? "主图" : source === "sku" ? "SKU图" : "详情图";
@ -1438,6 +1440,10 @@ export function AgentGenerationWorkspaceDarkV2({
const detailReplacementQueryType = detailReplacementState.queryType || "spu";
const detailReplacementProductDetailChannel =
detailReplacementState.productDetailChannel || "taobao";
const detailReplacementStrategy =
detailReplacementState.replacementStrategy === "product_only"
? "product_only"
: DEFAULT_DETAIL_REPLACEMENT_STRATEGY;
const skuReplacementState = resolvedAgentModeState.skuReplacement;
const skuReplacementReferenceCards = skuReplacementState.references;
const presetModeConfig = useMemo(
@ -1487,6 +1493,9 @@ export function AgentGenerationWorkspaceDarkV2({
const isDetailAiGenerationMethod = currentResultModeType === "detail_generation";
const isReplacementPreset =
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement";
const isProductOnlyDetailReplacementMode =
currentResultModeType === "detail_replacement" &&
detailReplacementStrategy === "product_only";
const isSelectedBusinessModeUnavailable = activeBusinessMode?.enabled === false;
const productSlotCount =
selectedAgentPreset?.type === "image" && requiresPresetSelection
@ -1966,6 +1975,7 @@ export function AgentGenerationWorkspaceDarkV2({
isSkuReplacementPreset: boolean;
detailReplacementQueryInput: string;
detailReplacementQueryType: AgentWorkflowReferenceQueryType;
detailReplacementStrategy: AgentDetailReplacementStrategy;
detailReplacementSelectedCandidateId: string | null;
detailReplacementReferenceCards: typeof detailReplacementReferenceCards;
detailReplacementProductSourceImages: typeof detailReplacementProductSourceImages;
@ -1989,6 +1999,7 @@ export function AgentGenerationWorkspaceDarkV2({
isSkuReplacementPreset: false,
detailReplacementQueryInput: "",
detailReplacementQueryType: "spu",
detailReplacementStrategy: DEFAULT_DETAIL_REPLACEMENT_STRATEGY,
detailReplacementSelectedCandidateId: null,
detailReplacementReferenceCards: [],
detailReplacementProductSourceImages: [],
@ -2009,6 +2020,7 @@ export function AgentGenerationWorkspaceDarkV2({
isSkuReplacementPreset,
detailReplacementQueryInput,
detailReplacementQueryType,
detailReplacementStrategy,
detailReplacementSelectedCandidateId: detailReplacementSelectedCandidateId || null,
detailReplacementReferenceCards,
detailReplacementProductSourceImages,
@ -2145,6 +2157,9 @@ export function AgentGenerationWorkspaceDarkV2({
queryType: transportState.isDetailReplacementPreset
? transportState.detailReplacementQueryType
: transportState.skuReplacementQueryType,
replacementStrategy: transportState.isDetailReplacementPreset
? transportState.detailReplacementStrategy
: undefined,
selectedCandidateId: transportState.isDetailReplacementPreset
? transportState.detailReplacementSelectedCandidateId || null
: transportState.skuReplacementSelectedCandidateId || null,
@ -3383,44 +3398,35 @@ export function AgentGenerationWorkspaceDarkV2({
setBatchDownloadState(params.scope);
try {
const zip = new JSZip();
let successCount = 0;
const form = document.createElement("form");
form.method = "POST";
form.action = "/api/canvas/download-zip";
form.style.display = "none";
for (const [index, imageUrl] of normalizedUrls.entries()) {
try {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const fallbackName = `${params.zipName}-${String(index + 1).padStart(2, "0")}.png`;
zip.file(buildDownloadFilename(imageUrl) || fallbackName, blob);
successCount += 1;
} catch (error) {
console.warn("[AgentGenerationWorkspaceDarkV2] Batch download item failed:", {
imageUrl,
error,
});
}
}
const payloadInput = document.createElement("input");
payloadInput.type = "hidden";
payloadInput.name = "payload";
payloadInput.value = JSON.stringify({
folderName: params.zipName,
files: normalizedUrls.map((imageUrl, index) => ({
url: imageUrl,
name:
buildDownloadFilename(imageUrl) ||
`${params.zipName}-${String(index + 1).padStart(2, "0")}`,
})),
});
form.appendChild(payloadInput);
if (successCount === 0) {
throw new Error("没有成功下载到任何图片");
}
const blob = await zip.generateAsync({ type: "blob" });
const link = document.createElement("a");
link.href = window.URL.createObjectURL(blob);
link.download = `${params.zipName}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(link.href);
toast.success(`已打包下载 ${successCount} 张图片`);
document.body.appendChild(form);
form.submit();
form.remove();
toast.success("已开始服务端打包下载,请留意浏览器下载栏");
} catch (error) {
toast.error(error instanceof Error ? error.message : "批量下载失败");
} finally {
setBatchDownloadState(null);
window.setTimeout(() => {
setBatchDownloadState(null);
}, 1200);
}
}, []);
const handleSendResultToCanvasTextEdit = useCallback((params: {
@ -4271,6 +4277,15 @@ export function AgentGenerationWorkspaceDarkV2({
},
[updateDetailReplacementState]
);
const updateDetailReplacementStrategy = useCallback(
(replacementStrategy: AgentDetailReplacementStrategy) => {
updateDetailReplacementState((current) => ({
...current,
replacementStrategy,
}));
},
[updateDetailReplacementState]
);
const handleAiDetailGenerationUnavailableClick = useCallback(() => {
toast.info(AI_DETAIL_GENERATION_DISABLED_MESSAGE);
}, []);
@ -5762,12 +5777,20 @@ export function AgentGenerationWorkspaceDarkV2({
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isProductOnlyDetailReplacementMode}
placeholder={
isDetailAiGenerationMethod
? "补充详情页想强调的卖点、视觉氛围、风格要求或屏数,例如:生成12屏现代极简风详情页..."
: isProductOnlyDetailReplacementMode
? "仅替换产品模式会忽略提示词,直接把输入产品替换到参考详情图的产品区域。"
: "描述你想要的风格、氛围、构图..."
}
className="min-h-[160px] resize-none rounded-[24px] border-white/12 bg-black text-sm text-white placeholder:text-white/20 focus:border-emerald-400/30"
className={cn(
"min-h-[160px] resize-none rounded-[24px] border-white/12 bg-black text-sm text-white placeholder:text-white/20 focus:border-emerald-400/30",
isProductOnlyDetailReplacementMode
? "cursor-not-allowed opacity-70"
: ""
)}
/>
</div>
@ -5838,6 +5861,54 @@ export function AgentGenerationWorkspaceDarkV2({
</button>
</div>
</div>
{!isDetailAiGenerationMethod ? (
<div className="mt-3 rounded-[20px] border border-white/10 bg-black/20 p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-sm font-medium text-white">替换策略</div>
<div className="text-[11px] text-white/45">
{isProductOnlyDetailReplacementMode
? "忽略提示词,只替换产品"
: "结合提示词重建场景"}
</div>
</div>
<div className="grid gap-2 lg:grid-cols-2">
<button
type="button"
onClick={() =>
updateDetailReplacementStrategy("scene_replace")
}
className={cn(
"rounded-[18px] border px-4 py-3 text-left transition-all",
detailReplacementStrategy === "scene_replace"
? "border-emerald-300/45 bg-emerald-400/18 text-white shadow-[0_0_0_1px_rgba(110,231,183,0.16),0_10px_28px_rgba(16,185,129,0.14)]"
: "border-white/12 bg-black/25 text-white/70 hover:border-white/22 hover:text-white"
)}
>
<div className="text-sm font-medium">场景整体替换</div>
<div className="mt-1 text-xs leading-5 text-inherit/80">
使用提示词和工作流风格,重建参考图里的产品/背景场景区域。
</div>
</button>
<button
type="button"
onClick={() =>
updateDetailReplacementStrategy("product_only")
}
className={cn(
"rounded-[18px] border px-4 py-3 text-left transition-all",
detailReplacementStrategy === "product_only"
? "border-emerald-300/45 bg-emerald-400/18 text-white shadow-[0_0_0_1px_rgba(110,231,183,0.16),0_10px_28px_rgba(16,185,129,0.14)]"
: "border-white/12 bg-black/25 text-white/70 hover:border-white/22 hover:text-white"
)}
>
<div className="text-sm font-medium">仅替换产品</div>
<div className="mt-1 text-xs leading-5 text-inherit/80">
不使用提示词,只把输入产品替换进参考详情图的产品区域,其余文案、排版和场景保持不变。
</div>
</button>
</div>
</div>
) : null}
</div>
) : null}
@ -5848,7 +5919,9 @@ export function AgentGenerationWorkspaceDarkV2({
{isDetailAiGenerationMethod
? "AI详情生成会先自动规划整套详情分镜,再按顺序逐屏生成,结果会保留在“AI详情”分类中。"
: isDetailReplacementPreset
? "参考图替换会按当前参考图顺序逐张执行,结果将在下方结果区一一回显。"
? isProductOnlyDetailReplacementMode
? "仅替换产品会逐张锁定参考图版式和文案,只替换其中的产品主体,结果将在下方结果区一一回显。"
: "参考图替换会按当前参考图顺序逐张执行,结果将在下方结果区一一回显。"
: "SKU替换会按当前参考图顺序执行一对一替换,结果将在下方结果区一一回显。"}
</div>
) : null}

View File

@ -1,4 +1,5 @@
import type {
AgentDetailReplacementStrategy,
AgentDetailReplacementReferenceItem,
AgentModeReferenceCandidate,
AgentModeWorkspaceState,
@ -20,6 +21,7 @@ export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState {
query: "",
queryType: "spu",
productDetailChannel: "taobao",
replacementStrategy: "scene_replace",
candidates: [],
selectedCandidateId: null,
references: [],
@ -88,6 +90,12 @@ function normalizeProductDetailChannel(value: unknown): AgentWorkflowProductDeta
return value === "1688" || value === "douyin" || value === "jd" ? value : "taobao";
}
function normalizeDetailReplacementStrategy(
value: unknown
): AgentDetailReplacementStrategy {
return value === "product_only" ? "product_only" : "scene_replace";
}
function normalizeGenerationSlots(value: unknown) {
if (!Array.isArray(value)) return createEmptyAgentDetailSlots();
const base = createEmptyAgentDetailSlots();
@ -193,6 +201,9 @@ export function normalizeAgentModeWorkspaceState(value: unknown): AgentModeWorks
query: String(detailRaw.query || detailRaw.spuQuery || "").trim(),
queryType: normalizeReferenceQueryType(detailRaw.queryType, "spu"),
productDetailChannel: normalizeProductDetailChannel(detailRaw.productDetailChannel),
replacementStrategy: normalizeDetailReplacementStrategy(
detailRaw.replacementStrategy
),
candidates: normalizeCandidates(detailRaw.candidates),
selectedCandidateId: String(detailRaw.selectedCandidateId || "").trim() || null,
references: normalizeReferenceItems(detailRaw.references),

View File

@ -424,11 +424,14 @@ export interface AgentModeReferenceCandidate {
allImageUrls: string[];
}
export type AgentDetailReplacementStrategy = "scene_replace" | "product_only";
export interface AgentDetailReplacementState {
method: AgentDetailMethod;
query: string;
queryType: AgentWorkflowReferenceQueryType;
productDetailChannel?: AgentWorkflowProductDetailChannel;
replacementStrategy?: AgentDetailReplacementStrategy;
candidates: AgentModeReferenceCandidate[];
selectedCandidateId?: string | null;
references: AgentDetailReplacementReferenceItem[];