feat(M2): Manipulation spatiale des calques personnage

L'utilisateur peut désormais déplacer, redimensionner, rotater et
flipper le personnage généré sans devoir tout régénérer.

DB:
- Nouveau champ transform JSON sur frameVariants
- Format: {x, y, scale, rotation, flipH, flipV} avec coords relatives

Backend (sharp):
- compositeLayers applique transform avant le blend:
  * scale: resize layer (peut être >100% ou <100%)
  * rotation: sharp.rotate avec fond transparent
  * flipH/flipV: flop/flip
  * x/y: offset en pourcentage de la base (centré + delta)
- Gestion intelligente des layers qui dépassent: extract crop
  (sharp interdit top/left négatifs et inputs plus grands que la base)
- compositing.composeFrame récupère le transform de la variant
  character active automatiquement
- Nouveau endpoint frameVariants.updateTransform

Frontend (LayerManipulator):
- Composant overlay avec bounding box pointillée + 8 handles
- Handles coins = scale, handle haut = rotation, area centrale = move
- CSS transform live (translate/scale/rotate/scaleX(-1) pour flip)
- Toolbar flottante: flip H/V, position/scale/rotation affichés en live
- Reset button quand transformé
- Bouton "Recomposer" déclenche composeFrame avec le nouveau transform
- Save backend automatique au release de souris

ViewportPanel:
- Bouton "Manipuler" dans toolbar (visible uniquement mode composite)
- Active LayerManipulator overlay, mutuellement exclusif avec Annoter/Loupe
- Désactivé si pas de variant character actif (toast warn)

Workflow:
1. Mode composite dans viewport
2. Click "Manipuler" → handles apparaissent
3. Drag pour déplacer / corners pour scale / handle haut pour rotation
4. Sauvegarde auto au release (en DB)
5. Click "Recomposer" → sharp regenère avec transform appliqué
6. Nouvelle variante composite créée (Module 1)
7. La galerie M1 montre l'avant/après

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-05-21 07:06:26 +00:00
parent 6a875ad0d5
commit d18424a416
5 changed files with 458 additions and 14 deletions

View file

