Zum Inhalt

scripts/render-readme-videos.js

// Renders the docs/examples/*.webm loops from the actual built-in presets
// (perlin-presets.js) through the full render pipeline (perlin.js
// generatePerlinImage via a headless mock ctx) and pipes RGB frames to
// ffmpeg (libvpx-vp9). Mathematically seamless: loopProgress goes from 0 to
// (N-1)/N; ffmpeg output played with `<video loop>` snaps back to the start
// → perfect loop.
//
//   node scripts/render-readme-videos.js
//
// Requires ffmpeg on PATH (`brew install ffmpeg`).
"use strict";

const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");

const REPO = path.resolve(__dirname, "..");
global.window = {};
require(path.join(REPO, "perlin.js"));
require(path.join(REPO, "perlin-presets.js"));
const { generatePerlinImage } = global.window.MTPerlin;
const { BUILTIN_PRESETS, resolveParams } = global.window.MTPerlinPresets;

// Render one frame through the full pipeline into a packed RGB buffer
// (alpha dropped — ffmpeg wants rgb24; presets don't use the mask).
function renderFrameRGB(params) {
  const ctx = {
    createImageData: (w, h) => ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }),
    _img: null,
    putImageData(img) { this._img = img; },
  };
  generatePerlinImage(params, ctx);
  const rgba = ctx._img.data;
  const rgb = Buffer.alloc(params.width * params.height * 3);
  for (let i = 0, j = 0; i < rgba.length; i += 4) {
    rgb[j++] = rgba[i]; rgb[j++] = rgba[i + 1]; rgb[j++] = rgba[i + 2];
  }
  return rgb;
}

// Encodes a sequence of RGB frames into a VP9 WebM via ffmpeg.
function encodeWebm(width, height, fps, frames, outPath) {
  return new Promise((resolve, reject) => {
    const ff = spawn("ffmpeg", [
      "-y",
      "-f", "rawvideo",
      "-pixel_format", "rgb24",
      "-video_size", `${width}x${height}`,
      "-framerate", String(fps),
      "-i", "pipe:0",
      "-c:v", "libvpx-vp9",
      "-b:v", "0",
      "-crf", "34",
      "-pix_fmt", "yuv420p",
      "-deadline", "good",
      "-cpu-used", "2",
      "-row-mt", "1",
      outPath,
    ], { stdio: ["pipe", "inherit", "pipe"] });

    let errOut = "";
    ff.stderr.on("data", (d) => { errOut += d.toString(); });
    ff.on("error", reject);
    ff.on("close", (code) => {
      if (code === 0) resolve();
      else reject(new Error(`ffmpeg exited ${code}\n${errOut.slice(-800)}`));
    });

    (async () => {
      for (const frame of frames) {
        if (!ff.stdin.write(frame)) {
          await new Promise((r) => ff.stdin.once("drain", r));
        }
      }
      ff.stdin.end();
    })().catch(reject);
  });
}

// ---------- Jobs ----------
const OUTDIR = path.join(REPO, "docs", "examples");
fs.mkdirSync(OUTDIR, { recursive: true });

const W = 480, H = 270, FPS = 24, FRAMES = 60; // 2.5 s loop

const JOBS = [
  { out: "lava-loop.webm",    preset: "lava" },
  { out: "aurora-loop.webm",  preset: "aurora" },
  { out: "terrain-loop.webm", preset: "terrain" },
];

(async () => {
  for (const job of JOBS) {
    const base = BUILTIN_PRESETS[job.preset];
    if (!base) throw new Error("unknown preset: " + job.preset);
    const t0 = Date.now();
    const frames = [];
    for (let i = 0; i < FRAMES; i++) {
      frames.push(renderFrameRGB(resolveParams({ ...base, width: W, height: H, loop: true, loopProgress: i / FRAMES })));
    }
    const tRender = Date.now();
    const outPath = path.join(OUTDIR, job.out);
    await encodeWebm(W, H, FPS, frames, outPath);
    const kb = (fs.statSync(outPath).size / 1024).toFixed(1);
    console.log(`✓ ${job.out.padEnd(22)} ${job.preset.padEnd(8)} ${FRAMES} frames  ${kb} KB  render ${tRender - t0}ms  encode ${Date.now() - tRender}ms`);
  }
  console.log("all loops written →", OUTDIR);
})().catch((e) => { console.error(e); process.exit(1); });