feat: add PSD template font rendering 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-03-27 11:01:13 +08:00
parent 21d3eb0769
commit 17dcfcd55d
22 changed files with 4501 additions and 5 deletions

View File

@ -6224,9 +6224,14 @@ export function AdminSettingsClient() {
建议在独立页面中维护每个环节的按钮名称、预览图上传、说明文案和 Prompt 片段。 建议在独立页面中维护每个环节的按钮名称、预览图上传、说明文案和 Prompt 片段。
</div> </div>
</div> </div>
<Button asChild> <div className="flex flex-wrap gap-2">
<Link href="/settings/agent-workflow">前往智能体工作流配置</Link> <Button asChild>
</Button> <Link href="/settings/agent-workflow">前往智能体工作流配置</Link>
</Button>
<Button asChild variant="outline">
<Link href="/settings/agent-workflow/psd-templates">管理PSD模板</Link>
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1926,6 +1926,12 @@ export function AgentWorkflowSettingsClient({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/settings/agent-workflow/psd-templates">
<Layers className="mr-2 h-4 w-4" />
PSD模板
</Link>
</Button>
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link href={backHref}> <Link href={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />

View File

@ -0,0 +1,19 @@
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import { redirect } from "next/navigation";
import { PsdTemplatesSettingsClient } from "./psd-templates-settings-client";
export const dynamic = "force-dynamic";
export default async function AgentWorkflowPsdTemplatesPage() {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session) {
redirect("/");
}
return (
<PsdTemplatesSettingsClient
backHref="/settings/agent-workflow"
backLabel="返回智能体工作流配置"
/>
);
}

View File

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import { upsertSystemSetting } from "@/lib/db/queries";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import {
AGENT_PSD_FONTS_KEY,
type AgentPsdFontMutationResponse,
} from "@/lib/agents/psd-font-config";
import { isAgentPsdTextLayerSlot } from "@/lib/agents/psd-template-config";
import {
readAgentPsdFonts,
tryDeleteAgentPsdFontAssets,
} from "@/lib/agents/psd-font-settings";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
export const runtime = "nodejs";
function jsonError(status: number, error: string) {
const payload: AgentPsdFontMutationResponse = {
success: false,
error,
};
return NextResponse.json(payload, { status });
}
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session?.user?.id) {
return jsonError(401, "未登录");
}
try {
const { id } = await context.params;
const fonts = await readAgentPsdFonts();
const font = fonts.find((entry) => entry.id === id);
if (!font) {
return jsonError(404, "字体不存在");
}
const templates = await readAgentPsdTemplates();
const blockingTemplate = templates.find((template) =>
template.layers.some(
(layer) =>
isAgentPsdTextLayerSlot(layer) &&
layer.fontId === font.id &&
layer.isEditable
)
);
if (blockingTemplate) {
return jsonError(400, `字体仍被模板“${blockingTemplate.name}”使用,请先解除关联`);
}
await upsertSystemSetting({
key: AGENT_PSD_FONTS_KEY,
value: fonts.filter((entry) => entry.id !== id),
updatedBy: session.user.id,
});
await tryDeleteAgentPsdFontAssets(font);
return NextResponse.json({
success: true,
font,
} satisfies AgentPsdFontMutationResponse);
} catch (error) {
console.error("[PSD Fonts][Admin] delete failed:", error);
return jsonError(502, "字体删除失败");
}
}

View File

@ -0,0 +1,133 @@
import { randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { upsertSystemSetting } from "@/lib/db/queries";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import { uploadFile } from "@/lib/storage/service";
import {
AGENT_PSD_FONTS_KEY,
upsertAgentPsdFont,
type AgentPsdFont,
type AgentPsdFontListResponse,
type AgentPsdFontMutationResponse,
} from "@/lib/agents/psd-font-config";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
export const runtime = "nodejs";
const MAX_FONT_FILE_SIZE = 20 * 1024 * 1024;
const ALLOWED_FONT_EXTENSIONS = new Set(["ttf", "otf", "woff", "woff2"]);
function jsonListError(status: number, error: string) {
const payload: AgentPsdFontListResponse = {
success: false,
fonts: [],
error,
};
return NextResponse.json(payload, { status });
}
function jsonMutationError(status: number, error: string) {
const payload: AgentPsdFontMutationResponse = {
success: false,
error,
};
return NextResponse.json(payload, { status });
}
function getFileExtension(fileName: string) {
return fileName.toLowerCase().split(".").pop() || "";
}
function getDefaultFontName(fileName: string) {
return fileName.replace(/\.[^.]+$/, "").trim() || "未命名字体";
}
export async function GET() {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session?.user?.id) {
return jsonListError(401, "未登录");
}
try {
const fonts = await readAgentPsdFonts();
return NextResponse.json({
success: true,
fonts,
} satisfies AgentPsdFontListResponse);
} catch (error) {
console.error("[PSD Fonts][Admin] list failed:", error);
return jsonListError(503, "字体库读取失败");
}
}
export async function POST(request: NextRequest) {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session?.user?.id) {
return jsonMutationError(401, "未登录");
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return jsonMutationError(400, "上传表单解析失败");
}
const file = formData.get("file");
const name =
String(formData.get("name") || "").trim() ||
(file instanceof File ? getDefaultFontName(file.name) : "");
const family = String(formData.get("family") || "").trim() || name;
if (!(file instanceof File)) {
return jsonMutationError(400, "缺少字体文件");
}
const extension = getFileExtension(file.name);
if (!ALLOWED_FONT_EXTENSIONS.has(extension)) {
return jsonMutationError(400, "字体文件仅支持 ttf / otf / woff / woff2");
}
if (file.size <= 0) {
return jsonMutationError(400, "字体文件不能为空");
}
if (file.size > MAX_FONT_FILE_SIZE) {
return jsonMutationError(400, "字体文件不能超过 20MB");
}
try {
const fileBuffer = await file.arrayBuffer();
const uploaded = await uploadFile(file.name, fileBuffer);
const now = new Date().toISOString();
const nextFont: AgentPsdFont = {
id: randomUUID(),
name,
family,
fileName: file.name,
fileUrl: uploaded.url,
filePath: uploaded.pathname,
contentType: uploaded.contentType || file.type || undefined,
createdAt: now,
updatedAt: now,
uploadedById: session.user.id,
};
const currentFonts = await readAgentPsdFonts();
const fonts = upsertAgentPsdFont(currentFonts, nextFont);
await upsertSystemSetting({
key: AGENT_PSD_FONTS_KEY,
value: fonts,
updatedBy: session.user.id,
});
return NextResponse.json({
success: true,
font: nextFont,
} satisfies AgentPsdFontMutationResponse);
} catch (error) {
console.error("[PSD Fonts][Admin] create failed:", error);
return jsonMutationError(502, "字体上传失败");
}
}

View File

@ -0,0 +1,206 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { upsertSystemSetting } from "@/lib/db/queries";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import {
AGENT_PSD_TEMPLATES_KEY,
getEditableAgentPsdTemplateLayers,
isAgentPsdTextLayerSlot,
normalizeAgentPsdTemplateStatus,
parseAgentPsdTemplateLayers,
type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config";
import { preparePsdTemplateRenderAssets } from "@/lib/agents/psd-template-parser";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { uploadFile } from "@/lib/storage/service";
import {
readAgentPsdTemplates,
tryDeleteAgentPsdTemplateAssets,
} from "@/lib/agents/psd-template-settings";
export const runtime = "nodejs";
const updateTemplateSchema = z.object({
name: z.string().trim().min(1).max(80),
description: z.string().optional().nullable(),
status: z.string().optional(),
layers: z.array(z.any()).default([]),
});
function jsonError(status: number, error: string) {
const payload: AgentPsdTemplateMutationResponse = {
success: false,
error,
};
return NextResponse.json(payload, { status });
}
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session?.user?.id) {
return jsonError(401, "未登录");
}
try {
const { id } = await context.params;
const templates = await readAgentPsdTemplates();
const template = templates.find((entry) => entry.id === id);
if (!template) {
return jsonError(404, "未找到 PSD 模板");
}
return NextResponse.json({
success: true,
template,
} satisfies AgentPsdTemplateMutationResponse);
} catch (error) {
console.error("[PSD Templates][Admin] get failed:", error);
return jsonError(503, "PSD 模板读取失败");
}
}
export async function PUT(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session?.user?.id) {
return jsonError(401, "未登录");
}
let body: z.infer<typeof updateTemplateSchema>;
try {
body = updateTemplateSchema.parse(await request.json());
} catch {
return jsonError(400, "模板参数不合法");
}
try {
const { id } = await context.params;
const templates = await readAgentPsdTemplates();
const currentTemplate = templates.find((entry) => entry.id === id);
if (!currentTemplate) {
return jsonError(404, "未找到 PSD 模板");
}
let nextLayers = parseAgentPsdTemplateLayers(body.layers);
if (nextLayers.length === 0) {
nextLayers = currentTemplate.layers;
}
if (
normalizeAgentPsdTemplateStatus(body.status) === "active" &&
getEditableAgentPsdTemplateLayers({
...currentTemplate,
layers: nextLayers,
}).length === 0
) {
return jsonError(400, "至少配置一个可编辑图层后才能启用模板");
}
const fonts = await readAgentPsdFonts();
const fontMap = new Map(fonts.map((font) => [font.id, font]));
for (const layer of nextLayers) {
if (!layer.isEditable || !isAgentPsdTextLayerSlot(layer)) {
continue;
}
if (layer.fontId) {
const matchedFont = fontMap.get(layer.fontId);
if (!matchedFont) {
return jsonError(400, `文字图层“${layer.layerName}”选择的字体不存在`);
}
layer.fontFamily = matchedFont.family;
layer.fontLabel = matchedFont.name;
}
if (!String(layer.fontFamily || "").trim()) {
return jsonError(400, `文字图层“${layer.layerName}”必须选择字体`);
}
}
const preparedAssets = await preparePsdTemplateRenderAssets({
templateId: currentTemplate.id,
fileUrl: currentTemplate.fileUrl,
layers: nextLayers,
});
nextLayers = preparedAssets.layers;
const uploadedRenderBase = await uploadFile(
`agent-psd-base-${currentTemplate.id}-${Date.now()}.png`,
preparedAssets.renderBaseBuffer.buffer.slice(
preparedAssets.renderBaseBuffer.byteOffset,
preparedAssets.renderBaseBuffer.byteOffset + preparedAssets.renderBaseBuffer.byteLength
) as ArrayBuffer
);
const updatedTemplate = {
...currentTemplate,
name: body.name.trim(),
description: String(body.description || "").trim() || undefined,
status: normalizeAgentPsdTemplateStatus(body.status),
renderBaseUrl: uploadedRenderBase.url,
renderBasePath: uploadedRenderBase.pathname,
layers: nextLayers,
layerCount: Math.max(currentTemplate.layerCount, nextLayers.length),
updatedAt: new Date().toISOString(),
};
await upsertSystemSetting({
key: AGENT_PSD_TEMPLATES_KEY,
value: templates.map((template) =>
template.id === currentTemplate.id ? updatedTemplate : template
),
updatedBy: session.user.id,
});
return NextResponse.json({
success: true,
template: updatedTemplate,
} satisfies AgentPsdTemplateMutationResponse);
} catch (error) {
console.error("[PSD Templates][Admin] update failed:", error);
return jsonError(502, "PSD 模板保存失败");
}
}
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session?.user?.id) {
return jsonError(401, "未登录");
}
try {
const { id } = await context.params;
const templates = await readAgentPsdTemplates();
const template = templates.find((entry) => entry.id === id);
if (!template) {
return jsonError(404, "未找到 PSD 模板");
}
await upsertSystemSetting({
key: AGENT_PSD_TEMPLATES_KEY,
value: templates.filter((entry) => entry.id !== id),
updatedBy: session.user.id,
});
await tryDeleteAgentPsdTemplateAssets(template);
return NextResponse.json({
success: true,
template,
} satisfies AgentPsdTemplateMutationResponse);
} catch (error) {
console.error("[PSD Templates][Admin] delete failed:", error);
return jsonError(502, "PSD 模板删除失败");
}
}

