576 lines
19 KiB
TypeScript
576 lines
19 KiB
TypeScript
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<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> {
|
|
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<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: "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<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 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 },
|
|
);
|
|
}
|
|
}
|