feat: support dragging and scaling psd image slots
This commit is contained in:
parent
d1ee239fd6
commit
dc04c1d91b
@ -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<string, string> = {};
|
||||
const imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>> = {};
|
||||
const textAssignments: Record<string, string> = {};
|
||||
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,
|
||||
});
|
||||
|
||||
@ -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<string, string> = {};
|
||||
const imagePlacements: Record<string, AgentPsdImagePlacement> = {};
|
||||
const textAssignments: Record<string, string> = {};
|
||||
|
||||
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<string>("");
|
||||
const [selectedLayerId, setSelectedLayerId] = useState<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 [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
|
||||
const [isRendering, setIsRendering] = useState(false);
|
||||
@ -250,6 +286,13 @@ export function AgentPsdTemplateEditorDialog({
|
||||
const previewViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement | 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(
|
||||
() => 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<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(
|
||||
() => 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<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() {
|
||||
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({
|
||||
<img
|
||||
src={layerImageUrl}
|
||||
alt={layer.layerName}
|
||||
className="h-full w-full"
|
||||
className={assignedUrl ? "" : "h-full w-full"}
|
||||
style={
|
||||
assignedUrl
|
||||
? getAssignedImageStyle(layer)
|
||||
: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "fill",
|
||||
}
|
||||
assignedUrl && imageSizes[layer.id]
|
||||
? getPlacedAssignedImageStyle({
|
||||
layer,
|
||||
imageSize: imageSizes[layer.id],
|
||||
placement: effectiveImagePlacements[layer.id],
|
||||
})
|
||||
: assignedUrl
|
||||
? {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: getImageFit(layer.replaceMode),
|
||||
...getAssignedImageMaskStyle(layer),
|
||||
}
|
||||
: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "fill",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@ -714,7 +891,33 @@ export function AgentPsdTemplateEditorDialog({
|
||||
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
|
||||
key={layer.id}
|
||||
type="button"
|
||||
@ -867,6 +1070,50 @@ export function AgentPsdTemplateEditorDialog({
|
||||
)}
|
||||
</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">
|
||||
{sourceImageUrl ? (
|
||||
<Button
|
||||
@ -874,10 +1121,21 @@ export function AgentPsdTemplateEditorDialog({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setImageAssignments((current) => ({
|
||||
...current,
|
||||
[selectedImageLayer.id]: sourceImageUrl,
|
||||
}))
|
||||
{
|
||||
setImageAssignments((current) => ({
|
||||
...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"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
setImageAssignments((current) => {
|
||||
const nextAssignments = { ...current };
|
||||
delete nextAssignments[selectedImageLayer.id];
|
||||
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>
|
||||
|
||||
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";
|
||||
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<string, string>;
|
||||
imagePlacements: Record<string, ReturnType<typeof normalizeAgentPsdImagePlacement>>;
|
||||
textAssignments: Record<string, string>;
|
||||
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,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user