ai-chatbot/lib/agents/psd-template-asset-backfill.ts
Your Name e84bfd94ec
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: split PSD parsing into fast parse and async backfill
2026-04-10 21:22:36 +08:00

268 lines
8.2 KiB
TypeScript

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);
}
})();
});
}