fix: harden detail replacement layout rules
This commit is contained in:
parent
1eddc7e296
commit
a154a28a4a
@ -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% 内边距;不得贴边、出血、半截显示、跑出画面或被裁切。若参考图原文字靠边,也要在保持层级和相对位置的前提下向内收。",
|
||||
"文字排版硬约束:标题和标签必须保持参考图原来的相对坐标、行数、行距、字号比例和对齐方式;不要把标题从白底区移动到照片区,不要把横排标签移动到角落,不要把两行标题改成三行或压成一行。",
|
||||
"白底/白场景硬约束:如果最后一张参考图的背景是白底、米白、浅灰、浅木色或大面积干净留白,最终结果也必须是同类白/浅色场景;只能在原照片框或主体区域内替换产品/材质/人物动作,不允许生成深色厨房、客厅、吊灯、黑墙、复杂柜体或强软装背景。",
|
||||
"单页画布硬约束:只生成当前这一张详情页,不允许把两个页面、两个方案或两张场景图上下拼接,不允许出现明显横向分割线、重复画面、瀑布流长图或截图拼接感。",
|
||||
"图片框硬约束:如果参考图下半部分是人物/产品照片框,人物、产品和新场景只能出现在这个照片框里;照片框之外的标题白底、标签、页边距和装饰必须保持干净,不要被场景纹理、墙面、灯具或人物侵入。",
|
||||
"镜头角度、透视关系、视平线、主体朝向和主体占比必须尽量跟随最后一张参考图,方便无缝替换当前版式,不要擅自改成完全不同的视角。",
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
|
||||
@ -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("");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user