code icon Code

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);
});