Stitch Comic Strip
Combine comic panels into a grid layout like a real comic page
Source Code
import fs from "fs";
import path from "path";
import sharp from "sharp";
const [inputDir, outputPath, columns] = process.argv.slice(2);
if (!inputDir || !outputPath) {
console.error("Usage: inputDir outputPath [columns]");
process.exit(1);
}
async function main() {
// Find all panel images in order
const files = fs.readdirSync(inputDir);
const panelFiles = files
.filter((f) => /^panel-\d+\.(png|jpg|jpeg|webp)$/i.test(f))
.sort((a, b) => {
const numA = parseInt(a.match(/panel-(\d+)/)[1]);
const numB = parseInt(b.match(/panel-(\d+)/)[1]);
return numA - numB;
});
if (panelFiles.length === 0) {
console.error("No panel images found in directory");
process.exit(1);
}
console.log(`Found ${panelFiles.length} panels to stitch`);
// Calculate grid dimensions
const panelCount = panelFiles.length;
let cols = columns ? parseInt(columns) : null;
if (!cols) {
// Comic grid layout defaults
if (panelCount <= 2) cols = 2;
else if (panelCount <= 4) cols = 2;
else if (panelCount <= 6) cols = 3;
else if (panelCount <= 9) cols = 3;
else cols = 4;
}
const rows = Math.ceil(panelCount / cols);
console.log(`Grid: ${cols} columns × ${rows} rows`);
// Get dimensions from first panel
const firstPanelPath = path.join(inputDir, panelFiles[0]);
const firstPanelMeta = await sharp(firstPanelPath).metadata();
const panelWidth = firstPanelMeta.width;
const panelHeight = firstPanelMeta.height;
console.log(`Panel size: ${panelWidth}×${panelHeight}`);
// Comic styling
const gutter = 16; // Space between panels
const border = 4; // Black border around each panel
const margin = 32; // Outer margin
// Canvas dimensions
const canvasWidth = margin * 2 + cols * panelWidth + (cols - 1) * gutter;
const canvasHeight = margin * 2 + rows * panelHeight + (rows - 1) * gutter;
console.log(`Canvas: ${canvasWidth}×${canvasHeight}`);
// Load all panels first
const panelBuffers = [];
for (const file of panelFiles) {
const panelPath = path.join(inputDir, file);
const buffer = await sharp(panelPath)
.resize(panelWidth, panelHeight, { fit: "cover" })
.toBuffer();
panelBuffers.push(buffer);
}
// Build composite array with borders and panels
const composites = [];
for (let i = 0; i < panelBuffers.length; i++) {
const row = Math.floor(i / cols);
const col = i % cols;
// Calculate position
const x = margin + col * (panelWidth + gutter);
const y = margin + row * (panelHeight + gutter);
console.log(` Panel ${i + 1}: row=${row}, col=${col}, position=(${x}, ${y})`);
// Black border (slightly larger rectangle behind the panel)
const borderBuffer = await sharp({
create: {
width: panelWidth + border * 2,
height: panelHeight + border * 2,
channels: 3,
background: { r: 0, g: 0, b: 0 },
},
})
.jpeg()
.toBuffer();
composites.push({
input: borderBuffer,
left: x - border,
top: y - border,
});
// The panel itself
composites.push({
input: panelBuffers[i],
left: x,
top: y,
});
}
// Ensure output directory exists
const outDir = path.dirname(outputPath);
if (outDir && outDir !== ".") {
fs.mkdirSync(outDir, { recursive: true });
}
// Create white canvas and composite everything
await sharp({
create: {
width: canvasWidth,
height: canvasHeight,
channels: 3,
background: { r: 255, g: 255, b: 255 },
},
})
.composite(composites)
.png()
.toFile(outputPath);
console.log(`\n✓ Comic grid created: ${outputPath}`);
console.log(` ${panelCount} panels in ${cols}×${rows} grid`);
console.log(
JSON.stringify({
success: true,
outputPath,
panelCount,
columns: cols,
rows,
width: canvasWidth,
height: canvasHeight,
})
);
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});