← back to Wallpaper Tour

stitch.mjs

64 lines

#!/usr/bin/env node
// Stitch all clips/*.webm into one MP4 with title overlay per clip.
// Usage: node stitch.mjs [outname]
import fs from 'node:fs';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import os from 'node:os';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUTNAME = process.argv[2] || 'wallpaper-portfolio-tour.mp4';
const OUT = path.join(__dirname, OUTNAME);
const SHIPPED = path.join(os.homedir(), 'Videos', 'shipped-apps');

const CLIPS_DIR_ = path.join(__dirname, 'clips');
const clips = fs.readdirSync(CLIPS_DIR_)
  .filter(f => f.endsWith('.webm'))
  .map(f => ({ f, full: path.join(CLIPS_DIR_, f), size: fs.statSync(path.join(CLIPS_DIR_, f)).size }))
  .filter(c => c.size > 100_000)  // skip corrupt tiny clips
  .sort((a, b) => a.f.localeCompare(b.f, undefined, { numeric: true }))
  .map(c => c.full);

if (!clips.length) { console.error('no clips'); process.exit(1); }
console.log(`stitching ${clips.length} clips → ${OUT}`);

const concatFile = path.join(__dirname, '.concat.txt');
fs.writeFileSync(concatFile, clips.map(c => `file '${c}'`).join('\n'));

const args = [
  '-f', 'concat', '-safe', '0', '-i', concatFile,
  '-vf', 'scale=1600:900:force_original_aspect_ratio=decrease,pad=1600:900:(ow-iw)/2:(oh-ih)/2:color=black,fps=30',
  '-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
  '-pix_fmt', 'yuv420p',
  '-an',
  '-y', OUT
];

const t0 = Date.now();
const child = execFile('ffmpeg', args, { maxBuffer: 64 * 1024 * 1024 }, (err, _, stderr) => {
  fs.unlinkSync(concatFile);
  if (err) {
    console.error('ffmpeg failed:', err.message);
    console.error(stderr.toString().slice(-1000));
    process.exit(1);
  }
  const ms = Date.now() - t0;
  const stat = fs.statSync(OUT);
  console.log(`✓ ${OUT}`);
  console.log(`  ${(stat.size / 1024 / 1024).toFixed(1)} MB in ${(ms / 1000).toFixed(1)}s`);

  // Stage to ~/Videos/shipped-apps for auto-publish to videos.agentabrams.com
  fs.mkdirSync(SHIPPED, { recursive: true });
  const staged = path.join(SHIPPED, OUTNAME);
  fs.copyFileSync(OUT, staged);
  console.log(`✓ staged for videos.agentabrams.com: ${staged}`);
});

child.stderr?.on('data', (d) => {
  const s = d.toString();
  // ffmpeg progress lines start with "frame=" — print the latest only
  const m = s.match(/frame=\s*\d+.*?time=\s*[\d:.]+\s*bitrate=\s*[\d.]+\s*\w+\/?\w*\s*speed=\s*[\d.]+x?/);
  if (m) process.stdout.write(`\r  ${m[0]}    `);
});