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; 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 = 10000; const FETCH_CONCURRENCY = 32; const SLICE_UPLOAD_CONCURRENCY = 10; const PREVIEW_MAX_WIDTH = 720; const PREVIEW_MIN_WIDTH = 96; const PREVIEW_MAX_HEIGHT = 32000; const PREVIEW_MAX_PIXELS = 36_000_000; const SEGMENT_MATCH_TOLERANCE = 2; function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { return buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength, ) as ArrayBuffer; } async function runBoundedParallel( items: T[], workerCount: number, worker: (item: T, index: number) => Promise, ): Promise { const results = new Array(items.length); const maxWorkers = Math.max(1, Math.min(workerCount, items.length)); let cursor = 0; await Promise.all( Array.from({ length: maxWorkers }, async () => { while (cursor < items.length) { const index = cursor; cursor += 1; results[index] = await worker(items[index]!, index); } }), ); return results; } function getRequestOrigin(request: NextRequest): string { const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim(); const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim(); 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; 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 = { 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 { 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; }) { 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>(); 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: "fill", 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) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const body = (await request.json().catch(() => ({}))) as Record; const mode = String(body.mode || "").trim(); const requestOrigin = getRequestOrigin(request); const cookie = request.headers.get("cookie") || ""; if (mode === "prepare") { const references = sanitizeReferenceInput(body.references, requestOrigin); if (references.length === 0) { return NextResponse.json({ success: false, error: "没有可拼合的参考图" }, { status: 400 }); } const data = await prepareStitch({ references, requestOrigin, cookie }); return NextResponse.json({ success: true, data }); } if (mode === "apply") { const previewUrl = resolveHttpUrl(body.previewUrl, requestOrigin); const width = Math.round(Number(body.width)); const height = Math.round(Number(body.height)); const rawSegments = Array.isArray(body.sourceSegments) ? body.sourceSegments : []; const sourceSegments = rawSegments .map((item): StitchSourceSegment | null => { const raw = (item || {}) as Record; const rawCard = (raw.card || {}) as Record; const imageUrl = resolveHttpUrl(rawCard.imageUrl, requestOrigin); const 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, }); 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 }, ); } }