reel-studio 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.claude/skills/capture-site/SKILL.md +39 -0
  2. package/.claude/skills/character-2d/SKILL.md +38 -0
  3. package/.claude/skills/make-video/SKILL.md +56 -0
  4. package/.claude/skills/new-brand/SKILL.md +19 -0
  5. package/.claude/skills/pick-engine/SKILL.md +38 -0
  6. package/.claude/skills/scene-3d/SKILL.md +36 -0
  7. package/.claude/skills/setup/SKILL.md +29 -0
  8. package/LICENSE +21 -0
  9. package/README.md +112 -2
  10. package/bin/reel-studio.mjs +32 -0
  11. package/brands/_template/README.md +46 -0
  12. package/brands/_template/assets/.gitkeep +0 -0
  13. package/brands/_template/compliance.md +19 -0
  14. package/brands/_template/tokens.css +24 -0
  15. package/core/README.md +58 -0
  16. package/core/lib/base.css +31 -0
  17. package/core/lib/runtime.js +126 -0
  18. package/core/package-lock.json +148 -0
  19. package/core/package.json +16 -0
  20. package/core/render.mjs +163 -0
  21. package/engines/README.md +46 -0
  22. package/engines/blender/example_scene.py +47 -0
  23. package/engines/blender/render.sh +21 -0
  24. package/engines/blender/run.py +96 -0
  25. package/engines/capture.mjs +74 -0
  26. package/engines/pixi.js +30 -0
  27. package/engines/svg-rig.js +81 -0
  28. package/engines/three.js +51 -0
  29. package/examples/engines-demo/README.md +22 -0
  30. package/examples/engines-demo/index.html +108 -0
  31. package/examples/engines-demo/plates/blob.mp4 +0 -0
  32. package/package.json +29 -4
  33. package/productions/_TEMPLATE/README.md +88 -0
  34. package/productions/_TEMPLATE/audio/.gitkeep +0 -0
  35. package/productions/_TEMPLATE/composition/.gitkeep +0 -0
  36. package/productions/_TEMPLATE/export/.gitkeep +0 -0
  37. package/productions/_TEMPLATE/qa/.gitkeep +0 -0
  38. package/productions/_TEMPLATE/refs/.gitkeep +0 -0
  39. package/scripts/extract-frames.sh +54 -0
  40. package/scripts/init-project.sh +67 -0
  41. package/scripts/mix_audio.py +78 -0
  42. package/scripts/qa-video.sh +95 -0
  43. package/scripts/qa_motion.py +66 -0
  44. package/scripts/svg-to-png.sh +37 -0
  45. package/scripts/transcribe.sh +25 -0
  46. package/templates/project/CLAUDE.md +34 -0
  47. package/templates/project/STATUS.md +21 -0
  48. package/templates/project/gitignore +15 -0
