Optimize agent image stitching and previews
This commit is contained in:
parent
95572bd476
commit
25d331fa51
501
app/api/agent-workflow/detail-stitch/route.ts
Normal file
501
app/api/agent-workflow/detail-stitch/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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 : (
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user