437 lines
13 KiB
TypeScript

import { NextResponse } from "next/server";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import {
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 {
AGENT_PSD_TEMPLATE_INCOMPATIBLE_PSB_MESSAGE,
AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE,
assertSupportedAgentPsdTemplateFile,
type AgentPsdTemplateFileInspection,
} from "@/lib/agents/psd-template-format";
import {
createAgentPsdTemplateParseHeartbeat,
updateAgentPsdTemplateById,
updateAgentPsdTemplateParseProgress,
} from "@/lib/agents/psd-template-progress";
import {
cacheAgentPsdSourceLocally,
hasAgentPsdLocalCache,
readAgentPsdSourceFromLocalCache,
} from "@/lib/agents/psd-local-cache";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { failTimedOutAgentPsdTemplates } from "@/lib/agents/psd-template-timeout";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 1800;
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 getPsdTemplateParseErrorMessage(params: {
error: unknown;
fileInspection?: AgentPsdTemplateFileInspection;
}) {
const message = getErrorMessage(params.error);
if (
params.fileInspection?.format === "psb" &&
/invalid signature/i.test(message)
) {
return AGENT_PSD_TEMPLATE_INCOMPATIBLE_PSB_MESSAGE;
}
return message;
}
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 updateParseProgress(params: {
templateId: string;
updatedBy: string;
progress: number;
stage: string;
error?: string;
restart?: boolean;
}) {
return updateAgentPsdTemplateParseProgress(params);
}
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;
let fileInspection: AgentPsdTemplateFileInspection | undefined;
let latestProgress = {
progress: 0,
stage: "准备解析 PSD",
error: undefined as string | undefined,
};
try {
const { templates, timedOutTemplates } = await failTimedOutAgentPsdTemplates({
updatedBy: session.user.id,
});
if (timedOutTemplates.length > 0) {
console.warn("[PSD Templates][Admin] cleared timed out parsing templates before parse:", {
requestedTemplateId: id,
count: timedOutTemplates.length,
templateIds: timedOutTemplates.map((template) => template.id),
});
}
const currentTemplate = templates.find((entry) => entry.id === id);
if (!currentTemplate) {
return jsonError(404, "未找到 PSD 模板");
}
if (currentTemplate.parseStatus === "processing") {
return jsonError(409, "该模板正在解析中,请稍后刷新");
}
latestProgress = {
progress: 5,
stage: "准备解析 PSD",
error: undefined,
};
const setParseProgress = async (progress: number, stage: string, error?: string) => {
latestProgress = {
progress,
stage,
error,
};
return updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress,
stage,
error,
});
};
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
...latestProgress,
error: undefined,
restart: true,
});
const heartbeat = createAgentPsdTemplateParseHeartbeat({
templateId: id,
updatedBy: session.user.id,
getSnapshot: () => latestProgress,
});
try {
let fileBuffer: ArrayBuffer;
if (
await hasAgentPsdLocalCache({
templateId: id,
fileName: currentTemplate.fileName,
})
) {
await setParseProgress(18, "正在读取服务器本地 PSD 缓存");
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 setParseProgress(18, "正在下载并写入服务器本地 PSD 缓存");
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,
});
});
}
await setParseProgress(22, "正在校验 PSD 文件格式");
fileInspection = assertSupportedAgentPsdTemplateFile(fileBuffer);
await setParseProgress(24, "正在解析 PSD 图层结构与预览");
const parsedTemplate = await parsePsdTemplateFile(fileBuffer, {
onProgress: async (event) => {
await setParseProgress(event.progress, event.stage);
},
});
if (parsedTemplate.layers.length === 0) {
throw new Error("PSD 中未识别到可配置图层");
}
await setParseProgress(42, "正在上传模板预览");
const uploadedPreview = await uploadFile(
`agent-psd-preview-${id}-${Date.now()}.png`,
toArrayBuffer(parsedTemplate.previewBuffer)
);
await setParseProgress(58, "正在匹配字体与初始化图层");
const fonts = await readAgentPsdFonts().catch(() => []);
const initialLayers = buildInitialTemplateLayers({
parsedLayers: parsedTemplate.layers,
fonts,
canvasWidth: parsedTemplate.width,
canvasHeight: parsedTemplate.height,
});
heartbeat.stop();
const updatedTemplate = await updateAgentPsdTemplateById({
templateId: id,
updatedBy: session.user.id,
updater: (template) => {
const now = new Date().toISOString();
return {
...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,
parseHeartbeatAt: now,
updatedAt: now,
};
},
});
if (!updatedTemplate) {
return jsonError(404, "未找到 PSD 模板");
}
schedulePsdTemplateAssetBackfill({
templateId: id,
updatedBy: session.user.id,
});
return NextResponse.json({
success: true,
template: updatedTemplate,
} satisfies AgentPsdTemplateMutationResponse);
} catch (error) {
heartbeat.stop();
throw error;
}
} catch (error) {
const message = getPsdTemplateParseErrorMessage({
error,
fileInspection,
});
const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id);
if (existingTemplate) {
const hasUsablePreviousParse =
existingTemplate.layers.length > 0 &&
existingTemplate.width > 1 &&
existingTemplate.height > 1 &&
Boolean(existingTemplate.previewUrl);
const fallbackStatus = hasUsablePreviousParse ? "completed" : "failed";
await updateAgentPsdTemplateById({
templateId: id,
updatedBy: session.user.id,
updater: (template) => ({
...template,
parseStatus: fallbackStatus,
parseProgress:
fallbackStatus === "completed"
? 100
: Math.max(0, Math.round(Number(latestProgress.progress || 0))),
parseStage:
fallbackStatus === "completed"
? "沿用上次成功解析结果"
: latestProgress.stage || "PSD 解析失败",
parseError:
fallbackStatus === "completed"
? `重新解析失败:${message}`
: message,
updatedAt: new Date().toISOString(),
}),
}).catch(() => undefined);
}
console.error("[PSD Templates][Admin] parse failed:", {
templateId: id,
fileName: existingTemplate?.fileName,
progress: latestProgress.progress,
stage: latestProgress.stage,
fileFormat: fileInspection?.format,
error,
});
return jsonError(
message === AGENT_PSD_TEMPLATE_INCOMPATIBLE_PSB_MESSAGE ||
message === AGENT_PSD_TEMPLATE_INVALID_FORMAT_MESSAGE
? 400
: 502,
message
);
}
}