feat: improve psd template layer editing
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-12 14:44:29 +08:00
parent 44d4a8065e
commit 4db458a69a
5 changed files with 693 additions and 60 deletions

View File

@ -1,14 +1,19 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { upsertSystemSetting } from "@/lib/db/queries";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import {
AGENT_PSD_TEMPLATES_KEY,
type AgentPsdTemplateMutationResponse,
getEditableAgentPsdTemplateLayers,
isAgentPsdTemplateReady,
isAgentPsdTemplateRuntimeReady,
isAgentPsdTextLayerSlot,
} from "@/lib/agents/psd-template-config";
import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair";
import {
repairAgentPsdTemplateAssetsIfNeeded,
repairAgentPsdTemplateFontBindings,
} from "@/lib/agents/psd-template-repair";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
export const runtime = "nodejs";
@ -48,19 +53,37 @@ export async function GET(
return jsonError(404, "未找到 PSD 模板");
}
if (!isAgentPsdTemplateReady(currentTemplate)) {
const repairedTemplate = await repairAgentPsdTemplateAssetsIfNeeded(
currentTemplate
);
if (repairedTemplate !== currentTemplate) {
await upsertSystemSetting({
key: AGENT_PSD_TEMPLATES_KEY,
value: templates.map((entry) =>
entry.id === currentTemplate.id ? repairedTemplate : entry
),
updatedBy: session.user.id,
}).catch((error) => {
console.warn("[PSD Templates] failed to persist repaired detail assets:", {
templateId: currentTemplate.id,
error,
});
});
}
if (!isAgentPsdTemplateReady(repairedTemplate)) {
return jsonError(409, "PSD 模板尚未完成解析");
}
if (!isAgentPsdTemplateRuntimeReady(currentTemplate)) {
if (!isAgentPsdTemplateRuntimeReady(repairedTemplate)) {
return jsonError(409, "PSD 模板资源尚未准备完成,请稍后重试");
}
if (getEditableAgentPsdTemplateLayers(currentTemplate).length === 0) {
if (getEditableAgentPsdTemplateLayers(repairedTemplate).length === 0) {
return jsonError(409, "当前模板没有可编辑图层");
}
const template = repairAgentPsdTemplateFontBindings(currentTemplate, fonts);
const template = repairAgentPsdTemplateFontBindings(repairedTemplate, fonts);
const normalizedTemplate = {
...template,
layers: template.layers.map((layer) => {

View File

@ -268,6 +268,7 @@ type AgentPsdTextPositionOffset = {
offsetX: number;
offsetY: number;
};
type AgentPsdLayerPositionOffset = AgentPsdTextPositionOffset;
function clampTextPositionOffsetValue(value: number) {
if (!Number.isFinite(value)) {
@ -301,6 +302,18 @@ function hasTextPositionOffset(
);
}
function normalizeLayerPositionOffset(
value?: Partial<AgentPsdLayerPositionOffset> | null
) {
return normalizeTextPositionOffset(value);
}
function hasLayerPositionOffset(
value?: Partial<AgentPsdLayerPositionOffset> | null
) {
return hasTextPositionOffset(value);
}
function buildTemplatePreviewThumbUrl(
previewUrl: string,
params?: {
@ -789,6 +802,8 @@ function createInitialState(
) {
const imageAssignments: Record<string, string> = {};
const imagePlacements: Record<string, AgentPsdImagePlacement> = {};
const layerOrderIds: string[] = [];
const layerPositionOffsets: Record<string, AgentPsdLayerPositionOffset> = {};
const textAssignments: Record<string, string> = {};
const textPositionOffsets: Record<string, AgentPsdTextPositionOffset> = {};
@ -796,12 +811,15 @@ function createInitialState(
return {
imageAssignments,
imagePlacements,
layerOrderIds,
layerPositionOffsets,
textAssignments,
textPositionOffsets,
};
}
for (const layer of template.layers) {
layerOrderIds.push(layer.id);
if (
isAgentPsdImageLayerSlot(layer) &&
layer.isDefaultResultSource &&
@ -818,6 +836,8 @@ function createInitialState(
return {
imageAssignments,
imagePlacements,
layerOrderIds,
layerPositionOffsets,
textAssignments,
textPositionOffsets,
};
@ -928,6 +948,7 @@ function createTemplateEditorState(params: {
sourceImageUrl: string;
previousImageAssignments: Record<string, string>;
previousImagePlacements: Record<string, AgentPsdImagePlacement>;
previousLayerPositionOffsets: Record<string, AgentPsdLayerPositionOffset>;
previousTextAssignments: Record<string, string>;
previousTextColorAssignments: Record<string, string>;
previousTextPositionOffsets: Record<string, AgentPsdTextPositionOffset>;
@ -938,6 +959,7 @@ function createTemplateEditorState(params: {
sourceImageUrl,
previousImageAssignments,
previousImagePlacements,
previousLayerPositionOffsets,
previousTextAssignments,
previousTextColorAssignments,
previousTextPositionOffsets,
@ -947,6 +969,8 @@ function createTemplateEditorState(params: {
const nextState = {
imageAssignments: { ...initialState.imageAssignments },
imagePlacements: { ...initialState.imagePlacements },
layerOrderIds: [...initialState.layerOrderIds],
layerPositionOffsets: { ...initialState.layerPositionOffsets },
textAssignments: { ...initialState.textAssignments },
textColorAssignments: {} as Record<string, string>,
textPositionOffsets: { ...initialState.textPositionOffsets },
@ -956,6 +980,27 @@ function createTemplateEditorState(params: {
return nextState;
}
const usedPreviousLayerIds = new Set<string>();
for (const nextLayer of nextTemplate.layers) {
const matchedPreviousLayer = findMatchingLayer({
nextLayer,
previousLayers: previousTemplate.layers,
usedPreviousLayerIds,
});
if (!matchedPreviousLayer) {
continue;
}
const previousLayerPositionOffset =
previousLayerPositionOffsets[matchedPreviousLayer.id];
if (hasLayerPositionOffset(previousLayerPositionOffset)) {
nextState.layerPositionOffsets[nextLayer.id] =
normalizeLayerPositionOffset(previousLayerPositionOffset);
}
usedPreviousLayerIds.add(matchedPreviousLayer.id);
}
const previousEditableImageLayers = previousTemplate.layers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable && isAgentPsdImageLayerSlot(layer)
@ -1168,11 +1213,13 @@ function getImageFit(
function getLayerFrameStyle(
layer: AgentPsdTemplateLayerSlot,
isSelected: boolean
isSelected: boolean,
positionOffset?: Partial<AgentPsdLayerPositionOffset> | null
): CSSProperties {
const normalizedOffset = normalizeLayerPositionOffset(positionOffset);
return {
left: layer.x,
top: layer.y,
left: layer.x + normalizedOffset.offsetX,
top: layer.y + normalizedOffset.offsetY,
width: layer.width,
height: layer.height,
border: `2px solid ${getLayerOutlineColor(layer)}`,
@ -1534,6 +1581,10 @@ export function AgentPsdTemplateEditorDialog({
const [imageSizes, setImageSizes] = useState<
Record<string, AgentPsdImageSize>
>({});
const [layerOrderIds, setLayerOrderIds] = useState<string[]>([]);
const [layerPositionOffsets, setLayerPositionOffsets] = useState<
Record<string, AgentPsdLayerPositionOffset>
>({});
const [textAssignments, setTextAssignments] = useState<
Record<string, string>
>({});
@ -1560,12 +1611,12 @@ export function AgentPsdTemplateEditorDialog({
const hiddenLayerScrollAreaRef = useRef<HTMLDivElement | null>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const previousSelectedTemplateRef = useRef<AgentPsdTemplate | null>(null);
const imageDragStateRef = useRef<{
const layerDragStateRef = useRef<{
layerId: string;
pointerId: number;
startClientX: number;
startClientY: number;
startPlacement: AgentPsdImagePlacement;
startOffset: AgentPsdLayerPositionOffset;
} | null>(null);
const textDragStateRef = useRef<{
layerId: string;
@ -1577,6 +1628,7 @@ export function AgentPsdTemplateEditorDialog({
const imageAssignmentsRef = useRef(imageAssignments);
const imagePlacementsRef = useRef(imagePlacements);
const layerPositionOffsetsRef = useRef(layerPositionOffsets);
const textAssignmentsRef = useRef(textAssignments);
const textColorAssignmentsRef = useRef(textColorAssignments);
const textPositionOffsetsRef = useRef(textPositionOffsets);
@ -1585,6 +1637,7 @@ export function AgentPsdTemplateEditorDialog({
imageAssignmentsRef.current = imageAssignments;
imagePlacementsRef.current = imagePlacements;
layerPositionOffsetsRef.current = layerPositionOffsets;
textAssignmentsRef.current = textAssignments;
textColorAssignmentsRef.current = textColorAssignments;
textPositionOffsetsRef.current = textPositionOffsets;
@ -1775,9 +1828,26 @@ export function AgentPsdTemplateEditorDialog({
),
[selectedTemplateLayers]
);
const orderedTemplateLayers = useMemo(() => {
if (selectedTemplateLayers.length === 0) {
return [] as AgentPsdTemplateLayerSlot[];
}
const layerMap = new Map(
selectedTemplateLayers.map((layer) => [layer.id, layer])
);
const normalizedOrder = [
...layerOrderIds
.map((layerId) => layerMap.get(layerId) || null)
.filter((layer): layer is AgentPsdTemplateLayerSlot => Boolean(layer)),
...selectedTemplateLayers.filter((layer) => !layerOrderIds.includes(layer.id)),
];
return normalizedOrder;
}, [layerOrderIds, selectedTemplateLayers]);
const orderedPreviewLayers = useMemo(
() => [...selectedTemplateLayers].reverse(),
[selectedTemplateLayers]
() => [...orderedTemplateLayers].reverse(),
[orderedTemplateLayers]
);
const hiddenLayerIdSet = useMemo(
() =>
@ -1845,6 +1915,12 @@ export function AgentPsdTemplateEditorDialog({
),
[orderedEditableLayers]
);
const layerStackZIndexById = useMemo(() => {
const total = orderedPreviewLayers.length;
return new Map(
orderedPreviewLayers.map((layer, index) => [layer.id, total - index])
);
}, [orderedPreviewLayers]);
const shouldShowCompositeFallback = useMemo(
() =>
orderedVisiblePreviewLayers.length === 0 ||
@ -1920,6 +1996,20 @@ export function AgentPsdTemplateEditorDialog({
return nextOffsets;
}, [editableTextLayers, selectedTemplate, textPositionOffsets]);
const effectiveLayerPositionOffsets = useMemo(() => {
const nextOffsets: Record<string, AgentPsdLayerPositionOffset> = {};
if (!selectedTemplate) {
return nextOffsets;
}
for (const layer of selectedTemplateLayers) {
nextOffsets[layer.id] = normalizeLayerPositionOffset(
layerPositionOffsets[layer.id]
);
}
return nextOffsets;
}, [layerPositionOffsets, selectedTemplate, selectedTemplateLayers]);
const effectiveImagePlacements = useMemo(() => {
const nextPlacements: Record<string, AgentPsdImagePlacement> = {};
@ -1977,6 +2067,10 @@ export function AgentPsdTemplateEditorDialog({
? effectiveTextPositionOffsets[selectedTextLayer.id] ||
normalizeTextPositionOffset()
: normalizeTextPositionOffset();
const selectedLayerPositionOffset = selectedLayer
? effectiveLayerPositionOffsets[selectedLayer.id] ||
normalizeLayerPositionOffset()
: normalizeLayerPositionOffset();
const isSelectedImageExtending = Boolean(
selectedImageLayer && extendingSlotId === selectedImageLayer.id
);
@ -1999,9 +2093,138 @@ export function AgentPsdTemplateEditorDialog({
return;
}
setHiddenLayerIds((current) =>
current.filter((currentLayerId) => currentLayerId !== normalizedLayerId)
current.filter((currentLayerId) => currentLayerId !== normalizedLayerId)
);
}
const selectedLayerPositionPanel = selectedLayer ? (
<div className="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5 shadow-inner">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-semibold text-sm text-white">图层位置</div>
<div className="mt-1.5 text-white/50 text-xs">
现在所有图层都支持在中间预览区直接拖动,这里也能做数值微调。
</div>
</div>
<Move className="h-5 w-5 shrink-0 text-emerald-300" />
</div>
<div className="grid grid-cols-2 gap-2">
<Button
className="h-10 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.04] text-white/75 hover:bg-white/[0.08] hover:text-white"
onClick={() => moveLayerOrder(selectedLayer.id, "forward")}
type="button"
variant="outline"
>
<ChevronUp className="h-4 w-4" />
上移一层
</Button>
<Button
className="h-10 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.04] text-white/75 hover:bg-white/[0.08] hover:text-white"
onClick={() => moveLayerOrder(selectedLayer.id, "backward")}
type="button"
variant="outline"
>
<ChevronDown className="h-4 w-4" />
下移一层
</Button>
<Button
className="h-10 justify-center rounded-xl border-white/10 bg-white/[0.04] text-white/75 hover:bg-white/[0.08] hover:text-white"
onClick={() => moveLayerOrder(selectedLayer.id, "front")}
type="button"
variant="outline"
>
置于最上
</Button>
<Button
className="h-10 justify-center rounded-xl border-white/10 bg-white/[0.04] text-white/75 hover:bg-white/[0.08] hover:text-white"
onClick={() => moveLayerOrder(selectedLayer.id, "back")}
type="button"
variant="outline"
>
置于最下
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-white/50 text-xs">水平 X</Label>
<Input
className="h-10 rounded-xl border-white/10 bg-black/20 text-white"
inputMode="numeric"
onChange={(event) => {
const nextValue = Number(event.target.value);
if (!Number.isFinite(nextValue)) return;
updateLayerPositionOffset(selectedLayer.id, () => ({
offsetX: nextValue,
}));
}}
type="number"
value={Math.round(selectedLayerPositionOffset.offsetX)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-white/50 text-xs">垂直 Y</Label>
<Input
className="h-10 rounded-xl border-white/10 bg-black/20 text-white"
inputMode="numeric"
onChange={(event) => {
const nextValue = Number(event.target.value);
if (!Number.isFinite(nextValue)) return;
updateLayerPositionOffset(selectedLayer.id, () => ({
offsetY: nextValue,
}));
}}
type="number"
value={Math.round(selectedLayerPositionOffset.offsetY)}
/>
</div>
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<div />
<Button
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() => nudgeLayerPositionOffset(selectedLayer.id, 0, -1)}
type="button"
variant="outline"
>
↑
</Button>
<div />
<Button
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() => nudgeLayerPositionOffset(selectedLayer.id, -1, 0)}
type="button"
variant="outline"
>
←
</Button>
<Button
className="h-9 rounded-xl border-white/10 bg-white/[0.04] px-3 text-white/60 hover:bg-white/[0.08] hover:text-white"
onClick={() => resetLayerPositionOffset(selectedLayer.id)}
type="button"
variant="outline"
>
归零
</Button>
<Button
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() => nudgeLayerPositionOffset(selectedLayer.id, 1, 0)}
type="button"
variant="outline"
>
→
</Button>
<div />
<Button
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() => nudgeLayerPositionOffset(selectedLayer.id, 0, 1)}
type="button"
variant="outline"
>
↓
</Button>
<div />
</div>
</div>
) : null;
const selectedImageAdjustmentPanel = selectedImageLayer ? (
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.03] p-3.5">
<div className="grid grid-cols-2 gap-2">
@ -2171,6 +2394,116 @@ export function AgentPsdTemplateEditorDialog({
</div>
</div>
<div className="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-4 shadow-inner">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-semibold text-sm text-white">
槽位内图片位置
</div>
<div className="mt-1.5 text-white/50 text-xs">
图层整体拖动已经改到中间预览区;这里保留槽位内图片的细调。
</div>
</div>
<ImageIcon className="h-5 w-5 shrink-0 text-orange-300" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-white/50 text-xs">水平 X</Label>
<Input
className="h-10 rounded-xl border-white/10 bg-black/20 text-white"
inputMode="numeric"
onChange={(event) => {
const nextValue = Number(event.target.value);
if (!Number.isFinite(nextValue)) return;
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
offsetX: nextValue,
}));
}}
type="number"
value={Math.round(selectedImagePlacement.offsetX)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-white/50 text-xs">垂直 Y</Label>
<Input
className="h-10 rounded-xl border-white/10 bg-black/20 text-white"
inputMode="numeric"
onChange={(event) => {
const nextValue = Number(event.target.value);
if (!Number.isFinite(nextValue)) return;
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
offsetY: nextValue,
}));
}}
type="number"
value={Math.round(selectedImagePlacement.offsetY)}
/>
</div>
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<div />
<Button
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
nudgeImagePlacementOffset(selectedImageLayer.id, 0, -1)
}
type="button"
variant="outline"
>
↑
</Button>
<div />
<Button
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
nudgeImagePlacementOffset(selectedImageLayer.id, -1, 0)
}
type="button"
variant="outline"
>
←
</Button>
<Button
className="h-9 rounded-xl border-white/10 bg-white/[0.04] px-3 text-white/60 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
offsetX: 0,
offsetY: 0,
}))
}
type="button"
variant="outline"
>
归零
</Button>
<Button
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
nudgeImagePlacementOffset(selectedImageLayer.id, 1, 0)
}
type="button"
variant="outline"
>
→
</Button>
<div />
<Button
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
nudgeImagePlacementOffset(selectedImageLayer.id, 0, 1)
}
type="button"
variant="outline"
>
↓
</Button>
<div />
</div>
</div>
<div className="flex justify-end">
<Button
className="rounded-lg px-3 text-white/60 transition-all hover:bg-white/5 hover:text-white/90"
@ -2342,12 +2675,15 @@ export function AgentPsdTemplateEditorDialog({
sourceImageUrl: sourceImageUrl || "",
previousImageAssignments: imageAssignmentsRef.current,
previousImagePlacements: imagePlacementsRef.current,
previousLayerPositionOffsets: layerPositionOffsetsRef.current,
previousTextAssignments: textAssignmentsRef.current,
previousTextColorAssignments: textColorAssignmentsRef.current,
previousTextPositionOffsets: textPositionOffsetsRef.current,
});
setImageAssignments(initialState.imageAssignments);
setImagePlacements(initialState.imagePlacements);
setLayerOrderIds(initialState.layerOrderIds);
setLayerPositionOffsets(initialState.layerPositionOffsets);
setTextAssignments(initialState.textAssignments);
setTextColorAssignments(initialState.textColorAssignments);
setTextPositionOffsets(initialState.textPositionOffsets);
@ -2692,6 +3028,18 @@ export function AgentPsdTemplateEditorDialog({
}));
}
function nudgeImagePlacementOffset(
layerId: string,
deltaX: number,
deltaY: number
) {
updateImagePlacement(layerId, (current) => ({
...current,
offsetX: Number(((current.offsetX || 0) + deltaX).toFixed(2)),
offsetY: Number(((current.offsetY || 0) + deltaY).toFixed(2)),
}));
}
function resetImagePlacement(layerId: string) {
setImagePlacements((current) => ({
...current,
@ -2699,6 +3047,77 @@ export function AgentPsdTemplateEditorDialog({
}));
}
function updateLayerPositionOffset(
layerId: string,
updater: (
current: AgentPsdLayerPositionOffset
) => Partial<AgentPsdLayerPositionOffset>
) {
setLayerPositionOffsets((current) => ({
...current,
[layerId]: normalizeLayerPositionOffset({
...normalizeLayerPositionOffset(current[layerId]),
...updater(normalizeLayerPositionOffset(current[layerId])),
}),
}));
}
function nudgeLayerPositionOffset(
layerId: string,
deltaX: number,
deltaY: number
) {
updateLayerPositionOffset(layerId, (current) => ({
offsetX: current.offsetX + deltaX,
offsetY: current.offsetY + deltaY,
}));
}
function resetLayerPositionOffset(layerId: string) {
setLayerPositionOffsets((current) => {
const next = { ...current };
delete next[layerId];
return next;
});
}
function moveLayerOrder(
layerId: string,
direction: "forward" | "backward" | "front" | "back"
) {
const normalizedLayerId = String(layerId || "").trim();
if (!normalizedLayerId) {
return;
}
setLayerOrderIds((current) => {
const fallbackOrder = selectedTemplateLayers.map((layer) => layer.id);
const baseOrder = current.length > 0 ? [...current] : fallbackOrder;
const currentIndex = baseOrder.indexOf(normalizedLayerId);
if (currentIndex < 0) {
return current;
}
const nextOrder = [...baseOrder];
const [removed] = nextOrder.splice(currentIndex, 1);
if (!removed) {
return current;
}
if (direction === "front") {
nextOrder.push(removed);
} else if (direction === "back") {
nextOrder.unshift(removed);
} else if (direction === "forward") {
nextOrder.splice(Math.min(baseOrder.length - 1, currentIndex + 1), 0, removed);
} else {
nextOrder.splice(Math.max(0, currentIndex - 1), 0, removed);
}
return nextOrder;
});
}
function updateTextColorAssignment(layerId: string, color: string) {
setTextColorAssignments((current) => ({
...current,
@ -2861,21 +3280,21 @@ export function AgentPsdTemplateEditorDialog({
};
}
function handleImageDragStart(
layer: AgentPsdTemplateImageLayerSlot,
event: ReactPointerEvent<HTMLDivElement>
function handleLayerDragStart(
layer: AgentPsdTemplateLayerSlot,
event: ReactPointerEvent<HTMLElement>
) {
if (!String(effectiveImageAssignments[layer.id] || "").trim()) {
return;
}
event.preventDefault();
event.stopPropagation();
setSelectedLayerId(layer.id);
imageDragStateRef.current = {
layerDragStateRef.current = {
layerId: layer.id,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startPlacement: normalizeAgentPsdImagePlacement(
effectiveImagePlacements[layer.id]
startOffset: normalizeLayerPositionOffset(
effectiveLayerPositionOffsets[layer.id]
),
};
@ -2890,25 +3309,24 @@ export function AgentPsdTemplateEditorDialog({
}
const handlePointerMove = (moveEvent: PointerEvent) => {
const dragState = imageDragStateRef.current;
const dragState = layerDragStateRef.current;
if (!dragState || dragState.layerId !== layer.id) {
return;
}
updateImagePlacement(layer.id, () => ({
...dragState.startPlacement,
updateLayerPositionOffset(layer.id, () => ({
offsetX:
dragState.startPlacement.offsetX +
dragState.startOffset.offsetX +
(moveEvent.clientX - dragState.startClientX) / stageScale,
offsetY:
dragState.startPlacement.offsetY +
dragState.startOffset.offsetY +
(moveEvent.clientY - dragState.startClientY) / stageScale,
}));
};
const handlePointerUp = (upEvent: PointerEvent) => {
if (imageDragStateRef.current?.pointerId === upEvent.pointerId) {
imageDragStateRef.current = null;
if (layerDragStateRef.current?.pointerId === upEvent.pointerId) {
layerDragStateRef.current = null;
}
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
@ -3015,10 +3433,12 @@ export function AgentPsdTemplateEditorDialog({
templateId: selectedTemplate.id,
sourceImageUrl: normalizedRenderInputs.sourceImageUrl,
hiddenLayerIds,
layerOrderIds,
imageAssignments: normalizedRenderInputs.imageAssignments,
imagePlacements: effectiveImagePlacements,
textAssignments: effectiveTextAssignments,
textColorAssignments: effectiveTextColorAssignments,
layerPositionOffsets: effectiveLayerPositionOffsets,
textPositionOffsets: effectiveTextPositionOffsets,
},
})) as AgentPsdTemplateRenderResponse;
@ -3575,9 +3995,12 @@ export function AgentPsdTemplateEditorDialog({
<div className="pointer-events-none absolute inset-0">
{orderedVisiblePreviewLayers.map((layer) => {
const layerPositionOffset =
effectiveLayerPositionOffsets[layer.id] ||
normalizeLayerPositionOffset();
const layerBoxStyle = {
left: layer.x,
top: layer.y,
left: layer.x + layerPositionOffset.offsetX,
top: layer.y + layerPositionOffset.offsetY,
width: layer.width,
height: layer.height,
} satisfies CSSProperties;
@ -3691,10 +4114,12 @@ export function AgentPsdTemplateEditorDialog({
style={{
left:
layer.x +
layerPositionOffset.offsetX +
textPositionOffset.offsetX +
textFrame.offsetX,
top:
layer.y +
layerPositionOffset.offsetY +
textPositionOffset.offsetY +
textFrame.offsetY,
width: textFrame.width,
@ -3712,14 +4137,65 @@ export function AgentPsdTemplateEditorDialog({
</div>
<div className="absolute inset-0">
{orderedVisiblePreviewLayers
.filter((layer) => !layer.isEditable)
.map((layer) => {
const isSelected = selectedLayerId === layer.id;
const baseZIndex =
layerStackZIndexById.get(layer.id) || 1;
const frameStyle = getLayerFrameStyle(
layer,
isSelected,
effectiveLayerPositionOffsets[layer.id]
);
return (
<button
aria-label={`普通图层 ${layer.layerName}`}
className="absolute overflow-hidden rounded-lg transition-all"
key={`static-layer-frame-${layer.id}`}
onClick={() => setSelectedLayerId(layer.id)}
onPointerDown={(event) =>
handleLayerDragStart(layer, event)
}
style={{
...frameStyle,
zIndex: baseZIndex + (isSelected ? 100 : 0),
background: isSelected
? "rgba(255,255,255,0.05)"
: "transparent",
cursor:
layerDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: "none",
}}
title={`普通图层:${layer.layerName}`}
type="button"
>
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
{layer.layerName}
</div>
<div className="pointer-events-none absolute right-2 bottom-2 flex items-center gap-1 rounded-full bg-white/85 px-2 py-1 text-[11px] text-black shadow-md backdrop-blur-md">
<Move className="h-3 w-3" />
拖动图层
</div>
</button>
);
})}
{orderedEditableImageLayers.map((layer) => {
if (hiddenLayerIdSet.has(layer.id)) {
return null;
}
const isSelected = selectedLayerId === layer.id;
const baseZIndex =
layerStackZIndexById.get(layer.id) || 1;
const frameStyle = getLayerFrameStyle(
layer,
isSelected
isSelected,
effectiveLayerPositionOffsets[layer.id]
);
const hasAssignedImage = Boolean(
String(
@ -3734,15 +4210,15 @@ export function AgentPsdTemplateEditorDialog({
key={layer.id}
onClick={() => setSelectedLayerId(layer.id)}
onPointerDown={(event) =>
handleImageDragStart(layer, event)
handleLayerDragStart(layer, event)
}
role="button"
style={{
...frameStyle,
zIndex: 20,
zIndex: baseZIndex + 120,
background: "rgba(249,115,22,0.08)",
cursor:
imageDragStateRef.current?.layerId ===
layerDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
@ -3756,7 +4232,7 @@ export function AgentPsdTemplateEditorDialog({
</div>
<div className="pointer-events-none absolute right-2 bottom-2 flex items-center gap-1 rounded-full bg-orange-500/90 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
<Move className="h-3 w-3" />
拖动调整
拖动图层
</div>
</div>
) : (
@ -3765,12 +4241,21 @@ export function AgentPsdTemplateEditorDialog({
className="absolute overflow-hidden rounded-lg transition-all"
key={layer.id}
onClick={() => setSelectedLayerId(layer.id)}
onPointerDown={(event) =>
handleLayerDragStart(layer, event)
}
style={{
...frameStyle,
zIndex: isSelected ? 20 : 10,
zIndex: baseZIndex + (isSelected ? 120 : 0),
background: isSelected
? "rgba(249,115,22,0.08)"
: "transparent",
cursor:
layerDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: "none",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
type="button"
@ -3780,8 +4265,9 @@ export function AgentPsdTemplateEditorDialog({
</div>
{hasAssignedImage ? null : (
<div className="pointer-events-none absolute right-2 bottom-2 rounded-full bg-orange-500/90 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
点击替换图片
<div className="pointer-events-none absolute right-2 bottom-2 flex items-center gap-1 rounded-full bg-orange-500/90 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
<Move className="h-3 w-3" />
拖动图层 / 点击替换
</div>
)}
</button>
@ -3798,6 +4284,8 @@ export function AgentPsdTemplateEditorDialog({
effectiveImageAssignments[layer.id] || ""
).trim()
);
const baseZIndex =
layerStackZIndexById.get(layer.id) || 1;
if (!isSelected || !hasAssignedImage) {
return null;
}
@ -3807,9 +4295,24 @@ export function AgentPsdTemplateEditorDialog({
className="absolute flex items-center gap-1 rounded-full border border-white/10 bg-black/80 p-1 text-white shadow-xl backdrop-blur-md"
key={`${layer.id}-controls`}
style={{
left: layer.x + layer.width - 112,
top: Math.max(8, layer.y - 44),
zIndex: 25,
left:
layer.x +
(
effectiveLayerPositionOffsets[layer.id]
?.offsetX || 0
) +
layer.width -
112,
top: Math.max(
8,
layer.y +
(
effectiveLayerPositionOffsets[layer.id]
?.offsetY || 0
) -
44
),
zIndex: baseZIndex + 140,
}}
>
<button
@ -3851,13 +4354,28 @@ export function AgentPsdTemplateEditorDialog({
return null;
}
const isSelected = selectedLayerId === layer.id;
const baseZIndex =
layerStackZIndexById.get(layer.id) || 1;
const textPositionOffset =
effectiveTextPositionOffsets[layer.id] ||
normalizeTextPositionOffset();
const layerPositionOffset =
effectiveLayerPositionOffsets[layer.id] ||
normalizeLayerPositionOffset();
const frameStyle = {
...getLayerFrameStyle(layer, isSelected),
left: layer.x + textPositionOffset.offsetX,
top: layer.y + textPositionOffset.offsetY,
...getLayerFrameStyle(
layer,
isSelected,
layerPositionOffset
),
left:
layer.x +
layerPositionOffset.offsetX +
textPositionOffset.offsetX,
top:
layer.y +
layerPositionOffset.offsetY +
textPositionOffset.offsetY,
} satisfies CSSProperties;
const currentValue =
effectiveTextAssignments[layer.id] ?? "";
@ -3888,7 +4406,7 @@ export function AgentPsdTemplateEditorDialog({
}
style={{
...frameStyle,
zIndex: 40,
zIndex: baseZIndex + 160,
background: "transparent",
cursor:
textDragStateRef.current?.layerId ===
@ -3948,7 +4466,7 @@ export function AgentPsdTemplateEditorDialog({
}
style={{
...frameStyle,
zIndex: isSelected ? 40 : 30,
zIndex: baseZIndex + (isSelected ? 160 : 0),
background: isSelected
? "rgba(56,189,248,0.05)"
: "transparent",
@ -4118,12 +4636,14 @@ export function AgentPsdTemplateEditorDialog({
</div>
) : selectedImageLayer ? (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto pr-1">
{selectedLayerPositionPanel}
{selectedImageAdjustmentPanel}
{textLayerQuickColorPanel}
</div>
) : selectedTextLayer ? (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02] p-4 shadow-inner xl:p-5">
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pr-1">
{selectedLayerPositionPanel}
<Textarea
className="min-h-[180px] flex-1 resize-none rounded-xl border border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-white/90 shadow-inner placeholder:text-white/30 focus-visible:border-emerald-500/50 focus-visible:bg-white/[0.04] focus-visible:ring-1 focus-visible:ring-emerald-500/50 xl:min-h-[200px]"
onChange={(event) =>
@ -4402,6 +4922,18 @@ export function AgentPsdTemplateEditorDialog({
</div>
</div>
</div>
) : selectedLayer ? (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto pr-1">
{selectedLayerPositionPanel}
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5 text-sm text-white/60 shadow-inner">
<div className="font-medium text-white/85">
当前是普通图层
</div>
<div className="mt-2 text-xs leading-relaxed text-white/45">
这个图层当前不支持替换图片或编辑文字,但现在已经支持在预览区直接拖动位置,也可以在上方做数值微调。
</div>
</div>
</div>
) : selectedTemplate &&
selectedTemplateLayers.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/40 shadow-inner">

View File

@ -1,9 +1,11 @@
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,
@ -13,7 +15,10 @@ import {
isAgentPsdTextLayerSlot,
} from "@/lib/agents/psd-template-config";
import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair";
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";
@ -21,6 +26,7 @@ type AgentPsdTextPositionOffset = {
offsetX: number;
offsetY: number;
};
type AgentPsdLayerPositionOffset = AgentPsdTextPositionOffset;
function clampTextPositionOffsetValue(value: number) {
if (!Number.isFinite(value)) {
@ -58,7 +64,26 @@ export async function executeAgentPsdTemplateRenderRequest(
}
const fonts = await readAgentPsdFonts();
const resolvedTemplate = repairAgentPsdTemplateFontBindings(template, fonts);
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) ||
@ -75,6 +100,13 @@ export async function executeAgentPsdTemplateRenderRequest(
.map((layerId) => String(layerId || "").trim())
.filter(Boolean)
);
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(
@ -98,12 +130,21 @@ export async function executeAgentPsdTemplateRenderRequest(
const textAssignments: Record<string, string> = {};
const textColorAssignments: Record<string, string> = {};
const textPositionOffsets: Record<string, AgentPsdTextPositionOffset> = {};
const layerPositionOffsets: Record<string, AgentPsdLayerPositionOffset> = {};
for (const layer of editableLayers) {
for (const layer of resolvedTemplate.layers) {
if (hiddenLayerIdSet.has(layer.id)) {
continue;
}
layerPositionOffsets[layer.id] = normalizeTextPositionOffset(
body.layerPositionOffsets[layer.id]
);
if (!layer.isEditable) {
continue;
}
if (isAgentPsdImageLayerSlot(layer)) {
const explicitUrl = String(body.imageAssignments[layer.id] || "").trim();
if (explicitUrl) {
@ -154,10 +195,12 @@ export async function executeAgentPsdTemplateRenderRequest(
const uploaded = await renderAgentPsdTemplate({
template: resolvedTemplate,
hiddenLayerIds: Array.from(hiddenLayerIdSet),
layerOrderIds: normalizedLayerOrderIds,
imageAssignments,
imagePlacements,
textAssignments,
textColorAssignments,
layerPositionOffsets,
textPositionOffsets,
fonts,
});

View File

@ -11,6 +11,7 @@ export const agentPsdTemplateRenderRequestSchema = z.object({
templateId: z.string().trim().min(1),
sourceImageUrl: z.string().trim().optional().nullable(),
hiddenLayerIds: z.array(z.string().trim().min(1)).default([]),
layerOrderIds: z.array(z.string().trim().min(1)).default([]),
imageAssignments: z.record(z.string(), z.string()).default({}),
imagePlacements: z
.record(
@ -24,6 +25,9 @@ export const agentPsdTemplateRenderRequestSchema = z.object({
.default({}),
textAssignments: z.record(z.string(), z.string()).default({}),
textColorAssignments: z.record(z.string(), z.string()).default({}),
layerPositionOffsets: z
.record(z.string(), textPositionOffsetSchema)
.default({}),
textPositionOffsets: z
.record(z.string(), textPositionOffsetSchema)
.default({}),

View File

@ -356,6 +356,7 @@ async function createTextOverlayBuffer(params: {
export async function renderAgentPsdTemplate(params: {
template: AgentPsdTemplate;
hiddenLayerIds?: string[];
layerOrderIds?: string[];
imageAssignments: Record<string, string>;
imagePlacements: Record<
string,
@ -363,16 +364,19 @@ export async function renderAgentPsdTemplate(params: {
>;
textAssignments: Record<string, string>;
textColorAssignments?: Record<string, string>;
layerPositionOffsets?: Record<string, Partial<AgentPsdTextPositionOffset>>;
textPositionOffsets?: Record<string, Partial<AgentPsdTextPositionOffset>>;
fonts: AgentPsdFont[];
}) {
const {
template,
hiddenLayerIds = [],
layerOrderIds = [],
imageAssignments,
imagePlacements,
textAssignments,
textColorAssignments = {},
layerPositionOffsets = {},
textPositionOffsets = {},
fonts,
} = params;
@ -386,14 +390,29 @@ export async function renderAgentPsdTemplate(params: {
.map((layerId) => String(layerId || "").trim())
.filter(Boolean)
);
const layerMap = new Map(template.layers.map((layer) => [layer.id, layer]));
const orderedTemplateLayers = [
...layerOrderIds
.map((layerId) => layerMap.get(String(layerId || "").trim()) || null)
.filter((layer): layer is AgentPsdTemplate["layers"][number] =>
Boolean(layer)
),
...template.layers.filter(
(layer) => !layerOrderIds.includes(layer.id)
),
];
const renderBaseUrl = String(template.renderBaseUrl || "").trim();
const canUseDedicatedRenderBase =
Boolean(renderBaseUrl) &&
renderBaseUrl !== String(template.previewUrl || "").trim() &&
!template.layers.some(
!orderedTemplateLayers.some(
(layer) => !layer.isEditable && hiddenLayerIdSet.has(layer.id)
);
function getLayerPositionOffset(layerId: string) {
return normalizeTextPositionOffset(layerPositionOffsets[layerId]);
}
async function readCachedBuffer(url: string, label = "PSD 模板资源") {
const normalizedUrl = String(url || "").trim();
if (!normalizedUrl) {
@ -442,19 +461,22 @@ export async function renderAgentPsdTemplate(params: {
}
async function appendStoredLayer(layer: AgentPsdTemplate["layers"][number]) {
if (!hasAgentPsdTemplateStoredLayerSource(layer)) {
const sourceUrl =
String(layer.sourceUrl || "").trim() ||
String(layer.previewUrl || "").trim();
if (!hasAgentPsdTemplateStoredLayerSource(layer) && !sourceUrl) {
throw new Error(
`PSD 模板图层“${layer.layerName}”缺少已打包资源,请在后台重新解析该模板`
);
}
const sourceUrl = String(layer.sourceUrl || "").trim();
const layerPositionOffset = getLayerPositionOffset(layer.id);
try {
const overlayBuffer = await readCachedBuffer(sourceUrl, "PSD 图层素材");
const didAppend = await appendBufferOverlay({
input: overlayBuffer,
left: layer.x,
top: layer.y,
left: layer.x + layerPositionOffset.offsetX,
top: layer.y + layerPositionOffset.offsetY,
width: layer.width,
height: layer.height,
});
@ -487,7 +509,7 @@ export async function renderAgentPsdTemplate(params: {
});
}
for (const layer of [...template.layers].reverse()) {
for (const layer of [...orderedTemplateLayers].reverse()) {
if (hiddenLayerIdSet.has(layer.id)) {
continue;
}
@ -615,8 +637,8 @@ export async function renderAgentPsdTemplate(params: {
const didAppend = await appendBufferOverlay({
input: overlayBuffer,
left: layer.x,
top: layer.y,
left: layer.x + getLayerPositionOffset(layer.id).offsetX,
top: layer.y + getLayerPositionOffset(layer.id).offsetY,
width: layer.width,
height: layer.height,
});
@ -637,6 +659,7 @@ export async function renderAgentPsdTemplate(params: {
const hasColorChange =
normalizeHexColor(textColor, layer.textColor) !==
normalizeHexColor(layer.textColor);
const layerPositionOffset = getLayerPositionOffset(layer.id);
const textPositionOffset = normalizeTextPositionOffset(
textPositionOffsets[layer.id]
);
@ -744,8 +767,16 @@ export async function renderAgentPsdTemplate(params: {
const didAppend = await appendBufferOverlay({
input: overlayBuffer,
left: layer.x + textPositionOffset.offsetX + overlayFrame.leftOffset,
top: layer.y + textPositionOffset.offsetY + overlayFrame.topOffset,
left:
layer.x +
layerPositionOffset.offsetX +
textPositionOffset.offsetX +
overlayFrame.leftOffset,
top:
layer.y +
layerPositionOffset.offsetY +
textPositionOffset.offsetY +
overlayFrame.topOffset,
width: overlayFrame.width,
height: overlayFrame.height,
});