diff --git a/site/public/film/ely-office.mp4 b/site/public/film/ely-office.mp4 index 339fed1..266225a 100644 Binary files a/site/public/film/ely-office.mp4 and b/site/public/film/ely-office.mp4 differ diff --git a/site/video/montage/recs.json b/site/video/montage/recs.json new file mode 100644 index 0000000..14b3dc8 --- /dev/null +++ b/site/video/montage/recs.json @@ -0,0 +1,41 @@ +{ + "first": 2057, + "apps": [ + { + "app": "ElyMark", + "rec": "mark", + "from": 7.0, + "beat": 128 + }, + { + "app": "ElyTable", + "rec": "table", + "from": 1.6, + "beat": 128.25 + }, + { + "app": "ElyShow", + "rec": "show", + "from": 12.2, + "beat": 128.5 + }, + { + "app": "ElyDoc", + "rec": "doc", + "from": 3.6, + "beat": 128.75 + }, + { + "app": "ElyPDF", + "rec": "pdf", + "from": 8.8, + "beat": 129 + }, + { + "app": "ElyTree", + "rec": "tree", + "from": 11.0, + "beat": 129 + } + ] +} diff --git a/site/video/montage/scene.ts b/site/video/montage/scene.ts index 9d73b8f..9a39e21 100644 --- a/site/video/montage/scene.ts +++ b/site/video/montage/scene.ts @@ -2,27 +2,19 @@ import { model } from "../../src/model.ts"; import { type Box, SCREEN, screens } from "../scenes/parts.ts"; import { BEAT, glide, toward } from "../scenes/time.ts"; +import recs from "./recs.json"; const WIDTH = 1920; const HEIGHT = 1080; const FPS = 30; /** Bar 33 downbeat to the frame before bar 35. */ -export const FIRST = 2057; +export const FIRST = recs.first; export const COUNT = 129; const TURN: [number, number] = [128, 136]; const PIVOT = [8, 5.5]; const GROW = 1.5; const TOP = model[0].blocks[0].h; - -/** Recording file, its start second, and landing beat. */ -export const apps = [ - { app: "ElyMark", rec: "mark", from: 7.0, beat: 128 }, - { app: "ElyTable", rec: "table", from: 1.6, beat: 128.25 }, - { app: "ElyShow", rec: "show", from: 12.2, beat: 128.5 }, - { app: "ElyDoc", rec: "doc", from: 3.6, beat: 128.75 }, - { app: "ElyPDF", rec: "pdf", from: 8.8, beat: 129 }, - { app: "ElyTree", rec: "tree", from: 11.0, beat: 129 }, -]; +const apps = recs.apps; /** A screen grown about its bottom centre; Show on its base. */ function slab(app: string): Box { diff --git a/site/video/scenes/page.ts b/site/video/scenes/page.ts index 5290557..9e6c8dc 100644 --- a/site/video/scenes/page.ts +++ b/site/video/scenes/page.ts @@ -5,7 +5,8 @@ import { ACCENT, model } from "../../src/model.ts"; import { type Box, DESK, PAPER, SCREEN, SHEET, lamp, props, screens, tiles } from "./parts.ts"; import { BAR, BEAT, glide, toward } from "./time.ts"; import { DESK_END, type Moment, SEED, TURN, moment } from "./timeline.ts"; -import { montage, turnedBlock, turnedOrder } from "./turn.ts"; +import { film, montage, slabs, turnedBlock, turnedOrder } from "./turn.ts"; +import recs from "../montage/recs.json"; import { view } from "../opening/plan.ts"; export const WIDTH = 1920; @@ -166,14 +167,23 @@ const svg = document.querySelector("#frame"); const world = document.querySelector("#world"); if (!svg || !world) throw new Error("index.html lacks #frame or #world"); -function draw(m: Moment, cam: Camera) { +/** Draws the moment; slabs carry the montage recordings at frame `f`. */ +function draw(m: Moment, cam: Camera, f: number): Promise[] { const page = mix(PAGE[0], PAGE[1], m.dark); document.body.style.background = page; svg!.style.background = page; world!.setAttribute("transform", `matrix(${cam.s} 0 0 ${cam.s} ${cam.x} ${cam.y})`); + const loads: Promise[] = []; const groups = m.pieces.map(({ box, land, dark }) => { const toned = { ...box, tone: tone(box.tone, dark) }; const g = m.angle ? turnedBlock(toned, m.angle) : block(toned); + const slab = slabs.indexOf(box); + if (slab >= 0 && land > 0) { + const index = String(f - recs.first + 1).padStart(5, "0"); + const { el, ready } = film(box, m.angle, `rec/${recs.apps[slab].rec}/${index}.jpg`); + g.append(el); + loads.push(ready); + } if (tiles.includes(box)) g.classList.add("tile"); if (land < 1) { g.setAttribute("transform", `translate(0 ${(-UNIT * (1 - land)).toFixed(3)})`); @@ -189,6 +199,7 @@ function draw(m: Moment, cam: Camera) { const [day, dark] = el.dataset.soft === undefined ? INK : SOFT; el.style.color = mix(day, dark, m.dark); } + return loads; } /** Texts another layer draws instead. */ @@ -202,7 +213,7 @@ export interface Face { } /** Draws global frame `f` of `scene`; plates return the face. */ -function render(name: string, f: number): Face | null { +async function render(name: string, f: number): Promise { const scene = scenes.find((s) => s.name === name); if (!scene) throw new Error(`no scene ${name}`); const [first, end] = frames(scene); @@ -212,7 +223,8 @@ function render(name: string, f: number): Face | null { const beat = scene.from * 4; const m = moment(t, app ? { app, beat: beat + 1 } : undefined); const { cam, flat } = app ? push(app, t, beat) : { cam: camera(t), flat: 0 }; - draw(m, cam); + await Promise.all(draw(m, cam, f)); + await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))); if (!app) return null; const piece = m.pieces.find((p) => p.box === screens[app]); if (!piece) return { opacity: 0, flat, corners: [] }; diff --git a/site/video/scenes/render.mjs b/site/video/scenes/render.mjs index ae4e1ef..867e54d 100644 --- a/site/video/scenes/render.mjs +++ b/site/video/scenes/render.mjs @@ -1,6 +1,7 @@ // Renders iso scenes to PNG frames, faces and MP4s. import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; @@ -16,6 +17,9 @@ const hide = [["type-title", ["title"]], ["type-end", ["name", "version", "url"] .filter(([d]) => existsSync(join(extra, d))) .flatMap(([, t]) => t); +const recs = JSON.parse(readFileSync(resolve(here, "../montage/recs.json"), "utf8")); +const frames = mkdtempSync(join(tmpdir(), "slabs-")); + const server = await createServer({ root: site, configFile: false, logLevel: "error", server: { port: 0 } }); await server.listen(); const url = `${server.resolvedUrls.local[0]}video/scenes/index.html`; @@ -30,6 +34,10 @@ async function open() { page.on("console", (m) => { if (m.type() === "error") throw new Error(m.text()); }); + await page.route("**/rec/**", (route) => { + const path = new URL(route.request().url()).pathname.split("/rec/")[1]; + route.fulfill({ body: readFileSync(join(frames, path)), contentType: "image/jpeg" }); + }); await page.goto(url, { waitUntil: "networkidle" }); await page.evaluate(() => document.fonts.ready); await page.evaluate((names) => window.ely.hide(names), hide); @@ -69,6 +77,18 @@ async function scene(page, s) { return { name: s.name, app: s.app ?? null, bars: [s.from, s.to], first, frames: end - first }; } +/** The montage's recording frames, continued for the leaving slabs. */ +const files = scenes.find((s) => s.name === "files"); +if (files) { + for (const { rec, from } of recs.apps) { + mkdirSync(join(frames, rec)); + const args = ["-ss", String(from), "-i", resolve(here, `../out/rec/${rec}.mp4`), "-frames:v", String(files.range[1] - recs.first)]; + const vf = ["-vf", "fps=30,scale=1280:720:flags=lanczos", "-q:v", "2", join(frames, rec, "%05d.jpg")]; + const run = spawnSync("ffmpeg", ["-y", "-v", "error", ...args, ...vf], { stdio: "inherit" }); + if (run.status !== 0) throw new Error(`ffmpeg failed for ${rec}`); + } +} + const queue = [...scenes]; const done = []; await Promise.all( @@ -82,3 +102,4 @@ if (!wanted.length) { } await browser.close(); await server.close(); +rmSync(frames, { recursive: true, force: true }); diff --git a/site/video/scenes/turn.ts b/site/video/scenes/turn.ts index 848e7a3..c49c231 100644 --- a/site/video/scenes/turn.ts +++ b/site/video/scenes/turn.ts @@ -1,6 +1,7 @@ // The model turned about the desk centre, as the montage draws it. import { project } from "../../src/iso.ts"; import { model } from "../../src/model.ts"; +import recs from "../montage/recs.json"; import { type Box, SCREEN, screens } from "./parts.ts"; const SVG = "http://www.w3.org/2000/svg"; @@ -15,13 +16,32 @@ export function turned(a: number, x: number, y: number, z: number): [number, num } /** The montage's grown screens, which leave in bar 35. */ -export const slabs: Box[] = ["ElyMark", "ElyTable", "ElyShow", "ElyDoc", "ElyPDF", "ElyTree"].map((app) => { +export const slabs: Box[] = recs.apps.map(({ app }) => { if (app === "ElyShow") return { x: 8.6, y: 2, z: TOP + 0.25, w: 4.8, d: 0.08, h: 2.7, tone: SCREEN }; // The montage drew Tree's screen at its old depth. const b = app === "ElyTree" ? { ...screens[app], y: 7.9 } : screens[app]; return { ...b, x: b.x - b.w / 4, w: b.w * 1.5, h: b.h * 1.5 }; }); +/** A slab's recording frame, pinned affinely on its +y face. */ +export function film(b: Box, a: number, href: string): { el: SVGImageElement; ready: Promise } { + const Y = b.y + b.d; + const [o, r, d] = [turned(a, b.x, Y, b.z + b.h), turned(a, b.x + b.w, Y, b.z + b.h), turned(a, b.x, Y, b.z)]; + const [w, h] = [1280, 720]; + const el = document.createElementNS(SVG, "image"); + const m = [(r[0] - o[0]) / w, (r[1] - o[1]) / w, (d[0] - o[0]) / h, (d[1] - o[1]) / h, o[0], o[1]]; + el.setAttribute("width", String(w)); + el.setAttribute("height", String(h)); + el.setAttribute("preserveAspectRatio", "none"); + el.setAttribute("transform", `matrix(${m.map((v) => v.toFixed(5)).join(" ")})`); + const ready = new Promise((ok, fail) => { + el.addEventListener("load", () => ok()); + el.addEventListener("error", () => fail(new Error(`no frame ${href}`))); + }); + el.setAttribute("href", href); + return { el, ready }; +} + /** The montage's fixed framing, in this renderer's camera terms. */ export const montage = (() => { const boxes = [...model.flatMap((p) => p.blocks), ...slabs, ...slabs.map((b) => ({ ...b, z: b.z + 1 }))]; diff --git a/site/video/type/scenes.js b/site/video/type/scenes.js index cb69941..663ef48 100644 --- a/site/video/type/scenes.js +++ b/site/video/type/scenes.js @@ -129,7 +129,7 @@ function app(name, b) { const boxes = list.flatMap((c, i) => { const at = arrive(t, t0 + (build[i] + 1) * SIX); if (!at) return []; - const gone = lift(t, t0 + (13 + out[i]) * SIX, LAND); + const gone = lift(t, t0 + (9 + out[i]) * SIX, LAND); return [box(c, at.dz + gone.dz, at.a * gone.a)]; }); cubes(ctx, v, boxes);