diff --git a/apps/api/src/routes/tools/collage.ts b/apps/api/src/routes/tools/collage.ts
index 612e03c6..9127448d 100644
--- a/apps/api/src/routes/tools/collage.ts
+++ b/apps/api/src/routes/tools/collage.ts
@@ -322,6 +322,7 @@ const cellSchema = z.object({
panX: z.number().min(-100).max(100).default(0),
panY: z.number().min(-100).max(100).default(0),
zoom: z.number().min(1).max(3).default(1),
+ objectFit: z.enum(["cover", "contain"]).default("cover"),
});
const settingsSchema = z.object({
@@ -528,7 +529,7 @@ export function registerCollage(app: FastifyInstance) {
const rect = cellRects[i];
const cellW = Math.max(1, Math.round(rect.w));
const cellH = Math.max(1, Math.round(rect.h));
- const cellSetting = cellSettings[i] ?? { panX: 0, panY: 0, zoom: 1 };
+ const cellSetting = cellSettings[i] ?? { panX: 0, panY: 0, zoom: 1, objectFit: "cover" };
// Get image metadata for proper crop calculation
const meta = await sharp(files[i].buffer).metadata();
@@ -536,29 +537,57 @@ export function registerCollage(app: FastifyInstance) {
const imgH = meta.height ?? cellH;
const zoom = Math.max(1, cellSetting.zoom);
+ const fitMode = cellSetting.objectFit ?? "cover";
- // Calculate the size we need to resize to before extracting
- // With zoom=1 and cover fit, we resize so the image fully covers the cell
- const scaleToFit = Math.max(cellW / imgW, cellH / imgH);
- const resizedW = Math.round(imgW * scaleToFit * zoom);
- const resizedH = Math.round(imgH * scaleToFit * zoom);
+ let cellBuffer: Buffer;
- // Pan offset: percentage of the available overflow
- const overflowX = Math.max(0, resizedW - cellW);
- const overflowY = Math.max(0, resizedH - cellH);
- // Center by default, then apply pan (-100..100 maps to full overflow range)
- const extractLeft = Math.round(overflowX / 2 - (cellSetting.panX / 100) * (overflowX / 2));
- const extractTop = Math.round(overflowY / 2 - (cellSetting.panY / 100) * (overflowY / 2));
+ if (fitMode === "contain") {
+ // Contain: fit entire image inside the cell, fill rest with background
+ const bgColor =
+ settings.backgroundColor === "transparent"
+ ? { r: 0, g: 0, b: 0, alpha: 0 }
+ : (() => {
+ const hex = settings.backgroundColor.replace("#", "");
+ return {
+ r: Number.parseInt(hex.substring(0, 2), 16),
+ g: Number.parseInt(hex.substring(2, 4), 16),
+ b: Number.parseInt(hex.substring(4, 6), 16),
+ alpha: 1,
+ };
+ })();
+ cellBuffer = await sharp(files[i].buffer)
+ .resize(Math.round(cellW * zoom), Math.round(cellH * zoom), {
+ fit: "inside",
+ withoutEnlargement: false,
+ })
+ .resize(cellW, cellH, {
+ fit: "contain",
+ background: bgColor,
+ })
+ .toBuffer();
+ } else {
+ // Cover: resize to fully fill the cell, then crop with pan offset
+ const scaleToFit = Math.max(cellW / imgW, cellH / imgH);
+ const resizedW = Math.round(imgW * scaleToFit * zoom);
+ const resizedH = Math.round(imgH * scaleToFit * zoom);
- let cellBuffer = await sharp(files[i].buffer)
- .resize(resizedW, resizedH, { fit: "fill" })
- .extract({
- left: Math.max(0, Math.min(extractLeft, resizedW - cellW)),
- top: Math.max(0, Math.min(extractTop, resizedH - cellH)),
- width: cellW,
- height: cellH,
- })
- .toBuffer();
+ const overflowX = Math.max(0, resizedW - cellW);
+ const overflowY = Math.max(0, resizedH - cellH);
+ const extractLeft = Math.round(
+ overflowX / 2 - (cellSetting.panX / 100) * (overflowX / 2),
+ );
+ const extractTop = Math.round(overflowY / 2 - (cellSetting.panY / 100) * (overflowY / 2));
+
+ cellBuffer = await sharp(files[i].buffer)
+ .resize(resizedW, resizedH, { fit: "fill" })
+ .extract({
+ left: Math.max(0, Math.min(extractLeft, resizedW - cellW)),
+ top: Math.max(0, Math.min(extractTop, resizedH - cellH)),
+ width: cellW,
+ height: cellH,
+ })
+ .toBuffer();
+ }
// Apply corner radius via SVG mask if needed
if (settings.cornerRadius > 0) {
diff --git a/apps/web/src/components/tools/collage-preview.tsx b/apps/web/src/components/tools/collage-preview.tsx
index 5769e662..fbf27c22 100644
--- a/apps/web/src/components/tools/collage-preview.tsx
+++ b/apps/web/src/components/tools/collage-preview.tsx
@@ -11,7 +11,17 @@ import {
useSensors,
} from "@dnd-kit/core";
import { useDrag, usePinch } from "@use-gesture/react";
-import { Download, GripVertical, ImagePlus, Loader2, RotateCcw, Upload, X } from "lucide-react";
+import {
+ Download,
+ Expand,
+ GripVertical,
+ ImagePlus,
+ Loader2,
+ Maximize,
+ RotateCcw,
+ Upload,
+ X,
+} from "lucide-react";
import { type DragEvent, useCallback, useEffect, useRef, useState } from "react";
import { type CollageTemplate, getTemplateById } from "@/lib/collage-templates";
import { cn } from "@/lib/utils";
@@ -373,6 +383,15 @@ function CollageCell({
[cellIndex, store],
);
+ const handleToggleFit = useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+ const next = transform.objectFit === "cover" ? "contain" : "cover";
+ store.setCellTransform(cellIndex, { objectFit: next });
+ },
+ [cellIndex, store, transform.objectFit],
+ );
+
const handleReset = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
@@ -431,7 +450,10 @@ function CollageCell({
src={displayUrl(image)}
alt=""
draggable={false}
- className="w-full h-full object-cover select-none pointer-events-none"
+ className={cn(
+ "w-full h-full select-none pointer-events-none",
+ transform.objectFit === "contain" ? "object-contain" : "object-cover",
+ )}
style={{
transform: `translate(${transform.panX}%, ${transform.panY}%) scale(${transform.zoom})`,
}}
@@ -484,6 +506,23 @@ function CollageCell({
{transform.zoom.toFixed(1)}x
+