fix: stabilize PSD template parsing workflow
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-04-10 21:03:45 +08:00
parent e74586c8a0
commit db46934025
9 changed files with 331 additions and 45 deletions

View File

@ -227,7 +227,10 @@ export function PsdTemplatesSettingsClient({
backHref: string;
backLabel: string;
}) {
const { data, mutate } = useSWR<AgentPsdTemplateListResponse>("/api/admin/psd-templates", fetcher);
const { data, mutate, isLoading } = useSWR<AgentPsdTemplateListResponse>(
"/api/admin/psd-templates",
fetcher
);
const { data: siteSettingsData, mutate: mutateSiteSettings } = useSWR<{
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
agentPsdTemplateCategoryTagConfig?: {
@ -240,6 +243,12 @@ export function PsdTemplatesSettingsClient({
);
const templates = useMemo(() => (data?.success ? data.templates : []), [data]);
const templateListErrorMessage = useMemo(() => {
if (!data || data.success) {
return "";
}
return String(data.error || "").trim() || "PSD 模板读取失败";
}, [data]);
const fonts = useMemo(() => (fontsData?.success ? fontsData.fonts : []), [fontsData]);
const brandOptions = useMemo(
() =>
@ -1712,7 +1721,31 @@ export function PsdTemplatesSettingsClient({
<CardDescription>按品牌查看模板,选择一个模板继续配置槽位。</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{templates.length === 0 ? (
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={`psd-template-list-loading-${index}`}
className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground"
>
正在加载 PSD 模板列表...
</div>
))}
</div>
) : templateListErrorMessage ? (
<div className="rounded-xl border border-dashed border-amber-300/30 bg-amber-500/5 p-4 text-sm text-amber-100">
<div>{templateListErrorMessage}</div>
<Button
className="mt-3"
onClick={() => void mutate()}
size="sm"
type="button"
variant="outline"
>
重新加载
</Button>
</div>
) : templates.length === 0 ? (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
还没有 PSD 模板,先上传一个模板开始配置。
</div>

View File

@ -16,6 +16,12 @@ import {
preparePsdTemplateRenderAssets,
} from "@/lib/agents/psd-template-parser";
import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection";
import {
cacheAgentPsdSourceLocally,
hasAgentPsdLocalCache,
readAgentPsdSourceFromLocalCache,
resolveAgentPsdLocalCachePath,
} from "@/lib/agents/psd-local-cache";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { uploadFile } from "@/lib/storage/service";
@ -223,24 +229,64 @@ export async function POST(
error: undefined,
});
const response = await fetch(currentTemplate.fileUrl, {
cache: "no-store",
signal: AbortSignal.timeout(60_000),
const localFilePath = resolveAgentPsdLocalCachePath({
templateId: id,
fileName: currentTemplate.fileName,
});
if (!response.ok) {
throw new Error(`PSD 文件下载失败 (${response.status})`);
let fileBuffer: ArrayBuffer;
if (
await hasAgentPsdLocalCache({
templateId: id,
fileName: currentTemplate.fileName,
})
) {
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 18,
stage: "正在读取服务器本地 PSD 缓存",
error: undefined,
});
const localBuffer = await readAgentPsdSourceFromLocalCache({
templateId: id,
fileName: currentTemplate.fileName,
});
fileBuffer = localBuffer.buffer.slice(
localBuffer.byteOffset,
localBuffer.byteOffset + localBuffer.byteLength
) as ArrayBuffer;
} else {
const response = await fetch(currentTemplate.fileUrl, {
cache: "no-store",
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) {
throw new Error(`PSD 文件下载失败 (${response.status})`);
}
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 18,
stage: "正在下载并写入服务器本地 PSD 缓存",
error: undefined,
});
fileBuffer = await response.arrayBuffer();
await cacheAgentPsdSourceLocally({
templateId: id,
fileName: currentTemplate.fileName,
buffer: fileBuffer,
}).catch((error) => {
console.warn("[PSD Templates][Admin] failed to cache PSD source locally:", {
templateId: id,
error,
});
});
}
await updateParseProgress({
templateId: id,
updatedBy: session.user.id,
progress: 18,
stage: "正在读取 PSD 文件",
error: undefined,
});
const fileBuffer = await response.arrayBuffer();
const parsedTemplate = await parsePsdTemplateFile(fileBuffer);
if (parsedTemplate.layers.length === 0) {
@ -287,6 +333,8 @@ export async function POST(
const preparedAssets = await preparePsdTemplateRenderAssets({
templateId: id,
fileUrl: currentTemplate.fileUrl,
filePath: localFilePath,
fileBuffer,
layers: initialLayers,
});

View File

@ -14,6 +14,7 @@ import {
type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config";
import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config";
import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
import {
preparePsdTemplateRenderAssets,
} from "@/lib/agents/psd-template-parser";
@ -198,6 +199,10 @@ export async function PUT(
const preparedAssets = await preparePsdTemplateRenderAssets({
templateId: currentTemplate.id,
fileUrl: currentTemplate.fileUrl,
filePath: resolveAgentPsdLocalCachePath({
templateId: currentTemplate.id,
fileName: currentTemplate.fileName,
}),
layers: nextLayers,
});
nextLayers = preparedAssets.layers;

View File

@ -4,8 +4,8 @@ import { upsertSystemSetting } from "@/lib/db/queries";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import { uploadFile } from "@/lib/storage/service";
import {
AGENT_PSD_TEMPLATES_KEY,
AGENT_PSD_TEMPLATE_ASSET_VERSION,
AGENT_PSD_TEMPLATES_KEY,
normalizeAgentPsdTemplateMode,
normalizeAgentPsdTemplateStatus,
upsertAgentPsdTemplate,
@ -15,7 +15,7 @@ import {
type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config";
import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config";
import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair";
import { cacheAgentPsdSourceLocally } from "@/lib/agents/psd-local-cache";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
export const runtime = "nodejs";
@ -67,28 +67,9 @@ export async function GET() {
try {
const templates = await readAgentPsdTemplates();
const repairedTemplates = [] as AgentPsdTemplate[];
let hasRepairedTemplate = false;
for (const template of templates) {
const repairedTemplate = await repairAgentPsdTemplateAssetsIfNeeded(template);
repairedTemplates.push(repairedTemplate);
if (repairedTemplate !== template) {
hasRepairedTemplate = true;
}
}
if (hasRepairedTemplate) {
await upsertSystemSetting({
key: AGENT_PSD_TEMPLATES_KEY,
value: repairedTemplates,
updatedBy: session.user.id,
});
}
return NextResponse.json({
success: true,
templates: hasRepairedTemplate ? repairedTemplates : templates,
templates,
} satisfies AgentPsdTemplateListResponse);
} catch (error) {
console.error("[PSD Templates][Admin] list failed:", error);
@ -161,6 +142,17 @@ export async function POST(request: NextRequest) {
try {
const fileBuffer = await file.arrayBuffer();
const templateId = randomUUID();
await cacheAgentPsdSourceLocally({
templateId,
fileName: file.name,
buffer: fileBuffer,
}).catch((error) => {
console.warn("[PSD Templates][Admin] failed to cache uploaded PSD locally:", {
templateId,
error,
});
});
const [uploadedFile, uploadedPreview] = await Promise.all([
uploadFile(file.name, fileBuffer),
uploadFile(
@ -169,7 +161,6 @@ export async function POST(request: NextRequest) {
),
]);
const templateId = randomUUID();
const now = new Date().toISOString();
const nextTemplate: AgentPsdTemplate = {
id: templateId,

View File

@ -0,0 +1,99 @@
import "server-only";
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";
const PSD_LOCAL_CACHE_ROOT = path.join(
process.env.AGENT_PSD_LOCAL_CACHE_DIR || os.tmpdir(),
"agent-psd-cache"
);
function sanitizePathSegment(value: string, fallback: string) {
const normalized = String(value || "")
.trim()
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || fallback;
}
function normalizeTemplateExtension(fileName: string) {
const normalized = String(fileName || "").trim().toLowerCase();
if (normalized.endsWith(".psb")) {
return "psb";
}
return "psd";
}
function toBuffer(value: ArrayBuffer | Buffer) {
if (Buffer.isBuffer(value)) {
return value;
}
return Buffer.from(value);
}
export function resolveAgentPsdLocalCachePath(params: {
templateId: string;
fileName?: string | null;
}) {
const templateId = sanitizePathSegment(params.templateId, "template");
const extension = normalizeTemplateExtension(String(params.fileName || ""));
return path.join(PSD_LOCAL_CACHE_ROOT, `${templateId}.${extension}`);
}
export async function cacheAgentPsdSourceLocally(params: {
templateId: string;
fileName?: string | null;
buffer: ArrayBuffer | Buffer;
}) {
const localFilePath = resolveAgentPsdLocalCachePath({
templateId: params.templateId,
fileName: params.fileName,
});
await fs.mkdir(path.dirname(localFilePath), { recursive: true });
await fs.writeFile(localFilePath, toBuffer(params.buffer));
return localFilePath;
}
export async function readAgentPsdSourceFromLocalCache(params: {
templateId: string;
fileName?: string | null;
}) {
return fs.readFile(
resolveAgentPsdLocalCachePath({
templateId: params.templateId,
fileName: params.fileName,
})
);
}
export async function hasAgentPsdLocalCache(params: {
templateId: string;
fileName?: string | null;
}) {
try {
await fs.access(
resolveAgentPsdLocalCachePath({
templateId: params.templateId,
fileName: params.fileName,
})
);
return true;
} catch {
return false;
}
}
export async function deleteAgentPsdLocalCache(params: {
templateId: string;
fileName?: string | null;
}) {
const localFilePath = resolveAgentPsdLocalCachePath({
templateId: params.templateId,
fileName: params.fileName,
});
await fs.rm(localFilePath, { force: true });
}

View File

@ -1,5 +1,6 @@
import "server-only";
import { promises as fs } from "node:fs";
import { uploadFile } from "@/lib/storage/service";
import { normalizeDetectedFontNames } from "@/lib/agents/psd-font-matching";
import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset";
@ -447,6 +448,54 @@ async function fetchRemoteBuffer(url: string) {
) as ArrayBuffer;
}
function toSourceArrayBuffer(value: ArrayBuffer | Buffer) {
if (Buffer.isBuffer(value)) {
return value.buffer.slice(
value.byteOffset,
value.byteOffset + value.byteLength
) as ArrayBuffer;
}
return value;
}
async function readLocalPsdSourceBuffer(filePath: string) {
const buffer = await fs.readFile(filePath);
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
async function resolvePsdSourceBuffer(params: {
fileUrl?: string;
filePath?: string;
fileBuffer?: ArrayBuffer | Buffer;
}) {
if (params.fileBuffer) {
return toSourceArrayBuffer(params.fileBuffer);
}
const localFilePath = String(params.filePath || "").trim();
if (localFilePath) {
try {
return await readLocalPsdSourceBuffer(localFilePath);
} catch (error) {
console.warn("[PSD Template] failed to read local PSD cache, falling back:", {
filePath: localFilePath,
error,
});
}
}
const fileUrl = String(params.fileUrl || "").trim();
if (fileUrl) {
return fetchRemoteBuffer(fileUrl);
}
throw new Error("PSD 模板源文件不可用");
}
export async function parsePsdTemplateFile(
buffer: ArrayBuffer
): Promise<ParsedPsdTemplatePayload> {
@ -472,6 +521,15 @@ export async function parsePsdTemplateFileFromUrl(fileUrl: string) {
return parsePsdTemplateFile(buffer);
}
export async function parsePsdTemplateFileFromSource(params: {
fileUrl?: string;
filePath?: string;
fileBuffer?: ArrayBuffer | Buffer;
}) {
const buffer = await resolvePsdSourceBuffer(params);
return parsePsdTemplateFile(buffer);
}
export async function loadPsdTemplateSourceFromUrl(fileUrl: string) {
const buffer = await fetchRemoteBuffer(fileUrl);
const psd = await parsePsdBuffer(buffer);
@ -489,6 +547,27 @@ export async function loadPsdTemplateSourceFromUrl(fileUrl: string) {
};
}
export async function loadPsdTemplateSource(params: {
fileUrl?: string;
filePath?: string;
fileBuffer?: ArrayBuffer | Buffer;
}) {
const buffer = await resolvePsdSourceBuffer(params);
const psd = await parsePsdBuffer(buffer);
const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []);
return {
width: Math.max(1, Math.round(Number(psd.width || 0))),
height: Math.max(1, Math.round(Number(psd.height || 0))),
layers: layerEntries
.filter((entry) => entry.node && typeof entry.node.composite === "function")
.map((entry) => ({
layer: entry.layer,
composite: () => entry.node.composite!(),
})) satisfies PsdTemplateSourceLayerEntry[],
};
}
async function createTransparentCanvasBuffer(width: number, height: number) {
const sharp = await getSharp();
const canvasWidth = Math.max(1, Math.round(Number(width || 0)));
@ -577,11 +656,17 @@ async function fitOverlayBufferIntoCanvas(params: {
export async function preparePsdTemplateRenderAssets(params: {
templateId: string;
fileUrl: string;
fileUrl?: string;
filePath?: string;
fileBuffer?: ArrayBuffer | Buffer;
layers: AgentPsdTemplateLayerSlot[];
forceRefreshLayerAssets?: boolean;
}) {
const psdBuffer = await fetchRemoteBuffer(params.fileUrl);
const psdBuffer = await resolvePsdSourceBuffer({
fileUrl: params.fileUrl,
filePath: params.filePath,
fileBuffer: params.fileBuffer,
});
const psd = await parsePsdBuffer(psdBuffer);
const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []);
const nodeMap = new Map(layerEntries.map((entry) => [entry.layer.layerId, entry]));

View File

@ -17,9 +17,10 @@ import {
pickPrimaryPsdImageLayerId,
} from "@/lib/agents/psd-template-image-slot-selection";
import {
parsePsdTemplateFileFromUrl,
parsePsdTemplateFileFromSource,
preparePsdTemplateRenderAssets,
} from "@/lib/agents/psd-template-parser";
import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
import { uploadFile } from "@/lib/storage/service";
function hasMissingLayerAssets(template: AgentPsdTemplate) {
@ -209,7 +210,14 @@ export async function repairAgentPsdTemplateAssetsIfNeeded(
let nextLayers = repairEditableImageLayerSelection(template);
if (needsFontDetection) {
try {
const parsedTemplate = await parsePsdTemplateFileFromUrl(template.fileUrl);
const localFilePath = resolveAgentPsdLocalCachePath({
templateId: template.id,
fileName: template.fileName,
});
const parsedTemplate = await parsePsdTemplateFileFromSource({
fileUrl: template.fileUrl,
filePath: localFilePath,
});
const parsedTextLayerMap = new Map(
parsedTemplate.layers
.filter((layer) => layer.slotType === "text")
@ -260,6 +268,10 @@ export async function repairAgentPsdTemplateAssetsIfNeeded(
const preparedAssets = await preparePsdTemplateRenderAssets({
templateId: template.id,
fileUrl: template.fileUrl,
filePath: resolveAgentPsdLocalCachePath({
templateId: template.id,
fileName: template.fileName,
}),
layers: nextLayers,
forceRefreshLayerAssets: needsAssetVersionRefresh,
});

View File

@ -7,6 +7,7 @@ import {
parseAgentPsdTemplates,
type AgentPsdTemplate,
} from "@/lib/agents/psd-template-config";
import { deleteAgentPsdLocalCache } from "@/lib/agents/psd-local-cache";
export async function readAgentPsdTemplates() {
const setting = await getSystemSetting({ key: AGENT_PSD_TEMPLATES_KEY });
@ -14,6 +15,11 @@ export async function readAgentPsdTemplates() {
}
export async function tryDeleteAgentPsdTemplateAssets(template: AgentPsdTemplate) {
await deleteAgentPsdLocalCache({
templateId: template.id,
fileName: template.fileName,
}).catch(() => undefined);
const filePaths = Array.from(
new Set(
[

View File

@ -6,7 +6,8 @@ import {
type AgentPsdTemplate,
type AgentPsdTemplateTextLayerSlot,
} from "@/lib/agents/psd-template-config";
import { loadPsdTemplateSourceFromUrl } from "@/lib/agents/psd-template-parser";
import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
import { loadPsdTemplateSource } from "@/lib/agents/psd-template-parser";
function getEstimatedFontSize(height: number) {
return Math.max(16, Math.min(height, Math.round(height * 0.78)));
@ -65,7 +66,13 @@ export async function resolveAgentPsdTemplateTextStyles(template: AgentPsdTempla
return template;
}
const source = await loadPsdTemplateSourceFromUrl(template.fileUrl);
const source = await loadPsdTemplateSource({
fileUrl: template.fileUrl,
filePath: resolveAgentPsdLocalCachePath({
templateId: template.id,
fileName: template.fileName,
}),
});
const sourceLayerMap = new Map(source.layers.map((entry) => [entry.layer.layerId, entry]));
const pixelAnalysisCache = new Map<
string,