"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, } 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 { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { cn } from "@/lib/utils"; import type { AgentDetailReplacementReferenceItem } from "@/types/agent"; const MERGED_IMAGE_MAX_WIDTH = 1600; const MIN_SLICE_HEIGHT = 120; const MERGED_IMAGE_MAX_PIXELS = 42_000_000; type LoadedReferenceImage = { card: AgentDetailReplacementReferenceItem; image: HTMLImageElement; width: number; height: number; scaledHeight: number; revokeObjectUrl?: (() => void) | null; }; type MergedSnapshot = { width: number; height: number; defaultCutLines: number[]; loadedImages: LoadedReferenceImage[]; }; function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } function createImageElement(src: string, useCrossOrigin = true) { return new Promise((resolve, reject) => { const image = new Image(); if (useCrossOrigin) { image.crossOrigin = "anonymous"; } image.onload = () => resolve(image); image.onerror = () => reject(new Error("图片加载失败")); image.src = src; }); } async function loadImage(url: string) { const normalizedSource = String(url || "").trim(); if (!normalizedSource) { throw new Error("图片地址为空"); } const candidateUrls = Array.from( new Set( [ buildImageProxyUrl(normalizedSource, { width: MERGED_IMAGE_MAX_WIDTH, quality: 92, format: "png", }), buildImageProxyUrl(normalizedSource, { width: Math.min(MERGED_IMAGE_MAX_WIDTH, 960), quality: 86, format: "webp", }), normalizedSource, ].filter((item): item is string => Boolean(item)) ) ); const errorMessages: string[] = []; for (const candidateUrl of candidateUrls) { try { const response = await fetch(candidateUrl, { cache: "force-cache", credentials: "same-origin", }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const blob = await response.blob(); if (!blob.type.startsWith("image/")) { throw new Error("返回内容不是图片"); } const objectUrl = URL.createObjectURL(blob); try { const image = await createImageElement(objectUrl, false); return { image, revokeObjectUrl: () => URL.revokeObjectURL(objectUrl), }; } catch (error) { URL.revokeObjectURL(objectUrl); throw error; } } catch (error) { errorMessages.push( error instanceof Error ? error.message : `无法加载图片资源: ${candidateUrl}` ); } } for (const candidateUrl of candidateUrls) { try { const image = await createImageElement( candidateUrl, !candidateUrl.startsWith("data:") && !candidateUrl.startsWith("blob:") ); return { image, revokeObjectUrl: null, }; } catch (error) { errorMessages.push( error instanceof Error ? error.message : `无法直接加载图片资源: ${candidateUrl}` ); } } throw new Error(errorMessages[0] || "图片加载失败"); } function buildMergedSnapshot(images: LoadedReferenceImage[]) { const baseWidth = Math.max( 1, Math.min( MERGED_IMAGE_MAX_WIDTH, Math.max(...images.map((item) => item.width), 1) ) ); const baseTotalHeight = images.reduce((total, item) => { return total + Math.max(1, Math.round((baseWidth / Math.max(item.width, 1)) * item.height)); }, 0); const basePixels = baseWidth * baseTotalHeight; const safeScale = basePixels > MERGED_IMAGE_MAX_PIXELS ? Math.sqrt(MERGED_IMAGE_MAX_PIXELS / Math.max(basePixels, 1)) : 1; const targetWidth = Math.max( 1, Math.min( baseWidth, Math.max(baseWidth > 720 ? 720 : baseWidth, Math.round(baseWidth * safeScale)) ) ); let totalHeight = 0; const defaultCutLines: number[] = []; const normalizedImages = images.map((item, index) => { const scaledHeight = Math.max( 1, Math.round((targetWidth / Math.max(item.width, 1)) * item.height) ); totalHeight += scaledHeight; if (index < images.length - 1) { defaultCutLines.push(totalHeight); } return { ...item, scaledHeight, }; }); const canvas = document.createElement("canvas"); canvas.width = targetWidth; canvas.height = totalHeight; const context = canvas.getContext("2d"); if (!context) { throw new Error("无法初始化拼合画布"); } context.fillStyle = "#ffffff"; context.fillRect(0, 0, canvas.width, canvas.height); let offsetY = 0; normalizedImages.forEach((item) => { context.drawImage(item.image, 0, offsetY, targetWidth, item.scaledHeight); offsetY += item.scaledHeight; }); return { canvas, snapshot: { width: targetWidth, height: totalHeight, defaultCutLines, loadedImages: normalizedImages, } satisfies MergedSnapshot, }; } interface AgentDetailReferenceStitchDialogProps { open: boolean; onOpenChange: (open: boolean) => void; cards: AgentDetailReplacementReferenceItem[]; sourceLabel?: string; onApply: (nextCards: AgentDetailReplacementReferenceItem[]) => void; } export function AgentDetailReferenceStitchDialog({ open, onOpenChange, cards, sourceLabel = "详情参考图", onApply, }: AgentDetailReferenceStitchDialogProps) { const previewCanvasRef = useRef(null); const previewContainerRef = useRef(null); const mergedCanvasRef = 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 [isApplying, setIsApplying] = useState(false); const [applyProgressText, setApplyProgressText] = useState(""); const [loadingProgressText, setLoadingProgressText] = useState(""); const [errorText, setErrorText] = useState(null); const [warningText, setWarningText] = useState(null); const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState([]); const validCards = useMemo( () => cards.filter( (card): card is AgentDetailReplacementReferenceItem & { imageUrl: string } => Boolean(String(card.imageUrl || "").trim()) ), [cards] ); const validCardLoadKey = useMemo( () => validCards .map((card) => `${card.id}::${String(card.imageUrl || "").trim()}`) .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 redrawPreviewCanvas = useCallback(() => { const previewCanvas = previewCanvasRef.current; const mergedCanvas = mergedCanvasRef.current; if (!previewCanvas || !mergedCanvas || !snapshot) { return; } previewCanvas.width = snapshot.width; previewCanvas.height = snapshot.height; const context = previewCanvas.getContext("2d"); if (!context) { return; } context.clearRect(0, 0, snapshot.width, snapshot.height); context.drawImage(mergedCanvas, 0, 0); }, [snapshot]); useEffect(() => { redrawPreviewCanvas(); }, [redrawPreviewCanvas]); useEffect(() => { if (!open) { setDraggingLineIndex(null); setIsAddingCutLine(false); setPreviewCutLineY(null); setLoadingProgressText(""); setWarningText(null); setPassthroughSegmentIndexes([]); return; } if (setupCards.length === 0) { setSnapshot(null); setCutLines([]); setErrorText(`当前没有可拼合的${sourceLabel}`); return; } let cancelled = false; const setup = async () => { let loadedImages: LoadedReferenceImage[] = []; let snapshotCommitted = false; try { setIsLoading(true); setErrorText(null); setWarningText(null); setLoadingProgressText(`正在加载参考图 0/${setupCards.length}`); const failedCards: string[] = []; let processedCount = 0; for (const [index, card] of setupCards.entries()) { try { const loaded = await loadImage(card.imageUrl); loadedImages.push({ card, image: loaded.image, width: loaded.image.naturalWidth, height: loaded.image.naturalHeight, scaledHeight: loaded.image.naturalHeight, revokeObjectUrl: loaded.revokeObjectUrl, } satisfies LoadedReferenceImage); } catch (error) { failedCards.push(card.label || `第 ${index + 1} 张参考图`); console.error("[AgentDetailReferenceStitchDialog] Failed to load reference image:", { cardId: card.id, label: card.label, imageUrl: card.imageUrl, error, }); } finally { processedCount += 1; if (!cancelled) { setLoadingProgressText(`正在加载参考图 ${processedCount}/${setupCards.length}`); } } } if (cancelled) return; if (loadedImages.length === 0) { throw new Error("当前参考图加载失败,请稍后重试"); } const next = buildMergedSnapshot(loadedImages); mergedCanvasRef.current = next.canvas; setSnapshot(next.snapshot); setCutLines(next.snapshot.defaultCutLines); snapshotCommitted = true; setWarningText( failedCards.length > 0 ? `有 ${failedCards.length} 张参考图加载失败,已自动跳过异常图片。` : null ); } catch (error) { if (cancelled) return; console.error("[AgentDetailReferenceStitchDialog] Failed to prepare merged image:", error); setSnapshot(null); setCutLines([]); setErrorText(error instanceof Error ? error.message : "拼合参考图失败"); } finally { if (!snapshotCommitted) { loadedImages.forEach((item) => item.revokeObjectUrl?.()); } if (!cancelled) { setIsLoading(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(() => { return () => { snapshot?.loadedImages.forEach((item) => item.revokeObjectUrl?.()); }; }, [snapshot]); 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; setCutLines(snapshot.defaultCutLines); }, [snapshot]); const handleClearCutLines = useCallback(() => { setCutLines([]); setDraggingLineIndex(null); setIsAddingCutLine(false); setPreviewCutLineY(null); }, []); const handleRemoveCutLine = useCallback((index: number) => { 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; } 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(); setDraggingLineIndex(index); }, [] ); const handleApply = useCallback(async () => { if (!snapshot || !mergedCanvasRef.current) { toast.error("当前还没有可用的拼合图"); return; } if (sliceSegments.length === 0) { toast.error("当前没有可切割的参考图"); return; } setIsApplying(true); setApplyProgressText(`正在上传切片 1/${sliceSegments.length}`); try { const nextCards: AgentDetailReplacementReferenceItem[] = []; const sourceCanvas = mergedCanvasRef.current; const sourceCandidateId = validCards[0]?.candidateId || null; const timestamp = Date.now(); for (const segment of sliceSegments) { setApplyProgressText(`正在上传切片 ${segment.index + 1}/${sliceSegments.length}`); const sliceCanvas = document.createElement("canvas"); sliceCanvas.width = snapshot.width; sliceCanvas.height = segment.height; const context = sliceCanvas.getContext("2d"); if (!context) { throw new Error("切片画布初始化失败"); } context.fillStyle = "#ffffff"; context.fillRect(0, 0, sliceCanvas.width, sliceCanvas.height); context.drawImage( sourceCanvas, 0, segment.top, snapshot.width, segment.height, 0, 0, snapshot.width, segment.height ); const blob = await new Promise((resolve, reject) => { sliceCanvas.toBlob( (value) => { if (value) { resolve(value); return; } reject(new Error("切片导出失败")); }, "image/jpeg", 0.92 ); }); const formData = new FormData(); formData.append( "file", new File([blob], `detail-reference-slice-${timestamp}-${segment.index + 1}.jpg`, { type: "image/jpeg", }) ); const response = await fetchUploadWithRetry(formData); const result = await response.json().catch(() => ({})); if (!response.ok || !result?.url) { throw new Error(result?.error || `第 ${segment.index + 1} 张切片上传失败`); } nextCards.push({ id: `detail-stitch-slice-${timestamp}-${segment.index + 1}`, label: `拼合切片 ${segment.index + 1}`, imageUrl: String(result.url), candidateId: sourceCandidateId, sortOrder: segment.index, passthrough: passthroughSegmentIndexSet.has(segment.index), }); } onApply(nextCards); onOpenChange(false); toast.success(`已生成 ${nextCards.length} 张切片参考图`); } catch (error) { console.error("[AgentDetailReferenceStitchDialog] Failed to apply slices:", error); toast.error(error instanceof Error ? error.message : "切割参考图失败"); } finally { setIsApplying(false); setApplyProgressText(""); } }, [onApply, onOpenChange, passthroughSegmentIndexSet, sliceSegments, snapshot, validCards]); 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}
默认切线已按原始详情页边界生成,可直接拖动横线微调。 {isAddingCutLine ? " 当前已进入加线模式,移动鼠标预览位置后点击长图即可落线。" : ""}
{isLoading ? (
{loadingProgressText || "正在拼合参考图..."}
会按当前参考图顺序从上到下拼成一张长图
) : errorText ? (
{errorText}
请先确认当前已经成功加载参考详情图,再进入拼合切割。
) : snapshot ? (
{warningText ? (
{warningText}
) : 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) ? "当前切片不会参与生图,应用后会直接进入结果区。" : "当前切片会继续参与详情替换生成。"}
)) ) : (
当前还没有可切割的长图。
)}
); }