fix: stabilize psd template parsing progress

This commit is contained in:
Your Name 2026-06-04 11:39:53 +08:00
parent 37e2b66725
commit 800eb10ba4
6 changed files with 560 additions and 271 deletions

View File

@ -1,11 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { upsertSystemSetting } from "@/lib/db/queries";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin"; import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching"; import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { import {
AGENT_PSD_TEMPLATES_KEY,
type AgentPsdTemplate,
type AgentPsdTemplateLayerSlot, type AgentPsdTemplateLayerSlot,
type AgentPsdTemplateMutationResponse, type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config"; } from "@/lib/agents/psd-template-config";
@ -15,6 +12,11 @@ import {
} from "@/lib/agents/psd-template-parser"; } from "@/lib/agents/psd-template-parser";
import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection"; import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection";
import { schedulePsdTemplateAssetBackfill } from "@/lib/agents/psd-template-asset-backfill"; import { schedulePsdTemplateAssetBackfill } from "@/lib/agents/psd-template-asset-backfill";
import {
createAgentPsdTemplateParseHeartbeat,
updateAgentPsdTemplateById,
updateAgentPsdTemplateParseProgress,
} from "@/lib/agents/psd-template-progress";
import { import {
cacheAgentPsdSourceLocally, cacheAgentPsdSourceLocally,
hasAgentPsdLocalCache, hasAgentPsdLocalCache,
@ -158,44 +160,15 @@ function buildInitialTemplateLayers(params: {
}); });
} }
async function updateTemplate(
templateId: string,
updatedBy: string,
updater: (template: AgentPsdTemplate) => AgentPsdTemplate
) {
const templates = await readAgentPsdTemplates();
const currentTemplate = templates.find((entry) => entry.id === templateId);
if (!currentTemplate) {
return null;
}
const nextTemplate = updater(currentTemplate);
await upsertSystemSetting({
key: AGENT_PSD_TEMPLATES_KEY,
value: templates.map((entry) => (entry.id === templateId ? nextTemplate : entry)),
updatedBy,
});
return nextTemplate;
}
async function updateParseProgress(params: { async function updateParseProgress(params: {
templateId: string; templateId: string;
updatedBy: string; updatedBy: string;
progress: number; progress: number;
stage: string; stage: string;
error?: string; error?: string;
restart?: boolean;
}) { }) {
const { templateId, updatedBy, progress, stage, error } = params; return updateAgentPsdTemplateParseProgress(params);
return updateTemplate(templateId, updatedBy, (template) => ({
...template,
parseStatus: "processing",
parseProgress: progress,
parseStage: stage,
parseError: error,
updatedAt: new Date().toISOString(),
}));
} }
export async function POST( export async function POST(
@ -231,137 +204,150 @@ export async function POST(
return jsonError(409, "该模板正在解析中,请稍后刷新"); return jsonError(409, "该模板正在解析中,请稍后刷新");
} }
await updateParseProgress({ let latestProgress = {
templateId: id,
updatedBy: session.user.id,
progress: 5, progress: 5,
stage: "准备解析 PSD", stage: "准备解析 PSD",
error: undefined, error: undefined as string | undefined,
}); };
const setParseProgress = async (progress: number, stage: string, error?: string) => {
let fileBuffer: ArrayBuffer; latestProgress = {
if ( progress,
await hasAgentPsdLocalCache({ stage,
templateId: id, error,
fileName: currentTemplate.fileName, };
}) return updateParseProgress({
) {
await updateParseProgress({
templateId: id, templateId: id,
updatedBy: session.user.id, updatedBy: session.user.id,
progress: 18, progress,
stage: "正在读取服务器本地 PSD 缓存", stage,
error: undefined, error,
});
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) { await updateParseProgress({
throw new Error(`PSD 文件下载失败 (${response.status})`); 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 updateParseProgress({ 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, templateId: id,
updatedBy: session.user.id, updatedBy: session.user.id,
progress: 18, updater: (template) => {
stage: "正在下载并写入服务器本地 PSD 缓存", const now = new Date().toISOString();
error: undefined, 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,
};
},
}); });
fileBuffer = await response.arrayBuffer(); if (!updatedTemplate) {
await cacheAgentPsdSourceLocally({ return jsonError(404, "未找到 PSD 模板");
}
schedulePsdTemplateAssetBackfill({
templateId: id, templateId: id,
fileName: currentTemplate.fileName, updatedBy: session.user.id,
buffer: fileBuffer,
}).catch((error) => {
console.warn("[PSD Templates][Admin] failed to cache PSD source locally:", {
templateId: id,
error,
});
}); });
return NextResponse.json({
success: true,
template: updatedTemplate,
} satisfies AgentPsdTemplateMutationResponse);
} catch (error) {
heartbeat.stop();
throw error;
} }
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 24,
stage: "正在解析 PSD 图层结构与预览",
error: undefined,
});
const parsedTemplate = await parsePsdTemplateFile(fileBuffer);
if (parsedTemplate.layers.length === 0) {
throw new Error("PSD 中未识别到可配置图层");
}
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 42,
stage: "正在上传模板预览",
error: undefined,
});
const uploadedPreview = await uploadFile(
`agent-psd-preview-${id}-${Date.now()}.png`,
toArrayBuffer(parsedTemplate.previewBuffer)
);
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 58,
stage: "正在匹配字体与初始化图层",
error: undefined,
});
const fonts = await readAgentPsdFonts().catch(() => []);
const initialLayers = buildInitialTemplateLayers({
parsedLayers: parsedTemplate.layers,
fonts,
canvasWidth: parsedTemplate.width,
canvasHeight: parsedTemplate.height,
});
const updatedTemplate = await updateTemplate(id, session.user.id, (template) => ({
...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,
updatedAt: new Date().toISOString(),
}));
if (!updatedTemplate) {
return jsonError(404, "未找到 PSD 模板");
}
schedulePsdTemplateAssetBackfill({
templateId: id,
updatedBy: session.user.id,
});
return NextResponse.json({
success: true,
template: updatedTemplate,
} satisfies AgentPsdTemplateMutationResponse);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id); const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id);
@ -372,18 +358,22 @@ export async function POST(
? "completed" ? "completed"
: "failed"; : "failed";
await updateTemplate(id, session.user.id, (template) => ({ await updateAgentPsdTemplateById({
...template, templateId: id,
parseStatus: fallbackStatus, updatedBy: session.user.id,
parseProgress: fallbackStatus === "completed" ? 100 : 0, updater: (template) => ({
parseStage: ...template,
fallbackStatus === "completed" ? "沿用上次成功解析结果" : undefined, parseStatus: fallbackStatus,
parseError: parseProgress: fallbackStatus === "completed" ? 100 : 0,
fallbackStatus === "completed" parseStage:
? `重新解析失败:${message}` fallbackStatus === "completed" ? "沿用上次成功解析结果" : undefined,
: message, parseError:
updatedAt: new Date().toISOString(), fallbackStatus === "completed"
})).catch(() => undefined); ? `重新解析失败:${message}`
: message,
updatedAt: new Date().toISOString(),
}),
}).catch(() => undefined);
} }
console.error("[PSD Templates][Admin] parse failed:", error); console.error("[PSD Templates][Admin] parse failed:", error);

