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