Optimize agent image stitching and previews
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-14 15:28:09 +08:00
parent 95572bd476
commit 25d331fa51
4 changed files with 783 additions and 388 deletions

View File

@ -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<T, R>(
items: T[],
workerCount: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(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<string, unknown>;
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<string, string> = {
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<Buffer> {
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<string, unknown>;
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<string, unknown>;
const rawCard = (raw.card || {}) as Record<string, unknown>;
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 },
);
}
}

View File

@ -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<T>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<void>
) {
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<HTMLImageElement>((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<HTMLCanvasElement | null>(null);
const previewContainerRef = useRef<HTMLDivElement | null>(null);
const mergedCanvasRef = useRef<HTMLCanvasElement | null>(null);
const [snapshot, setSnapshot] = useState<MergedSnapshot | null>(null);
const [cutLines, setCutLines] = useState<number[]>([]);
const [draggingLineIndex, setDraggingLineIndex] = useState<number | null>(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<LoadedReferenceImage | null> = 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<AgentDetailReplacementReferenceItem | null> = 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<Blob>((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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -823,9 +570,14 @@ export function AgentDetailReferenceStitchDialog({
onPointerLeave={handlePreviewPointerLeave}
onClick={handlePreviewClick}
>
<canvas
ref={previewCanvasRef}
<img
src={snapshot.previewUrl}
alt="拼合长图预览"
className="block h-auto w-full select-none"
width={snapshot.width}
height={snapshot.height}
draggable={false}
decoding="async"
/>
<div className="pointer-events-none absolute inset-0">

View File

@ -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({
<img
alt={image.title || `待处理图片 ${index + 1}`}
className="h-full w-full object-cover"
src={image.imageUrl}
decoding="async"
loading={index < 3 ? "eager" : "lazy"}
src={buildPsdLayerPreviewUrl(image.imageUrl, {
width: 112,
quality: 64,
})}
/>
</div>
<div className="min-w-0 flex-1">
@ -3895,8 +3924,9 @@ export function AgentPsdTemplateEditorDialog({
>
<TemplatePreviewImage
className="h-full w-full object-contain"
fetchPriority="high"
loading="eager"
quality={78}
quality={68}
template={selectedTemplate}
width={440}
/>
@ -3941,7 +3971,18 @@ export function AgentPsdTemplateEditorDialog({
>
{selectedTemplate ? (
selectedTemplateLayers.length === 0 ? (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/50 shadow-inner">
<div className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/50 shadow-inner">
<TemplatePreviewImage
alt={`${selectedTemplate.name} 快速预览`}
className="absolute inset-0 h-full w-full object-contain opacity-35 blur-[1px]"
fetchPriority="high"
loading="eager"
quality={58}
template={selectedTemplate}
width={720}
/>
<div className="absolute inset-0 bg-[#141416]/72" />
<div className="relative z-10 flex flex-col items-center">
{selectedTemplateDetailError ? (
<>
<div className="font-medium text-white/80">
@ -3962,6 +4003,7 @@ export function AgentPsdTemplateEditorDialog({
</div>
</>
)}
</div>
</div>
) : (
<div
@ -3985,11 +4027,12 @@ export function AgentPsdTemplateEditorDialog({
<TemplatePreviewImage
alt={`${selectedTemplate.name} 编辑底图`}
className="absolute inset-0 h-full w-full object-cover"
fetchPriority="high"
loading="eager"
preferRenderBase
quality={80}
quality={68}
template={selectedTemplate}
width={1280}
width={960}
/>
) : 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({
<img
alt={layer.layerName}
className="h-full w-full object-fill"
src={layer.sourceUrl}
decoding="async"
src={buildPsdLayerPreviewUrl(layer.sourceUrl, {
width: layer.width,
quality: 68,
})}
/>
</div>
);
@ -4421,7 +4474,11 @@ export function AgentPsdTemplateEditorDialog({
<img
alt={layer.layerName}
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
src={layer.sourceUrl}
decoding="async"
src={buildPsdLayerPreviewUrl(layer.sourceUrl, {
width: layer.width,
quality: 68,
})}
/>
) : null}
{preserveSourceAppearance ? null : (

View File

@ -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<HTMLImageElement, Event>,
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<typeof getAgentWorkflowProgress>;
@ -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 (
<button
@ -289,14 +327,21 @@ const StepOptionCard = memo(function StepOptionCard({
>
{/* 背景 */}
<div className="absolute inset-0 bg-zinc-900">
{option.previewImageUrl ? (
{previewUrl ? (
<>
<img
src={option.previewImageUrl}
src={previewUrl}
alt={option.buttonName}
loading={selected || index < 4 ? "eager" : "lazy"}
width={WORKFLOW_OPTION_PREVIEW_WIDTH}
height={Math.round(WORKFLOW_OPTION_PREVIEW_WIDTH * 0.625)}
loading={shouldLoadFast ? "eager" : "lazy"}
decoding="async"
fetchPriority={shouldLoadFast ? "high" : "low"}
draggable={false}
sizes="(min-width: 1536px) 18vw, (min-width: 1280px) 22vw, (min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw"
onError={(event) =>
handleWorkflowOptionPreviewError(event, option.previewImageUrl)
}
className={cn(
"h-full w-full object-cover transition-transform duration-700",
!disabled && "group-hover:scale-110"
@ -406,7 +451,7 @@ const VirtualizedOptionGrid = memo(function VirtualizedOptionGrid({
count: rowCount,
getScrollElement: () => scrollParentRef.current,
estimateSize: () => rowHeight,
overscan: 3,
overscan: 1,
});
const renderCard = (option: WorkflowOption, optionIndex: number) => {
@ -1080,6 +1125,19 @@ function AgentWorkflowPanelDarkV2Component({
activeEmbeddedOptionLevels[activeEmbeddedOptionLevels.length - 1] ||
activeEmbeddedOptionLevels[0] ||
null;
const workflowOptionPreviewPrefetchUrls = useMemo(() => {
const options = embedded
? activeEmbeddedOptionLevel?.options || []
: flattenedResolvedSteps[0]?.step.options || [];
return Array.from(
new Set(
options
.slice(0, WORKFLOW_OPTION_PREVIEW_PREFETCH_COUNT)
.map((option) => buildWorkflowOptionPreviewUrl(option.previewImageUrl))
.filter(Boolean)
)
);
}, [activeEmbeddedOptionLevel?.options, embedded, flattenedResolvedSteps]);
const activeEmbeddedDeepestSelectedOption = useMemo(
() =>
[...activeEmbeddedOptionLevels]
@ -1174,6 +1232,33 @@ function AgentWorkflowPanelDarkV2Component({
embedded,
]);
useEffect(() => {
if (
typeof window === "undefined" ||
workflowOptionPreviewPrefetchUrls.length === 0
) {
return;
}
const cleanupRef = { current: () => {} };
cleanupRef.current = scheduleWorkflowIdleTask(() => {
const images = workflowOptionPreviewPrefetchUrls.map((src) => {
const image = new Image();
image.decoding = "async";
image.src = src;
return image;
});
cleanupRef.current = () => {
images.forEach((image) => {
image.src = "";
});
};
}, 120);
return () => cleanupRef.current();
}, [workflowOptionPreviewPrefetchUrls]);
useEffect(() => {
if (!embedded || !activeEmbeddedStepEntry) {
return;