Skip to content

Commit a060dbd

Browse files
broomvaclaude
andauthored
feat(web): /scroll-cinema — the pitch as a reality map over Veo footage (BRO-2251) (#6)
An English landing pitch at /scroll-cinema, scroll-driven. It coexists with /demo (BRO-2244), which is the Spanish hackathon reel and is untouched. WHAT IS BORROWED, AND WHAT IS NOT The reference contributes the visual grammar of the paths and nothing else: a fan of faint candidate branches, one picked out, numbered columns, mono chrome in the corners, a scroll cue. The copy is ours. A map that highlights "the future you want" is a map of a simulator that lets you choose your own answer, which is the posture the README spends three paragraphs refusing — so the lit branch here is the one whose conservation invariant HELD, which is a property of the run rather than a preference. The accent stays Resonant AI Blue. DESIGN.md holds the palette invariant across domain extensions, and a second accent would make "blue means the chosen path" mean nothing. One clip came back with an amber cast and was regenerated against a cooler negative prompt rather than kept. THE FOOTAGE IS THE GROUND, THE MAP IS THE ARGUMENT Six Veo 3.1 clips, deliberately near-black and abstract so the diagram reads on top of them. The map takes its own scroll reading rather than the scrubber's: createScrollCinema throws when the browser will not give us a decoder, and a page whose diagram disappears with the video would have lost the whole claim to a codec. Footage can fail completely here and the argument still runs. Every reveal in the map is a clamp() over one custom property, so nothing tweens and the drawing cannot drift out of step with the words. --p defaults to 1, so no-JS and reduced-motion land on the finished diagram. scripts/veo/generate.ts is committed for the same reason `bun run video` is: footage only one laptop knows how to regenerate cannot be re-cut after a copy change. `bun run veo`. Raw masters cache in out/veo-raw/ (gitignored) so a re-encode never costs another generation — which is how the amber clip was fixed for the price of one call. TWO THINGS THE API AND THE TOOLCHAIN DISAGREED WITH - veo-3.1-fast rejects `generateAudio` outright (HTTP 400). Audio is stripped at encode time with -an instead, which is what an autoplaying muted page wanted anyway. Caught by generating ONE clip before committing to six. - This ffmpeg ships without a webp encoder, so posters go out as PNG and cwebp converts. Posters are clip BOUNDARIES, not clips: the scrubber requires exactly clips+1, so the last clip contributes two. 3.1 MB for six clips and seven posters, against the 25 MB the same number of scenes cost on /demo — CRF 31, no audio, 1280 wide. ALSO - Tokens live on .sc-page, not .sc. They were on the film, and the closing section is its sibling, so every var() down there resolved to nothing and the section painted transparent over the light document shell with the fixed film bleeding through a white panel. A token used by two subtrees belongs above both of them. - pages.yml asserts 6 clips and 7 posters for this page rather than loosening the existing /demo assertion. Verified in a browser against the built export: scrub drives --p 0 -> 1 with the beats and the step counter tracking it; reduced motion serves a poster and no video, track collapses to 180vh, diagram complete; 390px has no page overflow. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 58c9373 commit a060dbd

24 files changed

Lines changed: 1309 additions & 2 deletions

.github/workflows/pages.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,15 @@ jobs:
7777
posters=$(ls out/cinema/*.webp 2>/dev/null | wc -l | tr -d ' ')
7878
[ "$clips" = "5" ] || { echo "::error::expected 5 clips in out/cinema, found $clips"; exit 1; }
7979
[ "$posters" = "6" ] || { echo "::error::expected 6 posters in out/cinema, found $posters"; exit 1; }
80-
echo "export ok: $(find out -type f | wc -l) files, $clips clips, $posters posters"
80+
test -f out/scroll-cinema/index.html || { echo "::error::out/scroll-cinema/index.html missing"; exit 1; }
81+
# /scroll-cinema plays six clips against seven boundary posters.
82+
# The scrubber requires exactly clips+1 and throws below that,
83+
# which on a static export is a black stage and no error.
84+
sc_clips=$(ls out/scroll-cinema/*.mp4 2>/dev/null | wc -l | tr -d ' ')
85+
sc_posters=$(ls out/scroll-cinema/p*.webp 2>/dev/null | wc -l | tr -d ' ')
86+
[ "$sc_clips" = "6" ] || { echo "::error::expected 6 clips in out/scroll-cinema, found $sc_clips"; exit 1; }
87+
[ "$sc_posters" = "7" ] || { echo "::error::expected 7 posters in out/scroll-cinema, found $sc_posters"; exit 1; }
88+
echo "export ok: $(find out -type f | wc -l) files, $clips clips, $posters posters, $sc_clips scroll-cinema clips"
8189
8290
- uses: actions/configure-pages@v5
8391
- uses: actions/upload-pages-artifact@v3

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ web/tsconfig.tsbuildinfo
1818
# .jpg stills are ~40MB of regenerable middle. Note `dist/` and `out/` above are
1919
# BARE patterns, so anything named that anywhere in this tree vanishes silently.
2020
web/cinema/assets/
21+
out/veo-raw/

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"video:capture": "bun run scripts/video/capture.ts",
1616
"video": "bun run scripts/video/render.ts",
1717
"typecheck": "tsc --noEmit",
18-
"mutants": "bun run scripts/mutants.ts"
18+
"mutants": "bun run scripts/mutants.ts",
19+
"veo": "bun run scripts/veo/generate.ts"
1920
},
2021
"devDependencies": {
2122
"@biomejs/biome": "2.5.10",

scripts/veo/generate.ts

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
/**
2+
* Storyboard -> Veo 3.1 -> the clips /scroll-cinema plays.
3+
*
4+
* This exists as a committed script rather than as six files somebody once
5+
* produced by hand, for the same reason `bun run video` exists: footage that
6+
* only one laptop knows how to regenerate is footage nobody can re-cut after a
7+
* copy change. Re-run it and the page's ground layer comes back.
8+
*
9+
* bun run scripts/veo/generate.ts # generate whatever is missing
10+
* bun run scripts/veo/generate.ts --only 01 # one scene, by id prefix
11+
* bun run scripts/veo/generate.ts --force # regenerate everything
12+
* bun run scripts/veo/generate.ts --encode # re-encode from raw, no API calls
13+
*
14+
* Raw Veo output lands in out/veo-raw/ (gitignored) and is re-encoded into
15+
* web/public/scroll-cinema/. The raw file is kept so a re-encode never costs
16+
* another generation.
17+
*/
18+
19+
import { existsSync } from "node:fs";
20+
import { mkdir, readFile, writeFile } from "node:fs/promises";
21+
import { join } from "node:path";
22+
23+
const KEY = process.env.GEMINI_API_KEY;
24+
if (!KEY) {
25+
console.error("GEMINI_API_KEY is not set. Veo is reached through the Gemini API.");
26+
process.exit(2);
27+
}
28+
29+
const ROOT = join(import.meta.dir, "..", "..");
30+
const BOARD = join(ROOT, "web", "scroll-cinema", "storyboard.json");
31+
const RAW = join(ROOT, "out", "veo-raw");
32+
const OUT = join(ROOT, "web", "public", "scroll-cinema");
33+
const API = "https://generativelanguage.googleapis.com/v1beta";
34+
35+
/** Only the fields this script reads. The rest of the payload is ignored. */
36+
interface StartBody {
37+
name?: unknown;
38+
error?: unknown;
39+
}
40+
interface PollBody {
41+
done?: unknown;
42+
error?: unknown;
43+
response?: { error?: unknown } & Record<string, unknown>;
44+
}
45+
46+
interface Scene {
47+
id: string;
48+
beat: string;
49+
prompt: string;
50+
}
51+
interface Board {
52+
model: string;
53+
aspectRatio: string;
54+
resolution: string;
55+
/**
56+
* Declared in the storyboard but NOT sent: veo-3.1-fast rejects the field
57+
* outright ("`generateAudio` isn't supported by this model", HTTP 400). The
58+
* audio is removed at encode time with -an instead, which is what the page
59+
* needs anyway since it autoplays muted.
60+
*/
61+
generateAudio: boolean;
62+
negativePrompt: string;
63+
style: string;
64+
scenes: Scene[];
65+
}
66+
67+
const argv = process.argv.slice(2);
68+
const flag = (name: string) => argv.includes(`--${name}`);
69+
const value = (name: string) => {
70+
const i = argv.indexOf(`--${name}`);
71+
return i >= 0 ? argv[i + 1] : undefined;
72+
};
73+
74+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
75+
76+
async function run(cmd: string[]): Promise<void> {
77+
const p = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
78+
const code = await p.exited;
79+
if (code !== 0) {
80+
const err = await new Response(p.stderr).text();
81+
throw new Error(`${cmd[0]} exited ${code}\n${err.slice(-1200)}`);
82+
}
83+
}
84+
85+
/** Start one generation and return the long-running operation name. */
86+
async function start(board: Board, scene: Scene): Promise<string> {
87+
const res = await fetch(`${API}/models/${board.model}:predictLongRunning?key=${KEY}`, {
88+
method: "POST",
89+
headers: { "content-type": "application/json" },
90+
body: JSON.stringify({
91+
instances: [{ prompt: `${scene.prompt} ${board.style}` }],
92+
parameters: {
93+
aspectRatio: board.aspectRatio,
94+
resolution: board.resolution,
95+
negativePrompt: board.negativePrompt,
96+
},
97+
}),
98+
});
99+
const body = (await res.json()) as StartBody;
100+
if (!res.ok || body.error) {
101+
throw new Error(
102+
`start ${scene.id}: ${res.status} ${JSON.stringify(body.error ?? body).slice(0, 400)}`,
103+
);
104+
}
105+
if (typeof body.name !== "string") {
106+
throw new Error(
107+
`start ${scene.id}: no operation name in ${JSON.stringify(body).slice(0, 300)}`,
108+
);
109+
}
110+
return body.name;
111+
}
112+
113+
/** Poll until done, then dig the file URI out of whichever shape came back. */
114+
async function await_(op: string, id: string): Promise<string> {
115+
const deadline = Date.now() + 12 * 60_000;
116+
let waited = 0;
117+
while (Date.now() < deadline) {
118+
await sleep(10_000);
119+
waited += 10;
120+
const res = await fetch(`${API}/${op}?key=${KEY}`);
121+
const body = (await res.json()) as PollBody;
122+
if (body.error) throw new Error(`poll ${id}: ${JSON.stringify(body.error).slice(0, 400)}`);
123+
if (!body.done) {
124+
if (waited % 60 === 0) console.log(` ${id}: ${waited}s`);
125+
continue;
126+
}
127+
if (body.response?.error) {
128+
throw new Error(`generate ${id}: ${JSON.stringify(body.response.error).slice(0, 400)}`);
129+
}
130+
// The response shape has moved between previews, so find the uri rather
131+
// than index a path that a version bump can silently empty.
132+
const found = findUri(body.response);
133+
if (!found) {
134+
throw new Error(
135+
`generate ${id}: done, but no video uri in ${JSON.stringify(body.response).slice(0, 600)}`,
136+
);
137+
}
138+
console.log(` ${id}: done in ${waited}s`);
139+
return found;
140+
}
141+
throw new Error(`poll ${id}: still running after 12 minutes`);
142+
}
143+
144+
function findUri(node: unknown): string | null {
145+
if (typeof node === "string") return node.startsWith("http") ? node : null;
146+
if (Array.isArray(node)) {
147+
for (const child of node) {
148+
const hit = findUri(child);
149+
if (hit) return hit;
150+
}
151+
return null;
152+
}
153+
if (node && typeof node === "object") {
154+
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
155+
if ((k === "uri" || k === "videoUri" || k === "fileUri") && typeof v === "string") return v;
156+
const hit = findUri(v);
157+
if (hit) return hit;
158+
}
159+
}
160+
return null;
161+
}
162+
163+
async function download(uri: string, to: string): Promise<void> {
164+
const sep = uri.includes("?") ? "&" : "?";
165+
const res = await fetch(`${uri}${sep}key=${KEY}`);
166+
if (!res.ok) throw new Error(`download ${to}: ${res.status}`);
167+
await writeFile(to, Buffer.from(await res.arrayBuffer()));
168+
}
169+
170+
/**
171+
* Re-encode for the web. Veo returns a large, audio-bearing master; this page
172+
* plays six of these behind a diagram, muted, as texture. Two orders of
173+
* magnitude of bytes buy nothing here, and a public repo pays for them forever.
174+
*/
175+
async function encode(rawPath: string, id: string, index: number, last: boolean): Promise<void> {
176+
const mp4 = join(OUT, `${id}.mp4`);
177+
await run([
178+
"ffmpeg",
179+
"-y",
180+
"-loglevel",
181+
"error",
182+
"-i",
183+
rawPath,
184+
"-an",
185+
"-vf",
186+
"scale=1280:-2,format=yuv420p",
187+
"-c:v",
188+
"libx264",
189+
"-preset",
190+
"slow",
191+
"-crf",
192+
"31",
193+
"-profile:v",
194+
"high",
195+
"-movflags",
196+
"+faststart",
197+
mp4,
198+
]);
199+
// Posters are clip BOUNDARIES, not clips: the scrubber requires exactly
200+
// clips.length + 1 of them, one per seam, so the final clip contributes two
201+
// (its opening frame and its closing one).
202+
//
203+
// This ffmpeg build ships without a webp encoder ("Default encoder for
204+
// format webp (codec webp) is probably disabled"), so frames come out as PNG
205+
// and cwebp converts. Two steps, but it does not depend on how someone's
206+
// ffmpeg happened to be compiled.
207+
await poster(rawPath, id, "first", index);
208+
if (last) await poster(rawPath, id, "last", index + 1);
209+
}
210+
211+
async function poster(
212+
rawPath: string,
213+
id: string,
214+
which: "first" | "last",
215+
index: number,
216+
): Promise<void> {
217+
const frame = join(RAW, `${id}-${which}.png`);
218+
const out = join(OUT, `p${String(index).padStart(2, "0")}.webp`);
219+
const seek = which === "first" ? ["-ss", "0.2"] : ["-sseof", "-0.3"];
220+
await run([
221+
"ffmpeg",
222+
"-y",
223+
"-loglevel",
224+
"error",
225+
...seek,
226+
"-i",
227+
rawPath,
228+
"-vframes",
229+
"1",
230+
"-vf",
231+
"scale=1280:-2",
232+
frame,
233+
]);
234+
await run(["cwebp", "-quiet", "-q", "72", frame, "-o", out]);
235+
}
236+
237+
const board: Board = JSON.parse(await readFile(BOARD, "utf8"));
238+
await mkdir(RAW, { recursive: true });
239+
await mkdir(OUT, { recursive: true });
240+
241+
const only = value("only");
242+
const scenes = only ? board.scenes.filter((s) => s.id.startsWith(only)) : board.scenes;
243+
if (scenes.length === 0) {
244+
console.error(`--only ${only} matched no scene`);
245+
process.exit(2);
246+
}
247+
248+
console.log(
249+
`veo: ${board.model} · ${board.resolution} ${board.aspectRatio} · ${scenes.length} scene(s)`,
250+
);
251+
252+
for (const scene of scenes) {
253+
const raw = join(RAW, `${scene.id}.mp4`);
254+
const done = join(OUT, `${scene.id}.mp4`);
255+
256+
if (!flag("encode") && !flag("force") && existsSync(done)) {
257+
console.log(` ${scene.id}: already built, skipping`);
258+
continue;
259+
}
260+
261+
if (!existsSync(raw) || flag("force")) {
262+
if (flag("encode")) {
263+
console.log(` ${scene.id}: no raw file, and --encode does not generate`);
264+
continue;
265+
}
266+
console.log(` ${scene.id}: generating — "${scene.beat}"`);
267+
const op = await start(board, scene);
268+
const uri = await await_(op, scene.id);
269+
await download(uri, raw);
270+
} else {
271+
console.log(` ${scene.id}: raw present, re-encoding only`);
272+
}
273+
274+
await encode(
275+
raw,
276+
scene.id,
277+
board.scenes.indexOf(scene),
278+
scene === board.scenes[board.scenes.length - 1],
279+
);
280+
const size = Bun.file(join(OUT, `${scene.id}.mp4`)).size;
281+
console.log(` ${scene.id}: ${(size / 1e6).toFixed(2)} MB`);
282+
}
283+
284+
const manifest = {
285+
generatedFrom: "web/scroll-cinema/storyboard.json",
286+
model: board.model,
287+
clips: board.scenes.map((s) => `${s.id}.mp4`),
288+
// one per seam: clips.length + 1, which is what the scrubber asserts
289+
posters: board.scenes
290+
.map((_, i) => `p${String(i).padStart(2, "0")}.webp`)
291+
.concat(`p${String(board.scenes.length).padStart(2, "0")}.webp`),
292+
beats: board.scenes.map((s) => ({ id: s.id, beat: s.beat })),
293+
};
294+
await writeFile(join(OUT, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
295+
console.log("manifest written");

0 commit comments

Comments
 (0)