feat: add PSD text color and position editing

This commit is contained in:
Your Name 2026-04-24 17:28:55 +08:00
parent f9b6cfdc0d
commit f946d9c1f0
4 changed files with 1051 additions and 453 deletions

File diff suppressed because it is too large Load Diff

View File

@ -2,9 +2,7 @@ import "server-only";
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 { type AgentPsdTemplateRenderRequest } from "@/lib/agents/psd-render-task-contract";
import {
type AgentPsdTemplateRenderResult,
getAgentPsdTemplateRuntimeIssue,
@ -19,6 +17,31 @@ import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-re
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { normalizeHexColor } from "@/lib/agents/psd-text-layout";
type AgentPsdTextPositionOffset = {
offsetX: number;
offsetY: number;
};
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> {
@ -48,7 +71,9 @@ export async function executeAgentPsdTemplateRenderRequest(
throw new Error("当前模板未配置可编辑图层");
}
const hiddenLayerIdSet = new Set(
(body.hiddenLayerIds || []).map((layerId) => String(layerId || "").trim()).filter(Boolean)
(body.hiddenLayerIds || [])
.map((layerId) => String(layerId || "").trim())
.filter(Boolean)
);
const fontMap = new Map(fonts.map((font) => [font.id, font]));
@ -60,7 +85,9 @@ export async function executeAgentPsdTemplateRenderRequest(
!fontMap.has(layer.fontId)
);
if (missingFontLayer) {
throw new Error(`文字图层“${missingFontLayer.layerName}”引用的字体已不存在`);
throw new Error(
`文字图层“${missingFontLayer.layerName}”引用的字体已不存在`
);
}
const imageAssignments: Record<string, string> = {};
@ -70,6 +97,7 @@ export async function executeAgentPsdTemplateRenderRequest(
> = {};
const textAssignments: Record<string, string> = {};
const textColorAssignments: Record<string, string> = {};
const textPositionOffsets: Record<string, AgentPsdTextPositionOffset> = {};
for (const layer of editableLayers) {
if (hiddenLayerIdSet.has(layer.id)) {
@ -104,6 +132,9 @@ export async function executeAgentPsdTemplateRenderRequest(
body.textColorAssignments[layer.id] || layer.textColor,
layer.textColor
);
textPositionOffsets[layer.id] = normalizeTextPositionOffset(
body.textPositionOffsets[layer.id]
);
}
}
@ -127,6 +158,7 @@ export async function executeAgentPsdTemplateRenderRequest(
imagePlacements,
textAssignments,
textColorAssignments,
textPositionOffsets,
fonts,
});

View File

@ -2,6 +2,11 @@ import { z } from "zod";
export const AGENT_PSD_RENDER_TASK_POLL_AFTER_MS = 1500;
const textPositionOffsetSchema = z.object({
offsetX: z.number().min(-5000).max(5000).optional(),
offsetY: z.number().min(-5000).max(5000).optional(),
});
export const agentPsdTemplateRenderRequestSchema = z.object({
templateId: z.string().trim().min(1),
sourceImageUrl: z.string().trim().optional().nullable(),
@ -19,6 +24,9 @@ export const agentPsdTemplateRenderRequestSchema = z.object({
.default({}),
textAssignments: z.record(z.string(), z.string()).default({}),
textColorAssignments: z.record(z.string(), z.string()).default({}),
textPositionOffsets: z
.record(z.string(), textPositionOffsetSchema)
.default({}),
});
export type AgentPsdTemplateRenderRequest = z.infer<

View File

@ -11,8 +11,48 @@ import {
} from "@/lib/agents/psd-template-config";
import type { AgentPsdFont } from "@/lib/agents/psd-font-config";
import { ensurePsdFontAvailableForRender } from "@/lib/agents/psd-font-runtime";
import { normalizeAgentPsdImagePlacement, resolveAgentPsdImagePlacementFrame } from "@/lib/agents/psd-image-placement";
import { normalizeHexColor, resolveTextLayout } from "@/lib/agents/psd-text-layout";
import {
normalizeAgentPsdImagePlacement,
resolveAgentPsdImagePlacementFrame,
} from "@/lib/agents/psd-image-placement";
import {
normalizeHexColor,
resolveTextLayout,
} from "@/lib/agents/psd-text-layout";
type AgentPsdTextPositionOffset = {
offsetX: number;
offsetY: number;
};
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)
),
};
}
function hasTextPositionOffset(
value?: Partial<AgentPsdTextPositionOffset> | null
) {
const normalized = normalizeTextPositionOffset(value);
return (
Math.abs(normalized.offsetX) > 0.01 || Math.abs(normalized.offsetY) > 0.01
);
}
async function getSharp() {
const sharp = await import("sharp");
@ -116,7 +156,9 @@ function resolveTextOverlayFrame(params: {
width,
height,
leftOffset,
topOffset: Number.isFinite(Number(params.topOffset)) ? Number(params.topOffset) : 0,
topOffset: Number.isFinite(Number(params.topOffset))
? Number(params.topOffset)
: 0,
};
}
@ -139,7 +181,14 @@ function getFontMimeType(font: AgentPsdFont | undefined, fontFamily: string) {
return "font/ttf";
}
const GENERIC_FONT_FAMILIES = new Set(["sans-serif", "serif", "monospace", "cursive", "fantasy", "system-ui"]);
const GENERIC_FONT_FAMILIES = new Set([
"sans-serif",
"serif",
"monospace",
"cursive",
"fantasy",
"system-ui",
]);
const CJK_FALLBACK_FONT_FAMILIES = [
"Noto Sans CJK SC",
"Noto Sans SC",
@ -223,7 +272,9 @@ async function createTextOverlayBuffer(params: {
maxLines,
} = params;
const normalizedText = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const normalizedText = String(text || "")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
const resolvedLayout = resolveTextLayout({
text: normalizedText,
width,
@ -252,7 +303,10 @@ async function createTextOverlayBuffer(params: {
.filter(Boolean)
)
);
const fontFamilyStack = buildSvgFontFamilyStack(primaryFamilies, normalizedText);
const fontFamilyStack = buildSvgFontFamilyStack(
primaryFamilies,
normalizedText
);
const fontFaceCss = fontBuffer
? primaryFamilies
.map((family) => {
@ -262,7 +316,8 @@ async function createTextOverlayBuffer(params: {
.join("\n")
: "";
const clipId = `clip-${Math.random().toString(36).slice(2, 10)}`;
const anchor = textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start";
const anchor =
textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start";
const x =
textAlign === "center"
? resolvedLayout.contentWidth / 2
@ -302,9 +357,13 @@ export async function renderAgentPsdTemplate(params: {
template: AgentPsdTemplate;
hiddenLayerIds?: string[];
imageAssignments: Record<string, string>;
imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>>;
imagePlacements: Record<
string,
ReturnType<typeof normalizeAgentPsdImagePlacement>
>;
textAssignments: Record<string, string>;
textColorAssignments?: Record<string, string>;
textPositionOffsets?: Record<string, Partial<AgentPsdTextPositionOffset>>;
fonts: AgentPsdFont[];
}) {
const {
@ -314,6 +373,7 @@ export async function renderAgentPsdTemplate(params: {
imagePlacements,
textAssignments,
textColorAssignments = {},
textPositionOffsets = {},
fonts,
} = params;
const sharp = await getSharp();
@ -322,7 +382,9 @@ export async function renderAgentPsdTemplate(params: {
const remoteBufferCache = new Map<string, Buffer>();
const fontMap = new Map(fonts.map((font) => [font.id, font]));
const hiddenLayerIdSet = new Set(
hiddenLayerIds.map((layerId) => String(layerId || "").trim()).filter(Boolean)
hiddenLayerIds
.map((layerId) => String(layerId || "").trim())
.filter(Boolean)
);
async function readCachedBuffer(url: string, label = "PSD 模板资源") {
@ -345,7 +407,8 @@ export async function renderAgentPsdTemplate(params: {
return remoteBufferCache.get(normalizedUrl)!;
}
const compositeInputs: Array<{ input: Buffer; left: number; top: number }> = [];
const compositeInputs: Array<{ input: Buffer; left: number; top: number }> =
[];
async function appendBufferOverlay(params: {
input: Buffer;
@ -392,11 +455,14 @@ export async function renderAgentPsdTemplate(params: {
return;
}
} catch (error) {
console.warn("[PSD Template] failed to reuse stored layer asset during render:", {
templateId: template.id,
layerId: layer.layerId,
error,
});
console.warn(
"[PSD Template] failed to reuse stored layer asset during render:",
{
templateId: template.id,
layerId: layer.layerId,
error,
}
);
throw new Error(
`PSD 模板图层“${layer.layerName}”资源加载失败,请稍后重试或在后台重新解析该模板`
);
@ -420,8 +486,14 @@ export async function renderAgentPsdTemplate(params: {
continue;
}
const sourceBuffer = await readCachedBuffer(assignmentUrl, "PSD 套版素材");
const normalizedSourceBuffer = await sharp(sourceBuffer).rotate().png().toBuffer();
const sourceBuffer = await readCachedBuffer(
assignmentUrl,
"PSD 套版素材"
);
const normalizedSourceBuffer = await sharp(sourceBuffer)
.rotate()
.png()
.toBuffer();
const metadata = await sharp(normalizedSourceBuffer).metadata();
const placementFrame = resolveAgentPsdImagePlacementFrame({
containerWidth: layer.width,
@ -508,11 +580,14 @@ export async function renderAgentPsdTemplate(params: {
.png()
.toBuffer();
} catch (error) {
console.warn("[PSD Template] failed to apply layer mask during render:", {
templateId: template.id,
layerId: layer.layerId,
error,
});
console.warn(
"[PSD Template] failed to apply layer mask during render:",
{
templateId: template.id,
layerId: layer.layerId,
error,
}
);
}
}
@ -531,20 +606,26 @@ export async function renderAgentPsdTemplate(params: {
const hasTextAssignment = Object.hasOwn(textAssignments, layer.id);
const defaultText = String(layer.defaultText || "").trim();
const text = String(
hasTextAssignment ? textAssignments[layer.id] ?? "" : defaultText
hasTextAssignment ? (textAssignments[layer.id] ?? "") : defaultText
).trim();
const textColor = normalizeHexColor(
textColorAssignments[layer.id] || layer.textColor,
layer.textColor
);
const hasColorChange = normalizeHexColor(textColor, layer.textColor) !== normalizeHexColor(layer.textColor);
const hasColorChange =
normalizeHexColor(textColor, layer.textColor) !==
normalizeHexColor(layer.textColor);
const textPositionOffset = normalizeTextPositionOffset(
textPositionOffsets[layer.id]
);
const hasPositionChange = hasTextPositionOffset(textPositionOffset);
if (!text) {
if (!hasTextAssignment) {
await appendStoredLayer(layer);
}
continue;
}
if (text === defaultText && !hasColorChange) {
if (text === defaultText && !hasColorChange && !hasPositionChange) {
await appendStoredLayer(layer);
continue;
}
@ -552,23 +633,29 @@ export async function renderAgentPsdTemplate(params: {
const font = layer.fontId ? fontMap.get(layer.fontId) : undefined;
const fontBuffer = font?.fileUrl
? await readCachedBuffer(font.fileUrl, "PSD 字体资源").catch((error) => {
console.warn("[PSD Template] failed to load render font, falling back to family stack:", {
templateId: template.id,
layerId: layer.layerId,
fontId: font.id,
error,
});
return undefined;
})
? await readCachedBuffer(font.fileUrl, "PSD 字体资源").catch(
(error) => {
console.warn(
"[PSD Template] failed to load render font, falling back to family stack:",
{
templateId: template.id,
layerId: layer.layerId,
fontId: font.id,
error,
}
);
return undefined;
}
)
: undefined;
const runtimeFont = font && fontBuffer
? await ensurePsdFontAvailableForRender({
font,
fontBuffer,
preferredFamily: layer.fontFamily || font.family,
}).catch(() => null)
: null;
const runtimeFont =
font && fontBuffer
? await ensurePsdFontAvailableForRender({
font,
fontBuffer,
preferredFamily: layer.fontFamily || font.family,
}).catch(() => null)
: null;
const overlayBuffer = await createTextOverlayBuffer({
width: layer.width,
height: layer.height,
@ -586,13 +673,19 @@ export async function renderAgentPsdTemplate(params: {
runtimeFont.fontPostscriptName,
runtimeFont.fontFullName,
runtimeFont.fontFamily,
].filter((value): value is string => Boolean(String(value || "").trim()))
].filter((value): value is string =>
Boolean(String(value || "").trim())
)
: [
...(layer.detectedFontNames || []),
...(layer.detectedFontName ? [layer.detectedFontName] : []),
].filter((value): value is string => Boolean(String(value || "").trim())),
].filter((value): value is string =>
Boolean(String(value || "").trim())
),
renderFontFamily: runtimeFont?.fontFamily,
useExactFontFace: Boolean(runtimeFont?.fontPostscriptName || runtimeFont?.fontFullName),
useExactFontFace: Boolean(
runtimeFont?.fontPostscriptName || runtimeFont?.fontFullName
),
fontSize: layer.fontSize,
fontWeight: layer.fontWeight,
lineHeight: layer.lineHeight,
@ -629,8 +722,8 @@ export async function renderAgentPsdTemplate(params: {
const didAppend = await appendBufferOverlay({
input: overlayBuffer,
left: layer.x + overlayFrame.leftOffset,
top: layer.y + overlayFrame.topOffset,
left: layer.x + textPositionOffset.offsetX + overlayFrame.leftOffset,
top: layer.y + textPositionOffset.offsetY + overlayFrame.topOffset,
width: overlayFrame.width,
height: overlayFrame.height,
});