fix: improve detail stitch and model recovery
This commit is contained in:
parent
359b964033
commit
769862cee9
@ -20,12 +20,13 @@ type StitchSourceSegment = {
|
||||
card: StitchReferenceInput;
|
||||
top: number;
|
||||
height: number;
|
||||
sourceWidth?: number;
|
||||
sourceHeight?: number;
|
||||
};
|
||||
|
||||
const MAX_REFERENCE_COUNT = 120;
|
||||
const FETCH_TIMEOUT_MS = 10000;
|
||||
const FETCH_CONCURRENCY = 32;
|
||||
const TRANSFORM_CONCURRENCY = 10;
|
||||
const SLICE_UPLOAD_CONCURRENCY = 10;
|
||||
const PREVIEW_MAX_WIDTH = 720;
|
||||
const PREVIEW_MIN_WIDTH = 96;
|
||||
@ -250,73 +251,26 @@ async function prepareStitch(params: {
|
||||
const sourceSegments: StitchSourceSegment[] = [];
|
||||
const defaultCutLines: number[] = [];
|
||||
|
||||
const resizedItems = await runBoundedParallel(loaded, TRANSFORM_CONCURRENCY, async (item, loadedIndex) => {
|
||||
loaded.forEach((item, index) => {
|
||||
const height = Math.max(1, Math.round((previewWidth / Math.max(item.width, 1)) * item.height));
|
||||
const resized = await sharp(item.buffer, { failOn: "none" })
|
||||
.rotate()
|
||||
.resize(previewWidth, height, {
|
||||
fit: "fill",
|
||||
withoutEnlargement: false,
|
||||
})
|
||||
.jpeg({
|
||||
quality: 76,
|
||||
mozjpeg: true,
|
||||
progressive: true,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
input: resized,
|
||||
top: 0,
|
||||
left: 0,
|
||||
height,
|
||||
loadedIndex,
|
||||
card: item.card,
|
||||
};
|
||||
});
|
||||
|
||||
const compositeItems = resizedItems.map((item, index) => {
|
||||
const currentTop = top;
|
||||
top += item.height;
|
||||
if (index < resizedItems.length - 1) {
|
||||
top += height;
|
||||
if (index < loaded.length - 1) {
|
||||
defaultCutLines.push(top);
|
||||
}
|
||||
sourceSegments.push({
|
||||
card: item.card,
|
||||
top: currentTop,
|
||||
height: item.height,
|
||||
height,
|
||||
sourceWidth: item.width,
|
||||
sourceHeight: item.height,
|
||||
});
|
||||
return {
|
||||
input: item.input,
|
||||
top: currentTop,
|
||||
left: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const previewHeight = Math.max(1, top);
|
||||
const previewBuffer = await sharp({
|
||||
create: {
|
||||
width: previewWidth,
|
||||
height: previewHeight,
|
||||
channels: 3,
|
||||
background: "#ffffff",
|
||||
},
|
||||
})
|
||||
.composite(compositeItems)
|
||||
.jpeg({
|
||||
quality: 76,
|
||||
mozjpeg: true,
|
||||
progressive: true,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
const upload = await uploadFile(
|
||||
`detail-stitch-preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jpg`,
|
||||
bufferToArrayBuffer(previewBuffer),
|
||||
);
|
||||
|
||||
return {
|
||||
previewUrl: upload.url,
|
||||
previewUrl: "",
|
||||
width: previewWidth,
|
||||
height: previewHeight,
|
||||
defaultCutLines,
|
||||
@ -349,10 +303,36 @@ async function applyStitch(params: {
|
||||
}));
|
||||
const passthroughSet = new Set(params.passthroughSegmentIndexes);
|
||||
const segmentMatches = segments.map((segment) => findMatchingSourceSegment(segment, params.sourceSegments));
|
||||
const needsCropping = segmentMatches.some((match) => !match);
|
||||
const previewBuffer = needsCropping
|
||||
? await fetchImageBuffer(params.previewUrl, params.requestOrigin, params.cookie)
|
||||
: null;
|
||||
const preparedSourceCache = new Map<string, Promise<Buffer>>();
|
||||
|
||||
const getPreparedSourceBuffer = (source: StitchSourceSegment) => {
|
||||
const sourceUrl = source.card.imageUrl;
|
||||
const key = `${sourceUrl}|${params.width}|${source.height}`;
|
||||
const cached = preparedSourceCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const prepared = (async () => {
|
||||
const buffer = await fetchImageBuffer(
|
||||
buildReferenceFetchUrl(sourceUrl),
|
||||
params.requestOrigin,
|
||||
params.cookie,
|
||||
);
|
||||
return sharp(buffer, { failOn: "none" })
|
||||
.rotate()
|
||||
.resize(params.width, source.height, {
|
||||
fit: "fill",
|
||||
withoutEnlargement: false,
|
||||
})
|
||||
.jpeg({
|
||||
quality: 88,
|
||||
mozjpeg: true,
|
||||
progressive: true,
|
||||
})
|
||||
.toBuffer();
|
||||
})();
|
||||
preparedSourceCache.set(key, prepared);
|
||||
return prepared;
|
||||
};
|
||||
|
||||
const cards = await runBoundedParallel(segments, SLICE_UPLOAD_CONCURRENCY, async (segment) => {
|
||||
const matchedSource = segmentMatches[segment.index] || null;
|
||||
@ -367,21 +347,65 @@ async function applyStitch(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const safeTop = Math.max(0, Math.min(params.height - 1, Math.round(segment.top)));
|
||||
const safeHeight = Math.max(
|
||||
const segmentTop = Math.max(0, Math.round(segment.top));
|
||||
const segmentHeight = Math.max(
|
||||
1,
|
||||
Math.min(params.height - safeTop, Math.round(segment.height)),
|
||||
Math.min(params.height - segmentTop, Math.round(segment.height)),
|
||||
);
|
||||
if (!previewBuffer) {
|
||||
throw new Error("拼合预览图不可用");
|
||||
const segmentBottom = segmentTop + segmentHeight;
|
||||
const overlappingSources = params.sourceSegments.filter((source) => {
|
||||
const sourceTop = Math.round(source.top);
|
||||
const sourceBottom = sourceTop + Math.round(source.height);
|
||||
return sourceTop < segmentBottom && sourceBottom > segmentTop;
|
||||
});
|
||||
|
||||
if (overlappingSources.length === 0) {
|
||||
throw new Error("当前切片没有匹配到原始参考图");
|
||||
}
|
||||
const sliceBuffer = await sharp(previewBuffer, { failOn: "none" })
|
||||
.extract({
|
||||
left: 0,
|
||||
top: safeTop,
|
||||
|
||||
const compositeItems = await Promise.all(
|
||||
overlappingSources.map(async (source) => {
|
||||
const sourceTop = Math.round(source.top);
|
||||
const sourceHeight = Math.max(1, Math.round(source.height));
|
||||
const overlapTop = Math.max(segmentTop, sourceTop);
|
||||
const overlapBottom = Math.min(segmentBottom, sourceTop + sourceHeight);
|
||||
const cropTop = Math.max(0, Math.min(sourceHeight - 1, overlapTop - sourceTop));
|
||||
const cropHeight = Math.max(
|
||||
1,
|
||||
Math.min(sourceHeight - cropTop, Math.round(overlapBottom - overlapTop)),
|
||||
);
|
||||
const sourceBuffer = await getPreparedSourceBuffer(source);
|
||||
const input = await sharp(sourceBuffer, { failOn: "none" })
|
||||
.extract({
|
||||
left: 0,
|
||||
top: cropTop,
|
||||
width: params.width,
|
||||
height: cropHeight,
|
||||
})
|
||||
.jpeg({
|
||||
quality: 88,
|
||||
mozjpeg: true,
|
||||
progressive: true,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
input,
|
||||
top: Math.max(0, Math.round(overlapTop - segmentTop)),
|
||||
left: 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const sliceBuffer = await sharp({
|
||||
create: {
|
||||
width: params.width,
|
||||
height: safeHeight,
|
||||
})
|
||||
height: segmentHeight,
|
||||
channels: 3,
|
||||
background: "#ffffff",
|
||||
},
|
||||
})
|
||||
.composite(compositeItems)
|
||||
.jpeg({
|
||||
quality: 88,
|
||||
mozjpeg: true,
|
||||
@ -445,12 +469,16 @@ export async function POST(request: NextRequest) {
|
||||
const imageUrl = resolveHttpUrl(rawCard.imageUrl, requestOrigin);
|
||||
const top = Math.round(Number(raw.top));
|
||||
const segmentHeight = Math.round(Number(raw.height));
|
||||
const sourceWidth = Math.round(Number(raw.sourceWidth));
|
||||
const sourceHeight = Math.round(Number(raw.sourceHeight));
|
||||
if (!imageUrl || !Number.isFinite(top) || !Number.isFinite(segmentHeight)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
top,
|
||||
height: Math.max(1, segmentHeight),
|
||||
sourceWidth: Number.isFinite(sourceWidth) && sourceWidth > 0 ? sourceWidth : undefined,
|
||||
sourceHeight: Number.isFinite(sourceHeight) && sourceHeight > 0 ? sourceHeight : undefined,
|
||||
card: {
|
||||
id: String(rawCard.id || "").trim() || `source-${top}`,
|
||||
label: String(rawCard.label || "").trim() || "参考图",
|
||||
@ -466,7 +494,7 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
.filter((item): item is StitchSourceSegment => Boolean(item));
|
||||
|
||||
if (!previewUrl || !width || !height || sourceSegments.length === 0) {
|
||||
if (!width || !height || sourceSegments.length === 0) {
|
||||
return NextResponse.json({ success: false, error: "缺少切割参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@ -192,16 +192,19 @@ export async function GET(request: NextRequest) {
|
||||
const quality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : DEFAULT_QUALITY;
|
||||
const directQuality = requestedQuality ? clamp(requestedQuality, MIN_QUALITY, MAX_QUALITY) : null;
|
||||
const requestedFormat = searchParams.get("f");
|
||||
const forceProxy = searchParams.get("proxy") === "1";
|
||||
|
||||
const directImageTarget = resolveDirectImageTarget(
|
||||
request,
|
||||
target,
|
||||
width,
|
||||
directQuality,
|
||||
requestedFormat
|
||||
);
|
||||
if (directImageTarget) {
|
||||
return redirectToDirectImage(directImageTarget);
|
||||
if (!forceProxy) {
|
||||
const directImageTarget = resolveDirectImageTarget(
|
||||
request,
|
||||
target,
|
||||
width,
|
||||
directQuality,
|
||||
requestedFormat
|
||||
);
|
||||
if (directImageTarget) {
|
||||
return redirectToDirectImage(directImageTarget);
|
||||
}
|
||||
}
|
||||
|
||||
const cacheKey = `${target.toString()}|${width || "auto"}|${quality}|${requestedFormat || "auto"}`;
|
||||
|
||||
@ -32,10 +32,6 @@ import {
|
||||
type SceneFusionImageGenConfig,
|
||||
} from "@/lib/scene-fusion/output-capabilities";
|
||||
import { acquireCanvasImageModelSlot } from "@/lib/canvas/image-concurrency";
|
||||
import {
|
||||
recordAiModelHealthFailure,
|
||||
recordAiModelHealthSuccess,
|
||||
} from "@/lib/ai/runtime/model-health";
|
||||
|
||||
// 请求体 Schema
|
||||
const sceneFusionRequestSchema = z.object({
|
||||
@ -55,7 +51,7 @@ const sceneFusionRequestSchema = z.object({
|
||||
type SceneFusionMode = "scene-fission" | "multi-product-scene" | "random-bg";
|
||||
|
||||
// 默认提示词(当数据库未配置时使用)- 使用"参考"的表述方式
|
||||
const FALLBACK_PROMPT = "请参考第一张图片中的产品,将其自然地融合到第二张图片的场景中。要求:1)保持产品的整体外观、颜色、形状和设计风格与第一张图一致;2)调整产品的大小和角度以适应场景;3)注意光影和透视关系使融合自然。";
|
||||
const FALLBACK_PROMPT = "请参考第一张图片中的产品,将其自然地融合到第二张图片的场景中。要求:1)必须保持产品的核心结构、轮廓比例、部件数量、材质颜色和设计细节与第一张图一致;2)允许为了匹配参考场景调整产品角度、朝向、大小和透视,不要把角度变化当成结构改变;3)注意光影和透视关系使融合自然。";
|
||||
|
||||
const SCENE_FUSION_MODEL_COOLDOWN_MS = 2 * 60 * 1000;
|
||||
const SCENE_FUSION_MAX_ITEMS = 10;
|
||||
@ -68,9 +64,9 @@ const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000;
|
||||
const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8;
|
||||
const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000;
|
||||
const SCENE_FUSION_STRICT_PROMPT_TAIL =
|
||||
"硬性要求:严禁直接返回第一张参考图(产品图)原图,必须保留第二张图的场景与构图,仅替换目标主体;结果中不得同时完整保留原目标主体与新产品;若识别不确定,请优先保持第二张图背景不变并完成主体替换。";
|
||||
"硬性要求:严禁直接返回第一张参考图(产品图)原图,必须保留第二张图的场景与构图,仅替换目标主体;结果中不得同时完整保留原目标主体与新产品;产品可因场景透视调整角度、朝向和大小,但核心结构、轮廓比例、部件关系、材质颜色不得被改造;若识别不确定,请优先保持第二张图背景不变并完成主体替换。";
|
||||
const SCENE_FUSION_RANDOM_BG_PROMPT_TAIL =
|
||||
"硬性要求:背景必须与原图明显不同(颜色、结构、元素至少两项显著变化),禁止保留原背景纹理或直接回传原图;仅保留产品主体并保证主体边缘完整自然。";
|
||||
"硬性要求:背景必须与原图明显不同(颜色、结构、元素至少两项显著变化),禁止保留原背景纹理或直接回传原图;仅保留产品主体并保证主体结构、轮廓比例、部件关系和边缘完整自然,允许为新背景调整朝向、角度和透视。";
|
||||
let sceneFusionRoundRobinCursor = 0;
|
||||
type SceneFusionModelCooldown = Map<string, number>;
|
||||
|
||||
@ -533,12 +529,14 @@ async function reviewSceneFusionResult(params: {
|
||||
locationHints ? `定位提示:${locationHints}` : "",
|
||||
"请严格判断图3是否同时满足以下条件:",
|
||||
`1. 图3里已经出现了与图1同主体的${productLabel}`,
|
||||
`2. 图2原本需要被替换的${targetLabel}没有被完整保留下来`,
|
||||
"3. 图2的大部分场景构图、机位和背景仍被保留,只做主体替换",
|
||||
"4. 图3不是图1产品原图、裁切图或仅做轻微背景变化的回传图",
|
||||
`2. 图3中的${productLabel}核心结构、轮廓比例、部件数量、材质颜色和主要设计细节与图1一致`,
|
||||
`3. 图2原本需要被替换的${targetLabel}没有被完整保留下来`,
|
||||
"4. 图2的大部分场景构图、机位和背景仍被保留,只做主体替换",
|
||||
"5. 图3不是图1产品原图、裁切图或仅做轻微背景变化的回传图",
|
||||
"注意:为了匹配图2的机位和参考图角度,产品朝向、旋转、透视、大小变化是允许的;不要仅因为角度不同判失败。",
|
||||
"若任一条件不满足,就判定为 failed。",
|
||||
"仅返回 JSON:",
|
||||
'{"passed":true,"score":0.0,"productMatched":true,"targetRemoved":true,"scenePreserved":true,"suspectedEcho":false,"reason":"一句话说明","confidence":0.0}',
|
||||
'{"passed":true,"score":0.0,"productMatched":true,"productStructurePreserved":true,"angleAcceptable":true,"targetRemoved":true,"scenePreserved":true,"suspectedEcho":false,"reason":"一句话说明","confidence":0.0}',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
@ -562,6 +560,10 @@ async function reviewSceneFusionResult(params: {
|
||||
const score = Number.isFinite(scoreRaw) ? Math.max(0, Math.min(1, scoreRaw)) : 0.5;
|
||||
const passedFlag = normalizeSceneFusionBoolean(parsed.passed);
|
||||
const productMatched = normalizeSceneFusionBoolean(parsed.productMatched);
|
||||
const productStructurePreserved = normalizeSceneFusionBoolean(
|
||||
parsed.productStructurePreserved ?? parsed.productMatched
|
||||
);
|
||||
const angleAcceptable = normalizeSceneFusionBoolean(parsed.angleAcceptable);
|
||||
const targetRemoved = normalizeSceneFusionBoolean(parsed.targetRemoved);
|
||||
const scenePreserved = normalizeSceneFusionBoolean(parsed.scenePreserved);
|
||||
const suspectedEcho = normalizeSceneFusionBoolean(parsed.suspectedEcho);
|
||||
@ -569,6 +571,8 @@ async function reviewSceneFusionResult(params: {
|
||||
normalizeSceneFusionHint(parsed.reason, 80) ||
|
||||
[
|
||||
productMatched === false ? "未识别出参考产品主体" : "",
|
||||
productStructurePreserved === false ? "产品核心结构被改变" : "",
|
||||
angleAcceptable === false ? "产品角度透视不适合场景" : "",
|
||||
targetRemoved === false ? "原目标主体仍明显保留" : "",
|
||||
scenePreserved === false ? "原场景结构改动过大" : "",
|
||||
suspectedEcho === true ? "结果疑似回传产品图" : "",
|
||||
@ -580,10 +584,12 @@ async function reviewSceneFusionResult(params: {
|
||||
const passed =
|
||||
passedFlag === true &&
|
||||
productMatched !== false &&
|
||||
productStructurePreserved !== false &&
|
||||
angleAcceptable !== false &&
|
||||
targetRemoved !== false &&
|
||||
scenePreserved !== false &&
|
||||
suspectedEcho !== true &&
|
||||
score >= 0.55;
|
||||
score >= 0.5;
|
||||
|
||||
console.log(`[SceneFusion][${params.requestId}] Review result:`, {
|
||||
sceneIndex: params.sceneIndex + 1,
|
||||
@ -591,6 +597,8 @@ async function reviewSceneFusionResult(params: {
|
||||
passed,
|
||||
score,
|
||||
reason,
|
||||
productStructurePreserved,
|
||||
angleAcceptable,
|
||||
reviewModel: model,
|
||||
});
|
||||
|
||||
@ -1385,15 +1393,15 @@ function buildPrompt(
|
||||
|
||||
if (sceneMode === "random-bg") {
|
||||
const subject = productName || "产品";
|
||||
prompt = `请以第一张图片中的${subject}为唯一主体,对第二张图片进行背景重绘。要求:1)${subject}外观、材质、颜色与细节保持一致;2)允许轻微调整主体位置与角度以匹配新背景;3)背景需要完整重建并与原图明显不同;4)光影透视自然,输出商业摄影质感。`;
|
||||
prompt = `请以第一张图片中的${subject}为唯一主体,对第二张图片进行背景重绘。要求:1)必须保持${subject}核心结构、轮廓比例、部件数量、材质颜色和主要设计细节与第一张图一致,不要改造成另一款产品;2)允许调整主体位置、大小、角度、朝向和透视以匹配新背景,不要把角度变化当成结构改变;3)背景需要完整重建并与原图明显不同;4)光影透视自然,输出商业摄影质感。`;
|
||||
} else {
|
||||
const subject = productName || "产品";
|
||||
const targetSubject = targetName || "场景主主体";
|
||||
const locationHints = [positionHint, replaceCue].filter(Boolean).join(",");
|
||||
if (targetName) {
|
||||
prompt = `请参考第一张图片中的${subject},仅替换第二张图片中的${targetSubject},不要改成全新场景。要求:1)保持${subject}的整体外观、材质、颜色与结构细节和第一张图一致;2)先移除第二张图中原有的${targetSubject},再将${subject}放入其原位置;3)调整${subject}的大小、角度、光影和透视,使其与第二张图自然融合;4)结果中不得同时完整保留原${targetSubject}和新的${subject}。`;
|
||||
prompt = `请参考第一张图片中的${subject},仅替换第二张图片中的${targetSubject},不要改成全新场景。要求:1)必须保持${subject}核心结构、轮廓比例、部件数量、材质颜色和主要设计细节与第一张图一致,不要增删关键部件或改造成另一款产品;2)先移除第二张图中原有的${targetSubject},再将${subject}放入其原位置;3)允许调整${subject}的大小、角度、朝向、光影和透视,使其与第二张图自然融合,角度变化本身是允许的;4)结果中不得同时完整保留原${targetSubject}和新的${subject}。`;
|
||||
} else {
|
||||
prompt = `请参考第一张图片中的${subject},将其自然地融合到第二张图片的场景主主体位置。要求:1)保持${subject}的整体外观、颜色、形状和设计风格与第一张图一致;2)优先替换第二张图中最主要的同类主体,不要只做风格微调;3)注意光影和透视关系使融合自然。`;
|
||||
prompt = `请参考第一张图片中的${subject},将其自然地融合到第二张图片的场景主主体位置。要求:1)必须保持${subject}核心结构、轮廓比例、部件数量、材质颜色和主要设计细节与第一张图一致,不要改造成另一款产品;2)优先替换第二张图中最主要的同类主体,不要只做风格微调;3)允许为了匹配场景调整${subject}角度、朝向、大小和透视,角度变化不代表结构变化;4)注意光影和透视关系使融合自然。`;
|
||||
}
|
||||
if (locationHints) {
|
||||
prompt = `${prompt} 待替换目标定位:${locationHints}。`;
|
||||
@ -1870,7 +1878,6 @@ async function processSceneWithDiversity(
|
||||
|
||||
for (let attemptIndex = 0; attemptIndex < attemptCandidates.length; attemptIndex += 1) {
|
||||
const candidate = attemptCandidates[attemptIndex]!;
|
||||
const attemptStartedAt = Date.now();
|
||||
try {
|
||||
const normalizedOutputParams = normalizeSceneFusionOutputParamsForCandidate(candidate, {
|
||||
aspectRatio: params.aspectRatio,
|
||||
@ -1954,18 +1961,6 @@ async function processSceneWithDiversity(
|
||||
}
|
||||
|
||||
markSceneFusionCandidateHealthy(candidate, params.modelCooldown);
|
||||
await recordAiModelHealthSuccess({
|
||||
model: {
|
||||
id: candidate.model.id,
|
||||
healthStatus: "healthy",
|
||||
},
|
||||
}).catch((healthError) => {
|
||||
console.warn("[SceneFusion] Record model health success failed:", {
|
||||
requestId: params.requestId,
|
||||
model: candidate.model.modelId,
|
||||
error: healthError instanceof Error ? healthError.message : String(healthError),
|
||||
});
|
||||
});
|
||||
return {
|
||||
result,
|
||||
candidate,
|
||||
@ -1975,34 +1970,8 @@ async function processSceneWithDiversity(
|
||||
const errorMessage = error instanceof Error ? error.message : "未知错误";
|
||||
failureReasons.push(`${candidate.model.displayName}: ${errorMessage}`);
|
||||
markSceneFusionCandidateFailed(candidate, params.modelCooldown);
|
||||
await recordAiModelHealthFailure({
|
||||
model: {
|
||||
id: candidate.model.id,
|
||||
modelId: candidate.model.modelId,
|
||||
displayName: candidate.model.displayName,
|
||||
excludeFromFailover: Boolean(candidate.model.excludeFromFailover),
|
||||
},
|
||||
provider: {
|
||||
id: candidate.provider.id,
|
||||
name: candidate.provider.name,
|
||||
type: candidate.provider.type,
|
||||
},
|
||||
error,
|
||||
context: "scene-fusion",
|
||||
elapsedMs: Date.now() - attemptStartedAt,
|
||||
requestId: params.requestId,
|
||||
metadata: {
|
||||
sceneIndex: params.sceneIndex,
|
||||
attempt: attemptIndex + 1,
|
||||
sceneMode: params.sceneMode,
|
||||
},
|
||||
}).catch((healthError) => {
|
||||
console.warn("[SceneFusion] Record model health failure failed:", {
|
||||
requestId: params.requestId,
|
||||
model: candidate.model.modelId,
|
||||
error: healthError instanceof Error ? healthError.message : String(healthError),
|
||||
});
|
||||
});
|
||||
// Batch processing can fail because of per-image inputs, review gates, or echo checks.
|
||||
// Keep these failures local to this request so they do not auto-disable shared models.
|
||||
console.error("[SceneFusion] Candidate failed, try next:", {
|
||||
requestId: params.requestId,
|
||||
sceneIndex: params.sceneIndex,
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type SyntheticEvent,
|
||||
} from "react";
|
||||
import { Loader2, Plus, RotateCcw, Scissors, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@ -19,6 +20,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
|
||||
|
||||
const MIN_SLICE_HEIGHT = 120;
|
||||
@ -27,6 +29,8 @@ type StitchSourceSegment = {
|
||||
card: AgentDetailReplacementReferenceItem;
|
||||
top: number;
|
||||
height: number;
|
||||
sourceWidth?: number;
|
||||
sourceHeight?: number;
|
||||
};
|
||||
|
||||
type MergedSnapshot = {
|
||||
@ -62,6 +66,17 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function buildStitchPreviewImageUrl(url: string, width: number) {
|
||||
const normalizedUrl = String(url || "").trim();
|
||||
if (!normalizedUrl) return "";
|
||||
return buildImageProxyUrl(normalizedUrl, {
|
||||
width: Math.min(Math.max(Math.round(width || 720), 96), 1200),
|
||||
quality: 84,
|
||||
format: "jpeg",
|
||||
delivery: "proxy",
|
||||
});
|
||||
}
|
||||
|
||||
interface AgentDetailReferenceStitchDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@ -89,6 +104,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
const [loadingProgressText, setLoadingProgressText] = useState("");
|
||||
const [errorText, setErrorText] = useState<string | null>(null);
|
||||
const [warningText, setWarningText] = useState<string | null>(null);
|
||||
const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false);
|
||||
const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState<number[]>([]);
|
||||
|
||||
const validCards = useMemo(
|
||||
@ -135,6 +151,14 @@ export function AgentDetailReferenceStitchDialog({
|
||||
() => new Set(passthroughSegmentIndexes),
|
||||
[passthroughSegmentIndexes]
|
||||
);
|
||||
const mergedPreviewDisplayUrl = useMemo(
|
||||
() =>
|
||||
snapshot
|
||||
? buildStitchPreviewImageUrl(snapshot.previewUrl, snapshot.width)
|
||||
: "",
|
||||
[snapshot]
|
||||
);
|
||||
const useVirtualPreview = previewImageLoadFailed || !mergedPreviewDisplayUrl;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@ -143,6 +167,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
setPreviewCutLineY(null);
|
||||
setLoadingProgressText("");
|
||||
setWarningText(null);
|
||||
setPreviewImageLoadFailed(false);
|
||||
setPassthroughSegmentIndexes([]);
|
||||
return;
|
||||
}
|
||||
@ -161,7 +186,8 @@ export function AgentDetailReferenceStitchDialog({
|
||||
setIsLoading(true);
|
||||
setErrorText(null);
|
||||
setWarningText(null);
|
||||
setLoadingProgressText(`正在服务端并行拼合 ${setupCards.length} 张参考图`);
|
||||
setPreviewImageLoadFailed(false);
|
||||
setLoadingProgressText(`正在读取 ${setupCards.length} 张参考图尺寸`);
|
||||
|
||||
const response = await fetch("/api/agent-workflow/detail-stitch", {
|
||||
method: "POST",
|
||||
@ -199,7 +225,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
.filter((value) => Number.isFinite(value) && value > 0 && value < height)
|
||||
: [];
|
||||
|
||||
if (!previewUrl || !width || !height || sourceSegments.length === 0) {
|
||||
if (!width || !height || sourceSegments.length === 0) {
|
||||
throw new Error("拼合结果无效,请稍后重试");
|
||||
}
|
||||
|
||||
@ -210,6 +236,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
defaultCutLines,
|
||||
sourceSegments,
|
||||
});
|
||||
setPreviewImageLoadFailed(!previewUrl);
|
||||
setCutLines(defaultCutLines);
|
||||
setPassthroughSegmentIndexes([]);
|
||||
setWarningText(
|
||||
@ -390,6 +417,35 @@ export function AgentDetailReferenceStitchDialog({
|
||||
[]
|
||||
);
|
||||
|
||||
const handleMergedPreviewImageError = useCallback(
|
||||
(event: SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
const image = event.currentTarget;
|
||||
const fallbackUrl = String(snapshot?.previewUrl || "").trim();
|
||||
if (fallbackUrl && image.dataset.originalFallbackApplied !== "1") {
|
||||
image.dataset.originalFallbackApplied = "1";
|
||||
image.src = fallbackUrl;
|
||||
return;
|
||||
}
|
||||
setPreviewImageLoadFailed(true);
|
||||
setWarningText((current) => current || "拼合预览图加载失败,已切换为分段预览。");
|
||||
},
|
||||
[snapshot?.previewUrl]
|
||||
);
|
||||
|
||||
const handleSegmentPreviewImageError = useCallback(
|
||||
(originalUrl: string) => (event: SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
const image = event.currentTarget;
|
||||
const fallbackUrl = String(originalUrl || "").trim();
|
||||
if (fallbackUrl && image.dataset.originalFallbackApplied !== "1") {
|
||||
image.dataset.originalFallbackApplied = "1";
|
||||
image.src = fallbackUrl;
|
||||
return;
|
||||
}
|
||||
image.style.opacity = "0";
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleApply = useCallback(async () => {
|
||||
if (!snapshot) {
|
||||
toast.error("当前还没有可用的拼合图");
|
||||
@ -546,7 +602,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
<div className="text-sm text-white/75">
|
||||
{loadingProgressText || "正在拼合参考图..."}
|
||||
</div>
|
||||
<div className="text-xs text-white/42">会按当前参考图顺序从上到下拼成一张长图</div>
|
||||
<div className="text-xs text-white/42">会按当前参考图顺序生成虚拟长图,不再上传超长拼合图</div>
|
||||
</div>
|
||||
) : errorText ? (
|
||||
<div className="flex min-h-[420px] flex-col items-center justify-center rounded-[24px] border border-dashed border-red-400/20 bg-red-500/[0.05] px-6 text-center">
|
||||
@ -570,15 +626,49 @@ export function AgentDetailReferenceStitchDialog({
|
||||
onPointerLeave={handlePreviewPointerLeave}
|
||||
onClick={handlePreviewClick}
|
||||
>
|
||||
<img
|
||||
src={snapshot.previewUrl}
|
||||
alt="拼合长图预览"
|
||||
className="block h-auto w-full select-none"
|
||||
width={snapshot.width}
|
||||
height={snapshot.height}
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
/>
|
||||
{useVirtualPreview ? (
|
||||
<div className="w-full bg-white">
|
||||
{snapshot.sourceSegments.map((segment, index) => {
|
||||
const segmentImageUrl = String(segment.card.imageUrl || "").trim();
|
||||
return (
|
||||
<div
|
||||
key={`${segment.card.id || "segment"}-${index}-${segment.top}`}
|
||||
className="relative overflow-hidden bg-white"
|
||||
style={{
|
||||
aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-neutral-400">
|
||||
参考图 {index + 1} 加载失败
|
||||
</div>
|
||||
{segmentImageUrl ? (
|
||||
<img
|
||||
src={buildStitchPreviewImageUrl(segmentImageUrl, snapshot.width)}
|
||||
alt={`拼合分段预览 ${index + 1}`}
|
||||
className="relative z-10 block h-full w-full select-none object-fill"
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading={index > 2 ? "lazy" : "eager"}
|
||||
onError={handleSegmentPreviewImageError(segmentImageUrl)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
key={mergedPreviewDisplayUrl || snapshot.previewUrl}
|
||||
src={mergedPreviewDisplayUrl || snapshot.previewUrl}
|
||||
alt="拼合长图预览"
|
||||
className="block h-auto w-full select-none"
|
||||
width={snapshot.width}
|
||||
height={snapshot.height}
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
onError={handleMergedPreviewImageError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
{isAddingCutLine && previewCutLineY !== null ? (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, desc, eq, inArray, lte, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
aiModel,
|
||||
@ -188,10 +188,25 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string })
|
||||
const now = new Date();
|
||||
const cooldownCutoff = getAutoDisableCooldownCutoff(now);
|
||||
const conditions = [
|
||||
eq(aiModel.healthStatus, "auto_disabled"),
|
||||
eq(aiModel.isEnabled, false),
|
||||
eq(aiModel.excludeFromFailover, false),
|
||||
lte(aiModel.healthAutoDisabledAt, cooldownCutoff),
|
||||
sql`COALESCE(${aiModel.healthAutoDisabledAt}, ${aiModel.healthLastFailureAt}, ${aiModel.healthUpdatedAt}) <= ${cooldownCutoff}`,
|
||||
sql`(
|
||||
${aiModel.healthStatus} = 'auto_disabled'
|
||||
OR (
|
||||
${aiModel.healthStatus} = 'degraded'
|
||||
AND ${aiModel.healthLastFailureAt} IS NOT NULL
|
||||
AND ${aiModel.healthConsecutiveFailures} > 0
|
||||
AND ${aiModel.healthLastEventType} IN (
|
||||
'timeout',
|
||||
'quota',
|
||||
'unavailable',
|
||||
'invalid_model',
|
||||
'download_error',
|
||||
'error'
|
||||
)
|
||||
)
|
||||
)`,
|
||||
];
|
||||
|
||||
if (params?.modelId) {
|
||||
@ -203,6 +218,9 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string })
|
||||
id: aiModel.id,
|
||||
modelId: aiModel.modelId,
|
||||
displayName: aiModel.displayName,
|
||||
healthStatus: aiModel.healthStatus,
|
||||
healthAutoDisabledAt: aiModel.healthAutoDisabledAt,
|
||||
healthLastFailureAt: aiModel.healthLastFailureAt,
|
||||
})
|
||||
.from(aiModel)
|
||||
.where(and(...conditions));
|
||||
@ -237,6 +255,7 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string })
|
||||
id: item.id,
|
||||
modelId: item.modelId,
|
||||
displayName: item.displayName,
|
||||
previousStatus: item.healthStatus,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@ -4204,6 +4204,17 @@ export async function updateAiModel({
|
||||
updateData.healthRecoverySuccessStreak = 0;
|
||||
updateData.healthObservationStartedAt = null;
|
||||
updateData.healthUpdatedAt = new Date();
|
||||
} else {
|
||||
updateData.healthStatus = "healthy";
|
||||
updateData.healthConsecutiveFailures = 0;
|
||||
updateData.healthLastEventType = null;
|
||||
updateData.healthLastError = null;
|
||||
updateData.healthLastFailureAt = null;
|
||||
updateData.healthAutoDisabledAt = null;
|
||||
updateData.healthAutoDisabledReason = null;
|
||||
updateData.healthRecoverySuccessStreak = 0;
|
||||
updateData.healthObservationStartedAt = null;
|
||||
updateData.healthUpdatedAt = new Date();
|
||||
}
|
||||
}
|
||||
if (isHidden !== undefined) updateData.isHidden = isHidden;
|
||||
|
||||
@ -108,6 +108,7 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("url", source);
|
||||
if (options.delivery === "proxy") params.set("proxy", "1");
|
||||
if (width) params.set("w", String(width));
|
||||
if (quality) params.set("q", String(quality));
|
||||
if (format) params.set("f", format);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user