Files
zeekay 91fbe784d0 film/models: each model's concept, drawn by a shader and cut into a loop
Nine concepts to the owner's mockup, grey on true black, the subject right
of centre and the left 40% kept dark for a card's words: Enso a brushed
ensō shedding particles over water and mist, Kai three panes of glass
threaded by falling light, Zen a sphere in raked sand, Iro mountains and
cloud spilling out of a frame, Koma film frames running into depth, Oto a
mirrored waveform, Koe twisting ribbons, Uta a fan rising over strings,
Mori a wireframe peak.

Each is one fragment shader over film/models/gl.js, a pure function of the
loop's phase, so the frame after the last is the first. film.mjs steps it
on the GPU, refuses a seam, and cuts public/models/<name>.{webp,webm,mp4}:
1200x1500 for every model and a 1500x1500 Enso, 30 fps, each loop under
700 KB. Frames stay in film/models/frames (ignored).
2026-10-30 07:49:48 -07:00

159 lines
7.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The models' art: one concept per model, drawn by a fragment shader, filmed a
// frame at a time and cut into a still and a loop.
//
// node film/models/film.mjs every model
// node film/models/film.mjs enso kai these two
// node film/models/film.mjs --look frame 0 of each, quickly, no video
// node film/models/film.mjs --cut enso the frames on disk, cut again
//
// Each concept is `film/models/<slug>.html` over the shared frame in `gl.js`.
// Chromium opens it from disk on the GPU (SwiftShader when there is none),
// `draw(i / n)` paints frame i, the canvas is photographed, and ffmpeg cuts the
// frames into what the cards and the model pages play:
//
// public/models/<name>.webp frame 0, the first paint and the reduced-motion still
// public/models/<name>.webm VP9, muted, the loop
// public/models/<name>.mp4 H.264, the same loop for a browser without VP9
//
// The loop is the phase 0 → 1, and every motion in a scene turns a whole
// number of times in it, so the frame after the last is the first: the film
// checks that (`seam`) before it cuts anything. Frames land in
// film/models/frames/ (ignored); the generator and the scenes are the source.
//
// Each video is held under BUDGET bytes by raising its quantizer until it
// fits: by a step sized from how far over it came, since one VP9 cut of a
// 1200×1500 loop is minutes on a busy machine.
import { chromium } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import sharp from 'sharp'
const HERE = dirname(fileURLToPath(import.meta.url))
const ROOT = join(HERE, '..', '..')
const PUBLIC = join(ROOT, 'public', 'models')
const FRAMES = join(HERE, 'frames')
/** Frames a second, and the most a loop may weigh in either container. */
const FPS = 30
const BUDGET = 700 * 1024 * 0.97
/**
* The films. `seconds` is the loop; every 4:5 master is 1200×1500, and Enso,
* the tall card that runs square on a phone and on its own page, has a square
* master beside it.
*/
const FILMS = [
{ slug: 'enso', name: 'enso', w: 1200, h: 1500, seconds: 8 },
{ slug: 'enso', name: 'enso-square', w: 1500, h: 1500, seconds: 8 },
{ slug: 'kai', name: 'kai', w: 1200, h: 1500, seconds: 6 },
{ slug: 'zen', name: 'zen', w: 1200, h: 1500, seconds: 8 },
{ slug: 'iro', name: 'iro', w: 1200, h: 1500, seconds: 8 },
{ slug: 'koma', name: 'koma', w: 1200, h: 1500, seconds: 6 },
{ slug: 'oto', name: 'oto', w: 1200, h: 1500, seconds: 6 },
{ slug: 'koe', name: 'koe', w: 1200, h: 1500, seconds: 6 },
{ slug: 'uta', name: 'uta', w: 1200, h: 1500, seconds: 6 },
{ slug: 'mori', name: 'mori', w: 1200, h: 1500, seconds: 8 },
]
const args = process.argv.slice(2)
const look = args.includes('--look')
const again = args.includes('--cut')
const asked = args.filter((a) => !a.startsWith('--'))
const films = FILMS.filter((f) => !asked.length || asked.includes(f.slug) || asked.includes(f.name))
const ffmpeg = (...a) => execFileSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', ...a], { stdio: 'inherit' })
const size = (file) => statSync(file).size
const pad = (i) => String(i).padStart(4, '0')
/** The largest difference between two pictures, 0–255. */
async function apart(a, b) {
const [x, y] = await Promise.all([sharp(a).greyscale().raw().toBuffer(), sharp(b).greyscale().raw().toBuffer()])
let most = 0
for (let i = 0; i < x.length; i++) most = Math.max(most, Math.abs(x[i] - y[i]))
return most
}
/** Cut the frames into a container, raising the quantizer until it fits the budget. */
function cut(dir, out, codec) {
const input = ['-framerate', String(FPS), '-i', join(dir, '%04d.png')]
const grey = ['-vf', 'format=yuv420p', '-an']
for (let q = codec === 'vp9' ? 31 : 22; q <= 63; ) {
if (codec === 'vp9') {
const log = join(dir, 'vp9')
const common = [...input, ...grey, '-c:v', 'libvpx-vp9', '-b:v', '0', '-crf', String(q), '-row-mt', '1', '-tile-columns', '2', '-auto-alt-ref', '1', '-lag-in-frames', '25', '-passlogfile', log]
ffmpeg(...common, '-pass', '1', '-cpu-used', '4', '-f', 'webm', '/dev/null')
ffmpeg(...common, '-pass', '2', '-cpu-used', '2', out)
} else {
ffmpeg(...input, ...grey, '-c:v', 'libx264', '-preset', 'slower', '-tune', 'film', '-crf', String(q), '-profile:v', 'high', '-movflags', '+faststart', out)
}
const bytes = size(out)
if (bytes <= BUDGET) return { q, bytes }
// Each six steps of quantizer roughly halves a loop's bytes.
q += Math.max(2, Math.ceil(6 * Math.log2(bytes / BUDGET)) + 1)
}
throw new Error(`${out} does not fit ${BUDGET} bytes at any quantizer`)
}
/** The still and the two loops, into public/models. */
async function publish(film, dir, n, seam, started) {
mkdirSync(PUBLIC, { recursive: true })
const still = join(PUBLIC, `${film.name}.webp`)
await sharp(join(dir, '0000.png')).greyscale().webp({ quality: 82 }).toFile(still)
const webm = cut(dir, join(PUBLIC, `${film.name}.webm`), 'vp9')
const mp4 = cut(dir, join(PUBLIC, `${film.name}.mp4`), 'h264')
const line = `${film.name}: ${n} frames in ${((Date.now() - started) / 1000).toFixed(0)} s, seam ${seam}, webp ${size(still)}, webm ${webm.bytes} (crf ${webm.q}), mp4 ${mp4.bytes} (crf ${mp4.q})`
console.log(line)
return line
}
const browser = await chromium.launch({ args: ['--use-angle=vulkan', '--enable-gpu', '--ignore-gpu-blocklist'] })
const report = []
try {
for (const film of films) {
const n = look ? 1 : film.seconds * FPS
const dir = join(FRAMES, film.name)
const started = Date.now()
if (again && existsSync(join(dir, 'seam.png')) && existsSync(join(dir, `${pad(n - 1)}.png`))) {
const seam = await apart(join(dir, '0000.png'), join(dir, 'seam.png'))
if (seam > 3) throw new Error(`${film.name}: the loop has a seam (${seam}/255 apart)`)
report.push(await publish(film, dir, n, seam, started))
continue
}
rmSync(dir, { recursive: true, force: true })
mkdirSync(dir, { recursive: true })
const page = await browser.newPage({ viewport: { width: film.w, height: film.h }, deviceScaleFactor: 1 })
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
const url = `${pathToFileURL(join(HERE, `${film.slug}.html`)).href}?render&w=${film.w}&h=${film.h}`
await page.goto(url)
await page.waitForSelector('body[data-ready]', { timeout: 30_000 }).catch(() => {
throw new Error(`${film.slug}: ${errors.join('\n') || 'never drew'}`)
})
const canvas = page.locator('canvas')
const shoot = async (phase, file) => {
await page.evaluate((p) => window.draw(p), phase)
writeFileSync(file, await canvas.screenshot({ type: 'png' }))
}
for (let i = 0; i < n; i++) await shoot(i / n, join(dir, `${pad(i)}.png`))
if (look) {
mkdirSync(join(FRAMES, 'look'), { recursive: true })
await sharp(join(dir, '0000.png')).toFile(join(FRAMES, 'look', `${film.name}.png`))
console.log(`${film.name}: frame 0 in ${Date.now() - started} ms`)
await page.close()
continue
}
// The seam: the frame after the last must be the first.
await shoot(1, join(dir, 'seam.png'))
const seam = await apart(join(dir, '0000.png'), join(dir, 'seam.png'))
if (seam > 3) throw new Error(`${film.name}: the loop has a seam (${seam}/255 apart)`)
await page.close()
report.push(await publish(film, dir, n, seam, started))
}
} finally {
await browser.close()
}
if (report.length) console.log(`\n${report.join('\n')}`)