Your Name e84bfd94ec
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: split PSD parsing into fast parse and async backfill
2026-04-10 21:22:36 +08:00

374 lines
11 KiB
TypeScript

import { NextResponse } from "next/server";
import { upsertSystemSetting } from "@/lib/db/queries";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import {
AGENT_PSD_TEMPLATES_KEY,
type AgentPsdTemplate,
type AgentPsdTemplateLayerSlot,
type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config";
import {
type ParsedPsdTemplateLayer,
parsePsdTemplateFile,
} from "@/lib/agents/psd-template-parser";
import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection";
import { schedulePsdTemplateAssetBackfill } from "@/lib/agents/psd-template-asset-backfill";
import {
cacheAgentPsdSourceLocally,
hasAgentPsdLocalCache,
readAgentPsdSourceFromLocalCache,
} from "@/lib/agents/psd-local-cache";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
function jsonError(status: number, error: string) {
const payload: AgentPsdTemplateMutationResponse = {
success: false,
error,
};
return NextResponse.json(payload, { status });
}
function toArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
function getErrorMessage(error: unknown) {
if (error instanceof Error && error.message.trim()) {
return error.message.trim();
}
return "PSD 解析失败";
}
function pickPrimaryImageLayerId(params: {
parsedLayers: Awaited<ReturnType<typeof parsePsdTemplateFile>>["layers"];
canvasWidth: number;
canvasHeight: number;
}) {
const { parsedLayers, canvasWidth, canvasHeight } = params;
return pickPrimaryPsdImageLayerId({
imageLayers: parsedLayers.filter((layer) => layer.slotType === "image"),
canvasWidth,
canvasHeight,
});
}
function shouldAutoEditableTextLayer(
layer: ParsedPsdTemplateLayer
) {
const text = String(layer.defaultText || "").replace(/\s+/g, "");
if (!text) {
return false;
}
return (
layer.width >= 80 ||
layer.height >= 30 ||
text.length >= 4 ||
/[A-Za-z0-9]{3,}/.test(text)
);
}
function buildInitialTemplateLayers(params: {
parsedLayers: Awaited<ReturnType<typeof parsePsdTemplateFile>>["layers"];
fonts: Awaited<ReturnType<typeof readAgentPsdFonts>>;
canvasWidth: number;
canvasHeight: number;
}) {
const { parsedLayers, fonts, canvasWidth, canvasHeight } = params;
const primaryImageLayerId = pickPrimaryImageLayerId({
parsedLayers,
canvasWidth,
canvasHeight,
});
return parsedLayers.map((layer): AgentPsdTemplateLayerSlot => {
if (layer.slotType === "text") {
const matchedFont = findBestMatchingPsdFont({
detectedFontNames: layer.detectedFontNames || [],
fonts,
});
const autoBoundFont = matchedFont?.confidence === "high" ? matchedFont.font : undefined;
const isEditable = shouldAutoEditableTextLayer(layer);
return {
...layer,
slotType: "text",
styleSource: "auto",
isEditable,
isRequired: false,
placeholder: layer.defaultText || layer.layerName,
detectedFontName: layer.detectedFontName,
detectedFontNames: layer.detectedFontNames,
fontId: autoBoundFont?.id,
fontFamily:
autoBoundFont?.family ||
layer.detectedFontNames?.[0] ||
layer.detectedFontName,
fontLabel:
autoBoundFont?.name ||
layer.detectedFontNames?.[0] ||
layer.detectedFontName,
fontFileUrl: autoBoundFont?.fileUrl,
fontContentType: autoBoundFont?.contentType,
fontSize: Math.max(
10,
Math.round(
Number(
layer.detectedFontSize ||
Math.max(16, Math.min(layer.height, Math.round(layer.height * 0.78)))
)
)
),
lineHeight:
Number.isFinite(Number(layer.detectedLineHeight)) && Number(layer.detectedLineHeight) > 0
? Number(layer.detectedLineHeight)
: 1.2,
letterSpacing: Number.isFinite(Number(layer.detectedLetterSpacing))
? Number(layer.detectedLetterSpacing)
: 0,
fontWeight: 500,
textColor: layer.detectedTextColor || "#111111",
textAlign: "left",
paddingX: 0,
paddingY: 0,
maxLines: 4,
};
}
const isPrimaryImageLayer = layer.id === primaryImageLayerId;
return {
...layer,
slotType: "image",
isEditable: isPrimaryImageLayer,
isRequired: false,
isDefaultResultSource: isPrimaryImageLayer,
allowUserUploadOverride: isPrimaryImageLayer,
replaceMode: "cover",
};
});
}
async function updateTemplate(
templateId: string,
updatedBy: string,
updater: (template: AgentPsdTemplate) => AgentPsdTemplate
) {
const templates = await readAgentPsdTemplates();
const currentTemplate = templates.find((entry) => entry.id === templateId);
if (!currentTemplate) {
return null;
}
const nextTemplate = updater(currentTemplate);
await upsertSystemSetting({
key: AGENT_PSD_TEMPLATES_KEY,
value: templates.map((entry) => (entry.id === templateId ? nextTemplate : entry)),
updatedBy,
});
return nextTemplate;
}
async function updateParseProgress(params: {
templateId: string;
updatedBy: string;
progress: number;
stage: string;
error?: string;
}) {
const { templateId, updatedBy, progress, stage, error } = params;
return updateTemplate(templateId, updatedBy, (template) => ({
...template,
parseStatus: "processing",
parseProgress: progress,
parseStage: stage,
parseError: error,
updatedAt: new Date().toISOString(),
}));
}
export async function POST(
_request: Request,
context: { params: Promise<{ id: string }> }
) {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session?.user?.id) {
return jsonError(401, "未登录");
}
const { id } = await context.params;
try {
const templates = await readAgentPsdTemplates();
const currentTemplate = templates.find((entry) => entry.id === id);
if (!currentTemplate) {
return jsonError(404, "未找到 PSD 模板");
}
if (currentTemplate.parseStatus === "processing") {
return jsonError(409, "该模板正在解析中,请稍后刷新");
}
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 5,
stage: "准备解析 PSD",
error: undefined,
});
let fileBuffer: ArrayBuffer;
if (
await hasAgentPsdLocalCache({
templateId: id,
fileName: currentTemplate.fileName,
})
) {
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 18,
stage: "正在读取服务器本地 PSD 缓存",
error: undefined,
});
const localBuffer = await readAgentPsdSourceFromLocalCache({
templateId: id,
fileName: currentTemplate.fileName,
});
fileBuffer = localBuffer.buffer.slice(
localBuffer.byteOffset,
localBuffer.byteOffset + localBuffer.byteLength
) as ArrayBuffer;
} else {
const response = await fetch(currentTemplate.fileUrl, {
cache: "no-store",
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) {
throw new Error(`PSD 文件下载失败 (${response.status})`);
}
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 18,
stage: "正在下载并写入服务器本地 PSD 缓存",
error: undefined,
});
fileBuffer = await response.arrayBuffer();
await cacheAgentPsdSourceLocally({
templateId: id,
fileName: currentTemplate.fileName,
buffer: fileBuffer,
}).catch((error) => {
console.warn("[PSD Templates][Admin] failed to cache PSD source locally:", {
templateId: id,
error,
});
});
}
const parsedTemplate = await parsePsdTemplateFile(fileBuffer);
if (parsedTemplate.layers.length === 0) {
throw new Error("PSD 中未识别到可配置图层");
}
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 42,
stage: "正在生成模板预览",
error: undefined,
});
const uploadedPreview = await uploadFile(
`agent-psd-preview-${id}-${Date.now()}.png`,
toArrayBuffer(parsedTemplate.previewBuffer)
);
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 58,
stage: "正在匹配字体与初始化图层",
error: undefined,
});
const fonts = await readAgentPsdFonts().catch(() => []);
const initialLayers = buildInitialTemplateLayers({
parsedLayers: parsedTemplate.layers,
fonts,
canvasWidth: parsedTemplate.width,
canvasHeight: parsedTemplate.height,
});
const updatedTemplate = await updateTemplate(id, session.user.id, (template) => ({
...template,
previewUrl: uploadedPreview.url,
previewPath: uploadedPreview.pathname,
width: parsedTemplate.width,
height: parsedTemplate.height,
layerCount: parsedTemplate.layers.length,
layers: initialLayers,
parseStatus: "processing",
parseProgress: 48,
parseStage: "快速解析完成,正在后台补全图层资源和模板底图",
parseError: undefined,
updatedAt: new Date().toISOString(),
}));
if (!updatedTemplate) {
return jsonError(404, "未找到 PSD 模板");
}
schedulePsdTemplateAssetBackfill({
templateId: id,
updatedBy: session.user.id,
});
return NextResponse.json({
success: true,
template: updatedTemplate,
} satisfies AgentPsdTemplateMutationResponse);
} catch (error) {
const message = getErrorMessage(error);
const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id);
if (existingTemplate) {
const fallbackStatus =
existingTemplate.parseStatus === "completed" && existingTemplate.layers.length > 0
? "completed"
: "failed";
await updateTemplate(id, session.user.id, (template) => ({
...template,
parseStatus: fallbackStatus,
parseProgress: fallbackStatus === "completed" ? 100 : 0,
parseStage:
fallbackStatus === "completed" ? "沿用上次成功解析结果" : undefined,
parseError:
fallbackStatus === "completed"
? `重新解析失败:${message}`
: message,
updatedAt: new Date().toISOString(),
})).catch(() => undefined);
}
console.error("[PSD Templates][Admin] parse failed:", error);
return jsonError(502, message);
}
}