feat: add PSD text color and position editing
This commit is contained in:
parent
f9b6cfdc0d
commit
f946d9c1f0
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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<
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user