feat: add PSD template font rendering workflow
This commit is contained in:
parent
21d3eb0769
commit
17dcfcd55d
@ -6224,9 +6224,14 @@ export function AdminSettingsClient() {
|
||||
建议在独立页面中维护每个环节的按钮名称、预览图上传、说明文案和 Prompt 片段。
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/settings/agent-workflow">前往智能体工作流配置</Link>
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild>
|
||||
<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>
|
||||
|
||||
@ -1926,6 +1926,12 @@ export function AgentWorkflowSettingsClient({
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Link href={backHref}>
|
||||
<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 { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
|
||||
import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetailReferenceStitchDialog";
|
||||
import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog";
|
||||
import { ImageAnnotator } from "@/components/image-annotator";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
@ -942,6 +943,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
useState<AgentGenerationResultModeType>("scene_generation");
|
||||
const [fullscreenPreview, setFullscreenPreview] = 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 [detailReplacementSearchState, setDetailReplacementSearchState] = useState<{
|
||||
isLoading: boolean;
|
||||
@ -4861,7 +4863,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
alt={card.label}
|
||||
className="block h-auto w-full"
|
||||
onError={(event) =>
|
||||
handlePreviewImageLoadError(event, card.imageUrl)
|
||||
handlePreviewImageLoadError(event, card.imageUrl || "")
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
@ -5306,7 +5308,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
alt={card.label}
|
||||
className="h-full w-full object-cover"
|
||||
onError={(event) =>
|
||||
handlePreviewImageLoadError(event, card.imageUrl)
|
||||
handlePreviewImageLoadError(event, card.imageUrl || "")
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
@ -5563,11 +5565,39 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</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>
|
||||
</>
|
||||
) : 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
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
@ -5680,6 +5710,16 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AgentPsdTemplateEditorDialog
|
||||
open={Boolean(psdTemplateDialogSourceUrl)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPsdTemplateDialogSourceUrl(null);
|
||||
}
|
||||
}}
|
||||
sourceImageUrl={psdTemplateDialogSourceUrl}
|
||||
/>
|
||||
|
||||
{/* Fullscreen Preview */}
|
||||
<AnimatePresence>
|
||||
{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",
|
||||
webp: "image/webp",
|
||||
svg: "image/svg+xml",
|
||||
psd: "image/vnd.adobe.photoshop",
|
||||
psb: "application/octet-stream",
|
||||
pdf: "application/pdf",
|
||||
json: "application/json",
|
||||
txt: "text/plain",
|
||||
html: "text/html",
|
||||
css: "text/css",
|
||||
js: "application/javascript",
|
||||
ttf: "font/ttf",
|
||||
otf: "font/otf",
|
||||
woff: "font/woff",
|
||||
woff2: "font/woff2",
|
||||
};
|
||||
return types[ext ?? ""] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
@ -54,6 +54,7 @@
|
||||
"@vercel/blob": "^0.24.1",
|
||||
"@vercel/functions": "^2.0.0",
|
||||
"@vercel/otel": "^1.12.0",
|
||||
"@webtoon/psd": "^0.4.0",
|
||||
"@xenova/transformers": "^2.17.0",
|
||||
"ai": "^5.0.52",
|
||||
"bcrypt-ts": "^5.0.2",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -110,6 +110,9 @@ importers:
|
||||
'@vercel/otel':
|
||||
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))
|
||||
'@webtoon/psd':
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0
|
||||
'@xenova/transformers':
|
||||
specifier: ^2.17.0
|
||||
version: 2.17.2
|
||||
@ -3321,6 +3324,9 @@ packages:
|
||||
'@webgpu/types@0.1.68':
|
||||
resolution: {integrity: sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA==}
|
||||
|
||||
'@webtoon/psd@0.4.0':
|
||||
resolution: {integrity: sha512-ztriE8oFOamRrV9opBURDy+JMiyhur2//vOXsC5CgdnYCB0L1Lnaag4NzP8N+NFCj7uNz9JRYtPmAbQMSDLIsQ==}
|
||||
|
||||
'@xenova/transformers@2.17.2':
|
||||
resolution: {integrity: sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==}
|
||||
|
||||
@ -9389,6 +9395,8 @@ snapshots:
|
||||
|
||||
'@webgpu/types@0.1.68': {}
|
||||
|
||||
'@webtoon/psd@0.4.0': {}
|
||||
|
||||
'@xenova/transformers@2.17.2':
|
||||
dependencies:
|
||||
'@huggingface/jinja': 0.2.2
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user