From da4411330f3b2e45cc3a68e7a52eec00ad8f052b Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Tue, 14 Apr 2026 20:47:36 +0800 Subject: [PATCH] feat(collage): overhaul collage tool with preview panel and enhanced settings Apply stash from feat/border-redesign branch. Rewrites collage backend with improved layout engine and adds CollagePreview results panel for interactive preview. Updates tool registry to use no-dropzone display mode with the new preview component. --- apps/api/src/routes/tools/collage.ts | 580 ++++++++++++++++-- .../src/components/tools/collage-settings.tsx | 526 +++++++++++++--- apps/web/src/lib/tool-registry.tsx | 8 +- 3 files changed, 968 insertions(+), 146 deletions(-) diff --git a/apps/api/src/routes/tools/collage.ts b/apps/api/src/routes/tools/collage.ts index aa1a4dbb..cd6b4327 100644 --- a/apps/api/src/routes/tools/collage.ts +++ b/apps/api/src/routes/tools/collage.ts @@ -9,20 +9,413 @@ import { validateImageBuffer } from "../../lib/file-validation.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; -const settingsSchema = z.object({ - layout: z.enum(["2x2", "3x3", "1x3", "2x1", "3x1", "1x2"]).default("2x2"), - gap: z.number().min(0).max(50).default(4), - backgroundColor: z - .string() - .regex(/^#[0-9a-fA-F]{6}$/) - .default("#FFFFFF"), +// ── Template definitions (mirrors the frontend) ───────────────────── +// We only need the grid proportions and cell definitions here. + +interface TemplateCell { + gridColumn: string; + gridRow: string; +} + +interface Template { + id: string; + imageCount: number; + gridTemplateColumns: string; + gridTemplateRows: string; + cells: TemplateCell[]; +} + +// Kept in sync with apps/web/src/lib/collage-templates.ts +const TEMPLATES: Template[] = [ + { + id: "2-h-equal", + imageCount: 2, + gridTemplateColumns: "1fr 1fr", + gridTemplateRows: "1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1" }, + ], + }, + { + id: "2-v-equal", + imageCount: 2, + gridTemplateColumns: "1fr", + gridTemplateRows: "1fr 1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "1", gridRow: "2" }, + ], + }, + { + id: "2-h-left-large", + imageCount: 2, + gridTemplateColumns: "2fr 1fr", + gridTemplateRows: "1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1" }, + ], + }, + { + id: "2-h-right-large", + imageCount: 2, + gridTemplateColumns: "1fr 2fr", + gridTemplateRows: "1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1" }, + ], + }, + { + id: "3-left-large", + imageCount: 3, + gridTemplateColumns: "2fr 1fr", + gridTemplateRows: "1fr 1fr", + cells: [ + { gridColumn: "1", gridRow: "1 / 3" }, + { gridColumn: "2", gridRow: "1" }, + { gridColumn: "2", gridRow: "2" }, + ], + }, + { + id: "3-right-large", + imageCount: 3, + gridTemplateColumns: "1fr 2fr", + gridTemplateRows: "1fr 1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "1", gridRow: "2" }, + { gridColumn: "2", gridRow: "1 / 3" }, + ], + }, + { + id: "3-top-large", + imageCount: 3, + gridTemplateColumns: "1fr 1fr", + gridTemplateRows: "2fr 1fr", + cells: [ + { gridColumn: "1 / 3", gridRow: "1" }, + { gridColumn: "1", gridRow: "2" }, + { gridColumn: "2", gridRow: "2" }, + ], + }, + { + id: "3-h-equal", + imageCount: 3, + gridTemplateColumns: "1fr 1fr 1fr", + gridTemplateRows: "1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1" }, + { gridColumn: "3", gridRow: "1" }, + ], + }, + { + id: "3-v-equal", + imageCount: 3, + gridTemplateColumns: "1fr", + gridTemplateRows: "1fr 1fr 1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "1", gridRow: "2" }, + { gridColumn: "1", gridRow: "3" }, + ], + }, + { + id: "4-grid", + imageCount: 4, + gridTemplateColumns: "1fr 1fr", + gridTemplateRows: "1fr 1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1" }, + { gridColumn: "1", gridRow: "2" }, + { gridColumn: "2", gridRow: "2" }, + ], + }, + { + id: "4-left-large", + imageCount: 4, + gridTemplateColumns: "2fr 1fr", + gridTemplateRows: "1fr 1fr 1fr", + cells: [ + { gridColumn: "1", gridRow: "1 / 4" }, + { gridColumn: "2", gridRow: "1" }, + { gridColumn: "2", gridRow: "2" }, + { gridColumn: "2", gridRow: "3" }, + ], + }, + { + id: "4-top-large", + imageCount: 4, + gridTemplateColumns: "1fr 1fr 1fr", + gridTemplateRows: "2fr 1fr", + cells: [ + { gridColumn: "1 / 4", gridRow: "1" }, + { gridColumn: "1", gridRow: "2" }, + { gridColumn: "2", gridRow: "2" }, + { gridColumn: "3", gridRow: "2" }, + ], + }, + { + id: "4-bottom-large", + imageCount: 4, + gridTemplateColumns: "1fr 1fr 1fr", + gridTemplateRows: "1fr 2fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1" }, + { gridColumn: "3", gridRow: "1" }, + { gridColumn: "1 / 4", gridRow: "2" }, + ], + }, + { + id: "5-top2-bottom3", + imageCount: 5, + gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr 1fr", + gridTemplateRows: "1fr 1fr", + cells: [ + { gridColumn: "1 / 4", gridRow: "1" }, + { gridColumn: "4 / 7", gridRow: "1" }, + { gridColumn: "1 / 3", gridRow: "2" }, + { gridColumn: "3 / 5", gridRow: "2" }, + { gridColumn: "5 / 7", gridRow: "2" }, + ], + }, + { + id: "5-top3-bottom2", + imageCount: 5, + gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr 1fr", + gridTemplateRows: "1fr 1fr", + cells: [ + { gridColumn: "1 / 3", gridRow: "1" }, + { gridColumn: "3 / 5", gridRow: "1" }, + { gridColumn: "5 / 7", gridRow: "1" }, + { gridColumn: "1 / 4", gridRow: "2" }, + { gridColumn: "4 / 7", gridRow: "2" }, + ], + }, + { + id: "5-left-large", + imageCount: 5, + gridTemplateColumns: "2fr 1fr", + gridTemplateRows: "1fr 1fr 1fr 1fr", + cells: [ + { gridColumn: "1", gridRow: "1 / 5" }, + { gridColumn: "2", gridRow: "1" }, + { gridColumn: "2", gridRow: "2" }, + { gridColumn: "2", gridRow: "3" }, + { gridColumn: "2", gridRow: "4" }, + ], + }, + { + id: "5-center-large", + imageCount: 5, + gridTemplateColumns: "1fr 2fr 1fr", + gridTemplateRows: "1fr 1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1 / 3" }, + { gridColumn: "3", gridRow: "1" }, + { gridColumn: "1", gridRow: "2" }, + { gridColumn: "3", gridRow: "2" }, + ], + }, + { + id: "6-grid-2x3", + imageCount: 6, + gridTemplateColumns: "1fr 1fr", + gridTemplateRows: "1fr 1fr 1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1" }, + { gridColumn: "1", gridRow: "2" }, + { gridColumn: "2", gridRow: "2" }, + { gridColumn: "1", gridRow: "3" }, + { gridColumn: "2", gridRow: "3" }, + ], + }, + { + id: "6-grid-3x2", + imageCount: 6, + gridTemplateColumns: "1fr 1fr 1fr", + gridTemplateRows: "1fr 1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1" }, + { gridColumn: "3", gridRow: "1" }, + { gridColumn: "1", gridRow: "2" }, + { gridColumn: "2", gridRow: "2" }, + { gridColumn: "3", gridRow: "2" }, + ], + }, + { + id: "6-top-large", + imageCount: 6, + gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr", + gridTemplateRows: "2fr 1fr", + cells: [ + { gridColumn: "1 / 6", gridRow: "1" }, + { gridColumn: "1", gridRow: "2" }, + { gridColumn: "2", gridRow: "2" }, + { gridColumn: "3", gridRow: "2" }, + { gridColumn: "4", gridRow: "2" }, + { gridColumn: "5", gridRow: "2" }, + ], + }, + { + id: "7-mosaic", + imageCount: 7, + gridTemplateColumns: "1fr 1fr 1fr", + gridTemplateRows: "1fr 1fr 1fr", + cells: [ + { gridColumn: "1 / 3", gridRow: "1" }, + { gridColumn: "3", gridRow: "1" }, + { gridColumn: "1", gridRow: "2" }, + { gridColumn: "2", gridRow: "2" }, + { gridColumn: "3", gridRow: "2" }, + { gridColumn: "1", gridRow: "3" }, + { gridColumn: "2 / 4", gridRow: "3" }, + ], + }, + { + id: "8-mosaic", + imageCount: 8, + gridTemplateColumns: "1fr 1fr 1fr 1fr", + gridTemplateRows: "1fr 1fr 1fr", + cells: [ + { gridColumn: "1 / 3", gridRow: "1" }, + { gridColumn: "3", gridRow: "1" }, + { gridColumn: "4", gridRow: "1" }, + { gridColumn: "1", gridRow: "2" }, + { gridColumn: "2", gridRow: "2" }, + { gridColumn: "3 / 5", gridRow: "2" }, + { gridColumn: "1 / 3", gridRow: "3" }, + { gridColumn: "3 / 5", gridRow: "3" }, + ], + }, + { + id: "9-grid", + imageCount: 9, + gridTemplateColumns: "1fr 1fr 1fr", + gridTemplateRows: "1fr 1fr 1fr", + cells: [ + { gridColumn: "1", gridRow: "1" }, + { gridColumn: "2", gridRow: "1" }, + { gridColumn: "3", gridRow: "1" }, + { gridColumn: "1", gridRow: "2" }, + { gridColumn: "2", gridRow: "2" }, + { gridColumn: "3", gridRow: "2" }, + { gridColumn: "1", gridRow: "3" }, + { gridColumn: "2", gridRow: "3" }, + { gridColumn: "3", gridRow: "3" }, + ], + }, +]; + +// ── Zod schema ──────────────��─────────────────────────────────────── + +const cellSchema = z.object({ + imageIndex: z.number().int().min(0), + 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), }); -function parseLayout(layout: string): { cols: number; rows: number } { - const [cols, rows] = layout.split("x").map(Number); - return { cols, rows }; +const settingsSchema = z.object({ + templateId: z.string(), + cells: z.array(cellSchema).optional(), + gap: z.number().min(0).max(50).default(8), + cornerRadius: z.number().min(0).max(30).default(0), + backgroundColor: z.string().default("#FFFFFF"), + aspectRatio: z.string().default("free"), + outputFormat: z.enum(["png", "jpeg", "webp"]).default("png"), + quality: z.number().min(1).max(100).default(90), +}); + +// ── Grid math ─────────────���───────────────────────────────────────── + +function parseFrValues(template: string): number[] { + return template + .trim() + .split(/\s+/) + .map((s) => { + const m = s.match(/^(\d+(?:\.\d+)?)fr$/); + return m ? Number(m[1]) : 1; + }); } +function parseGridRange(value: string, trackCount: number): [number, number] { + const parts = value.split("/").map((s) => s.trim()); + const start = Number(parts[0]) - 1; + const end = parts.length > 1 ? Number(parts[1]) - 1 : start + 1; + return [Math.max(0, start), Math.min(trackCount, end)]; +} + +interface CellRect { + x: number; + y: number; + w: number; + h: number; +} + +function computeCellRects( + tmpl: Template, + canvasW: number, + canvasH: number, + gapPx: number, +): CellRect[] { + const cols = parseFrValues(tmpl.gridTemplateColumns); + const rows = parseFrValues(tmpl.gridTemplateRows); + + const totalColGaps = (cols.length + 1) * gapPx; + const totalRowGaps = (rows.length + 1) * gapPx; + const availW = canvasW - totalColGaps; + const availH = canvasH - totalRowGaps; + + const colFrTotal = cols.reduce((s, v) => s + v, 0); + const rowFrTotal = rows.reduce((s, v) => s + v, 0); + + const colWidths = cols.map((fr) => Math.round((fr / colFrTotal) * availW)); + const rowHeights = rows.map((fr) => Math.round((fr / rowFrTotal) * availH)); + + const colStarts: number[] = [gapPx]; + for (let i = 1; i < cols.length; i++) { + colStarts.push(colStarts[i - 1] + colWidths[i - 1] + gapPx); + } + const rowStarts: number[] = [gapPx]; + for (let i = 1; i < rows.length; i++) { + rowStarts.push(rowStarts[i - 1] + rowHeights[i - 1] + gapPx); + } + + return tmpl.cells.map((cell) => { + const [cs, ce] = parseGridRange(cell.gridColumn, cols.length); + const [rs, re] = parseGridRange(cell.gridRow, rows.length); + + const x = colStarts[cs]; + const y = rowStarts[rs]; + const w = colStarts[ce - 1] + colWidths[ce - 1] - colStarts[cs]; + const h = rowStarts[re - 1] + rowHeights[re - 1] - rowStarts[rs]; + + return { x, y, w, h }; + }); +} + +function getAspectMultiplier(ar: string): number | null { + const map: Record = { + "1:1": 1, + "4:3": 3 / 4, + "3:2": 2 / 3, + "16:9": 9 / 16, + "9:16": 16 / 9, + "4:5": 5 / 4, + }; + return map[ar] ?? null; +} + +// ── Route registration ────────────���───────────────────────────────── + export function registerCollage(app: FastifyInstance) { app.post("/api/v1/tools/collage", async (request, reply) => { const files: Array<{ buffer: Buffer; filename: string }> = []; @@ -82,54 +475,143 @@ export function registerCollage(app: FastifyInstance) { } try { - const { cols, rows } = parseLayout(settings.layout); - const totalSlots = cols * rows; - - // Determine cell size based on first image - const firstMeta = await sharp(files[0].buffer).metadata(); - const cellW = firstMeta.width ?? 400; - const cellH = firstMeta.height ?? 400; - - // Canvas dimensions - const canvasW = cellW * cols + settings.gap * (cols + 1); - const canvasH = cellH * rows + settings.gap * (rows + 1); - - // Parse background color - const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16); - const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16); - const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16); - - // Create canvas - const composites: sharp.OverlayOptions[] = []; - - for (let i = 0; i < Math.min(files.length, totalSlots); i++) { - const row = Math.floor(i / cols); - const col = i % cols; - const x = settings.gap + col * (cellW + settings.gap); - const y = settings.gap + row * (cellH + settings.gap); - - const resized = await sharp(files[i].buffer) - .resize(cellW, cellH, { fit: "cover" }) - .toBuffer(); - - composites.push({ input: resized, top: y, left: x }); + // Find the template + const template = TEMPLATES.find((t) => t.id === settings.templateId); + if (!template) { + return reply.status(400).send({ error: `Unknown template: ${settings.templateId}` }); } - const result = await sharp({ + // Determine output canvas size + const BASE_SIZE = 2400; + const arMultiplier = getAspectMultiplier(settings.aspectRatio); + let canvasW: number; + let canvasH: number; + if (arMultiplier) { + if (arMultiplier > 1) { + canvasH = BASE_SIZE; + canvasW = Math.round(BASE_SIZE / arMultiplier); + } else { + canvasW = BASE_SIZE; + canvasH = Math.round(BASE_SIZE * arMultiplier); + } + } else { + // "free" - use 4:3 default + canvasW = BASE_SIZE; + canvasH = Math.round(BASE_SIZE * 0.75); + } + + const gapPx = settings.gap; + const cellRects = computeCellRects(template, canvasW, canvasH, gapPx); + + // Parse background color + const bgIsTransparent = settings.backgroundColor === "transparent"; + let bgColor: { r: number; g: number; b: number }; + if (bgIsTransparent) { + bgColor = { r: 0, g: 0, b: 0 }; + } else { + const hex = settings.backgroundColor.replace("#", ""); + bgColor = { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16), + }; + } + + const channels = bgIsTransparent ? 4 : 3; + const composites: sharp.OverlayOptions[] = []; + const cellSettings = settings.cells ?? []; + + for (let i = 0; i < cellRects.length && i < files.length; i++) { + 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 }; + + // Get image metadata for proper crop calculation + const meta = await sharp(files[i].buffer).metadata(); + const imgW = meta.width ?? cellW; + const imgH = meta.height ?? cellH; + + const zoom = Math.max(1, cellSetting.zoom); + + // 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); + + // 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)); + + 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(); + + // Apply corner radius via SVG mask if needed + if (settings.cornerRadius > 0) { + const r = settings.cornerRadius; + const mask = Buffer.from( + ` + + `, + ); + cellBuffer = await sharp(cellBuffer) + .ensureAlpha() + .composite([{ input: mask, blend: "dest-in" }]) + .toBuffer(); + } + + composites.push({ + input: cellBuffer, + top: Math.round(rect.y), + left: Math.round(rect.x), + }); + } + + // Create canvas and composite + let pipeline = sharp({ create: { width: canvasW, height: canvasH, - channels: 3, - background: { r: bgR, g: bgG, b: bgB }, + channels: channels as 3 | 4, + background: bgIsTransparent + ? { r: 0, g: 0, b: 0, alpha: 0 } + : { r: bgColor.r, g: bgColor.g, b: bgColor.b }, }, - }) - .composite(composites) - .png() - .toBuffer(); + }).composite(composites); + + // Output format + let outputExt: string; + switch (settings.outputFormat) { + case "jpeg": + pipeline = pipeline.jpeg({ quality: settings.quality }); + outputExt = "jpg"; + break; + case "webp": + pipeline = pipeline.webp({ quality: settings.quality }); + outputExt = "webp"; + break; + default: + pipeline = pipeline.png(); + outputExt = "png"; + break; + } + + const result = await pipeline.toBuffer(); const jobId = randomUUID(); const workspacePath = await createWorkspace(jobId); - const filename = "collage.png"; + const filename = `collage.${outputExt}`; const outputPath = join(workspacePath, "output", filename); await writeFile(outputPath, result); diff --git a/apps/web/src/components/tools/collage-settings.tsx b/apps/web/src/components/tools/collage-settings.tsx index 6977a81d..b58df24b 100644 --- a/apps/web/src/components/tools/collage-settings.tsx +++ b/apps/web/src/components/tools/collage-settings.tsx @@ -1,42 +1,98 @@ import { Download, Loader2 } from "lucide-react"; -import { useState } from "react"; +import { useCallback } from "react"; +import { CollapsibleSection } from "@/components/common/collapsible-section"; import { formatHeaders } from "@/lib/api"; -import { useFileStore } from "@/stores/file-store"; +import { + COLLAGE_TEMPLATES, + type CollageTemplate, + getTemplateById, + getTemplatesForCount, +} from "@/lib/collage-templates"; +import { cn } from "@/lib/utils"; +import { type AspectRatio, type OutputFormat, useCollageStore } from "@/stores/collage-store"; -type Layout = "2x2" | "3x3" | "1x3" | "2x1" | "3x1" | "1x2"; +const ASPECT_RATIOS: { value: AspectRatio; label: string }[] = [ + { value: "free", label: "Free" }, + { value: "1:1", label: "1:1" }, + { value: "4:3", label: "4:3" }, + { value: "3:2", label: "3:2" }, + { value: "16:9", label: "16:9" }, + { value: "9:16", label: "9:16" }, + { value: "4:5", label: "4:5" }, +]; -const LAYOUTS: { value: Layout; label: string }[] = [ - { value: "2x2", label: "2 x 2" }, - { value: "3x3", label: "3 x 3" }, - { value: "1x3", label: "1 x 3" }, - { value: "3x1", label: "3 x 1" }, - { value: "2x1", label: "2 x 1" }, - { value: "1x2", label: "1 x 2" }, +const OUTPUT_FORMATS: { value: OutputFormat; label: string }[] = [ + { value: "png", label: "PNG" }, + { value: "jpeg", label: "JPEG" }, + { value: "webp", label: "WebP" }, +]; + +const BG_PRESETS = [ + { id: "white" as const, label: "White", color: "#FFFFFF", border: true }, + { id: "black" as const, label: "Black", color: "#000000", border: false }, + { id: "transparent" as const, label: "None", color: "transparent", border: true }, + { id: "custom" as const, label: "Custom", color: null, border: true }, ]; export function CollageSettings() { - const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = - useFileStore(); - const [layout, setLayout] = useState("2x2"); - const [gap, setGap] = useState(4); - const [backgroundColor, setBackgroundColor] = useState("#FFFFFF"); - const [downloadUrl, setDownloadUrl] = useState(null); - const [originalSize, setOriginalSize] = useState(null); - const [processedSize, setProcessedSize] = useState(null); + const store = useCollageStore(); + const { + images, + templateId, + cellAssignments, + cellTransforms, + gap, + cornerRadius, + backgroundColor, + bgPreset, + aspectRatio, + outputFormat, + quality, + phase, + resultUrl, + resultSize, + originalSize, + error, + } = store; - const handleProcess = async () => { - if (files.length === 0) return; + const template = getTemplateById(templateId); + const imageCount = images.length; + const hasImages = imageCount > 0; - setProcessing(true); - setError(null); - setDownloadUrl(null); + const handleProcess = useCallback(async () => { + if (!hasImages || !template) return; + + store.setPhase("processing"); + store.setError(null); try { const formData = new FormData(); - for (const file of files) { - formData.append("file", file); + // Send images in cell-assignment order + for (let i = 0; i < template.cells.length; i++) { + const imgIdx = cellAssignments[i] ?? -1; + if (imgIdx >= 0 && images[imgIdx]) { + formData.append("file", images[imgIdx].file); + } } - formData.append("settings", JSON.stringify({ layout, gap, backgroundColor })); + + const cells = template.cells.map((_, i) => { + const t = cellTransforms[i] ?? { panX: 0, panY: 0, zoom: 1 }; + return { imageIndex: i, panX: t.panX, panY: t.panY, zoom: t.zoom }; + }); + + formData.append( + "settings", + JSON.stringify({ + templateId, + cells, + gap, + cornerRadius, + backgroundColor, + aspectRatio, + outputFormat, + quality, + }), + ); const res = await fetch("/api/v1/tools/collage", { method: "POST", @@ -50,93 +106,241 @@ export function CollageSettings() { } const result = await res.json(); - setJobId(result.jobId); - setProcessedUrl(result.downloadUrl); - setDownloadUrl(result.downloadUrl); - setOriginalSize(result.originalSize); - setProcessedSize(result.processedSize); - setSizes(result.originalSize, result.processedSize); + store.setResult(result.downloadUrl, result.processedSize, result.originalSize, result.jobId); } catch (err) { - setError(err instanceof Error ? err.message : "Collage failed"); - } finally { - setProcessing(false); + store.setError(err instanceof Error ? err.message : "Collage failed"); } - }; + }, [ + hasImages, + template, + store, + cellAssignments, + cellTransforms, + images, + templateId, + gap, + cornerRadius, + backgroundColor, + aspectRatio, + outputFormat, + quality, + ]); - const hasFiles = files.length > 0; + // Group templates by image count, prioritizing current count + const matchingTemplates = imageCount > 0 ? getTemplatesForCount(imageCount) : []; + const allTemplates = COLLAGE_TEMPLATES; return ( -
-
-

