Video: app names clear before the flatten; montage screens play while they lift (R2.3)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 02:31:47 -04:00
1 parent 48e83a9b45
commit 5e2cf83da8
7 files changed
+104 -18

No files matched your search

Binary file not shown.
+41
View File
@@ -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
}
]
}
+3 -11
View File
@@ -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 {
+16 -4
View File
@@ -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<SVGSVGElement>("#frame");
const world = document.querySelector<SVGGElement>("#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<void>[] {
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<void>[] = [];
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<Face | null> {
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: [] };
+22 -1
View File
@@ -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 });
+21 -1
View File
@@ -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<void> } {
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<void>((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 }))];
+1 -1
View File
@@ -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);