View File

@ -0,0 +1,198 @@
import { randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { upsertSystemSetting } from "@/lib/db/queries";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import { uploadFile } from "@/lib/storage/service";
import {
AGENT_PSD_TEMPLATES_KEY,
normalizeAgentPsdTemplateStatus,
upsertAgentPsdTemplate,
type AgentPsdTemplate,
type AgentPsdTemplateLayerSlot,
type AgentPsdTemplateListResponse,
type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config";
import { parsePsdTemplateFile } from "@/lib/agents/psd-template-parser";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
export const runtime = "nodejs";
const MAX_TEMPLATE_FILE_SIZE = 80 * 1024 * 1024;
const ALLOWED_TEMPLATE_EXTENSIONS = new Set(["psd", "psb"]);
function jsonListError(status: number, error: string) {
const payload: AgentPsdTemplateListResponse = {
success: false,
templates: [],
error,
};
return NextResponse.json(payload, { status });
}
function jsonMutationError(status: number, error: string) {
const payload: AgentPsdTemplateMutationResponse = {
success: false,
error,
};
return NextResponse.json(payload, { status });
}
function getFileExtension(fileName: string) {
return fileName.toLowerCase().split(".").pop() || "";
}
function getDefaultTemplateName(fileName: string) {
return fileName.replace(/\.[^.]+$/, "").trim() || "未命名 PSD 模板";
}
export async function GET() {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session?.user?.id) {
return jsonListError(401, "未登录");
}
try {
const templates = await readAgentPsdTemplates();
return NextResponse.json({
success: true,
templates,
} satisfies AgentPsdTemplateListResponse);
} catch (error) {
console.error("[PSD Templates][Admin] list failed:", error);
return jsonListError(503, "PSD 模板读取失败");
}
}
export async function POST(request: NextRequest) {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session?.user?.id) {
return jsonMutationError(401, "未登录");
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return jsonMutationError(400, "上传表单解析失败");
}
const file = formData.get("file");
const name =
String(formData.get("name") || "").trim() ||
(file instanceof File ? getDefaultTemplateName(file.name) : "");
const description = String(formData.get("description") || "").trim();
const status = normalizeAgentPsdTemplateStatus(formData.get("status"));
if (!name) {
return jsonMutationError(400, "缺少模板名称");
}
if (name.length > 80) {
return jsonMutationError(400, "模板名称不能超过 80 个字符");
}
if (!(file instanceof File)) {
return jsonMutationError(400, "缺少 PSD 模板文件");
}
const extension = getFileExtension(file.name);
if (!ALLOWED_TEMPLATE_EXTENSIONS.has(extension)) {
return jsonMutationError(400, "模板文件仅支持 PSD / PSB");
}
if (file.size <= 0) {
return jsonMutationError(400, "模板文件不能为空");
}
if (file.size > MAX_TEMPLATE_FILE_SIZE) {
return jsonMutationError(400, "模板文件不能超过 80MB");
}
try {
const fileBuffer = await file.arrayBuffer();
const parsedTemplate = await parsePsdTemplateFile(fileBuffer);
if (parsedTemplate.layers.length === 0) {
return jsonMutationError(400, "PSD 中未识别到可配置图层");
}
const [uploadedFile, uploadedPreview] = await Promise.all([
uploadFile(file.name, fileBuffer),
uploadFile(
`agent-psd-preview-${Date.now()}.png`,
parsedTemplate.previewBuffer.buffer.slice(
parsedTemplate.previewBuffer.byteOffset,
parsedTemplate.previewBuffer.byteOffset + parsedTemplate.previewBuffer.byteLength
) as ArrayBuffer
),
]);
const now = new Date().toISOString();
const nextTemplate: AgentPsdTemplate = {
id: randomUUID(),
name,
description: description || undefined,
status,
fileName: file.name,
fileUrl: uploadedFile.url,
filePath: uploadedFile.pathname,
previewUrl: uploadedPreview.url,
previewPath: uploadedPreview.pathname,
renderBaseUrl: uploadedPreview.url,
renderBasePath: uploadedPreview.pathname,
width: parsedTemplate.width,
height: parsedTemplate.height,
layerCount: parsedTemplate.layers.length,
layers: parsedTemplate.layers.map((layer): AgentPsdTemplateLayerSlot => {
if (layer.slotType === "text") {
return {
...layer,
slotType: "text",
isEditable: false,
isRequired: false,
placeholder: layer.defaultText || layer.layerName,
fontSize: Math.max(16, Math.min(layer.height, Math.round(layer.height * 0.78))),
lineHeight: 1.2,
letterSpacing: 0,
fontWeight: 500,
textColor: "#111111",
textAlign: "left",
paddingX: 0,
paddingY: 0,
maxLines: 4,
};
}
return {
...layer,
slotType: "image",
isEditable: false,
isRequired: false,
isDefaultResultSource: false,
allowUserUploadOverride: true,
replaceMode: "cover",
};
}),
createdAt: now,
updatedAt: now,
uploadedById: session.user.id,
};
const currentTemplates = await readAgentPsdTemplates();
const templates = upsertAgentPsdTemplate(currentTemplates, nextTemplate);
await upsertSystemSetting({
key: AGENT_PSD_TEMPLATES_KEY,
value: templates,
updatedBy: session.user.id,
});
return NextResponse.json({
success: true,
template: nextTemplate,
} satisfies AgentPsdTemplateMutationResponse);
} catch (error) {
console.error("[PSD Templates][Admin] create failed:", error);
return jsonMutationError(502, "PSD 模板上传或解析失败");
}
}

View File

@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
getEditableAgentPsdTemplateLayers,
isAgentPsdImageLayerSlot,
isAgentPsdTextLayerSlot,
type AgentPsdTemplateRenderResponse,
} from "@/lib/agents/psd-template-config";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
export const runtime = "nodejs";
const renderSchema = z.object({
templateId: z.string().trim().min(1),
sourceImageUrl: z.string().trim().optional().nullable(),
imageAssignments: z.record(z.string(), z.string()).default({}),
textAssignments: z.record(z.string(), z.string()).default({}),
});
function jsonError(status: number, error: string) {
const payload: AgentPsdTemplateRenderResponse = {
success: false,
error,
};
return NextResponse.json(payload, { status });
}
export async function POST(request: NextRequest) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return jsonError(401, "未登录");
}
let body: z.infer<typeof renderSchema>;
try {
body = renderSchema.parse(await request.json());
} catch {
return jsonError(400, "渲染参数不合法");
}
try {
const templates = await readAgentPsdTemplates();
const template = templates.find(
(entry) => entry.id === body.templateId && entry.status === "active"
);
if (!template) {
return jsonError(404, "PSD 模板不存在或未启用");
}
const editableLayers = getEditableAgentPsdTemplateLayers(template);
if (editableLayers.length === 0) {
return jsonError(400, "当前模板未配置可编辑图层");
}
const fonts = await readAgentPsdFonts();
const fontMap = new Map(fonts.map((font) => [font.id, font]));
const missingFontLayer = editableLayers.find(
(layer) => isAgentPsdTextLayerSlot(layer) && layer.fontId && !fontMap.has(layer.fontId)
);
if (missingFontLayer) {
return jsonError(400, `文字图层“${missingFontLayer.layerName}”引用的字体已不存在`);
}
const imageAssignments: Record<string, string> = {};
const textAssignments: Record<string, string> = {};
for (const layer of editableLayers) {
if (isAgentPsdImageLayerSlot(layer)) {
const explicitUrl = String(body.imageAssignments[layer.id] || "").trim();
if (explicitUrl) {
imageAssignments[layer.id] = explicitUrl;
continue;
}
if (layer.isDefaultResultSource && body.sourceImageUrl?.trim()) {
imageAssignments[layer.id] = body.sourceImageUrl.trim();
}
}
if (isAgentPsdTextLayerSlot(layer)) {
const nextText = String(body.textAssignments[layer.id] || layer.defaultText || "").trim();
if (nextText) {
textAssignments[layer.id] = nextText;
}
}
}
const missingRequiredLayer = editableLayers.find(
(layer) =>
layer.isRequired &&
(isAgentPsdImageLayerSlot(layer)
? !imageAssignments[layer.id]
: !textAssignments[layer.id])
);
if (missingRequiredLayer) {
return jsonError(400, `缺少必传素材:${missingRequiredLayer.layerName}`);
}
const uploaded = await renderAgentPsdTemplate({
template,
imageAssignments,
textAssignments,
fonts,
});
return NextResponse.json({
success: true,
result: {
url: uploaded.url,
pathname: uploaded.pathname,
contentType: uploaded.contentType,
},
} satisfies AgentPsdTemplateRenderResponse);
} catch (error) {
console.error("[PSD Templates] render failed:", error);
return jsonError(502, "PSD 模板渲染失败");
}
}

View File

@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
getEditableAgentPsdTemplateLayers,
type AgentPsdTemplateListResponse,
} from "@/lib/agents/psd-template-config";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function jsonError(status: number, error: string) {
const payload: AgentPsdTemplateListResponse = {
success: false,
templates: [],
error,
};
return NextResponse.json(payload, { status });
}
export async function GET() {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return jsonError(401, "未登录");
}
try {
const templates = (await readAgentPsdTemplates())
.filter((template) => template.status === "active")
.map((template) => ({
...template,
layers: getEditableAgentPsdTemplateLayers(template),
}))
.filter((template) => template.layers.length > 0);
return NextResponse.json({
success: true,
templates,
} satisfies AgentPsdTemplateListResponse);
} catch (error) {
console.error("[PSD Templates] list failed:", error);
return jsonError(503, "PSD 模板读取失败");
}
}