@ -0,0 +1,291 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
import {
Move, FlipHorizontal, FlipVertical, RotateCw, Maximize2, Loader2,
RefreshCw, Sparkles,
} from "lucide-react";
export interface LayerTransform {
x?: number; // -1..1 relative to canvas width
y?: number; // -1..1 relative to canvas height
scale?: number; // multiplier, 1=original
rotation?: number; // degrees
flipH?: boolean;
flipV?: boolean;
}
interface LayerManipulatorProps {
/** The character variant ID to modify (for backend save) */
variantId: number | undefined;
/** Character image URL */
imageUrl: string;
/** Initial transform */
initialTransform?: LayerTransform | null;
/** Canvas dimensions */
width: number;
height: number;
/** Triggered when user requests recompose */
onRecompose?: () => void;
/** Whether recompose is in progress */
isRecomposing?: boolean;
}
const DEFAULT_TRANSFORM: LayerTransform = { x: 0, y: 0, scale: 1, rotation: 0, flipH: false, flipV: false };
export default function LayerManipulator({
variantId,
imageUrl,
initialTransform,
width,
height,
onRecompose,
isRecomposing,
}: LayerManipulatorProps) {
const [transform, setTransform] = useState<LayerTransform>(initialTransform || DEFAULT_TRANSFORM);
const [dragMode, setDragMode] = useState<"none" | "move" | "scale" | "rotate">("none");
const dragStartRef = useRef<{ x: number; y: number; transform: LayerTransform } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isSaving, setIsSaving] = useState(false);
// Sync with new initial transform when variant changes
useEffect(() => {
setTransform(initialTransform || DEFAULT_TRANSFORM);
}, [variantId, initialTransform]);
const updateTransform = trpc.frameVariants.updateTransform.useMutation({
onError: (err) => toast.error(`Transform: ${err.message}`),
});
const saveTransform = useCallback(async (newTransform: LayerTransform) => {
if (!variantId) return;
setIsSaving(true);
try {
await updateTransform.mutateAsync({ variantId, transform: newTransform });
} finally {
setIsSaving(false);
}
}, [variantId, updateTransform]);
const resetTransform = useCallback(() => {
setTransform(DEFAULT_TRANSFORM);
saveTransform(DEFAULT_TRANSFORM);
}, [saveTransform]);
const handleFlipH = () => {
const next = { ...transform, flipH: !transform.flipH };
setTransform(next);
saveTransform(next);
};
const handleFlipV = () => {
const next = { ...transform, flipV: !transform.flipV };
setTransform(next);
saveTransform(next);
};
// Mouse handling for drag
const handleMouseDown = (mode: "move" | "scale" | "rotate") => (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setDragMode(mode);
dragStartRef.current = { x: e.clientX, y: e.clientY, transform: { ...transform } };
};
useEffect(() => {
if (dragMode === "none") return;
const onMove = (e: MouseEvent) => {
if (!dragStartRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const dx = e.clientX - dragStartRef.current.x;
const dy = e.clientY - dragStartRef.current.y;
const start = dragStartRef.current.transform;
if (dragMode === "move") {
// Convert pixel delta to relative
setTransform({
...start,
x: (start.x ?? 0) + dx / rect.width,
y: (start.y ?? 0) + dy / rect.height,
});
} else if (dragMode === "scale") {
// Distance from center increase = scale up
const distance = Math.sqrt(dx * dx + dy * dy);
const sign = dx + dy > 0 ? 1 : -1;
const factor = 1 + (sign * distance) / 200;
const newScale = Math.max(0.1, Math.min(5, (start.scale ?? 1) * factor));
setTransform({ ...start, scale: newScale });
} else if (dragMode === "rotate") {
// dx pixels = degree change
setTransform({
...start,
rotation: ((start.rotation ?? 0) + dx) % 360,
});
}
};
const onUp = () => {
setDragMode("none");
// Save on release
if (dragStartRef.current) {
const finalTransform = transform;
saveTransform(finalTransform);
dragStartRef.current = null;
}
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
return () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
}, [dragMode, transform, saveTransform]);
// CSS transform: combine all
const cssTransform = [
`translate(${(transform.x ?? 0) * 100}%, ${(transform.y ?? 0) * 100}%)`,
`scale(${transform.scale ?? 1})`,
`rotate(${transform.rotation ?? 0}deg)`,
transform.flipH ? "scaleX(-1)" : "",
transform.flipV ? "scaleY(-1)" : "",
].filter(Boolean).join(" ");
const isModified =
(transform.x ?? 0) !== 0 ||
(transform.y ?? 0) !== 0 ||
(transform.scale ?? 1) !== 1 ||
(transform.rotation ?? 0) !== 0 ||
transform.flipH ||
transform.flipV;
return (
<div ref={containerRef} className="absolute inset-0 z-30">
{/* Layer image with live CSS transform */}
<div
className="absolute inset-0 flex items-center justify-center pointer-events-none"
style={{
width: "100%",
height: "100%",
}}
>
<img
src={imageUrl}
alt="Character"
className="max-w-full max-h-full object-contain"
style={{
transform: cssTransform,
transformOrigin: "center center",
transition: dragMode === "none" ? "transform 0.1s ease-out" : "none",
}}
/>
</div>
{/* Bounding box + handles */}
<div
className="absolute inset-0 pointer-events-none"
style={{
transform: cssTransform,
transformOrigin: "center center",
transition: dragMode === "none" ? "transform 0.1s ease-out" : "none",
}}
>
<div className="absolute inset-4 border-2 border-dashed border-primary/60 pointer-events-none rounded">
{/* Move area in center */}
<div
className="absolute inset-8 cursor-move pointer-events-auto group"
onMouseDown={handleMouseDown("move")}
>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-50 group-hover:opacity-100">
<Move className="h-6 w-6 text-primary drop-shadow-lg" />
</div>
</div>
{/* Corner handles (scale) */}
{[
{ pos: "top-0 left-0 -translate-x-1/2 -translate-y-1/2", cursor: "nwse-resize" },
{ pos: "top-0 right-0 translate-x-1/2 -translate-y-1/2", cursor: "nesw-resize" },
{ pos: "bottom-0 left-0 -translate-x-1/2 translate-y-1/2", cursor: "nesw-resize" },
{ pos: "bottom-0 right-0 translate-x-1/2 translate-y-1/2", cursor: "nwse-resize" },
].map((h, i) => (
<div
key={i}
className={`absolute ${h.pos} w-3 h-3 bg-primary border-2 border-white rounded-sm pointer-events-auto`}
style={{ cursor: h.cursor }}
onMouseDown={handleMouseDown("scale")}
/>
))}
{/* Rotation handle (top center, extended above) */}
<div className="absolute -top-8 left-1/2 -translate-x-1/2 flex flex-col items-center pointer-events-auto cursor-grab">
<div
className="w-3 h-3 bg-primary border-2 border-white rounded-full"
onMouseDown={handleMouseDown("rotate")}
/>
<div className="w-px h-4 bg-primary/60" />
</div>
</div>
</div>
{/* Toolbar */}
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-40 flex items-center gap-1 bg-card/95 backdrop-blur-sm rounded-lg p-1.5 border border-primary/30 shadow-xl">
<Badge variant="outline" className="text-[9px] h-5 px-1.5">
MANIPULATION
</Badge>
<div className="w-px h-4 bg-border/50 mx-0.5" />
<Button size="sm" variant="ghost" className="h-7 px-2 text-[10px] gap-1" onClick={handleFlipH} title="Miroir horizontal">
<FlipHorizontal className="h-3 w-3" />
</Button>
<Button size="sm" variant="ghost" className="h-7 px-2 text-[10px] gap-1" onClick={handleFlipV} title="Miroir vertical">
<FlipVertical className="h-3 w-3" />
</Button>
<div className="w-px h-4 bg-border/50 mx-0.5" />
{/* Live values */}
<div className="flex items-center gap-2 text-[9px] font-mono text-muted-foreground px-1">
<span title="Position">
<Move className="inline h-2.5 w-2.5 mr-0.5" />
{((transform.x ?? 0) * 100).toFixed(0)},{((transform.y ?? 0) * 100).toFixed(0)}%
</span>
<span title="Scale">
<Maximize2 className="inline h-2.5 w-2.5 mr-0.5" />
{((transform.scale ?? 1) * 100).toFixed(0)}%
</span>
<span title="Rotation">
<RotateCw className="inline h-2.5 w-2.5 mr-0.5" />
{(transform.rotation ?? 0).toFixed(0)}°
</span>
</div>
<div className="w-px h-4 bg-border/50 mx-0.5" />
{isModified && (
<Button size="sm" variant="ghost" className="h-7 px-2 text-[10px] gap-1" onClick={resetTransform} title="Reset">
<RefreshCw className="h-3 w-3" />
Reset
</Button>
)}
{isSaving && <Loader2 className="h-3 w-3 animate-spin text-primary" />}
{onRecompose && (
<Button
size="sm"
variant="default"
className="h-7 px-2 text-[10px] gap-1 bg-amber-600 hover:bg-amber-700"
onClick={onRecompose}
disabled={isRecomposing}
>
{isRecomposing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
Recomposer
</Button>
)}
</div>
</div>
);
}

View file

@ -4,6 +4,7 @@ import type { ViewMode } from "@/contexts/ShortcutsContext";
import ShortcutSettings from "@/components/ShortcutSettings";
import CompositePreview from "@/components/CompositePreview";
import AnnotationCanvas from "@/components/AnnotationCanvas";
import LayerManipulator from "@/components/LayerManipulator";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Slider } from "@/components/ui/slider";
@ -31,6 +32,7 @@ import {
Circle,
Square,
Brush,
Move,
} from "lucide-react";
type FrameUrlMap = Map<number, { originalUrl: string | null; compositedUrl: string | null }>;
@ -97,6 +99,7 @@ function ViewportPanelInner({ project, selectedFrame, layers, frameUrlMap, isPla
const [onionFrameOffset, setOnionFrameOffset] = useState(1);
const [imgErrors, setImgErrors] = useState<Record<string, boolean>>({});
const [annotating, setAnnotating] = useState(false);
const [manipulating, setManipulating] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Loupe state
@ -123,6 +126,22 @@ function ViewportPanelInner({ project, selectedFrame, layers, frameUrlMap, isPla
const originalFrameUrl = cachedFrame?.originalUrl || frameData?.originalUrl || null;
const regeneratedFrameUrl = cachedFrame?.compositedUrl || frameData?.compositedUrl || frameData?.regeneratedBgUrl || null;
// Character variants (for spatial transform manipulator)
const utils = trpc.useUtils();
const { data: charVariants } = trpc.frameVariants.list.useQuery(
{ frameId: (frameData as any)?.id || 0, type: "character" },
{ enabled: !!(frameData as any)?.id && manipulating, staleTime: 5_000 }
);
const activeCharVariant = charVariants?.find(v => v.isActive);
const composeFrame = trpc.compositing.composeFrame.useMutation({
onSuccess: () => {
utils.frames.getByIndex.invalidate();
utils.frames.list.invalidate();
utils.frameVariants.list.invalidate();
},
});
// Preload upcoming frames during playback
useEffect(() => {
if (!isPlaying || !frameUrlMap) return;
@ -499,7 +518,7 @@ function ViewportPanelInner({ project, selectedFrame, layers, frameUrlMap, isPla
variant={annotating ? "secondary" : "ghost"}
size="sm"
className={`h-6 text-[10px] px-1.5 gap-0.5 ${annotating ? "ring-1 ring-primary/50" : ""}`}
onClick={() => { setAnnotating(prev => !prev); setLoupe(prev => ({ ...prev, active: false })); }}
onClick={() => { setAnnotating(prev => !prev); setLoupe(prev => ({ ...prev, active: false })); setManipulating(false); }}
>
<Brush className="h-3 w-3" />
<span className="hidden sm:inline">Annoter</span>
@ -510,6 +529,28 @@ function ViewportPanelInner({ project, selectedFrame, layers, frameUrlMap, isPla
</TooltipContent>
</Tooltip>
{/* Layer manipulator (composite mode only) */}
{viewMode === "composite" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={manipulating ? "secondary" : "ghost"}
size="sm"
className={`h-6 text-[10px] px-1.5 gap-0.5 ${manipulating ? "ring-1 ring-primary/50" : ""}`}
onClick={() => { setManipulating(prev => !prev); setAnnotating(false); setLoupe(prev => ({ ...prev, active: false })); }}
disabled={!activeCharVariant}
>
<Move className="h-3 w-3" />
<span className="hidden sm:inline">Manipuler</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Déplacer / redimensionner / rotater le personnage
{!activeCharVariant && <span className="block text-amber-400 mt-1"> Générez d'abord un personnage</span>}
</TooltipContent>
</Tooltip>
)}
{/* Loupe controls - shown when active */}
{loupe.active && (
<>
@ -872,8 +913,20 @@ function ViewportPanelInner({ project, selectedFrame, layers, frameUrlMap, isPla
<div className="absolute top-2 right-2 z-10">
<Badge className="text-[8px] h-4 bg-purple-600/80 hover:bg-purple-600/80 border-0 font-mono">COMPOSITE</Badge>
</div>
{/* Layer manipulator overlay (composite mode) */}
{manipulating && activeCharVariant && project?.id && (
<LayerManipulator
variantId={activeCharVariant.id}
imageUrl={activeCharVariant.url}
initialTransform={(activeCharVariant.transform as any) || null}
width={frameWidth}
height={frameHeight}
onRecompose={() => composeFrame.mutate({ projectId: project.id, frameIndex: selectedFrame, featherRadius: 2 })}
isRecomposing={composeFrame.isPending}
/>
)}
{/* Loupe overlay */}
{loupe.active && renderLoupeOverlay()}
{loupe.active && !manipulating && renderLoupeOverlay()}
</div>
)}

View file

@ -197,6 +197,8 @@ export const frameVariants = mysqlTable("frameVariants", {
isPinned: boolean("isPinned").default(false),
label: varchar("label", { length: 255 }), // user-defined name
metadata: json("metadata"), // {model, prompt, style, etc}
/** Spatial transform applied when compositing: {x, y, scaleX, scaleY, rotation, flipH, flipV} - all relative (x,y in [-1,1], scale multiplier, rotation degrees) */
transform: json("transform"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});

View file

@ -793,6 +793,26 @@ Réponds de manière concise et technique, en français. Propose des actions con
await db.renameVariant(input.variantId, input.label);
return { success: true };
}),
updateTransform: protectedProcedure
.input(z.object({
variantId: z.number(),
transform: z.object({
x: z.number().optional(),
y: z.number().optional(),
scale: z.number().optional(),
rotation: z.number().optional(),
flipH: z.boolean().optional(),
flipV: z.boolean().optional(),
}).nullable(),
}))
.mutation(async ({ input }) => {
const dbInstance = await db.getDb();
if (!dbInstance) return { success: false };
const { frameVariants } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
await dbInstance.update(frameVariants).set({ transform: input.transform as any }).where(eq(frameVariants.id, input.variantId));
return { success: true };
}),
}),
// ============ COMPOSITING ============
@ -810,7 +830,7 @@ Réponds de manière concise et technique, en français. Propose des actions con
const projectLayers = await db.listLayers(input.projectId);
const visibleLayers = projectLayers.filter(l => l.visible).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const composites: Array<{ imageUrl: string; opacity: number; blendMode?: "normal" | "multiply" | "screen" | "overlay"; maskUrl?: string }> = [];
const composites: Array<{ imageUrl: string; opacity: number; blendMode?: "normal" | "multiply" | "screen" | "overlay"; maskUrl?: string; transform?: any }> = [];
const bgUrl = frame.regeneratedBgUrl || frame.backgroundUrl || frame.originalUrl;
if (bgUrl) {
@ -819,11 +839,14 @@ Réponds de manière concise et technique, en français. Propose des actions con
const fgUrl = frame.regeneratedFgUrl || frame.foregroundUrl;
if (fgUrl) {
// Look up active character variant for its transform
const charVariant = await db.getActiveVariant(frame.id, "character");
composites.push({
imageUrl: fgUrl,
opacity: 100,
blendMode: "normal",
maskUrl: frame.maskUrl || undefined,
transform: (charVariant?.transform as any) || undefined,
});
}

View file

@ -333,11 +333,27 @@ export async function propagateMask(
* - Black = background visible
* - Gray = partial transparency (edges, anti-aliasing)
*/
export interface LayerTransform {
/** Translation X relative to canvas width: -1..1 (0 = no shift) */
x?: number;
/** Translation Y relative to canvas height: -1..1 */
y?: number;
/** Scale multiplier (1.0 = original size) */
scale?: number;
/** Rotation in degrees */
rotation?: number;
/** Horizontal flip */
flipH?: boolean;
/** Vertical flip */
flipV?: boolean;
}
export interface CompositeLayer {
imageUrl: string;
opacity: number;
blendMode?: "normal" | "multiply" | "screen" | "overlay";
maskUrl?: string;
transform?: LayerTransform;
}
type SharpBlendMode = "over" | "multiply" | "screen" | "overlay";
@ -388,23 +404,31 @@ export async function compositeLayers(
const layer = layers[i];
let layerBuffer = await fetchImageBuffer(layer.imageUrl);
// Resize to match base
let layerPipeline = sharp(layerBuffer).resize(width, height, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } });
// Apply transform if present (translate, scale, rotate, flip)
const t = layer.transform;
const hasTransform = t && (t.x || t.y || (t.scale !== undefined && t.scale !== 1) || t.rotation || t.flipH || t.flipV);
// Apply mask if provided
// Compute target layer dimensions
const scale = t?.scale ?? 1;
const layerW = Math.max(1, Math.round(width * scale));
const layerH = Math.max(1, Math.round(height * scale));
let layerPipeline = sharp(layerBuffer).resize(layerW, layerH, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } });
// Apply mask if provided (mask is matched to layer pre-transform)
if (layer.maskUrl) {
const maskBuffer = await fetchImageBuffer(layer.maskUrl);
let maskPipeline = sharp(maskBuffer).resize(width, height, { fit: "contain" }).greyscale();
let maskPipeline = sharp(maskBuffer).resize(layerW, layerH, { fit: "contain" }).greyscale();
if (options.featherRadius && options.featherRadius > 0) {
maskPipeline = maskPipeline.blur(options.featherRadius);
}
const maskRaw = await maskPipeline.raw().toBuffer();
layerPipeline = layerPipeline.ensureAlpha().joinChannel(maskRaw, { raw: { width, height, channels: 1 } });
layerPipeline = layerPipeline.ensureAlpha().joinChannel(maskRaw, { raw: { width: layerW, height: layerH, channels: 1 } });
} else {
layerPipeline = layerPipeline.ensureAlpha();
}
// Apply layer opacity via composite
// Apply opacity
if (layer.opacity < 100) {
const alphaMultiplier = layer.opacity / 100;
const rawData = await layerPipeline.raw().toBuffer({ resolveWithObject: true });
@ -412,15 +436,66 @@ export async function compositeLayers(
for (let p = 0; p < rawData.data.length; p += channels) {
rawData.data[p + channels - 1] = Math.round(rawData.data[p + channels - 1] * alphaMultiplier);
}
layerBuffer = await sharp(rawData.data, { raw: { width, height, channels } }).png().toBuffer();
layerBuffer = await sharp(rawData.data, { raw: { width: layerW, height: layerH, channels } }).png().toBuffer();
} else {
layerBuffer = await layerPipeline.png().toBuffer();
}
composites.push({
input: layerBuffer,
blend: blendModeMap[layer.blendMode || "normal"] || "over",
});
// Apply flip + rotation
if (hasTransform) {
let transformedPipeline = sharp(layerBuffer);
if (t.flipH) transformedPipeline = transformedPipeline.flop();
if (t.flipV) transformedPipeline = transformedPipeline.flip();
if (t.rotation && t.rotation !== 0) {
transformedPipeline = transformedPipeline.rotate(t.rotation, { background: { r: 0, g: 0, b: 0, alpha: 0 } });
}
layerBuffer = await transformedPipeline.png().toBuffer();
}
// Compute placement on the base canvas
const finalDims = await sharp(layerBuffer).metadata();
const fW = finalDims.width || layerW;
const fH = finalDims.height || layerH;
const baseLeft = Math.round((width - fW) / 2);
const baseTop = Math.round((height - fH) / 2);
const offsetX = Math.round((t?.x ?? 0) * width);
const offsetY = Math.round((t?.y ?? 0) * height);
const placeLeft = baseLeft + offsetX;
const placeTop = baseTop + offsetY;
// Sharp's composite requires top/left >= 0 AND layer to fit within base.
// For transformed layers that overflow or have negative placement, we
// need to extract the visible portion from the layer before compositing.
if (fW > width || fH > height || placeLeft < 0 || placeTop < 0 || placeLeft + fW > width || placeTop + fH > height) {
// Compute visible crop from layer
const cropLeft = Math.max(0, -placeLeft);
const cropTop = Math.max(0, -placeTop);
const cropRight = Math.min(fW, width - placeLeft);
const cropBottom = Math.min(fH, height - placeTop);
const cropW = Math.max(0, cropRight - cropLeft);
const cropH = Math.max(0, cropBottom - cropTop);
if (cropW > 0 && cropH > 0) {
const croppedLayer = await sharp(layerBuffer)
.extract({ left: cropLeft, top: cropTop, width: cropW, height: cropH })
.png()
.toBuffer();
composites.push({
input: croppedLayer,
blend: blendModeMap[layer.blendMode || "normal"] || "over",
top: Math.max(0, placeTop),
left: Math.max(0, placeLeft),
});
}
// else: layer is fully out of bounds, skip it
} else {
composites.push({
input: layerBuffer,
blend: blendModeMap[layer.blendMode || "normal"] || "over",
top: placeTop,
left: placeLeft,
});
}
}
const finalBuffer = await pipeline.composite(composites).png().toBuffer();