feat: optimize psd template asset backfill
This commit is contained in:
parent
11a2babb77
commit
19970915fd
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
132
lib/agents/psd-template-timeout.ts
Normal file
132
lib/agents/psd-template-timeout.ts
Normal 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;
|
||||
}
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user