feat: optimize psd template asset backfill
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-09 11:28:38 +08:00
parent 11a2babb77
commit 19970915fd
10 changed files with 570 additions and 200 deletions

View File

@ -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) {

View File

@ -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,

View File

@ -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,

View File

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

View File

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

View File

@ -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<T, R>(
items: T[],
concurrency: number,
mapper: (item: T, index: number) => Promise<R>
) {
const results = new Array<R>(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);

View File

@ -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;
}

View File

@ -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)
)

View File

@ -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;
}

View File

@ -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<UploadedFileResult>;
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<UploadedFileResult> {
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<StorageUploadFunction> {
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"