ai-chatbot/lib/agents/psd-render-service.ts
Your Name edc24cb956
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: refine PSD templates and result exports
2026-05-14 20:29:27 +08:00

223 lines
6.7 KiB
TypeScript

import "server-only";
import { upsertSystemSetting } from "@/lib/db/queries";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { normalizeAgentPsdImagePlacement } from "@/lib/agents/psd-image-placement";
import { type AgentPsdTemplateRenderRequest } from "@/lib/agents/psd-render-task-contract";
import {
AGENT_PSD_TEMPLATES_KEY,
type AgentPsdTemplateRenderResult,
getAgentPsdTemplateRuntimeIssue,
getEditableAgentPsdTemplateLayers,
isAgentPsdImageLayerSlot,
isAgentPsdLayerSuperLocked,
isAgentPsdTemplateReady,
isAgentPsdTemplateRuntimeReady,
isAgentPsdTemplateLayerUserEditable,
isAgentPsdTextLayerSlot,
} from "@/lib/agents/psd-template-config";
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
import {
repairAgentPsdTemplateAssetsIfNeeded,
repairAgentPsdTemplateFontBindings,
} from "@/lib/agents/psd-template-repair";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { normalizeHexColor } from "@/lib/agents/psd-text-layout";
type AgentPsdTextPositionOffset = {
offsetX: number;
offsetY: number;
};
type AgentPsdLayerPositionOffset = AgentPsdTextPositionOffset;
function clampTextPositionOffsetValue(value: number) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(-5000, Math.min(5000, value));
}
function normalizeTextPositionOffset(
value?: Partial<AgentPsdTextPositionOffset> | null
): AgentPsdTextPositionOffset {
return {
offsetX: Number(
clampTextPositionOffsetValue(Number(value?.offsetX || 0)).toFixed(2)
),
offsetY: Number(
clampTextPositionOffsetValue(Number(value?.offsetY || 0)).toFixed(2)
),
};
}
export async function executeAgentPsdTemplateRenderRequest(
body: AgentPsdTemplateRenderRequest
): Promise<AgentPsdTemplateRenderResult> {
const templates = await readAgentPsdTemplates();
const template = templates.find(
(entry) =>
entry.id === body.templateId &&
entry.status === "active" &&
isAgentPsdTemplateReady(entry)
);
if (!template) {
throw new Error("PSD 模板不存在或未启用");
}
const fonts = await readAgentPsdFonts();
const repairedTemplate = await repairAgentPsdTemplateAssetsIfNeeded(template);
if (repairedTemplate !== template) {
await upsertSystemSetting({
key: AGENT_PSD_TEMPLATES_KEY,
value: templates.map((entry) =>
entry.id === template.id ? repairedTemplate : entry
),
updatedBy: "system:psd-template-repair",
}).catch((error) => {
console.warn("[PSD Template] failed to persist repaired assets:", {
templateId: template.id,
error,
});
});
}
const resolvedTemplate = repairAgentPsdTemplateFontBindings(
repairedTemplate,
fonts
);
if (!isAgentPsdTemplateRuntimeReady(resolvedTemplate)) {
throw new Error(
getAgentPsdTemplateRuntimeIssue(resolvedTemplate) ||
"PSD 模板资源不完整,请在后台重新解析后再导出"
);
}
const editableLayers = getEditableAgentPsdTemplateLayers(resolvedTemplate);
if (editableLayers.length === 0) {
throw new Error("当前模板未配置可编辑图层");
}
const hiddenLayerIdSet = new Set(
(body.hiddenLayerIds || [])
.map((layerId) => String(layerId || "").trim())
.filter(Boolean)
);
for (const layer of resolvedTemplate.layers) {
if (isAgentPsdLayerSuperLocked(layer)) {
hiddenLayerIdSet.delete(layer.id);
}
}
const normalizedLayerOrderIds = Array.from(
new Set(
(body.layerOrderIds || [])
.map((layerId) => String(layerId || "").trim())
.filter(Boolean)
)
);
const fontMap = new Map(fonts.map((font) => [font.id, font]));
const missingFontLayer = editableLayers.find(
(layer) =>
!hiddenLayerIdSet.has(layer.id) &&
isAgentPsdTextLayerSlot(layer) &&
layer.fontId &&
!fontMap.has(layer.fontId)
);
if (missingFontLayer) {
throw new Error(
`文字图层“${missingFontLayer.layerName}”引用的字体已不存在`
);
}
const imageAssignments: Record<string, string> = {};
const imagePlacements: Record<
string,
ReturnType<typeof normalizeAgentPsdImagePlacement>
> = {};
const textAssignments: Record<string, string> = {};
const textColorAssignments: Record<string, string> = {};
const textPositionOffsets: Record<string, AgentPsdTextPositionOffset> = {};
const layerPositionOffsets: Record<string, AgentPsdLayerPositionOffset> = {};
for (const layer of resolvedTemplate.layers) {
if (hiddenLayerIdSet.has(layer.id)) {
continue;
}
if (!isAgentPsdLayerSuperLocked(layer)) {
layerPositionOffsets[layer.id] = normalizeTextPositionOffset(
body.layerPositionOffsets[layer.id]
);
}
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
continue;
}
if (isAgentPsdImageLayerSlot(layer)) {
const explicitUrl = String(body.imageAssignments[layer.id] || "").trim();
if (explicitUrl) {
imageAssignments[layer.id] = explicitUrl;
imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(
body.imagePlacements[layer.id]
);
continue;
}
if (layer.isDefaultResultSource && body.sourceImageUrl?.trim()) {
imageAssignments[layer.id] = body.sourceImageUrl.trim();
imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(
body.imagePlacements[layer.id]
);
}
}
if (isAgentPsdTextLayerSlot(layer)) {
if (Object.hasOwn(body.textAssignments, layer.id)) {
textAssignments[layer.id] = String(
body.textAssignments[layer.id] ?? ""
).trim();
}
textColorAssignments[layer.id] = normalizeHexColor(
body.textColorAssignments[layer.id] || layer.textColor,
layer.textColor
);
textPositionOffsets[layer.id] = normalizeTextPositionOffset(
body.textPositionOffsets[layer.id]
);
}
}
const missingRequiredLayer = editableLayers.find(
(layer) =>
!hiddenLayerIdSet.has(layer.id) &&
layer.isRequired &&
(isAgentPsdImageLayerSlot(layer)
? !imageAssignments[layer.id]
: !Object.hasOwn(textAssignments, layer.id) &&
!String(layer.defaultText || "").trim())
);
if (missingRequiredLayer) {
throw new Error(`缺少必传素材:${missingRequiredLayer.layerName}`);
}
const uploaded = await renderAgentPsdTemplate({
template: resolvedTemplate,
hiddenLayerIds: Array.from(hiddenLayerIdSet),
layerOrderIds: normalizedLayerOrderIds,
imageAssignments,
imagePlacements,
textAssignments,
textColorAssignments,
layerPositionOffsets,
textPositionOffsets,
fonts,
});
return {
url: uploaded.url,
pathname: uploaded.pathname,
contentType: uploaded.contentType,
};
}