374 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|