mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
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).
159 lines
7.5 KiB
JavaScript
159 lines
7.5 KiB
JavaScript
// 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')}`)
|