437 lines
13 KiB
TypeScript
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
|
|
);
|
|
}
|
|
}
|