View File

@ -50,6 +50,7 @@ import {
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar"; import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner"; import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog"; import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog";
import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog";
import { ImageAnnotator } from "@/components/image-annotator"; import { ImageAnnotator } from "@/components/image-annotator";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
@ -942,6 +943,7 @@ export function AgentGenerationWorkspaceDarkV2({
useState<AgentGenerationResultModeType>("scene_generation"); useState<AgentGenerationResultModeType>("scene_generation");
const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null); const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null);
const [sceneResizeEditorTargetUrl, setSceneResizeEditorTargetUrl] = useState<string | null>(null); const [sceneResizeEditorTargetUrl, setSceneResizeEditorTargetUrl] = useState<string | null>(null);
const [psdTemplateDialogSourceUrl, setPsdTemplateDialogSourceUrl] = useState<string | null>(null);
const [batchDownloadState, setBatchDownloadState] = useState<null | "scene" | "detail" | "sku">(null); const [batchDownloadState, setBatchDownloadState] = useState<null | "scene" | "detail" | "sku">(null);
const [detailReplacementSearchState, setDetailReplacementSearchState] = useState<{ const [detailReplacementSearchState, setDetailReplacementSearchState] = useState<{
isLoading: boolean; isLoading: boolean;
@ -4861,7 +4863,7 @@ export function AgentGenerationWorkspaceDarkV2({
alt={card.label} alt={card.label}
className="block h-auto w-full" className="block h-auto w-full"
onError={(event) => onError={(event) =>
handlePreviewImageLoadError(event, card.imageUrl) handlePreviewImageLoadError(event, card.imageUrl || "")
} }
/> />
) : null} ) : null}
@ -5306,7 +5308,7 @@ export function AgentGenerationWorkspaceDarkV2({
alt={card.label} alt={card.label}
className="h-full w-full object-cover" className="h-full w-full object-cover"
onError={(event) => onError={(event) =>
handlePreviewImageLoadError(event, card.imageUrl) handlePreviewImageLoadError(event, card.imageUrl || "")
} }
/> />
) : null} ) : null}
@ -5563,11 +5565,39 @@ export function AgentGenerationWorkspaceDarkV2({
> >
<Maximize2 className="h-4 w-4" /> <Maximize2 className="h-4 w-4" />
</button> </button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
setPsdTemplateDialogSourceUrl(card.url!);
}}
className="absolute left-14 top-3 flex items-center gap-1 rounded-full border border-white/10 bg-black/60 px-2.5 py-1 text-[11px] text-white transition-colors hover:border-white/20"
aria-label="套用 PSD 模板"
>
<ImagePlus className="h-3.5 w-3.5" />
<span>套PSD</span>
</button>
<div className="pointer-events-none absolute inset-x-3 bottom-3 rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-[11px] text-white/78 backdrop-blur"> <div className="pointer-events-none absolute inset-x-3 bottom-3 rounded-2xl border border-white/10 bg-black/55 px-3 py-2 text-[11px] text-white/78 backdrop-blur">
双击查看大图 双击查看大图
</div> </div>
</> </>
) : null} ) : null}
{!isSceneResultCard ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedResultUrl(card.url!);
setPsdTemplateDialogSourceUrl(card.url!);
}}
className="absolute left-3 top-3 flex items-center gap-1 rounded-full border border-white/10 bg-black/60 px-2.5 py-1 text-[11px] text-white transition-colors hover:border-white/20"
aria-label="套用 PSD 模板"
>
<ImagePlus className="h-3.5 w-3.5" />
<span>套PSD</span>
</button>
) : null}
<button <button
type="button" type="button"
onClick={(event) => { onClick={(event) => {
@ -5680,6 +5710,16 @@ export function AgentGenerationWorkspaceDarkV2({
/> />
) : null} ) : null}
<AgentPsdTemplateEditorDialog
open={Boolean(psdTemplateDialogSourceUrl)}
onOpenChange={(open) => {
if (!open) {
setPsdTemplateDialogSourceUrl(null);
}
}}
sourceImageUrl={psdTemplateDialogSourceUrl}
/>
{/* Fullscreen Preview */} {/* Fullscreen Preview */}
<AnimatePresence> <AnimatePresence>
{fullscreenPreview && ( {fullscreenPreview && (

View File

@ -0,0 +1,464 @@
"use client";
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import {
Download,
Image as ImageIcon,
Loader2,
Sparkles,
Type,
Upload,
} from "lucide-react";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
isAgentPsdImageLayerSlot,
isAgentPsdTextLayerSlot,
type AgentPsdTemplate,
type AgentPsdTemplateListResponse,
type AgentPsdTemplateRenderResponse,
} from "@/lib/agents/psd-template-config";
const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
function createInitialState(template: AgentPsdTemplate | null, sourceImageUrl: string) {
const imageAssignments: Record<string, string> = {};
const textAssignments: Record<string, string> = {};
if (!template) {
return { imageAssignments, textAssignments };
}
for (const layer of template.layers) {
if (isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource && sourceImageUrl) {
imageAssignments[layer.id] = sourceImageUrl;
}
if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) {
textAssignments[layer.id] = layer.defaultText;
}
}
return { imageAssignments, textAssignments };
}
export function AgentPsdTemplateEditorDialog({
open,
onOpenChange,
sourceImageUrl,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceImageUrl: string | null;
}) {
const { data, isLoading } = useSWR<AgentPsdTemplateListResponse>(
open ? "/api/psd-templates" : null,
fetcher
);
const templates = useMemo(() => (data?.success ? data.templates : []), [data]);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [imageAssignments, setImageAssignments] = useState<Record<string, string>>({});
const [textAssignments, setTextAssignments] = useState<Record<string, string>>({});
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false);
const [renderedResultUrl, setRenderedResultUrl] = useState<string>("");
const selectedTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) || null,
[selectedTemplateId, templates]
);
useEffect(() => {
if (!open) return;
if (templates.length > 0 && !templates.some((template) => template.id === selectedTemplateId)) {
setSelectedTemplateId(templates[0].id);
}
}, [open, selectedTemplateId, templates]);
useEffect(() => {
if (!open) return;
setRenderedResultUrl("");
const initialState = createInitialState(selectedTemplate, sourceImageUrl || "");
setImageAssignments(initialState.imageAssignments);
setTextAssignments(initialState.textAssignments);
}, [open, selectedTemplate, sourceImageUrl]);
async function handleSlotUpload(slotId: string, event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
try {
setUploadingSlotId(slotId);
const formData = new FormData();
formData.set("file", file);
const response = await fetchUploadWithRetry(formData);
const payload = await response.json();
if (!response.ok || !payload.url) {
throw new Error(payload.error || "图片上传失败");
}
setImageAssignments((current) => ({
...current,
[slotId]: payload.url,
}));
toast.success("素材已上传");
} catch (error) {
toast.error(error instanceof Error ? error.message : "素材上传失败");
} finally {
setUploadingSlotId(null);
event.target.value = "";
}
}
async function handleRender() {
if (!selectedTemplate) {
toast.error("请先选择 PSD 模板");
return;
}
try {
setIsRendering(true);
const response = await fetch("/api/psd-templates/render", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
templateId: selectedTemplate.id,
sourceImageUrl: sourceImageUrl || "",
imageAssignments,
textAssignments,
}),
});
const payload = (await response.json()) as AgentPsdTemplateRenderResponse;
if (!response.ok || !payload.success || !payload.result?.url) {
throw new Error(payload.error || "PSD 模板渲染失败");
}
setRenderedResultUrl(payload.result.url);
toast.success("套版结果已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "PSD 模板渲染失败");
} finally {
setIsRendering(false);
}
}
function handleDownload() {
if (!renderedResultUrl) return;
window.open(renderedResultUrl, "_blank", "noopener,noreferrer");
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-6xl overflow-hidden p-0">
<DialogHeader className="border-b px-6 py-5">
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-orange-500" />
套用 PSD 模板
</DialogTitle>
<DialogDescription>
当前结果图会自动填入默认图片槽位,文字槽位可直接输入,其他图片槽位可继续上传素材替换。
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 gap-0 lg:grid-cols-[360px,minmax(0,1fr),minmax(0,1fr)]">
<div className="border-r p-5">
<div className="space-y-4">
<div className="space-y-2">
<Label>选择模板</Label>
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId}>
<SelectTrigger>
<SelectValue placeholder={isLoading ? "加载模板中..." : "请选择模板"} />
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedTemplate ? (
<>
<div className="overflow-hidden rounded-2xl border bg-muted/20">
<div className="aspect-[4/5]">
<img
src={selectedTemplate.previewUrl}
alt={selectedTemplate.name}
className="h-full w-full object-contain"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{selectedTemplate.width} x {selectedTemplate.height}
</Badge>
<Badge variant="outline">
{
selectedTemplate.layers.filter((layer) => layer.slotType === "image").length
}{" "}
个图片槽位
</Badge>
<Badge variant="outline">
{
selectedTemplate.layers.filter((layer) => layer.slotType === "text").length
}{" "}
个文字槽位
</Badge>
</div>
{selectedTemplate.description ? (
<div className="rounded-xl border p-3 text-sm text-muted-foreground">
{selectedTemplate.description}
</div>
) : null}
<Button onClick={handleRender} disabled={isRendering} className="w-full">
{isRendering ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
生成套版结果
</Button>
</>
) : (
<div className="rounded-2xl border border-dashed p-5 text-sm text-muted-foreground">
{isLoading ? "正在加载可用模板..." : "当前还没有启用的 PSD 模板,请先去后台配置。"}
</div>
)}
</div>
</div>
<div className="border-r p-5">
<div className="mb-4">
<div className="text-sm font-medium">槽位内容</div>
<div className="mt-1 text-xs text-muted-foreground">
图片槽位支持上传和套用当前结果图,文字槽位直接输入即可。
</div>
</div>
<ScrollArea className="h-[65vh] pr-4">
<div className="space-y-4">
{selectedTemplate?.layers.length ? (
selectedTemplate.layers.map((layer) => {
if (isAgentPsdImageLayerSlot(layer)) {
const assignedUrl = imageAssignments[layer.id] || "";
const slotUploadInputId = `psd-slot-upload-${layer.id}`;
return (
<div key={layer.id} className="rounded-2xl border p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{layer.layerName}</div>
<Badge variant="outline">图片槽位</Badge>
{layer.isRequired ? <Badge variant="destructive">必传</Badge> : null}
{layer.isDefaultResultSource ? (
<Badge variant="secondary">默认结果图</Badge>
) : null}
<Badge variant="outline">
{layer.width} x {layer.height}
</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">{layer.layerPath}</div>
{assignedUrl ? (
<div className="mt-3 overflow-hidden rounded-xl border bg-muted/20">
<div className="aspect-[4/3]">
<img
src={assignedUrl}
alt={layer.layerName}
className="h-full w-full object-contain"
/>
</div>
</div>
) : (
<div className="mt-3 flex aspect-[4/3] items-center justify-center rounded-xl border border-dashed bg-muted/10 text-xs text-muted-foreground">
暂未分配素材
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
{sourceImageUrl ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setImageAssignments((current) => ({
...current,
[layer.id]: sourceImageUrl,
}))
}
>
使用当前结果图
</Button>
) : null}
{layer.allowUserUploadOverride ? (
<>
<Input
id={slotUploadInputId}
className="hidden"
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => handleSlotUpload(layer.id, event)}
/>
<Label htmlFor={slotUploadInputId} className="inline-flex">
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingSlotId === layer.id}
onClick={(event) => {
if (uploadingSlotId === layer.id) {
event.preventDefault();
}
}}
>
{uploadingSlotId === layer.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传替换图
</Button>
</Label>
</>
) : null}
{assignedUrl ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setImageAssignments((current) => {
const nextAssignments = { ...current };
delete nextAssignments[layer.id];
return nextAssignments;
})
}
>
清空
</Button>
) : null}
</div>
</div>
);
}
const textValue = textAssignments[layer.id] || "";
return (
<div key={layer.id} className="rounded-2xl border p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{layer.layerName}</div>
<Badge variant="default">文字槽位</Badge>
{layer.isRequired ? <Badge variant="destructive">必填</Badge> : null}
<Badge variant="outline">
{layer.width} x {layer.height}
</Badge>
{layer.fontLabel || layer.fontFamily ? (
<Badge variant="secondary">{layer.fontLabel || layer.fontFamily}</Badge>
) : null}
</div>
<div className="mt-1 text-xs text-muted-foreground">{layer.layerPath}</div>
<div className="mt-3 grid gap-3">
<Textarea
rows={4}
value={textValue}
placeholder={layer.placeholder || "请输入文字"}
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[layer.id]: event.target.value,
}))
}
/>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>字体:{layer.fontLabel || layer.fontFamily || "系统字体"}</span>
<span>字号:{layer.fontSize}px</span>
<span>行高:{layer.lineHeight}</span>
<span>对齐:{layer.textAlign}</span>
</div>
</div>
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed p-5 text-sm text-muted-foreground">
暂无可编辑槽位。
</div>
)}
</div>
</ScrollArea>
</div>
<div className="p-5">
<div className="mb-4">
<div className="text-sm font-medium">套版结果</div>
<div className="mt-1 text-xs text-muted-foreground">
生成后可直接预览并下载结果图。
</div>
</div>
{renderedResultUrl ? (
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border bg-muted/20">
<div className="aspect-[4/5]">
<img
src={renderedResultUrl}
alt="PSD 模板渲染结果"
className="h-full w-full object-contain"
/>
</div>
</div>
<Button onClick={handleDownload} className="w-full">
<Download className="mr-2 h-4 w-4" />
下载结果图
</Button>
</div>
) : (
<div className="flex h-[65vh] flex-col items-center justify-center rounded-2xl border border-dashed bg-muted/10 px-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted">
{selectedTemplate?.layers.some((layer) => layer.slotType === "text") ? (
<Type className="h-8 w-8 text-muted-foreground" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground" />
)}
</div>
<div className="mt-4 text-sm font-medium">结果还没生成</div>
<div className="mt-2 text-xs leading-6 text-muted-foreground">
选好模板和槽位内容后点击“生成套版结果”,这里会显示导出的图片。
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,702 @@
# 智能体结果区接入 PSD 模板替换功能开发文档
## 1. 目标
在智能体专用功能页面中,为生图结果区增加一个新的结果后处理能力:
- 管理员可以在后台上传 PSD 模板
- 管理员可以配置 PSD 中哪些图层允许用户替换,哪些图层锁定不可改
- 用户可以在智能体结果区直接选择某张生成结果图,套用 PSD 模板
- 用户可继续上传图片替换模板中可编辑图层
- 系统输出最终合成图,并支持下载
本功能定位为:
- `管理员预设模板 + 用户替换素材 + 一键合成导出`
本功能不定位为:
- 通用 PSD 编辑器
- 完整 Photoshop 在线还原器
- 支持任意复杂 PSD 全特效、全图层类型的无损回写
---
## 2. 现状评估
### 2.1 已有能力
当前项目已经具备以下基础,可以直接复用:
- 智能体专用工作台和结果区
- `components/agents/AgentGenerationWorkspaceDarkV2.tsx`
- 智能体项目结果归档
- `types/agent.ts`
- `lib/agents/generation-project-state.ts`
- 文件上传和对象存储
- `lib/storage/service.ts`
- 图片编辑弹层容器
- `components/image-annotator.tsx`
- 后台系统设置页与保存链路
- `app/(admin)/settings/admin-settings-client.tsx`
- `app/api/site-settings/route.ts`
### 2.2 当前缺口
项目中目前没有以下能力:
- PSD 模板管理
- PSD 图层树解析
- 图层级可编辑槽位定义
- 根据图层规则进行模板合成
- 结果区内“套用 PSD 模板”的入口
因此该需求是一个可独立立项的新功能,不属于简单前端补按钮。
---
## 3. 用户路径设计
### 3.1 管理员路径
1. 管理员进入后台 `系统设置`
2. 打开新的 `PSD模板管理` 模块
3. 上传 PSD 模板文件
4. 系统解析图层树并生成模板预览
5. 管理员为图层配置:
- 是否可替换
- 显示名称
- 是否必填
- 默认是否使用“当前结果图”
- 填充模式:`cover / contain / stretch`
- 是否允许用户自行上传覆盖
6. 保存模板并发布启用
### 3.2 用户路径
1. 用户在智能体结果区看到某张结果图
2. 点击该结果图上的 `套用PSD模板`
3. 打开模板编辑面板
4. 当前结果图自动填入管理员配置的默认槽位
5. 用户继续为其余可编辑图层上传图片
6. 点击 `生成模板图`
7. 系统返回合成结果
8. 用户下载 PNG/JPG
### 3.3 为什么要从结果区直接进入
这样最符合当前工作流:
- 用户不需要先下载结果图再重新上传
- 当前结果图可以直接作为模板主图输入
- 功能属于“结果加工”,放在结果区比放在独立页面更自然
---
## 4. MVP 范围
第一版建议仅支持以下范围:
- PSD 模板文件上传
- 解析 PSD 图层树
- 支持普通位图图层作为可替换槽位
- 支持图层位置、尺寸、透明度、显示顺序
- 支持图层分组的层级结构读取
- 支持生成扁平化结果图 PNG/JPG
- 支持从智能体结果图直接进入模板编辑
- 支持用户上传图片替换多个可编辑槽位
第一版不建议承诺:
- 复杂图层特效完整还原
- 调整图层完整支持
- 智能对象完整支持
- 文字层在线编辑
- PSD 文件回写导出
- CMYK/16bit/32bit 全兼容
- 任意 Photoshop 私有特性兼容
---
## 5. 开源方案评估
### 5.1 候选方案
#### 方案 A:`@webtoon/psd`
- 仓库:<https://github.com/webtoon/psd>
- 语言:TypeScript
- 适用:Node / Browser
- 优点:
- 前后端都能接
- 对当前 Next.js 项目接入成本低
- 适合做 PSD/PSB 结构解析、图层信息读取
- 风险:
- 对复杂 Photoshop 元数据支持有限
- 图层特效完整兼容不适合做第一版承诺
#### 方案 B:`ag-psd`
- 仓库:<https://github.com/Agamnentzar/ag-psd>
- 语言:JavaScript / TypeScript
- 适用:Node / Browser
- 优点:
- 生态成熟
- 读写 PSD 成本较低
- 适合快速做 Node 侧 PoC
- 风险:
- 对复杂 PSD 特性支持有限
- 不适合作为“复杂模板 100% 兼容”的基础承诺
#### 方案 C:`psd-tools`
- 仓库:<https://github.com/psd-tools/psd-tools>
- 语言:Python
- 优点:
- 服务端解析能力更稳
- 适合复杂模板和批处理
- 风险:
- 需要额外引入 Python 服务
- 当前 Node 项目接入复杂度更高
### 5.2 推荐结论
推荐分阶段:
- 第一阶段:
- 优先使用 Node 方案
- 首选 `ag-psd` 或 `@webtoon/psd` 做 PoC
- 结合现有 `sharp` 完成最终合成导出
- 第二阶段:
- 若第一阶段发现复杂 PSD 兼容性不够
- 再拆出 Python 微服务,基于 `psd-tools` 做增强版解析
### 5.3 与本项目最匹配的建议
当前项目是 Next.js + Node + S3 上传链路,第一版建议:
- PSD 解析:Node 侧 `ag-psd`
- 图片处理与导出:`sharp`
- 存储:复用 `lib/storage/service.ts`
原因:
- 改动最小
- 接入速度最快
- 适合先把产品闭环跑通
---
## 6. 产品设计建议
### 6.1 后台管理入口
建议在系统设置中新增一个分组:
- `PSD模板管理`
建议不要继续把模板配置塞到单个 `site_settings` 大 JSON 中,而是独立成资源管理。
### 6.2 智能体结果区入口
在智能体结果图卡片上增加新操作按钮:
- `套用PSD模板`
保留现有动作:
- 下载
- 送入画布改字
新增后的结果卡片动作建议:
- 下载
- 送入画布改字
- 套用PSD模板
- 删除
### 6.3 模板编辑弹层
建议使用右侧抽屉或大弹窗,不建议跳新页面。
弹层结构建议:
- 左侧:模板预览图
- 右侧:可编辑图层槽位列表
每个槽位显示:
- 槽位名称
- 图层预览缩略图
- 当前素材来源
- 当前结果图
- 用户上传
- 替换按钮
- 清空按钮
底部操作:
- 生成模板图
- 下载结果
---
## 7. 数据结构设计
建议新增以下表。
### 7.1 `PsdTemplate`
用于存模板主信息。
字段建议:
- `id`
- `name`
- `description`
- `status`
- `draft`
- `active`
- `disabled`
- `psdAssetUrl`
- `previewImageUrl`
- `flattenPreviewUrl`
- `canvasWidth`
- `canvasHeight`
- `layerCount`
- `parserEngine`
- `parserVersion`
- `createdBy`
- `createdAt`
- `updatedAt`
### 7.2 `PsdTemplateLayerSlot`
用于存 PSD 图层配置。
字段建议:
- `id`
- `templateId`
- `layerKey`
- `layerName`
- `parentPath`
- `sortOrder`
- `isEditable`
- `isRequired`
- `isDefaultResultSource`
- `allowUserUploadOverride`
- `replaceMode`
- `cover`
- `contain`
- `stretch`
- `x`
- `y`
- `width`
- `height`
- `opacity`
- `blendMode`
- `maskAssetUrl`
- `layerPreviewUrl`
- `lockedReason`
### 7.3 `PsdTemplateRenderTask`
用于记录用户模板替换任务。
字段建议:
- `id`
- `userId`
- `chatId`
- `projectId`
- `sourceResultImageUrl`
- `templateId`
- `status`
- `queued`
- `processing`
- `succeeded`
- `failed`
- `slotAssignments`
- `resultImageUrl`
- `errorMessage`
- `createdAt`
- `updatedAt`
### 7.4 结果归档扩展
建议在智能体结果模式中新增:
- `psd_template_replace`
对应类型位置:
- `types/agent.ts`
- `lib/agents/generation-project-state.ts`
这样模板替换结果可以进入当前项目的结果归档体系,而不是游离在外。
---
## 8. 后台功能设计
### 8.1 模板上传
管理员上传 PSD 后,服务端执行:
1. 上传原始 PSD 到对象存储
2. 创建解析任务
3. 解析图层树
4. 生成模板预览图
5. 抽取图层预览图
6. 初始化图层槽位配置
### 8.2 图层配置页面
管理员在后台对每个图层设置:
- 是否允许替换
- 是否锁定
- 是否默认使用当前结果图
- 是否必填
- 用户是否可覆盖
- 填充模式
- 图层显示名称
### 8.3 模板发布
只有 `active` 模板才会在前台结果区中可选。
---
## 9. 前台功能设计
### 9.1 结果图卡片新增按钮
修改文件:
- `components/agents/AgentGenerationWorkspaceDarkV2.tsx`
在以下结果卡片区域增加 `套用PSD模板`:
- `detail_generation`
- `detail_replacement`
- `sku_replacement`
- `scene_generation`
### 9.2 打开模板编辑器
点击后执行:
1. 打开模板选择器
2. 若只有一个启用模板,直接进入模板编辑面板
3. 自动把当前结果图填入默认槽位
### 9.3 替换槽位填充
用户为每个 editable slot 选择素材来源:
- 使用当前结果图
- 上传新图片
- 清空
### 9.4 结果下载
输出结果建议:
- 默认导出 PNG
- 可选导出 JPG
不建议第一版导出 PSD。
---
## 10. API 设计
建议新增以下接口。
### 10.1 后台模板管理
- `GET /api/admin/psd-templates`
- `POST /api/admin/psd-templates`
- `GET /api/admin/psd-templates/:id`
- `PUT /api/admin/psd-templates/:id`
- `DELETE /api/admin/psd-templates/:id`
### 10.2 图层槽位管理
- `PUT /api/admin/psd-templates/:id/layers`
### 10.3 用户模板列表
- `GET /api/psd-templates`
返回启用模板的简要信息。
### 10.4 模板渲染
- `POST /api/psd-templates/render`
请求体建议:
```json
{
"templateId": "xxx",
"projectId": "xxx",
"chatId": "xxx",
"sourceResultImageUrl": "https://...",
"slotAssignments": [
{
"layerSlotId": "slot-1",
"sourceType": "result_image",
"imageUrl": "https://..."
},
{
"layerSlotId": "slot-2",
"sourceType": "upload",
"imageUrl": "https://..."
}
]
}
```
响应建议:
```json
{
"success": true,
"taskId": "xxx",
"resultImageUrl": "https://..."
}
```
---
## 11. 渲染与合成方案
### 11.1 推荐实现方式
不要尝试在第一版做“完整 PSD 在线编辑”,而是采用:
- `PSD 解析`
- `图层资产切片`
- `规则化重组`
### 11.2 合成流程
管理员上传模板后:
1. 解析 PSD
2. 读取图层顺序
3. 把每个锁定图层导出为 PNG 资产
4. 把可替换图层记录为 `slot`
5. 若图层自带蒙版,导出蒙版资产
用户执行模板替换时:
1. 读取模板图层顺序
2. 对每个可替换槽位做图片变换
- 缩放
- 裁剪
- 平移
- 蒙版裁切
3. 按原始 z-index 顺序叠加
4. 用 `sharp` 输出最终 PNG/JPG
### 11.3 第一版支持的图层类型
建议仅支持:
- 普通位图图层
- 图层组
- 透明度
- 基础可见性
- 基础图层顺序
### 11.4 第一版不保证的图层能力
- 图层样式
- 调整图层
- 智能对象深度编辑
- 文字层重新布局
- 高级混合模式精确保真
---
## 12. 模板规范约束
为了让第一版稳定,必须给运营/设计一个模板规范:
- 模板请使用 RGB / 8bit
- 可替换图层尽量使用普通像素图层
- 可替换图层命名必须规范,例如:
- `slot_main_product`
- `slot_model`
- `slot_logo`
- 不要把关键替换区域做成复杂智能对象
- 文字层、滤镜、阴影、渐变叠加等复杂效果尽量做成锁定图层
- 所有用户可替换区域必须在 PSD 中有明确边界
---
## 13. 与现有系统的对接点
### 13.1 智能体结果区
改动文件:
- `components/agents/AgentGenerationWorkspaceDarkV2.tsx`
新增:
- 结果卡片按钮 `套用PSD模板`
- 模板编辑抽屉
- 模板结果下载按钮
### 13.2 项目结果归档
改动文件:
- `types/agent.ts`
- `lib/agents/generation-project-state.ts`
- `app/canvas/[id]/page.tsx`
新增:
- `psd_template_replace` 结果分类
### 13.3 后台系统设置
如果继续沿用系统设置容器,可在:
- `app/(admin)/settings/admin-settings-client.tsx`
中增加模板管理入口。
但更推荐新增独立管理页:
- `app/(admin)/settings/psd-templates/page.tsx`
---
## 14. 风险评估
### 14.1 技术风险
- 开源 PSD 库兼容性有限
- 不同 PSD 导出习惯会导致模板可解析性差异很大
- 智能对象、文字层、图层样式是风险最高区域
### 14.2 产品风险
- 如果一开始承诺“任意 PSD 都能改”,后续维护成本会极高
- 如果管理员模板配置过于复杂,后台操作成本会上升
### 14.3 控制策略
- 第一版强约束模板规范
- 只开放位图层替换
- 后台明确标注“不支持复杂 PSD 高级效果完整还原”
- 先做少量模板验证,再扩大使用范围
---
## 15. 开发拆分建议
### 阶段 1:PoC
目标:
- 上传一个 PSD
- 解析图层树
- 指定 1 到 2 个可替换图层
- 输出合成 PNG
产出:
- 证明技术链路成立
### 阶段 2:后台配置
目标:
- 完成模板管理
- 完成图层槽位设置
- 完成启用/禁用
### 阶段 3:前台结果区接入
目标:
- 从智能体结果卡片进入模板编辑
- 自动带入当前结果图
- 用户补图
- 输出结果并下载
### 阶段 4:项目归档与运营化
目标:
- 模板替换结果进入项目结果区
- 支持结果二次查看
- 支持模板权限与状态管理
---
## 16. 工期预估
### MVP
- 需求细化与 PoC:2 到 3 天
- 后台模板管理:3 到 5 天
- 前台结果区接入:3 到 5 天
- 服务端渲染导出:3 到 5 天
- 联调与修正:2 到 4 天
总计:
- 约 2 周
### 稳定版
若加入更多模板兼容、任务队列、错误回退、运营配置,建议预留:
- 3 到 4 周
---
## 17. 验收标准
### 后台
- 管理员可上传 PSD 模板
- 系统可展示图层列表
- 管理员可标记哪些图层允许替换
- 模板可启用/停用
### 前台
- 用户可从智能体结果区直接点击进入 PSD 模板编辑
- 当前结果图可自动注入默认槽位
- 用户可上传图片替换其他 editable slot
- 能生成最终模板图
- 能下载生成结果
### 数据
- 模板替换结果可被记录
- 模板配置可持久化
- 出错时能返回明确失败原因
---
## 18. 最终建议
建议立项,但必须按以下原则推进:
- 第一版只做“模板替换器”,不做“通用 PSD 编辑器”
- 第一版只支持规范模板,不承诺任意 PSD 全兼容
- 入口放在智能体结果区,直接作用于现有生成结果
- 模板管理后台独立建设,不建议继续把所有配置堆到单个大 JSON 里
如果按这个边界推进,该功能具备明确商业价值,并且和当前项目的智能体工作台链路高度匹配。

View File

@ -0,0 +1,90 @@
export const AGENT_PSD_FONTS_KEY = "agent_psd_fonts";
export interface AgentPsdFont {
id: string;
name: string;
family: string;
fileName: string;
fileUrl: string;
filePath?: string;
contentType?: string;
createdAt: string;
updatedAt: string;
uploadedById?: string;
}
export interface AgentPsdFontListResponse {
success: boolean;
fonts: AgentPsdFont[];
error?: string;
}
export interface AgentPsdFontMutationResponse {
success: boolean;
font?: AgentPsdFont;
error?: string;
}
function normalizeText(value: unknown) {
return String(value || "").trim();
}
function normalizeUrl(value: unknown) {
const text = normalizeText(value);
return /^https?:\/\//i.test(text) ? text : "";
}
function toIsoDateString(value: unknown) {
const text = normalizeText(value);
if (!text) return null;
const date = new Date(text);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
}
export function parseAgentPsdFonts(value: unknown) {
if (!Array.isArray(value)) return [] as AgentPsdFont[];
const fonts: AgentPsdFont[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
continue;
}
const record = entry as Record<string, unknown>;
const id = normalizeText(record.id);
const name = normalizeText(record.name);
const family = normalizeText(record.family);
const fileName = normalizeText(record.fileName);
const fileUrl = normalizeUrl(record.fileUrl);
const createdAt = toIsoDateString(record.createdAt);
const updatedAt = toIsoDateString(record.updatedAt) || createdAt;
if (!id || !name || !family || !fileName || !fileUrl || !createdAt || !updatedAt) {
continue;
}
fonts.push({
id,
name,
family,
fileName,
fileUrl,
filePath: normalizeText(record.filePath) || undefined,
contentType: normalizeText(record.contentType) || undefined,
createdAt,
updatedAt,
uploadedById: normalizeText(record.uploadedById) || undefined,
});
}
return fonts.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
export function upsertAgentPsdFont(fonts: AgentPsdFont[], font: AgentPsdFont) {
return [font, ...fonts.filter((entry) => entry.id !== font.id)].sort((left, right) =>
right.updatedAt.localeCompare(left.updatedAt)
);
}

View File

@ -0,0 +1,30 @@
import "server-only";
import { getDefaultStorageConfig, getSystemSetting } from "@/lib/db/queries";
import {
AGENT_PSD_FONTS_KEY,
parseAgentPsdFonts,
type AgentPsdFont,
} from "@/lib/agents/psd-font-config";
export async function readAgentPsdFonts() {
const setting = await getSystemSetting({ key: AGENT_PSD_FONTS_KEY });
return parseAgentPsdFonts(setting?.value);
}
export async function tryDeleteAgentPsdFontAssets(font: AgentPsdFont) {
if (!font.filePath) return;
try {
const storageConfig = await getDefaultStorageConfig().catch(() => undefined);
if (!storageConfig || storageConfig.type !== "s3") return;
const { deleteS3ObjectByPath, toDbS3UploadConfig } = await import("@/lib/storage/service");
const uploadConfig = toDbS3UploadConfig(storageConfig);
if (!uploadConfig) return;
await deleteS3ObjectByPath(font.filePath, uploadConfig);
} catch (error) {
console.warn("[PSD Font] Failed to delete font asset:", error);
}
}

View File

@ -0,0 +1,361 @@
export const AGENT_PSD_TEMPLATES_KEY = "agent_psd_templates";
export type AgentPsdTemplateStatus = "active" | "disabled";
export type AgentPsdTemplateReplaceMode = "cover" | "contain" | "stretch";
export type AgentPsdTemplateSlotType = "image" | "text";
export type AgentPsdTextAlign = "left" | "center" | "right";
export interface AgentPsdTemplateBaseLayerSlot {
id: string;
layerId: string;
layerName: string;
layerPath: string;
depth: number;
x: number;
y: number;
width: number;
height: number;
isEditable: boolean;
isRequired: boolean;
}
export interface AgentPsdTemplateImageLayerSlot
extends AgentPsdTemplateBaseLayerSlot {
slotType: "image";
isDefaultResultSource: boolean;
allowUserUploadOverride: boolean;
replaceMode: AgentPsdTemplateReplaceMode;
maskUrl?: string;
maskPath?: string;
}
export interface AgentPsdTemplateTextLayerSlot
extends AgentPsdTemplateBaseLayerSlot {
slotType: "text";
defaultText?: string;
placeholder?: string;
fontId?: string;
fontFamily?: string;
fontLabel?: string;
fontSize: number;
lineHeight: number;
letterSpacing: number;
fontWeight: number;
textColor: string;
textAlign: AgentPsdTextAlign;
paddingX: number;
paddingY: number;
maxLines: number;
}
export type AgentPsdTemplateLayerSlot =
| AgentPsdTemplateImageLayerSlot
| AgentPsdTemplateTextLayerSlot;
export interface AgentPsdTemplate {
id: string;
name: string;
description?: string;
status: AgentPsdTemplateStatus;
fileName: string;
fileUrl: string;
filePath?: string;
previewUrl: string;
previewPath?: string;
renderBaseUrl?: string;
renderBasePath?: string;
width: number;
height: number;
layerCount: number;
layers: AgentPsdTemplateLayerSlot[];
createdAt: string;
updatedAt: string;
uploadedById?: string;
}
export interface AgentPsdTemplateListResponse {
success: boolean;
templates: AgentPsdTemplate[];
error?: string;
}
export interface AgentPsdTemplateMutationResponse {
success: boolean;
template?: AgentPsdTemplate;
error?: string;
}
export interface AgentPsdTemplateRenderResult {
url: string;
pathname: string;
contentType?: string;
}
export interface AgentPsdTemplateRenderResponse {
success: boolean;
result?: AgentPsdTemplateRenderResult;
error?: string;
}
function normalizeText(value: unknown) {
return String(value || "").trim();
}
function normalizeUrl(value: unknown) {
const text = normalizeText(value);
return /^https?:\/\//i.test(text) ? text : "";
}
function normalizeOptionalUrl(value: unknown) {
const url = normalizeUrl(value);
return url || undefined;
}
function toIsoDateString(value: unknown) {
const text = normalizeText(value);
if (!text) return null;
const date = new Date(text);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
}
function normalizePositiveInteger(value: unknown) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) return 0;
return Math.max(0, Math.round(numberValue));
}
function normalizePositiveNumber(value: unknown, fallback: number) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue) || numberValue <= 0) {
return fallback;
}
return numberValue;
}
function normalizeColor(value: unknown, fallback = "#111111") {
const text = normalizeText(value);
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(text)) {
return text;
}
return fallback;
}
export function normalizeAgentPsdTemplateStatus(value: unknown): AgentPsdTemplateStatus {
return normalizeText(value) === "active" ? "active" : "disabled";
}
export function normalizeAgentPsdTemplateReplaceMode(
value: unknown
): AgentPsdTemplateReplaceMode {
const mode = normalizeText(value);
if (mode === "contain" || mode === "stretch") {
return mode;
}
return "cover";
}
export function normalizeAgentPsdTemplateSlotType(
value: unknown
): AgentPsdTemplateSlotType {
return normalizeText(value) === "text" ? "text" : "image";
}
export function normalizeAgentPsdTextAlign(value: unknown): AgentPsdTextAlign {
const align = normalizeText(value);
if (align === "center" || align === "right") {
return align;
}
return "left";
}
export function parseAgentPsdTemplateLayers(value: unknown) {
if (!Array.isArray(value)) return [] as AgentPsdTemplateLayerSlot[];
const layers: AgentPsdTemplateLayerSlot[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
continue;
}
const record = entry as Record<string, unknown>;
const id = normalizeText(record.id);
const layerId = normalizeText(record.layerId);
const layerName = normalizeText(record.layerName);
const layerPath = normalizeText(record.layerPath);
const width = normalizePositiveInteger(record.width);
const height = normalizePositiveInteger(record.height);
const slotType = normalizeAgentPsdTemplateSlotType(record.slotType);
if (!id || !layerId || !layerName || !layerPath || width <= 0 || height <= 0) {
continue;
}
const isEditable = record.isEditable === true;
const baseRecord = {
id,
layerId,
layerName,
layerPath,
depth: Math.max(0, normalizePositiveInteger(record.depth)),
x: normalizePositiveInteger(record.x),
y: normalizePositiveInteger(record.y),
width,
height,
isEditable,
isRequired: isEditable && record.isRequired === true,
} satisfies AgentPsdTemplateBaseLayerSlot;
if (slotType === "text") {
const estimatedFontSize = Math.max(16, Math.min(height, Math.round(height * 0.78)));
layers.push({
...baseRecord,
slotType: "text",
defaultText: normalizeText(record.defaultText) || undefined,
placeholder: normalizeText(record.placeholder) || undefined,
fontId: normalizeText(record.fontId) || undefined,
fontFamily: normalizeText(record.fontFamily) || undefined,
fontLabel: normalizeText(record.fontLabel) || undefined,
fontSize: normalizePositiveNumber(record.fontSize, estimatedFontSize),
lineHeight: normalizePositiveNumber(record.lineHeight, 1.2),
letterSpacing: Number.isFinite(Number(record.letterSpacing))
? Number(record.letterSpacing)
: 0,
fontWeight: Math.max(
100,
Math.min(900, normalizePositiveInteger(record.fontWeight) || 500)
),
textColor: normalizeColor(record.textColor),
textAlign: normalizeAgentPsdTextAlign(record.textAlign),
paddingX: normalizePositiveInteger(record.paddingX),
paddingY: normalizePositiveInteger(record.paddingY),
maxLines: Math.max(1, normalizePositiveInteger(record.maxLines) || 4),
});
continue;
}
layers.push({
...baseRecord,
slotType: "image",
isDefaultResultSource: isEditable && record.isDefaultResultSource === true,
allowUserUploadOverride: isEditable ? record.allowUserUploadOverride !== false : false,
replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode),
maskUrl: normalizeOptionalUrl(record.maskUrl),
maskPath: normalizeText(record.maskPath) || undefined,
});
}
let defaultAssigned = false;
return layers.map((layer) => {
if (layer.slotType === "image") {
if (!layer.isEditable || !layer.isDefaultResultSource) {
return {
...layer,
isDefaultResultSource: false,
};
}
if (defaultAssigned) {
return {
...layer,
isDefaultResultSource: false,
};
}
defaultAssigned = true;
return layer;
}
return layer;
});
}
export function parseAgentPsdTemplates(value: unknown) {
if (!Array.isArray(value)) return [] as AgentPsdTemplate[];
const templates: AgentPsdTemplate[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
continue;
}
const record = entry as Record<string, unknown>;
const id = normalizeText(record.id);
const name = normalizeText(record.name);
const fileName = normalizeText(record.fileName);
const fileUrl = normalizeUrl(record.fileUrl);
const previewUrl = normalizeUrl(record.previewUrl);
const createdAt = toIsoDateString(record.createdAt);
const updatedAt = toIsoDateString(record.updatedAt) || createdAt;
const width = normalizePositiveInteger(record.width);
const height = normalizePositiveInteger(record.height);
const layers = parseAgentPsdTemplateLayers(record.layers);
const layerCount = Math.max(layers.length, normalizePositiveInteger(record.layerCount));
if (
!id ||
!name ||
!fileName ||
!fileUrl ||
!previewUrl ||
!createdAt ||
!updatedAt ||
width <= 0 ||
height <= 0
) {
continue;
}
templates.push({
id,
name,
description: normalizeText(record.description) || undefined,
status: normalizeAgentPsdTemplateStatus(record.status),
fileName,
fileUrl,
filePath: normalizeText(record.filePath) || undefined,
previewUrl,
previewPath: normalizeText(record.previewPath) || undefined,
renderBaseUrl:
normalizeOptionalUrl(record.renderBaseUrl) || normalizeOptionalUrl(record.previewUrl),
renderBasePath: normalizeText(record.renderBasePath) || undefined,
width,
height,
layerCount,
layers,
createdAt,
updatedAt,
uploadedById: normalizeText(record.uploadedById) || undefined,
});
}
return templates.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
export function upsertAgentPsdTemplate(
templates: AgentPsdTemplate[],
template: AgentPsdTemplate
) {
return [template, ...templates.filter((entry) => entry.id !== template.id)].sort((left, right) =>
right.updatedAt.localeCompare(left.updatedAt)
);
}
export function getEditableAgentPsdTemplateLayers(template: AgentPsdTemplate) {
return template.layers.filter((layer) => layer.isEditable);
}
export function isAgentPsdTextLayerSlot(
layer: AgentPsdTemplateLayerSlot
): layer is AgentPsdTemplateTextLayerSlot {
return layer.slotType === "text";
}
export function isAgentPsdImageLayerSlot(
layer: AgentPsdTemplateLayerSlot
): layer is AgentPsdTemplateImageLayerSlot {
return layer.slotType === "image";
}

