mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
This commit is contained in:
@@ -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<string, number> = {
|
||||
"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(
|
||||
`<svg width="${cellW}" height="${cellH}">
|
||||
<rect x="0" y="0" width="${cellW}" height="${cellH}" rx="${r}" ry="${r}" fill="white"/>
|
||||
</svg>`,
|
||||
);
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<Layout>("2x2");
|
||||
const [gap, setGap] = useState(4);
|
||||
const [backgroundColor, setBackgroundColor] = useState("#FFFFFF");
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [originalSize, setOriginalSize] = useState<number | null>(null);
|
||||
const [processedSize, setProcessedSize] = useState<number | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Layout</p>
|
||||
<div className="grid grid-cols-3 gap-1 mt-1">
|
||||
{LAYOUTS.map((l) => (
|
||||
<div className="space-y-3">
|
||||
{/* Image count info */}
|
||||
{hasImages && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{imageCount} image{imageCount !== 1 ? "s" : ""} loaded
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
key={l.value}
|
||||
onClick={() => setLayout(l.value)}
|
||||
className={`text-xs py-1.5 rounded ${layout === l.value ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
onClick={() => store.clearImages()}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{l.label}
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Layout templates */}
|
||||
<CollapsibleSection title="Layout" badge={template?.label} defaultOpen>
|
||||
<div className="space-y-2">
|
||||
{matchingTemplates.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5">
|
||||
Best for {imageCount} images
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{matchingTemplates.map((t) => (
|
||||
<TemplateButton
|
||||
key={t.id}
|
||||
template={t}
|
||||
isSelected={templateId === t.id}
|
||||
onClick={() => store.setTemplateId(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{matchingTemplates.length > 0 && (
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5">All layouts</p>
|
||||
)}
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{allTemplates.map((t) => (
|
||||
<TemplateButton
|
||||
key={t.id}
|
||||
template={t}
|
||||
isSelected={templateId === t.id}
|
||||
onClick={() => store.setTemplateId(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Spacing & Style */}
|
||||
<CollapsibleSection title="Spacing & Style" defaultOpen>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="collage-gap" className="text-xs text-muted-foreground">
|
||||
Gap
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground">Gap</span>
|
||||
<span className="text-xs font-mono text-foreground">{gap}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="collage-gap"
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={gap}
|
||||
onChange={(e) => setGap(Number(e.target.value))}
|
||||
onChange={(e) => store.setGap(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="collage-background-color" className="text-xs text-muted-foreground">
|
||||
Background Color
|
||||
</label>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Corner Radius</span>
|
||||
<span className="text-xs font-mono text-foreground">{cornerRadius}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="collage-background-color"
|
||||
type="color"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
className="w-full mt-0.5 h-8 rounded border border-border"
|
||||
type="range"
|
||||
min={0}
|
||||
max={30}
|
||||
value={cornerRadius}
|
||||
onChange={(e) => store.setCornerRadius(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Background</span>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{BG_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => store.setBgPreset(p.id)}
|
||||
className={cn(
|
||||
"w-7 h-7 rounded-md transition-all",
|
||||
bgPreset === p.id && "ring-2 ring-primary ring-offset-1",
|
||||
p.border && "border border-border",
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
p.id === "transparent"
|
||||
? "repeating-conic-gradient(#e0e0e0 0% 25%, #fff 0% 50%) 0 0 / 8px 8px"
|
||||
: p.id === "custom"
|
||||
? backgroundColor
|
||||
: (p.color ?? undefined),
|
||||
}}
|
||||
title={p.label}
|
||||
/>
|
||||
))}
|
||||
{bgPreset === "custom" && (
|
||||
<input
|
||||
type="color"
|
||||
value={backgroundColor === "transparent" ? "#FFFFFF" : backgroundColor}
|
||||
onChange={(e) => store.setBackgroundColor(e.target.value)}
|
||||
className="w-7 h-7 rounded border border-border cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Input total: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Collage: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
{/* Canvas */}
|
||||
<CollapsibleSection title="Canvas" badge={aspectRatio === "free" ? undefined : aspectRatio}>
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Aspect Ratio</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{ASPECT_RATIOS.map((ar) => (
|
||||
<button
|
||||
key={ar.value}
|
||||
type="button"
|
||||
onClick={() => store.setAspectRatio(ar.value)}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs rounded-md transition-colors",
|
||||
aspectRatio === ar.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
)}
|
||||
>
|
||||
{ar.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Output */}
|
||||
<CollapsibleSection title="Output" badge={outputFormat.toUpperCase()}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Format</span>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{OUTPUT_FORMATS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
onClick={() => store.setOutputFormat(f.value)}
|
||||
className={cn(
|
||||
"flex-1 py-1.5 text-xs rounded-md transition-colors",
|
||||
outputFormat === f.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{outputFormat !== "png" && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Quality</span>
|
||||
<span className="text-xs font-mono text-foreground">{quality}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={quality}
|
||||
onChange={(e) => store.setQuality(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="collage-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFiles || processing}
|
||||
disabled={!hasImages || phase === "processing"}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Creating..." : `Create Collage (${files.length} images)`}
|
||||
{phase === "processing" && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{phase === "processing" ? "Creating..." : `Create Collage (${imageCount} images)`}
|
||||
</button>
|
||||
|
||||
{downloadUrl && (
|
||||
{resultUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
href={resultUrl}
|
||||
download
|
||||
data-testid="collage-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
@@ -145,6 +349,136 @@ export function CollageSettings() {
|
||||
Download Collage
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && resultSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Input total: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Collage: {(resultSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mini template thumbnail as an SVG diagram. */
|
||||
function TemplateButton({
|
||||
template,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
template: CollageTemplate;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 p-1 rounded-md border transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-primary/50 bg-muted/30",
|
||||
)}
|
||||
title={`${template.label} (${template.imageCount} images)`}
|
||||
>
|
||||
<TemplateDiagram template={template} size={40} />
|
||||
<span className="text-[9px] text-muted-foreground leading-tight">{template.imageCount}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
role="img"
|
||||
aria-label={template.label}
|
||||
>
|
||||
{rects.map((r, i) => (
|
||||
<rect
|
||||
key={`${template.id}-${i}`}
|
||||
x={padding + r.x}
|
||||
y={padding + r.y}
|
||||
width={r.w}
|
||||
height={r.h}
|
||||
rx={1}
|
||||
className="fill-current text-muted-foreground/40"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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)];
|
||||
}
|
||||
|
||||
@@ -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<string, ToolRegistryEntry>([
|
||||
],
|
||||
|
||||
// Layout & Composition
|
||||
["collage", { displayMode: "before-after", Settings: CollageSettings }],
|
||||
[
|
||||
"collage",
|
||||
{ displayMode: "no-dropzone", Settings: CollageSettings, ResultsPanel: CollagePreview },
|
||||
],
|
||||
["stitch", { displayMode: "no-comparison", Settings: StitchSettings }],
|
||||
[
|
||||
"split",
|
||||
|
||||
Reference in New Issue
Block a user