feat: split PSD parsing into fast parse and async backfill
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-10 21:22:22 +08:00
parent 8c8b1794e4
commit e84bfd94ec
3 changed files with 317 additions and 68 deletions

View File

@ -182,16 +182,25 @@ function countEditableTextLayers(template: AgentPsdTemplate | null) {
);
}
function hasTemplateParsedCanvas(template: AgentPsdTemplate | null) {
return Boolean(
template &&
Number(template.width || 0) > 1 &&
Number(template.height || 0) > 1 &&
String(template.previewUrl || "").trim()
);
}
function getTemplateParseLabel(template: AgentPsdTemplate | null) {
if (!template) return "未解析";
switch (template.parseStatus) {
case "processing":
return "解析中";
return hasTemplateParsedCanvas(template) ? "补全中" : "解析中";
case "completed":
return "已解析";
case "failed":
return "解析失败";
return hasTemplateParsedCanvas(template) ? "补全失败" : "解析失败";
default:
return "待解析";
}
@ -219,6 +228,9 @@ function getTemplateParseProgress(template: AgentPsdTemplate | null) {
}
function getTemplateParseActionLabel(template: AgentPsdTemplate | null) {
if (template?.parseStatus === "processing") {
return "补全中";
}
return template?.parseStatus === "completed" ? "重新解析" : "解析 PSD";
}
@ -775,8 +787,12 @@ export function PsdTemplatesSettingsClient({
onStart: () => {
setTemplateUploadStatus("正在解析 PSD,请稍候...");
},
onDone: () => {
setTemplateUploadStatus("解析完成");
onDone: (template) => {
setTemplateUploadStatus(
template.parseStatus === "completed"
? "解析完成"
: "快速解析完成,后台继续补全资产..."
);
},
onError: (message) => {
setTemplateUploadStatus(`解析失败:${message}`);
@ -1035,7 +1051,7 @@ export function PsdTemplatesSettingsClient({
options?: {
skipCompletedConfirm?: boolean;
onStart?: () => void;
onDone?: () => void;
onDone?: (template: AgentPsdTemplate) => void;
onError?: (message: string) => void;
}
) {
@ -1099,11 +1115,15 @@ export function PsdTemplatesSettingsClient({
throw new Error(payload.error || "PSD 解析失败");
}
toast.success(`模板“${payload.template.name}”解析完成`);
toast.success(
payload.template.parseStatus === "completed"
? `模板“${payload.template.name}”解析完成`
: `模板“${payload.template.name}”已完成快速解析,后台继续补全资产`
);
setDraftTemplate(cloneTemplate(payload.template));
await mutate();
setSelectedTemplateId(payload.template.id);
options?.onDone?.();
options?.onDone?.(payload.template);
} catch (error) {
await mutate();
const message = error instanceof Error ? error.message : "PSD 解析失败";
@ -1184,7 +1204,7 @@ export function PsdTemplatesSettingsClient({
{getTemplateParseLabel(draftTemplate)}
</Badge>
<span className="text-sm text-muted-foreground">
{draftTemplate.parseStatus === "completed"
{hasTemplateParsedCanvas(draftTemplate)
? `${draftTemplate.width} x ${draftTemplate.height}`
: "等待解析后显示尺寸"}
</span>
@ -1851,7 +1871,7 @@ export function PsdTemplatesSettingsClient({
<div className="min-w-0">
<div className="truncate text-sm font-medium">{template.name}</div>
<div className="mt-1 text-xs text-muted-foreground">
{template.parseStatus === "completed"
{hasTemplateParsedCanvas(template)
? `${template.width} x ${template.height} · ${template.layerCount} 图层`
: "待解析完成后生成尺寸和图层"}
</div>
@ -2031,12 +2051,12 @@ export function PsdTemplatesSettingsClient({
className="flex w-full items-center justify-center bg-black/5"
style={{
aspectRatio:
draftTemplate.parseStatus === "completed"
hasTemplateParsedCanvas(draftTemplate)
? `${Math.max(1, draftTemplate.width)} / ${Math.max(1, draftTemplate.height)}`
: "1 / 1",
}}
>
{draftTemplate.parseStatus === "completed" ? (
{hasTemplateParsedCanvas(draftTemplate) ? (
<img
src={draftTemplate.previewUrl}
alt={draftTemplate.name}
@ -2082,7 +2102,9 @@ export function PsdTemplatesSettingsClient({
{draftTemplate.parseStage ||
(draftTemplate.parseStatus === "completed"
? "解析完成,可开始配置槽位"
: "上传完成后需要手动解析,适合批量上传多个 PSD 再逐个处理")}
: hasTemplateParsedCanvas(draftTemplate)
? "快速解析已完成,后台正在补全图层缩略图、源图和模板底图"
: "上传完成后需要手动解析,适合批量上传多个 PSD 再逐个处理")}
</div>
{draftTemplate.parseStatus === "processing" ? (
<div className="space-y-2">
@ -2103,7 +2125,7 @@ export function PsdTemplatesSettingsClient({
<div className="rounded-xl border p-3">
<div className="text-xs text-muted-foreground">画布尺寸</div>
<div className="mt-1 text-sm font-medium">
{draftTemplate.parseStatus === "completed"
{hasTemplateParsedCanvas(draftTemplate)
? `${draftTemplate.width} x ${draftTemplate.height}`
: "待解析"}
</div>
@ -2111,7 +2133,7 @@ export function PsdTemplatesSettingsClient({
<div className="rounded-xl border p-3">
<div className="text-xs text-muted-foreground">当前槽位</div>
<div className="mt-1 text-sm font-medium">
{draftTemplate.parseStatus === "completed"
{hasTemplateParsedCanvas(draftTemplate)
? `${countEditableLayers(draftTemplate)} / ${draftTemplate.layers.length}`
: "待解析"}
</div>
@ -2388,7 +2410,7 @@ export function PsdTemplatesSettingsClient({
</Badge>
<Badge variant="secondary">
当前图层数:
{draftTemplate.parseStatus === "completed"
{hasTemplateParsedCanvas(draftTemplate)
? draftTemplate.layers.length
: "待解析"}
</Badge>
@ -2438,7 +2460,9 @@ export function PsdTemplatesSettingsClient({
</div>
<div className="mt-4 text-base font-medium">先解析 PSD,再配置槽位</div>
<div className="mt-2 text-sm text-muted-foreground">
当前模板还没有生成图层结构、缩略图和字体识别结果,完成解析后这里会自动显示可编辑配置项。
{hasTemplateParsedCanvas(draftTemplate)
? "快速解析已经返回图层结构,后台正在补全缩略图、源图和底图,完成后这里会自动开放完整配置。"
: "当前模板还没有生成图层结构、缩略图和字体识别结果,完成解析后这里会自动显示可编辑配置项。"}
</div>
<div className="mt-4 flex justify-center">
<Button

View File

@ -5,7 +5,6 @@ import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import {
AGENT_PSD_TEMPLATES_KEY,
AGENT_PSD_TEMPLATE_ASSET_VERSION,
type AgentPsdTemplate,
type AgentPsdTemplateLayerSlot,
type AgentPsdTemplateMutationResponse,
@ -13,14 +12,13 @@ import {
import {
type ParsedPsdTemplateLayer,
parsePsdTemplateFile,
preparePsdTemplateRenderAssets,
} 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,
resolveAgentPsdLocalCachePath,
} from "@/lib/agents/psd-local-cache";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { uploadFile } from "@/lib/storage/service";
@ -229,11 +227,6 @@ export async function POST(
error: undefined,
});
const localFilePath = resolveAgentPsdLocalCachePath({
templateId: id,
fileName: currentTemplate.fileName,
});
let fileBuffer: ArrayBuffer;
if (
await hasAgentPsdLocalCache({
@ -322,57 +315,17 @@ export async function POST(
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,
filePath: localFilePath,
fileBuffer,
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,
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION,
status: nextStatus,
parseStatus: "completed",
parseProgress: 100,
parseStage: "解析完成",
layers: initialLayers,
parseStatus: "processing",
parseProgress: 48,
parseStage: "快速解析完成,正在后台补全图层资源和模板底图",
parseError: undefined,
updatedAt: new Date().toISOString(),
}));
@ -381,6 +334,11 @@ export async function POST(
return jsonError(404, "未找到 PSD 模板");
}
schedulePsdTemplateAssetBackfill({
templateId: id,
updatedBy: session.user.id,
});
return NextResponse.json({
success: true,
template: updatedTemplate,

View File

@ -0,0 +1,267 @@
import "server-only";
import { upsertSystemSetting } from "@/lib/db/queries";
import {
AGENT_PSD_TEMPLATES_KEY,
AGENT_PSD_TEMPLATE_ASSET_VERSION,
isAgentPsdImageLayerSlot,
isAgentPsdTextLayerSlot,
type AgentPsdTemplate,
type AgentPsdTemplateLayerSlot,
} from "@/lib/agents/psd-template-config";
import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
import { preparePsdTemplateRenderAssets } from "@/lib/agents/psd-template-parser";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { uploadFile } from "@/lib/storage/service";
type PsdTemplateAssetBackfillJobState = {
running: boolean;
rerun: boolean;
};
declare global {
// eslint-disable-next-line no-var
var __agentPsdTemplateAssetBackfillJobs:
| Map<string, PsdTemplateAssetBackfillJobState>
| undefined;
}
function getJobMap() {
if (!globalThis.__agentPsdTemplateAssetBackfillJobs) {
globalThis.__agentPsdTemplateAssetBackfillJobs = new Map();
}
return globalThis.__agentPsdTemplateAssetBackfillJobs;
}
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 后台资产补全失败";
}
async function updateTemplateById(params: {
templateId: string;
updatedBy: string;
updater: (template: AgentPsdTemplate) => AgentPsdTemplate;
}) {
const templates = await readAgentPsdTemplates();
const currentTemplate = templates.find((entry) => entry.id === params.templateId);
if (!currentTemplate) {
return null;
}
const nextTemplate = params.updater(currentTemplate);
await upsertSystemSetting({
key: AGENT_PSD_TEMPLATES_KEY,
value: templates.map((entry) => (entry.id === params.templateId ? nextTemplate : entry)),
updatedBy: params.updatedBy,
});
return nextTemplate;
}
function mergePreparedLayerAssets(params: {
currentLayers: AgentPsdTemplateLayerSlot[];
preparedLayers: AgentPsdTemplateLayerSlot[];
}) {
const preparedLayerMap = new Map(
params.preparedLayers.map((layer) => [layer.id, layer] as const)
);
return params.currentLayers.map((layer) => {
const preparedLayer = preparedLayerMap.get(layer.id);
if (!preparedLayer) {
return layer;
}
if (isAgentPsdImageLayerSlot(layer) && isAgentPsdImageLayerSlot(preparedLayer)) {
return {
...layer,
previewUrl: preparedLayer.previewUrl || layer.previewUrl,
previewPath: preparedLayer.previewPath || layer.previewPath,
sourceUrl: preparedLayer.sourceUrl || layer.sourceUrl,
sourcePath: preparedLayer.sourcePath || layer.sourcePath,
contentBounds: preparedLayer.contentBounds || layer.contentBounds,
maskUrl: preparedLayer.maskUrl,
maskPath: preparedLayer.maskPath,
} satisfies AgentPsdTemplateLayerSlot;
}
if (isAgentPsdTextLayerSlot(layer) && isAgentPsdTextLayerSlot(preparedLayer)) {
let nextLayer: AgentPsdTemplateLayerSlot = {
...layer,
previewUrl: preparedLayer.previewUrl || layer.previewUrl,
previewPath: preparedLayer.previewPath || layer.previewPath,
sourceUrl: preparedLayer.sourceUrl || layer.sourceUrl,
sourcePath: preparedLayer.sourcePath || layer.sourcePath,
};
if (layer.styleSource !== "custom") {
nextLayer = {
...nextLayer,
textColor:
layer.textColor === "#111111" && preparedLayer.textColor
? preparedLayer.textColor
: layer.textColor,
textAlign:
layer.textAlign === "left" && preparedLayer.textAlign !== "left"
? preparedLayer.textAlign
: layer.textAlign,
paddingX: layer.paddingX > 0 ? layer.paddingX : preparedLayer.paddingX,
paddingY: layer.paddingY > 0 ? layer.paddingY : preparedLayer.paddingY,
fontId: layer.fontId || preparedLayer.fontId,
fontFamily: layer.fontFamily || preparedLayer.fontFamily,
fontLabel: layer.fontLabel || preparedLayer.fontLabel,
fontFileUrl: layer.fontFileUrl || preparedLayer.fontFileUrl,
fontContentType: layer.fontContentType || preparedLayer.fontContentType,
} satisfies AgentPsdTemplateLayerSlot;
}
return nextLayer;
}
return layer;
});
}
async function backfillTemplateAssetsOnce(params: {
templateId: string;
updatedBy: string;
}) {
const templates = await readAgentPsdTemplates();
const template = templates.find((entry) => entry.id === params.templateId);
if (!template || template.parseStatus !== "processing") {
return;
}
if (template.layers.length === 0 || template.width <= 1 || template.height <= 1) {
throw new Error("PSD 模板尚未完成快速解析");
}
await updateTemplateById({
templateId: template.id,
updatedBy: params.updatedBy,
updater: (currentTemplate) => ({
...currentTemplate,
parseStatus: "processing",
parseProgress: Math.max(62, Number(currentTemplate.parseProgress || 0)),
parseStage: "快速解析完成,正在后台补全图层资源",
parseError: undefined,
updatedAt: new Date().toISOString(),
}),
});
const preparedAssets = await preparePsdTemplateRenderAssets({
templateId: template.id,
fileUrl: template.fileUrl,
filePath: resolveAgentPsdLocalCachePath({
templateId: template.id,
fileName: template.fileName,
}),
layers: template.layers,
});
await updateTemplateById({
templateId: template.id,
updatedBy: params.updatedBy,
updater: (currentTemplate) => ({
...currentTemplate,
parseStatus: "processing",
parseProgress: Math.max(88, Number(currentTemplate.parseProgress || 0)),
parseStage: "正在后台生成模板底图",
parseError: undefined,
updatedAt: new Date().toISOString(),
}),
});
const uploadedRenderBase = await uploadFile(
`agent-psd-base-${template.id}-${Date.now()}.png`,
toArrayBuffer(preparedAssets.renderBaseBuffer)
);
await updateTemplateById({
templateId: template.id,
updatedBy: params.updatedBy,
updater: (currentTemplate) => {
const mergedLayers = mergePreparedLayerAssets({
currentLayers: currentTemplate.layers,
preparedLayers: preparedAssets.layers,
});
return {
...currentTemplate,
renderBaseUrl: uploadedRenderBase.url,
renderBasePath: uploadedRenderBase.pathname,
layers: mergedLayers,
layerCount: mergedLayers.length,
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION,
parseStatus: "completed",
parseProgress: 100,
parseStage: "快速解析和后台资产补全已完成",
parseError: undefined,
updatedAt: new Date().toISOString(),
};
},
});
}
export function schedulePsdTemplateAssetBackfill(params: {
templateId: string;
updatedBy: string;
}) {
const jobs = getJobMap();
const existingJob = jobs.get(params.templateId);
if (existingJob?.running) {
existingJob.rerun = true;
return;
}
const jobState: PsdTemplateAssetBackfillJobState = {
running: true,
rerun: false,
};
jobs.set(params.templateId, jobState);
queueMicrotask(() => {
void (async () => {
try {
do {
jobState.rerun = false;
await backfillTemplateAssetsOnce(params);
} while (jobState.rerun);
} catch (error) {
const message = getErrorMessage(error);
await updateTemplateById({
templateId: params.templateId,
updatedBy: params.updatedBy,
updater: (currentTemplate) => ({
...currentTemplate,
parseStatus: "failed",
parseProgress: Math.min(95, Math.max(45, Number(currentTemplate.parseProgress || 0))),
parseStage: "快速解析已完成,但后台补全资产失败",
parseError: message,
updatedAt: new Date().toISOString(),
}),
}).catch(() => undefined);
console.error("[PSD Template] background asset backfill failed:", {
templateId: params.templateId,
error,
});
} finally {
jobs.delete(params.templateId);
}
})();
});
}