View File

@ -0,0 +1,339 @@
import "server-only";
import { uploadFile } from "@/lib/storage/service";
import {
isAgentPsdImageLayerSlot,
type AgentPsdTemplateLayerSlot,
} from "@/lib/agents/psd-template-config";
type PsdLayerLike = {
type?: string;
name?: string;
children?: PsdLayerLike[];
width?: number;
height?: number;
left?: number;
top?: number;
isHidden?: boolean;
text?: string;
additionalProperties?: Record<string, unknown>;
composite?: (effect?: boolean, composed?: boolean) => Promise<Uint8ClampedArray>;
};
type PsdRootLike = {
width: number;
height: number;
children: PsdLayerLike[];
composite: () => Promise<Uint8ClampedArray>;
};
export interface ParsedPsdTemplateLayer {
id: string;
layerId: string;
layerName: string;
layerPath: string;
depth: number;
x: number;
y: number;
width: number;
height: number;
slotType: "image" | "text";
defaultText?: string;
}
export interface ParsedPsdTemplatePayload {
width: number;
height: number;
previewBuffer: Buffer;
layers: ParsedPsdTemplateLayer[];
}
interface PsdLayerNodeEntry {
node: PsdLayerLike;
layer: ParsedPsdTemplateLayer;
}
function normalizeLayerName(value: unknown, fallback: string) {
const text = String(value || "").trim();
return text || fallback;
}
function sanitizeFileSegment(value: string, fallback: string) {
const normalized = value
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
return normalized || fallback;
}
async function getPsdConstructor() {
const module = await import("@webtoon/psd");
return module.default;
}
async function getSharp() {
const sharp = await import("sharp");
return sharp.default;
}
async function rgbaToPngBuffer(rgba: Uint8ClampedArray, width: number, height: number) {
const sharp = await getSharp();
return sharp(Buffer.from(rgba), {
raw: {
width,
height,
channels: 4,
},
})
.png()
.toBuffer();
}
function extractLayerId(node: PsdLayerLike, fallback: string) {
const additional = node.additionalProperties;
const layerIdRecord =
additional && typeof additional === "object"
? (additional.lyid as { value?: unknown } | undefined)
: undefined;
const layerId = layerIdRecord?.value;
if (typeof layerId === "number" || typeof layerId === "string") {
const normalized = String(layerId).trim();
if (normalized) return normalized;
}
return fallback;
}
function collectPsdLayerEntries(
nodes: PsdLayerLike[],
ancestors: string[] = [],
depth = 0,
result: PsdLayerNodeEntry[] = [],
orderRef: { current: number } = { current: 0 }
) {
for (const node of nodes) {
if (!node || typeof node !== "object") continue;
const nodeType = String(node.type || "");
if (nodeType === "Group") {
const groupName = normalizeLayerName(node.name, `分组${orderRef.current + 1}`);
collectPsdLayerEntries(
Array.isArray(node.children) ? node.children : [],
[...ancestors, groupName],
depth + 1,
result,
orderRef
);
continue;
}
if (nodeType !== "Layer") {
continue;
}
const width = Math.max(0, Math.round(Number(node.width || 0)));
const height = Math.max(0, Math.round(Number(node.height || 0)));
if (width <= 0 || height <= 0 || node.isHidden) {
continue;
}
const order = orderRef.current;
orderRef.current += 1;
const layerName = normalizeLayerName(node.name, `图层${order + 1}`);
const fallbackLayerId = `${ancestors.join("/")}/${layerName}#${order + 1}`;
const layerId = extractLayerId(node, fallbackLayerId);
const defaultText = String(node.text || "").trim();
result.push({
node,
layer: {
id: `layer-${sanitizeFileSegment(layerId, `layer-${order + 1}`)}`,
layerId,
layerName,
layerPath: [...ancestors, layerName].join(" / "),
depth,
x: Math.max(0, Math.round(Number(node.left || 0))),
y: Math.max(0, Math.round(Number(node.top || 0))),
width,
height,
slotType: defaultText ? "text" : "image",
defaultText: defaultText || undefined,
},
});
}
return result;
}
async function parsePsdBuffer(buffer: ArrayBuffer) {
const Psd = await getPsdConstructor();
return Psd.parse(buffer) as PsdRootLike;
}
async function fetchRemoteBuffer(url: string) {
const response = await fetch(url, {
cache: "no-store",
signal: AbortSignal.timeout(20_000),
});
if (!response.ok) {
throw new Error(`PSD file download failed: ${response.status}`);
}
return await response.arrayBuffer();
}
export async function parsePsdTemplateFile(
buffer: ArrayBuffer
): Promise<ParsedPsdTemplatePayload> {
const psd = await parsePsdBuffer(buffer);
const width = Math.max(1, Math.round(Number(psd.width || 0)));
const height = Math.max(1, Math.round(Number(psd.height || 0)));
const previewRgba = await psd.composite();
const previewBuffer = await rgbaToPngBuffer(previewRgba, width, height);
const layers = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []).map(
(entry) => entry.layer
);
return {
width,
height,
previewBuffer,
layers,
};
}
async function createTransparentCanvasBuffer(width: number, height: number) {
const sharp = await getSharp();
const canvasWidth = Math.max(1, Math.round(Number(width || 0)));
const canvasHeight = Math.max(1, Math.round(Number(height || 0)));
return sharp({
create: {
width: canvasWidth,
height: canvasHeight,
channels: 4,
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
},
})
.png()
.toBuffer();
}
export async function preparePsdTemplateRenderAssets(params: {
templateId: string;
fileUrl: string;
layers: AgentPsdTemplateLayerSlot[];
}) {
const psdBuffer = await fetchRemoteBuffer(params.fileUrl);
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]));
const editableLayerIdSet = new Set(
params.layers.filter((layer) => layer.isEditable).map((layer) => layer.layerId)
);
const nextLayers = [...params.layers];
for (const targetLayer of nextLayers) {
if (!isAgentPsdImageLayerSlot(targetLayer) || !targetLayer.isEditable || targetLayer.maskUrl) {
continue;
}
const entry = nodeMap.get(targetLayer.layerId);
if (!entry?.node || typeof entry.node.composite !== "function") {
continue;
}
try {
const rgba = await entry.node.composite(false, true);
const pixelCount = entry.layer.width * entry.layer.height;
const maskRgba = new Uint8ClampedArray(pixelCount * 4);
let hasVisiblePixel = false;
for (let index = 0; index < pixelCount; index += 1) {
const alpha = rgba[index * 4 + 3] || 0;
maskRgba[index * 4] = 255;
maskRgba[index * 4 + 1] = 255;
maskRgba[index * 4 + 2] = 255;
maskRgba[index * 4 + 3] = alpha;
if (alpha > 0) {
hasVisiblePixel = true;
}
}
if (!hasVisiblePixel) {
continue;
}
const maskBuffer = await rgbaToPngBuffer(maskRgba, entry.layer.width, entry.layer.height);
const uploaded = await uploadFile(
`agent-psd-mask-${params.templateId}-${sanitizeFileSegment(targetLayer.layerId, targetLayer.id)}.png`,
maskBuffer.buffer.slice(
maskBuffer.byteOffset,
maskBuffer.byteOffset + maskBuffer.byteLength
) as ArrayBuffer
);
const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id);
if (layerIndex >= 0 && isAgentPsdImageLayerSlot(nextLayers[layerIndex])) {
nextLayers[layerIndex] = {
...nextLayers[layerIndex],
maskUrl: uploaded.url,
maskPath: uploaded.pathname,
};
}
} catch (error) {
console.warn("[PSD Template] Failed to generate layer mask:", {
layerId: targetLayer.layerId,
error,
});
}
}
const baseCompositeInputs: Array<{ input: Buffer; left: number; top: number }> = [];
for (const entry of layerEntries) {
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);
baseCompositeInputs.push({
input: overlayBuffer,
left: entry.layer.x,
top: entry.layer.y,
});
} catch (error) {
console.warn("[PSD Template] Failed to compose base layer:", {
layerId: entry.layer.layerId,
error,
});
}
}
const sharp = await getSharp();
const transparentBaseBuffer = await createTransparentCanvasBuffer(psd.width, psd.height);
const renderBaseBuffer = await sharp(transparentBaseBuffer)
.composite(baseCompositeInputs)
.png()
.toBuffer();
return {
layers: nextLayers,
renderBaseBuffer,
};
}

