664 lines
22 KiB
TypeScript
664 lines
22 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
||
import { auth } from "@/app/(auth)/auth";
|
||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||
import { createLogger } from "@/lib/observability/logger";
|
||
import { uploadFile } from "@/lib/storage/service";
|
||
|
||
export const runtime = "nodejs";
|
||
export const maxDuration = 300;
|
||
|
||
type StitchReferenceInput = {
|
||
id: string;
|
||
label?: string | null;
|
||
imageUrl: string;
|
||
previewUrl?: string | null;
|
||
width?: number | null;
|
||
height?: number | null;
|
||
candidateId?: string | null;
|
||
sortOrder?: number | null;
|
||
passthrough?: boolean | null;
|
||
assignedProductIndex?: number | null;
|
||
};
|
||
|
||
type StitchSourceSegment = {
|
||
card: StitchReferenceInput;
|
||
top: number;
|
||
height: number;
|
||
sourceWidth?: number;
|
||
sourceHeight?: number;
|
||
};
|
||
|
||
const MAX_REFERENCE_COUNT = 120;
|
||
const FETCH_TIMEOUT_MS = 20_000;
|
||
const FETCH_MAX_ATTEMPTS = 3;
|
||
const FETCH_RETRY_BASE_DELAY_MS = 350;
|
||
const FETCH_CONCURRENCY = 12;
|
||
const SLICE_UPLOAD_CONCURRENCY = 4;
|
||
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;
|
||
const logger = createLogger("detail-stitch");
|
||
|
||
class ImageFetchError extends Error {
|
||
retryable: boolean;
|
||
|
||
constructor(message: string, retryable = false) {
|
||
super(message);
|
||
this.name = "ImageFetchError";
|
||
this.retryable = retryable;
|
||
}
|
||
}
|
||
|
||
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||
return buffer.buffer.slice(
|
||
buffer.byteOffset,
|
||
buffer.byteOffset + buffer.byteLength,
|
||
) as ArrayBuffer;
|
||
}
|
||
|
||
function sleep(ms: number) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
function getErrorMessage(error: unknown) {
|
||
return error instanceof Error ? error.message : String(error || "");
|
||
}
|
||
|
||
function isTimeoutLikeError(error: unknown) {
|
||
if (!(error instanceof Error)) return false;
|
||
return (
|
||
error.name === "TimeoutError" ||
|
||
error.name === "AbortError" ||
|
||
/timeout|aborted|operation was aborted/i.test(error.message)
|
||
);
|
||
}
|
||
|
||
function isRetryableFetchError(error: unknown) {
|
||
if (error instanceof ImageFetchError) {
|
||
return error.retryable;
|
||
}
|
||
return isTimeoutLikeError(error);
|
||
}
|
||
|
||
function toPublicErrorMessage(error: unknown) {
|
||
const message = getErrorMessage(error);
|
||
if (isTimeoutLikeError(error) || /timeout|aborted|operation was aborted/i.test(message)) {
|
||
return "切割图片下载超时,通常是源图过大、源站响应慢或网络抖动导致。请稍后重试,或减少本次切割图片数量后再应用。";
|
||
}
|
||
return message || "拼合切割失败";
|
||
}
|
||
|
||
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();
|
||
const host = forwardedHost || (request.headers.get("host") || "").split(",")[0]?.trim();
|
||
if (host) {
|
||
const requestUrl = new URL(request.url);
|
||
const isLocalHost =
|
||
host.startsWith("localhost") ||
|
||
host.startsWith("127.0.0.1") ||
|
||
host.startsWith("[::1]");
|
||
const proto = forwardedProto || (isLocalHost ? requestUrl.protocol.replace(/:$/, "") : "https");
|
||
return `${proto}://${host}`;
|
||
}
|
||
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;
|
||
const previewUrl = resolveHttpUrl(raw.previewUrl, origin);
|
||
const width = Number(raw.width);
|
||
const height = Number(raw.height);
|
||
return {
|
||
id: String(raw.id || `reference-${index + 1}`).trim() || `reference-${index + 1}`,
|
||
label: String(raw.label || "").trim() || `参考图 ${index + 1}`,
|
||
imageUrl,
|
||
previewUrl: previewUrl || null,
|
||
width: Number.isFinite(width) && width > 0 ? Math.round(width) : null,
|
||
height: Number.isFinite(height) && height > 0 ? Math.round(height) : null,
|
||
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: "jpeg",
|
||
});
|
||
}
|
||
|
||
async function fetchImageBuffer(url: string, requestOrigin: string, cookie: string): Promise<Buffer> {
|
||
let lastError: unknown = null;
|
||
|
||
for (let attempt = 1; attempt <= FETCH_MAX_ATTEMPTS; attempt += 1) {
|
||
try {
|
||
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 ImageFetchError(
|
||
`图片下载失败:HTTP ${response.status}`,
|
||
response.status === 408 || response.status === 429 || response.status >= 500,
|
||
);
|
||
}
|
||
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 ImageFetchError("返回内容不是图片");
|
||
}
|
||
const buffer = Buffer.from(await response.arrayBuffer());
|
||
if (buffer.length === 0) {
|
||
throw new ImageFetchError("图片内容为空");
|
||
}
|
||
return buffer;
|
||
} catch (error) {
|
||
lastError = error;
|
||
if (attempt >= FETCH_MAX_ATTEMPTS || !isRetryableFetchError(error)) {
|
||
break;
|
||
}
|
||
logger.warn("image_fetch_retry", {
|
||
attempt,
|
||
maxAttempts: FETCH_MAX_ATTEMPTS,
|
||
timeoutMs: FETCH_TIMEOUT_MS,
|
||
reason: getErrorMessage(error),
|
||
});
|
||
await sleep(FETCH_RETRY_BASE_DELAY_MS * attempt);
|
||
}
|
||
}
|
||
|
||
throw new ImageFetchError(toPublicErrorMessage(lastError), false);
|
||
}
|
||
|
||
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;
|
||
}) {
|
||
let sharp: ((input: Buffer, options?: import("sharp").SharpOptions) => import("sharp").Sharp) | null = null;
|
||
const fetched = await runBoundedParallel(
|
||
params.references,
|
||
FETCH_CONCURRENCY,
|
||
async (card, index) => {
|
||
try {
|
||
if (card.width && card.height) {
|
||
return {
|
||
ok: true as const,
|
||
card,
|
||
index,
|
||
width: card.width,
|
||
height: card.height,
|
||
};
|
||
}
|
||
const buffer = await fetchImageBuffer(
|
||
buildReferenceFetchUrl(card.imageUrl),
|
||
params.requestOrigin,
|
||
params.cookie,
|
||
);
|
||
sharp = sharp || (await import("sharp")).default;
|
||
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: {
|
||
...card,
|
||
width,
|
||
height,
|
||
},
|
||
index,
|
||
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[] = [];
|
||
|
||
loaded.forEach((item, index) => {
|
||
const height = Math.max(1, Math.round((previewWidth / Math.max(item.width, 1)) * item.height));
|
||
const currentTop = top;
|
||
top += height;
|
||
if (index < loaded.length - 1) {
|
||
defaultCutLines.push(top);
|
||
}
|
||
sourceSegments.push({
|
||
card: item.card,
|
||
top: currentTop,
|
||
height,
|
||
sourceWidth: item.width,
|
||
sourceHeight: item.height,
|
||
});
|
||
});
|
||
|
||
const previewHeight = Math.max(1, top);
|
||
|
||
return {
|
||
previewUrl: "",
|
||
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 preparedSourceCache = new Map<string, Promise<Buffer>>();
|
||
|
||
const getPreparedSourceBuffer = (source: StitchSourceSegment) => {
|
||
const sourceUrl = source.card.imageUrl;
|
||
const key = `${sourceUrl}|${params.width}|${source.height}`;
|
||
const cached = preparedSourceCache.get(key);
|
||
if (cached) return cached;
|
||
|
||
const prepared = (async () => {
|
||
const buffer = await fetchImageBuffer(
|
||
buildReferenceFetchUrl(sourceUrl),
|
||
params.requestOrigin,
|
||
params.cookie,
|
||
);
|
||
return sharp(buffer, { failOn: "none" })
|
||
.rotate()
|
||
.resize(params.width, source.height, {
|
||
fit: "contain",
|
||
position: "centre",
|
||
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
||
withoutEnlargement: false,
|
||
})
|
||
.jpeg({
|
||
quality: 88,
|
||
mozjpeg: true,
|
||
progressive: true,
|
||
})
|
||
.toBuffer();
|
||
})();
|
||
preparedSourceCache.set(key, prepared);
|
||
return prepared;
|
||
};
|
||
|
||
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 segmentTop = Math.max(0, Math.round(segment.top));
|
||
const segmentHeight = Math.max(
|
||
1,
|
||
Math.min(params.height - segmentTop, Math.round(segment.height)),
|
||
);
|
||
const segmentBottom = segmentTop + segmentHeight;
|
||
const overlappingSources = params.sourceSegments.filter((source) => {
|
||
const sourceTop = Math.round(source.top);
|
||
const sourceBottom = sourceTop + Math.round(source.height);
|
||
return sourceTop < segmentBottom && sourceBottom > segmentTop;
|
||
});
|
||
|
||
if (overlappingSources.length === 0) {
|
||
throw new Error("当前切片没有匹配到原始参考图");
|
||
}
|
||
|
||
const compositeItems = await Promise.all(
|
||
overlappingSources.map(async (source) => {
|
||
const sourceTop = Math.round(source.top);
|
||
const sourceHeight = Math.max(1, Math.round(source.height));
|
||
const overlapTop = Math.max(segmentTop, sourceTop);
|
||
const overlapBottom = Math.min(segmentBottom, sourceTop + sourceHeight);
|
||
const cropTop = Math.max(0, Math.min(sourceHeight - 1, overlapTop - sourceTop));
|
||
const cropHeight = Math.max(
|
||
1,
|
||
Math.min(sourceHeight - cropTop, Math.round(overlapBottom - overlapTop)),
|
||
);
|
||
const sourceBuffer = await getPreparedSourceBuffer(source);
|
||
const input = await sharp(sourceBuffer, { failOn: "none" })
|
||
.extract({
|
||
left: 0,
|
||
top: cropTop,
|
||
width: params.width,
|
||
height: cropHeight,
|
||
})
|
||
.jpeg({
|
||
quality: 88,
|
||
mozjpeg: true,
|
||
progressive: true,
|
||
})
|
||
.toBuffer();
|
||
|
||
return {
|
||
input,
|
||
top: Math.max(0, Math.round(overlapTop - segmentTop)),
|
||
left: 0,
|
||
};
|
||
}),
|
||
);
|
||
|
||
const sliceBuffer = await sharp({
|
||
create: {
|
||
width: params.width,
|
||
height: segmentHeight,
|
||
channels: 3,
|
||
background: "#ffffff",
|
||
},
|
||
})
|
||
.composite(compositeItems)
|
||
.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,
|
||
previewUrl: upload.url,
|
||
width: params.width,
|
||
height: segmentHeight,
|
||
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) {
|
||
const startedAt = Date.now();
|
||
let mode = "unknown";
|
||
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>;
|
||
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 });
|
||
logger.info("prepare_completed", {
|
||
referenceCount: references.length,
|
||
sourceSegmentCount: data.sourceSegments.length,
|
||
failedCount: data.failed.length,
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
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 previewUrl = resolveHttpUrl(rawCard.previewUrl, requestOrigin);
|
||
const top = Math.round(Number(raw.top));
|
||
const segmentHeight = Math.round(Number(raw.height));
|
||
const sourceWidth = Math.round(Number(raw.sourceWidth));
|
||
const sourceHeight = Math.round(Number(raw.sourceHeight));
|
||
const cardWidth = Math.round(Number(rawCard.width));
|
||
const cardHeight = Math.round(Number(rawCard.height));
|
||
if (!imageUrl || !Number.isFinite(top) || !Number.isFinite(segmentHeight)) {
|
||
return null;
|
||
}
|
||
return {
|
||
top,
|
||
height: Math.max(1, segmentHeight),
|
||
sourceWidth: Number.isFinite(sourceWidth) && sourceWidth > 0 ? sourceWidth : undefined,
|
||
sourceHeight: Number.isFinite(sourceHeight) && sourceHeight > 0 ? sourceHeight : undefined,
|
||
card: {
|
||
id: String(rawCard.id || "").trim() || `source-${top}`,
|
||
label: String(rawCard.label || "").trim() || "参考图",
|
||
imageUrl,
|
||
previewUrl: previewUrl || null,
|
||
width: Number.isFinite(cardWidth) && cardWidth > 0 ? cardWidth : null,
|
||
height: Number.isFinite(cardHeight) && cardHeight > 0 ? cardHeight : null,
|
||
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 (!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,
|
||
});
|
||
logger.info("apply_completed", {
|
||
sourceSegmentCount: sourceSegments.length,
|
||
cutLineCount: normalizeCutLines(body.cutLines, height).length,
|
||
outputCount: data.cards.length,
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
return NextResponse.json({ success: true, data });
|
||
}
|
||
|
||
return NextResponse.json({ success: false, error: "未知操作" }, { status: 400 });
|
||
} catch (error) {
|
||
logger.error("request_failed", error, {
|
||
mode,
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: toPublicErrorMessage(error),
|
||
},
|
||
{ status: 500 },
|
||
);
|
||
}
|
||
}
|