// Render one filmstrip per song: N frames evenly across the track, tiled. // // node tools/strip.js --songs drone,runner,dusk // node tools/strip.js --songs drone --cols 6 --rows 2 --w 480 --h 270 // // Frames come from the same Run as a full render, and the sim is advanced // through every frame in between, so a strip cannot show a world that the // video would not have. import { writeFileSync, mkdirSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadSong } from './load.js'; import { Run } from '../src/film.js'; import { encodePNG, tile } from '../src/render/png.js'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(HERE, '..'); const BANK = resolve(ROOT, '../flow-state/test/songs'); function args(argv) { const out = {}; for (let i = 2; i < argv.length; i += 2) out[argv[i].replace(/^--/, '')] = argv[i + 1]; return out; } const opt = args(process.argv); const names = (opt.songs || 'drone,runner,dusk').split(',').map((s) => s.trim()).filter(Boolean); const seed = Number(opt.seed || 12345); const cols = Number(opt.cols || 4); const rows = Number(opt.rows || 3); const width = Number(opt.w || 480); const height = Number(opt.h || 270); const fps = 30; const everything = opt.combined === '1'; const outDir = resolve(ROOT, opt.out || 'out'); mkdirSync(outDir, { recursive: true }); const count = cols * rows; const all = []; for (const name of names) { const { song, analysis } = loadSong(resolve(BANK, `${name}.wav`), { fps, seed }); const run = new Run(song, { fps, width, height, seed }); const frames = []; for (let i = 0; i < count; i++) { const t = (0.10 + 0.85 * (i / Math.max(1, count - 1))) * analysis.duration; frames.push(Math.min(analysis.frames - 1, Math.round(t * fps))); } const rendered = run.renderAt(frames); const shots = frames.map((f) => rendered.get(f)); const zooms = shots.map((s) => `${s.cam.zoom.toFixed(1)}@${s.shot}`).join(' '); const sheet = tile(shots, cols, rows, 2); const file = resolve(outDir, `strip-${name}.png`); writeFileSync(file, encodePNG(sheet.w, sheet.h, sheet.data)); const stats = run.world.stats(); console.log(`${file} ${sheet.w}x${sheet.h} ${song.name} peak=${stats.peak} pop=${run.world.count} hue=${song.palette.hue.toFixed(2)} key=${song.summary.key}`); console.log(` shots: ${zooms}`); all.push({ name, song, shots, stats }); } if (everything) { // One row per song, same fractions of each track: the comparison sheet. const per = 4; const chosen = []; for (const entry of all) { for (let i = 0; i < per; i++) chosen.push(entry.shots[Math.round((i / (per - 1)) * (entry.shots.length - 1))]); } const sheet = tile(chosen, per, all.length, 3); const file = resolve(outDir, 'strip-all.png'); writeFileSync(file, encodePNG(sheet.w, sheet.h, sheet.data)); console.log(`${file} ${sheet.w}x${sheet.h}`); }