feat: support dragging and scaling psd image slots
This commit is contained in:
parent
d1ee239fd6
commit
dc04c1d91b
@ -9,6 +9,7 @@ import {
|
|||||||
type AgentPsdTemplateRenderResponse,
|
type AgentPsdTemplateRenderResponse,
|
||||||
} from "@/lib/agents/psd-template-config";
|
} from "@/lib/agents/psd-template-config";
|
||||||
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
|
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 { renderAgentPsdTemplate } from "@/lib/agents/psd-template-renderer";
|
||||||
import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver";
|
import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver";
|
||||||
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
|
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
|
||||||
@ -19,6 +20,16 @@ const renderSchema = z.object({
|
|||||||
templateId: z.string().trim().min(1),
|
templateId: z.string().trim().min(1),
|
||||||
sourceImageUrl: z.string().trim().optional().nullable(),
|
sourceImageUrl: z.string().trim().optional().nullable(),
|
||||||
imageAssignments: z.record(z.string(), z.string()).default({}),
|
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({}),
|
textAssignments: z.record(z.string(), z.string()).default({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -74,17 +85,20 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const imageAssignments: Record<string, string> = {};
|
const imageAssignments: Record<string, string> = {};
|
||||||
|
const imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>> = {};
|
||||||
const textAssignments: Record<string, string> = {};
|
const textAssignments: Record<string, string> = {};
|
||||||
for (const layer of editableLayers) {
|
for (const layer of editableLayers) {
|
||||||
if (isAgentPsdImageLayerSlot(layer)) {
|
if (isAgentPsdImageLayerSlot(layer)) {
|
||||||
const explicitUrl = String(body.imageAssignments[layer.id] || "").trim();
|
const explicitUrl = String(body.imageAssignments[layer.id] || "").trim();
|
||||||
if (explicitUrl) {
|
if (explicitUrl) {
|
||||||
imageAssignments[layer.id] = explicitUrl;
|
imageAssignments[layer.id] = explicitUrl;
|
||||||
|
imagePlacements[layer.id] = normalizeAgentPsdImagePlacement(body.imagePlacements[layer.id]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layer.isDefaultResultSource && body.sourceImageUrl?.trim()) {
|
if (layer.isDefaultResultSource && body.sourceImageUrl?.trim()) {
|
||||||
imageAssignments[layer.id] = 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({
|
const uploaded = await renderAgentPsdTemplate({
|
||||||
template: resolvedTemplate,
|
template: resolvedTemplate,
|
||||||
imageAssignments,
|
imageAssignments,
|
||||||
|
imagePlacements,
|
||||||
textAssignments,
|
textAssignments,
|
||||||
fonts,
|
fonts,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
"use client";
|
"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 useSWR from "swr";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Move,
|
||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Type,
|
Type,
|
||||||
Upload,
|
Upload,
|
||||||
|
ZoomIn,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
import {
|
import {
|
||||||
@ -26,7 +28,15 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
getDefaultAgentPsdImagePlacement,
|
||||||
|
normalizeAgentPsdImagePlacement,
|
||||||
|
resolveAgentPsdImagePlacementFrame,
|
||||||
|
type AgentPsdImagePlacement,
|
||||||
|
type AgentPsdImageSize,
|
||||||
|
} from "@/lib/agents/psd-image-placement";
|
||||||
import {
|
import {
|
||||||
isAgentPsdImageLayerSlot,
|
isAgentPsdImageLayerSlot,
|
||||||
isAgentPsdTextLayerSlot,
|
isAgentPsdTextLayerSlot,
|
||||||
@ -45,22 +55,24 @@ const PREVIEW_STAGE_MAX_SCALE = 0.82;
|
|||||||
|
|
||||||
function createInitialState(template: AgentPsdTemplate | null, sourceImageUrl: string) {
|
function createInitialState(template: AgentPsdTemplate | null, sourceImageUrl: string) {
|
||||||
const imageAssignments: Record<string, string> = {};
|
const imageAssignments: Record<string, string> = {};
|
||||||
|
const imagePlacements: Record<string, AgentPsdImagePlacement> = {};
|
||||||
const textAssignments: Record<string, string> = {};
|
const textAssignments: Record<string, string> = {};
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
return { imageAssignments, textAssignments };
|
return { imageAssignments, imagePlacements, textAssignments };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const layer of template.layers) {
|
for (const layer of template.layers) {
|
||||||
if (isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource && sourceImageUrl) {
|
if (isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource && sourceImageUrl) {
|
||||||
imageAssignments[layer.id] = sourceImageUrl;
|
imageAssignments[layer.id] = sourceImageUrl;
|
||||||
|
imagePlacements[layer.id] = getDefaultAgentPsdImagePlacement();
|
||||||
}
|
}
|
||||||
if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) {
|
if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) {
|
||||||
textAssignments[layer.id] = layer.defaultText;
|
textAssignments[layer.id] = layer.defaultText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { imageAssignments, textAssignments };
|
return { imageAssignments, imagePlacements, textAssignments };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLayerLabel(layer: AgentPsdTemplateLayerSlot) {
|
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);
|
const hasMask = Boolean(layer.maskUrl);
|
||||||
return {
|
return {
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
objectFit: getImageFit(layer.replaceMode),
|
|
||||||
WebkitMaskImage: hasMask ? `url(${layer.maskUrl})` : undefined,
|
WebkitMaskImage: hasMask ? `url(${layer.maskUrl})` : undefined,
|
||||||
maskImage: hasMask ? `url(${layer.maskUrl})` : undefined,
|
maskImage: hasMask ? `url(${layer.maskUrl})` : undefined,
|
||||||
WebkitMaskSize: hasMask ? "100% 100%" : 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) {
|
function hasEditedTextValue(layer: AgentPsdTemplateTextLayerSlot, value: string) {
|
||||||
return String(value || "").replace(/\r\n/g, "\n") !== String(layer.defaultText || "").replace(/\r\n/g, "\n");
|
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<string>("");
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||||||
const [selectedLayerId, setSelectedLayerId] = useState<string>("");
|
const [selectedLayerId, setSelectedLayerId] = useState<string>("");
|
||||||
const [imageAssignments, setImageAssignments] = useState<Record<string, string>>({});
|
const [imageAssignments, setImageAssignments] = useState<Record<string, string>>({});
|
||||||
|
const [imagePlacements, setImagePlacements] = useState<Record<string, AgentPsdImagePlacement>>({});
|
||||||
|
const [imageSizes, setImageSizes] = useState<Record<string, AgentPsdImageSize>>({});
|
||||||
const [textAssignments, setTextAssignments] = useState<Record<string, string>>({});
|
const [textAssignments, setTextAssignments] = useState<Record<string, string>>({});
|
||||||
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
|
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
|
||||||
const [isRendering, setIsRendering] = useState(false);
|
const [isRendering, setIsRendering] = useState(false);
|
||||||
@ -250,6 +286,13 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
const previewViewportRef = useRef<HTMLDivElement | null>(null);
|
const previewViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const inlineTextEditorRef = useRef<HTMLTextAreaElement | null>(null);
|
const inlineTextEditorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const imageDragStateRef = useRef<{
|
||||||
|
layerId: string;
|
||||||
|
pointerId: number;
|
||||||
|
startClientX: number;
|
||||||
|
startClientY: number;
|
||||||
|
startPlacement: AgentPsdImagePlacement;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const selectedTemplate = useMemo(
|
const selectedTemplate = useMemo(
|
||||||
() => templates.find((template) => template.id === selectedTemplateId) || null,
|
() => templates.find((template) => template.id === selectedTemplateId) || null,
|
||||||
@ -339,12 +382,31 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
return nextAssignments;
|
return nextAssignments;
|
||||||
}, [editableTextLayers, selectedTemplate, textAssignments]);
|
}, [editableTextLayers, selectedTemplate, textAssignments]);
|
||||||
|
|
||||||
|
const effectiveImagePlacements = useMemo(() => {
|
||||||
|
const nextPlacements: Record<string, AgentPsdImagePlacement> = {};
|
||||||
|
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(
|
const selectedLayer = useMemo(
|
||||||
() => selectedTemplate?.layers.find((layer) => layer.id === selectedLayerId) || null,
|
() => selectedTemplate?.layers.find((layer) => layer.id === selectedLayerId) || null,
|
||||||
[selectedLayerId, selectedTemplate]
|
[selectedLayerId, selectedTemplate]
|
||||||
);
|
);
|
||||||
const selectedImageLayer = selectedLayer && isAgentPsdImageLayerSlot(selectedLayer) ? selectedLayer : null;
|
const selectedImageLayer = selectedLayer && isAgentPsdImageLayerSlot(selectedLayer) ? selectedLayer : null;
|
||||||
const selectedTextLayer = selectedLayer && isAgentPsdTextLayerSlot(selectedLayer) ? selectedLayer : null;
|
const selectedTextLayer = selectedLayer && isAgentPsdTextLayerSlot(selectedLayer) ? selectedLayer : null;
|
||||||
|
const selectedImagePlacement = selectedImageLayer
|
||||||
|
? normalizeAgentPsdImagePlacement(effectiveImagePlacements[selectedImageLayer.id])
|
||||||
|
: getDefaultAgentPsdImagePlacement();
|
||||||
const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]);
|
const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -360,10 +422,48 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
setRenderedResultUrl("");
|
setRenderedResultUrl("");
|
||||||
const initialState = createInitialState(selectedTemplate, sourceImageUrl || "");
|
const initialState = createInitialState(selectedTemplate, sourceImageUrl || "");
|
||||||
setImageAssignments(initialState.imageAssignments);
|
setImageAssignments(initialState.imageAssignments);
|
||||||
|
setImagePlacements(initialState.imagePlacements);
|
||||||
setTextAssignments(initialState.textAssignments);
|
setTextAssignments(initialState.textAssignments);
|
||||||
setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate));
|
setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate));
|
||||||
}, [open, selectedTemplate, sourceImageUrl]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!selectedTemplate) {
|
if (!selectedTemplate) {
|
||||||
setStageScale(1);
|
setStageScale(1);
|
||||||
@ -429,6 +529,15 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
...current,
|
...current,
|
||||||
[slotId]: payload.url,
|
[slotId]: payload.url,
|
||||||
}));
|
}));
|
||||||
|
setImagePlacements((current) => ({
|
||||||
|
...current,
|
||||||
|
[slotId]: getDefaultAgentPsdImagePlacement(),
|
||||||
|
}));
|
||||||
|
setImageSizes((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[slotId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
toast.success("替换图片已上传");
|
toast.success("替换图片已上传");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "素材上传失败");
|
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<HTMLDivElement>
|
||||||
|
) {
|
||||||
|
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() {
|
async function handleRender() {
|
||||||
if (!selectedTemplate) {
|
if (!selectedTemplate) {
|
||||||
toast.error("请先选择 PSD 模板");
|
toast.error("请先选择 PSD 模板");
|
||||||
@ -463,6 +628,7 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
templateId: selectedTemplate.id,
|
templateId: selectedTemplate.id,
|
||||||
sourceImageUrl: sourceImageUrl || "",
|
sourceImageUrl: sourceImageUrl || "",
|
||||||
imageAssignments: effectiveImageAssignments,
|
imageAssignments: effectiveImageAssignments,
|
||||||
|
imagePlacements: effectiveImagePlacements,
|
||||||
textAssignments: effectiveTextAssignments,
|
textAssignments: effectiveTextAssignments,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -646,15 +812,26 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
<img
|
<img
|
||||||
src={layerImageUrl}
|
src={layerImageUrl}
|
||||||
alt={layer.layerName}
|
alt={layer.layerName}
|
||||||
className="h-full w-full"
|
className={assignedUrl ? "" : "h-full w-full"}
|
||||||
style={
|
style={
|
||||||
assignedUrl
|
assignedUrl && imageSizes[layer.id]
|
||||||
? getAssignedImageStyle(layer)
|
? getPlacedAssignedImageStyle({
|
||||||
: {
|
layer,
|
||||||
width: "100%",
|
imageSize: imageSizes[layer.id],
|
||||||
height: "100%",
|
placement: effectiveImagePlacements[layer.id],
|
||||||
objectFit: "fill",
|
})
|
||||||
}
|
: assignedUrl
|
||||||
|
? {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: getImageFit(layer.replaceMode),
|
||||||
|
...getAssignedImageMaskStyle(layer),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "fill",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -714,7 +891,33 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
String(effectiveImageAssignments[layer.id] || "").trim()
|
String(effectiveImageAssignments[layer.id] || "").trim()
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return isSelected && hasAssignedImage ? (
|
||||||
|
<div
|
||||||
|
key={layer.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => 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}`}
|
||||||
|
>
|
||||||
|
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
|
||||||
|
{layer.layerName}
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute bottom-2 right-2 flex items-center gap-1 rounded-full bg-orange-500/85 px-2 py-1 text-[11px] text-white">
|
||||||
|
<Move className="h-3 w-3" />
|
||||||
|
拖动调整
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
key={layer.id}
|
key={layer.id}
|
||||||
type="button"
|
type="button"
|
||||||
@ -867,6 +1070,50 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{effectiveImageAssignments[selectedImageLayer.id] ? (
|
||||||
|
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Move className="h-3.5 w-3.5" />
|
||||||
|
选中画布中的图片后可直接拖动位置
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ZoomIn className="h-3.5 w-3.5" />
|
||||||
|
缩放
|
||||||
|
</div>
|
||||||
|
<span>{Math.round(selectedImagePlacement.scale * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={0.4}
|
||||||
|
max={3}
|
||||||
|
step={0.01}
|
||||||
|
value={[selectedImagePlacement.scale]}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateImagePlacement(selectedImageLayer.id, (current) => ({
|
||||||
|
...current,
|
||||||
|
scale: value[0] || 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setImagePlacements((current) => ({
|
||||||
|
...current,
|
||||||
|
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
重置位置与缩放
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{sourceImageUrl ? (
|
{sourceImageUrl ? (
|
||||||
<Button
|
<Button
|
||||||
@ -874,10 +1121,21 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setImageAssignments((current) => ({
|
{
|
||||||
...current,
|
setImageAssignments((current) => ({
|
||||||
[selectedImageLayer.id]: sourceImageUrl,
|
...current,
|
||||||
}))
|
[selectedImageLayer.id]: sourceImageUrl,
|
||||||
|
}));
|
||||||
|
setImagePlacements((current) => ({
|
||||||
|
...current,
|
||||||
|
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
|
||||||
|
}));
|
||||||
|
setImageSizes((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[selectedImageLayer.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
使用当前图片
|
使用当前图片
|
||||||
@ -902,13 +1160,23 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
setImageAssignments((current) => {
|
setImageAssignments((current) => {
|
||||||
const nextAssignments = { ...current };
|
const nextAssignments = { ...current };
|
||||||
delete nextAssignments[selectedImageLayer.id];
|
delete nextAssignments[selectedImageLayer.id];
|
||||||
return nextAssignments;
|
return nextAssignments;
|
||||||
})
|
});
|
||||||
}
|
setImagePlacements((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[selectedImageLayer.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setImageSizes((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[selectedImageLayer.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
清空
|
清空
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
101
lib/agents/psd-image-placement.ts
Normal file
101
lib/agents/psd-image-placement.ts
Normal file
@ -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<AgentPsdImagePlacement> | 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<AgentPsdImagePlacement> | 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/lib/agents/psd-template-config";
|
} from "@/lib/agents/psd-template-config";
|
||||||
import type { AgentPsdFont } from "@/lib/agents/psd-font-config";
|
import type { AgentPsdFont } from "@/lib/agents/psd-font-config";
|
||||||
import { ensurePsdFontAvailableForRender } from "@/lib/agents/psd-font-runtime";
|
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";
|
import { resolveTextLayout } from "@/lib/agents/psd-text-layout";
|
||||||
|
|
||||||
async function getSharp() {
|
async function getSharp() {
|
||||||
@ -298,10 +299,11 @@ async function createTextOverlayBuffer(params: {
|
|||||||
export async function renderAgentPsdTemplate(params: {
|
export async function renderAgentPsdTemplate(params: {
|
||||||
template: AgentPsdTemplate;
|
template: AgentPsdTemplate;
|
||||||
imageAssignments: Record<string, string>;
|
imageAssignments: Record<string, string>;
|
||||||
|
imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>>;
|
||||||
textAssignments: Record<string, string>;
|
textAssignments: Record<string, string>;
|
||||||
fonts: AgentPsdFont[];
|
fonts: AgentPsdFont[];
|
||||||
}) {
|
}) {
|
||||||
const { template, imageAssignments, textAssignments, fonts } = params;
|
const { template, imageAssignments, imagePlacements, textAssignments, fonts } = params;
|
||||||
const sharp = await getSharp();
|
const sharp = await getSharp();
|
||||||
const source = await loadPsdTemplateSourceFromUrl(template.fileUrl);
|
const source = await loadPsdTemplateSourceFromUrl(template.fileUrl);
|
||||||
const canvasWidth = Math.max(1, Math.round(Number(source.width || template.width || 1)));
|
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 sourceBuffer = await readCachedBuffer(assignmentUrl);
|
||||||
const fit =
|
const metadata = await sharp(sourceBuffer).metadata();
|
||||||
layer.replaceMode === "stretch"
|
const placementFrame = resolveAgentPsdImagePlacementFrame({
|
||||||
? "fill"
|
containerWidth: layer.width,
|
||||||
: layer.replaceMode === "contain"
|
containerHeight: layer.height,
|
||||||
? "contain"
|
imageWidth: Math.max(1, Number(metadata.width || 1)),
|
||||||
: "cover";
|
imageHeight: Math.max(1, Number(metadata.height || 1)),
|
||||||
|
replaceMode: layer.replaceMode,
|
||||||
|
placement: imagePlacements[layer.id],
|
||||||
|
});
|
||||||
|
|
||||||
let overlayBuffer = await sharp(sourceBuffer)
|
let overlayBuffer = await sharp(sourceBuffer)
|
||||||
.rotate()
|
.rotate()
|
||||||
.resize({
|
.resize({
|
||||||
width: layer.width,
|
width: Math.max(1, Math.round(placementFrame.width)),
|
||||||
height: layer.height,
|
height: Math.max(1, Math.round(placementFrame.height)),
|
||||||
fit,
|
fit: "fill",
|
||||||
background: {
|
background: {
|
||||||
r: 0,
|
r: 0,
|
||||||
g: 0,
|
g: 0,
|
||||||
@ -399,10 +404,10 @@ export async function renderAgentPsdTemplate(params: {
|
|||||||
|
|
||||||
const fittedOverlay = await fitOverlayBufferIntoCanvas({
|
const fittedOverlay = await fitOverlayBufferIntoCanvas({
|
||||||
input: overlayBuffer,
|
input: overlayBuffer,
|
||||||
left: layer.x,
|
left: layer.x + placementFrame.left,
|
||||||
top: layer.y,
|
top: layer.y + placementFrame.top,
|
||||||
width: layer.width,
|
width: placementFrame.width,
|
||||||
height: layer.height,
|
height: placementFrame.height,
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user