diff --git a/app/api/admin/psd-templates/[id]/parse/route.ts b/app/api/admin/psd-templates/[id]/parse/route.ts index 3f8bf5895..796b8914b 100644 --- a/app/api/admin/psd-templates/[id]/parse/route.ts +++ b/app/api/admin/psd-templates/[id]/parse/route.ts @@ -1,11 +1,8 @@ import { NextResponse } from "next/server"; -import { upsertSystemSetting } from "@/lib/db/queries"; import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin"; import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { - AGENT_PSD_TEMPLATES_KEY, - type AgentPsdTemplate, type AgentPsdTemplateLayerSlot, type AgentPsdTemplateMutationResponse, } from "@/lib/agents/psd-template-config"; @@ -15,6 +12,11 @@ import { } 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 { + createAgentPsdTemplateParseHeartbeat, + updateAgentPsdTemplateById, + updateAgentPsdTemplateParseProgress, +} from "@/lib/agents/psd-template-progress"; import { cacheAgentPsdSourceLocally, 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: { templateId: string; updatedBy: string; progress: number; stage: string; error?: string; + restart?: boolean; }) { - const { templateId, updatedBy, progress, stage, error } = params; - return updateTemplate(templateId, updatedBy, (template) => ({ - ...template, - parseStatus: "processing", - parseProgress: progress, - parseStage: stage, - parseError: error, - updatedAt: new Date().toISOString(), - })); + return updateAgentPsdTemplateParseProgress(params); } export async function POST( @@ -231,137 +204,150 @@ export async function POST( return jsonError(409, "该模板正在解析中,请稍后刷新"); } - await updateParseProgress({ - templateId: id, - updatedBy: session.user.id, + let latestProgress = { progress: 5, stage: "准备解析 PSD", - error: undefined, - }); - - let fileBuffer: ArrayBuffer; - if ( - await hasAgentPsdLocalCache({ - templateId: id, - fileName: currentTemplate.fileName, - }) - ) { - await updateParseProgress({ + error: undefined as string | undefined, + }; + const setParseProgress = async (progress: number, stage: string, error?: string) => { + latestProgress = { + progress, + stage, + error, + }; + return updateParseProgress({ templateId: id, updatedBy: session.user.id, - progress: 18, - stage: "正在读取服务器本地 PSD 缓存", - error: undefined, - }); - 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), + progress, + stage, + error, }); + }; - if (!response.ok) { - throw new Error(`PSD 文件下载失败 (${response.status})`); + 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 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, updatedBy: session.user.id, - progress: 18, - stage: "正在下载并写入服务器本地 PSD 缓存", - error: undefined, + 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, + }; + }, }); - fileBuffer = await response.arrayBuffer(); - await cacheAgentPsdSourceLocally({ + if (!updatedTemplate) { + return jsonError(404, "未找到 PSD 模板"); + } + + schedulePsdTemplateAssetBackfill({ templateId: id, - fileName: currentTemplate.fileName, - buffer: fileBuffer, - }).catch((error) => { - console.warn("[PSD Templates][Admin] failed to cache PSD source locally:", { - templateId: id, - error, - }); + updatedBy: session.user.id, }); + + 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) { const message = getErrorMessage(error); const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id); @@ -372,18 +358,22 @@ export async function POST( ? "completed" : "failed"; - await updateTemplate(id, session.user.id, (template) => ({ - ...template, - parseStatus: fallbackStatus, - parseProgress: fallbackStatus === "completed" ? 100 : 0, - parseStage: - fallbackStatus === "completed" ? "沿用上次成功解析结果" : undefined, - parseError: - fallbackStatus === "completed" - ? `重新解析失败:${message}` - : message, - updatedAt: new Date().toISOString(), - })).catch(() => undefined); + await updateAgentPsdTemplateById({ + templateId: id, + updatedBy: session.user.id, + updater: (template) => ({ + ...template, + parseStatus: fallbackStatus, + parseProgress: fallbackStatus === "completed" ? 100 : 0, + parseStage: + fallbackStatus === "completed" ? "沿用上次成功解析结果" : undefined, + parseError: + fallbackStatus === "completed" + ? `重新解析失败:${message}` + : message, + updatedAt: new Date().toISOString(), + }), + }).catch(() => undefined); } console.error("[PSD Templates][Admin] parse failed:", error); diff --git a/lib/agents/psd-template-asset-backfill.ts b/lib/agents/psd-template-asset-backfill.ts index 19a4e903b..243116aab 100644 --- a/lib/agents/psd-template-asset-backfill.ts +++ b/lib/agents/psd-template-asset-backfill.ts @@ -1,14 +1,16 @@ 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 { + createAgentPsdTemplateParseHeartbeat, + updateAgentPsdTemplateById, + updateAgentPsdTemplateParseProgress, +} from "@/lib/agents/psd-template-progress"; import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache"; import { preparePsdTemplateRenderAssets, @@ -51,28 +53,6 @@ function getErrorMessage(error: unknown) { 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[]; @@ -152,79 +132,98 @@ async function backfillTemplateAssetsOnce(params: { 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 uploadAsset = await createStorageUploadSession(); - const preparedAssets = await preparePsdTemplateRenderAssets({ - templateId: template.id, - fileUrl: template.fileUrl, - filePath: resolveAgentPsdLocalCachePath({ + 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, - fileName: template.fileName, - }), - layers: template.layers, - uploadAsset, - }); - - await updateTemplateById({ + updatedBy: params.updatedBy, + progress, + stage, + error, + }); + }; + const heartbeat = createAgentPsdTemplateParseHeartbeat({ 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(), - }), + getSnapshot: () => latestProgress, }); - const uploadedRenderBase = await uploadAsset( - `agent-psd-base-${template.id}-${Date.now()}.png`, - toArrayBuffer(preparedAssets.renderBaseBuffer) - ); - const uploadedRenderSegments = await uploadPreparedPsdTemplateRenderSegments({ - templateId: template.id, - segments: preparedAssets.renderSegments, - uploadAsset, - }); + await setBackfillProgress(latestProgress.progress, latestProgress.stage); - await updateTemplateById({ - templateId: template.id, - updatedBy: params.updatedBy, - updater: (currentTemplate) => { - const mergedLayers = mergePreparedLayerAssets({ - currentLayers: currentTemplate.layers, - preparedLayers: preparedAssets.layers, - }); + 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); + }, + }); - 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, - updatedAt: new Date().toISOString(), - }; - }, - }); + 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: { @@ -253,7 +252,7 @@ export function schedulePsdTemplateAssetBackfill(params: { } while (jobState.rerun); } catch (error) { const message = getErrorMessage(error); - await updateTemplateById({ + await updateAgentPsdTemplateById({ templateId: params.templateId, updatedBy: params.updatedBy, updater: (currentTemplate) => ({ diff --git a/lib/agents/psd-template-config.ts b/lib/agents/psd-template-config.ts index 6b7ddec65..3bbee134f 100644 --- a/lib/agents/psd-template-config.ts +++ b/lib/agents/psd-template-config.ts @@ -105,6 +105,8 @@ export interface AgentPsdTemplate { parseProgress?: number; parseStage?: string; parseError?: string; + parseStartedAt?: string; + parseHeartbeatAt?: string; fileName: string; fileUrl: string; filePath?: string; @@ -528,6 +530,8 @@ export function parseAgentPsdTemplates(value: unknown) { const parseStatus = normalizeAgentPsdTemplateParseStatus(record.parseStatus); const rawParseProgress = normalizePositiveInteger(record.parseProgress); + const parseStartedAt = toIsoDateString(record.parseStartedAt) || undefined; + const parseHeartbeatAt = toIsoDateString(record.parseHeartbeatAt) || undefined; const categoryTags = normalizeAgentPsdTemplateCategoryTags( record.categoryTags ?? record.productCategoryTags @@ -549,6 +553,8 @@ export function parseAgentPsdTemplates(value: unknown) { : Math.min(100, Math.max(0, rawParseProgress)), parseStage: normalizeText(record.parseStage) || undefined, parseError: normalizeText(record.parseError) || undefined, + parseStartedAt, + parseHeartbeatAt, fileName, fileUrl, filePath: normalizeText(record.filePath) || undefined, diff --git a/lib/agents/psd-template-parser.ts b/lib/agents/psd-template-parser.ts index c62b6c870..a9e546efb 100644 --- a/lib/agents/psd-template-parser.ts +++ b/lib/agents/psd-template-parser.ts @@ -126,6 +126,26 @@ export interface PreparedPsdTemplateRenderSegment { buffer: Buffer; } +export interface PsdTemplateParseProgressEvent { + progress: number; + stage: string; +} + +type PsdTemplateParseProgressReporter = ( + event: PsdTemplateParseProgressEvent +) => void | Promise; + +async function reportPsdTemplateParseProgress( + reporter: PsdTemplateParseProgressReporter | undefined, + event: PsdTemplateParseProgressEvent +) { + if (!reporter) { + return; + } + + await reporter(event); +} + function resolveVisibleContentBounds(params: { rgba: Uint8ClampedArray; width: number; @@ -509,17 +529,44 @@ async function resolvePsdSourceBuffer(params: { } export async function parsePsdTemplateFile( - buffer: ArrayBuffer + buffer: ArrayBuffer, + options?: { + onProgress?: PsdTemplateParseProgressReporter; + } ): Promise { + await reportPsdTemplateParseProgress(options?.onProgress, { + progress: 26, + stage: "正在加载 PSD 解析引擎", + }); const psd = await parsePsdBuffer(buffer); const width = Math.max(1, Math.round(Number(psd.width || 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(); + + await reportPsdTemplateParseProgress(options?.onProgress, { + progress: 35, + stage: "正在生成 PSD 预览图", + }); const previewBuffer = await rgbaToPngBuffer(previewRgba, width, height); + + await reportPsdTemplateParseProgress(options?.onProgress, { + progress: 39, + stage: "正在扫描可配置图层", + }); const layers = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []).map( (entry) => entry.layer ); + await reportPsdTemplateParseProgress(options?.onProgress, { + progress: 41, + stage: `已识别 ${layers.length} 个可配置图层`, + }); + return { width, height, @@ -705,11 +752,17 @@ export async function uploadPreparedPsdTemplateRenderSegments(params: { async function mapWithConcurrency( items: T[], concurrency: number, - mapper: (item: T, index: number) => Promise + mapper: (item: T, index: number) => Promise, + onItemComplete?: (params: { + completed: number; + total: number; + index: number; + }) => void | Promise ) { const results = new Array(items.length); const workerCount = Math.max(1, Math.min(Math.round(concurrency), items.length || 1)); let nextIndex = 0; + let completed = 0; await Promise.all( Array.from({ length: workerCount }, async () => { @@ -717,6 +770,12 @@ async function mapWithConcurrency( const currentIndex = nextIndex; nextIndex += 1; 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; assetConcurrency?: number; uploadAsset?: StorageUploadFunction; + onProgress?: PsdTemplateParseProgressReporter; }) { const assetProfile = params.assetProfile || "fast-runtime"; const assetConcurrency = params.assetConcurrency || DEFAULT_PSD_TEMPLATE_ASSET_CONCURRENCY; const uploadAsset = params.uploadAsset || uploadFile; + + await reportPsdTemplateParseProgress(params.onProgress, { + progress: 63, + stage: "正在读取 PSD 源文件并准备补全资产", + }); const psdBuffer = await resolvePsdSourceBuffer({ fileUrl: params.fileUrl, filePath: params.filePath, fileBuffer: params.fileBuffer, }); + + await reportPsdTemplateParseProgress(params.onProgress, { + progress: 65, + stage: "正在解析 PSD 图层资源", + }); const psd = await parsePsdBuffer(psdBuffer); const canvasWidth = Math.max(1, Math.round(Number(psd.width || 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]; + await reportPsdTemplateParseProgress(params.onProgress, { + progress: 66, + stage: `正在补全图层缩略图和源图 0/${nextLayers.length}`, + }); + const preparedLayerResults = await mapWithConcurrency( nextLayers, assetConcurrency, @@ -934,6 +1009,17 @@ export async function preparePsdTemplateRenderAssets(params: { } 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() .filter((entry) => entry.node && typeof entry.node.composite === "function"); + await reportPsdTemplateParseProgress(params.onProgress, { + progress: 79, + stage: `正在生成模板底图图层 0/${drawableEntries.length}`, + }); + const layerCompositeResults = await mapWithConcurrency( drawableEntries, assetConcurrency, @@ -996,6 +1087,17 @@ export async function preparePsdTemplateRenderAssets(params: { }); 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); const sharp = await getSharp(); + + await reportPsdTemplateParseProgress(params.onProgress, { + progress: 89, + stage: "正在合成模板底图", + }); const transparentBaseBuffer = await createTransparentCanvasBuffer( canvasWidth, canvasHeight @@ -1042,6 +1149,10 @@ export async function preparePsdTemplateRenderAssets(params: { for (const result of sortedLayerCompositeResults) { if (result.editableBoundary) { if (pendingSegmentInputs.length > 0) { + await reportPsdTemplateParseProgress(params.onProgress, { + progress: 92, + stage: "正在生成快速导出分层", + }); renderSegments.push({ id: `before-${sanitizeFileSegment( result.beforeLayerId, @@ -1061,6 +1172,10 @@ export async function preparePsdTemplateRenderAssets(params: { } if (pendingSegmentInputs.length > 0) { + await reportPsdTemplateParseProgress(params.onProgress, { + progress: 94, + stage: "正在生成顶部快速导出分层", + }); renderSegments.push({ id: "above-editable-layers", buffer: await composeSegmentBuffer(pendingSegmentInputs), diff --git a/lib/agents/psd-template-progress.ts b/lib/agents/psd-template-progress.ts new file mode 100644 index 000000000..c82a45437 --- /dev/null +++ b/lib/agents/psd-template-progress.ts @@ -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); + }, + }; +} diff --git a/lib/agents/psd-template-timeout.ts b/lib/agents/psd-template-timeout.ts index ef446b6b9..f0e401db7 100644 --- a/lib/agents/psd-template-timeout.ts +++ b/lib/agents/psd-template-timeout.ts @@ -20,15 +20,42 @@ export const AGENT_PSD_TEMPLATE_ASSET_BACKFILL_TIMEOUT_MS = getEnvPositiveInt( "AGENT_PSD_TEMPLATE_ASSET_BACKFILL_TIMEOUT_MS", 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) { - const updatedAt = new Date(template.updatedAt).getTime(); - if (Number.isFinite(updatedAt)) { +function getIsoTime(value?: string) { + const time = new Date(value || "").getTime(); + 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; } - const createdAt = new Date(template.createdAt).getTime(); - return Number.isFinite(createdAt) ? createdAt : null; + return getIsoTime(template.createdAt); +} + +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) { @@ -49,12 +76,24 @@ export function getAgentPsdTemplateParseAgeMs( template: AgentPsdTemplate, now = Date.now() ) { - const updatedAt = getTemplateUpdatedAtTime(template); - if (!updatedAt) { + const heartbeatAt = getTemplateHeartbeatTime(template); + if (!heartbeatAt) { 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( @@ -67,20 +106,32 @@ export function isAgentPsdTemplateParseTimedOut( return ( getAgentPsdTemplateParseAgeMs(template, now) > - getAgentPsdTemplateParseTimeoutMs(template) + getAgentPsdTemplateParseTimeoutMs(template) || + getAgentPsdTemplateParseDurationMs(template, now) > + AGENT_PSD_TEMPLATE_PARSE_MAX_DURATION_MS ); } -function buildTimeoutMessage(template: AgentPsdTemplate) { - const timeoutMinutes = Math.round( - getAgentPsdTemplateParseTimeoutMs(template) / 60_000 - ); +function buildTimeoutMessage(template: AgentPsdTemplate, now = Date.now()) { const phase = hasPassedFastParsePhase(template) ? "后台图层资产补全" : "PSD 快速解析"; 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: { @@ -107,7 +158,8 @@ export function failTimedOutAgentPsdTemplatesInMemory(params: { parseStage: hasPassedFastParsePhase(template) ? "后台图层资产补全超时" : "PSD 快速解析超时", - parseError: buildTimeoutMessage(template), + parseError: buildTimeoutMessage(template, now.getTime()), + parseHeartbeatAt: now.toISOString(), updatedAt: now.toISOString(), }; timedOutTemplates.push(nextTemplate);