feat: add PSD template font rendering workflow
This commit is contained in:
parent
21d3eb0769
commit
17dcfcd55d
@ -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>
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
19
app/(admin)/settings/agent-workflow/psd-templates/page.tsx
Normal file
19
app/(admin)/settings/agent-workflow/psd-templates/page.tsx
Normal 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="返回智能体工作流配置"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
72
app/api/admin/psd-fonts/[id]/route.ts
Normal file
72
app/api/admin/psd-fonts/[id]/route.ts
Normal 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, "字体删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
133
app/api/admin/psd-fonts/route.ts
Normal file
133
app/api/admin/psd-fonts/route.ts
Normal 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, "字体上传失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
206
app/api/admin/psd-templates/[id]/route.ts
Normal file
206
app/api/admin/psd-templates/[id]/route.ts
Normal 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 模板删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
198
app/api/admin/psd-templates/route.ts
Normal file
198
app/api/admin/psd-templates/route.ts
Normal 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 模板上传或解析失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/api/psd-templates/render/route.ts
Normal file
121
app/api/psd-templates/render/route.ts
Normal 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 模板渲染失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/api/psd-templates/route.ts
Normal file
44
app/api/psd-templates/route.ts
Normal 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 模板读取失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 && (
|
||||||
|
|||||||
464
components/agents/AgentPsdTemplateEditorDialog.tsx
Normal file
464
components/agents/AgentPsdTemplateEditorDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
702
docs/智能体PSD模板替换开发文档-2026-03-26.md
Normal file
702
docs/智能体PSD模板替换开发文档-2026-03-26.md
Normal 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 里
|
||||||
|
|
||||||
|
如果按这个边界推进,该功能具备明确商业价值,并且和当前项目的智能体工作台链路高度匹配。
|
||||||
90
lib/agents/psd-font-config.ts
Normal file
90
lib/agents/psd-font-config.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
30
lib/agents/psd-font-settings.ts
Normal file
30
lib/agents/psd-font-settings.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
361
lib/agents/psd-template-config.ts
Normal file
361
lib/agents/psd-template-config.ts
Normal 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";
|
||||||
|
}
|
||||||
339
lib/agents/psd-template-parser.ts
Normal file
339
lib/agents/psd-template-parser.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
342
lib/agents/psd-template-renderer.ts
Normal file
342
lib/agents/psd-template-renderer.ts
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
53
lib/agents/psd-template-settings.ts
Normal file
53
lib/agents/psd-template-settings.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
8
pnpm-lock.yaml
generated
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user