Your Name 5387d6f1d2
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add style pro canvas workspace
2026-05-16 15:06:12 +08:00

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 },
);
}
}