View File

@ -0,0 +1,342 @@
import "server-only";
import { uploadFile } from "@/lib/storage/service";
import {
isAgentPsdImageLayerSlot,
isAgentPsdTextLayerSlot,
type AgentPsdTemplate,
} from "@/lib/agents/psd-template-config";
import type { AgentPsdFont } from "@/lib/agents/psd-font-config";
async function getSharp() {
const sharp = await import("sharp");
return sharp.default;
}
async function fetchRemoteBuffer(url: string) {
const response = await fetch(url, {
cache: "no-store",
signal: AbortSignal.timeout(25_000),
});
if (!response.ok) {
throw new Error(`Resource download failed: ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
function sanitizeFileSegment(value: string, fallback: string) {
const normalized = value
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
return normalized || fallback;
}
function escapeXml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function getFontMimeType(font: AgentPsdFont | undefined, fontFamily: string) {
const contentType = String(font?.contentType || "").trim();
if (contentType) return contentType;
const family = fontFamily.toLowerCase();
if (family.endsWith(".woff2")) return "font/woff2";
if (family.endsWith(".woff")) return "font/woff";
if (family.endsWith(".otf")) return "font/otf";
return "font/ttf";
}
function estimateGlyphWidth(char: string, fontSize: number, letterSpacing: number) {
if (char === " ") return fontSize * 0.36 + letterSpacing;
if (/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(char)) {
return fontSize + letterSpacing;
}
if (/[A-Z0-9]/.test(char)) return fontSize * 0.66 + letterSpacing;
return fontSize * 0.58 + letterSpacing;
}
function wrapTextByWidth(params: {
text: string;
maxWidth: number;
fontSize: number;
letterSpacing: number;
maxLines: number;
}) {
const { text, maxWidth, fontSize, letterSpacing, maxLines } = params;
const paragraphs = String(text || "")
.replace(/\r\n/g, "\n")
.split("\n");
const lines: string[] = [];
function pushLine(line: string) {
if (lines.length < maxLines) {
lines.push(line);
}
}
for (const paragraph of paragraphs) {
if (lines.length >= maxLines) break;
if (!paragraph) {
pushLine("");
continue;
}
let currentLine = "";
let currentWidth = 0;
for (const char of paragraph) {
const charWidth = estimateGlyphWidth(char, fontSize, letterSpacing);
if (currentLine && currentWidth + charWidth > maxWidth) {
pushLine(currentLine);
currentLine = char;
currentWidth = charWidth;
if (lines.length >= maxLines) break;
} else {
currentLine += char;
currentWidth += charWidth;
}
}
if (lines.length >= maxLines) break;
if (currentLine || paragraph.length === 0) {
pushLine(currentLine);
}
}
return lines.slice(0, maxLines);
}
async function createTextOverlayBuffer(params: {
width: number;
height: number;
text: string;
fontBuffer?: Buffer;
font: AgentPsdFont | undefined;
fontFamily: string;
fontSize: number;
fontWeight: number;
lineHeight: number;
letterSpacing: number;
textColor: string;
textAlign: "left" | "center" | "right";
paddingX: number;
paddingY: number;
maxLines: number;
}) {
const {
width,
height,
text,
fontBuffer,
font,
fontFamily,
fontSize,
fontWeight,
lineHeight,
letterSpacing,
textColor,
textAlign,
paddingX,
paddingY,
maxLines,
} = params;
const availableWidth = Math.max(8, width - paddingX * 2);
const lineHeightPx = Math.max(fontSize, fontSize * lineHeight);
const heightLimitedMaxLines = Math.max(
1,
Math.floor((height - paddingY * 2 + fontSize * 0.2) / lineHeightPx)
);
const resolvedMaxLines = Math.max(1, Math.min(maxLines, heightLimitedMaxLines));
const lines = wrapTextByWidth({
text,
maxWidth: availableWidth,
fontSize,
letterSpacing,
maxLines: resolvedMaxLines,
});
if (lines.length === 0) {
return null;
}
const escapedFamily = escapeXml(fontFamily || "sans-serif");
const fontFaceCss = fontBuffer
? `@font-face { font-family: "${escapedFamily}"; src: url("data:${getFontMimeType(font, fontFamily)};base64,${fontBuffer.toString("base64")}"); }`
: "";
const clipId = `clip-${Math.random().toString(36).slice(2, 10)}`;
const anchor = textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start";
const x =
textAlign === "center"
? width / 2
: textAlign === "right"
? width - paddingX
: paddingX;
const tspans = lines
.map((line, index) => {
const y = paddingY + fontSize + index * lineHeightPx;
return `<tspan x="${x}" y="${y}">${escapeXml(line)}</tspan>`;
})
.join("");
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>
<style>
${fontFaceCss}
.text { font-family: "${escapedFamily}", sans-serif; font-size: ${fontSize}px; font-weight: ${fontWeight}; fill: ${textColor}; letter-spacing: ${letterSpacing}px; }
</style>
<clipPath id="${clipId}">
<rect x="0" y="0" width="${width}" height="${height}" />
</clipPath>
</defs>
<g clip-path="url(#${clipId})">
<text class="text" text-anchor="${anchor}">${tspans}</text>
</g>
</svg>
`;
const sharp = await getSharp();
return sharp(Buffer.from(svg)).png().toBuffer();
}
export async function renderAgentPsdTemplate(params: {
template: AgentPsdTemplate;
imageAssignments: Record<string, string>;
textAssignments: Record<string, string>;
fonts: AgentPsdFont[];
}) {
const { template, imageAssignments, textAssignments, fonts } = params;
const sharp = await getSharp();
const baseBuffer = await fetchRemoteBuffer(template.renderBaseUrl || template.previewUrl);
const remoteBufferCache = new Map<string, Buffer>();
const fontMap = new Map(fonts.map((font) => [font.id, font]));
async function readCachedBuffer(url: string) {
const normalizedUrl = String(url || "").trim();
if (!normalizedUrl) {
throw new Error("Missing remote asset URL");
}
if (!remoteBufferCache.has(normalizedUrl)) {
remoteBufferCache.set(normalizedUrl, await fetchRemoteBuffer(normalizedUrl));
}
return remoteBufferCache.get(normalizedUrl)!;
}
const compositeInputs: Array<{ input: Buffer; left: number; top: number }> = [];
for (const layer of template.layers) {
if (!layer.isEditable) continue;
if (isAgentPsdImageLayerSlot(layer)) {
const assignmentUrl = String(imageAssignments[layer.id] || "").trim();
if (!assignmentUrl) continue;
const sourceBuffer = await readCachedBuffer(assignmentUrl);
const fit =
layer.replaceMode === "stretch"
? "fill"
: layer.replaceMode === "contain"
? "contain"
: "cover";
let overlayBuffer = await sharp(sourceBuffer)
.rotate()
.resize({
width: layer.width,
height: layer.height,
fit,
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
})
.png()
.toBuffer();
if (layer.maskUrl) {
const maskBuffer = await readCachedBuffer(layer.maskUrl);
overlayBuffer = await sharp(overlayBuffer)
.composite([
{
input: maskBuffer,
blend: "dest-in",
},
])
.png()
.toBuffer();
}
compositeInputs.push({
input: overlayBuffer,
left: layer.x,
top: layer.y,
});
continue;
}
if (isAgentPsdTextLayerSlot(layer)) {
const text = String(textAssignments[layer.id] || layer.defaultText || "").trim();
if (!text) continue;
const font = layer.fontId ? fontMap.get(layer.fontId) : undefined;
const fontBuffer = font?.fileUrl ? await readCachedBuffer(font.fileUrl) : undefined;
const overlayBuffer = await createTextOverlayBuffer({
width: layer.width,
height: layer.height,
text,
fontBuffer,
font,
fontFamily: layer.fontFamily || font?.family || "sans-serif",
fontSize: layer.fontSize,
fontWeight: layer.fontWeight,
lineHeight: layer.lineHeight,
letterSpacing: layer.letterSpacing,
textColor: layer.textColor,
textAlign: layer.textAlign,
paddingX: layer.paddingX,
paddingY: layer.paddingY,
maxLines: layer.maxLines,
});
if (!overlayBuffer) continue;
compositeInputs.push({
input: overlayBuffer,
left: layer.x,
top: layer.y,
});
}
}
const resultBuffer = await sharp(baseBuffer)
.composite(compositeInputs)
.png()
.toBuffer();
const uploaded = await uploadFile(
`agent-psd-render-${sanitizeFileSegment(template.id, "template")}-${Date.now()}.png`,
resultBuffer.buffer.slice(
resultBuffer.byteOffset,
resultBuffer.byteOffset + resultBuffer.byteLength
) as ArrayBuffer
);
return uploaded;
}

View File

@ -0,0 +1,53 @@
import "server-only";
import { getDefaultStorageConfig, getSystemSetting } from "@/lib/db/queries";
import {
AGENT_PSD_TEMPLATES_KEY,
isAgentPsdImageLayerSlot,
parseAgentPsdTemplates,
type AgentPsdTemplate,
} from "@/lib/agents/psd-template-config";
export async function readAgentPsdTemplates() {
const setting = await getSystemSetting({ key: AGENT_PSD_TEMPLATES_KEY });
return parseAgentPsdTemplates(setting?.value);
}
export async function tryDeleteAgentPsdTemplateAssets(template: AgentPsdTemplate) {
const filePaths = Array.from(
new Set(
[
template.filePath,
template.previewPath,
template.renderBasePath,
...template.layers
.filter(isAgentPsdImageLayerSlot)
.map((layer) => layer.maskPath),
].filter((value): value is string => Boolean(String(value || "").trim()))
)
);
if (filePaths.length === 0) return;
try {
const storageConfig = await getDefaultStorageConfig().catch(() => undefined);
if (!storageConfig || storageConfig.type !== "s3") return;
const { deleteS3ObjectByPath, toDbS3UploadConfig } = await import("@/lib/storage/service");
const uploadConfig = toDbS3UploadConfig(storageConfig);
if (!uploadConfig) return;
await Promise.all(
filePaths.map((filePath) =>
deleteS3ObjectByPath(filePath, uploadConfig).catch((error) => {
console.warn("[PSD Template] Failed to delete asset:", {
filePath,
error,
});
})
)
);
} catch (error) {
console.warn("[PSD Template] Failed to clean template assets:", error);
}
}

View File

@ -61,12 +61,18 @@ function getContentType(filename: string): string {
gif: "image/gif", gif: "image/gif",
webp: "image/webp", webp: "image/webp",
svg: "image/svg+xml", svg: "image/svg+xml",
psd: "image/vnd.adobe.photoshop",
psb: "application/octet-stream",
pdf: "application/pdf", pdf: "application/pdf",
json: "application/json", json: "application/json",
txt: "text/plain", txt: "text/plain",
html: "text/html", html: "text/html",
css: "text/css", css: "text/css",
js: "application/javascript", js: "application/javascript",
ttf: "font/ttf",
otf: "font/otf",
woff: "font/woff",
woff2: "font/woff2",
}; };
return types[ext ?? ""] ?? "application/octet-stream"; return types[ext ?? ""] ?? "application/octet-stream";
} }