@@ -0,0 +1,126 @@
1
+ /* Composition runtime: one clock for every engine.
2
+ *
3
+ * A composition is an HTML page that:
4
+ * 1. loads /core/node_modules/gsap/dist/gsap.min.js, /core/lib/base.css and this file
5
+ * 2. calls Studio.setup({ width, height, fps, duration })
6
+ * 3. builds its motion on Studio.tl (a paused GSAP timeline), in seconds
7
+ * 4. registers anything else that moves with Studio.onSeek(fn): fn(t) draws
8
+ * that layer at time t (it may return a promise). Engine adapters in
9
+ * /engines/ do this for you (Lottie, Three.js, Pixi, <video>).
10
+ * 5. registers anything that must load first with Studio.wait(promise)
11
+ *
12
+ * The renderer calls window.__seek(t) for every frame and screenshots it, so
13
+ * every frame is deterministic. Avoid CSS animations/transitions, setTimeout,
14
+ * requestAnimationFrame loops and Math.random() (use Studio.random(seed)).
15
+ *
16
+ * Preview in a browser (served from the repo root):
17
+ * .../composition/index.html plays live, loops
18
+ * .../composition/index.html?t=2.5 freezes at 2.5 s
19
+ * .../composition/index.html?safe=1 shows the safe-zone overlay
20
+ */
21
+ (function () {
22
+ const params = new URLSearchParams(location.search);
23
+ const hooks = [];
24
+ const pending = [];
25
+
26
+ const Studio = {
27
+ meta: null,
28
+ tl: null,
29
+ params,
30
+
31
+ setup({ width = 1080, height = 1920, fps = 30, duration }) {
32
+ if (!duration) throw new Error("Studio.setup needs a duration (seconds)");
33
+ this.meta = { width, height, fps, duration };
34
+ const stage = document.getElementById("stage");
35
+ stage.style.width = width + "px";
36
+ stage.style.height = height + "px";
37
+ this.tl = gsap.timeline({ paused: true });
38
+ // Make sure the timeline is exactly `duration` long.
39
+ this.tl.set({}, {}, duration);
40
+ if (params.get("safe") === "1") document.documentElement.classList.add("show-safe");
41
+ return this.tl;
42
+ },
43
+
44
+ /** Draw a layer at time t on every seek. Hooks run in registration order. */
45
+ onSeek(fn) { hooks.push(fn); return fn; },
46
+
47
+ /** Hold the first frame until this promise settles (assets, engine init). */
48
+ wait(promise) { pending.push(promise); return promise; },
49
+
50
+ /** Seeded PRNG (mulberry32): same seed, same numbers, every render. */
51
+ random(seed = 1) {
52
+ let a = seed >>> 0;
53
+ return () => {
54
+ a = (a + 0x6d2b79f5) >>> 0;
55
+ let t = a;
56
+ t = Math.imul(t ^ (t >>> 15), t | 1);
57
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
58
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
59
+ };
60
+ },
61
+
62
+ /** Register a Lottie animation (needs lottie.min.js). `start` = second it begins. */
63
+ lottie(el, path, { start = 0, loop = false, speed = 1 } = {}) {
64
+ const anim = lottie.loadAnimation({ container: el, renderer: "svg", loop: false, autoplay: false, path });
65
+ this.wait(new Promise((res) => anim.addEventListener("DOMLoaded", res)));
66
+ this.onSeek((t) => {
67
+ const total = anim.totalFrames;
68
+ let f = Math.max(0, (t - start) * anim.frameRate * speed);
69
+ f = loop ? f % total : Math.min(f, total - 1);
70
+ anim.goToAndStop(f, true);
71
+ });
72
+ return anim;
73
+ },
74
+
75
+ /** Register a <video> element (a pre-rendered plate: Blender, a capture, footage). */
76
+ video(el, { start = 0 } = {}) {
77
+ el.muted = true; el.preload = "auto"; el.pause();
78
+ this.wait(new Promise((res, rej) => {
79
+ // A missing or broken plate fails the render instead of hanging it (the error may have fired already)
80
+ const fail = () => rej(new Error(`Video plate failed to load: ${el.currentSrc || el.src}`));
81
+ if (el.readyState >= 2) return res();
82
+ if (el.error || el.networkState === HTMLMediaElement.NETWORK_NO_SOURCE) return fail();
83
+ el.addEventListener("loadeddata", res, { once: true });
84
+ el.addEventListener("error", fail, { once: true });
85
+ }));
86
+ this.onSeek((t) => new Promise((res) => {
87
+ const target = Math.max(0, Math.min(t - start, (el.duration || 0) - 0.001));
88
+ if (Math.abs(el.currentTime - target) < 0.0005) return res();
89
+ el.addEventListener("seeked", res, { once: true });
90
+ el.currentTime = target;
91
+ }));
92
+ return el;
93
+ },
94
+ };
95
+
96
+ async function seek(t) {
97
+ Studio.tl.seek(t, false);
98
+ for (const fn of hooks) await fn(t);
99
+ }
100
+
101
+ window.Studio = Studio;
102
+ window.__seek = seek;
103
+ window.__meta = () => Studio.meta;
104
+ window.__ready = (async () => {
105
+ await new Promise((r) => (document.readyState === "complete" ? r() : addEventListener("load", r)));
106
+ await document.fonts.ready;
107
+ // Module scripts may register more work while earlier work loads: wait until it settles.
108
+ for (let n = 0; n < pending.length; n = pending.length) await Promise.all(pending.slice(n));
109
+ await Promise.all([...document.images].map((img) => (img.complete ? null : new Promise((r) => (img.onload = img.onerror = r)))));
110
+ if (!Studio.meta) throw new Error("Composition never called Studio.setup()");
111
+ return true;
112
+ })();
113
+
114
+ // Browser preview: freeze at ?t=, otherwise play in a loop. The renderer passes ?render=1.
115
+ window.__ready.then(() => {
116
+ if (params.get("render") === "1") return;
117
+ if (params.has("t")) return seek(parseFloat(params.get("t")));
118
+ const t0 = performance.now();
119
+ let busy = false;
120
+ const loop = async () => {
121
+ if (!busy) { busy = true; await seek(((performance.now() - t0) / 1000) % Studio.meta.duration); busy = false; }
122
+ requestAnimationFrame(loop);
123
+ };
124
+ loop();
125
+ });
126
+ })();
@@ -0,0 +1,148 @@
1
+ {
2
+ "name": "reel-studio-core",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "name": "reel-studio-core",
8
+ "dependencies": {
9
+ "gsap": "^3.12.5",
10
+ "lottie-web": "^5.12.2",
11
+ "pixi.js": "^8.6.0",
12
+ "playwright-core": "1.49.1",
13
+ "three": "^0.170.0"
14
+ }
15
+ },
16
+ "node_modules/@pixi/colord": {
17
+ "version": "2.9.6",
18
+ "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
19
+ "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
20
+ "license": "MIT"
21
+ },
22
+ "node_modules/@types/earcut": {
23
+ "version": "3.0.0",
24
+ "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
25
+ "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
26
+ "license": "MIT"
27
+ },
28
+ "node_modules/@webgpu/types": {
29
+ "version": "0.1.74",
30
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
31
+ "integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
32
+ "license": "BSD-3-Clause"
33
+ },
34
+ "node_modules/@xmldom/xmldom": {
35
+ "version": "0.8.15",
36
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
37
+ "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
38
+ "license": "MIT",
39
+ "engines": {
40
+ "node": ">=10.0.0"
41
+ }
42
+ },
43
+ "node_modules/earcut": {
44
+ "version": "3.2.4",
45
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz",
46
+ "integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==",
47
+ "license": "ISC"
48
+ },
49
+ "node_modules/eventemitter3": {
50
+ "version": "5.0.4",
51
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
52
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
53
+ "license": "MIT"
54
+ },
55
+ "node_modules/gifuct-js": {
56
+ "version": "2.1.2",
57
+ "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
58
+ "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
59
+ "license": "MIT",
60
+ "dependencies": {
61
+ "js-binary-schema-parser": "^2.0.3"
62
+ }
63
+ },
64
+ "node_modules/gsap": {
65
+ "version": "3.15.0",
66
+ "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
67
+ "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
68
+ "license": "Standard 'no charge' license: https://gsap.com/standard-license."
69
+ },
70
+ "node_modules/ismobilejs": {
71
+ "version": "1.1.1",
72
+ "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
73
+ "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
74
+ "license": "MIT"
75
+ },
76
+ "node_modules/js-binary-schema-parser": {
77
+ "version": "2.0.3",
78
+ "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
79
+ "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
80
+ "license": "MIT"
81
+ },
82
+ "node_modules/lottie-web": {
83
+ "version": "5.13.0",
84
+ "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
85
+ "integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
86
+ "license": "MIT"
87
+ },
88
+ "node_modules/parse-svg-path": {
89
+ "version": "0.2.0",
90
+ "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz",
91
+ "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==",
92
+ "license": "MIT"
93
+ },
94
+ "node_modules/pixi.js": {
95
+ "version": "8.22.0",
96
+ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.22.0.tgz",
97
+ "integrity": "sha512-QbTANHMZ751MIjLiLSUSM8a7LNMWwMIf0KbRCqlW+SPS9hNLfSVS+cl1O2SEp9g1K/wyLzj/OJHv+1TUfiPr/w==",
98
+ "license": "MIT",
99
+ "workspaces": [
100
+ "examples",
101
+ "playground"
102
+ ],
103
+ "dependencies": {
104
+ "@pixi/colord": "^2.9.6",
105
+ "@types/earcut": "^3.0.0",
106
+ "@webgpu/types": "^0.1.69",
107
+ "@xmldom/xmldom": "^0.8.15",
108
+ "earcut": "^3.0.2",
109
+ "eventemitter3": "^5.0.1",
110
+ "gifuct-js": "^2.1.2",
111
+ "ismobilejs": "^1.1.1",
112
+ "parse-svg-path": "^0.2.0",
113
+ "tiny-lru": "^11.4.7"
114
+ },
115
+ "funding": {
116
+ "type": "opencollective",
117
+ "url": "https://opencollective.com/pixijs"
118
+ }
119
+ },
120
+ "node_modules/playwright-core": {
121
+ "version": "1.49.1",
122
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
123
+ "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
124
+ "license": "Apache-2.0",
125
+ "bin": {
126
+ "playwright-core": "cli.js"
127
+ },
128
+ "engines": {
129
+ "node": ">=18"
130
+ }
131
+ },
132
+ "node_modules/three": {
133
+ "version": "0.170.0",
134
+ "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
135
+ "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
136
+ "license": "MIT"
137
+ },
138
+ "node_modules/tiny-lru": {
139
+ "version": "11.4.7",
140
+ "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
141
+ "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
142
+ "license": "BSD-3-Clause",
143
+ "engines": {
144
+ "node": ">=12"
145
+ }
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "reel-studio-core",
3
+ "private": true,
4
+ "type": "module",
5
+ "description": "Deterministic renderer: HTML + GSAP / Lottie / Three.js / Pixi → MP4 / PNG",
6
+ "scripts": {
7
+ "render": "node render.mjs"
8
+ },
9
+ "dependencies": {
10
+ "gsap": "^3.12.5",
11
+ "lottie-web": "^5.12.2",
12
+ "pixi.js": "^8.6.0",
13
+ "playwright-core": "1.49.1",
14
+ "three": "^0.170.0"
15
+ }
16
+ }
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ /* Deterministic renderer for compositions (HTML + GSAP, plus any engine in
3
+ * engines/ that registers with Studio.onSeek: Lottie, Three.js, Pixi, video).
4
+ *
5
+ * node core/render.mjs video <composition.html> <out.mp4> [--audio mix.wav] [--safe] [--frames <dir>]
6
+ * node core/render.mjs still <composition.html> <out.png> [--t 1.5] [--safe]
7
+ * node core/render.mjs stills <composition.html> <out_dir> [--every 0.5 | --at 0,1.2,3] [--safe]
8
+ *
9
+ * Paths are relative to the repo root (reel-studio/). The page is served from
10
+ * the repo root over a local HTTP server so assets load with absolute paths
11
+ * like /brands/<name>/assets/..., /core/lib/... and /engines/...
12
+ *
13
+ * Size, fps and duration come from the composition's Studio.setup().
14
+ */
15
+ import { existsSync } from "node:fs";
16
+ import { createServer } from "node:http";
17
+ import { readFile, mkdir, stat } from "node:fs/promises";
18
+ import { spawn } from "node:child_process";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { chromium } from "playwright-core";
22
+
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
24
+ // Google Chrome: CHROME_PATH wins, else the usual install paths on macOS and Linux
25
+ const CHROME = process.env.CHROME_PATH || [
26
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
27
+ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
28
+ ].find((p) => existsSync(p)) || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
29
+ const TYPES = {
30
+ ".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css",
31
+ ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg",
32
+ ".jpeg": "image/jpeg", ".webp": "image/webp", ".ttf": "font/ttf", ".otf": "font/otf", ".woff2": "font/woff2",
33
+ ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".wav": "audio/wav",
34
+ };
35
+
36
+ function args(argv) {
37
+ const [mode, input, output, ...rest] = argv;
38
+ const opts = {};
39
+ for (let i = 0; i < rest.length; i++) {
40
+ const k = rest[i].replace(/^--/, "");
41
+ const next = rest[i + 1];
42
+ if (next === undefined || next.startsWith("--")) opts[k] = true;
43
+ else { opts[k] = next; i++; }
44
+ }
45
+ if (!mode || !input || !output) {
46
+ console.error("usage: render.mjs <video|still|stills> <composition.html> <out> [options]");
47
+ process.exit(2);
48
+ }
49
+ return { mode, input, output, opts };
50
+ }
51
+
52
+ function serve() {
53
+ const server = createServer(async (req, res) => {
54
+ try {
55
+ const url = decodeURIComponent(new URL(req.url, "http://x").pathname);
56
+ const file = path.join(ROOT, url);
57
+ if (!file.startsWith(ROOT)) throw new Error("outside root");
58
+ const s = await stat(file);
59
+ const data = await readFile(s.isDirectory() ? path.join(file, "index.html") : file);
60
+ const type = TYPES[path.extname(file).toLowerCase()] || "application/octet-stream";
61
+ // Range requests: Chrome needs them to seek inside larger <video> files.
62
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || "");
63
+ if (range) {
64
+ const start = range[1] ? +range[1] : data.length - +range[2];
65
+ const end = range[1] && range[2] ? Math.min(+range[2], data.length - 1) : data.length - 1;
66
+ res.writeHead(206, { "content-type": type, "accept-ranges": "bytes", "content-range": `bytes ${start}-${end}/${data.length}`, "content-length": end - start + 1 });
67
+ return res.end(data.subarray(start, end + 1));
68
+ }
69
+ res.writeHead(200, { "content-type": type, "accept-ranges": "bytes" });
70
+ res.end(data);
71
+ } catch {
72
+ res.writeHead(404); res.end("not found");
73
+ }
74
+ });
75
+ return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server)));
76
+ }
77
+
78
+ // WebGL (Three.js, Pixi) on the GPU, but compositing in software: with GPU compositing Chrome
79
+ // draws <video> plates ~10 levels brighter than ffmpeg decodes them (measured 2026-11-02).
80
+ // playwright-core is pinned (1.49.1) because 1.64 shifted plate colours the same way.
81
+ const CHROME_ARGS = ["--force-color-profile=srgb", "--hide-scrollbars",
82
+ "--enable-gpu", "--use-angle=metal", "--ignore-gpu-blocklist", "--disable-gpu-compositing"];
83
+
84
+ async function openPage(input, safe) {
85
+ const server = await serve();
86
+ const { port } = server.address();
87
+ const rel = path.relative(ROOT, path.resolve(input)).split(path.sep).join("/");
88
+ const browser = await chromium.launch({ executablePath: CHROME, args: CHROME_ARGS });
89
+ const page = await browser.newPage({ viewport: { width: 1080, height: 1920 }, deviceScaleFactor: 1 });
90
+ const errors = [];
91
+ page.on("pageerror", (e) => errors.push(String(e)));
92
+ page.on("console", (m) => m.type() === "error" && !m.text().startsWith("Failed to load resource") && errors.push(m.text()));
93
+ page.on("response", (r) => r.status() >= 400 && !r.url().endsWith("/favicon.ico") && errors.push(`${r.status()} ${r.url()}`));
94
+ await page.goto(`http://127.0.0.1:${port}/${rel}?render=1${safe ? "&safe=1" : ""}`);
95
+ await page.evaluate(() => window.__ready);
96
+ const meta = await page.evaluate(() => window.__meta());
97
+ await page.setViewportSize({ width: meta.width, height: meta.height });
98
+ const stage = page.locator("#stage");
99
+ const shot = async (t, file) => {
100
+ await page.evaluate((tt) => window.__seek(tt), t);
101
+ return stage.screenshot({ path: file, type: "png", animations: "disabled" });
102
+ };
103
+ const close = async () => { await browser.close(); server.close(); };
104
+ return { page, meta, shot, close, errors };
105
+ }
106
+
107
+ function ffmpeg(argv) {
108
+ const p = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", ...argv], { stdio: ["pipe", "inherit", "inherit"] });
109
+ const done = new Promise((res, rej) => p.on("close", (c) => (c === 0 ? res() : rej(new Error("ffmpeg exited " + c)))));
110
+ return { stdin: p.stdin, done };
111
+ }
112
+
113
+ const { mode, input, output, opts } = args(process.argv.slice(2));
114
+ const { meta, shot, close, errors } = await openPage(input, !!opts.safe);
115
+ const t0 = Date.now();
116
+
117
+ try {
118
+ if (mode === "still") {
119
+ await mkdir(path.dirname(path.resolve(output)), { recursive: true });
120
+ await shot(parseFloat(opts.t ?? 0), output);
121
+ console.log(`${output} (${meta.width}x${meta.height} @ t=${opts.t ?? 0}s)`);
122
+ } else if (mode === "stills") {
123
+ await mkdir(output, { recursive: true });
124
+ const times = opts.at
125
+ ? String(opts.at).split(",").map(Number)
126
+ : Array.from({ length: Math.floor(meta.duration / parseFloat(opts.every ?? 0.5)) + 1 }, (_, i) => +(i * parseFloat(opts.every ?? 0.5)).toFixed(3));
127
+ for (const t of times) {
128
+ const name = path.join(output, `t_${t.toFixed(2).padStart(6, "0")}.png`);
129
+ await shot(Math.min(t, meta.duration - 1 / meta.fps), name);
130
+ }
131
+ console.log(`${times.length} stills → ${output}`);
132
+ } else if (mode === "video") {
133
+ const frames = Math.round(meta.duration * meta.fps);
134
+ const enc = [
135
+ "-f", "image2pipe", "-framerate", String(meta.fps), "-c:v", "png", "-i", "-",
136
+ ...(opts.audio ? ["-i", opts.audio] : []),
137
+ // aq-mode=3 + light deblock keeps smooth brand gradients from banding/shifting between frames.
138
+ "-c:v", "libx264", "-profile:v", "high", "-preset", "slow", "-crf", "16",
139
+ "-x264-params", "aq-mode=3:aq-strength=0.9:deblock=-1,-1", "-g", String(meta.fps * 2),
140
+ "-pix_fmt", "yuv420p", "-r", String(meta.fps), "-fps_mode", "cfr",
141
+ "-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709",
142
+ ...(opts.audio ? ["-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-af", "apad", "-shortest"] : []),
143
+ "-movflags", "+faststart", output,
144
+ ];
145
+ await mkdir(path.dirname(path.resolve(output)), { recursive: true });
146
+ if (opts.frames) await mkdir(opts.frames, { recursive: true });
147
+ const { stdin, done } = ffmpeg(enc);
148
+ for (let i = 0; i < frames; i++) {
149
+ const file = opts.frames ? path.join(opts.frames, `f_${String(i).padStart(5, "0")}.png`) : undefined;
150
+ const buf = await shot(i / meta.fps, file);
151
+ if (!stdin.write(buf)) await new Promise((r) => stdin.once("drain", r));
152
+ if (i % meta.fps === 0) process.stdout.write(`\rframe ${i}/${frames}`);
153
+ }
154
+ stdin.end();
155
+ await done;
156
+ console.log(`\r${output} (${meta.width}x${meta.height}, ${meta.fps} fps, ${frames} frames, ${meta.duration}s) in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
157
+ } else {
158
+ throw new Error("unknown mode " + mode);
159
+ }
160
+ if (errors.length) { console.error("Page errors:\n " + errors.join("\n ")); process.exitCode = 1; }
161
+ } finally {
162
+ await close();
163
+ }
@@ -0,0 +1,46 @@
1
+ # Engines
2
+
3
+ Every engine plugs into one clock. The renderer asks the page for time `t`, and every layer draws itself at `t`.
4
+ That is what makes every frame deterministic and checkable, whatever drew it.
5
+
6
+ ## Which engine for which look
7
+
8
+ | Look | Engine | File | Status |
9
+ |---|---|---|---|
10
+ | Moving text, app UI demos, ad layouts, end cards | **HTML/CSS + GSAP** | built into `core/lib/runtime.js` (`Studio.tl`) | ✅ tested |
11
+ | Flat 2D cartoon character drawn in code | **SVG rig + GSAP** | `svg-rig.js` (`idle`, `blink`, `talk`, `wave`, `hop`) | ✅ tested |
12
+ | 2D animation from a designer (After Effects) | **Lottie** | `Studio.lottie(el, path)` in the runtime | ✅ ported from the Yuppo renderer |
13
+ | 2D character with states, from Rive | **Rive** | — | 🔜 planned: needs a `.riv` file to build and test against |
14
+ | Particles, confetti, many sprites, 2D game-like motion | **Pixi.js v8** | `pixi.js` (`pixiLayer`) | ✅ tested |
15
+ | 3D product, CAD, camera moves, 3D cartoon (toon + outline) | **Three.js** | `three.js` (`threeLayer`, `toon`, `outline`) | ✅ tested |
16
+ | High-end 3D cartoon, real lighting, physics, simulations | **Blender** (headless, Python) | `blender/render.sh` + a scene script → MP4 plate | ✅ tested (Blender 5.2, Eevee) |
17
+ | A live website or web app you own (its own 3D scene, UI) | **Page capture** | `capture.mjs` → PNG sequence → MP4 plate | ✅ used for the Bike Logger reel |
18
+ | Real-looking footage | AI video generation or stock, as a plate | `Studio.video(el)` | Footage only. Never shows product facts; check the brand's compliance |
19
+
20
+ **Rule of thumb:** start with GSAP. Add one engine for the hero visual. More than two engines in one piece usually means the idea is unclear.
21
+
22
+ ## Two kinds of engine
23
+
24
+ - **Live layers** run in the composition page and are drawn on every seek: GSAP, SVG rig, Lottie, Pixi, Three.js.
25
+ They can be stacked freely (`<div class="layer">` per engine) and all share the GSAP timeline for cuts and fades.
26
+ - **Plates** are rendered first, then played as a `<video>` layer with `Studio.video(el, { start })`: Blender,
27
+ page capture, footage. Encode plates with `-g 1` (every frame a keyframe) so seeking is frame-exact.
28
+
29
+ ## Determinism rules (all engines)
30
+
31
+ 1. **Pose from `t` only.** `update(t)` sets every position, rotation and colour from `t`. No `+= delta`, no state carried between frames. The renderer can ask for any frame in any order.
32
+ 2. **No hidden clocks.** No `requestAnimationFrame` loops, `setTimeout`, CSS animations or transitions, `Date.now()`, or video autoplay.
33
+ 3. **No unseeded randomness.** Use `Studio.random(seed)`.
34
+ 4. **Physics:** simulate at a fixed step ahead of time (or in Blender), store the result, look it up by `t`.
35
+ 5. **Wait for loading.** Anything async goes through `Studio.wait(promise)` so frame 0 is never half-loaded.
36
+ 6. **WebGL needs `preserveDrawingBuffer: true`** (the adapters set it) so screenshots see the canvas.
37
+ 7. **Don't change the Chrome flags or the pinned playwright-core casually.** GPU compositing or a newer Playwright
38
+ made `<video>` plates ~10 levels too bright (see `CHROME_ARGS` in `core/render.mjs` and *Learnings* in `STATUS.md`).
39
+
40
+ Check with: `node core/render.mjs still <comp> a.png --t 3` twice, then `md5 -q a.png b.png`. They must match.
41
+
42
+ ## Adding an engine
43
+
44
+ Write `engines/<name>.js` that creates its canvas or DOM inside a layer element, registers loading with
45
+ `Studio.wait`, and draws with `Studio.onSeek((t) => …)`. Add a scene to `examples/engines-demo/`, render stills,
46
+ check two renders match, then add a row to the table above.
@@ -0,0 +1,47 @@
1
+ """Example Blender scene: a cel-shaded blob hops on a coloured floor.
2
+
3
+ engines/blender/render.sh engines/blender/example_scene.py /tmp/blob.mp4 --size 540x960 --duration 2
4
+ """
5
+ import math
6
+
7
+ import bpy
8
+
9
+
10
+ def build(ctx):
11
+ scene, toon, key = ctx["scene"], ctx["toon"], ctx["key"]
12
+ d = ctx["duration"]
13
+
14
+ # Backdrop colour seen by the camera only; it must not light the scene, or it flattens the toon bands.
15
+ world = bpy.data.worlds.new("bg")
16
+ world.use_nodes = True
17
+ nt = world.node_tree
18
+ bg = nt.nodes["Background"]
19
+ bg.inputs[0].default_value = (1.0, 0.86, 0.72, 1)
20
+ path, black, mix = nt.nodes.new("ShaderNodeLightPath"), nt.nodes.new("ShaderNodeBackground"), nt.nodes.new("ShaderNodeMixShader")
21
+ black.inputs[0].default_value = (0, 0, 0, 1)
22
+ nt.links.new(path.outputs["Is Camera Ray"], mix.inputs[0])
23
+ nt.links.new(black.outputs[0], mix.inputs[1])
24
+ nt.links.new(bg.outputs[0], mix.inputs[2])
25
+ nt.links.new(mix.outputs[0], nt.nodes["World Output"].inputs[0])
26
+ scene.world = world
27
+
28
+ bpy.ops.mesh.primitive_plane_add(size=40)
29
+ bpy.context.object.data.materials.append(toon((1.0, 0.72, 0.5)))
30
+
31
+ bpy.ops.mesh.primitive_uv_sphere_add(radius=1, segments=48, ring_count=24, location=(0, 0, 1))
32
+ blob = bpy.context.object
33
+ bpy.ops.object.shade_smooth()
34
+ blob.data.materials.append(toon((0.93, 0.42, 0.09)))
35
+
36
+ # Hop: squash, rise, fall, squash, settle (same beats as SvgRig.hop).
37
+ for t, z, sx, sz in [(0, 1, 1, 1), (0.1 * d, 0.85, 1.1, 0.85), (0.35 * d, 2.6, 0.92, 1.1),
38
+ (0.6 * d, 1, 1, 1), (0.67 * d, 0.88, 1.08, 0.88), (0.8 * d, 1, 1, 1), (d, 1, 1, 1)]:
39
+ key(blob, "location", t, (0, 0, z))
40
+ key(blob, "scale", t, (sx, sx, sz))
41
+
42
+ bpy.ops.object.light_add(type="SUN", rotation=(math.radians(50), 0, math.radians(30)))
43
+ bpy.context.object.data.energy = 4
44
+
45
+ bpy.ops.object.camera_add(location=(0, -11, 3.2), rotation=(math.radians(82), 0, 0))
46
+ scene.camera = bpy.context.object
47
+ scene.camera.data.lens = 40
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ # Render a Blender scene script to an MP4 plate the composition plays with Studio.video().
3
+ #
4
+ # engines/blender/render.sh <scene.py> <out.mp4> [--size 1080x1920] [--fps 30] [--duration 4] [--engine EEVEE|CYCLES|WORKBENCH]
5
+ #
6
+ # <scene.py> builds the scene with bpy and defines build(ctx); see example_scene.py.
7
+ # Frames go to a temp folder, then ffmpeg encodes with every frame a keyframe
8
+ # so the browser can seek it frame-exactly. Alpha is not kept: render the
9
+ # background in Blender, or put the plate behind transparent HTML layers.
10
+ set -euo pipefail
11
+ BLENDER="${BLENDER:-/Applications/Blender.app/Contents/MacOS/Blender}"
12
+ here="$(cd "$(dirname "$0")" && pwd)"
13
+ scene="$1"; out="$2"; shift 2
14
+ frames="$(mktemp -d)/f_"
15
+ "$BLENDER" -b --factory-startup -noaudio --python "$here/run.py" -- "$scene" "$frames" "$@" >"${frames%f_}blender.log" 2>&1 \
16
+ || { tail -30 "${frames%f_}blender.log"; exit 1; }
17
+ fps=$(awk '/^FPS /{print $2}' "${frames%f_}blender.log")
18
+ mkdir -p "$(dirname "$out")"
19
+ ffmpeg -hide_banner -loglevel error -y -framerate "$fps" -i "${frames}%05d.png" \
20
+ -c:v libx264 -preset slow -crf 12 -pix_fmt yuv420p -g 1 -movflags +faststart "$out"
21
+ echo "$out ($(ls "${frames%f_}" | grep -c png) frames @ ${fps} fps)"