fix: unify detail processing placeholders

This commit is contained in:
134 2026-06-18 14:45:37 +08:00
parent 74ae88edbb
commit 07854d7c6a
2 changed files with 553 additions and 112 deletions

View File

@ -479,23 +479,24 @@ function buildScaleAdjustPrompt(
.join("\n");
return [
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
"输入图片2是定位参考图:它只用于告诉你用户框选的目标主体位置。输入图片2里的彩色框、编号、标签、半透明遮罩、文字和 UI 只是定位标记,绝不能出现在最终画面中。",
"输入图片1是定位参考图:它只用于告诉你用户框选的目标主体位置。输入图片1里的彩色框、编号、标签、半透明遮罩、文字和 UI 只是定位标记,绝不能出现在最终画面中。",
"输入图片2是原始干净图,也是最终输出必须继承的唯一画面基底。如果系统或模型只能读取一张参考图,则必须使用输入图片1中的框选位置定位目标,同时主动去除所有定位标记并恢复干净画面。",
"系统已根据用户框选区域计算出下方每个区域的原图坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片或蒙版边界。",
"最终结果必须保持输入图片1的原始画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度。",
"最终结果必须保持原始干净图的画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度。",
"请不要生成任何彩色框、矩形边框、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。",
"",
`任务:对用户框选的主体物体做“比例调整”,统一执行${actionLabel}。目标主体调整后的视觉尺寸必须约为原主体的 ${targetLabel},普通用户肉眼应能清楚看出主体尺寸已经变化;如果输出看起来和原图一样,就是失败。`,
regionLines,
"",
"关键要求:",
`1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放到约 ${targetLabel},不得保留原尺寸,不得压扁、拉伸、扭曲、改变外观结构、改变材质、改变颜色、改变文字或图案。`,
`1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放到约 ${targetLabel},不得保留原尺寸;请把主体轮廓宽度和高度都调整到约 ${targetLabel},而不是仅改变亮度、清晰度、颜色或画面裁切。`,
"2. 严禁把整个矩形框选区域当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。",
"3. 缩小时,目标主体应围绕原位置视觉中心自然变小;主体缩小后露出的区域必须根据周围真实背景、阴影、遮挡、纹理和透视进行无缝补全。",
"4. 放大时,目标主体应围绕原位置视觉中心自然变大;边缘与遮挡关系要合理,不能变形,也不能产生突兀裁切。",
`3. 缩小时,目标主体应围绕原位置视觉中心自然变小到约 ${targetLabel};主体缩小后,原来被遮挡的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。`,
`4. 放大时,目标主体应围绕原位置视觉中心自然变大到约 ${targetLabel};边缘与遮挡关系要合理,不能变形,也不能产生突兀裁切。`,
"5. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。",
"6. 未受比例调整影响的其它画面区域必须尽量保持原图不变,不允许整体重绘、改色、补光、移动物体或改变背景细节。",
"7. 最终输出必须是一张完整、干净、无缝的照片,不得出现任何选区框、说明文字、对比线、比例数字或 UI 元素。",
"8. 输出前自检:如果目标主体大小与原图相比没有明显变化,必须重新生成;禁止返回看起来和原图相同的结果。",
].join("\n");
}
@ -607,7 +608,7 @@ export async function POST(request: NextRequest) {
region.referenceImages.map((reference) => reference.url)
);
const referenceImages = isScaleAdjustOperation
? [sourceImageUrl, annotatedUpload.url, ...regionReferenceImageUrls]
? [annotatedUpload.url, sourceImageUrl, ...regionReferenceImageUrls]
: [sourceImageUrl, ...regionReferenceImageUrls];
const resolvedAspectRatio = resolveLocalEditAspectRatio(
body.aspectRatio,

View File

@ -10,6 +10,7 @@ import {
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
type ReactNode,
type SyntheticEvent,
} from "react";
@ -23,6 +24,7 @@ import {
ChevronDown,
ChevronUp,
Download,
GitCompareArrows,
ImagePlus,
Loader2,
Maximize2,
@ -733,6 +735,7 @@ type DirectDetailGenerationImage = {
title?: string;
subtitle?: string;
copy?: string;
compareSourceUrl?: string | null;
};
type DirectDetailReviewResult = {
@ -757,6 +760,21 @@ function isHttpImageUrl(url?: string) {
return /^https?:\/\//i.test(String(url || "").trim());
}
function normalizeResultCompareSourceUrl(
resultUrl?: string | null,
sourceUrl?: string | null
) {
const normalizedResultUrl = String(resultUrl || "").trim();
const normalizedSourceUrl = String(sourceUrl || "").trim();
if (!isHttpImageUrl(normalizedSourceUrl)) {
return null;
}
if (normalizedResultUrl && normalizedSourceUrl === normalizedResultUrl) {
return null;
}
return normalizedSourceUrl;
}
function getDetailLocalEditUploadFileName(file: File) {
const extension = file.name.toLowerCase().split(".").pop() || "jpg";
const baseName =
@ -809,8 +827,22 @@ function getDetailScaleAdjustLabel(scalePercent: DetailResultScalePercent) {
return scalePercent > 0 ? `放大 ${scalePercent}%` : `缩小 ${Math.abs(scalePercent)}%`;
}
function getDetailScaleAdjustTargetLabel(scalePercent: DetailResultScalePercent) {
return `${Math.round((1 + scalePercent / 100) * 100)}%`;
}
function getDetailScaleAdjustRegionPrompt(scalePercent: DetailResultScalePercent | null) {
return scalePercent ? `比例调整:${getDetailScaleAdjustLabel(scalePercent)}` : "比例调整";
if (!scalePercent) return "比例调整";
const actionLabel = getDetailScaleAdjustLabel(scalePercent);
const targetLabel = getDetailScaleAdjustTargetLabel(scalePercent);
const scaleVerb = scalePercent > 0 ? "放大" : "缩小";
return [
`对框选范围内的主物体执行等比${actionLabel},调整后主体视觉尺寸必须约为原来的 ${targetLabel},需要肉眼明显看出已经${scaleVerb}。`,
"只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
scalePercent < 0
? "主体缩小后露出的背景、桌面、墙面、阴影和遮挡关系必须自然补全。"
: "主体放大后边缘、遮挡和透视必须自然衔接。",
].join(" ");
}
function DetailResultLocalEditDialog({
@ -1182,6 +1214,7 @@ function DetailResultLocalEditDialog({
) : null}
{submitting ? (
<DetailResultProcessingScanOverlay
imageUrl={target.imageUrl}
label={isScaleAdjustMode ? "比例调整处理中" : "局部编辑处理中"}
/>
) : null}
@ -1705,6 +1738,7 @@ interface ReplacementResultRecord {
text: string;
status: Extract<ReplacementCardStatus, "completed" | "passthrough" | "fallback">;
updatedAt: number;
compareSourceUrl?: string | null;
}
interface ReplacementExecutionContext {
@ -1929,68 +1963,162 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
}
}
function DetailResultProcessingScanOverlay({ label }: { label: string }) {
const DETAIL_RESULT_PROCESSING_STAGES = [
"解码图像",
"定位选区",
"分离主体",
"重绘背景",
"融合边缘",
"检查细节",
"输出结果",
];
function DetailResultProcessingScanOverlay({
imageUrl,
label,
}: {
imageUrl?: string;
label: string;
}) {
const [progress, setProgress] = useState(6);
useEffect(() => {
const startedAt = Date.now();
const durationMs = 240_000;
const timer = window.setInterval(() => {
const elapsed = Date.now() - startedAt;
const linear = Math.min(1, elapsed / durationMs);
const eased = 1 - Math.pow(1 - linear, 1.8);
const drift = Math.sin(elapsed / 3200) * 1.2;
const next = Math.max(6, Math.min(96, Math.round(eased * 96 + drift)));
setProgress((current) => Math.max(current, next));
}, 720);
return () => window.clearInterval(timer);
}, [imageUrl, label]);
const stageIndex = Math.min(
DETAIL_RESULT_PROCESSING_STAGES.length - 1,
Math.floor((progress / 100) * DETAIL_RESULT_PROCESSING_STAGES.length)
);
const stageName = DETAIL_RESULT_PROCESSING_STAGES[stageIndex] || "处理中";
return (
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#030605]/48">
<motion.div
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-[#07090f]">
<style>{`
@keyframes detailResultScanBreatheBlur {
0%, 100% { filter: blur(22px) brightness(0.78) saturate(0.86); transform: scale(1.13); }
50% { filter: blur(26px) brightness(0.9) saturate(0.94); transform: scale(1.16); }
}
@keyframes detailResultScanSweep {
0% { top: -7%; opacity: 0; }
8% { opacity: 1; }
92% { opacity: 1; }
100% { top: 107%; opacity: 0; }
}
@keyframes detailResultScanPulseRing {
0% { box-shadow: 0 0 0 0 rgba(125, 211, 252, 0.48); }
70% { box-shadow: 0 0 0 8px rgba(125, 211, 252, 0); }
100% { box-shadow: 0 0 0 0 rgba(125, 211, 252, 0); }
}
`}</style>
{isHttpImageUrl(imageUrl) ? (
<img
alt=""
aria-hidden
className="absolute inset-0 h-full w-full object-cover"
src={imageUrl}
style={{ animation: "detailResultScanBreatheBlur 6s ease-in-out infinite" }}
/>
) : (
<div
aria-hidden
className="absolute inset-0"
style={{
background:
"radial-gradient(58% 46% at 50% 42%, rgba(125,211,252,0.16), transparent 62%), radial-gradient(58% 48% at 80% 105%, rgba(167,139,250,0.14), transparent 64%), #0d111c",
}}
/>
)}
<div
aria-hidden
className="absolute inset-0 opacity-[0.36]"
animate={{
backgroundPosition: ["0px 0px, 0px 0px", "0px 24px, 24px 0px"],
}}
transition={{ duration: 2.6, repeat: Infinity, ease: "linear" }}
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage:
"linear-gradient(rgba(255,255,255,0.13) 0px, rgba(255,255,255,0.13) 1px, transparent 1px, transparent 24px), linear-gradient(90deg, rgba(16,185,129,0.1) 0px, rgba(16,185,129,0.1) 1px, transparent 1px, transparent 24px)",
backgroundSize: "100% 24px, 24px 100%",
background:
"radial-gradient(ellipse at center, transparent 52%, rgba(0,0,0,0.48) 100%), repeating-linear-gradient(0deg, rgba(255,255,255,0.018) 0px, rgba(255,255,255,0.018) 1px, transparent 2px, transparent 4px)",
}}
/>
<div
aria-hidden
className="absolute inset-0 opacity-80"
style={{
background:
"radial-gradient(circle at 50% 50%, rgba(16,185,129,0.12), transparent 44%), linear-gradient(180deg, rgba(255,255,255,0.08), transparent 18%, transparent 82%, rgba(255,255,255,0.08))",
}}
/>
<motion.div
aria-hidden
className="absolute inset-x-[-6%] h-[34%] min-h-[150px]"
animate={{ top: ["-36%", "108%"], opacity: [0.3, 1, 0.46] }}
transition={{ duration: 3.8, repeat: Infinity, ease: [0.33, 0, 0.67, 1] }}
className="absolute inset-x-0 top-0 z-40 h-px pointer-events-none"
style={{ animation: "detailResultScanSweep 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite" }}
>
<div
className="absolute inset-0"
className="absolute inset-x-0 bottom-0 h-[110px]"
style={{
background:
"linear-gradient(180deg, transparent 0%, rgba(16,185,129,0.04) 18%, rgba(34,211,238,0.1) 38%, rgba(255,255,255,0.72) 50%, rgba(34,211,238,0.1) 62%, rgba(16,185,129,0.04) 82%, transparent 100%)",
"linear-gradient(180deg, transparent, rgba(125,211,252,0.1) 55%, rgba(167,139,250,0.16))",
filter: "blur(12px)",
}}
/>
<div className="absolute inset-x-[3%] top-1/2 h-[2px] -translate-y-1/2 bg-white shadow-[0_0_10px_rgba(255,255,255,0.86),0_0_18px_rgba(45,212,191,0.58)]" />
<div className="absolute inset-x-[4%] top-[calc(50%-18px)] h-px bg-emerald-100/55" />
<div className="absolute inset-x-[4%] top-[calc(50%+18px)] h-px bg-cyan-100/42" />
<div
className="absolute inset-x-[5%] top-[calc(50%-44px)] h-[88px] opacity-55"
className="absolute inset-x-0 top-0 h-[38px]"
style={{
background:
"repeating-linear-gradient(90deg, rgba(255,255,255,0.38) 0 1px, transparent 1px 12px)",
maskImage:
"linear-gradient(90deg, transparent 0%, black 16%, black 84%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(90deg, transparent 0%, black 16%, black 84%, transparent 100%)",
"linear-gradient(0deg, transparent, rgba(255,255,255,0.1))",
filter: "blur(6px)",
}}
/>
</motion.div>
<motion.div
aria-hidden
className="absolute inset-y-0 w-px bg-emerald-100/36"
animate={{ left: ["8%", "92%", "8%"], opacity: [0.08, 0.35, 0.08] }}
transition={{ duration: 5.4, repeat: Infinity, ease: "easeInOut" }}
/>
<div className="absolute left-3 top-3 inline-flex items-center gap-2 rounded-full border border-emerald-100/70 bg-zinc-950/88 px-3 py-1.5 text-[11px] font-semibold text-emerald-50 shadow-[0_14px_36px_rgba(0,0,0,0.5),0_0_18px_rgba(16,185,129,0.2)] backdrop-blur-md">
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-200" />
<span>{label}</span>
</div>
<div
aria-hidden
className="absolute inset-x-0 top-0 z-50 h-px pointer-events-none"
style={{
animation: "detailResultScanSweep 2.6s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite",
background:
"linear-gradient(90deg, transparent, rgba(167,139,250,0.7) 18%, rgba(255,255,255,0.98) 50%, rgba(125,211,252,0.7) 82%, transparent)",
boxShadow:
"0 0 6px rgba(255,255,255,0.7), 0 0 18px rgba(125,211,252,0.55), 0 0 34px rgba(167,139,250,0.35)",
}}
/>
<div className="absolute right-3 top-3 z-[60] inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-[#07090f]/60 px-2.5 py-1.5 text-[11px] font-medium text-slate-100 shadow-[0_12px_28px_rgba(0,0,0,0.28)] backdrop-blur-md">
<span
className="h-1.5 w-1.5 rounded-full bg-sky-300"
style={{ animation: "detailResultScanPulseRing 1.8s ease-out infinite" }}
/>
<span>{label}</span>
<span className="min-w-[34px] text-right tabular-nums text-slate-400">{progress}%</span>
</div>
<div className="absolute bottom-3 left-3 z-[60] inline-flex items-center gap-2 rounded-full border border-white/10 bg-[#07090f]/60 px-2.5 py-1.5 text-[11px] font-medium text-slate-400 shadow-[0_12px_28px_rgba(0,0,0,0.25)] backdrop-blur-md">
<span className="tabular-nums text-sky-300">{String(stageIndex + 1).padStart(2, "0")}</span>
<span>{stageName}</span>
</div>
<div className="absolute inset-x-0 bottom-0 z-[60] h-0.5 bg-white/5">
<div
className="h-full rounded-r-full shadow-[0_0_8px_rgba(125,211,252,0.6)] transition-[width] duration-300 ease-linear"
style={{
background: "linear-gradient(90deg, #7dd3fc, #a78bfa)",
width: `${progress}%`,
}}
/>
</div>
</div>
);
}
function DetailResultProcessingPlaceholderCard({
imageUrl,
label,
}: {
imageUrl?: string | null;
label: string;
}) {
return (
<div className="relative h-full w-full overflow-hidden rounded-[20px] bg-[#07090f]">
<DetailResultProcessingScanOverlay
imageUrl={String(imageUrl || "").trim() || undefined}
label={label}
/>
</div>
);
}
@ -2012,6 +2140,169 @@ function ResultProcessedHintBubble({ label }: { label: string }) {
);
}
function DetailResultCompareImage({
resultUrl,
compareSourceUrl,
alt,
imageClassName,
imageStyle,
previewWidth = 720,
previewQuality = 78,
onClick,
onError,
toggleClassName,
}: {
resultUrl: string;
compareSourceUrl?: string | null;
alt: string;
imageClassName?: string;
imageStyle?: CSSProperties;
previewWidth?: number;
previewQuality?: number;
onClick?: () => void;
onError?: (event: SyntheticEvent<HTMLImageElement, Event>) => void;
toggleClassName?: string;
}) {
const compareFrameRef = useRef<HTMLDivElement | null>(null);
const [isCompareMode, setIsCompareMode] = useState(false);
const [compareSplitPercent, setCompareSplitPercent] = useState(50);
const [isDraggingCompare, setIsDraggingCompare] = useState(false);
const sourceUrl = normalizeResultCompareSourceUrl(resultUrl, compareSourceUrl);
useEffect(() => {
setIsCompareMode(false);
setCompareSplitPercent(50);
setIsDraggingCompare(false);
}, [resultUrl, sourceUrl]);
const updateCompareSplit = useCallback((clientX: number) => {
const frame = compareFrameRef.current;
if (!frame) return;
const rect = frame.getBoundingClientRect();
if (rect.width <= 0) return;
const nextPercent = ((clientX - rect.left) / rect.width) * 100;
setCompareSplitPercent(Math.max(5, Math.min(95, nextPercent)));
}, []);
const handleComparePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!sourceUrl) return;
event.preventDefault();
event.stopPropagation();
updateCompareSplit(event.clientX);
setIsDraggingCompare(true);
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Pointer capture can fail if the browser has already cancelled the pointer.
}
},
[sourceUrl, updateCompareSplit]
);
const handleComparePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!isDraggingCompare || !sourceUrl) return;
event.preventDefault();
event.stopPropagation();
updateCompareSplit(event.clientX);
},
[isDraggingCompare, sourceUrl, updateCompareSplit]
);
const handleComparePointerEnd = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!isDraggingCompare) return;
event.preventDefault();
event.stopPropagation();
setIsDraggingCompare(false);
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Ignore release failures for cancelled pointer sequences.
}
},
[isDraggingCompare]
);
return (
<div
ref={compareFrameRef}
className={cn("relative", isCompareMode && sourceUrl ? "cursor-col-resize select-none" : null)}
onPointerDown={isCompareMode && sourceUrl ? handleComparePointerDown : undefined}
onPointerMove={isCompareMode && sourceUrl ? handleComparePointerMove : undefined}
onPointerUp={isCompareMode && sourceUrl ? handleComparePointerEnd : undefined}
onPointerCancel={isCompareMode && sourceUrl ? handleComparePointerEnd : undefined}
>
<AgentPreviewImage
src={resultUrl}
alt={alt}
className={imageClassName}
style={imageStyle}
previewWidth={previewWidth}
previewQuality={previewQuality}
onError={onError}
onClick={onClick}
/>
{isCompareMode && sourceUrl ? (
<>
<div
className="pointer-events-none absolute inset-0 z-10 overflow-hidden"
style={{ clipPath: `inset(0 0 0 ${compareSplitPercent}%)` }}
>
<AgentPreviewImage
src={sourceUrl}
alt={`${alt} 原图`}
className={cn(imageClassName, "absolute inset-0 h-full w-full object-contain")}
style={imageStyle}
previewWidth={previewWidth}
previewQuality={previewQuality}
eager
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 z-20 w-[2px] -translate-x-1/2 bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.32),0_0_22px_rgba(255,255,255,0.72)]"
style={{ left: `${compareSplitPercent}%` }}
/>
<div
className="pointer-events-none absolute top-1/2 z-20 flex h-9 w-9 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white/70 bg-zinc-950/82 text-white shadow-[0_12px_34px_rgba(0,0,0,0.48)] backdrop-blur-md"
style={{ left: `${compareSplitPercent}%` }}
>
<GitCompareArrows className="h-4 w-4" />
</div>
<div className="pointer-events-none absolute bottom-14 left-3 z-20 rounded-full border border-white/35 bg-zinc-950/78 px-2.5 py-1 text-[10px] font-semibold text-white shadow-[0_10px_28px_rgba(0,0,0,0.38)] backdrop-blur-md">
新图
</div>
<div className="pointer-events-none absolute bottom-14 right-3 z-20 rounded-full border border-white/35 bg-zinc-950/78 px-2.5 py-1 text-[10px] font-semibold text-white shadow-[0_10px_28px_rgba(0,0,0,0.38)] backdrop-blur-md">
原图
</div>
</>
) : null}
{sourceUrl ? (
<button
type="button"
title={isCompareMode ? "关闭划线对比" : "开启划线对比"}
aria-label={isCompareMode ? "关闭划线对比" : "开启划线对比"}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsCompareMode((current) => !current);
}}
className={cn(
IMAGE_OVERLAY_ACTION_CLASS,
"absolute right-14 top-3 z-40 flex h-9 w-9 items-center justify-center px-0 py-0",
isCompareMode && "border-emerald-200/80 bg-emerald-600/92 text-white",
toggleClassName
)}
>
<GitCompareArrows className="h-4 w-4" />
</button>
) : null}
</div>
);
}
function AnimatedGenerationPlaceholderCard(params: {
title: string;
description: string;
@ -2257,6 +2548,9 @@ export function AgentGenerationWorkspaceDarkV2({
const [processedResultHintByUrl, setProcessedResultHintByUrl] = useState<
Record<string, string>
>({});
const [resultCompareSourceByUrl, setResultCompareSourceByUrl] = useState<
Record<string, string>
>({});
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
() => createEmptyReplacementResultState()
);
@ -3565,20 +3859,36 @@ export function AgentGenerationWorkspaceDarkV2({
return;
}
const inferredStatus = inferReplacementCardStatusFromText(result.text);
const previousRecord = nextModeState[cardId];
const referenceCard =
currentResultModeType === "detail_replacement"
? detailReplacementReferenceCards.find((card) => card.id === cardId)
: skuReplacementReferenceCards.find((card) => card.id === cardId);
const resultUrl = result.url || null;
const previousUrl =
previousRecord?.url && previousRecord.url !== resultUrl
? previousRecord.url
: null;
const referenceSourceUrl =
referenceCard?.imageUrl || referenceCard?.previewUrl || null;
const nextRecord: ReplacementResultRecord = {
url: result.url || null,
url: resultUrl,
text: result.text || "",
status:
inferredStatus === "passthrough" || inferredStatus === "fallback"
? inferredStatus
: "completed",
updatedAt: Date.now(),
compareSourceUrl: normalizeResultCompareSourceUrl(
resultUrl,
previousUrl || referenceSourceUrl || previousRecord?.compareSourceUrl || null
),
};
const previousRecord = nextModeState[cardId];
if (
previousRecord?.url === nextRecord.url &&
previousRecord?.text === nextRecord.text &&
previousRecord?.status === nextRecord.status
previousRecord?.status === nextRecord.status &&
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl
) {
return;
}
@ -3595,7 +3905,13 @@ export function AgentGenerationWorkspaceDarkV2({
[currentResultModeType]: nextModeState,
};
});
}, [currentReplacementExecutionCardIds, currentResultModeType, latestReplacementResults]);
}, [
currentReplacementExecutionCardIds,
currentResultModeType,
detailReplacementReferenceCards,
latestReplacementResults,
skuReplacementReferenceCards,
]);
const detailReplacementResultUrls = useMemo(
() =>
detailReplacementReferenceCards
@ -3785,6 +4101,11 @@ export function AgentGenerationWorkspaceDarkV2({
: localPassthroughResultUrl
? MANUAL_PASSTHROUGH_RESULT_TEXT
: "";
const compareSourceUrl = normalizeResultCompareSourceUrl(
resultUrl,
resultMeta?.compareSourceUrl ||
(!localPassthroughResultUrl ? card.imageUrl || card.previewUrl || null : null)
);
const activeRunIndex =
pendingGeneration?.modeType === "detail_replacement" &&
Array.isArray(pendingGeneration.requestedCardIds)
@ -3795,6 +4116,7 @@ export function AgentGenerationWorkspaceDarkV2({
index,
resultUrl,
resultText,
compareSourceUrl,
queuePosition:
activeRunIndex >= 0 && pendingGeneration?.modeType === "detail_replacement"
? activeRunIndex + 1
@ -3847,6 +4169,11 @@ export function AgentGenerationWorkspaceDarkV2({
: localPassthroughResultUrl
? MANUAL_PASSTHROUGH_RESULT_TEXT
: "";
const compareSourceUrl = normalizeResultCompareSourceUrl(
resultUrl,
resultMeta?.compareSourceUrl ||
(!localPassthroughResultUrl ? card.imageUrl || card.previewUrl || null : null)
);
const activeRunIndex =
pendingGeneration?.modeType === "sku_replacement" &&
Array.isArray(pendingGeneration.requestedCardIds)
@ -3857,6 +4184,7 @@ export function AgentGenerationWorkspaceDarkV2({
index,
resultUrl,
resultText,
compareSourceUrl,
status: getReplacementCardStatus({
index,
resultUrl,
@ -4003,6 +4331,9 @@ export function AgentGenerationWorkspaceDarkV2({
id: url || `grid-result-placeholder-${activeResultModeType}-${index}`,
index,
url,
compareSourceUrl: url
? normalizeResultCompareSourceUrl(url, resultCompareSourceByUrl[url])
: null,
status: url
? ("completed" as const)
: activeResultModeType === "detail_generation" &&
@ -4023,6 +4354,7 @@ export function AgentGenerationWorkspaceDarkV2({
detailGenerationProgress.status,
gridPendingRemainingCount,
gridResultCardCount,
resultCompareSourceByUrl,
]
);
const shouldShowGridResultSection =
@ -4626,11 +4958,16 @@ export function AgentGenerationWorkspaceDarkV2({
url: string | null;
text?: string | null;
status?: Extract<ReplacementCardStatus, "completed" | "passthrough" | "fallback">;
compareSourceUrl?: string | null;
}) => {
setReplacementResultState((current) => {
const currentModeState = current[params.modeType];
const previousRecord = currentModeState[params.cardId];
const inferredStatus = inferReplacementCardStatusFromText(params.text);
const explicitCompareSourceUrl =
params.compareSourceUrl === undefined
? undefined
: normalizeResultCompareSourceUrl(params.url, params.compareSourceUrl);
const nextRecord: ReplacementResultRecord = {
url: params.url,
text: String(params.text || "").trim(),
@ -4642,12 +4979,17 @@ export function AgentGenerationWorkspaceDarkV2({
previousRecord?.status ||
"completed",
updatedAt: Date.now(),
compareSourceUrl:
explicitCompareSourceUrl === undefined
? previousRecord?.compareSourceUrl || null
: explicitCompareSourceUrl,
};
if (
previousRecord?.url === nextRecord.url &&
previousRecord?.text === nextRecord.text &&
previousRecord?.status === nextRecord.status
previousRecord?.status === nextRecord.status &&
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl
) {
return current;
}
@ -4728,6 +5070,7 @@ export function AgentGenerationWorkspaceDarkV2({
url: nextUrl,
text: "已完成去水印处理。",
status: "completed",
compareSourceUrl: rawUrl,
});
setActiveResultModeType("detail_replacement");
setSelectedResultUrl(nextUrl);
@ -5080,6 +5423,7 @@ export function AgentGenerationWorkspaceDarkV2({
url: result.originalUrl,
text: "已在当前页面完成文字编辑。",
status: "completed",
compareSourceUrl: replacementTextEditTarget.imageUrl,
});
setReplacementTextEditTarget((current) =>
current
@ -5155,6 +5499,35 @@ export function AgentGenerationWorkspaceDarkV2({
}
return uploadedUrl;
}, []);
const registerResultCompareSource = useCallback(
(resultUrl?: string | null, sourceUrl?: string | null) => {
const normalizedResultUrl = String(resultUrl || "").trim();
if (!isHttpImageUrl(normalizedResultUrl)) {
return;
}
const normalizedSourceUrl = normalizeResultCompareSourceUrl(
normalizedResultUrl,
sourceUrl
);
setResultCompareSourceByUrl((current) => {
if (!normalizedSourceUrl) {
if (!(normalizedResultUrl in current)) return current;
const next = { ...current };
delete next[normalizedResultUrl];
return next;
}
if (current[normalizedResultUrl] === normalizedSourceUrl) {
return current;
}
return {
...current,
[normalizedResultUrl]: normalizedSourceUrl,
};
});
},
[]
);
const handleSceneResultResizeComplete = useCallback(
async (resizedImageUrl: string) => {
if (!onResultPreviewUrlsChange) {
@ -5187,15 +5560,26 @@ export function AgentGenerationWorkspaceDarkV2({
);
onResultPreviewUrlsChange("scene_generation", nextSceneUrls);
registerResultCompareSource(finalUrl, sceneResizeEditorTargetUrl);
setActiveResultModeType("scene_generation");
setSelectedResultUrl(finalUrl);
setSceneResizeEditorTargetUrl(null);
toast.success("尺寸调整结果已加入场景结果区");
},
[mergedResultCollections.scene_generation, onResultPreviewUrlsChange, uploadBase64ResultToStorage]
[
mergedResultCollections.scene_generation,
onResultPreviewUrlsChange,
registerResultCompareSource,
sceneResizeEditorTargetUrl,
uploadBase64ResultToStorage,
]
);
const appendResultUrlToCollection = useCallback(
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
(
modeType: AgentGenerationResultModeType,
imageUrl: string,
options?: { compareSourceUrl?: string | null }
) => {
const normalizedUrl = String(imageUrl || "").trim();
if (!normalizedUrl || !onResultPreviewUrlsChange) {
return false;
@ -5208,12 +5592,20 @@ export function AgentGenerationWorkspaceDarkV2({
])
);
onResultPreviewUrlsChange(modeType, nextUrls);
if (options && "compareSourceUrl" in options) {
registerResultCompareSource(normalizedUrl, options.compareSourceUrl);
}
if (activeResultModeType === modeType) {
setSelectedResultUrl(normalizedUrl);
}
return true;
},
[activeResultModeType, mergedResultCollections, onResultPreviewUrlsChange]
[
activeResultModeType,
mergedResultCollections,
onResultPreviewUrlsChange,
registerResultCompareSource,
]
);
const showProcessedResultHint = useCallback((imageUrl: string, label: string) => {
const normalizedUrl = String(imageUrl || "").trim();
@ -5275,12 +5667,17 @@ export function AgentGenerationWorkspaceDarkV2({
url: nextUrl,
text: "已完成高清处理。",
status: "completed",
compareSourceUrl: imageUrl,
});
setActiveResultModeType("detail_replacement");
setActiveDetailReferenceCardId(cardId);
setSelectedResultUrl(nextUrl);
} else {
if (!appendResultUrlToCollection("detail_generation", nextUrl)) {
if (
!appendResultUrlToCollection("detail_generation", nextUrl, {
compareSourceUrl: imageUrl,
})
) {
throw new Error("当前项目未接入结果保存能力");
}
setActiveResultModeType("detail_generation");
@ -5383,12 +5780,17 @@ export function AgentGenerationWorkspaceDarkV2({
url: nextUrl,
text: isScaleAdjust ? "已完成比例调整处理。" : "已完成局部编辑处理。",
status: "completed",
compareSourceUrl: imageUrl,
});
setActiveResultModeType("detail_replacement");
setActiveDetailReferenceCardId(cardId);
setSelectedResultUrl(nextUrl);
} else {
if (!appendResultUrlToCollection("detail_generation", nextUrl)) {
if (
!appendResultUrlToCollection("detail_generation", nextUrl, {
compareSourceUrl: imageUrl,
})
) {
throw new Error("当前项目未接入结果保存能力");
}
setActiveResultModeType("detail_generation");
@ -5440,15 +5842,27 @@ export function AgentGenerationWorkspaceDarkV2({
);
const handlePsdDerivedImageCreated = useCallback(
(imageUrl: string) => {
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl);
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl, {
compareSourceUrl: psdTemplateDialogSourceUrl,
});
},
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
[
appendResultUrlToCollection,
psdTemplateDialogResultModeType,
psdTemplateDialogSourceUrl,
]
);
const handlePsdRenderedResultCreated = useCallback(
(imageUrl: string) => {
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl);
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl, {
compareSourceUrl: psdTemplateDialogSourceUrl,
});
},
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
[
appendResultUrlToCollection,
psdTemplateDialogResultModeType,
psdTemplateDialogSourceUrl,
]
);
const handleDeleteResultImage = useCallback(
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
@ -5477,6 +5891,12 @@ export function AgentGenerationWorkspaceDarkV2({
delete next[normalizedUrl];
return next;
});
setResultCompareSourceByUrl((current) => {
if (!(normalizedUrl in current)) return current;
const next = { ...current };
delete next[normalizedUrl];
return next;
});
onDeleteResultImage(modeType, normalizedUrl);
toast.success("结果图已删除");
@ -8384,10 +8804,11 @@ export function AgentGenerationWorkspaceDarkV2({
>
{hasResult ? (
<div className="relative min-h-[220px] overflow-hidden bg-white/95">
<AgentPreviewImage
src={card.resultUrl!}
<DetailResultCompareImage
resultUrl={card.resultUrl!}
compareSourceUrl={card.compareSourceUrl}
alt={`结果图 ${card.index + 1}`}
className="block h-auto w-full cursor-zoom-in"
imageClassName="block h-auto w-full cursor-zoom-in"
previewWidth={720}
previewQuality={78}
onError={(event) =>
@ -8556,7 +8977,10 @@ export function AgentGenerationWorkspaceDarkV2({
</button>
</div>
{processingLabel ? (
<DetailResultProcessingScanOverlay label={processingLabel} />
<DetailResultProcessingScanOverlay
imageUrl={card.resultUrl || card.imageUrl || card.previewUrl || undefined}
label={processingLabel}
/>
) : null}
</div>
) : (
@ -8564,18 +8988,14 @@ export function AgentGenerationWorkspaceDarkV2({
className="aspect-[5/3]"
onClick={() => setActiveDetailReferenceCardId(card.id)}
>
<AnimatedGenerationPlaceholderCard
badge={statusMeta.label}
badgeClassName={statusMeta.className}
animated={card.status === "generating"}
showSpinner={card.status === "generating"}
title={`结果图 ${card.index + 1}`}
description={
card.status === "queued" &&
card.queuePosition &&
card.queueTotal
? `批次内排队 ${card.queuePosition}/${card.queueTotal},将按“输入图组 + 第 ${card.index + 1} 张${detailReferenceSourceLabel}参考图”继续生成`
: `将按“输入图组 + 第 ${card.index + 1} 张${detailReferenceSourceLabel}参考图”生成`
<DetailResultProcessingPlaceholderCard
imageUrl={card.imageUrl || card.previewUrl}
label={
card.status === "generating"
? "详情图生成中"
: card.status === "queued"
? "详情图排队中"
: statusMeta.label
}
/>
</div>
@ -9086,13 +9506,15 @@ export function AgentGenerationWorkspaceDarkV2({
className="aspect-[5/3]"
onClick={() => setActiveSkuReferenceCardId(card.id)}
>
<AnimatedGenerationPlaceholderCard
badge={statusMeta.label}
badgeClassName={statusMeta.className}
animated={card.status === "generating"}
showSpinner={card.status === "generating"}
title={`结果图 ${card.index + 1}`}
description={`将按“输入图 + 第 ${card.index + 1} 张${skuReferenceSourceLabel}参考图”生成`}
<DetailResultProcessingPlaceholderCard
imageUrl={card.imageUrl || card.previewUrl}
label={
card.status === "generating"
? "SKU图生成中"
: card.status === "queued"
? "SKU图排队中"
: statusMeta.label
}
/>
</div>
)}
@ -9194,10 +9616,11 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="relative aspect-[4/5] overflow-hidden bg-[linear-gradient(180deg,#090b0c_0%,#111417_100%)]">
{card.url ? (
<>
<AgentPreviewImage
src={card.url}
<DetailResultCompareImage
resultUrl={card.url!}
compareSourceUrl={card.compareSourceUrl}
alt={`${getResultModeLabel(activeResultModeType)} 结果图 ${card.index + 1}`}
className="h-full w-full object-contain p-2"
imageClassName="h-full w-full object-contain p-2"
previewWidth={720}
previewQuality={78}
/>
@ -9376,30 +9799,47 @@ export function AgentGenerationWorkspaceDarkV2({
</>
) : null}
{processingLabel ? (
<DetailResultProcessingScanOverlay label={processingLabel} />
<DetailResultProcessingScanOverlay
imageUrl={card.url!}
label={processingLabel}
/>
) : null}
</>
) : (
<AnimatedGenerationPlaceholderCard
badge={card.status === "generating" ? "生成中" : card.status === "queued" ? "排队中" : "待生成"}
badgeClassName={
card.status === "generating"
? "border-sky-400/30 bg-sky-500/[0.10] text-sky-100"
: card.status === "queued"
? "border-white/12 bg-white/[0.05] text-white/62"
: "border-white/10 bg-white/[0.04] text-white/58"
}
animated={card.status === "generating"}
showSpinner={card.status === "generating"}
title={`${getResultModeLabel(activeResultModeType)} 结果图 ${card.index + 1}`}
description={
card.status === "generating"
? "当前图片正在生成中,结果返回后会自动替换这张占位卡。"
: card.status === "queued"
? "前面的结果完成后会继续处理这一张,请保持当前页面即可。"
: "开始生成后,这里会出现对应数量的结果预览卡片。"
}
/>
isDetailGenerationCard ? (
<DetailResultProcessingPlaceholderCard
label={
card.status === "generating"
? "详情图生成中"
: card.status === "queued"
? "详情图排队中"
: card.status === "missing"
? "详情图未回传"
: "等待详情生成"
}
/>
) : (
<AnimatedGenerationPlaceholderCard
badge={card.status === "generating" ? "生成中" : card.status === "queued" ? "排队中" : "待生成"}
badgeClassName={
card.status === "generating"
? "border-sky-400/30 bg-sky-500/[0.10] text-sky-100"
: card.status === "queued"
? "border-white/12 bg-white/[0.05] text-white/62"
: "border-white/10 bg-white/[0.04] text-white/58"
}
animated={card.status === "generating"}
showSpinner={card.status === "generating"}
title={`${getResultModeLabel(activeResultModeType)} 结果图 ${card.index + 1}`}
description={
card.status === "generating"
? "当前图片正在生成中,结果返回后会自动替换这张占位卡。"
: card.status === "queued"
? "前面的结果完成后会继续处理这一张,请保持当前页面即可。"
: "开始生成后,这里会出现对应数量的结果预览卡片。"
}
/>
)
)}
</div>
</div>