View File

@ -1,14 +1,16 @@
import "server-only"; import "server-only";
import { upsertSystemSetting } from "@/lib/db/queries";
import { import {
AGENT_PSD_TEMPLATES_KEY,
AGENT_PSD_TEMPLATE_ASSET_VERSION, AGENT_PSD_TEMPLATE_ASSET_VERSION,
isAgentPsdImageLayerSlot, isAgentPsdImageLayerSlot,
isAgentPsdTextLayerSlot, isAgentPsdTextLayerSlot,
type AgentPsdTemplate,
type AgentPsdTemplateLayerSlot, type AgentPsdTemplateLayerSlot,
} from "@/lib/agents/psd-template-config"; } 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 { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
import { import {
preparePsdTemplateRenderAssets, preparePsdTemplateRenderAssets,
@ -51,28 +53,6 @@ function getErrorMessage(error: unknown) {
return "PSD 后台资产补全失败"; 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: { function mergePreparedLayerAssets(params: {
currentLayers: AgentPsdTemplateLayerSlot[]; currentLayers: AgentPsdTemplateLayerSlot[];
preparedLayers: AgentPsdTemplateLayerSlot[]; preparedLayers: AgentPsdTemplateLayerSlot[];
@ -152,79 +132,98 @@ async function backfillTemplateAssetsOnce(params: {
throw new Error("PSD 模板尚未完成快速解析"); throw new Error("PSD 模板尚未完成快速解析");
} }
await updateTemplateById({ let latestProgress = {
templateId: template.id, progress: Math.max(62, Number(template.parseProgress || 0)),
updatedBy: params.updatedBy, stage: "快速解析完成,正在后台补全图层资源",
updater: (currentTemplate) => ({ error: undefined as string | undefined,
...currentTemplate, };
parseStatus: "processing", const setBackfillProgress = async (
parseProgress: Math.max(62, Number(currentTemplate.parseProgress || 0)), progress: number,
parseStage: "快速解析完成,正在后台补全图层资源", stage: string,
parseError: undefined, error?: string
updatedAt: new Date().toISOString(), ) => {
}), latestProgress = {
}); progress,
stage,
const uploadAsset = await createStorageUploadSession(); error,
const preparedAssets = await preparePsdTemplateRenderAssets({ };
templateId: template.id, await updateAgentPsdTemplateParseProgress({
fileUrl: template.fileUrl,
filePath: resolveAgentPsdLocalCachePath({
templateId: template.id, templateId: template.id,
fileName: template.fileName, updatedBy: params.updatedBy,
}), progress,
layers: template.layers, stage,
uploadAsset, error,
}); });
};
await updateTemplateById({ const heartbeat = createAgentPsdTemplateParseHeartbeat({
templateId: template.id, templateId: template.id,
updatedBy: params.updatedBy, updatedBy: params.updatedBy,
updater: (currentTemplate) => ({ getSnapshot: () => latestProgress,
...currentTemplate,
parseStatus: "processing",
parseProgress: Math.max(88, Number(currentTemplate.parseProgress || 0)),
parseStage: "正在后台生成模板底图和快速导出分层",
parseError: undefined,
updatedAt: new Date().toISOString(),
}),
}); });
const uploadedRenderBase = await uploadAsset( await setBackfillProgress(latestProgress.progress, latestProgress.stage);
`agent-psd-base-${template.id}-${Date.now()}.png`,
toArrayBuffer(preparedAssets.renderBaseBuffer)
);
const uploadedRenderSegments = await uploadPreparedPsdTemplateRenderSegments({
templateId: template.id,
segments: preparedAssets.renderSegments,
uploadAsset,
});
await updateTemplateById({ try {
templateId: template.id, const uploadAsset = await createStorageUploadSession();
updatedBy: params.updatedBy, const preparedAssets = await preparePsdTemplateRenderAssets({
updater: (currentTemplate) => { templateId: template.id,
const mergedLayers = mergePreparedLayerAssets({ fileUrl: template.fileUrl,
currentLayers: currentTemplate.layers, filePath: resolveAgentPsdLocalCachePath({
preparedLayers: preparedAssets.layers, templateId: template.id,
}); fileName: template.fileName,
}),
layers: template.layers,
uploadAsset,
onProgress: async (event) => {
await setBackfillProgress(event.progress, event.stage);
},
});
return { await setBackfillProgress(88, "正在后台生成模板底图和快速导出分层");
...currentTemplate,
renderBaseUrl: uploadedRenderBase.url, const uploadedRenderBase = await uploadAsset(
renderBasePath: uploadedRenderBase.pathname, `agent-psd-base-${template.id}-${Date.now()}.png`,
renderSegments: uploadedRenderSegments, toArrayBuffer(preparedAssets.renderBaseBuffer)
layers: mergedLayers, );
layerCount: mergedLayers.length, await setBackfillProgress(96, "正在上传快速导出分层");
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION, const uploadedRenderSegments = await uploadPreparedPsdTemplateRenderSegments({
parseStatus: "completed", templateId: template.id,
parseProgress: 100, segments: preparedAssets.renderSegments,
parseStage: "快速解析和可用资产补全已完成", uploadAsset,
parseError: undefined, });
updatedAt: new Date().toISOString(),
}; 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: { export function schedulePsdTemplateAssetBackfill(params: {
@ -253,7 +252,7 @@ export function schedulePsdTemplateAssetBackfill(params: {
} while (jobState.rerun); } while (jobState.rerun);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
await updateTemplateById({ await updateAgentPsdTemplateById({
templateId: params.templateId, templateId: params.templateId,
updatedBy: params.updatedBy, updatedBy: params.updatedBy,
updater: (currentTemplate) => ({ updater: (currentTemplate) => ({

View File

@ -105,6 +105,8 @@ export interface AgentPsdTemplate {
parseProgress?: number; parseProgress?: number;
parseStage?: string; parseStage?: string;
parseError?: string; parseError?: string;
parseStartedAt?: string;
parseHeartbeatAt?: string;
fileName: string; fileName: string;
fileUrl: string; fileUrl: string;
filePath?: string; filePath?: string;
@ -528,6 +530,8 @@ export function parseAgentPsdTemplates(value: unknown) {
const parseStatus = normalizeAgentPsdTemplateParseStatus(record.parseStatus); const parseStatus = normalizeAgentPsdTemplateParseStatus(record.parseStatus);
const rawParseProgress = normalizePositiveInteger(record.parseProgress); const rawParseProgress = normalizePositiveInteger(record.parseProgress);
const parseStartedAt = toIsoDateString(record.parseStartedAt) || undefined;
const parseHeartbeatAt = toIsoDateString(record.parseHeartbeatAt) || undefined;
const categoryTags = normalizeAgentPsdTemplateCategoryTags( const categoryTags = normalizeAgentPsdTemplateCategoryTags(
record.categoryTags ?? record.productCategoryTags record.categoryTags ?? record.productCategoryTags
@ -549,6 +553,8 @@ export function parseAgentPsdTemplates(value: unknown) {
: Math.min(100, Math.max(0, rawParseProgress)), : Math.min(100, Math.max(0, rawParseProgress)),
parseStage: normalizeText(record.parseStage) || undefined, parseStage: normalizeText(record.parseStage) || undefined,
parseError: normalizeText(record.parseError) || undefined, parseError: normalizeText(record.parseError) || undefined,
parseStartedAt,
parseHeartbeatAt,
fileName, fileName,
fileUrl, fileUrl,
filePath: normalizeText(record.filePath) || undefined, filePath: normalizeText(record.filePath) || undefined,

View File

@ -126,6 +126,26 @@ export interface PreparedPsdTemplateRenderSegment {
buffer: Buffer; buffer: Buffer;
} }
export interface PsdTemplateParseProgressEvent {
progress: number;
stage: string;
}
type PsdTemplateParseProgressReporter = (
event: PsdTemplateParseProgressEvent
) => void | Promise<void>;
async function reportPsdTemplateParseProgress(
reporter: PsdTemplateParseProgressReporter | undefined,
event: PsdTemplateParseProgressEvent
) {
if (!reporter) {
return;
}
await reporter(event);
}
function resolveVisibleContentBounds(params: { function resolveVisibleContentBounds(params: {
rgba: Uint8ClampedArray; rgba: Uint8ClampedArray;
width: number; width: number;
@ -509,17 +529,44 @@ async function resolvePsdSourceBuffer(params: {
} }
export async function parsePsdTemplateFile( export async function parsePsdTemplateFile(
buffer: ArrayBuffer buffer: ArrayBuffer,
options?: {
onProgress?: PsdTemplateParseProgressReporter;
}
): Promise<ParsedPsdTemplatePayload> { ): Promise<ParsedPsdTemplatePayload> {
await reportPsdTemplateParseProgress(options?.onProgress, {
progress: 26,
stage: "正在加载 PSD 解析引擎",
});
const psd = await parsePsdBuffer(buffer); const psd = await parsePsdBuffer(buffer);
const width = Math.max(1, Math.round(Number(psd.width || 0))); const width = Math.max(1, Math.round(Number(psd.width || 0)));
const height = Math.max(1, Math.round(Number(psd.height || 0))); const height = Math.max(1, Math.round(Number(psd.height || 0)));
await reportPsdTemplateParseProgress(options?.onProgress, {
progress: 30,
stage: "正在合成 PSD 整体预览",
});
const previewRgba = await psd.composite(); const previewRgba = await psd.composite();
await reportPsdTemplateParseProgress(options?.onProgress, {
progress: 35,
stage: "正在生成 PSD 预览图",
});
const previewBuffer = await rgbaToPngBuffer(previewRgba, width, height); const previewBuffer = await rgbaToPngBuffer(previewRgba, width, height);
await reportPsdTemplateParseProgress(options?.onProgress, {
progress: 39,
stage: "正在扫描可配置图层",
});
const layers = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []).map( const layers = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []).map(
(entry) => entry.layer (entry) => entry.layer
); );
await reportPsdTemplateParseProgress(options?.onProgress, {
progress: 41,
stage: `已识别 ${layers.length} 个可配置图层`,
});
return { return {
width, width,
height, height,
@ -705,11 +752,17 @@ export async function uploadPreparedPsdTemplateRenderSegments(params: {
async function mapWithConcurrency<T, R>( async function mapWithConcurrency<T, R>(
items: T[], items: T[],
concurrency: number, concurrency: number,
mapper: (item: T, index: number) => Promise<R> mapper: (item: T, index: number) => Promise<R>,
onItemComplete?: (params: {
completed: number;
total: number;
index: number;
}) => void | Promise<void>
) { ) {
const results = new Array<R>(items.length); const results = new Array<R>(items.length);
const workerCount = Math.max(1, Math.min(Math.round(concurrency), items.length || 1)); const workerCount = Math.max(1, Math.min(Math.round(concurrency), items.length || 1));
let nextIndex = 0; let nextIndex = 0;
let completed = 0;
await Promise.all( await Promise.all(
Array.from({ length: workerCount }, async () => { Array.from({ length: workerCount }, async () => {
@ -717,6 +770,12 @@ async function mapWithConcurrency<T, R>(
const currentIndex = nextIndex; const currentIndex = nextIndex;
nextIndex += 1; nextIndex += 1;
results[currentIndex] = await mapper(items[currentIndex], currentIndex); results[currentIndex] = await mapper(items[currentIndex], currentIndex);
completed += 1;
await onItemComplete?.({
completed,
total: items.length,
index: currentIndex,
});
} }
}) })
); );
@ -752,16 +811,27 @@ export async function preparePsdTemplateRenderAssets(params: {
assetProfile?: PsdTemplateLayerAssetProfile; assetProfile?: PsdTemplateLayerAssetProfile;
assetConcurrency?: number; assetConcurrency?: number;
uploadAsset?: StorageUploadFunction; uploadAsset?: StorageUploadFunction;
onProgress?: PsdTemplateParseProgressReporter;
}) { }) {
const assetProfile = params.assetProfile || "fast-runtime"; const assetProfile = params.assetProfile || "fast-runtime";
const assetConcurrency = const assetConcurrency =
params.assetConcurrency || DEFAULT_PSD_TEMPLATE_ASSET_CONCURRENCY; params.assetConcurrency || DEFAULT_PSD_TEMPLATE_ASSET_CONCURRENCY;
const uploadAsset = params.uploadAsset || uploadFile; const uploadAsset = params.uploadAsset || uploadFile;
await reportPsdTemplateParseProgress(params.onProgress, {
progress: 63,
stage: "正在读取 PSD 源文件并准备补全资产",
});
const psdBuffer = await resolvePsdSourceBuffer({ const psdBuffer = await resolvePsdSourceBuffer({
fileUrl: params.fileUrl, fileUrl: params.fileUrl,
filePath: params.filePath, filePath: params.filePath,
fileBuffer: params.fileBuffer, fileBuffer: params.fileBuffer,
}); });
await reportPsdTemplateParseProgress(params.onProgress, {
progress: 65,
stage: "正在解析 PSD 图层资源",
});
const psd = await parsePsdBuffer(psdBuffer); const psd = await parsePsdBuffer(psdBuffer);
const canvasWidth = Math.max(1, Math.round(Number(psd.width || 0))); const canvasWidth = Math.max(1, Math.round(Number(psd.width || 0)));
const canvasHeight = Math.max(1, Math.round(Number(psd.height || 0))); const canvasHeight = Math.max(1, Math.round(Number(psd.height || 0)));
@ -778,6 +848,11 @@ export async function preparePsdTemplateRenderAssets(params: {
const nextLayers = [...params.layers]; const nextLayers = [...params.layers];
await reportPsdTemplateParseProgress(params.onProgress, {
progress: 66,
stage: `正在补全图层缩略图和源图 0/${nextLayers.length}`,
});
const preparedLayerResults = await mapWithConcurrency( const preparedLayerResults = await mapWithConcurrency(
nextLayers, nextLayers,
assetConcurrency, assetConcurrency,
@ -934,6 +1009,17 @@ export async function preparePsdTemplateRenderAssets(params: {
} }
return preparedLayer; return preparedLayer;
},
async ({ completed, total }) => {
if (completed !== total && completed % 4 !== 0) {
return;
}
const progress = 66 + Math.round((completed / Math.max(1, total)) * 12);
await reportPsdTemplateParseProgress(params.onProgress, {
progress,
stage: `正在补全图层缩略图和源图 ${completed}/${total}`,
});
} }
); );
@ -945,6 +1031,11 @@ export async function preparePsdTemplateRenderAssets(params: {
.reverse() .reverse()
.filter((entry) => entry.node && typeof entry.node.composite === "function"); .filter((entry) => entry.node && typeof entry.node.composite === "function");
await reportPsdTemplateParseProgress(params.onProgress, {
progress: 79,
stage: `正在生成模板底图图层 0/${drawableEntries.length}`,
});
const layerCompositeResults = await mapWithConcurrency( const layerCompositeResults = await mapWithConcurrency(
drawableEntries, drawableEntries,
assetConcurrency, assetConcurrency,
@ -996,6 +1087,17 @@ export async function preparePsdTemplateRenderAssets(params: {
}); });
return null; return null;
} }
},
async ({ completed, total }) => {
if (completed !== total && completed % 4 !== 0) {
return;
}
const progress = 79 + Math.round((completed / Math.max(1, total)) * 9);
await reportPsdTemplateParseProgress(params.onProgress, {
progress,
stage: `正在生成模板底图图层 ${completed}/${total}`,
});
} }
); );
@ -1025,6 +1127,11 @@ export async function preparePsdTemplateRenderAssets(params: {
.map((result) => result.input); .map((result) => result.input);
const sharp = await getSharp(); const sharp = await getSharp();
await reportPsdTemplateParseProgress(params.onProgress, {
progress: 89,
stage: "正在合成模板底图",
});
const transparentBaseBuffer = await createTransparentCanvasBuffer( const transparentBaseBuffer = await createTransparentCanvasBuffer(
canvasWidth, canvasWidth,
canvasHeight canvasHeight
@ -1042,6 +1149,10 @@ export async function preparePsdTemplateRenderAssets(params: {
for (const result of sortedLayerCompositeResults) { for (const result of sortedLayerCompositeResults) {
if (result.editableBoundary) { if (result.editableBoundary) {
if (pendingSegmentInputs.length > 0) { if (pendingSegmentInputs.length > 0) {
await reportPsdTemplateParseProgress(params.onProgress, {
progress: 92,
stage: "正在生成快速导出分层",
});
renderSegments.push({ renderSegments.push({
id: `before-${sanitizeFileSegment( id: `before-${sanitizeFileSegment(
result.beforeLayerId, result.beforeLayerId,
@ -1061,6 +1172,10 @@ export async function preparePsdTemplateRenderAssets(params: {
} }
if (pendingSegmentInputs.length > 0) { if (pendingSegmentInputs.length > 0) {
await reportPsdTemplateParseProgress(params.onProgress, {
progress: 94,
stage: "正在生成顶部快速导出分层",
});
renderSegments.push({ renderSegments.push({
id: "above-editable-layers", id: "above-editable-layers",
buffer: await composeSegmentBuffer(pendingSegmentInputs), buffer: await composeSegmentBuffer(pendingSegmentInputs),

View File

@ -0,0 +1,127 @@
import "server-only";
import { upsertSystemSetting } from "@/lib/db/queries";
import {
AGENT_PSD_TEMPLATES_KEY,
type AgentPsdTemplate,
} from "@/lib/agents/psd-template-config";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
const DEFAULT_PARSE_HEARTBEAT_INTERVAL_MS = 25_000;
function clampProgress(value: number) {
return Math.max(0, Math.min(99, Math.round(Number(value || 0))));
}
export async function updateAgentPsdTemplateById(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;
}
export async function updateAgentPsdTemplateParseProgress(params: {
templateId: string;
updatedBy: string;
progress: number;
stage: string;
error?: string;
restart?: boolean;
}) {
const now = new Date().toISOString();
return updateAgentPsdTemplateById({
templateId: params.templateId,
updatedBy: params.updatedBy,
updater: (template) => {
if (!params.restart && template.parseStatus !== "processing") {
return template;
}
return {
...template,
parseStatus: "processing",
parseProgress: clampProgress(params.progress),
parseStage: params.stage,
parseError: params.error,
parseStartedAt:
params.restart || !template.parseStartedAt
? now
: template.parseStartedAt,
parseHeartbeatAt: now,
updatedAt: now,
};
},
});
}
export function createAgentPsdTemplateParseHeartbeat(params: {
templateId: string;
updatedBy: string;
getSnapshot: () => {
progress: number;
stage: string;
error?: string;
};
intervalMs?: number;
}) {
let stopped = false;
let inFlight = false;
let failedBeats = 0;
const interval = setInterval(() => {
if (stopped || inFlight) {
return;
}
inFlight = true;
const snapshot = params.getSnapshot();
void updateAgentPsdTemplateParseProgress({
templateId: params.templateId,
updatedBy: params.updatedBy,
progress: snapshot.progress,
stage: snapshot.stage,
error: snapshot.error,
})
.then(() => {
failedBeats = 0;
})
.catch((error) => {
failedBeats += 1;
if (failedBeats <= 3) {
console.warn("[PSD Template] parse heartbeat update failed:", {
templateId: params.templateId,
failedBeats,
error,
});
}
})
.finally(() => {
inFlight = false;
});
}, params.intervalMs || DEFAULT_PARSE_HEARTBEAT_INTERVAL_MS);
return {
stop() {
stopped = true;
clearInterval(interval);
},
};
}

View File

@ -20,15 +20,42 @@ export const AGENT_PSD_TEMPLATE_ASSET_BACKFILL_TIMEOUT_MS = getEnvPositiveInt(
"AGENT_PSD_TEMPLATE_ASSET_BACKFILL_TIMEOUT_MS", "AGENT_PSD_TEMPLATE_ASSET_BACKFILL_TIMEOUT_MS",
20 * 60 * 1000 20 * 60 * 1000
); );
export const AGENT_PSD_TEMPLATE_PARSE_MAX_DURATION_MS = getEnvPositiveInt(
"AGENT_PSD_TEMPLATE_PARSE_MAX_DURATION_MS",
45 * 60 * 1000
);
function getTemplateUpdatedAtTime(template: AgentPsdTemplate) { function getIsoTime(value?: string) {
const updatedAt = new Date(template.updatedAt).getTime(); const time = new Date(value || "").getTime();
if (Number.isFinite(updatedAt)) { return Number.isFinite(time) ? time : null;
}
function getTemplateHeartbeatTime(template: AgentPsdTemplate) {
const heartbeatAt = getIsoTime(template.parseHeartbeatAt);
if (heartbeatAt) {
return heartbeatAt;
}
const updatedAt = getIsoTime(template.updatedAt);
if (updatedAt) {
return updatedAt; return updatedAt;
} }
const createdAt = new Date(template.createdAt).getTime(); return getIsoTime(template.createdAt);
return Number.isFinite(createdAt) ? createdAt : null; }
function getTemplateParseStartedAtTime(template: AgentPsdTemplate) {
const startedAt = getIsoTime(template.parseStartedAt);
if (startedAt) {
return startedAt;
}
const updatedAt = getIsoTime(template.updatedAt);
if (updatedAt) {
return updatedAt;
}
return getIsoTime(template.createdAt);
} }
function hasPassedFastParsePhase(template: AgentPsdTemplate) { function hasPassedFastParsePhase(template: AgentPsdTemplate) {
@ -49,12 +76,24 @@ export function getAgentPsdTemplateParseAgeMs(
template: AgentPsdTemplate, template: AgentPsdTemplate,
now = Date.now() now = Date.now()
) { ) {
const updatedAt = getTemplateUpdatedAtTime(template); const heartbeatAt = getTemplateHeartbeatTime(template);
if (!updatedAt) { if (!heartbeatAt) {
return Number.POSITIVE_INFINITY; return Number.POSITIVE_INFINITY;
} }
return Math.max(0, now - updatedAt); return Math.max(0, now - heartbeatAt);
}
export function getAgentPsdTemplateParseDurationMs(
template: AgentPsdTemplate,
now = Date.now()
) {
const startedAt = getTemplateParseStartedAtTime(template);
if (!startedAt) {
return Number.POSITIVE_INFINITY;
}
return Math.max(0, now - startedAt);
} }
export function isAgentPsdTemplateParseTimedOut( export function isAgentPsdTemplateParseTimedOut(
@ -67,20 +106,32 @@ export function isAgentPsdTemplateParseTimedOut(
return ( return (
getAgentPsdTemplateParseAgeMs(template, now) > getAgentPsdTemplateParseAgeMs(template, now) >
getAgentPsdTemplateParseTimeoutMs(template) getAgentPsdTemplateParseTimeoutMs(template) ||
getAgentPsdTemplateParseDurationMs(template, now) >
AGENT_PSD_TEMPLATE_PARSE_MAX_DURATION_MS
); );
} }
function buildTimeoutMessage(template: AgentPsdTemplate) { function buildTimeoutMessage(template: AgentPsdTemplate, now = Date.now()) {
const timeoutMinutes = Math.round(
getAgentPsdTemplateParseTimeoutMs(template) / 60_000
);
const phase = hasPassedFastParsePhase(template) const phase = hasPassedFastParsePhase(template)
? "后台图层资产补全" ? "后台图层资产补全"
: "PSD 快速解析"; : "PSD 快速解析";
const stage = template.parseStage ? `,最后阶段:${template.parseStage}` : ""; const stage = template.parseStage ? `,最后阶段:${template.parseStage}` : "";
return `${phase}超过 ${timeoutMinutes} 分钟未更新进度,系统已自动结束本次任务${stage}。可检查 PSD 文件后重新解析。`; if (
getAgentPsdTemplateParseDurationMs(template, now) >
AGENT_PSD_TEMPLATE_PARSE_MAX_DURATION_MS
) {
const timeoutMinutes = Math.round(
AGENT_PSD_TEMPLATE_PARSE_MAX_DURATION_MS / 60_000
);
return `${phase}总耗时超过 ${timeoutMinutes} 分钟,系统已自动结束本次任务${stage}。请检查 PSD 是否过大、图层是否异常,或重新上传后再解析。`;
}
const timeoutMinutes = Math.round(
getAgentPsdTemplateParseTimeoutMs(template) / 60_000
);
return `${phase}超过 ${timeoutMinutes} 分钟没有收到解析心跳,系统已自动结束本次任务${stage}。可检查 PSD 文件后重新解析。`;
} }
export function failTimedOutAgentPsdTemplatesInMemory(params: { export function failTimedOutAgentPsdTemplatesInMemory(params: {
@ -107,7 +158,8 @@ export function failTimedOutAgentPsdTemplatesInMemory(params: {
parseStage: hasPassedFastParsePhase(template) parseStage: hasPassedFastParsePhase(template)
? "后台图层资产补全超时" ? "后台图层资产补全超时"
: "PSD 快速解析超时", : "PSD 快速解析超时",
parseError: buildTimeoutMessage(template), parseError: buildTimeoutMessage(template, now.getTime()),
parseHeartbeatAt: now.toISOString(),
updatedAt: now.toISOString(), updatedAt: now.toISOString(),
}; };
timedOutTemplates.push(nextTemplate); timedOutTemplates.push(nextTemplate);