View File

@ -54,6 +54,7 @@
"@vercel/blob": "^0.24.1", "@vercel/blob": "^0.24.1",
"@vercel/functions": "^2.0.0", "@vercel/functions": "^2.0.0",
"@vercel/otel": "^1.12.0", "@vercel/otel": "^1.12.0",
"@webtoon/psd": "^0.4.0",
"@xenova/transformers": "^2.17.0", "@xenova/transformers": "^2.17.0",
"ai": "^5.0.52", "ai": "^5.0.52",
"bcrypt-ts": "^5.0.2", "bcrypt-ts": "^5.0.2",

8
pnpm-lock.yaml generated
View File

@ -110,6 +110,9 @@ importers:
'@vercel/otel': '@vercel/otel':
specifier: ^1.12.0 specifier: ^1.12.0
version: 1.14.0(@opentelemetry/api-logs@0.200.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)) version: 1.14.0(@opentelemetry/api-logs@0.200.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))
'@webtoon/psd':
specifier: ^0.4.0
version: 0.4.0
'@xenova/transformers': '@xenova/transformers':
specifier: ^2.17.0 specifier: ^2.17.0
version: 2.17.2 version: 2.17.2
@ -3321,6 +3324,9 @@ packages:
'@webgpu/types@0.1.68': '@webgpu/types@0.1.68':
resolution: {integrity: sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA==} resolution: {integrity: sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA==}
'@webtoon/psd@0.4.0':
resolution: {integrity: sha512-ztriE8oFOamRrV9opBURDy+JMiyhur2//vOXsC5CgdnYCB0L1Lnaag4NzP8N+NFCj7uNz9JRYtPmAbQMSDLIsQ==}
'@xenova/transformers@2.17.2': '@xenova/transformers@2.17.2':
resolution: {integrity: sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==} resolution: {integrity: sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==}
@ -9389,6 +9395,8 @@ snapshots:
'@webgpu/types@0.1.68': {} '@webgpu/types@0.1.68': {}
'@webtoon/psd@0.4.0': {}
'@xenova/transformers@2.17.2': '@xenova/transformers@2.17.2':
dependencies: dependencies:
'@huggingface/jinja': 0.2.2 '@huggingface/jinja': 0.2.2