From 25d331fa515fcc12c25a4d19180ab5a0930982e7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 14 May 2026 15:28:09 +0800 Subject: [PATCH] Optimize agent image stitching and previews --- app/api/agent-workflow/detail-stitch/route.ts | 501 ++++++++++++++++++ .../AgentDetailReferenceStitchDialog.tsx | 490 +++++------------ .../agents/AgentPsdTemplateEditorDialog.tsx | 85 ++- .../agents/AgentWorkflowPanelDarkV2.tsx | 95 +++- 4 files changed, 783 insertions(+), 388 deletions(-) create mode 100644 app/api/agent-workflow/detail-stitch/route.ts diff --git a/app/api/agent-workflow/detail-stitch/route.ts b/app/api/agent-workflow/detail-stitch/route.ts new file mode 100644 index 000000000..4f5fa9bbb --- /dev/null +++ b/app/api/agent-workflow/detail-stitch/route.ts @@ -0,0 +1,501 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { buildImageProxyUrl } from "@/lib/image-delivery"; +import { uploadFile } from "@/lib/storage/service"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +type StitchReferenceInput = { + id: string; + label?: string | null; + imageUrl: string; + candidateId?: string | null; + sortOrder?: number | null; + passthrough?: boolean | null; + assignedProductIndex?: number | null; +}; + +type StitchSourceSegment = { + card: StitchReferenceInput; + top: number; + height: number; +}; + +const MAX_REFERENCE_COUNT = 120; +const FETCH_TIMEOUT_MS = 10000; +const FETCH_CONCURRENCY = 32; +const TRANSFORM_CONCURRENCY = 10; +const SLICE_UPLOAD_CONCURRENCY = 10; +const PREVIEW_MAX_WIDTH = 720; +const PREVIEW_MIN_WIDTH = 96; +const PREVIEW_MAX_HEIGHT = 32000; +const PREVIEW_MAX_PIXELS = 36_000_000; +const SEGMENT_MATCH_TOLERANCE = 2; + +function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ) as ArrayBuffer; +} + +async function runBoundedParallel( + items: T[], + workerCount: number, + worker: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length); + const maxWorkers = Math.max(1, Math.min(workerCount, items.length)); + let cursor = 0; + + await Promise.all( + Array.from({ length: maxWorkers }, async () => { + while (cursor < items.length) { + const index = cursor; + cursor += 1; + results[index] = await worker(items[index]!, index); + } + }), + ); + + return results; +} + +function getRequestOrigin(request: NextRequest): string { + const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim(); + const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim(); + if (forwardedHost && forwardedProto) { + return `${forwardedProto}://${forwardedHost}`; + } + return new URL(request.url).origin; +} + +function resolveHttpUrl(rawUrl: unknown, origin: string): string { + const value = String(rawUrl || "").trim(); + if (!value) return ""; + try { + const parsed = new URL(value, origin); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return ""; + return parsed.toString(); + } catch { + return ""; + } +} + +function sanitizeReferenceInput(input: unknown, origin: string): StitchReferenceInput[] { + if (!Array.isArray(input)) return []; + return input + .map((item, index): StitchReferenceInput | null => { + const raw = (item || {}) as Record; + const imageUrl = resolveHttpUrl(raw.imageUrl, origin); + if (!imageUrl) return null; + return { + id: String(raw.id || `reference-${index + 1}`).trim() || `reference-${index + 1}`, + label: String(raw.label || "").trim() || `参考图 ${index + 1}`, + imageUrl, + candidateId: String(raw.candidateId || "").trim() || null, + sortOrder: Number.isFinite(Number(raw.sortOrder)) ? Number(raw.sortOrder) : index, + passthrough: raw.passthrough === true, + assignedProductIndex: Number.isFinite(Number(raw.assignedProductIndex)) + ? Number(raw.assignedProductIndex) + : null, + } satisfies StitchReferenceInput; + }) + .filter((item): item is StitchReferenceInput => Boolean(item)) + .slice(0, MAX_REFERENCE_COUNT); +} + +function buildFetchHeaders(url: string, requestOrigin: string, cookie: string): HeadersInit { + const headers: Record = { + Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + "User-Agent": "Mozilla/5.0 (compatible; BuyiDetailStitch/1.0)", + Referer: requestOrigin, + }; + try { + if (new URL(url).origin === requestOrigin && cookie) { + headers.cookie = cookie; + } + } catch { + // URL has already been normalized. + } + return headers; +} + +function buildReferenceFetchUrl(url: string): string { + return buildImageProxyUrl(url, { + width: PREVIEW_MAX_WIDTH, + quality: 82, + format: "webp", + }); +} + +async function fetchImageBuffer(url: string, requestOrigin: string, cookie: string): Promise { + const response = await fetch(url, { + cache: "no-store", + redirect: "follow", + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: buildFetchHeaders(url, requestOrigin, cookie), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const contentType = response.headers.get("content-type") || ""; + const normalizedContentType = contentType.toLowerCase(); + if ( + normalizedContentType && + !normalizedContentType.startsWith("image/") && + (normalizedContentType.startsWith("text/") || + normalizedContentType.includes("json") || + normalizedContentType.includes("xml")) + ) { + throw new Error("返回内容不是图片"); + } + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.length === 0) { + throw new Error("图片内容为空"); + } + return buffer; +} + +function resolvePreviewWidth(items: Array<{ width: number; height: number }>) { + const maxSourceWidth = Math.max(...items.map((item) => item.width), 1); + const baseWidth = Math.max(1, Math.min(PREVIEW_MAX_WIDTH, maxSourceWidth)); + const heightRatioSum = items.reduce( + (total, item) => total + item.height / Math.max(item.width, 1), + 0, + ); + const heightLimitedWidth = Math.floor(PREVIEW_MAX_HEIGHT / Math.max(heightRatioSum, 1)); + const pixelLimitedWidth = Math.floor( + Math.sqrt(PREVIEW_MAX_PIXELS / Math.max(heightRatioSum, 1)), + ); + const constrainedWidth = Math.min( + baseWidth, + Math.max(1, heightLimitedWidth), + Math.max(1, pixelLimitedWidth), + ); + return constrainedWidth >= PREVIEW_MIN_WIDTH + ? constrainedWidth + : Math.max(1, constrainedWidth); +} + +function normalizeCutLines(cutLines: unknown, height: number) { + if (!Array.isArray(cutLines)) return []; + return Array.from( + new Set( + cutLines + .map((value) => Math.round(Number(value))) + .filter((value) => Number.isFinite(value) && value > 0 && value < height) + .sort((left, right) => left - right), + ), + ); +} + +function findMatchingSourceSegment( + segment: { top: number; height: number }, + sourceSegments: StitchSourceSegment[], +) { + return ( + sourceSegments.find( + (source) => + Math.abs(source.top - segment.top) <= SEGMENT_MATCH_TOLERANCE && + Math.abs(source.height - segment.height) <= SEGMENT_MATCH_TOLERANCE, + ) || null + ); +} + +async function prepareStitch(params: { + references: StitchReferenceInput[]; + requestOrigin: string; + cookie: string; +}) { + const sharp = (await import("sharp")).default; + const fetched = await runBoundedParallel( + params.references, + FETCH_CONCURRENCY, + async (card, index) => { + try { + const buffer = await fetchImageBuffer( + buildReferenceFetchUrl(card.imageUrl), + params.requestOrigin, + params.cookie, + ); + const metadata = await sharp(buffer, { failOn: "none" }).rotate().metadata(); + const width = metadata.width || 0; + const height = metadata.height || 0; + if (!width || !height) { + throw new Error("无法读取图片尺寸"); + } + return { ok: true as const, card, index, buffer, width, height }; + } catch (error) { + return { + ok: false as const, + card, + index, + error: error instanceof Error ? error.message : "图片加载失败", + }; + } + }, + ); + + const loaded = fetched.filter((item): item is Extract<(typeof fetched)[number], { ok: true }> => item.ok); + const failed = fetched.filter((item): item is Extract<(typeof fetched)[number], { ok: false }> => !item.ok); + + if (loaded.length === 0) { + throw new Error("当前参考图加载失败,请检查图片链接"); + } + + const previewWidth = resolvePreviewWidth(loaded); + let top = 0; + const sourceSegments: StitchSourceSegment[] = []; + const defaultCutLines: number[] = []; + + const resizedItems = await runBoundedParallel(loaded, TRANSFORM_CONCURRENCY, async (item, loadedIndex) => { + const height = Math.max(1, Math.round((previewWidth / Math.max(item.width, 1)) * item.height)); + const resized = await sharp(item.buffer, { failOn: "none" }) + .rotate() + .resize(previewWidth, height, { + fit: "fill", + withoutEnlargement: false, + }) + .jpeg({ + quality: 76, + mozjpeg: true, + progressive: true, + }) + .toBuffer(); + + return { + input: resized, + top: 0, + left: 0, + height, + loadedIndex, + card: item.card, + }; + }); + + const compositeItems = resizedItems.map((item, index) => { + const currentTop = top; + top += item.height; + if (index < resizedItems.length - 1) { + defaultCutLines.push(top); + } + sourceSegments.push({ + card: item.card, + top: currentTop, + height: item.height, + }); + return { + input: item.input, + top: currentTop, + left: 0, + }; + }); + + const previewHeight = Math.max(1, top); + const previewBuffer = await sharp({ + create: { + width: previewWidth, + height: previewHeight, + channels: 3, + background: "#ffffff", + }, + }) + .composite(compositeItems) + .webp({ + quality: 76, + effort: 4, + }) + .toBuffer(); + + const upload = await uploadFile( + `detail-stitch-preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.webp`, + bufferToArrayBuffer(previewBuffer), + ); + + return { + previewUrl: upload.url, + width: previewWidth, + height: previewHeight, + defaultCutLines, + sourceSegments, + failed: failed.map((item) => ({ + id: item.card.id, + label: item.card.label || `参考图 ${item.index + 1}`, + imageUrl: item.card.imageUrl, + error: item.error, + })), + }; +} + +async function applyStitch(params: { + previewUrl: string; + width: number; + height: number; + cutLines: number[]; + sourceSegments: StitchSourceSegment[]; + passthroughSegmentIndexes: number[]; + requestOrigin: string; + cookie: string; +}) { + const sharp = (await import("sharp")).default; + const points = [0, ...normalizeCutLines(params.cutLines, params.height), params.height]; + const segments = Array.from({ length: Math.max(points.length - 1, 0) }, (_, index) => ({ + index, + top: points[index]!, + height: Math.max(points[index + 1]! - points[index]!, 1), + })); + const passthroughSet = new Set(params.passthroughSegmentIndexes); + const segmentMatches = segments.map((segment) => findMatchingSourceSegment(segment, params.sourceSegments)); + const needsCropping = segmentMatches.some((match) => !match); + const previewBuffer = needsCropping + ? await fetchImageBuffer(params.previewUrl, params.requestOrigin, params.cookie) + : null; + + const cards = await runBoundedParallel(segments, SLICE_UPLOAD_CONCURRENCY, async (segment) => { + const matchedSource = segmentMatches[segment.index] || null; + if (matchedSource) { + return { + ...matchedSource.card, + id: `detail-stitch-slice-${Date.now()}-${segment.index + 1}`, + label: `拼合切片 ${segment.index + 1}`, + imageUrl: matchedSource.card.imageUrl, + sortOrder: segment.index, + passthrough: passthroughSet.has(segment.index), + }; + } + + const safeTop = Math.max(0, Math.min(params.height - 1, Math.round(segment.top))); + const safeHeight = Math.max( + 1, + Math.min(params.height - safeTop, Math.round(segment.height)), + ); + if (!previewBuffer) { + throw new Error("拼合预览图不可用"); + } + const sliceBuffer = await sharp(previewBuffer, { failOn: "none" }) + .extract({ + left: 0, + top: safeTop, + width: params.width, + height: safeHeight, + }) + .jpeg({ + quality: 88, + mozjpeg: true, + progressive: true, + }) + .toBuffer(); + const upload = await uploadFile( + `detail-reference-slice-${Date.now()}-${segment.index + 1}.jpg`, + bufferToArrayBuffer(sliceBuffer), + ); + const sourceSegment = + params.sourceSegments.find( + (item) => segment.top >= item.top && segment.top < item.top + item.height, + ) || params.sourceSegments[0] || null; + + return { + id: `detail-stitch-slice-${Date.now()}-${segment.index + 1}`, + label: `拼合切片 ${segment.index + 1}`, + imageUrl: upload.url, + candidateId: sourceSegment?.card.candidateId || null, + sortOrder: segment.index, + passthrough: passthroughSet.has(segment.index), + assignedProductIndex: sourceSegment?.card.assignedProductIndex ?? null, + }; + }); + + return { cards }; +} + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const body = (await request.json().catch(() => ({}))) as Record; + const mode = String(body.mode || "").trim(); + const requestOrigin = getRequestOrigin(request); + const cookie = request.headers.get("cookie") || ""; + + if (mode === "prepare") { + const references = sanitizeReferenceInput(body.references, requestOrigin); + if (references.length === 0) { + return NextResponse.json({ success: false, error: "没有可拼合的参考图" }, { status: 400 }); + } + + const data = await prepareStitch({ references, requestOrigin, cookie }); + return NextResponse.json({ success: true, data }); + } + + if (mode === "apply") { + const previewUrl = resolveHttpUrl(body.previewUrl, requestOrigin); + const width = Math.round(Number(body.width)); + const height = Math.round(Number(body.height)); + const rawSegments = Array.isArray(body.sourceSegments) ? body.sourceSegments : []; + const sourceSegments = rawSegments + .map((item): StitchSourceSegment | null => { + const raw = (item || {}) as Record; + const rawCard = (raw.card || {}) as Record; + const imageUrl = resolveHttpUrl(rawCard.imageUrl, requestOrigin); + const top = Math.round(Number(raw.top)); + const segmentHeight = Math.round(Number(raw.height)); + if (!imageUrl || !Number.isFinite(top) || !Number.isFinite(segmentHeight)) { + return null; + } + return { + top, + height: Math.max(1, segmentHeight), + card: { + id: String(rawCard.id || "").trim() || `source-${top}`, + label: String(rawCard.label || "").trim() || "参考图", + imageUrl, + candidateId: String(rawCard.candidateId || "").trim() || null, + sortOrder: Number.isFinite(Number(rawCard.sortOrder)) ? Number(rawCard.sortOrder) : null, + passthrough: rawCard.passthrough === true, + assignedProductIndex: Number.isFinite(Number(rawCard.assignedProductIndex)) + ? Number(rawCard.assignedProductIndex) + : null, + }, + } satisfies StitchSourceSegment; + }) + .filter((item): item is StitchSourceSegment => Boolean(item)); + + if (!previewUrl || !width || !height || sourceSegments.length === 0) { + return NextResponse.json({ success: false, error: "缺少切割参数" }, { status: 400 }); + } + + const passthroughSegmentIndexes = Array.isArray(body.passthroughSegmentIndexes) + ? body.passthroughSegmentIndexes + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value >= 0) + : []; + const data = await applyStitch({ + previewUrl, + width, + height, + cutLines: normalizeCutLines(body.cutLines, height), + sourceSegments, + passthroughSegmentIndexes, + requestOrigin, + cookie, + }); + return NextResponse.json({ success: true, data }); + } + + return NextResponse.json({ success: false, error: "未知操作" }, { status: 400 }); + } catch (error) { + console.error("[DetailStitch API] Failed:", error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "拼合切割失败", + }, + { status: 500 }, + ); + } +} diff --git a/components/agents/AgentDetailReferenceStitchDialog.tsx b/components/agents/AgentDetailReferenceStitchDialog.tsx index b1a8dccfc..0d46772a8 100644 --- a/components/agents/AgentDetailReferenceStitchDialog.tsx +++ b/components/agents/AgentDetailReferenceStitchDialog.tsx @@ -18,231 +18,50 @@ import { 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; -const STITCH_IMAGE_LOAD_CONCURRENCY = 6; -const STITCH_SLICE_UPLOAD_CONCURRENCY = 4; -type LoadedReferenceImage = { +type StitchSourceSegment = { card: AgentDetailReplacementReferenceItem; - image: HTMLImageElement; - width: number; + top: number; height: number; - scaledHeight: number; - revokeObjectUrl?: (() => void) | null; }; type MergedSnapshot = { width: number; height: number; + previewUrl: string; defaultCutLines: number[]; - loadedImages: LoadedReferenceImage[]; + 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)); } -async function runIndexedParallel( - items: T[], - concurrency: number, - worker: (item: T, index: number) => Promise -) { - const workerCount = Math.max(1, Math.min(concurrency, items.length)); - let cursor = 0; - let firstError: unknown = null; - - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (cursor < items.length) { - const index = cursor; - cursor += 1; - if (firstError) { - return; - } - - try { - await worker(items[index]!, index); - } catch (error) { - firstError = firstError || error; - } - } - }) - ); - - if (firstError) { - throw firstError; - } -} - -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", - delivery: "proxy", - }), - buildImageProxyUrl(normalizedSource, { - width: Math.min(MERGED_IMAGE_MAX_WIDTH, 960), - quality: 86, - format: "webp", - delivery: "proxy", - }), - 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; @@ -258,9 +77,7 @@ export function AgentDetailReferenceStitchDialog({ 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); @@ -319,27 +136,6 @@ export function AgentDetailReferenceStitchDialog({ [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); @@ -361,65 +157,64 @@ export function AgentDetailReferenceStitchDialog({ let cancelled = false; const setup = async () => { - let loadedImages: LoadedReferenceImage[] = []; - let snapshotCommitted = false; try { setIsLoading(true); setErrorText(null); setWarningText(null); - setLoadingProgressText(`正在加载参考图 0/${setupCards.length}`); + setLoadingProgressText(`正在服务端并行拼合 ${setupCards.length} 张参考图`); - const failedCards: string[] = []; - let processedCount = 0; - - const loadedResults: Array = new Array(setupCards.length).fill(null); - await runIndexedParallel( - setupCards, - STITCH_IMAGE_LOAD_CONCURRENCY, - async (card, index) => { - if (cancelled) return; - try { - const loaded = await loadImage(card.imageUrl); - loadedResults[index] = { - 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}`); - } - } - } - ); - loadedImages = loadedResults.filter((item): item is LoadedReferenceImage => Boolean(item)); + 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 (loadedImages.length === 0) { - throw new Error("当前参考图加载失败,请稍后重试"); + if (!response.ok || !result.success || !result.data) { + throw new Error(result.error || "拼合参考图失败"); } - const next = buildMergedSnapshot(loadedImages); - mergedCanvasRef.current = next.canvas; - setSnapshot(next.snapshot); - setCutLines(next.snapshot.defaultCutLines); - snapshotCommitted = true; + 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 (!previewUrl || !width || !height || sourceSegments.length === 0) { + throw new Error("拼合结果无效,请稍后重试"); + } + + setSnapshot({ + width, + height, + previewUrl, + defaultCutLines, + sourceSegments, + }); + setCutLines(defaultCutLines); + setPassthroughSegmentIndexes([]); setWarningText( - failedCards.length > 0 - ? `有 ${failedCards.length} 张参考图加载失败,已自动跳过异常图片。` + (result.data.failed?.length || 0) > 0 + ? `有 ${result.data.failed?.length || 0} 张参考图加载失败,已自动跳过异常图片。` : null ); } catch (error) { @@ -429,9 +224,6 @@ export function AgentDetailReferenceStitchDialog({ setCutLines([]); setErrorText(error instanceof Error ? error.message : "拼合参考图失败"); } finally { - if (!snapshotCommitted) { - loadedImages.forEach((item) => item.revokeObjectUrl?.()); - } if (!cancelled) { setIsLoading(false); setLoadingProgressText(""); @@ -453,12 +245,6 @@ export function AgentDetailReferenceStitchDialog({ ); }, [sliceSegments]); - useEffect(() => { - return () => { - snapshot?.loadedImages.forEach((item) => item.revokeObjectUrl?.()); - }; - }, [snapshot]); - const resolveInsertCutLineY = useCallback( (rawY: number) => { if (!snapshot) return null; @@ -605,7 +391,7 @@ export function AgentDetailReferenceStitchDialog({ ); const handleApply = useCallback(async () => { - if (!snapshot || !mergedCanvasRef.current) { + if (!snapshot) { toast.error("当前还没有可用的拼合图"); return; } @@ -616,89 +402,42 @@ export function AgentDetailReferenceStitchDialog({ } setIsApplying(true); - setApplyProgressText(`正在上传切片 0/${sliceSegments.length}`); + setApplyProgressText(`正在服务端切割 ${sliceSegments.length} 张`); try { - const nextCards: Array = new Array(sliceSegments.length).fill(null); - const sourceCanvas = mergedCanvasRef.current; - const sourceCandidateId = validCards[0]?.candidateId || null; - const timestamp = Date.now(); - let completedCount = 0; - - await runIndexedParallel( - sliceSegments, - STITCH_SLICE_UPLOAD_CONCURRENCY, - async (segment, index) => { - 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[index] = { - 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), - }; - completedCount += 1; - setApplyProgressText(`正在上传切片 ${completedCount}/${sliceSegments.length}`); - } - ); - - const orderedCards = nextCards.filter( - (item): item is AgentDetailReplacementReferenceItem => Boolean(item) - ); - if (orderedCards.length !== sliceSegments.length) { - throw new Error("部分切片上传失败,请稍后重试"); + 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} 张切片参考图`); @@ -709,7 +448,15 @@ export function AgentDetailReferenceStitchDialog({ setIsApplying(false); setApplyProgressText(""); } - }, [onApply, onOpenChange, passthroughSegmentIndexSet, sliceSegments, snapshot, validCards]); + }, [ + normalizedCutLines, + onApply, + onOpenChange, + passthroughSegmentIndexes, + passthroughSegmentIndexSet, + sliceSegments.length, + snapshot, + ]); return ( @@ -823,9 +570,14 @@ export function AgentDetailReferenceStitchDialog({ onPointerLeave={handlePreviewPointerLeave} onClick={handlePreviewClick} > -
diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index bb75d1454..6f1ce8030 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -326,14 +326,37 @@ function buildTemplatePreviewThumbUrl( return ""; } - const searchParams = new URLSearchParams({ - url: normalizedUrl, - w: String(Math.max(80, Math.round(params?.width || 160))), - q: String(Math.max(40, Math.round(params?.quality || 72))), - f: "webp", + const width = Math.max(80, Math.round(params?.width || 160)); + const quality = Math.max(40, Math.round(params?.quality || 72)); + const directOptimizedUrl = buildImageProxyUrl(normalizedUrl, { + width, + quality, + format: "webp", }); - return `/api/image-proxy?${searchParams.toString()}`; + if (directOptimizedUrl !== normalizedUrl) { + return directOptimizedUrl; + } + + return buildImageProxyUrl(normalizedUrl, { + width, + quality, + format: "webp", + delivery: "proxy", + }); +} + +function buildPsdLayerPreviewUrl( + imageUrl: string, + params?: { + width?: number; + quality?: number; + } +) { + return buildTemplatePreviewThumbUrl(imageUrl, { + width: Math.max(96, Math.min(1280, Math.round(params?.width || 720))), + quality: Math.max(52, Math.min(82, Math.round(params?.quality || 68))), + }); } function buildTemplatePreviewFallbackUrls( @@ -764,6 +787,7 @@ function renderTemplatePickerCardContent( className="h-full w-full object-cover" fetchPriority={eager ? "high" : "low"} loading={eager ? "eager" : "lazy"} + quality={66} template={template} width={112} /> @@ -3615,7 +3639,12 @@ export function AgentPsdTemplateEditorDialog({ {image.title
@@ -3895,8 +3924,9 @@ export function AgentPsdTemplateEditorDialog({ > @@ -3941,7 +3971,18 @@ export function AgentPsdTemplateEditorDialog({ > {selectedTemplate ? ( selectedTemplateLayers.length === 0 ? ( -
+
+ +
+
{selectedTemplateDetailError ? ( <>
@@ -3962,6 +4003,7 @@ export function AgentPsdTemplateEditorDialog({
)} +
) : (
) : null} @@ -4031,7 +4074,13 @@ export function AgentPsdTemplateEditorDialog({ className={ assignedUrl ? "" : "h-full w-full" } - src={layerImageUrl} + decoding="async" + src={buildPsdLayerPreviewUrl(layerImageUrl, { + width: assignedUrl + ? Math.max(layer.width, layer.height) + : layer.width, + quality: assignedUrl ? 74 : 68, + })} style={ assignedUrl && imageSizes[layer.id] ? getPlacedAssignedImageStyle({ @@ -4092,7 +4141,11 @@ export function AgentPsdTemplateEditorDialog({ {layer.layerName}
); @@ -4421,7 +4474,11 @@ export function AgentPsdTemplateEditorDialog({ {layer.layerName} ) : null} {preserveSourceAppearance ? null : ( diff --git a/components/agents/AgentWorkflowPanelDarkV2.tsx b/components/agents/AgentWorkflowPanelDarkV2.tsx index cdb220f8e..1843f4ed3 100644 --- a/components/agents/AgentWorkflowPanelDarkV2.tsx +++ b/components/agents/AgentWorkflowPanelDarkV2.tsx @@ -10,6 +10,7 @@ import { useRef, useState, type ReactNode, + type SyntheticEvent, } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { motion } from "framer-motion"; @@ -50,6 +51,7 @@ import { import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark"; import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner"; import { getAgentWorkflowProgress } from "@/lib/agents/workflow-composer"; +import { buildImageProxyUrl } from "@/lib/image-delivery"; import type { AgentWorkflowMaterialSelections, AgentWorkflowShowcaseConfig, @@ -108,7 +110,38 @@ type WorkflowOption = WorkflowStep["options"][number]; const VIRTUAL_OPTION_GRID_GAP = 16; const VIRTUAL_OPTION_GRID_MAX_HEIGHT = 640; const VIRTUAL_OPTION_GRID_MIN_CARD_HEIGHT = 122; -const VIRTUAL_OPTION_GRID_THRESHOLD = 18; +const VIRTUAL_OPTION_GRID_THRESHOLD = 12; +const WORKFLOW_OPTION_PREVIEW_WIDTH = 360; +const WORKFLOW_OPTION_PREVIEW_QUALITY = 62; +const WORKFLOW_OPTION_PREVIEW_EAGER_COUNT = 10; +const WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT = 12; + +function buildWorkflowOptionPreviewUrl(url?: string | null) { + const source = String(url || "").trim(); + if (!source) return ""; + return buildImageProxyUrl(source, { + width: WORKFLOW_OPTION_PREVIEW_WIDTH, + quality: WORKFLOW_OPTION_PREVIEW_QUALITY, + format: "webp", + delivery: "proxy", + }); +} + +function handleWorkflowOptionPreviewError( + event: SyntheticEvent, + originalUrl?: string | null +) { + const fallbackUrl = String(originalUrl || "").trim(); + if (!fallbackUrl) return; + + const image = event.currentTarget; + if (image.dataset.workflowPreviewFallbackApplied === "1") { + return; + } + + image.dataset.workflowPreviewFallbackApplied = "1"; + image.src = fallbackUrl; +} interface WorkflowPanelDerivedState { progress: ReturnType; @@ -269,6 +302,11 @@ const StepOptionCard = memo(function StepOptionCard({ const handleClick = useCallback(() => { onSelect(option.key); }, [onSelect, option.key]); + const previewUrl = useMemo( + () => buildWorkflowOptionPreviewUrl(option.previewImageUrl), + [option.previewImageUrl] + ); + const shouldLoadFast = selected || index < WORKFLOW_OPTION_PREVIEW_EAGER_COUNT; return (