feat: improve psd template layer editing
This commit is contained in:
parent
44d4a8065e
commit
4db458a69a
@ -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) => {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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({}),
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user