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,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
|
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
|
||||||
|
|
||||||
const MERGED_IMAGE_MAX_WIDTH = 1600;
|
|
||||||
const MIN_SLICE_HEIGHT = 120;
|
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;
|
card: AgentDetailReplacementReferenceItem;
|
||||||
image: HTMLImageElement;
|
top: number;
|
||||||
width: number;
|
|
||||||
height: number;
|
height: number;
|
||||||
scaledHeight: number;
|
|
||||||
revokeObjectUrl?: (() => void) | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type MergedSnapshot = {
|
type MergedSnapshot = {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
previewUrl: string;
|
||||||
defaultCutLines: number[];
|
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) {
|
function clamp(value: number, min: number, max: number) {
|
||||||
return Math.max(min, Math.min(max, value));
|
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 {
|
interface AgentDetailReferenceStitchDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@ -258,9 +77,7 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
sourceLabel = "详情参考图",
|
sourceLabel = "详情参考图",
|
||||||
onApply,
|
onApply,
|
||||||
}: AgentDetailReferenceStitchDialogProps) {
|
}: AgentDetailReferenceStitchDialogProps) {
|
||||||
const previewCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
const previewContainerRef = useRef<HTMLDivElement | null>(null);
|
const previewContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mergedCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
const [snapshot, setSnapshot] = useState<MergedSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<MergedSnapshot | null>(null);
|
||||||
const [cutLines, setCutLines] = useState<number[]>([]);
|
const [cutLines, setCutLines] = useState<number[]>([]);
|
||||||
const [draggingLineIndex, setDraggingLineIndex] = useState<number | null>(null);
|
const [draggingLineIndex, setDraggingLineIndex] = useState<number | null>(null);
|
||||||
@ -319,27 +136,6 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
[passthroughSegmentIndexes]
|
[passthroughSegmentIndexes]
|
||||||
);
|
);
|
||||||
|
|
||||||
const redrawPreviewCanvas = useCallback(() => {
|
|
||||||
const previewCanvas = previewCanvasRef.current;
|
|
||||||
const mergedCanvas = mergedCanvasRef.current;
|
|
||||||
if (!previewCanvas || !mergedCanvas || !snapshot) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
previewCanvas.width = snapshot.width;
|
|
||||||
previewCanvas.height = snapshot.height;
|
|
||||||
const context = previewCanvas.getContext("2d");
|
|
||||||
if (!context) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
context.clearRect(0, 0, snapshot.width, snapshot.height);
|
|
||||||
context.drawImage(mergedCanvas, 0, 0);
|
|
||||||
}, [snapshot]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
redrawPreviewCanvas();
|
|
||||||
}, [redrawPreviewCanvas]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setDraggingLineIndex(null);
|
setDraggingLineIndex(null);
|
||||||
@ -361,65 +157,64 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
let loadedImages: LoadedReferenceImage[] = [];
|
|
||||||
let snapshotCommitted = false;
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setErrorText(null);
|
setErrorText(null);
|
||||||
setWarningText(null);
|
setWarningText(null);
|
||||||
setLoadingProgressText(`正在加载参考图 0/${setupCards.length}`);
|
setLoadingProgressText(`正在服务端并行拼合 ${setupCards.length} 张参考图`);
|
||||||
|
|
||||||
const failedCards: string[] = [];
|
const response = await fetch("/api/agent-workflow/detail-stitch", {
|
||||||
let processedCount = 0;
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
const loadedResults: Array<LoadedReferenceImage | null> = new Array(setupCards.length).fill(null);
|
body: JSON.stringify({
|
||||||
await runIndexedParallel(
|
mode: "prepare",
|
||||||
setupCards,
|
references: setupCards.map((card, index) => ({
|
||||||
STITCH_IMAGE_LOAD_CONCURRENCY,
|
...card,
|
||||||
async (card, index) => {
|
sortOrder: card.sortOrder ?? index,
|
||||||
if (cancelled) return;
|
})),
|
||||||
try {
|
}),
|
||||||
const loaded = await loadImage(card.imageUrl);
|
});
|
||||||
loadedResults[index] = {
|
const result = (await response.json().catch(() => ({}))) as DetailStitchPrepareResponse;
|
||||||
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));
|
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
if (loadedImages.length === 0) {
|
if (!response.ok || !result.success || !result.data) {
|
||||||
throw new Error("当前参考图加载失败,请稍后重试");
|
throw new Error(result.error || "拼合参考图失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = buildMergedSnapshot(loadedImages);
|
const previewUrl = String(result.data.previewUrl || "").trim();
|
||||||
mergedCanvasRef.current = next.canvas;
|
const width = Math.round(Number(result.data.width));
|
||||||
setSnapshot(next.snapshot);
|
const height = Math.round(Number(result.data.height));
|
||||||
setCutLines(next.snapshot.defaultCutLines);
|
const sourceSegments = Array.isArray(result.data.sourceSegments)
|
||||||
snapshotCommitted = true;
|
? 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(
|
setWarningText(
|
||||||
failedCards.length > 0
|
(result.data.failed?.length || 0) > 0
|
||||||
? `有 ${failedCards.length} 张参考图加载失败,已自动跳过异常图片。`
|
? `有 ${result.data.failed?.length || 0} 张参考图加载失败,已自动跳过异常图片。`
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -429,9 +224,6 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
setCutLines([]);
|
setCutLines([]);
|
||||||
setErrorText(error instanceof Error ? error.message : "拼合参考图失败");
|
setErrorText(error instanceof Error ? error.message : "拼合参考图失败");
|
||||||
} finally {
|
} finally {
|
||||||
if (!snapshotCommitted) {
|
|
||||||
loadedImages.forEach((item) => item.revokeObjectUrl?.());
|
|
||||||
}
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setLoadingProgressText("");
|
setLoadingProgressText("");
|
||||||
@ -453,12 +245,6 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
);
|
);
|
||||||
}, [sliceSegments]);
|
}, [sliceSegments]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
snapshot?.loadedImages.forEach((item) => item.revokeObjectUrl?.());
|
|
||||||
};
|
|
||||||
}, [snapshot]);
|
|
||||||
|
|
||||||
const resolveInsertCutLineY = useCallback(
|
const resolveInsertCutLineY = useCallback(
|
||||||
(rawY: number) => {
|
(rawY: number) => {
|
||||||
if (!snapshot) return null;
|
if (!snapshot) return null;
|
||||||
@ -605,7 +391,7 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleApply = useCallback(async () => {
|
const handleApply = useCallback(async () => {
|
||||||
if (!snapshot || !mergedCanvasRef.current) {
|
if (!snapshot) {
|
||||||
toast.error("当前还没有可用的拼合图");
|
toast.error("当前还没有可用的拼合图");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -616,89 +402,42 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsApplying(true);
|
setIsApplying(true);
|
||||||
setApplyProgressText(`正在上传切片 0/${sliceSegments.length}`);
|
setApplyProgressText(`正在服务端切割 ${sliceSegments.length} 张`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextCards: Array<AgentDetailReplacementReferenceItem | null> = new Array(sliceSegments.length).fill(null);
|
const response = await fetch("/api/agent-workflow/detail-stitch", {
|
||||||
const sourceCanvas = mergedCanvasRef.current;
|
method: "POST",
|
||||||
const sourceCandidateId = validCards[0]?.candidateId || null;
|
headers: { "Content-Type": "application/json" },
|
||||||
const timestamp = Date.now();
|
body: JSON.stringify({
|
||||||
let completedCount = 0;
|
mode: "apply",
|
||||||
|
previewUrl: snapshot.previewUrl,
|
||||||
await runIndexedParallel(
|
width: snapshot.width,
|
||||||
sliceSegments,
|
height: snapshot.height,
|
||||||
STITCH_SLICE_UPLOAD_CONCURRENCY,
|
cutLines: normalizedCutLines,
|
||||||
async (segment, index) => {
|
sourceSegments: snapshot.sourceSegments,
|
||||||
const sliceCanvas = document.createElement("canvas");
|
passthroughSegmentIndexes,
|
||||||
sliceCanvas.width = snapshot.width;
|
}),
|
||||||
sliceCanvas.height = segment.height;
|
});
|
||||||
const context = sliceCanvas.getContext("2d");
|
const result = (await response.json().catch(() => ({}))) as DetailStitchApplyResponse;
|
||||||
if (!context) {
|
if (!response.ok || !result.success || !result.data?.cards?.length) {
|
||||||
throw new Error("切片画布初始化失败");
|
throw new Error(result.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 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);
|
onApply(orderedCards);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
toast.success(`已生成 ${orderedCards.length} 张切片参考图`);
|
toast.success(`已生成 ${orderedCards.length} 张切片参考图`);
|
||||||
@ -709,7 +448,15 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
setIsApplying(false);
|
setIsApplying(false);
|
||||||
setApplyProgressText("");
|
setApplyProgressText("");
|
||||||
}
|
}
|
||||||
}, [onApply, onOpenChange, passthroughSegmentIndexSet, sliceSegments, snapshot, validCards]);
|
}, [
|
||||||
|
normalizedCutLines,
|
||||||
|
onApply,
|
||||||
|
onOpenChange,
|
||||||
|
passthroughSegmentIndexes,
|
||||||
|
passthroughSegmentIndexSet,
|
||||||
|
sliceSegments.length,
|
||||||
|
snapshot,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@ -823,9 +570,14 @@ export function AgentDetailReferenceStitchDialog({
|
|||||||
onPointerLeave={handlePreviewPointerLeave}
|
onPointerLeave={handlePreviewPointerLeave}
|
||||||
onClick={handlePreviewClick}
|
onClick={handlePreviewClick}
|
||||||
>
|
>
|
||||||
<canvas
|
<img
|
||||||
ref={previewCanvasRef}
|
src={snapshot.previewUrl}
|
||||||
|
alt="拼合长图预览"
|
||||||
className="block h-auto w-full select-none"
|
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">
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
|||||||
@ -326,14 +326,37 @@ function buildTemplatePreviewThumbUrl(
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchParams = new URLSearchParams({
|
const width = Math.max(80, Math.round(params?.width || 160));
|
||||||
url: normalizedUrl,
|
const quality = Math.max(40, Math.round(params?.quality || 72));
|
||||||
w: String(Math.max(80, Math.round(params?.width || 160))),
|
const directOptimizedUrl = buildImageProxyUrl(normalizedUrl, {
|
||||||
q: String(Math.max(40, Math.round(params?.quality || 72))),
|
width,
|
||||||
f: "webp",
|
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(
|
function buildTemplatePreviewFallbackUrls(
|
||||||
@ -764,6 +787,7 @@ function renderTemplatePickerCardContent(
|
|||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
fetchPriority={eager ? "high" : "low"}
|
fetchPriority={eager ? "high" : "low"}
|
||||||
loading={eager ? "eager" : "lazy"}
|
loading={eager ? "eager" : "lazy"}
|
||||||
|
quality={66}
|
||||||
template={template}
|
template={template}
|
||||||
width={112}
|
width={112}
|
||||||
/>
|
/>
|
||||||
@ -3615,7 +3639,12 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
<img
|
<img
|
||||||
alt={image.title || `待处理图片 ${index + 1}`}
|
alt={image.title || `待处理图片 ${index + 1}`}
|
||||||
className="h-full w-full object-cover"
|
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>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@ -3895,8 +3924,9 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
>
|
>
|
||||||
<TemplatePreviewImage
|
<TemplatePreviewImage
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
|
fetchPriority="high"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
quality={78}
|
quality={68}
|
||||||
template={selectedTemplate}
|
template={selectedTemplate}
|
||||||
width={440}
|
width={440}
|
||||||
/>
|
/>
|
||||||
@ -3941,7 +3971,18 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
>
|
>
|
||||||
{selectedTemplate ? (
|
{selectedTemplate ? (
|
||||||
selectedTemplateLayers.length === 0 ? (
|
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 ? (
|
{selectedTemplateDetailError ? (
|
||||||
<>
|
<>
|
||||||
<div className="font-medium text-white/80">
|
<div className="font-medium text-white/80">
|
||||||
@ -3962,6 +4003,7 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@ -3985,11 +4027,12 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
<TemplatePreviewImage
|
<TemplatePreviewImage
|
||||||
alt={`${selectedTemplate.name} 编辑底图`}
|
alt={`${selectedTemplate.name} 编辑底图`}
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
fetchPriority="high"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
preferRenderBase
|
preferRenderBase
|
||||||
quality={80}
|
quality={68}
|
||||||
template={selectedTemplate}
|
template={selectedTemplate}
|
||||||
width={1280}
|
width={960}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -4031,7 +4074,13 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
className={
|
className={
|
||||||
assignedUrl ? "" : "h-full w-full"
|
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={
|
style={
|
||||||
assignedUrl && imageSizes[layer.id]
|
assignedUrl && imageSizes[layer.id]
|
||||||
? getPlacedAssignedImageStyle({
|
? getPlacedAssignedImageStyle({
|
||||||
@ -4092,7 +4141,11 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
<img
|
<img
|
||||||
alt={layer.layerName}
|
alt={layer.layerName}
|
||||||
className="h-full w-full object-fill"
|
className="h-full w-full object-fill"
|
||||||
src={layer.sourceUrl}
|
decoding="async"
|
||||||
|
src={buildPsdLayerPreviewUrl(layer.sourceUrl, {
|
||||||
|
width: layer.width,
|
||||||
|
quality: 68,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -4421,7 +4474,11 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
<img
|
<img
|
||||||
alt={layer.layerName}
|
alt={layer.layerName}
|
||||||
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
|
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}
|
) : null}
|
||||||
{preserveSourceAppearance ? null : (
|
{preserveSourceAppearance ? null : (
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
|
type SyntheticEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
@ -50,6 +51,7 @@ import {
|
|||||||
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
|
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
|
||||||
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
|
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
|
||||||
import { getAgentWorkflowProgress } from "@/lib/agents/workflow-composer";
|
import { getAgentWorkflowProgress } from "@/lib/agents/workflow-composer";
|
||||||
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import type {
|
import type {
|
||||||
AgentWorkflowMaterialSelections,
|
AgentWorkflowMaterialSelections,
|
||||||
AgentWorkflowShowcaseConfig,
|
AgentWorkflowShowcaseConfig,
|
||||||
@ -108,7 +110,38 @@ type WorkflowOption = WorkflowStep["options"][number];
|
|||||||
const VIRTUAL_OPTION_GRID_GAP = 16;
|
const VIRTUAL_OPTION_GRID_GAP = 16;
|
||||||
const VIRTUAL_OPTION_GRID_MAX_HEIGHT = 640;
|
const VIRTUAL_OPTION_GRID_MAX_HEIGHT = 640;
|
||||||
const VIRTUAL_OPTION_GRID_MIN_CARD_HEIGHT = 122;
|
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 {
|
interface WorkflowPanelDerivedState {
|
||||||
progress: ReturnType<typeof getAgentWorkflowProgress>;
|
progress: ReturnType<typeof getAgentWorkflowProgress>;
|
||||||
@ -269,6 +302,11 @@ const StepOptionCard = memo(function StepOptionCard({
|
|||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
onSelect(option.key);
|
onSelect(option.key);
|
||||||
}, [onSelect, option.key]);
|
}, [onSelect, option.key]);
|
||||||
|
const previewUrl = useMemo(
|
||||||
|
() => buildWorkflowOptionPreviewUrl(option.previewImageUrl),
|
||||||
|
[option.previewImageUrl]
|
||||||
|
);
|
||||||
|
const shouldLoadFast = selected || index < WORKFLOW_OPTION_PREVIEW_EAGER_COUNT;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -289,14 +327,21 @@ const StepOptionCard = memo(function StepOptionCard({
|
|||||||
>
|
>
|
||||||
{/* 背景 */}
|
{/* 背景 */}
|
||||||
<div className="absolute inset-0 bg-zinc-900">
|
<div className="absolute inset-0 bg-zinc-900">
|
||||||
{option.previewImageUrl ? (
|
{previewUrl ? (
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
src={option.previewImageUrl}
|
src={previewUrl}
|
||||||
alt={option.buttonName}
|
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"
|
decoding="async"
|
||||||
|
fetchPriority={shouldLoadFast ? "high" : "low"}
|
||||||
draggable={false}
|
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(
|
className={cn(
|
||||||
"h-full w-full object-cover transition-transform duration-700",
|
"h-full w-full object-cover transition-transform duration-700",
|
||||||
!disabled && "group-hover:scale-110"
|
!disabled && "group-hover:scale-110"
|
||||||
@ -406,7 +451,7 @@ const VirtualizedOptionGrid = memo(function VirtualizedOptionGrid({
|
|||||||
count: rowCount,
|
count: rowCount,
|
||||||
getScrollElement: () => scrollParentRef.current,
|
getScrollElement: () => scrollParentRef.current,
|
||||||
estimateSize: () => rowHeight,
|
estimateSize: () => rowHeight,
|
||||||
overscan: 3,
|
overscan: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderCard = (option: WorkflowOption, optionIndex: number) => {
|
const renderCard = (option: WorkflowOption, optionIndex: number) => {
|
||||||
@ -1080,6 +1125,19 @@ function AgentWorkflowPanelDarkV2Component({
|
|||||||
activeEmbeddedOptionLevels[activeEmbeddedOptionLevels.length - 1] ||
|
activeEmbeddedOptionLevels[activeEmbeddedOptionLevels.length - 1] ||
|
||||||
activeEmbeddedOptionLevels[0] ||
|
activeEmbeddedOptionLevels[0] ||
|
||||||
null;
|
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(
|
const activeEmbeddedDeepestSelectedOption = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...activeEmbeddedOptionLevels]
|
[...activeEmbeddedOptionLevels]
|
||||||
@ -1174,6 +1232,33 @@ function AgentWorkflowPanelDarkV2Component({
|
|||||||
embedded,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!embedded || !activeEmbeddedStepEntry) {
|
if (!embedded || !activeEmbeddedStepEntry) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user