diff --git a/app/api/admin/psd-templates/[id]/parse/route.ts b/app/api/admin/psd-templates/[id]/parse/route.ts index d99c9ce69..5befacedc 100644 --- a/app/api/admin/psd-templates/[id]/parse/route.ts +++ b/app/api/admin/psd-templates/[id]/parse/route.ts @@ -21,6 +21,7 @@ import { readAgentPsdSourceFromLocalCache, } from "@/lib/agents/psd-local-cache"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; +import { failTimedOutAgentPsdTemplates } from "@/lib/agents/psd-template-timeout"; import { uploadFile } from "@/lib/storage/service"; export const runtime = "nodejs"; @@ -208,7 +209,17 @@ export async function POST( const { id } = await context.params; try { - const templates = await readAgentPsdTemplates(); + const { templates, timedOutTemplates } = await failTimedOutAgentPsdTemplates({ + updatedBy: session.user.id, + }); + if (timedOutTemplates.length > 0) { + console.warn("[PSD Templates][Admin] cleared timed out parsing templates before parse:", { + requestedTemplateId: id, + count: timedOutTemplates.length, + templateIds: timedOutTemplates.map((template) => template.id), + }); + } + const currentTemplate = templates.find((entry) => entry.id === id); if (!currentTemplate) { diff --git a/app/api/admin/psd-templates/[id]/route.ts b/app/api/admin/psd-templates/[id]/route.ts index 15c2506df..e5418bfb1 100644 --- a/app/api/admin/psd-templates/[id]/route.ts +++ b/app/api/admin/psd-templates/[id]/route.ts @@ -20,11 +20,12 @@ import { } from "@/lib/agents/psd-template-parser"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair"; -import { uploadFile } from "@/lib/storage/service"; +import { createStorageUploadSession } from "@/lib/storage/service"; import { readAgentPsdTemplates, tryDeleteAgentPsdTemplateAssets, } from "@/lib/agents/psd-template-settings"; +import { failTimedOutAgentPsdTemplates } from "@/lib/agents/psd-template-timeout"; export const runtime = "nodejs"; @@ -58,7 +59,9 @@ export async function GET( try { const { id } = await context.params; - const templates = await readAgentPsdTemplates(); + const { templates } = await failTimedOutAgentPsdTemplates({ + updatedBy: session.user.id, + }); const template = templates.find((entry) => entry.id === id); if (!template) { @@ -196,6 +199,7 @@ export async function PUT( } } + const uploadAsset = await createStorageUploadSession(); const preparedAssets = await preparePsdTemplateRenderAssets({ templateId: currentTemplate.id, fileUrl: currentTemplate.fileUrl, @@ -204,9 +208,10 @@ export async function PUT( fileName: currentTemplate.fileName, }), layers: nextLayers, + uploadAsset, }); nextLayers = preparedAssets.layers; - const uploadedRenderBase = await uploadFile( + const uploadedRenderBase = await uploadAsset( `agent-psd-base-${currentTemplate.id}-${Date.now()}.png`, preparedAssets.renderBaseBuffer.buffer.slice( preparedAssets.renderBaseBuffer.byteOffset, diff --git a/app/api/admin/psd-templates/route.ts b/app/api/admin/psd-templates/route.ts index 699667f22..259748d50 100644 --- a/app/api/admin/psd-templates/route.ts +++ b/app/api/admin/psd-templates/route.ts @@ -17,6 +17,7 @@ import { import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config"; import { cacheAgentPsdSourceLocally } from "@/lib/agents/psd-local-cache"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; +import { failTimedOutAgentPsdTemplates } from "@/lib/agents/psd-template-timeout"; export const runtime = "nodejs"; @@ -66,7 +67,16 @@ export async function GET() { } try { - const templates = await readAgentPsdTemplates(); + const { templates, timedOutTemplates } = await failTimedOutAgentPsdTemplates({ + updatedBy: session.user.id, + }); + if (timedOutTemplates.length > 0) { + console.warn("[PSD Templates][Admin] timed out stale parsing templates:", { + count: timedOutTemplates.length, + templateIds: timedOutTemplates.map((template) => template.id), + }); + } + return NextResponse.json({ success: true, templates, diff --git a/lib/agents/psd-template-asset-backfill.ts b/lib/agents/psd-template-asset-backfill.ts index d54ead39c..aceb30a3e 100644 --- a/lib/agents/psd-template-asset-backfill.ts +++ b/lib/agents/psd-template-asset-backfill.ts @@ -12,7 +12,7 @@ import { 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"; +import { createStorageUploadSession } from "@/lib/storage/service"; type PsdTemplateAssetBackfillJobState = { running: boolean; @@ -162,6 +162,7 @@ async function backfillTemplateAssetsOnce(params: { }), }); + const uploadAsset = await createStorageUploadSession(); const preparedAssets = await preparePsdTemplateRenderAssets({ templateId: template.id, fileUrl: template.fileUrl, @@ -170,6 +171,7 @@ async function backfillTemplateAssetsOnce(params: { fileName: template.fileName, }), layers: template.layers, + uploadAsset, }); await updateTemplateById({ @@ -185,7 +187,7 @@ async function backfillTemplateAssetsOnce(params: { }), }); - const uploadedRenderBase = await uploadFile( + const uploadedRenderBase = await uploadAsset( `agent-psd-base-${template.id}-${Date.now()}.png`, toArrayBuffer(preparedAssets.renderBaseBuffer) ); @@ -208,7 +210,7 @@ async function backfillTemplateAssetsOnce(params: { assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION, parseStatus: "completed", parseProgress: 100, - parseStage: "快速解析和后台资产补全已完成", + parseStage: "快速解析和可用资产补全已完成", parseError: undefined, updatedAt: new Date().toISOString(), }; diff --git a/lib/agents/psd-template-config.ts b/lib/agents/psd-template-config.ts index c111ad73d..66c9d2077 100644 --- a/lib/agents/psd-template-config.ts +++ b/lib/agents/psd-template-config.ts @@ -554,8 +554,24 @@ export function hasAgentPsdTemplateDedicatedRenderBase( } export function getAgentPsdTemplateRuntimeMissingLayers(template: AgentPsdTemplate) { + const renderBaseUrl = String(template.renderBaseUrl || "").trim(); + const hasUsableRenderBase = + hasAgentPsdTemplateDedicatedRenderBase(template) && + Boolean(renderBaseUrl) && + renderBaseUrl !== String(template.previewUrl || "").trim(); + return template.layers.filter( - (layer) => !hasAgentPsdTemplateStoredLayerSource(layer) + (layer) => { + if (hasUsableRenderBase) { + return ( + isAgentPsdImageLayerSlot(layer) && + layer.isEditable && + !hasAgentPsdTemplateStoredLayerSource(layer) + ); + } + + return !hasAgentPsdTemplateStoredLayerSource(layer); + } ); } diff --git a/lib/agents/psd-template-parser.ts b/lib/agents/psd-template-parser.ts index 0fe81933f..6b24ea6a7 100644 --- a/lib/agents/psd-template-parser.ts +++ b/lib/agents/psd-template-parser.ts @@ -1,7 +1,7 @@ import "server-only"; import { promises as fs } from "node:fs"; -import { uploadFile } from "@/lib/storage/service"; +import { uploadFile, type StorageUploadFunction } from "@/lib/storage/service"; import { normalizeDetectedFontNames } from "@/lib/agents/psd-font-matching"; import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset"; import { analyzeTextLayerPixels, normalizeHexColor } from "@/lib/agents/psd-text-layout"; @@ -12,6 +12,10 @@ import { type AgentPsdTemplateLayerSlot, } from "@/lib/agents/psd-template-config"; +const DEFAULT_PSD_TEMPLATE_ASSET_CONCURRENCY = 4; + +type PsdTemplateLayerAssetProfile = "fast-runtime" | "full"; + type PsdFontSetEntry = { Name?: string; }; @@ -654,6 +658,53 @@ async function fitOverlayBufferIntoCanvas(params: { }; } +function toArrayBuffer(buffer: Buffer) { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) as ArrayBuffer; +} + +async function mapWithConcurrency( + items: T[], + concurrency: number, + mapper: (item: T, index: number) => Promise +) { + const results = new Array(items.length); + const workerCount = Math.max(1, Math.min(Math.round(concurrency), items.length || 1)); + let nextIndex = 0; + + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const currentIndex = nextIndex; + nextIndex += 1; + results[currentIndex] = await mapper(items[currentIndex], currentIndex); + } + }) + ); + + return results; +} + +function shouldPrepareLayerPreview( + layer: AgentPsdTemplateLayerSlot, + assetProfile: PsdTemplateLayerAssetProfile +) { + return assetProfile === "full" || layer.isEditable; +} + +function shouldPrepareLayerSource( + layer: AgentPsdTemplateLayerSlot, + assetProfile: PsdTemplateLayerAssetProfile +) { + if (assetProfile === "full") { + return true; + } + + return isAgentPsdImageLayerSlot(layer) && layer.isEditable; +} + export async function preparePsdTemplateRenderAssets(params: { templateId: string; fileUrl?: string; @@ -661,7 +712,14 @@ export async function preparePsdTemplateRenderAssets(params: { fileBuffer?: ArrayBuffer | Buffer; layers: AgentPsdTemplateLayerSlot[]; forceRefreshLayerAssets?: boolean; + assetProfile?: PsdTemplateLayerAssetProfile; + assetConcurrency?: number; + uploadAsset?: StorageUploadFunction; }) { + const assetProfile = params.assetProfile || "fast-runtime"; + const assetConcurrency = + params.assetConcurrency || DEFAULT_PSD_TEMPLATE_ASSET_CONCURRENCY; + const uploadAsset = params.uploadAsset || uploadFile; const psdBuffer = await resolvePsdSourceBuffer({ fileUrl: params.fileUrl, filePath: params.filePath, @@ -676,183 +734,223 @@ export async function preparePsdTemplateRenderAssets(params: { const nextLayers = [...params.layers]; - for (const targetLayer of nextLayers) { - const entry = nodeMap.get(targetLayer.layerId); - if (!entry?.node || typeof entry.node.composite !== "function") { - continue; - } - - try { - const rgba = await entry.node.composite(); - const visibleContentBounds = resolveVisibleContentBounds({ - rgba, - width: entry.layer.width, - height: entry.layer.height, - }); - - if (params.forceRefreshLayerAssets || !targetLayer.previewUrl) { - const thumbnailBuffer = await createLayerThumbnailBuffer({ - rgba, - width: entry.layer.width, - height: entry.layer.height, - }); - const uploadedPreview = await uploadFile( - `agent-psd-layer-preview-${params.templateId}-${sanitizeFileSegment(targetLayer.layerId, targetLayer.id)}.png`, - thumbnailBuffer.buffer.slice( - thumbnailBuffer.byteOffset, - thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength - ) as ArrayBuffer - ); - const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id); - if (layerIndex >= 0) { - nextLayers[layerIndex] = { - ...nextLayers[layerIndex], - previewUrl: uploadedPreview.url, - previewPath: uploadedPreview.pathname, - }; - } - } - - if (params.forceRefreshLayerAssets || !String(targetLayer.sourceUrl || "").trim()) { - const sourceBuffer = await rgbaToPngBuffer(rgba, entry.layer.width, entry.layer.height); - const uploadedSource = await uploadFile( - `agent-psd-layer-source-${params.templateId}-${sanitizeFileSegment(targetLayer.layerId, targetLayer.id)}.png`, - sourceBuffer.buffer.slice( - sourceBuffer.byteOffset, - sourceBuffer.byteOffset + sourceBuffer.byteLength - ) as ArrayBuffer - ); - const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id); - if (layerIndex >= 0) { - nextLayers[layerIndex] = { - ...nextLayers[layerIndex], - sourceUrl: uploadedSource.url, - sourcePath: uploadedSource.pathname, - }; - } - } - - if (isAgentPsdTextLayerSlot(targetLayer) && targetLayer.styleSource !== "custom") { - const analysis = analyzeTextLayerPixels({ - rgba, - width: entry.layer.width, - height: entry.layer.height, - }); - const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id); - if (analysis && layerIndex >= 0 && isAgentPsdTextLayerSlot(nextLayers[layerIndex])) { - const currentLayer = nextLayers[layerIndex]; - let nextLayer = currentLayer; - - if (normalizeHexColor(currentLayer.textColor) === "#111111") { - nextLayer = { - ...nextLayer, - textColor: analysis.dominantColor, - }; - } - - if (currentLayer.textAlign === "left" && analysis.inferredTextAlign !== "left") { - nextLayer = { - ...nextLayer, - textAlign: analysis.inferredTextAlign, - }; - } - - if ( - currentLayer.paddingX <= 0 && - Number.isFinite(Number(analysis.suggestedPaddingX)) && - Number(analysis.suggestedPaddingX) > 0 - ) { - nextLayer = { - ...nextLayer, - paddingX: Math.max(0, Number(analysis.suggestedPaddingX)), - }; - } - - if ( - currentLayer.paddingY <= 0 && - Number.isFinite(Number(analysis.suggestedPaddingY)) && - Number(analysis.suggestedPaddingY) > 0 - ) { - nextLayer = { - ...nextLayer, - paddingY: Math.max(0, Number(analysis.suggestedPaddingY)), - }; - } - - if (nextLayer !== currentLayer) { - nextLayers[layerIndex] = nextLayer; - } - } - } + const preparedLayerResults = await mapWithConcurrency( + nextLayers, + assetConcurrency, + async (targetLayer) => { + let preparedLayer = targetLayer; + const shouldUploadPreview = + shouldPrepareLayerPreview(targetLayer, assetProfile) && + (params.forceRefreshLayerAssets || !targetLayer.previewUrl); + const shouldUploadSource = + shouldPrepareLayerSource(targetLayer, assetProfile) && + (params.forceRefreshLayerAssets || + !String(targetLayer.sourceUrl || "").trim()); + const shouldAnalyzeText = + targetLayer.isEditable && + isAgentPsdTextLayerSlot(targetLayer) && + targetLayer.styleSource !== "custom"; + const shouldResolveImageBounds = + targetLayer.isEditable && isAgentPsdImageLayerSlot(targetLayer); if ( - !isAgentPsdImageLayerSlot(targetLayer) || - !targetLayer.isEditable + !shouldUploadPreview && + !shouldUploadSource && + !shouldAnalyzeText && + !shouldResolveImageBounds ) { - const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id); - if (layerIndex >= 0 && isAgentPsdImageLayerSlot(nextLayers[layerIndex])) { - nextLayers[layerIndex] = { - ...nextLayers[layerIndex], - contentBounds: visibleContentBounds, - }; + return preparedLayer; + } + + const entry = nodeMap.get(targetLayer.layerId); + if (!entry?.node || typeof entry.node.composite !== "function") { + return preparedLayer; + } + + try { + const rgba = await entry.node.composite!(); + const visibleContentBounds = resolveVisibleContentBounds({ + rgba, + width: entry.layer.width, + height: entry.layer.height, + }); + + if (shouldUploadPreview) { + const thumbnailBuffer = await createLayerThumbnailBuffer({ + rgba, + width: entry.layer.width, + height: entry.layer.height, + }); + const uploadedPreview = await uploadAsset( + `agent-psd-layer-preview-${params.templateId}-${sanitizeFileSegment(targetLayer.layerId, targetLayer.id)}.png`, + toArrayBuffer(thumbnailBuffer) + ); + preparedLayer = { + ...preparedLayer, + previewUrl: uploadedPreview.url, + previewPath: uploadedPreview.pathname, + } satisfies AgentPsdTemplateLayerSlot; } - continue; + + if (shouldUploadSource) { + const sourceBuffer = await rgbaToPngBuffer( + rgba, + entry.layer.width, + entry.layer.height + ); + const uploadedSource = await uploadAsset( + `agent-psd-layer-source-${params.templateId}-${sanitizeFileSegment(targetLayer.layerId, targetLayer.id)}.png`, + toArrayBuffer(sourceBuffer) + ); + preparedLayer = { + ...preparedLayer, + sourceUrl: uploadedSource.url, + sourcePath: uploadedSource.pathname, + } satisfies AgentPsdTemplateLayerSlot; + } + + if (shouldAnalyzeText) { + const analysis = analyzeTextLayerPixels({ + rgba, + width: entry.layer.width, + height: entry.layer.height, + }); + if (analysis && isAgentPsdTextLayerSlot(preparedLayer)) { + let nextLayer = preparedLayer; + + if (normalizeHexColor(preparedLayer.textColor) === "#111111") { + nextLayer = { + ...nextLayer, + textColor: analysis.dominantColor, + }; + } + + if (preparedLayer.textAlign === "left" && analysis.inferredTextAlign !== "left") { + nextLayer = { + ...nextLayer, + textAlign: analysis.inferredTextAlign, + }; + } + + if ( + preparedLayer.paddingX <= 0 && + Number.isFinite(Number(analysis.suggestedPaddingX)) && + Number(analysis.suggestedPaddingX) > 0 + ) { + nextLayer = { + ...nextLayer, + paddingX: Math.max(0, Number(analysis.suggestedPaddingX)), + }; + } + + if ( + preparedLayer.paddingY <= 0 && + Number.isFinite(Number(analysis.suggestedPaddingY)) && + Number(analysis.suggestedPaddingY) > 0 + ) { + nextLayer = { + ...nextLayer, + paddingY: Math.max(0, Number(analysis.suggestedPaddingY)), + }; + } + + preparedLayer = nextLayer; + } + } + + if (!isAgentPsdImageLayerSlot(targetLayer) || !targetLayer.isEditable) { + if (isAgentPsdImageLayerSlot(preparedLayer)) { + preparedLayer = { + ...preparedLayer, + contentBounds: visibleContentBounds, + } satisfies AgentPsdTemplateLayerSlot; + } + return preparedLayer; + } + + if (isAgentPsdImageLayerSlot(preparedLayer)) { + preparedLayer = { + ...preparedLayer, + contentBounds: visibleContentBounds, + // Do not auto-reuse the original PSD image alpha as a hard replacement mask. + // Otherwise newly assigned images get trapped inside the source image silhouette. + maskUrl: undefined, + maskPath: undefined, + } satisfies AgentPsdTemplateLayerSlot; + } + } catch (error) { + console.warn("[PSD Template] Failed to prepare editable image layer assets:", { + layerId: targetLayer.layerId, + error, + }); } - const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id); - if (layerIndex >= 0 && isAgentPsdImageLayerSlot(nextLayers[layerIndex])) { - nextLayers[layerIndex] = { - ...nextLayers[layerIndex], - contentBounds: visibleContentBounds, - // Do not auto-reuse the original PSD image alpha as a hard replacement mask. - // Otherwise newly assigned images get trapped inside the source image silhouette. - maskUrl: undefined, - maskPath: undefined, + return preparedLayer; + } + ); + + preparedLayerResults.forEach((layer, index) => { + nextLayers[index] = layer; + }); + + const baseEntries = [...layerEntries] + .reverse() + .filter( + (entry) => + !editableLayerIdSet.has(entry.layer.layerId) && + entry.node && + typeof entry.node.composite === "function" + ); + + const baseCompositeResults = await mapWithConcurrency( + baseEntries, + assetConcurrency, + async (entry, index) => { + try { + const rgba = await entry.node.composite!(); + const overlayBuffer = await rgbaToPngBuffer( + rgba, + entry.layer.width, + entry.layer.height + ); + const fittedOverlay = await fitOverlayBufferIntoCanvas({ + input: overlayBuffer, + left: entry.layer.x, + top: entry.layer.y, + width: entry.layer.width, + height: entry.layer.height, + canvasWidth: Math.max(1, Math.round(Number(psd.width || 0))), + canvasHeight: Math.max(1, Math.round(Number(psd.height || 0))), + }); + if (!fittedOverlay) { + return null; + } + + return { + index, + input: fittedOverlay, }; + } catch (error) { + console.warn("[PSD Template] Failed to compose base layer:", { + layerId: entry.layer.layerId, + error, + }); + return null; } - } catch (error) { - console.warn("[PSD Template] Failed to prepare editable image layer assets:", { - layerId: targetLayer.layerId, - error, - }); } - } + ); - const baseCompositeInputs: Array<{ input: Buffer; left: number; top: number }> = []; - - for (const entry of [...layerEntries].reverse()) { - if (editableLayerIdSet.has(entry.layer.layerId)) { - continue; - } - - if (!entry.node || typeof entry.node.composite !== "function") { - continue; - } - - try { - const rgba = await entry.node.composite(); - const overlayBuffer = await rgbaToPngBuffer(rgba, entry.layer.width, entry.layer.height); - const fittedOverlay = await fitOverlayBufferIntoCanvas({ - input: overlayBuffer, - left: entry.layer.x, - top: entry.layer.y, - width: entry.layer.width, - height: entry.layer.height, - canvasWidth: Math.max(1, Math.round(Number(psd.width || 0))), - canvasHeight: Math.max(1, Math.round(Number(psd.height || 0))), - }); - if (!fittedOverlay) { - continue; - } - - baseCompositeInputs.push(fittedOverlay); - } catch (error) { - console.warn("[PSD Template] Failed to compose base layer:", { - layerId: entry.layer.layerId, - error, - }); - } - } + const baseCompositeInputs = baseCompositeResults + .filter( + ( + result + ): result is { + index: number; + input: { input: Buffer; left: number; top: number }; + } => Boolean(result) + ) + .sort((left, right) => left.index - right.index) + .map((result) => result.input); const sharp = await getSharp(); const transparentBaseBuffer = await createTransparentCanvasBuffer(psd.width, psd.height); diff --git a/lib/agents/psd-template-renderer.ts b/lib/agents/psd-template-renderer.ts index bdfd9173a..4ed4e4a8b 100644 --- a/lib/agents/psd-template-renderer.ts +++ b/lib/agents/psd-template-renderer.ts @@ -386,6 +386,13 @@ export async function renderAgentPsdTemplate(params: { .map((layerId) => String(layerId || "").trim()) .filter(Boolean) ); + const renderBaseUrl = String(template.renderBaseUrl || "").trim(); + const canUseDedicatedRenderBase = + Boolean(renderBaseUrl) && + renderBaseUrl !== String(template.previewUrl || "").trim() && + !template.layers.some( + (layer) => !layer.isEditable && hiddenLayerIdSet.has(layer.id) + ); async function readCachedBuffer(url: string, label = "PSD 模板资源") { const normalizedUrl = String(url || "").trim(); @@ -469,12 +476,27 @@ export async function renderAgentPsdTemplate(params: { } } + if (canUseDedicatedRenderBase) { + const renderBaseBuffer = await readCachedBuffer(renderBaseUrl, "PSD 模板底图"); + await appendBufferOverlay({ + input: renderBaseBuffer, + left: 0, + top: 0, + width: canvasWidth, + height: canvasHeight, + }); + } + for (const layer of [...template.layers].reverse()) { if (hiddenLayerIdSet.has(layer.id)) { continue; } if (!layer.isEditable) { + if (canUseDedicatedRenderBase) { + continue; + } + await appendStoredLayer(layer); continue; } diff --git a/lib/agents/psd-template-repair.ts b/lib/agents/psd-template-repair.ts index 943137406..c4b60ca29 100644 --- a/lib/agents/psd-template-repair.ts +++ b/lib/agents/psd-template-repair.ts @@ -21,13 +21,27 @@ import { preparePsdTemplateRenderAssets, } from "@/lib/agents/psd-template-parser"; import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache"; -import { uploadFile } from "@/lib/storage/service"; +import { createStorageUploadSession } from "@/lib/storage/service"; function hasMissingLayerAssets(template: AgentPsdTemplate) { + const hasRenderBase = hasDedicatedRenderBase(template); + return template.layers.some( - (layer) => - !String(layer.previewUrl || "").trim() || - !String(layer.sourceUrl || "").trim() + (layer) => { + if (layer.isEditable && !String(layer.previewUrl || "").trim()) { + return true; + } + + if (hasRenderBase) { + return ( + isAgentPsdImageLayerSlot(layer) && + layer.isEditable && + !String(layer.sourceUrl || "").trim() + ); + } + + return !String(layer.sourceUrl || "").trim(); + } ); } @@ -44,10 +58,7 @@ function needsTextMetadataRepair(template: AgentPsdTemplate) { function hasDedicatedRenderBase(template: AgentPsdTemplate) { const renderBaseUrl = String(template.renderBaseUrl || "").trim(); const previewUrl = String(template.previewUrl || "").trim(); - return Boolean(String(template.renderBasePath || "").trim()) || ( - Boolean(renderBaseUrl) && - renderBaseUrl !== previewUrl - ); + return Boolean(renderBaseUrl) && renderBaseUrl !== previewUrl; } function repairEditableImageLayerSelection(template: AgentPsdTemplate) { @@ -292,6 +303,7 @@ export async function repairAgentPsdTemplateAssetsIfNeeded( } try { + const uploadAsset = await createStorageUploadSession(); const preparedAssets = await preparePsdTemplateRenderAssets({ templateId: template.id, fileUrl: template.fileUrl, @@ -301,10 +313,11 @@ export async function repairAgentPsdTemplateAssetsIfNeeded( }), layers: nextLayers, forceRefreshLayerAssets: needsAssetVersionRefresh, + uploadAsset, }); const uploadedRenderBase = needsRenderBaseRepair - ? await uploadFile( + ? await uploadAsset( `agent-psd-base-${template.id}-${Date.now()}.png`, toArrayBuffer(preparedAssets.renderBaseBuffer) ) diff --git a/lib/agents/psd-template-timeout.ts b/lib/agents/psd-template-timeout.ts new file mode 100644 index 000000000..f41afcf20 --- /dev/null +++ b/lib/agents/psd-template-timeout.ts @@ -0,0 +1,132 @@ +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"; + +export const AGENT_PSD_TEMPLATE_QUICK_PARSE_TIMEOUT_MS = 8 * 60 * 1000; +export const AGENT_PSD_TEMPLATE_ASSET_BACKFILL_TIMEOUT_MS = 20 * 60 * 1000; + +function getTemplateUpdatedAtTime(template: AgentPsdTemplate) { + const updatedAt = new Date(template.updatedAt).getTime(); + if (Number.isFinite(updatedAt)) { + return updatedAt; + } + + const createdAt = new Date(template.createdAt).getTime(); + return Number.isFinite(createdAt) ? createdAt : null; +} + +function hasPassedFastParsePhase(template: AgentPsdTemplate) { + return ( + Number(template.parseProgress || 0) >= 48 || + template.layers.length > 0 || + (Number(template.width || 0) > 1 && Number(template.height || 0) > 1) + ); +} + +export function getAgentPsdTemplateParseTimeoutMs(template: AgentPsdTemplate) { + return hasPassedFastParsePhase(template) + ? AGENT_PSD_TEMPLATE_ASSET_BACKFILL_TIMEOUT_MS + : AGENT_PSD_TEMPLATE_QUICK_PARSE_TIMEOUT_MS; +} + +export function getAgentPsdTemplateParseAgeMs( + template: AgentPsdTemplate, + now = Date.now() +) { + const updatedAt = getTemplateUpdatedAtTime(template); + if (!updatedAt) { + return Number.POSITIVE_INFINITY; + } + + return Math.max(0, now - updatedAt); +} + +export function isAgentPsdTemplateParseTimedOut( + template: AgentPsdTemplate, + now = Date.now() +) { + if (template.parseStatus !== "processing") { + return false; + } + + return ( + getAgentPsdTemplateParseAgeMs(template, now) > + getAgentPsdTemplateParseTimeoutMs(template) + ); +} + +function buildTimeoutMessage(template: AgentPsdTemplate) { + const timeoutMinutes = Math.round( + getAgentPsdTemplateParseTimeoutMs(template) / 60_000 + ); + const phase = hasPassedFastParsePhase(template) + ? "后台图层资产补全" + : "PSD 快速解析"; + const stage = template.parseStage ? `,最后阶段:${template.parseStage}` : ""; + + return `${phase}超过 ${timeoutMinutes} 分钟未更新进度,系统已自动结束本次任务${stage}。可检查 PSD 文件后重新解析。`; +} + +export function failTimedOutAgentPsdTemplatesInMemory(params: { + templates: AgentPsdTemplate[]; + now?: Date; +}) { + const now = params.now || new Date(); + let changed = false; + const timedOutTemplates: AgentPsdTemplate[] = []; + + const templates = params.templates.map((template) => { + if (!isAgentPsdTemplateParseTimedOut(template, now.getTime())) { + return template; + } + + changed = true; + const nextTemplate: AgentPsdTemplate = { + ...template, + parseStatus: "failed", + parseProgress: Math.min( + 95, + Math.max(0, Math.round(Number(template.parseProgress || 0))) + ), + parseStage: hasPassedFastParsePhase(template) + ? "后台图层资产补全超时" + : "PSD 快速解析超时", + parseError: buildTimeoutMessage(template), + updatedAt: now.toISOString(), + }; + timedOutTemplates.push(nextTemplate); + return nextTemplate; + }); + + return { + changed, + templates, + timedOutTemplates, + }; +} + +export async function failTimedOutAgentPsdTemplates(params: { + updatedBy: string; + now?: Date; +}) { + const currentTemplates = await readAgentPsdTemplates(); + const result = failTimedOutAgentPsdTemplatesInMemory({ + templates: currentTemplates, + now: params.now, + }); + + if (result.changed) { + await upsertSystemSetting({ + key: AGENT_PSD_TEMPLATES_KEY, + value: result.templates, + updatedBy: params.updatedBy, + }); + } + + return result; +} diff --git a/lib/storage/service.ts b/lib/storage/service.ts index 1b64d2e22..f11dbfe79 100644 --- a/lib/storage/service.ts +++ b/lib/storage/service.ts @@ -29,6 +29,17 @@ export type S3UploadConfig = { publicUrl?: string; }; +export type UploadedFileResult = { + url: string; + pathname: string; + contentType: string; +}; + +export type StorageUploadFunction = ( + filename: string, + buffer: ArrayBuffer +) => Promise; + let lastKnownGoodDbS3Config: S3UploadConfig | null = null; const STORAGE_FAILOVER_CONFIG_KEY = "storage_failover_config"; @@ -111,14 +122,12 @@ function createS3Client(config: S3UploadConfig): S3Client { async function uploadToS3( filename: string, buffer: ArrayBuffer, - config: S3UploadConfig + config: S3UploadConfig, + s3Client = createS3Client(config) ) { const { endpoint, - region, bucket, - accessKeyId, - secretAccessKey, publicUrl, } = config; const normalizedEndpoint = normalizeEndpoint(endpoint); @@ -133,15 +142,6 @@ async function uploadToS3( const contentType = getContentType(filename); try { - const s3Client = createS3Client({ - endpoint: normalizedEndpoint, - region, - bucket, - accessKeyId, - secretAccessKey, - publicUrl: normalizedPublicUrl, - }); - console.log(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${bucket}/${key}`); // 上传文件 @@ -385,7 +385,7 @@ async function uploadWithBackupStorageConfig( export async function uploadFile( filename: string, buffer: ArrayBuffer -): Promise<{ url: string; pathname: string; contentType: string }> { +): Promise { console.log(`[Upload] Starting upload for file: ${filename}`); let dbS3Config: S3UploadConfig | null = null; @@ -489,6 +489,67 @@ export async function uploadFile( }; } +export async function createStorageUploadSession(): Promise { + try { + let migrationConfig = defaultOssImageMigrationConfig; + const migrationSetting = await getSystemSetting({ + key: OSS_MIGRATION_CONFIG_KEY, + }).catch(() => undefined); + migrationConfig = normalizeOssImageMigrationConfig(migrationSetting?.value); + + const selectedStorageConfig = await resolvePreferredUploadStorageConfig(migrationConfig); + const primaryStorageConfigId = selectedStorageConfig?.id; + const dbS3Config = toDbS3UploadConfig(selectedStorageConfig); + + if (!dbS3Config) { + return uploadFile; + } + + const s3Client = createS3Client(dbS3Config); + console.log(`[Upload] Created reusable upload session: ${selectedStorageConfig?.name}`); + console.log(`[Upload] Endpoint: ${dbS3Config.endpoint}`); + console.log(`[Upload] Bucket: ${dbS3Config.bucket}`); + + return async (filename: string, buffer: ArrayBuffer) => { + console.log(`[Upload] Starting session upload for file: ${filename}`); + try { + const uploaded = await uploadToS3(filename, buffer, dbS3Config, s3Client); + lastKnownGoodDbS3Config = dbS3Config; + await maybeEnqueueAliyunMigrationTask({ + uploaded, + storageConfig: selectedStorageConfig, + migrationConfig, + }).catch((error) => { + console.error("[Upload] Failed to enqueue OSS migration task:", error); + }); + return uploaded; + } catch (error) { + console.error("[Upload] Reusable storage session failed:", error); + const uploadedFromBackup = await uploadWithBackupStorageConfig( + filename, + buffer, + primaryStorageConfigId + ); + if (uploadedFromBackup) { + await maybeEnqueueAliyunMigrationTask({ + uploaded: uploadedFromBackup.uploaded, + storageConfig: uploadedFromBackup.storageConfig, + migrationConfig, + }).catch((migrationError) => { + console.error("[Upload] Failed to enqueue backup OSS migration task:", migrationError); + }); + return uploadedFromBackup.uploaded; + } + + throw error; + } + }; + } catch (error) { + console.error("[Upload] Failed to create reusable upload session:", error); + return uploadFile; + } +} + // 获取当前使用的存储类型 export async function getStorageType(): Promise< "database-s3" | "env-s3" | "vercel-blob"