The hero gets its own film, shot bare and encoded for a page that autoplays it
`hero-soma` is the landing backdrop: SoMa, midnight to midnight, 180 frames at eight minutes each so the loop closes without repeating a minute. It is the only film shot `chrome: "bare"` — the figures keep the app's panel because its clock is their caption, but a hero has a headline on top and a second interface under that is two interfaces arguing. It is also the only one that runs a full 24 hours, because the page seeks it to the reader's local hour and a film starting at 04:40 has no frame for somebody at two in the morning. Whole-board was the first camera and was wrong: at that standoff the edge of the terrain plate stands against the sky across the top of the frame, and midday is a lot of pale sand. Encoding is two-stage now — frames to a CRF 18 master, deliverables derived from it. A time-lapse is near the worst case for inter-frame compression, because the camera never moves but every pixel changes as the light does, and at the figure default of CRF 22 this reel came out at 2.5 MB. Deriving from a master turned 'try CRF 28' into ten seconds instead of a twenty-two minute re-shoot, and it keeps what ships identical to what this script makes rather than something hand-rolled with ffmpeg the day the size became a problem. It lands at 1440w CRF 28 (1.0 MB) plus a 960w CRF 30 (402 kB) for small screens, because <video> has no srcset and a phone has no use for the wide encode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+163
-26
@@ -50,6 +50,40 @@ const ROOT = join(HERE, "..", "..");
|
|||||||
|
|
||||||
// ---- The films --------------------------------------------------------------
|
// ---- The films --------------------------------------------------------------
|
||||||
|
|
||||||
|
const VIEWPORT = { width: 1440, height: 900 };
|
||||||
|
/** A hero is a band, not a window, so it is framed wider than the figures. */
|
||||||
|
const HERO_VIEWPORT = { width: 1600, height: 900 };
|
||||||
|
/** Delivered at 1280 wide. The frames are shot at 1440 and scaled once, by ffmpeg. */
|
||||||
|
const DELIVER_WIDTH = 1280;
|
||||||
|
const DELIVER_HEIGHT = Math.round((DELIVER_WIDTH * VIEWPORT.height) / VIEWPORT.width);
|
||||||
|
/**
|
||||||
|
* What each film ships as.
|
||||||
|
*
|
||||||
|
* A figure is one file at 1280. The hero ships **two**, because it autoplays
|
||||||
|
* above the fold and a phone at 390 CSS px has no use for a 1440-wide encode —
|
||||||
|
* it is the same picture at two and a half times the bytes, on the connection
|
||||||
|
* least able to spare them.
|
||||||
|
*
|
||||||
|
* The CRFs are measured, not guessed. A time-lapse is close to the worst case
|
||||||
|
* for inter-frame compression: the camera never moves but every pixel changes
|
||||||
|
* every frame as the light does, so the encoder has no static background to
|
||||||
|
* lean on. At CRF 22 the hero came out at 2.5 MB. Behind a scrim that is 38–96%
|
||||||
|
* opaque, CRF 28 is indistinguishable and 987 kB.
|
||||||
|
*/
|
||||||
|
const DELIVERABLES = {
|
||||||
|
figure: [{ suffix: "", width: DELIVER_WIDTH, crf: 22 }],
|
||||||
|
hero: [
|
||||||
|
{ suffix: "", width: 1440, crf: 28 },
|
||||||
|
{ suffix: "-sm", width: 960, crf: 30 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const deliverablesFor = (spec) => (spec.chrome === "bare" ? DELIVERABLES.hero : DELIVERABLES.figure);
|
||||||
|
const deliverSize = (spec) => {
|
||||||
|
const vp = spec.viewport ?? VIEWPORT;
|
||||||
|
const w = deliverablesFor(spec)[0].width;
|
||||||
|
return { w, h: Math.round((w * vp.height) / vp.width) };
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `chapter` and `expect` work exactly as they do in `shots.mjs` — an index into
|
* `chapter` and `expect` work exactly as they do in `shots.mjs` — an index into
|
||||||
* the chapter list, and the `shortLabel` it is asserted to be.
|
* the chapter list, and the `shortLabel` it is asserted to be.
|
||||||
@@ -78,6 +112,47 @@ const FILMS = [
|
|||||||
"One camera, eighteen hours, six seconds. Nothing here is keyframed: every frame is the engine asked for a different instant, and the light, the shadows, the sky and the window lights all follow from that one number. The clock in the corner is the film captioning itself.",
|
"One camera, eighteen hours, six seconds. Nothing here is keyframed: every frame is the engine asked for a different instant, and the light, the shadows, the sky and the window lights all follow from that one number. The clock in the corner is the film captioning itself.",
|
||||||
alt: "A time-lapse of the San Francisco financial district seen from above. Shadow sweeps across the towers as the sun crosses the sky, the water changes colour, and after sunset the tower windows light up one by one.",
|
alt: "A time-lapse of the San Francisco financial district seen from above. Shadow sweeps across the towers as the sun crosses the sky, the water changes colour, and after sunset the tower windows light up one by one.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The hero on lumbridgecorp.com, and the only film shot `bare`.
|
||||||
|
*
|
||||||
|
* The figures keep the app's panel because the clock in it is the caption.
|
||||||
|
* A hero has its own headline sitting on top of the picture, and a second
|
||||||
|
* column of interface underneath that headline is not atmosphere, it is
|
||||||
|
* two interfaces arguing.
|
||||||
|
*
|
||||||
|
* The camera is SoMa rather than the whole board, which was tried first: at
|
||||||
|
* that standoff the edge of the terrain plate is visible against the sky
|
||||||
|
* across the top of the frame, and midday is a lot of pale sand. Close in,
|
||||||
|
* the city fills the frame to every edge at every hour.
|
||||||
|
*
|
||||||
|
* It is also the only one that runs **midnight to midnight**. The others
|
||||||
|
* crop to the part of the day that moves; this one cannot, because the page
|
||||||
|
* seeks it to the reader's own local hour and a film that starts at 04:40
|
||||||
|
* has no frame to show someone at two in the morning. 180 frames across 24
|
||||||
|
* hours is exactly eight minutes a frame, and the last frame lands at 23:52
|
||||||
|
* rather than midnight so the loop closes without showing the same minute
|
||||||
|
* twice.
|
||||||
|
*/
|
||||||
|
id: "hero-soma",
|
||||||
|
door: "tera",
|
||||||
|
city: "sf",
|
||||||
|
chapter: 2,
|
||||||
|
expect: "SoMa",
|
||||||
|
from: "2026-08-06T00:00:00-07:00",
|
||||||
|
to: "2026-08-06T23:52:00-07:00",
|
||||||
|
frames: 180,
|
||||||
|
fps: 30,
|
||||||
|
viewport: HERO_VIEWPORT,
|
||||||
|
chrome: "bare",
|
||||||
|
/** Whole-day, so this is the only film where the poster hour is a choice about light. */
|
||||||
|
poster: 0.33,
|
||||||
|
title: "San Francisco, one whole day",
|
||||||
|
place: "SoMa, San Francisco",
|
||||||
|
caption:
|
||||||
|
"A full day of San Francisco every six seconds, starting at whatever hour it is where you are. The sun, the moon and the window lights are computed from a clock rather than themed — the one claim this engine makes that a picture can settle on its own.",
|
||||||
|
alt: "A time-lapse of downtown San Francisco from above, running through a whole day: dark before dawn, long shadows at sunrise, flat midday light over the tower cluster and the Bay Bridge, then dusk and the windows lighting up one by one.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "bay-relief-day",
|
id: "bay-relief-day",
|
||||||
door: "tera",
|
door: "tera",
|
||||||
@@ -100,11 +175,6 @@ const FILMS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const VIEWPORT = { width: 1440, height: 900 };
|
|
||||||
/** Delivered at 1280 wide. The frames are shot at 1440 and scaled once, by ffmpeg. */
|
|
||||||
const DELIVER_WIDTH = 1280;
|
|
||||||
const DELIVER_HEIGHT = Math.round((DELIVER_WIDTH * VIEWPORT.height) / VIEWPORT.width);
|
|
||||||
|
|
||||||
// ---- Arguments --------------------------------------------------------------
|
// ---- Arguments --------------------------------------------------------------
|
||||||
|
|
||||||
function flag(name, fallback = null) {
|
function flag(name, fallback = null) {
|
||||||
@@ -209,7 +279,7 @@ async function film(browser, spec, dir) {
|
|||||||
const query = spec.city ? `?city=${spec.city}` : "";
|
const query = spec.city ? `?city=${spec.city}` : "";
|
||||||
const frames = frameOverride ?? spec.frames;
|
const frames = frameOverride ?? spec.frames;
|
||||||
const page = await browser.newPage({
|
const page = await browser.newPage({
|
||||||
viewport: VIEWPORT,
|
viewport: spec.viewport ?? VIEWPORT,
|
||||||
deviceScaleFactor: 1,
|
deviceScaleFactor: 1,
|
||||||
// As in `shots.mjs`: the chapter cut has to be a cut, not a twenty-second
|
// As in `shots.mjs`: the chapter cut has to be a cut, not a twenty-second
|
||||||
// flight the film would open in the middle of.
|
// flight the film would open in the middle of.
|
||||||
@@ -243,10 +313,14 @@ async function film(browser, spec, dir) {
|
|||||||
}
|
}
|
||||||
await page.waitForTimeout(2500);
|
await page.waitForTimeout(2500);
|
||||||
|
|
||||||
// The clock stays. It is the caption the film writes for itself, and the
|
// For a figure the clock stays: it is the caption the film writes for
|
||||||
// only thing on screen that proves the light is following a real time
|
// itself, and the only thing on screen proving the light follows a real
|
||||||
// rather than a hand-keyed fade.
|
// time rather than a hand-keyed fade. A `bare` film is going behind
|
||||||
await hide(page, [...FURNITURE.transient, ...FURNITURE.CLUTTER]);
|
// somebody else's headline and takes all of it off.
|
||||||
|
await hide(page, [
|
||||||
|
...FURNITURE.transient,
|
||||||
|
...(spec.chrome === "bare" ? FURNITURE.BARE : FURNITURE.CLUTTER),
|
||||||
|
]);
|
||||||
|
|
||||||
const start = new Date(spec.from).getTime();
|
const start = new Date(spec.from).getTime();
|
||||||
const end = new Date(spec.to).getTime();
|
const end = new Date(spec.to).getTime();
|
||||||
@@ -280,23 +354,52 @@ async function film(browser, spec, dir) {
|
|||||||
* second the index sits at the end and the video will not begin until the whole
|
* second the index sits at the end and the video will not begin until the whole
|
||||||
* thing has arrived.
|
* thing has arrived.
|
||||||
*/
|
*/
|
||||||
function encode(dir, out, fps, frames, posterAt) {
|
/**
|
||||||
|
* Frames to a master, the master to deliverables, and a poster off the frames.
|
||||||
|
*
|
||||||
|
* Two stages rather than one, deliberately. The master is a near-lossless
|
||||||
|
* archive encode that stays in the render directory; every shipped file is
|
||||||
|
* derived from it. That makes "the hero is too heavy, try CRF 28" a ten-second
|
||||||
|
* job instead of a twenty-two-minute re-shoot, and — the part that matters —
|
||||||
|
* it means what is on the site is always exactly what this script produces,
|
||||||
|
* rather than something hand-rolled with ffmpeg the day the size became a
|
||||||
|
* problem.
|
||||||
|
*
|
||||||
|
* `yuv420p` and `+faststart` are not decoration: without the first, Safari and
|
||||||
|
* a good deal of Android will not decode the file at all, and without the
|
||||||
|
* second the index sits at the end and playback waits for the whole download.
|
||||||
|
*
|
||||||
|
* The poster comes off the PNG, not off the master, because it is a still and
|
||||||
|
* has no reason to inherit a video codec's compromises.
|
||||||
|
*/
|
||||||
|
function encode(dir, out, fps, frames, posterAt, deliverables) {
|
||||||
const ff = (...args) => execFileSync("ffmpeg", ["-y", "-loglevel", "error", ...args]);
|
const ff = (...args) => execFileSync("ffmpeg", ["-y", "-loglevel", "error", ...args]);
|
||||||
|
const master = `${out}-master.mp4`;
|
||||||
ff(
|
ff(
|
||||||
"-framerate", String(fps),
|
"-framerate", String(fps),
|
||||||
"-i", join(dir, "f-%04d.png"),
|
"-i", join(dir, "f-%04d.png"),
|
||||||
"-vf", `scale=${DELIVER_WIDTH}:-2:flags=lanczos`,
|
|
||||||
"-c:v", "libx264",
|
"-c:v", "libx264",
|
||||||
"-preset", "slow",
|
"-preset", "slow",
|
||||||
"-crf", "22",
|
"-crf", "18",
|
||||||
"-pix_fmt", "yuv420p",
|
"-pix_fmt", "yuv420p",
|
||||||
"-movflags", "+faststart",
|
master,
|
||||||
`${out}.mp4`,
|
|
||||||
);
|
);
|
||||||
|
for (const d of deliverables) {
|
||||||
|
ff(
|
||||||
|
"-i", master,
|
||||||
|
"-vf", `scale=${d.width}:-2:flags=lanczos`,
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "slow",
|
||||||
|
"-crf", String(d.crf),
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
`${out}${d.suffix}.mp4`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const posterFrame = Math.min(frames - 1, Math.round((frames - 1) * posterAt));
|
const posterFrame = Math.min(frames - 1, Math.round((frames - 1) * posterAt));
|
||||||
ff(
|
ff(
|
||||||
"-i", join(dir, `f-${String(posterFrame).padStart(4, "0")}.png`),
|
"-i", join(dir, `f-${String(posterFrame).padStart(4, "0")}.png`),
|
||||||
"-vf", `scale=${DELIVER_WIDTH}:-2:flags=lanczos`,
|
"-vf", `scale=${deliverables[0].width}:-2:flags=lanczos`,
|
||||||
"-quality", "82",
|
"-quality", "82",
|
||||||
`${out}-poster.webp`,
|
`${out}-poster.webp`,
|
||||||
);
|
);
|
||||||
@@ -316,16 +419,42 @@ async function writeManifest() {
|
|||||||
return join(dataDir, "films.ts");
|
return join(dataDir, "films.ts");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a render directory to the site, then rewrite the manifest.
|
||||||
|
*
|
||||||
|
* A film that is not in `fromDir` but is already published is **left alone**
|
||||||
|
* rather than treated as an error. Reels cost twenty-two minutes each, so a
|
||||||
|
* one-reel render is the normal way to add or replace one, and demanding that
|
||||||
|
* every reel be present in the same directory would mean re-shooting the whole
|
||||||
|
* set to change any of it. What is not tolerated is a film that exists in
|
||||||
|
* neither place: the manifest is about to name it, so that is a hard failure
|
||||||
|
* rather than a broken `<video>` discovered later.
|
||||||
|
*/
|
||||||
async function publish(fromDir) {
|
async function publish(fromDir) {
|
||||||
await mkdir(publicDir, { recursive: true });
|
await mkdir(publicDir, { recursive: true });
|
||||||
|
let copied = 0;
|
||||||
|
const kept = [];
|
||||||
for (const spec of FILMS) {
|
for (const spec of FILMS) {
|
||||||
for (const name of [`${spec.id}.mp4`, `${spec.id}-poster.webp`]) {
|
const names = [
|
||||||
const src = join(fromDir, name);
|
...deliverablesFor(spec).map((d) => `${spec.id}${d.suffix}.mp4`),
|
||||||
if (!(await exists(src))) throw new Error(`${src} is not there — film it first`);
|
`${spec.id}-poster.webp`,
|
||||||
await copyFile(src, join(publicDir, name));
|
];
|
||||||
|
if (await exists(join(fromDir, names[0]))) {
|
||||||
|
for (const name of names) await copyFile(join(fromDir, name), join(publicDir, name));
|
||||||
|
copied += 1;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
if (await exists(join(publicDir, names[0]))) {
|
||||||
|
kept.push(spec.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`${spec.id} is neither in ${fromDir} nor already published — film it before the ` +
|
||||||
|
`manifest names it`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.log(`site ${FILMS.length} film(s) → ${publicDir}`);
|
console.log(`site ${copied} film(s) copied → ${publicDir}`);
|
||||||
|
if (kept.length) console.log(` kept already-published: ${kept.join(", ")}`);
|
||||||
console.log(` manifest → ${await writeManifest()}`);
|
console.log(` manifest → ${await writeManifest()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +485,7 @@ try {
|
|||||||
|
|
||||||
const shot = await film(browser, spec, framesDir);
|
const shot = await film(browser, spec, framesDir);
|
||||||
const out = join(outDir, spec.id);
|
const out = join(outDir, spec.id);
|
||||||
const posterFrame = encode(framesDir, out, spec.fps, shot, spec.poster);
|
const posterFrame = encode(framesDir, out, spec.fps, shot, spec.poster, deliverablesFor(spec));
|
||||||
await rm(framesDir, { recursive: true, force: true });
|
await rm(framesDir, { recursive: true, force: true });
|
||||||
made.push({ spec, out });
|
made.push({ spec, out });
|
||||||
|
|
||||||
@@ -381,7 +510,9 @@ try {
|
|||||||
} else {
|
} else {
|
||||||
await mkdir(publicDir, { recursive: true });
|
await mkdir(publicDir, { recursive: true });
|
||||||
for (const { spec, out } of made) {
|
for (const { spec, out } of made) {
|
||||||
await copyFile(`${out}.mp4`, join(publicDir, `${spec.id}.mp4`));
|
for (const d of deliverablesFor(spec)) {
|
||||||
|
await copyFile(`${out}${d.suffix}.mp4`, join(publicDir, `${spec.id}${d.suffix}.mp4`));
|
||||||
|
}
|
||||||
await copyFile(`${out}-poster.webp`, join(publicDir, `${spec.id}-poster.webp`));
|
await copyFile(`${out}-poster.webp`, join(publicDir, `${spec.id}-poster.webp`));
|
||||||
}
|
}
|
||||||
console.log(`site ${made.length} film(s) → ${publicDir}`);
|
console.log(`site ${made.length} film(s) → ${publicDir}`);
|
||||||
@@ -406,10 +537,14 @@ function manifest() {
|
|||||||
id: ${JSON.stringify(f.id)},
|
id: ${JSON.stringify(f.id)},
|
||||||
place: ${JSON.stringify(f.place)},
|
place: ${JSON.stringify(f.place)},
|
||||||
title: ${JSON.stringify(f.title)},
|
title: ${JSON.stringify(f.title)},
|
||||||
src: ${JSON.stringify(`/films/${f.id}.mp4`)},
|
src: ${JSON.stringify(`/films/${f.id}.mp4`)},${
|
||||||
|
deliverablesFor(f).some((d) => d.suffix === "-sm")
|
||||||
|
? `\n srcSmall: ${JSON.stringify(`/films/${f.id}-sm.mp4`)},`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
poster: ${JSON.stringify(`/films/${f.id}-poster.webp`)},
|
poster: ${JSON.stringify(`/films/${f.id}-poster.webp`)},
|
||||||
width: ${DELIVER_WIDTH},
|
width: ${deliverSize(f).w},
|
||||||
height: ${DELIVER_HEIGHT},
|
height: ${deliverSize(f).h},
|
||||||
seconds: ${Number((f.frames / f.fps).toFixed(2))},
|
seconds: ${Number((f.frames / f.fps).toFixed(2))},
|
||||||
from: ${JSON.stringify(f.from)},
|
from: ${JSON.stringify(f.from)},
|
||||||
to: ${JSON.stringify(f.to)},
|
to: ${JSON.stringify(f.to)},
|
||||||
@@ -442,6 +577,8 @@ export interface Film {
|
|||||||
/** One line, for the figure's heading. */
|
/** One line, for the figure's heading. */
|
||||||
title: string;
|
title: string;
|
||||||
src: string;
|
src: string;
|
||||||
|
/** A narrower encode for small screens, where one exists. Same film, fewer bytes. */
|
||||||
|
srcSmall?: string;
|
||||||
poster: string;
|
poster: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user