diff --git a/app/api/psd-templates/render/route.ts b/app/api/psd-templates/render/route.ts index 4409c5a5b..d78c150d4 100644 --- a/app/api/psd-templates/render/route.ts +++ b/app/api/psd-templates/render/route.ts @@ -9,6 +9,7 @@ import { type AgentPsdTemplateRenderResponse, } from "@/lib/agents/psd-template-config"; import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; +import { normalizeAgentPsdImagePlacement } from "@/lib/agents/psd-image-placement"; import { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer"; import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; @@ -19,6 +20,16 @@ const renderSchema = z.object({ templateId: z.string().trim().min(1), sourceImageUrl: z.string().trim().optional().nullable(), imageAssignments: z.record(z.string(), z.string()).default({}), + imagePlacements: z + .record( + z.string(), + z.object({ + scale: z.number().positive().max(8).optional(), + offsetX: z.number().optional(), + offsetY: z.number().optional(), + }) + ) + .default({}), textAssignments: z.record(z.string(), z.string()).default({}), }); @@ -74,17 +85,20 @@ export async function POST(request: NextRequest) { } const imageAssignments: Record = {}; + const imagePlacements: Record> = {}; const textAssignments: Record = {}; for (const layer of editableLayers) { if (isAgentPsdImageLayerSlot(layer)) { const explicitUrl = String(body.imageAssignments[layer.id] || "").trim(); if (explicitUrl) { imageAssignments[layer.id] = explicitUrl; + imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(body.imagePlacements[layer.id]); continue; } if (layer.isDefaultResultSource && body.sourceImageUrl?.trim()) { imageAssignments[layer.id] = body.sourceImageUrl.trim(); + imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(body.imagePlacements[layer.id]); } } @@ -110,6 +124,7 @@ export async function POST(request: NextRequest) { const uploaded = await renderAgentPsdTemplate({ template: resolvedTemplate, imageAssignments, + imagePlacements, textAssignments, fonts, }); diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index 7409a88a6..2c37113d0 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -1,16 +1,18 @@ "use client"; -import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type PointerEvent as ReactPointerEvent } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { Download, Image as ImageIcon, Loader2, + Move, MousePointerClick, Sparkles, Type, Upload, + ZoomIn, } from "lucide-react"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { @@ -26,7 +28,15 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; import { Textarea } from "@/components/ui/textarea"; +import { + getDefaultAgentPsdImagePlacement, + normalizeAgentPsdImagePlacement, + resolveAgentPsdImagePlacementFrame, + type AgentPsdImagePlacement, + type AgentPsdImageSize, +} from "@/lib/agents/psd-image-placement"; import { isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, @@ -45,22 +55,24 @@ const PREVIEW_STAGE_MAX_SCALE = 0.82; function createInitialState(template: AgentPsdTemplate | null, sourceImageUrl: string) { const imageAssignments: Record = {}; + const imagePlacements: Record = {}; const textAssignments: Record = {}; if (!template) { - return { imageAssignments, textAssignments }; + return { imageAssignments, imagePlacements, textAssignments }; } for (const layer of template.layers) { if (isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource && sourceImageUrl) { imageAssignments[layer.id] = sourceImageUrl; + imagePlacements[layer.id] = getDefaultAgentPsdImagePlacement(); } if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) { textAssignments[layer.id] = layer.defaultText; } } - return { imageAssignments, textAssignments }; + return { imageAssignments, imagePlacements, textAssignments }; } function getLayerLabel(layer: AgentPsdTemplateLayerSlot) { @@ -172,12 +184,9 @@ function getTextLayerContentStyle( }; } -function getAssignedImageStyle(layer: AgentPsdTemplateImageLayerSlot): CSSProperties { +function getAssignedImageMaskStyle(layer: AgentPsdTemplateImageLayerSlot): CSSProperties { const hasMask = Boolean(layer.maskUrl); return { - width: "100%", - height: "100%", - objectFit: getImageFit(layer.replaceMode), WebkitMaskImage: hasMask ? `url(${layer.maskUrl})` : undefined, maskImage: hasMask ? `url(${layer.maskUrl})` : undefined, WebkitMaskSize: hasMask ? "100% 100%" : undefined, @@ -189,6 +198,31 @@ function getAssignedImageStyle(layer: AgentPsdTemplateImageLayerSlot): CSSProper }; } +function getPlacedAssignedImageStyle(params: { + layer: AgentPsdTemplateImageLayerSlot; + imageSize: AgentPsdImageSize; + placement: AgentPsdImagePlacement; +}): CSSProperties { + const { layer, imageSize, placement } = params; + const frame = resolveAgentPsdImagePlacementFrame({ + containerWidth: layer.width, + containerHeight: layer.height, + imageWidth: imageSize.width, + imageHeight: imageSize.height, + replaceMode: layer.replaceMode, + placement, + }); + + return { + position: "absolute", + left: frame.left, + top: frame.top, + width: frame.width, + height: frame.height, + ...getAssignedImageMaskStyle(layer), + }; +} + function hasEditedTextValue(layer: AgentPsdTemplateTextLayerSlot, value: string) { return String(value || "").replace(/\r\n/g, "\n") !== String(layer.defaultText || "").replace(/\r\n/g, "\n"); } @@ -241,6 +275,8 @@ export function AgentPsdTemplateEditorDialog({ const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [selectedLayerId, setSelectedLayerId] = useState(""); const [imageAssignments, setImageAssignments] = useState>({}); + const [imagePlacements, setImagePlacements] = useState>({}); + const [imageSizes, setImageSizes] = useState>({}); const [textAssignments, setTextAssignments] = useState>({}); const [uploadingSlotId, setUploadingSlotId] = useState(null); const [isRendering, setIsRendering] = useState(false); @@ -250,6 +286,13 @@ export function AgentPsdTemplateEditorDialog({ const previewViewportRef = useRef(null); const uploadInputRef = useRef(null); const inlineTextEditorRef = useRef(null); + const imageDragStateRef = useRef<{ + layerId: string; + pointerId: number; + startClientX: number; + startClientY: number; + startPlacement: AgentPsdImagePlacement; + } | null>(null); const selectedTemplate = useMemo( () => templates.find((template) => template.id === selectedTemplateId) || null, @@ -339,12 +382,31 @@ export function AgentPsdTemplateEditorDialog({ return nextAssignments; }, [editableTextLayers, selectedTemplate, textAssignments]); + const effectiveImagePlacements = useMemo(() => { + const nextPlacements: Record = {}; + if (!selectedTemplate) { + return nextPlacements; + } + + for (const layer of editableImageLayers) { + if (!String(effectiveImageAssignments[layer.id] || "").trim()) { + continue; + } + nextPlacements[layer.id] = normalizeAgentPsdImagePlacement(imagePlacements[layer.id]); + } + + return nextPlacements; + }, [editableImageLayers, effectiveImageAssignments, imagePlacements, selectedTemplate]); + const selectedLayer = useMemo( () => selectedTemplate?.layers.find((layer) => layer.id === selectedLayerId) || null, [selectedLayerId, selectedTemplate] ); const selectedImageLayer = selectedLayer && isAgentPsdImageLayerSlot(selectedLayer) ? selectedLayer : null; const selectedTextLayer = selectedLayer && isAgentPsdTextLayerSlot(selectedLayer) ? selectedLayer : null; + const selectedImagePlacement = selectedImageLayer + ? normalizeAgentPsdImagePlacement(effectiveImagePlacements[selectedImageLayer.id]) + : getDefaultAgentPsdImagePlacement(); const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]); useEffect(() => { @@ -360,10 +422,48 @@ export function AgentPsdTemplateEditorDialog({ setRenderedResultUrl(""); const initialState = createInitialState(selectedTemplate, sourceImageUrl || ""); setImageAssignments(initialState.imageAssignments); + setImagePlacements(initialState.imagePlacements); setTextAssignments(initialState.textAssignments); setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate)); }, [open, selectedTemplate, sourceImageUrl]); + useEffect(() => { + if (!selectedTemplate) { + setImageSizes({}); + return; + } + + let cancelled = false; + for (const layer of editableImageLayers) { + const assignedUrl = String(effectiveImageAssignments[layer.id] || "").trim(); + if (!assignedUrl) { + continue; + } + + const currentSize = imageSizes[layer.id]; + if (currentSize && currentSize.width > 0 && currentSize.height > 0) { + continue; + } + + const image = new window.Image(); + image.onload = () => { + if (cancelled) return; + setImageSizes((current) => ({ + ...current, + [layer.id]: { + width: Math.max(1, image.naturalWidth || layer.width), + height: Math.max(1, image.naturalHeight || layer.height), + }, + })); + }; + image.src = assignedUrl; + } + + return () => { + cancelled = true; + }; + }, [editableImageLayers, effectiveImageAssignments, imageSizes, selectedTemplate]); + useEffect(() => { if (!selectedTemplate) { setStageScale(1); @@ -429,6 +529,15 @@ export function AgentPsdTemplateEditorDialog({ ...current, [slotId]: payload.url, })); + setImagePlacements((current) => ({ + ...current, + [slotId]: getDefaultAgentPsdImagePlacement(), + })); + setImageSizes((current) => { + const next = { ...current }; + delete next[slotId]; + return next; + }); toast.success("替换图片已上传"); } catch (error) { toast.error(error instanceof Error ? error.message : "素材上传失败"); @@ -446,6 +555,62 @@ export function AgentPsdTemplateEditorDialog({ } } + function updateImagePlacement( + layerId: string, + updater: (current: AgentPsdImagePlacement) => AgentPsdImagePlacement + ) { + setImagePlacements((current) => ({ + ...current, + [layerId]: normalizeAgentPsdImagePlacement(updater(normalizeAgentPsdImagePlacement(current[layerId]))), + })); + } + + function handleImageDragStart( + layer: AgentPsdTemplateImageLayerSlot, + event: ReactPointerEvent + ) { + if (!String(effectiveImageAssignments[layer.id] || "").trim()) { + return; + } + + imageDragStateRef.current = { + layerId: layer.id, + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startPlacement: normalizeAgentPsdImagePlacement(effectiveImagePlacements[layer.id]), + }; + + const nextTarget = event.currentTarget; + nextTarget.setPointerCapture?.(event.pointerId); + + const handlePointerMove = (moveEvent: PointerEvent) => { + const dragState = imageDragStateRef.current; + if (!dragState || dragState.layerId !== layer.id) { + return; + } + + updateImagePlacement(layer.id, () => ({ + ...dragState.startPlacement, + offsetX: dragState.startPlacement.offsetX + (moveEvent.clientX - dragState.startClientX) / stageScale, + offsetY: dragState.startPlacement.offsetY + (moveEvent.clientY - dragState.startClientY) / stageScale, + })); + }; + + const handlePointerUp = (upEvent: PointerEvent) => { + if (imageDragStateRef.current?.pointerId === upEvent.pointerId) { + imageDragStateRef.current = null; + } + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + } + async function handleRender() { if (!selectedTemplate) { toast.error("请先选择 PSD 模板"); @@ -463,6 +628,7 @@ export function AgentPsdTemplateEditorDialog({ templateId: selectedTemplate.id, sourceImageUrl: sourceImageUrl || "", imageAssignments: effectiveImageAssignments, + imagePlacements: effectiveImagePlacements, textAssignments: effectiveTextAssignments, }), }); @@ -646,15 +812,26 @@ export function AgentPsdTemplateEditorDialog({ {layer.layerName} @@ -714,7 +891,33 @@ export function AgentPsdTemplateEditorDialog({ String(effectiveImageAssignments[layer.id] || "").trim() ); - return ( + return isSelected && hasAssignedImage ? ( +
setSelectedLayerId(layer.id)} + onPointerDown={(event) => handleImageDragStart(layer, event)} + className="absolute overflow-hidden rounded-lg transition-all" + style={{ + ...frameStyle, + zIndex: 20, + background: "rgba(249,115,22,0.08)", + cursor: imageDragStateRef.current?.layerId === layer.id ? "grabbing" : "grab", + touchAction: "none", + }} + aria-label={`${getLayerLabel(layer)} ${layer.layerName}`} + title={`${getLayerLabel(layer)}:${layer.layerName}`} + > +
+ {layer.layerName} +
+
+ + 拖动调整 +
+
+ ) : ( + + + ) : null}
{sourceImageUrl ? ( diff --git a/lib/agents/psd-image-placement.ts b/lib/agents/psd-image-placement.ts new file mode 100644 index 000000000..e8fda7984 --- /dev/null +++ b/lib/agents/psd-image-placement.ts @@ -0,0 +1,101 @@ +import type { AgentPsdTemplateReplaceMode } from "@/lib/agents/psd-template-config"; + +export interface AgentPsdImagePlacement { + scale: number; + offsetX: number; + offsetY: number; +} + +export interface AgentPsdImageSize { + width: number; + height: number; +} + +export function getDefaultAgentPsdImagePlacement(): AgentPsdImagePlacement { + return { + scale: 1, + offsetX: 0, + offsetY: 0, + }; +} + +export function normalizeAgentPsdImagePlacement( + value: Partial | null | undefined +): AgentPsdImagePlacement { + const scale = Number(value?.scale); + const offsetX = Number(value?.offsetX); + const offsetY = Number(value?.offsetY); + + return { + scale: Number.isFinite(scale) && scale > 0 ? Math.max(0.1, Math.min(8, scale)) : 1, + offsetX: Number.isFinite(offsetX) ? offsetX : 0, + offsetY: Number.isFinite(offsetY) ? offsetY : 0, + }; +} + +function resolveBaseFittedSize(params: { + containerWidth: number; + containerHeight: number; + imageWidth: number; + imageHeight: number; + replaceMode: AgentPsdTemplateReplaceMode; +}) { + const { containerWidth, containerHeight, imageWidth, imageHeight, replaceMode } = params; + const safeImageWidth = Math.max(1, imageWidth); + const safeImageHeight = Math.max(1, imageHeight); + + if (replaceMode === "stretch") { + return { + width: containerWidth, + height: containerHeight, + left: 0, + top: 0, + }; + } + + const fitRatio = + replaceMode === "contain" + ? Math.min(containerWidth / safeImageWidth, containerHeight / safeImageHeight) + : Math.max(containerWidth / safeImageWidth, containerHeight / safeImageHeight); + + const width = safeImageWidth * fitRatio; + const height = safeImageHeight * fitRatio; + + return { + width, + height, + left: (containerWidth - width) / 2, + top: (containerHeight - height) / 2, + }; +} + +export function resolveAgentPsdImagePlacementFrame(params: { + containerWidth: number; + containerHeight: number; + imageWidth: number; + imageHeight: number; + replaceMode: AgentPsdTemplateReplaceMode; + placement?: Partial | null; +}) { + const { containerWidth, containerHeight, imageWidth, imageHeight, replaceMode } = params; + const placement = normalizeAgentPsdImagePlacement(params.placement); + const baseFrame = resolveBaseFittedSize({ + containerWidth, + containerHeight, + imageWidth, + imageHeight, + replaceMode, + }); + + const width = baseFrame.width * placement.scale; + const height = baseFrame.height * placement.scale; + const left = baseFrame.left - (width - baseFrame.width) / 2 + placement.offsetX; + const top = baseFrame.top - (height - baseFrame.height) / 2 + placement.offsetY; + + return { + width, + height, + left, + top, + }; +} diff --git a/lib/agents/psd-template-renderer.ts b/lib/agents/psd-template-renderer.ts index f0ad8c3bb..4bfcf935d 100644 --- a/lib/agents/psd-template-renderer.ts +++ b/lib/agents/psd-template-renderer.ts @@ -9,6 +9,7 @@ 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 { resolveTextLayout } from "@/lib/agents/psd-text-layout"; async function getSharp() { @@ -298,10 +299,11 @@ async function createTextOverlayBuffer(params: { export async function renderAgentPsdTemplate(params: { template: AgentPsdTemplate; imageAssignments: Record; + imagePlacements: Record>; textAssignments: Record; fonts: AgentPsdFont[]; }) { - const { template, imageAssignments, textAssignments, fonts } = params; + const { template, imageAssignments, imagePlacements, textAssignments, fonts } = params; const sharp = await getSharp(); const source = await loadPsdTemplateSourceFromUrl(template.fileUrl); const canvasWidth = Math.max(1, Math.round(Number(source.width || template.width || 1))); @@ -361,19 +363,22 @@ export async function renderAgentPsdTemplate(params: { } const sourceBuffer = await readCachedBuffer(assignmentUrl); - const fit = - layer.replaceMode === "stretch" - ? "fill" - : layer.replaceMode === "contain" - ? "contain" - : "cover"; + const metadata = await sharp(sourceBuffer).metadata(); + const placementFrame = resolveAgentPsdImagePlacementFrame({ + containerWidth: layer.width, + containerHeight: layer.height, + imageWidth: Math.max(1, Number(metadata.width || 1)), + imageHeight: Math.max(1, Number(metadata.height || 1)), + replaceMode: layer.replaceMode, + placement: imagePlacements[layer.id], + }); let overlayBuffer = await sharp(sourceBuffer) .rotate() .resize({ - width: layer.width, - height: layer.height, - fit, + width: Math.max(1, Math.round(placementFrame.width)), + height: Math.max(1, Math.round(placementFrame.height)), + fit: "fill", background: { r: 0, g: 0, @@ -399,10 +404,10 @@ export async function renderAgentPsdTemplate(params: { const fittedOverlay = await fitOverlayBufferIntoCanvas({ input: overlayBuffer, - left: layer.x, - top: layer.y, - width: layer.width, - height: layer.height, + left: layer.x + placementFrame.left, + top: layer.y + placementFrame.top, + width: placementFrame.width, + height: placementFrame.height, canvasWidth, canvasHeight, });