fix: harden detail replacement layout rules

This commit is contained in:
Your Name 2026-06-03 11:13:19 +08:00
parent 1eddc7e296
commit a154a28a4a
3 changed files with 263 additions and 52 deletions

View File

@ -54,8 +54,11 @@ export type ReplacementReferenceVisualProfile = {
height: number;
nearWhitePixelRatio: number;
lightLowSaturationPixelRatio: number;
topLightLowSaturationPixelRatio: number;
borderLightLowSaturationPixelRatio: number;
darkPixelRatio: number;
likelyWhiteLayout: boolean;
likelyWhiteScene: boolean;
reason: string;
};
@ -131,9 +134,16 @@ export async function resolveReplacementReferenceVisualProfile(
const pixelCount = Math.max(1, info.width * info.height);
let nearWhiteCount = 0;
let lightLowSaturationCount = 0;
let topLightLowSaturationCount = 0;
let topPixelCount = 0;
let borderLightLowSaturationCount = 0;
let borderPixelCount = 0;
let darkCount = 0;
for (let offset = 0; offset < data.length; offset += info.channels) {
const pixelIndex = Math.floor(offset / info.channels);
const x = pixelIndex % info.width;
const y = Math.floor(pixelIndex / info.width);
const red = data[offset] || 0;
const green = data[offset + 1] || 0;
const blue = data[offset + 2] || 0;
@ -141,13 +151,32 @@ export async function resolveReplacementReferenceVisualProfile(
const minChannel = Math.min(red, green, blue);
const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255;
const saturationSpan = maxChannel - minChannel;
const isLightLowSaturation = luminance >= 0.82 && saturationSpan <= 42;
const isTopBand = y <= Math.round(info.height * 0.32);
const isBorderBand =
x <= Math.round(info.width * 0.06) ||
x >= Math.round(info.width * 0.94) ||
y <= Math.round(info.height * 0.08) ||
y >= Math.round(info.height * 0.92);
if (red >= 238 && green >= 238 && blue >= 238) {
nearWhiteCount += 1;
}
if (luminance >= 0.82 && saturationSpan <= 42) {
if (isLightLowSaturation) {
lightLowSaturationCount += 1;
}
if (isTopBand) {
topPixelCount += 1;
if (isLightLowSaturation) {
topLightLowSaturationCount += 1;
}
}
if (isBorderBand) {
borderPixelCount += 1;
if (isLightLowSaturation) {
borderLightLowSaturationCount += 1;
}
}
if (luminance <= 0.2) {
darkCount += 1;
}
@ -155,23 +184,40 @@ export async function resolveReplacementReferenceVisualProfile(
const nearWhitePixelRatio = nearWhiteCount / pixelCount;
const lightLowSaturationPixelRatio = lightLowSaturationCount / pixelCount;
const topLightLowSaturationPixelRatio =
topLightLowSaturationCount / Math.max(1, topPixelCount);
const borderLightLowSaturationPixelRatio =
borderLightLowSaturationCount / Math.max(1, borderPixelCount);
const darkPixelRatio = darkCount / pixelCount;
const likelyWhiteScene =
(lightLowSaturationPixelRatio >= 0.5 && darkPixelRatio <= 0.28) ||
(topLightLowSaturationPixelRatio >= 0.68 &&
borderLightLowSaturationPixelRatio >= 0.52 &&
darkPixelRatio <= 0.32);
const likelyWhiteLayout =
nearWhitePixelRatio >= 0.42 ||
(nearWhitePixelRatio >= 0.3 &&
lightLowSaturationPixelRatio >= 0.58 &&
darkPixelRatio <= 0.18);
darkPixelRatio <= 0.18) ||
(topLightLowSaturationPixelRatio >= 0.72 &&
borderLightLowSaturationPixelRatio >= 0.6 &&
darkPixelRatio <= 0.24);
return {
width: info.width,
height: info.height,
nearWhitePixelRatio,
lightLowSaturationPixelRatio,
topLightLowSaturationPixelRatio,
borderLightLowSaturationPixelRatio,
darkPixelRatio,
likelyWhiteLayout,
likelyWhiteScene,
reason: likelyWhiteLayout
? "像素初筛显示大面积白底/浅色低饱和区域,按白底说明图保护"
: "像素初筛未达到白底说明图阈值",
? "像素初筛显示大面积白底/浅色低饱和版式区域,按白底说明图保护"
: likelyWhiteScene
? "像素初筛显示白/浅色低饱和场景,按白场景保护"
: "像素初筛未达到白底/白场景阈值",
};
} catch (error) {
chatApiLogger.warn("[Chat API] Failed to resolve replacement reference visual profile:", {
@ -717,11 +763,18 @@ function applyDetailReplacementScenePlanGuards(
scenePlan.promptPackPolicy !== "forbid";
const hasWhiteLayoutHint = DETAIL_WHITE_LAYOUT_HINT_PATTERN.test(planText);
const hasPeopleActionHint = DETAIL_PEOPLE_ACTION_HINT_PATTERN.test(planText);
const visualProfileSuggestsWhiteScene =
params.visualProfile?.likelyWhiteScene === true ||
params.visualProfile?.likelyWhiteLayout === true;
const shouldProtectWhiteLayout =
!isProductOnly &&
(scenePlan.whiteBackgroundLikely ||
params.visualProfile?.likelyWhiteLayout === true ||
hasWhiteLayoutHint);
const shouldProtectWhiteScene =
!isProductOnly &&
!shouldProtectWhiteLayout &&
visualProfileSuggestsWhiteScene;
const shouldProtectPeople =
!isProductOnly &&
(params.containsPeople ||
@ -737,7 +790,43 @@ function applyDetailReplacementScenePlanGuards(
let next: ReplacementScenePlan = { ...scenePlan };
if (shouldProtectPeople) {
if (shouldProtectPeople && (shouldProtectWhiteLayout || shouldProtectWhiteScene)) {
next = {
...next,
generationRoute: shouldProtectWhiteLayout
? "layout_preserve_replace"
: "people_relation_locked",
promptPackPolicy: "forbid",
textPolicy: next.textPolicy === "free" ? "preserve" : next.textPolicy,
peoplePolicy: shouldProtectPeople ? "preserve_relation" : next.peoplePolicy,
useModelImage: params.hasModelImage && shouldProtectPeople,
modelImageReason: params.hasModelImage
? next.modelImageReason ||
"当前参考页有人物/动作,但参考图为白底/浅色场景,模特只用于保持人物身份、动作和姿态,不用于重建复杂空间。"
: next.modelImageReason ||
"当前参考页有人物/动作,但参考图为白底/浅色场景;应保留人物数量、动作和关系,不要生成复杂家居场景。",
routeReason: appendUniqueInstruction(
next.routeReason,
shouldProtectWhiteLayout
? "命中白底排版 + 人物/动作语义,锁定白底版式和人物关系,禁止套用场景词包。"
: "命中白/浅色场景 + 人物/动作语义,锁定白场景和人物关系,禁止套用复杂空间词包。",
220,
),
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
shouldProtectWhiteLayout
? "本屏是白底/浅色详情排版页:必须保持白底或浅色底、标题区、标签、图片框、留白、人物数量/动作/关系和产品位置;不使用前置词包,不生成厨房、客厅、黑墙、吊灯或复杂软装场景。"
: "本屏是白/浅色低饱和场景页:最终结果也必须保持白色、米白、浅灰或浅木色的干净场景基调;人物数量、动作、朝向和产品关系保真,但不要生成厨房、客厅、黑墙、吊灯、复杂柜体或强软装背景。",
),
avoidSummary: appendUniqueInstruction(
next.avoidSummary,
"不要把白底/白场景人物页改成复杂室内场景,不要让前置词包覆盖白色背景,不要把人物动作变成走动路人或无关模特。",
280,
),
whiteBackgroundLikely: true,
copyImageCoupled: next.copyImageCoupled || shouldProtectWhiteLayout,
};
} else if (shouldProtectPeople) {
next = {
...next,
generationRoute: "people_relation_locked",
@ -796,7 +885,7 @@ function applyDetailReplacementScenePlanGuards(
whiteBackgroundLikely: next.whiteBackgroundLikely || shouldProtectWhiteLayout,
copyImageCoupled: true,
};
} else if (shouldProtectWhiteLayout) {
} else if (shouldProtectWhiteLayout || shouldProtectWhiteScene) {
next = {
...next,
generationRoute: "layout_preserve_replace",
@ -810,7 +899,9 @@ function applyDetailReplacementScenePlanGuards(
),
generationPrompt: appendUniqueInstruction(
next.generationPrompt,
"本屏是白底/浅色产品说明版式:不要套用空间、人群、灯光、机位等前置词包,只保留参考图白底排版并做必要局部替换。",
shouldProtectWhiteLayout
? "本屏是白底/浅色产品说明版式:不要套用空间、人群、灯光、机位等前置词包,只保留参考图白底排版并做必要局部替换。"
: "本屏是白/浅色低饱和场景:最终结果也必须保持白场景、浅色背景、干净留白和低饱和氛围,不要改成厨房、客厅、黑墙、吊灯或复杂软装场景。",
),
whiteBackgroundLikely: true,
};
@ -1009,6 +1100,7 @@ export function buildHeuristicReplacementScenePlan(params: {
isDetailReplacement &&
!isProductOnly &&
(Boolean(params.visualProfile?.likelyWhiteLayout) ||
Boolean(params.visualProfile?.likelyWhiteScene) ||
DETAIL_STRONG_COPY_HINT_PATTERN.test(fallbackText));
const generationRoute = params.shouldReplace
? isProductOnly
@ -1027,14 +1119,16 @@ export function buildHeuristicReplacementScenePlan(params: {
? "preserve"
: "free",
peoplePolicy: "none",
whiteBackgroundLikely: Boolean(params.visualProfile?.likelyWhiteLayout),
whiteBackgroundLikely: Boolean(
params.visualProfile?.likelyWhiteLayout || params.visualProfile?.likelyWhiteScene,
),
copyImageCoupled: false,
routeReason: params.shouldReplace
? isProductOnly
? "兜底按仅替换产品执行,不使用词包。"
: isDetailReplacement
? shouldProtectFallbackLayout
? "兜底命中白底/说明/强文案特征,按详情版式保护执行,不使用词包。"
? "兜底命中白底/白场景/说明/强文案特征,按详情版式或白场景保护执行,不使用词包。"
: "兜底未命中白底、强文案或真人互动硬约束,按通用场景图执行词包场景替换。"
: "兜底未检测到强约束,允许使用词包生成场景。"
: params.reason,
@ -1050,7 +1144,7 @@ export function buildHeuristicReplacementScenePlan(params: {
? isProductOnly
? "严格保留参考图的场景、文案和版式,只把产品展示区中的旧产品替换成输入产品。"
: shouldProtectFallbackLayout
? "保留参考图版式骨架、构图、文字和主体位置,只做必要的产品或视觉区替换。"
? "保留参考图白底/白场景、版式骨架、构图、文字和主体位置,只做必要的产品或视觉区替换。"
: "保留参考图版式骨架、构图和主体位置,但把旧场景视觉内容按工作流目标风格重建。"
: "当前参考图不满足替换条件。",
cameraSummary: "保持参考图原有机位、构图和景别,不做整体重画。",
@ -1063,18 +1157,18 @@ export function buildHeuristicReplacementScenePlan(params: {
styleSummary: isProductOnly
? "延续参考图原有风格、背景和氛围,不引入新的场景改造要求。"
: shouldProtectFallbackLayout
? "延续参考图原有版式和图文语义,不引入新的空间场景。"
? "延续参考图原有白底/白场景、版式和图文语义,不引入新的空间场景。"
: "除版式骨架外,墙面、地面、背景家具、软装、灯光、摆件和空间氛围都要按工作流风格重建。",
avoidSummary: isProductOnly
? "不要改动背景、文案、按钮、图标、排版和装饰,不要重建整个场景,不要把多个产品角度同时塞进一张图。"
: shouldProtectFallbackLayout
? "不要把白底/说明/强文案页场景化,不要新增厨房/客厅/人物/软装,不要让文字和图片失配。"
? "不要把白底/白场景/说明/强文案页场景化,不要新增厨房/客厅/黑墙/吊灯/复杂软装,不要让文字和图片失配。"
: "不要只换产品不换场景,不要把参考图原有背景物件和空间氛围原样保留下来,不要把所有输入产品角度图同时强行出现在一张图中。",
generationPrompt: params.shouldReplace
? isProductOnly
? `仅替换参考图中的产品主体,严格保留其余背景、文案、排版与光影关系。${params.reason}`
: isDetailReplacement && shouldProtectFallbackLayout
? `保留参考图的版式、文案、白底/留白和图文语义,不使用前置词包;只在产品、材质、板材、原材料或视觉图片区做必要替换。${params.reason}`
? `保留参考图的版式、文案、白底/白场景/留白和图文语义,不使用前置词包;只在产品、材质、板材、原材料或视觉图片区做必要替换。${params.reason}`
: isDetailReplacement
? `当前参考图按通用空间/场景详情页处理:必须使用前置工作流词包重建旧背景、空间、软装、灯光和产品场景,只保留参考图版式骨架、文字区、机位和信息层级。${params.reason}`
: `以工作流目标风格重建当前这一屏的场景,只保留版式、机位、文字区与留白结构。${params.reason}`
@ -1565,10 +1659,13 @@ export async function analyzeReplacementExecutionPlan(params: {
`当前要分析的参考图:${params.referenceLabel}`,
"你现在只分析这一张参考图本身,不分析产品图,不分析模特图。",
referenceVisualProfile
? `像素初筛:nearWhite=${referenceVisualProfile.nearWhitePixelRatio.toFixed(2)}, lightLowSaturation=${referenceVisualProfile.lightLowSaturationPixelRatio.toFixed(2)}, dark=${referenceVisualProfile.darkPixelRatio.toFixed(2)}, likelyWhiteLayout=${referenceVisualProfile.likelyWhiteLayout ? "true" : "false"}。${referenceVisualProfile.reason}`
? `像素初筛:nearWhite=${referenceVisualProfile.nearWhitePixelRatio.toFixed(2)}, lightLowSaturation=${referenceVisualProfile.lightLowSaturationPixelRatio.toFixed(2)}, topLight=${referenceVisualProfile.topLightLowSaturationPixelRatio.toFixed(2)}, borderLight=${referenceVisualProfile.borderLightLowSaturationPixelRatio.toFixed(2)}, dark=${referenceVisualProfile.darkPixelRatio.toFixed(2)}, likelyWhiteLayout=${referenceVisualProfile.likelyWhiteLayout ? "true" : "false"}, likelyWhiteScene=${referenceVisualProfile.likelyWhiteScene ? "true" : "false"}。${referenceVisualProfile.reason}`
: "",
referenceVisualProfile?.likelyWhiteLayout
? "像素初筛已经命中白底/浅色说明页:如果画面是纯产品参数/说明/卖点排版且没有人物动作或真实使用展示,generationRoute 必须是 layout_preserve_replace 或 copy_locked_regenerate,promptPackPolicy 必须 forbid;如果画面包含人物、自拍、承重测试、使用产品、真实展示动作或明确生活场景,可以选择 people_relation_locked,并把 promptPackPolicy 设为 allow 或 soft。"
? "像素初筛已经命中白底/浅色说明页:只要画面存在标题、标签、参数、尺寸、卖点、图标、白底图片区、圆角图片区或明显留白排版,generationRoute 必须是 layout_preserve_replace 或 copy_locked_regenerate,promptPackPolicy 必须 forbid;即使包含人物/动作,也只能保留人物关系与白底版式,不允许套用厨房、客厅、灯光、软装等场景词包。"
: "",
!referenceVisualProfile?.likelyWhiteLayout && referenceVisualProfile?.likelyWhiteScene
? "像素初筛已经命中白/浅色低饱和场景:如果参考图主体是白底、米白、浅灰、浅木色、干净棚拍或轻生活场景,最终结果也必须保持白/浅色场景基调;不要把它改造成深色厨房、客厅、吊灯、黑墙、复杂柜体或强软装场景。"
: "",
"产品图和模特图会在后续生图阶段单独提供给生图模型使用,你这里不要尝试理解它们的具体外观,也不要要求必须使用所有输入图。",
params.modelImageUrl && !isProductOnlyDetailReplacement
@ -1602,6 +1699,9 @@ export async function analyzeReplacementExecutionPlan(params: {
isDetailReplacement
? "4. 判断 promptPackPolicy:纯白底参数/说明/卖点页、强图文绑定且没有真实展示动作的详情页必须 forbid;通用空间场景必须 allow;人物/自拍/聚会/承重测试/使用产品等动作展示页可以 people_relation_locked + allow/soft,含义是保留人物动作与文字层级,但允许前置词包重建背景、灯光、软装和产品场景。"
: "",
isDetailReplacement
? "4a. 白场景优先规则:如果参考图是白底/浅色说明页或白/浅色低饱和场景,promptPackPolicy 必须 forbid;人物动作可以保留,但不能使用前置词包把白场景改成厨房、客厅、黑墙、吊灯或复杂软装。"
: "",
isDetailReplacement
? "5. 判断 textPolicy:画面中存在营销标题、卖点、编号、参数、标签且与图匹配时必须 lock 或 preserve,避免后续生图改字、混色或重排。"
: "",
@ -1850,7 +1950,7 @@ export function buildAgentReplacementPrompt(params: {
params.scenePlan.whiteBackgroundLikely
? shouldApplyPromptPack
? "白底/说明图判断:是,但本屏包含人物动作/真实展示语义,允许在保留版式和文字层级的前提下按词包改造背景/产品场景。"
: "白底/说明图判断:是,禁止强行场景化。"
: "白底/白场景判断:是,禁止强行场景化;最终结果必须保持白底、浅色底或干净低饱和场景基调。"
: "",
params.scenePlan.copyImageCoupled
? "图文绑定判断:是,文案与画面语义必须保持匹配。"
@ -1964,14 +2064,15 @@ export function buildAgentReplacementPrompt(params: {
: params.scenePlan?.peoplePolicy === "preserve_relation"
? "本屏包含人物关系:必须保留人物数量、相对位置、互动关系、动作方向和出镜比例;如果参考图文字写了自拍/合照/聚会/用餐/拿手机等动作,必须让人物执行该动作,不要把多人图改成单人图、无人图或无关模特图。"
: "如果参考图没有明确人物展示需求,不要凭空新增人物主体。",
params.scenePlan?.whiteBackgroundLikely && !shouldApplyPromptPack
? "本屏疑似白底/说明图:禁止场景化、生活化或套空间词包,只允许保留白底/说明图语义并做必要的局部替换。"
params.scenePlan?.whiteBackgroundLikely && !shouldApplyPromptPack
? "本屏疑似白底/白场景/浅色说明图:禁止场景化、生活化或套空间词包,只允许保留白底、浅色底、干净留白、原图片框和原说明图语义并做必要的局部替换;最终不能变成厨房、客厅、黑墙、吊灯或复杂软装场景。"
: "",
params.referenceGeometry
? `当前参考图画布尺寸为 ${params.referenceGeometry.width}x${params.referenceGeometry.height},输出必须保持这个页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。`
: "输出必须保持当前参考图页面画布比例。替换背景/商品时只能等比缩放后裁切或延展背景,绝对禁止把商品、人物、文字、图标横向或纵向拉伸变形。",
"文字安全区硬约束:所有标题、正文、编号、标签、按钮、品牌字样和图标必须完整落在画布内,左右至少保留 6% 内边距,上下至少保留 4% 内边距;不得贴边、出血、半截显示、跑出画面或被裁切。若参考图原文字靠边,也要在保持层级和相对位置的前提下向内收。",
"文字排版硬约束:标题和标签必须保持参考图原来的相对坐标、行数、行距、字号比例和对齐方式;不要把标题从白底区移动到照片区,不要把横排标签移动到角落,不要把两行标题改成三行或压成一行。",
"白底/白场景硬约束:如果最后一张参考图的背景是白底、米白、浅灰、浅木色或大面积干净留白,最终结果也必须是同类白/浅色场景;只能在原照片框或主体区域内替换产品/材质/人物动作,不允许生成深色厨房、客厅、吊灯、黑墙、复杂柜体或强软装背景。",
"单页画布硬约束:只生成当前这一张详情页,不允许把两个页面、两个方案或两张场景图上下拼接,不允许出现明显横向分割线、重复画面、瀑布流长图或截图拼接感。",
"图片框硬约束:如果参考图下半部分是人物/产品照片框,人物、产品和新场景只能出现在这个照片框里;照片框之外的标题白底、标签、页边距和装饰必须保持干净,不要被场景纹理、墙面、灯具或人物侵入。",
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前版式,不要擅自改成完全不同的视角。",

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
@ -28,14 +29,27 @@ type StitchSourceSegment = {
};
const MAX_REFERENCE_COUNT = 120;
const FETCH_TIMEOUT_MS = 10000;
const FETCH_CONCURRENCY = 32;
const SLICE_UPLOAD_CONCURRENCY = 10;
const FETCH_TIMEOUT_MS = 20_000;
const FETCH_MAX_ATTEMPTS = 3;
const FETCH_RETRY_BASE_DELAY_MS = 350;
const FETCH_CONCURRENCY = 12;
const SLICE_UPLOAD_CONCURRENCY = 4;
const PREVIEW_MAX_WIDTH = 720;
const PREVIEW_MIN_WIDTH = 96;
const PREVIEW_MAX_HEIGHT = 32000;
const PREVIEW_MAX_PIXELS = 36_000_000;
const SEGMENT_MATCH_TOLERANCE = 2;
const logger = createLogger("detail-stitch");
class ImageFetchError extends Error {
retryable: boolean;
constructor(message: string, retryable = false) {
super(message);
this.name = "ImageFetchError";
this.retryable = retryable;
}
}
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(
@ -44,6 +58,38 @@ function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
) as ArrayBuffer;
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error || "");
}
function isTimeoutLikeError(error: unknown) {
if (!(error instanceof Error)) return false;
return (
error.name === "TimeoutError" ||
error.name === "AbortError" ||
/timeout|aborted|operation was aborted/i.test(error.message)
);
}
function isRetryableFetchError(error: unknown) {
if (error instanceof ImageFetchError) {
return error.retryable;
}
return isTimeoutLikeError(error);
}
function toPublicErrorMessage(error: unknown) {
const message = getErrorMessage(error);
if (isTimeoutLikeError(error) || /timeout|aborted|operation was aborted/i.test(message)) {
return "切割图片下载超时,通常是源图过大、源站响应慢或网络抖动导致。请稍后重试,或减少本次切割图片数量后再应用。";
}
return message || "拼合切割失败";
}
async function runBoundedParallel<T, R>(
items: T[],
workerCount: number,
@ -148,31 +194,54 @@ function buildReferenceFetchUrl(url: string): string {
}
async function fetchImageBuffer(url: string, requestOrigin: string, cookie: string): Promise<Buffer> {
const response = await fetch(url, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: buildFetchHeaders(url, requestOrigin, cookie),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
let lastError: unknown = null;
for (let attempt = 1; attempt <= FETCH_MAX_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(url, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: buildFetchHeaders(url, requestOrigin, cookie),
});
if (!response.ok) {
throw new ImageFetchError(
`图片下载失败:HTTP ${response.status}`,
response.status === 408 || response.status === 429 || response.status >= 500,
);
}
const contentType = response.headers.get("content-type") || "";
const normalizedContentType = contentType.toLowerCase();
if (
normalizedContentType &&
!normalizedContentType.startsWith("image/") &&
(normalizedContentType.startsWith("text/") ||
normalizedContentType.includes("json") ||
normalizedContentType.includes("xml"))
) {
throw new ImageFetchError("返回内容不是图片");
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new ImageFetchError("图片内容为空");
}
return buffer;
} catch (error) {
lastError = error;
if (attempt >= FETCH_MAX_ATTEMPTS || !isRetryableFetchError(error)) {
break;
}
logger.warn("image_fetch_retry", {
attempt,
maxAttempts: FETCH_MAX_ATTEMPTS,
timeoutMs: FETCH_TIMEOUT_MS,
reason: getErrorMessage(error),
});
await sleep(FETCH_RETRY_BASE_DELAY_MS * attempt);
}
}
const contentType = response.headers.get("content-type") || "";
const normalizedContentType = contentType.toLowerCase();
if (
normalizedContentType &&
!normalizedContentType.startsWith("image/") &&
(normalizedContentType.startsWith("text/") ||
normalizedContentType.includes("json") ||
normalizedContentType.includes("xml"))
) {
throw new Error("返回内容不是图片");
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new Error("图片内容为空");
}
return buffer;
throw new ImageFetchError(toPublicErrorMessage(lastError), false);
}
function resolvePreviewWidth(items: Array<{ width: number; height: number }>) {
@ -477,6 +546,8 @@ async function applyStitch(params: {
}
export async function POST(request: NextRequest) {
const startedAt = Date.now();
let mode = "unknown";
try {
const session = await auth();
if (!session?.user?.id) {
@ -484,7 +555,7 @@ export async function POST(request: NextRequest) {
}
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const mode = String(body.mode || "").trim();
mode = String(body.mode || "").trim();
const requestOrigin = getRequestOrigin(request);
const cookie = request.headers.get("cookie") || "";
@ -495,6 +566,12 @@ export async function POST(request: NextRequest) {
}
const data = await prepareStitch({ references, requestOrigin, cookie });
logger.info("prepare_completed", {
referenceCount: references.length,
sourceSegmentCount: data.sourceSegments.length,
failedCount: data.failed.length,
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ success: true, data });
}
@ -560,16 +637,25 @@ export async function POST(request: NextRequest) {
requestOrigin,
cookie,
});
logger.info("apply_completed", {
sourceSegmentCount: sourceSegments.length,
cutLineCount: normalizeCutLines(body.cutLines, height).length,
outputCount: data.cards.length,
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ success: true, data });
}
return NextResponse.json({ success: false, error: "未知操作" }, { status: 400 });
} catch (error) {
console.error("[DetailStitch API] Failed:", error);
logger.error("request_failed", error, {
mode,
durationMs: Date.now() - startedAt,
});
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "拼合切割失败",
error: toPublicErrorMessage(error),
},
{ status: 500 },
);

View File

@ -21,12 +21,14 @@ import {
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { createClientLogger } from "@/lib/observability/client-logger";
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
const MIN_SLICE_HEIGHT = 120;
const FAST_PREVIEW_WIDTH = 720;
const ESTIMATED_REFERENCE_HEIGHT = 960;
const SEGMENT_PRELOAD_CONCURRENCY = 4;
const logger = createClientLogger("agent-detail-reference-stitch");
type StitchSourceSegment = {
card: AgentDetailReplacementReferenceItem;
@ -96,6 +98,18 @@ function getReferenceDimension(value: unknown) {
return Number.isFinite(normalized) && normalized > 0 ? normalized : null;
}
function normalizeDetailStitchErrorMessage(error: unknown, fallback: string) {
const message =
error instanceof Error ? error.message : String(error || "").trim();
if (/timeout|aborted|operation was aborted/i.test(message)) {
return "切割图片下载超时,通常是源图过大、源站响应慢或网络抖动导致。请稍后重试,或减少本次切割图片数量后再应用。";
}
if (/failed to fetch|networkerror|network error/i.test(message)) {
return "网络连接异常,部分参考图暂时无法下载,请稍后重试。";
}
return message || fallback;
}
function resolveReferencePreviewHeight(card: AgentDetailReplacementReferenceItem) {
const width = getReferenceDimension(card.width);
const height = getReferenceDimension(card.height);
@ -389,9 +403,13 @@ export function AgentDetailReferenceStitchDialog({
);
} catch (error) {
if (cancelled) return;
console.error("[AgentDetailReferenceStitchDialog] Failed to prepare merged image:", error);
const message = normalizeDetailStitchErrorMessage(error, "拼合参考图失败");
logger.warn("prepare_failed", {
cardCount: setupCards.length,
message,
});
setWarningText(
`精准尺寸校准失败,已先使用快速预览:${error instanceof Error ? error.message : "拼合参考图失败"}`
`精准尺寸校准失败,已先使用快速预览:${message}`
);
} finally {
if (!cancelled) {
@ -692,8 +710,14 @@ export function AgentDetailReferenceStitchDialog({
onOpenChange(false);
toast.success(`已生成 ${orderedCards.length} 张切片参考图`);
} catch (error) {
console.error("[AgentDetailReferenceStitchDialog] Failed to apply slices:", error);
toast.error(error instanceof Error ? error.message : "切割参考图失败");
const message = normalizeDetailStitchErrorMessage(error, "切割参考图失败");
logger.error("apply_failed", {
cutLineCount: normalizedCutLines.length,
sliceCount: sliceSegments.length,
sourceSegmentCount: snapshot.sourceSegments.length,
message,
});
toast.error(message);
} finally {
setIsApplying(false);
setApplyProgressText("");