Layout

-
- {LAYOUTS.map((l) => ( - - ))} -
-
- -
-
- - {gap}px -
- setGap(Number(e.target.value))} - className="w-full mt-1" - /> -
- -
- - setBackgroundColor(e.target.value)} - className="w-full mt-0.5 h-8 rounded border border-border" - /> -
- - {error &&

{error}

} - - {originalSize != null && processedSize != null && ( -
-

Input total: {(originalSize / 1024).toFixed(1)} KB

-

Collage: {(processedSize / 1024).toFixed(1)} KB

+
+ {/* Image count info */} + {hasImages && ( +
+ + {imageCount} image{imageCount !== 1 ? "s" : ""} loaded + +
)} + {/* Layout templates */} + +
+ {matchingTemplates.length > 0 && ( +
+

+ Best for {imageCount} images +

+
+ {matchingTemplates.map((t) => ( + store.setTemplateId(t.id)} + /> + ))} +
+
+ )} +
+ {matchingTemplates.length > 0 && ( +

All layouts

+ )} +
+ {allTemplates.map((t) => ( + store.setTemplateId(t.id)} + /> + ))} +
+
+
+
+ + {/* Spacing & Style */} + +
+
+
+ Gap + {gap}px +
+ store.setGap(Number(e.target.value))} + className="w-full mt-1" + /> +
+ +
+
+ Corner Radius + {cornerRadius}px +
+ store.setCornerRadius(Number(e.target.value))} + className="w-full mt-1" + /> +
+ +
+ Background +
+ {BG_PRESETS.map((p) => ( +
+
+
+
+ + {/* Canvas */} + +
+ Aspect Ratio +
+ {ASPECT_RATIOS.map((ar) => ( + + ))} +
+
+
+ + {/* Output */} + +
+
+ Format +
+ {OUTPUT_FORMATS.map((f) => ( + + ))} +
+
+ + {outputFormat !== "png" && ( +
+
+ Quality + {quality}% +
+ store.setQuality(Number(e.target.value))} + className="w-full mt-1" + /> +
+ )} +
+
+ + {/* Error */} + {error &&

{error}

} + + {/* Actions */} - {downloadUrl && ( + {resultUrl && ( )} + + {/* Size info */} + {originalSize != null && resultSize != null && ( +
+

Input total: {(originalSize / 1024).toFixed(1)} KB

+

Collage: {(resultSize / 1024).toFixed(1)} KB

+
+ )}
); } + +/** Mini template thumbnail as an SVG diagram. */ +function TemplateButton({ + template, + isSelected, + onClick, +}: { + template: CollageTemplate; + isSelected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +/** Renders a mini SVG preview of a template layout. */ +function TemplateDiagram({ template, size }: { template: CollageTemplate; size: number }) { + const padding = 2; + const gap = 1.5; + const inner = size - padding * 2; + + // Parse the CSS grid template to compute cell rects + const rects = computeCellRects(template, inner, gap); + + return ( + + {rects.map((r, i) => ( + + ))} + + ); +} + +/** Parse CSS grid definitions into pixel rects for the SVG diagram. */ +function computeCellRects( + template: CollageTemplate, + size: number, + gap: number, +): Array<{ x: number; y: number; w: number; h: number }> { + const cols = parseFrValues(template.gridTemplateColumns); + const rows = parseFrValues(template.gridTemplateRows); + + const totalColGaps = (cols.length - 1) * gap; + const totalRowGaps = (rows.length - 1) * gap; + const availW = size - totalColGaps; + const availH = size - totalRowGaps; + + const colFrTotal = cols.reduce((s, v) => s + v, 0); + const rowFrTotal = rows.reduce((s, v) => s + v, 0); + + const colWidths = cols.map((fr) => (fr / colFrTotal) * availW); + const rowHeights = rows.map((fr) => (fr / rowFrTotal) * availH); + + // Compute cumulative positions + const colStarts: number[] = [0]; + for (let i = 1; i < cols.length; i++) { + colStarts.push(colStarts[i - 1] + colWidths[i - 1] + gap); + } + const rowStarts: number[] = [0]; + for (let i = 1; i < rows.length; i++) { + rowStarts.push(rowStarts[i - 1] + rowHeights[i - 1] + gap); + } + + return template.cells.map((cell) => { + const [colStart, colEnd] = parseGridRange(cell.gridColumn, cols.length); + const [rowStart, rowEnd] = parseGridRange(cell.gridRow, rows.length); + + const x = colStarts[colStart]; + const y = rowStarts[rowStart]; + const w = colStarts[colEnd - 1] + colWidths[colEnd - 1] - colStarts[colStart]; + const h = rowStarts[rowEnd - 1] + rowHeights[rowEnd - 1] - rowStarts[rowStart]; + + return { x, y, w, h }; + }); +} + +/** Parse "1fr 2fr 1fr" into [1, 2, 1]. */ +function parseFrValues(template: string): number[] { + return template + .trim() + .split(/\s+/) + .map((s) => { + const match = s.match(/^(\d+(?:\.\d+)?)fr$/); + return match ? Number(match[1]) : 1; + }); +} + +/** Parse CSS grid-column/grid-row value like "1 / 3" or "2" into [startIndex, endIndex]. */ +function parseGridRange(value: string, trackCount: number): [number, number] { + const parts = value.split("/").map((s) => s.trim()); + const start = Number(parts[0]) - 1; // CSS grid lines are 1-based + const end = parts.length > 1 ? Number(parts[1]) - 1 : start + 1; + return [Math.max(0, start), Math.min(trackCount, end)]; +} diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index 4b2eff92..5f2e25de 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -173,6 +173,9 @@ const ImageToBase64Results = lazy(() => const CollageSettings = lazy(() => import("@/components/tools/collage-settings").then((m) => ({ default: m.CollageSettings })), ); +const CollagePreview = lazy(() => + import("@/components/tools/collage-preview").then((m) => ({ default: m.CollagePreview })), +); const StitchSettings = lazy(() => import("@/components/tools/stitch-settings").then((m) => ({ default: m.StitchSettings })), ); @@ -384,7 +387,10 @@ export const toolRegistry = new Map([ ], // Layout & Composition - ["collage", { displayMode: "before-after", Settings: CollageSettings }], + [ + "collage", + { displayMode: "no-dropzone", Settings: CollageSettings, ResultsPanel: CollagePreview }, + ], ["stitch", { displayMode: "no-comparison", Settings: StitchSettings }], [ "split",