"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type SyntheticEvent, } from "react"; import { Loader2, Plus, RotateCcw, Scissors, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { createClientLogger } from "@/lib/observability/client-logger"; import type { AgentDetailReplacementReferenceItem } from "@/types/agent"; const MIN_SLICE_HEIGHT = 120; const FAST_PREVIEW_WIDTH = 720; const ESTIMATED_REFERENCE_HEIGHT = 960; const SEGMENT_PRELOAD_CONCURRENCY = 4; const logger = createClientLogger("agent-detail-reference-stitch"); type StitchSourceSegment = { card: AgentDetailReplacementReferenceItem; top: number; height: number; sourceWidth?: number; sourceHeight?: number; }; type MergedSnapshot = { width: number; height: number; previewUrl: string; defaultCutLines: number[]; sourceSegments: StitchSourceSegment[]; }; type DetailStitchPrepareResponse = { success?: boolean; data?: { previewUrl?: string; width?: number; height?: number; defaultCutLines?: number[]; sourceSegments?: StitchSourceSegment[]; failed?: Array<{ id?: string; label?: string; error?: string }>; }; error?: string; }; type DetailStitchApplyResponse = { success?: boolean; data?: { cards?: AgentDetailReplacementReferenceItem[]; }; error?: string; }; 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", }); } function buildStitchSegmentDisplayImageUrl(url: string, width: number) { const normalizedUrl = String(url || "").trim(); if (!normalizedUrl) return ""; return buildImageProxyUrl(normalizedUrl, { width: Math.min(Math.max(Math.round(width || FAST_PREVIEW_WIDTH), 96), 960), quality: 78, format: "jpeg", delivery: "display", }); } function getReferenceDimension(value: unknown) { const normalized = Math.round(Number(value)); return Number.isFinite(normalized) && normalized > 0 ? normalized : null; } function normalizeDetailStitchErrorMessage(error: unknown, fallback: string) { const message = error instanceof Error ? error.message : String(error || "").trim(); if (/timeout|aborted|operation was aborted/i.test(message)) { return "切割图片下载超时,通常是源图过大、源站响应慢或网络抖动导致。请稍后重试,或减少本次切割图片数量后再应用。"; } if (/failed to fetch|networkerror|network error/i.test(message)) { return "网络连接异常,部分参考图暂时无法下载,请稍后重试。"; } return message || fallback; } function resolveReferencePreviewHeight(card: AgentDetailReplacementReferenceItem) { const width = getReferenceDimension(card.width); const height = getReferenceDimension(card.height); if (width && height) { return Math.max(MIN_SLICE_HEIGHT, Math.round((FAST_PREVIEW_WIDTH / width) * height)); } return ESTIMATED_REFERENCE_HEIGHT; } function buildOptimisticSnapshot( cards: Array ): MergedSnapshot { let top = 0; const defaultCutLines: number[] = []; const sourceSegments = cards.map((card, index) => { const height = resolveReferencePreviewHeight(card); const currentTop = top; top += height; if (index < cards.length - 1) { defaultCutLines.push(top); } return { card, top: currentTop, height, sourceWidth: getReferenceDimension(card.width) || undefined, sourceHeight: getReferenceDimension(card.height) || undefined, }; }); return { previewUrl: "", width: FAST_PREVIEW_WIDTH, height: Math.max(1, top), defaultCutLines, sourceSegments, }; } function areCutLinesEqual(left: number[], right: number[]) { if (left.length !== right.length) return false; return left.every((value, index) => Math.round(value) === Math.round(right[index] || 0)); } function scaleCutLines(lines: number[], fromHeight: number, toHeight: number) { const ratio = toHeight / Math.max(1, fromHeight); return Array.from( new Set( lines .map((line) => Math.round(line * ratio)) .filter((line) => Number.isFinite(line) && line > 0 && line < toHeight) ) ).sort((left, right) => left - right); } function preloadBrowserImage(url: string) { return new Promise((resolve) => { if (typeof window === "undefined" || !url) { resolve(); return; } const image = new Image(); image.decoding = "async"; image.onload = () => resolve(); image.onerror = () => resolve(); image.src = url; }); } interface AgentDetailReferenceStitchDialogProps { open: boolean; onOpenChange: (open: boolean) => void; cards: AgentDetailReplacementReferenceItem[]; sourceLabel?: string; onPrepared?: (nextCards: AgentDetailReplacementReferenceItem[]) => void; onApply: (nextCards: AgentDetailReplacementReferenceItem[]) => void; } export function AgentDetailReferenceStitchDialog({ open, onOpenChange, cards, sourceLabel = "详情参考图", onPrepared, onApply, }: AgentDetailReferenceStitchDialogProps) { const previewContainerRef = useRef(null); const [snapshot, setSnapshot] = useState(null); const [cutLines, setCutLines] = useState([]); const [draggingLineIndex, setDraggingLineIndex] = useState(null); const [isAddingCutLine, setIsAddingCutLine] = useState(false); const [previewCutLineY, setPreviewCutLineY] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isPreparing, setIsPreparing] = useState(false); const [isApplying, setIsApplying] = useState(false); const [applyProgressText, setApplyProgressText] = useState(""); const [loadingProgressText, setLoadingProgressText] = useState(""); const [errorText, setErrorText] = useState(null); const [warningText, setWarningText] = useState(null); const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false); const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState([]); const hasManualCutLineChangesRef = useRef(false); const onPreparedRef = useRef(onPrepared); useEffect(() => { onPreparedRef.current = onPrepared; }, [onPrepared]); const validCards = useMemo( () => cards.filter( (card): card is AgentDetailReplacementReferenceItem & { imageUrl: string } => Boolean(String(card.imageUrl || "").trim()) ), [cards] ); const validCardLoadKey = useMemo( () => validCards .map( (card, index) => `${card.id}::${String(card.imageUrl || "").trim()}::${card.sortOrder ?? index}` ) .join("|"), [validCards] ); const setupCardsRef = useRef(validCards); const setupCardsKeyRef = useRef(validCardLoadKey); if (setupCardsKeyRef.current !== validCardLoadKey) { setupCardsKeyRef.current = validCardLoadKey; setupCardsRef.current = validCards; } const setupCards = setupCardsRef.current; const normalizedCutLines = useMemo( () => [...cutLines] .filter((value) => Number.isFinite(value)) .sort((left, right) => left - right), [cutLines] ); const sliceSegments = useMemo(() => { if (!snapshot) return []; const points = [0, ...normalizedCutLines, snapshot.height]; return Array.from({ length: Math.max(points.length - 1, 0) }, (_, index) => ({ index, top: points[index], height: Math.max(points[index + 1] - points[index], 1), })); }, [normalizedCutLines, snapshot]); const passthroughSegmentIndexSet = useMemo( () => new Set(passthroughSegmentIndexes), [passthroughSegmentIndexes] ); const mergedPreviewDisplayUrl = useMemo( () => snapshot ? buildStitchPreviewImageUrl(snapshot.previewUrl, snapshot.width) : "", [snapshot] ); const useVirtualPreview = previewImageLoadFailed || !mergedPreviewDisplayUrl; useEffect(() => { if (!open) { setDraggingLineIndex(null); setIsAddingCutLine(false); setPreviewCutLineY(null); setLoadingProgressText(""); setIsLoading(false); setIsPreparing(false); setWarningText(null); setPreviewImageLoadFailed(false); setPassthroughSegmentIndexes([]); hasManualCutLineChangesRef.current = false; return; } if (setupCards.length === 0) { setSnapshot(null); setCutLines([]); hasManualCutLineChangesRef.current = false; setIsLoading(false); setIsPreparing(false); setErrorText(`当前没有可拼合的${sourceLabel}`); return; } let cancelled = false; const optimisticSnapshot = buildOptimisticSnapshot(setupCards); setSnapshot(optimisticSnapshot); setCutLines(optimisticSnapshot.defaultCutLines); hasManualCutLineChangesRef.current = false; setPassthroughSegmentIndexes([]); setErrorText(null); setWarningText(null); setPreviewImageLoadFailed(true); setIsLoading(false); setIsPreparing(true); setLoadingProgressText(`正在后台校准 ${setupCards.length} 张参考图尺寸`); const setup = async () => { try { const response = await fetch("/api/agent-workflow/detail-stitch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "prepare", references: setupCards.map((card, index) => ({ ...card, sortOrder: card.sortOrder ?? index, })), }), }); const result = (await response.json().catch(() => ({}))) as DetailStitchPrepareResponse; if (cancelled) return; if (!response.ok || !result.success || !result.data) { throw new Error(result.error || "拼合参考图失败"); } const previewUrl = String(result.data.previewUrl || "").trim(); const width = Math.round(Number(result.data.width)); const height = Math.round(Number(result.data.height)); const sourceSegments = Array.isArray(result.data.sourceSegments) ? result.data.sourceSegments.filter( (segment): segment is StitchSourceSegment => Boolean(segment?.card?.imageUrl) && Number.isFinite(Number(segment.top)) && Number.isFinite(Number(segment.height)) ) : []; const defaultCutLines = Array.isArray(result.data.defaultCutLines) ? result.data.defaultCutLines .map((value) => Math.round(Number(value))) .filter((value) => Number.isFinite(value) && value > 0 && value < height) : []; if (!width || !height || sourceSegments.length === 0) { throw new Error("拼合结果无效,请稍后重试"); } const preciseSnapshot = { width, height, previewUrl, defaultCutLines, sourceSegments, }; setSnapshot(preciseSnapshot); setPreviewImageLoadFailed(!previewUrl); setCutLines((current) => { if (hasManualCutLineChangesRef.current) { return scaleCutLines(current, optimisticSnapshot.height, height); } return areCutLinesEqual(current, optimisticSnapshot.defaultCutLines) ? defaultCutLines : scaleCutLines(current, optimisticSnapshot.height, height); }); if (onPreparedRef.current) { const dimensionByCardId = new Map( sourceSegments.map((segment) => [ segment.card.id, { previewUrl: segment.card.previewUrl || segment.card.imageUrl || null, width: segment.sourceWidth || segment.card.width || null, height: segment.sourceHeight || segment.card.height || null, }, ]) ); onPreparedRef.current( setupCards.map((card, index) => { const dimensions = dimensionByCardId.get(card.id); return { ...card, sortOrder: card.sortOrder ?? index, previewUrl: dimensions?.previewUrl || card.previewUrl || card.imageUrl || null, width: dimensions?.width || card.width || null, height: dimensions?.height || card.height || null, }; }) ); } setWarningText( (result.data.failed?.length || 0) > 0 ? `有 ${result.data.failed?.length || 0} 张参考图加载失败,已自动跳过异常图片。` : null ); } catch (error) { if (cancelled) return; const message = normalizeDetailStitchErrorMessage(error, "拼合参考图失败"); logger.warn("prepare_failed", { cardCount: setupCards.length, message, }); setWarningText( `精准尺寸校准失败,已先使用快速预览:${message}` ); } finally { if (!cancelled) { setIsPreparing(false); setLoadingProgressText(""); } } }; void setup(); return () => { cancelled = true; }; }, [open, setupCards, sourceLabel]); useEffect(() => { const validIndexes = new Set(sliceSegments.map((segment) => segment.index)); setPassthroughSegmentIndexes((current) => current.filter((index) => validIndexes.has(index)) ); }, [sliceSegments]); useEffect(() => { if (!open || !snapshot || !useVirtualPreview) return; const urls = Array.from( new Set( snapshot.sourceSegments .map((segment) => { const originalImageUrl = String(segment.card.imageUrl || "").trim(); const segmentImageUrl = String(segment.card.previewUrl || "").trim() || originalImageUrl; return buildStitchSegmentDisplayImageUrl(segmentImageUrl, snapshot.width); }) .filter(Boolean) ) ); if (urls.length <= 2) return; let cancelled = false; let cursor = 2; const timer = window.setTimeout(() => { const worker = async () => { while (!cancelled && cursor < urls.length) { const currentIndex = cursor; cursor += 1; await preloadBrowserImage(urls[currentIndex] || ""); } }; void Promise.all( Array.from( { length: Math.min(SEGMENT_PRELOAD_CONCURRENCY, Math.max(urls.length - 2, 0)) }, () => worker() ) ); }, 300); return () => { cancelled = true; window.clearTimeout(timer); }; }, [open, snapshot, useVirtualPreview]); const resolveInsertCutLineY = useCallback( (rawY: number) => { if (!snapshot) return null; const sortedLines = [...normalizedCutLines].sort((left, right) => left - right); const insertIndex = sortedLines.findIndex((value) => value > rawY); const previousBoundary = insertIndex <= 0 ? 0 : sortedLines[(insertIndex === -1 ? sortedLines.length : insertIndex) - 1]; const nextBoundary = insertIndex === -1 ? snapshot.height : sortedLines[insertIndex]; const minY = previousBoundary + MIN_SLICE_HEIGHT; const maxY = nextBoundary - MIN_SLICE_HEIGHT; if (maxY < minY) { return null; } return clamp(rawY, minY, maxY); }, [normalizedCutLines, snapshot] ); const resolveCanvasYFromClient = useCallback( (clientY: number) => { const container = previewContainerRef.current; if (!container || !snapshot) return null; const rect = container.getBoundingClientRect(); if (rect.height <= 0) return null; const rawY = ((clientY - rect.top) / rect.height) * snapshot.height; return clamp(rawY, 0, snapshot.height); }, [snapshot] ); useEffect(() => { if (draggingLineIndex === null || !snapshot) { return; } const handlePointerMove = (event: PointerEvent) => { const container = previewContainerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); if (rect.height <= 0) return; const rawY = ((event.clientY - rect.top) / rect.height) * snapshot.height; setCutLines((current) => { const next = [...current].sort((left, right) => left - right); const minY = draggingLineIndex === 0 ? MIN_SLICE_HEIGHT : next[draggingLineIndex - 1] + MIN_SLICE_HEIGHT; const maxY = draggingLineIndex === next.length - 1 ? snapshot.height - MIN_SLICE_HEIGHT : next[draggingLineIndex + 1] - MIN_SLICE_HEIGHT; next[draggingLineIndex] = clamp(rawY, minY, maxY); return next; }); }; const handlePointerUp = () => { setDraggingLineIndex(null); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); return () => { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); }; }, [draggingLineIndex, snapshot]); const handleAddCutLine = useCallback(() => { if (!snapshot) return; const points = [0, ...normalizedCutLines, snapshot.height]; const hasSpace = points.some( (value, index) => index < points.length - 1 && points[index + 1] - value >= MIN_SLICE_HEIGHT * 2 ); if (!hasSpace) { toast.error("当前没有足够空间继续添加切线"); return; } setIsAddingCutLine((current) => !current); setPreviewCutLineY(null); }, [normalizedCutLines, snapshot]); const handleResetCutLines = useCallback(() => { if (!snapshot) return; hasManualCutLineChangesRef.current = false; setCutLines(snapshot.defaultCutLines); }, [snapshot]); const handleClearCutLines = useCallback(() => { hasManualCutLineChangesRef.current = true; setCutLines([]); setDraggingLineIndex(null); setIsAddingCutLine(false); setPreviewCutLineY(null); }, []); const handleRemoveCutLine = useCallback((index: number) => { hasManualCutLineChangesRef.current = true; setCutLines((current) => current.filter((_, currentIndex) => currentIndex !== index)); }, []); const handlePreviewPointerMove = useCallback( (event: ReactPointerEvent) => { if (!isAddingCutLine) return; const rawY = resolveCanvasYFromClient(event.clientY); if (rawY === null) { setPreviewCutLineY(null); return; } setPreviewCutLineY(resolveInsertCutLineY(rawY)); }, [isAddingCutLine, resolveCanvasYFromClient, resolveInsertCutLineY] ); const handlePreviewPointerLeave = useCallback(() => { if (!isAddingCutLine) return; setPreviewCutLineY(null); }, [isAddingCutLine]); const handlePreviewClick = useCallback( (event: ReactPointerEvent) => { if (!isAddingCutLine) return; const rawY = resolveCanvasYFromClient(event.clientY); const nextY = rawY === null ? null : resolveInsertCutLineY(rawY); if (nextY === null) { toast.error("当前位置无法添加切线,请换一个位置"); return; } hasManualCutLineChangesRef.current = true; setCutLines((current) => [...current, Math.round(nextY)].sort((left, right) => left - right)); setIsAddingCutLine(false); setPreviewCutLineY(null); }, [isAddingCutLine, resolveCanvasYFromClient, resolveInsertCutLineY] ); const handleLinePointerDown = useCallback( (index: number) => (event: ReactPointerEvent) => { event.preventDefault(); event.stopPropagation(); try { event.currentTarget.setPointerCapture(event.pointerId); } catch { // Older browsers may not support pointer capture for this target. } hasManualCutLineChangesRef.current = true; setDraggingLineIndex(index); }, [] ); const handleMergedPreviewImageError = useCallback( (event: SyntheticEvent) => { 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) => { 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("当前还没有可用的拼合图"); return; } if (sliceSegments.length === 0) { toast.error("当前没有可切割的参考图"); return; } setIsApplying(true); setApplyProgressText(`正在服务端切割 ${sliceSegments.length} 张`); try { const response = await fetch("/api/agent-workflow/detail-stitch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "apply", previewUrl: snapshot.previewUrl, width: snapshot.width, height: snapshot.height, cutLines: normalizedCutLines, sourceSegments: snapshot.sourceSegments, passthroughSegmentIndexes, }), }); const result = (await response.json().catch(() => ({}))) as DetailStitchApplyResponse; if (!response.ok || !result.success || !result.data?.cards?.length) { throw new Error(result.error || "切割参考图失败"); } const orderedCards = result.data.cards.map((card, index) => ({ ...card, sortOrder: index, passthrough: passthroughSegmentIndexSet.has(index), })); if (orderedCards.length !== sliceSegments.length) { throw new Error("部分切片处理失败,请稍后重试"); } setApplyProgressText( normalizedCutLines.length === snapshot.defaultCutLines.length ? "已复用原图边界" : `已生成 ${orderedCards.length} 张切片` ); onApply(orderedCards); onOpenChange(false); toast.success(`已生成 ${orderedCards.length} 张切片参考图`); } catch (error) { const message = normalizeDetailStitchErrorMessage(error, "切割参考图失败"); logger.error("apply_failed", { cutLineCount: normalizedCutLines.length, sliceCount: sliceSegments.length, sourceSegmentCount: snapshot.sourceSegments.length, message, }); toast.error(message); } finally { setIsApplying(false); setApplyProgressText(""); } }, [ normalizedCutLines, onApply, onOpenChange, passthroughSegmentIndexes, passthroughSegmentIndexSet, sliceSegments.length, snapshot, ]); return ( { event.preventDefault(); }} onInteractOutside={(event) => { event.preventDefault(); }} onPointerDownOutside={(event) => { event.preventDefault(); }} overlayClassName="bg-black/85 backdrop-blur-sm" >
拼合切割{sourceLabel} 先把当前参考图按顺序纵向拼成一张长图,再通过横向切线重新切成多屏,继续用于详情替换逐屏生成。
原始 {validCards.length} 张
当前将切成 {sliceSegments.length || 0} 张
{passthroughSegmentIndexes.length > 0 ? (
直出 {passthroughSegmentIndexes.length} 张
) : null}
{isPreparing ? loadingProgressText || "正在后台校准参考图尺寸,完成后会自动修正切线。" : "默认切线已按原始详情页边界生成,可直接拖动横线微调。"} {isAddingCutLine ? " 当前已进入加线模式,移动鼠标预览位置后点击长图即可落线。" : ""}
{isLoading ? (
{loadingProgressText || "正在拼合参考图..."}
会按当前参考图顺序生成虚拟长图,不再上传超长拼合图
) : errorText ? (
{errorText}
请先确认当前已经成功加载参考详情图,再进入拼合切割。
) : snapshot ? (
{warningText ? (
{warningText}
) : null}
{useVirtualPreview ? (
{snapshot.sourceSegments.map((segment, index) => { const originalImageUrl = String(segment.card.imageUrl || "").trim(); const segmentImageUrl = String(segment.card.previewUrl || "").trim() || originalImageUrl; return (
4 ? "auto" : undefined, containIntrinsicSize: index > 4 ? `${Math.max(segment.height, 1)}px` : undefined, }} >
参考图 {index + 1} 加载失败
{segmentImageUrl ? ( {`拼合分段预览 2 ? "lazy" : "eager"} fetchPriority={index < 2 ? "high" : "low"} onError={handleSegmentPreviewImageError(originalImageUrl)} /> ) : null}
); })}
) : ( 拼合长图预览 )}
{isAddingCutLine && previewCutLineY !== null ? (
点击落线
) : null} {normalizedCutLines.map((lineY, index) => { const topPercent = (lineY / snapshot.height) * 100; return (
切线 {index + 1}
); })}
向下拖动可浏览整张拼合长图。每条横线代表一次切割边界;右侧可把某些切片标记为“直接应用”,这些模块会跳过继续生成,直接进入结果区。
) : null}
切割结果预估
应用切割后,下方每一段都会回到详情替换参考图区;标记为“不处理直出”的切片会跳过继续生成,未标记的切片再逐屏生成。
{sliceSegments.length > 0 ? ( sliceSegments.map((segment) => (
切片 {segment.index + 1}
高度 {segment.height}px
从 {Math.round(segment.top)}px 到 {Math.round(segment.top + segment.height)}px
{passthroughSegmentIndexSet.has(segment.index) ? "当前切片不会参与生图,应用后会直接进入结果区。" : "当前切片会继续参与详情替换生成。"}
)) ) : (
当前还没有可切割的长图。
)}
); }