Your Name 2b2b2bd228
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add in-canvas PSD template editing
2026-03-27 23:05:45 +08:00

402 lines
12 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,
preparePsdTemplateRenderAssets,
} from "@/lib/agents/psd-template-parser";
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 getCanvasOverlapArea(params: {
x: number;
y: number;
width: number;
height: number;
canvasWidth: number;
canvasHeight: number;
}) {
const left = Math.max(0, params.x);
const top = Math.max(0, params.y);
const right = Math.min(params.canvasWidth, params.x + params.width);
const bottom = Math.min(params.canvasHeight, params.y + params.height);
return Math.max(0, right - left) * Math.max(0, bottom - top);
}
function pickPrimaryImageLayerId(params: {
parsedLayers: Awaited<ReturnType<typeof parsePsdTemplateFile>>["layers"];
canvasWidth: number;
canvasHeight: number;
}) {
const { parsedLayers, canvasWidth, canvasHeight } = params;
const imageLayers = parsedLayers.filter((layer) => layer.slotType === "image");
if (imageLayers.length === 0) {
return null;
}
const backgroundPattern = /(背景|底图|bg|background)/i;
const scored = imageLayers.map((layer) => {
const overlapArea = getCanvasOverlapArea({
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
canvasWidth,
canvasHeight,
});
const layerArea = Math.max(1, layer.width * layer.height);
const coversWholeCanvas =
overlapArea >= canvasWidth * canvasHeight * 0.96 &&
layer.x <= 0 &&
layer.y <= 0 &&
layer.width >= canvasWidth * 0.96 &&
layer.height >= canvasHeight * 0.96;
const backgroundPenalty =
backgroundPattern.test(layer.layerName) || backgroundPattern.test(layer.layerPath) || coversWholeCanvas
? 0.45
: 1;
return {
layer,
score: Math.max(overlapArea, layerArea * 0.5) * backgroundPenalty,
};
});
scored.sort((left, right) => right.score - left.score);
return scored[0]?.layer.id || null;
}
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 isEditable = shouldAutoEditableTextLayer(layer);
return {
...layer,
slotType: "text",
isEditable,
isRequired: false,
placeholder: layer.defaultText || layer.layerName,
detectedFontName: layer.detectedFontName,
detectedFontNames: layer.detectedFontNames,
fontId: matchedFont?.font.id,
fontFamily: matchedFont?.font.family,
fontLabel: matchedFont?.font.name,
fontFileUrl: matchedFont?.font.fileUrl,
fontContentType: matchedFont?.font.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: "#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,
});
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,
});
const fileBuffer = await response.arrayBuffer();
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,
});
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 78,
stage: "正在生成图层预览和遮罩",
error: undefined,
});
const preparedAssets = await preparePsdTemplateRenderAssets({
templateId: id,
fileUrl: currentTemplate.fileUrl,
layers: initialLayers,
});
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 92,
stage: "正在生成模板底图",
error: undefined,
});
const uploadedRenderBase = await uploadFile(
`agent-psd-base-${id}-${Date.now()}.png`,
toArrayBuffer(preparedAssets.renderBaseBuffer)
);
const hasEditableLayers = preparedAssets.layers.some((layer) => layer.isEditable);
const nextStatus =
currentTemplate.status === "active" ||
(currentTemplate.layers.length === 0 && hasEditableLayers)
? "active"
: currentTemplate.status;
const updatedTemplate = await updateTemplate(id, session.user.id, (template) => ({
...template,
previewUrl: uploadedPreview.url,
previewPath: uploadedPreview.pathname,
renderBaseUrl: uploadedRenderBase.url,
renderBasePath: uploadedRenderBase.pathname,
width: parsedTemplate.width,
height: parsedTemplate.height,
layerCount: parsedTemplate.layers.length,
layers: preparedAssets.layers,
status: nextStatus,
parseStatus: "completed",
parseProgress: 100,
parseStage: "解析完成",
parseError: undefined,
updatedAt: new Date().toISOString(),
}));
if (!updatedTemplate) {
return jsonError(404, "未找到 PSD 模板");
}
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);
}
}