ai-chatbot/lib/agents/psd-template-asset-backfill.ts
2026-06-04 11:39:53 +08:00

278 lines
8.5 KiB
TypeScript

import "server-only";
import {
AGENT_PSD_TEMPLATE_ASSET_VERSION,
isAgentPsdImageLayerSlot,
isAgentPsdTextLayerSlot,
type AgentPsdTemplateLayerSlot,
} from "@/lib/agents/psd-template-config";
import {
createAgentPsdTemplateParseHeartbeat,
updateAgentPsdTemplateById,
updateAgentPsdTemplateParseProgress,
} from "@/lib/agents/psd-template-progress";
import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
import {
preparePsdTemplateRenderAssets,
uploadPreparedPsdTemplateRenderSegments,
} from "@/lib/agents/psd-template-parser";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { createStorageUploadSession } 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 后台资产补全失败";
}
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 模板尚未完成快速解析");
}
let latestProgress = {
progress: Math.max(62, Number(template.parseProgress || 0)),
stage: "快速解析完成,正在后台补全图层资源",
error: undefined as string | undefined,
};
const setBackfillProgress = async (
progress: number,
stage: string,
error?: string
) => {
latestProgress = {
progress,
stage,
error,
};
await updateAgentPsdTemplateParseProgress({
templateId: template.id,
updatedBy: params.updatedBy,
progress,
stage,
error,
});
};
const heartbeat = createAgentPsdTemplateParseHeartbeat({
templateId: template.id,
updatedBy: params.updatedBy,
getSnapshot: () => latestProgress,
});
await setBackfillProgress(latestProgress.progress, latestProgress.stage);
try {
const uploadAsset = await createStorageUploadSession();
const preparedAssets = await preparePsdTemplateRenderAssets({
templateId: template.id,
fileUrl: template.fileUrl,
filePath: resolveAgentPsdLocalCachePath({
templateId: template.id,
fileName: template.fileName,
}),
layers: template.layers,
uploadAsset,
onProgress: async (event) => {
await setBackfillProgress(event.progress, event.stage);
},
});
await setBackfillProgress(88, "正在后台生成模板底图和快速导出分层");
const uploadedRenderBase = await uploadAsset(
`agent-psd-base-${template.id}-${Date.now()}.png`,
toArrayBuffer(preparedAssets.renderBaseBuffer)
);
await setBackfillProgress(96, "正在上传快速导出分层");
const uploadedRenderSegments = await uploadPreparedPsdTemplateRenderSegments({
templateId: template.id,
segments: preparedAssets.renderSegments,
uploadAsset,
});
heartbeat.stop();
await updateAgentPsdTemplateById({
templateId: template.id,
updatedBy: params.updatedBy,
updater: (currentTemplate) => {
const now = new Date().toISOString();
const mergedLayers = mergePreparedLayerAssets({
currentLayers: currentTemplate.layers,
preparedLayers: preparedAssets.layers,
});
return {
...currentTemplate,
renderBaseUrl: uploadedRenderBase.url,
renderBasePath: uploadedRenderBase.pathname,
renderSegments: uploadedRenderSegments,
layers: mergedLayers,
layerCount: mergedLayers.length,
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION,
parseStatus: "completed",
parseProgress: 100,
parseStage: "快速解析和可用资产补全已完成",
parseError: undefined,
parseHeartbeatAt: now,
updatedAt: now,
};
},
});
} catch (error) {
heartbeat.stop();
throw error;
}
}
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 updateAgentPsdTemplateById({
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);
}
})();
});
}