reelson 0.7.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 (105) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +15 -0
  4. package/README.md +180 -0
  5. package/bin/reelson.js +649 -0
  6. package/bin/reelson.ts +676 -0
  7. package/bin/run.js +5 -0
  8. package/demo.config.example.json +51 -0
  9. package/docs/demo.webp +0 -0
  10. package/docs/prompting.md +72 -0
  11. package/docs/style-guide.md +117 -0
  12. package/examples/demo.config.json +18 -0
  13. package/examples/todo-add-item/scenario.ts +45 -0
  14. package/examples/todo-add-item/video.json +35 -0
  15. package/music/README.md +7 -0
  16. package/package.json +68 -0
  17. package/skills/reelson-compose/SKILL.md +226 -0
  18. package/skills/reelson-compose/schemas/video.schema.json +172 -0
  19. package/skills/reelson-compose/scripts/build.js +424 -0
  20. package/skills/reelson-compose/scripts/build.ts +532 -0
  21. package/skills/reelson-compose/scripts/captions.js +24 -0
  22. package/skills/reelson-compose/scripts/captions.ts +40 -0
  23. package/skills/reelson-compose/scripts/check.js +80 -0
  24. package/skills/reelson-compose/scripts/check.ts +93 -0
  25. package/skills/reelson-compose/scripts/composition.js +163 -0
  26. package/skills/reelson-compose/scripts/composition.ts +241 -0
  27. package/skills/reelson-compose/scripts/hyperframes.js +138 -0
  28. package/skills/reelson-compose/scripts/hyperframes.ts +157 -0
  29. package/skills/reelson-compose/scripts/portrait.js +150 -0
  30. package/skills/reelson-compose/scripts/portrait.ts +218 -0
  31. package/skills/reelson-compose/scripts/project.js +238 -0
  32. package/skills/reelson-compose/scripts/project.ts +324 -0
  33. package/skills/reelson-compose/scripts/studio.js +330 -0
  34. package/skills/reelson-compose/scripts/studio.ts +394 -0
  35. package/skills/reelson-compose/scripts/timeline.js +271 -0
  36. package/skills/reelson-compose/scripts/timeline.ts +514 -0
  37. package/skills/reelson-compose/scripts/tts.js +303 -0
  38. package/skills/reelson-compose/scripts/tts.ts +371 -0
  39. package/skills/reelson-compose/scripts/verify.js +190 -0
  40. package/skills/reelson-compose/scripts/verify.ts +209 -0
  41. package/skills/reelson-compose/scripts/voice.js +178 -0
  42. package/skills/reelson-compose/scripts/voice.ts +224 -0
  43. package/skills/reelson-compose/scripts/zooms.js +259 -0
  44. package/skills/reelson-compose/scripts/zooms.ts +333 -0
  45. package/skills/reelson-compose/sections/README.md +5 -0
  46. package/skills/reelson-compose/sections/intro/minimal/section.css +27 -0
  47. package/skills/reelson-compose/sections/intro/minimal/section.html +12 -0
  48. package/skills/reelson-compose/sections/intro/minimal/section.js +16 -0
  49. package/skills/reelson-compose/sections/intro/minimal/section.json +9 -0
  50. package/skills/reelson-compose/sections/intro/poster/section.css +27 -0
  51. package/skills/reelson-compose/sections/intro/poster/section.html +14 -0
  52. package/skills/reelson-compose/sections/intro/poster/section.js +28 -0
  53. package/skills/reelson-compose/sections/intro/poster/section.json +9 -0
  54. package/skills/reelson-compose/sections/intro/split/section.css +30 -0
  55. package/skills/reelson-compose/sections/intro/split/section.html +12 -0
  56. package/skills/reelson-compose/sections/intro/split/section.js +19 -0
  57. package/skills/reelson-compose/sections/intro/split/section.json +9 -0
  58. package/skills/reelson-compose/sections/outro/compact/section.css +23 -0
  59. package/skills/reelson-compose/sections/outro/compact/section.html +10 -0
  60. package/skills/reelson-compose/sections/outro/compact/section.js +11 -0
  61. package/skills/reelson-compose/sections/outro/compact/section.json +8 -0
  62. package/skills/reelson-compose/sections/outro/endcard/section.css +25 -0
  63. package/skills/reelson-compose/sections/outro/endcard/section.html +13 -0
  64. package/skills/reelson-compose/sections/outro/endcard/section.js +13 -0
  65. package/skills/reelson-compose/sections/outro/endcard/section.json +8 -0
  66. package/skills/reelson-compose/sections/outro/wordmark/section.css +11 -0
  67. package/skills/reelson-compose/sections/outro/wordmark/section.html +5 -0
  68. package/skills/reelson-compose/sections/outro/wordmark/section.js +12 -0
  69. package/skills/reelson-compose/sections/outro/wordmark/section.json +8 -0
  70. package/skills/reelson-compose/sections/recap/compact/section.css +26 -0
  71. package/skills/reelson-compose/sections/recap/compact/section.html +8 -0
  72. package/skills/reelson-compose/sections/recap/compact/section.js +23 -0
  73. package/skills/reelson-compose/sections/recap/compact/section.json +11 -0
  74. package/skills/reelson-compose/sections/recap/steps/section.css +36 -0
  75. package/skills/reelson-compose/sections/recap/steps/section.html +8 -0
  76. package/skills/reelson-compose/sections/recap/steps/section.js +27 -0
  77. package/skills/reelson-compose/sections/recap/steps/section.json +11 -0
  78. package/skills/reelson-compose/studio/index.html +81 -0
  79. package/skills/reelson-compose/studio/studio.css +142 -0
  80. package/skills/reelson-compose/studio/studio.js +1031 -0
  81. package/skills/reelson-compose/templates/README.md +193 -0
  82. package/skills/reelson-compose/templates/classic/assets/vendor/Inter-OFL.txt +93 -0
  83. package/skills/reelson-compose/templates/classic/assets/vendor/gsap.min.js +11 -0
  84. package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
  85. package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-wght-normal.woff2 +0 -0
  86. package/skills/reelson-compose/templates/classic/stage.html +447 -0
  87. package/skills/reelson-compose/templates/classic/template.json +5 -0
  88. package/skills/reelson-record/SKILL.md +220 -0
  89. package/skills/reelson-record/schemas/demo.config.schema.json +269 -0
  90. package/skills/reelson-record/scripts/capture.js +142 -0
  91. package/skills/reelson-record/scripts/capture.ts +190 -0
  92. package/skills/reelson-record/scripts/config.js +148 -0
  93. package/skills/reelson-record/scripts/config.ts +264 -0
  94. package/skills/reelson-record/scripts/cursor-overlay.js +111 -0
  95. package/skills/reelson-record/scripts/cursor-overlay.ts +122 -0
  96. package/skills/reelson-record/scripts/doctor.js +128 -0
  97. package/skills/reelson-record/scripts/doctor.ts +147 -0
  98. package/skills/reelson-record/scripts/languages.js +29 -0
  99. package/skills/reelson-record/scripts/languages.ts +41 -0
  100. package/skills/reelson-record/scripts/record.js +365 -0
  101. package/skills/reelson-record/scripts/record.ts +451 -0
  102. package/skills/reelson-record/scripts/scenario.js +356 -0
  103. package/skills/reelson-record/scripts/scenario.ts +570 -0
  104. package/skills/reelson-record/scripts/validate.js +143 -0
  105. package/skills/reelson-record/scripts/validate.ts +179 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Injected into every page before load. Playwright's synthetic mouse never
3
+ * moves the OS cursor, and screen/video capture never shows it either, so we
4
+ * draw our own: a large macOS-style black arrow that follows `mousemove`, a
5
+ * press "squash" and an accent-coloured expanding ring on `mousedown`. Position survives
6
+ * navigations via sessionStorage.
7
+ *
8
+ * With `report`, every move and press in the top window is also sent to the recorder
9
+ * (the CURSOR_BINDING binding) with the page's clock: the cursor layer is drawn from that
10
+ * log. With `draw: false` nothing is drawn — the video draws the cursor instead.
11
+ */
12
+ export const CURSOR_BINDING = '__reelsonCursor';
13
+ export function cursorOverlayScript(accent = '#dc2626', options = {}) {
14
+ const { draw = true, report = false } = options;
15
+ return `
16
+ (() => {
17
+ if (window.__demoCursor) return;
18
+ window.__demoCursor = true;
19
+
20
+ const REPORT = ${JSON.stringify(report)} && window.top === window;
21
+ const send = (type, e) => {
22
+ if (!REPORT) return;
23
+ const t = Date.now();
24
+ try { window[${JSON.stringify(CURSOR_BINDING)}]({ type, x: e.clientX, y: e.clientY, t }); } catch {}
25
+ };
26
+ document.addEventListener('mousemove', (e) => send('move', e), true);
27
+ document.addEventListener('mousedown', (e) => send('down', e), true);
28
+ document.addEventListener('mouseup', (e) => send('up', e), true);
29
+ if (!${JSON.stringify(draw)}) return;
30
+
31
+ const SIZE = 44; // px
32
+ const ACCENT = ${JSON.stringify(accent)};
33
+ // Arrow tip in the 24-unit viewBox is (5.5, 2.5); this is where the pointer actually is.
34
+ const TIP_X = 5.5 / 24 * SIZE;
35
+ const TIP_Y = 2.5 / 24 * SIZE;
36
+ const style = document.createElement('style');
37
+ style.textContent = \`
38
+ #__demo-cursor { position: fixed; left: 0; top: 0; width: \${SIZE}px; height: \${SIZE}px; z-index: 2147483647;
39
+ pointer-events: none; will-change: transform;
40
+ filter: drop-shadow(0 2px 3px rgba(0,0,0,.35)) drop-shadow(0 8px 16px rgba(0,0,0,.25)); }
41
+ /* The press squash scales the SVG child about the arrow tip. Never scale the
42
+ container: its scale composes outside the positioning translate and drags the
43
+ cursor toward the page origin while it animates. */
44
+ #__demo-cursor svg { display: block; transform-origin: \${TIP_X}px \${TIP_Y}px; }
45
+ #__demo-cursor.pressed svg { animation: __demo-press .22s ease-out; }
46
+ @keyframes __demo-press { 0% { transform: scale(1) } 40% { transform: scale(.82) } 100% { transform: scale(1) } }
47
+ #__demo-ripple, #__demo-ripple2 { position: fixed; border-radius: 50%; z-index: 2147483646; pointer-events: none;
48
+ transform: translate(-50%, -50%) scale(.2); opacity: 0; }
49
+ #__demo-ripple { width: 84px; height: 84px; border: 4px solid \${ACCENT}; background: color-mix(in srgb, \${ACCENT} 22%, transparent); }
50
+ #__demo-ripple2 { width: 84px; height: 84px; border: 2px solid color-mix(in srgb, \${ACCENT} 90%, transparent); }
51
+ #__demo-ripple.go { animation: __demo-ripple .55s cubic-bezier(.2,.7,.3,1) forwards; }
52
+ #__demo-ripple2.go { animation: __demo-ripple .8s .08s cubic-bezier(.2,.7,.3,1) forwards; }
53
+ @keyframes __demo-ripple { 0% { transform: translate(-50%,-50%) scale(.2); opacity: 1 }
54
+ 100% { transform: translate(-50%,-50%) scale(1.25); opacity: 0 } }
55
+ \`;
56
+
57
+ const cursor = document.createElement('div');
58
+ cursor.id = '__demo-cursor';
59
+ cursor.innerHTML = '<svg viewBox="0 0 24 24" width="' + SIZE + '" height="' + SIZE + '">'
60
+ + '<path d="M5.5 2.5v17.4l4.6-4.4 2.9 6.5 3-1.3-2.9-6.4h6.4z" fill="#111" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg>';
61
+ const ripple = document.createElement('div');
62
+ ripple.id = '__demo-ripple';
63
+ const ripple2 = document.createElement('div');
64
+ ripple2.id = '__demo-ripple2';
65
+
66
+ let saved = { x: -100, y: -100 };
67
+ try { saved = JSON.parse(sessionStorage.getItem('__demoCursorPos') || 'null') || saved; } catch {}
68
+
69
+ const place = (x, y) => {
70
+ cursor.style.transform = 'translate(' + (x - TIP_X) + 'px,' + (y - TIP_Y) + 'px)';
71
+ try { sessionStorage.setItem('__demoCursorPos', JSON.stringify({ x, y })); } catch {}
72
+ };
73
+
74
+ const mount = () => {
75
+ const host = document.documentElement;
76
+ host.appendChild(style); host.appendChild(ripple2); host.appendChild(ripple); host.appendChild(cursor);
77
+ place(saved.x, saved.y);
78
+ };
79
+ if (document.documentElement) mount(); else document.addEventListener('DOMContentLoaded', mount);
80
+
81
+ document.addEventListener('mousemove', (e) => place(e.clientX, e.clientY), true);
82
+ document.addEventListener('mousedown', (e) => {
83
+ for (const r of [ripple, ripple2]) {
84
+ r.style.left = e.clientX + 'px'; r.style.top = e.clientY + 'px';
85
+ r.classList.remove('go'); void r.offsetWidth; r.classList.add('go');
86
+ }
87
+ cursor.classList.remove('pressed'); void cursor.offsetWidth; cursor.classList.add('pressed');
88
+ }, true);
89
+ })();
90
+ `;
91
+ }
92
+ /**
93
+ * Hides local-only UI (debug toolbars, environment badges, dev-login buttons)
94
+ * in every recorded page. Selectors come from COMMON_DEV_CHROME plus the
95
+ * project's `record.hideSelectors` in demo.config.json.
96
+ */
97
+ export function hideDevChromeScript(selectors) {
98
+ if (selectors.length === 0) {
99
+ return '';
100
+ }
101
+ // One rule per selector: an invalid selector in a list drops the whole rule.
102
+ const css = selectors.map((s) => `${s} { display: none !important; }`).join('\n');
103
+ return `
104
+ (() => {
105
+ const style = document.createElement('style');
106
+ style.textContent = ${JSON.stringify(css)};
107
+ const mount = () => document.head.appendChild(style);
108
+ if (document.head) mount(); else document.addEventListener('DOMContentLoaded', mount);
109
+ })();
110
+ `;
111
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Injected into every page before load. Playwright's synthetic mouse never
3
+ * moves the OS cursor, and screen/video capture never shows it either, so we
4
+ * draw our own: a large macOS-style black arrow that follows `mousemove`, a
5
+ * press "squash" and an accent-coloured expanding ring on `mousedown`. Position survives
6
+ * navigations via sessionStorage.
7
+ *
8
+ * With `report`, every move and press in the top window is also sent to the recorder
9
+ * (the CURSOR_BINDING binding) with the page's clock: the cursor layer is drawn from that
10
+ * log. With `draw: false` nothing is drawn — the video draws the cursor instead.
11
+ */
12
+ export const CURSOR_BINDING = '__reelsonCursor'
13
+
14
+ export interface CursorEvent {
15
+ type: 'move' | 'down' | 'up'
16
+ x: number
17
+ y: number
18
+ /** Date.now() in the page when the event was handled. */
19
+ t: number
20
+ }
21
+
22
+ export function cursorOverlayScript(accent: string = '#dc2626', options: { draw?: boolean; report?: boolean } = {}): string {
23
+ const { draw = true, report = false } = options
24
+ return `
25
+ (() => {
26
+ if (window.__demoCursor) return;
27
+ window.__demoCursor = true;
28
+
29
+ const REPORT = ${JSON.stringify(report)} && window.top === window;
30
+ const send = (type, e) => {
31
+ if (!REPORT) return;
32
+ const t = Date.now();
33
+ try { window[${JSON.stringify(CURSOR_BINDING)}]({ type, x: e.clientX, y: e.clientY, t }); } catch {}
34
+ };
35
+ document.addEventListener('mousemove', (e) => send('move', e), true);
36
+ document.addEventListener('mousedown', (e) => send('down', e), true);
37
+ document.addEventListener('mouseup', (e) => send('up', e), true);
38
+ if (!${JSON.stringify(draw)}) return;
39
+
40
+ const SIZE = 44; // px
41
+ const ACCENT = ${JSON.stringify(accent)};
42
+ // Arrow tip in the 24-unit viewBox is (5.5, 2.5); this is where the pointer actually is.
43
+ const TIP_X = 5.5 / 24 * SIZE;
44
+ const TIP_Y = 2.5 / 24 * SIZE;
45
+ const style = document.createElement('style');
46
+ style.textContent = \`
47
+ #__demo-cursor { position: fixed; left: 0; top: 0; width: \${SIZE}px; height: \${SIZE}px; z-index: 2147483647;
48
+ pointer-events: none; will-change: transform;
49
+ filter: drop-shadow(0 2px 3px rgba(0,0,0,.35)) drop-shadow(0 8px 16px rgba(0,0,0,.25)); }
50
+ /* The press squash scales the SVG child about the arrow tip. Never scale the
51
+ container: its scale composes outside the positioning translate and drags the
52
+ cursor toward the page origin while it animates. */
53
+ #__demo-cursor svg { display: block; transform-origin: \${TIP_X}px \${TIP_Y}px; }
54
+ #__demo-cursor.pressed svg { animation: __demo-press .22s ease-out; }
55
+ @keyframes __demo-press { 0% { transform: scale(1) } 40% { transform: scale(.82) } 100% { transform: scale(1) } }
56
+ #__demo-ripple, #__demo-ripple2 { position: fixed; border-radius: 50%; z-index: 2147483646; pointer-events: none;
57
+ transform: translate(-50%, -50%) scale(.2); opacity: 0; }
58
+ #__demo-ripple { width: 84px; height: 84px; border: 4px solid \${ACCENT}; background: color-mix(in srgb, \${ACCENT} 22%, transparent); }
59
+ #__demo-ripple2 { width: 84px; height: 84px; border: 2px solid color-mix(in srgb, \${ACCENT} 90%, transparent); }
60
+ #__demo-ripple.go { animation: __demo-ripple .55s cubic-bezier(.2,.7,.3,1) forwards; }
61
+ #__demo-ripple2.go { animation: __demo-ripple .8s .08s cubic-bezier(.2,.7,.3,1) forwards; }
62
+ @keyframes __demo-ripple { 0% { transform: translate(-50%,-50%) scale(.2); opacity: 1 }
63
+ 100% { transform: translate(-50%,-50%) scale(1.25); opacity: 0 } }
64
+ \`;
65
+
66
+ const cursor = document.createElement('div');
67
+ cursor.id = '__demo-cursor';
68
+ cursor.innerHTML = '<svg viewBox="0 0 24 24" width="' + SIZE + '" height="' + SIZE + '">'
69
+ + '<path d="M5.5 2.5v17.4l4.6-4.4 2.9 6.5 3-1.3-2.9-6.4h6.4z" fill="#111" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg>';
70
+ const ripple = document.createElement('div');
71
+ ripple.id = '__demo-ripple';
72
+ const ripple2 = document.createElement('div');
73
+ ripple2.id = '__demo-ripple2';
74
+
75
+ let saved = { x: -100, y: -100 };
76
+ try { saved = JSON.parse(sessionStorage.getItem('__demoCursorPos') || 'null') || saved; } catch {}
77
+
78
+ const place = (x, y) => {
79
+ cursor.style.transform = 'translate(' + (x - TIP_X) + 'px,' + (y - TIP_Y) + 'px)';
80
+ try { sessionStorage.setItem('__demoCursorPos', JSON.stringify({ x, y })); } catch {}
81
+ };
82
+
83
+ const mount = () => {
84
+ const host = document.documentElement;
85
+ host.appendChild(style); host.appendChild(ripple2); host.appendChild(ripple); host.appendChild(cursor);
86
+ place(saved.x, saved.y);
87
+ };
88
+ if (document.documentElement) mount(); else document.addEventListener('DOMContentLoaded', mount);
89
+
90
+ document.addEventListener('mousemove', (e) => place(e.clientX, e.clientY), true);
91
+ document.addEventListener('mousedown', (e) => {
92
+ for (const r of [ripple, ripple2]) {
93
+ r.style.left = e.clientX + 'px'; r.style.top = e.clientY + 'px';
94
+ r.classList.remove('go'); void r.offsetWidth; r.classList.add('go');
95
+ }
96
+ cursor.classList.remove('pressed'); void cursor.offsetWidth; cursor.classList.add('pressed');
97
+ }, true);
98
+ })();
99
+ `
100
+ }
101
+
102
+ /**
103
+ * Hides local-only UI (debug toolbars, environment badges, dev-login buttons)
104
+ * in every recorded page. Selectors come from COMMON_DEV_CHROME plus the
105
+ * project's `record.hideSelectors` in demo.config.json.
106
+ */
107
+ export function hideDevChromeScript(selectors: string[]): string {
108
+ if (selectors.length === 0) {
109
+ return ''
110
+ }
111
+ // One rule per selector: an invalid selector in a list drops the whole rule.
112
+ const css = selectors.map((s) => `${s} { display: none !important; }`).join('\n')
113
+
114
+ return `
115
+ (() => {
116
+ const style = document.createElement('style');
117
+ style.textContent = ${JSON.stringify(css)};
118
+ const mount = () => document.head.appendChild(style);
119
+ if (document.head) mount(); else document.addEventListener('DOMContentLoaded', mount);
120
+ })();
121
+ `
122
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `reelson doctor`: is this machine ready to record and render, and does the cursor layer
3
+ * line up with the footage here? Checks the tools, then films a local test page the way
4
+ * `reelson record` does (screencast + the page's cursor log) with a magenta dot drawn at the
5
+ * pointer, finds the dot in every frame and measures how far the logged cursor is from it.
6
+ */
7
+ import { spawnSync } from 'node:child_process';
8
+ import { mkdtempSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join, resolve } from 'node:path';
11
+ import { startScreencast } from "./capture.js";
12
+ import { CURSOR_BINDING, cursorOverlayScript } from "./cursor-overlay.js";
13
+ export async function doctor(log = console.log) {
14
+ let problems = 0;
15
+ const check = (ok, good, bad) => {
16
+ log(`${ok ? '✓' : '✗'} ${ok ? good : bad}`);
17
+ if (!ok)
18
+ problems++;
19
+ };
20
+ const [major, minor] = process.versions.node.split('.').map(Number);
21
+ check(major > 22 || (major === 22 && minor >= 18), `Node ${process.versions.node}`, `Node ${process.versions.node} — reelson needs 22.18+`);
22
+ for (const tool of ['ffmpeg', 'ffprobe']) {
23
+ const found = spawnSync(tool, ['-version'], { encoding: 'utf8' });
24
+ check(found.status === 0, `${found.stdout.split('\n')[0]}`, `${tool} not found — brew install ffmpeg`);
25
+ }
26
+ try {
27
+ const { hyperframesDist, HYPERFRAMES_VERSION } = await import("../../reelson-compose/scripts/hyperframes.js");
28
+ hyperframesDist();
29
+ check(true, `HyperFrames ${HYPERFRAMES_VERSION}`, '');
30
+ }
31
+ catch (error) {
32
+ check(false, '', `HyperFrames: ${error.message}`);
33
+ }
34
+ try {
35
+ const sync = await measureCursorSync();
36
+ check(sync.fps >= 30, `screencast: ${sync.fps.toFixed(0)} fps at 2x`, `screencast only ${sync.fps.toFixed(0)} fps — footage will stutter; try record.capture "playwright"`);
37
+ check(Math.abs(sync.lagMs) <= 34 && sync.errorPx <= 3, `cursor layer lines up with the footage: offset ${sync.lagMs} ms, ${sync.errorPx.toFixed(1)} px (${sync.frames} frames)`, `cursor layer is off by ${sync.lagMs} ms / ${sync.errorPx.toFixed(1)} px on this machine — use record.cursor "recorded", and report it`);
38
+ }
39
+ catch (error) {
40
+ check(false, '', `could not film a test page: ${error.message} — npx playwright install chromium`);
41
+ }
42
+ log(problems ? `\n${problems} problem(s)` : '\nready to record and render');
43
+ return problems;
44
+ }
45
+ const PAGE = `<body style="margin:0;background:#fff"><div id="dot" style="position:fixed;left:0;top:0;width:8px;height:8px;background:#f0f;transform:translate(-99px,-99px)"></div>
46
+ <script>document.addEventListener('mousemove', (e) => { dot.style.transform = 'translate(' + (e.clientX - 4) + 'px,' + (e.clientY - 4) + 'px)' }, true)</script></body>`;
47
+ /** Films a local page with a pointer moving over it; see the file comment. */
48
+ export async function measureCursorSync() {
49
+ const { chromium } = await import('@playwright/test');
50
+ const dir = mkdtempSync(join(tmpdir(), 'reelson-doctor-'));
51
+ const viewport = { width: 1440, height: 900 };
52
+ const browser = await chromium.launch({ args: ['--force-device-scale-factor=2'] });
53
+ try {
54
+ const context = await browser.newContext({ viewport, deviceScaleFactor: 2 });
55
+ const events = [];
56
+ await context.exposeBinding(CURSOR_BINDING, (_source, event) => {
57
+ events.push(event);
58
+ });
59
+ await context.addInitScript(cursorOverlayScript('#6366f1', { draw: false, report: true }));
60
+ const page = await context.newPage();
61
+ // A real navigation (setContent would replace the document the cursor log listens on).
62
+ await page.route('https://reelson.doctor/', (route) => route.fulfill({ contentType: 'text/html', body: PAGE }));
63
+ await page.goto('https://reelson.doctor/');
64
+ const filming = await startScreencast(context, page, dir);
65
+ await page.mouse.move(200, 200);
66
+ for (let i = 0; i < 150; i++) {
67
+ const u = i / 150;
68
+ await page.mouse.move(200 + 1000 * u, 200 + 400 * Math.sin(u * Math.PI * 2) * 0.5 + 200 * u);
69
+ await page.waitForTimeout(16);
70
+ }
71
+ const frames = await filming.stop();
72
+ return analyse(frames, events.filter((e) => e.type === 'move'), viewport);
73
+ }
74
+ finally {
75
+ await browser.close();
76
+ rmSync(dir, { recursive: true, force: true });
77
+ }
78
+ }
79
+ function analyse(frames, moves, viewport) {
80
+ const { width: w, height: h } = viewport;
81
+ // Decode every frame at CSS size in one ffmpeg run, then find the magenta dot in each.
82
+ const decoded = spawnSync('ffmpeg', ['-loglevel', 'error', '-i', resolve(frames[0].file, '..', 'f%06d.jpg'), '-vf', `scale=${w}:${h}`, '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'], { maxBuffer: w * h * 3 * (frames.length + 1) });
83
+ const size = w * h * 3;
84
+ const dots = [];
85
+ frames.forEach((frame, i) => {
86
+ const px = decoded.stdout.subarray(i * size, (i + 1) * size);
87
+ let sx = 0;
88
+ let sy = 0;
89
+ let n = 0;
90
+ for (let p = 0; p + 2 < px.length; p += 3) {
91
+ if (px[p] > 180 && px[p + 2] > 180 && px[p + 1] < 90) {
92
+ const k = p / 3;
93
+ sx += k % w;
94
+ sy += Math.floor(k / w);
95
+ n++;
96
+ }
97
+ }
98
+ if (n >= 12) {
99
+ dots.push({ t: frame.t, x: sx / n, y: sy / n });
100
+ }
101
+ });
102
+ const at = (t) => {
103
+ let last;
104
+ for (const m of moves) {
105
+ if (m.t <= t)
106
+ last = m;
107
+ else
108
+ break;
109
+ }
110
+ return last;
111
+ };
112
+ const errorAt = (lag) => {
113
+ const errors = dots.flatMap((d) => {
114
+ const m = at(d.t - lag);
115
+ return m ? [Math.hypot(d.x - m.x, d.y - m.y)] : [];
116
+ });
117
+ errors.sort((a, b) => a - b);
118
+ return errors.length ? errors[errors.length >> 1] : Infinity;
119
+ };
120
+ let best = { lag: 0, error: errorAt(0) };
121
+ for (let lag = -100; lag <= 100; lag += 4) {
122
+ const error = errorAt(lag);
123
+ if (error < best.error - 0.05)
124
+ best = { lag, error };
125
+ }
126
+ const span = (frames.at(-1).t - frames[0].t) / 1000;
127
+ return { fps: frames.length / Math.max(span, 0.001), lagMs: best.lag, errorPx: best.error, frames: dots.length };
128
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `reelson doctor`: is this machine ready to record and render, and does the cursor layer
3
+ * line up with the footage here? Checks the tools, then films a local test page the way
4
+ * `reelson record` does (screencast + the page's cursor log) with a magenta dot drawn at the
5
+ * pointer, finds the dot in every frame and measures how far the logged cursor is from it.
6
+ */
7
+ import { spawnSync } from 'node:child_process'
8
+ import { mkdtempSync, rmSync } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { join, resolve } from 'node:path'
11
+ import { startScreencast, type Frame } from './capture.ts'
12
+ import { CURSOR_BINDING, cursorOverlayScript, type CursorEvent } from './cursor-overlay.ts'
13
+
14
+ export interface SyncResult {
15
+ /** Frames per second while the page moves. */
16
+ fps: number
17
+ /** Video time − logged time that fits best (ms); ~0 means the layer sits on the footage. */
18
+ lagMs: number
19
+ /** Median distance (CSS px) between the logged cursor and the drawn dot at that lag. */
20
+ errorPx: number
21
+ frames: number
22
+ }
23
+
24
+ type Log = (line: string) => void
25
+
26
+ export async function doctor(log: Log = console.log): Promise<number> {
27
+ let problems = 0
28
+ const check = (ok: boolean, good: string, bad: string): void => {
29
+ log(`${ok ? '✓' : '✗'} ${ok ? good : bad}`)
30
+ if (!ok) problems++
31
+ }
32
+ const [major, minor] = process.versions.node.split('.').map(Number)
33
+ check(major > 22 || (major === 22 && minor >= 18), `Node ${process.versions.node}`, `Node ${process.versions.node} — reelson needs 22.18+`)
34
+ for (const tool of ['ffmpeg', 'ffprobe']) {
35
+ const found = spawnSync(tool, ['-version'], { encoding: 'utf8' })
36
+ check(found.status === 0, `${found.stdout.split('\n')[0]}`, `${tool} not found — brew install ffmpeg`)
37
+ }
38
+ try {
39
+ const { hyperframesDist, HYPERFRAMES_VERSION } = await import('../../reelson-compose/scripts/hyperframes.ts')
40
+ hyperframesDist()
41
+ check(true, `HyperFrames ${HYPERFRAMES_VERSION}`, '')
42
+ } catch (error) {
43
+ check(false, '', `HyperFrames: ${(error as Error).message}`)
44
+ }
45
+ try {
46
+ const sync = await measureCursorSync()
47
+ check(sync.fps >= 30, `screencast: ${sync.fps.toFixed(0)} fps at 2x`, `screencast only ${sync.fps.toFixed(0)} fps — footage will stutter; try record.capture "playwright"`)
48
+ check(
49
+ Math.abs(sync.lagMs) <= 34 && sync.errorPx <= 3,
50
+ `cursor layer lines up with the footage: offset ${sync.lagMs} ms, ${sync.errorPx.toFixed(1)} px (${sync.frames} frames)`,
51
+ `cursor layer is off by ${sync.lagMs} ms / ${sync.errorPx.toFixed(1)} px on this machine — use record.cursor "recorded", and report it`,
52
+ )
53
+ } catch (error) {
54
+ check(false, '', `could not film a test page: ${(error as Error).message} — npx playwright install chromium`)
55
+ }
56
+ log(problems ? `\n${problems} problem(s)` : '\nready to record and render')
57
+ return problems
58
+ }
59
+
60
+ const PAGE = `<body style="margin:0;background:#fff"><div id="dot" style="position:fixed;left:0;top:0;width:8px;height:8px;background:#f0f;transform:translate(-99px,-99px)"></div>
61
+ <script>document.addEventListener('mousemove', (e) => { dot.style.transform = 'translate(' + (e.clientX - 4) + 'px,' + (e.clientY - 4) + 'px)' }, true)</script></body>`
62
+
63
+ /** Films a local page with a pointer moving over it; see the file comment. */
64
+ export async function measureCursorSync(): Promise<SyncResult> {
65
+ const { chromium } = await import('@playwright/test')
66
+ const dir = mkdtempSync(join(tmpdir(), 'reelson-doctor-'))
67
+ const viewport = { width: 1440, height: 900 }
68
+ const browser = await chromium.launch({ args: ['--force-device-scale-factor=2'] })
69
+ try {
70
+ const context = await browser.newContext({ viewport, deviceScaleFactor: 2 })
71
+ const events: CursorEvent[] = []
72
+ await context.exposeBinding(CURSOR_BINDING, (_source, event: CursorEvent) => {
73
+ events.push(event)
74
+ })
75
+ await context.addInitScript(cursorOverlayScript('#6366f1', { draw: false, report: true }))
76
+ const page = await context.newPage()
77
+ // A real navigation (setContent would replace the document the cursor log listens on).
78
+ await page.route('https://reelson.doctor/', (route) => route.fulfill({ contentType: 'text/html', body: PAGE }))
79
+ await page.goto('https://reelson.doctor/')
80
+ const filming = await startScreencast(context, page, dir)
81
+
82
+ await page.mouse.move(200, 200)
83
+ for (let i = 0; i < 150; i++) {
84
+ const u = i / 150
85
+ await page.mouse.move(200 + 1000 * u, 200 + 400 * Math.sin(u * Math.PI * 2) * 0.5 + 200 * u)
86
+ await page.waitForTimeout(16)
87
+ }
88
+ const frames = await filming.stop()
89
+ return analyse(frames, events.filter((e) => e.type === 'move'), viewport)
90
+ } finally {
91
+ await browser.close()
92
+ rmSync(dir, { recursive: true, force: true })
93
+ }
94
+ }
95
+
96
+ function analyse(frames: Frame[], moves: CursorEvent[], viewport: { width: number; height: number }): SyncResult {
97
+ const { width: w, height: h } = viewport
98
+ // Decode every frame at CSS size in one ffmpeg run, then find the magenta dot in each.
99
+ const decoded = spawnSync(
100
+ 'ffmpeg',
101
+ ['-loglevel', 'error', '-i', resolve(frames[0].file, '..', 'f%06d.jpg'), '-vf', `scale=${w}:${h}`, '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'],
102
+ { maxBuffer: w * h * 3 * (frames.length + 1) },
103
+ )
104
+ const size = w * h * 3
105
+ const dots: { t: number; x: number; y: number }[] = []
106
+ frames.forEach((frame, i) => {
107
+ const px = decoded.stdout.subarray(i * size, (i + 1) * size)
108
+ let sx = 0
109
+ let sy = 0
110
+ let n = 0
111
+ for (let p = 0; p + 2 < px.length; p += 3) {
112
+ if (px[p] > 180 && px[p + 2] > 180 && px[p + 1] < 90) {
113
+ const k = p / 3
114
+ sx += k % w
115
+ sy += Math.floor(k / w)
116
+ n++
117
+ }
118
+ }
119
+ if (n >= 12) {
120
+ dots.push({ t: frame.t, x: sx / n, y: sy / n })
121
+ }
122
+ })
123
+ const at = (t: number): CursorEvent | undefined => {
124
+ let last: CursorEvent | undefined
125
+ for (const m of moves) {
126
+ if (m.t <= t) last = m
127
+ else break
128
+ }
129
+ return last
130
+ }
131
+ const errorAt = (lag: number): number => {
132
+ const errors = dots.flatMap((d) => {
133
+ const m = at(d.t - lag)
134
+ return m ? [Math.hypot(d.x - m.x, d.y - m.y)] : []
135
+ })
136
+ errors.sort((a, b) => a - b)
137
+ return errors.length ? errors[errors.length >> 1] : Infinity
138
+ }
139
+ let best = { lag: 0, error: errorAt(0) }
140
+ for (let lag = -100; lag <= 100; lag += 4) {
141
+ const error = errorAt(lag)
142
+ if (error < best.error - 0.05) best = { lag, error }
143
+ }
144
+ const span = (frames.at(-1)!.t - frames[0].t) / 1000
145
+
146
+ return { fps: frames.length / Math.max(span, 0.001), lagMs: best.lag, errorPx: best.error, frames: dots.length }
147
+ }
@@ -0,0 +1,29 @@
1
+ const table = (recapTitle, steps, seconds) => ({
2
+ recapTitle,
3
+ stepsLabel: steps,
4
+ secondsLabel: seconds,
5
+ });
6
+ export const LANGUAGE_STRINGS = {
7
+ en: table('In short', { one: 'step', other: 'steps' }, { one: 'second', other: 'seconds' }),
8
+ ro: table('Pe scurt', { one: 'pas', few: 'pași', other: 'de pași' }, { one: 'secundă', few: 'secunde', other: 'de secunde' }),
9
+ de: table('Kurz gesagt', { one: 'Schritt', other: 'Schritte' }, { one: 'Sekunde', other: 'Sekunden' }),
10
+ fr: table('En bref', { one: 'étape', other: 'étapes' }, { one: 'seconde', other: 'secondes' }),
11
+ es: table('En resumen', { one: 'paso', other: 'pasos' }, { one: 'segundo', other: 'segundos' }),
12
+ it: table('In breve', { one: 'passaggio', other: 'passaggi' }, { one: 'secondo', other: 'secondi' }),
13
+ pt: table('Em resumo', { one: 'passo', other: 'passos' }, { one: 'segundo', other: 'segundos' }),
14
+ nl: table('Kort gezegd', { one: 'stap', other: 'stappen' }, { one: 'seconde', other: 'seconden' }),
15
+ pl: table('W skrócie', { one: 'krok', few: 'kroki', many: 'kroków', other: 'kroku' }, { one: 'sekunda', few: 'sekundy', many: 'sekund', other: 'sekundy' }),
16
+ ru: table('Коротко', { one: 'шаг', few: 'шага', many: 'шагов', other: 'шага' }, { one: 'секунда', few: 'секунды', many: 'секунд', other: 'секунды' }),
17
+ uk: table('Коротко', { one: 'крок', few: 'кроки', many: 'кроків', other: 'кроку' }, { one: 'секунда', few: 'секунди', many: 'секунд', other: 'секунди' }),
18
+ cs: table('Ve zkratce', { one: 'krok', few: 'kroky', many: 'kroku', other: 'kroků' }, { one: 'sekunda', few: 'sekundy', many: 'sekundy', other: 'sekund' }),
19
+ sv: table('I korthet', { one: 'steg', other: 'steg' }, { one: 'sekund', other: 'sekunder' }),
20
+ da: table('Kort fortalt', { one: 'trin', other: 'trin' }, { one: 'sekund', other: 'sekunder' }),
21
+ nb: table('Kort oppsummert', { one: 'steg', other: 'steg' }, { one: 'sekund', other: 'sekunder' }),
22
+ fi: table('Lyhyesti', { one: 'vaihe', other: 'vaihetta' }, { one: 'sekunti', other: 'sekuntia' }),
23
+ hu: table('Röviden', { one: 'lépés', other: 'lépés' }, { one: 'másodperc', other: 'másodperc' }),
24
+ tr: table('Kısaca', { one: 'adım', other: 'adım' }, { one: 'saniye', other: 'saniye' }),
25
+ };
26
+ /** The built-in strings for `language` ("pt-BR" falls back to "pt"), or English. */
27
+ export function stringsFor(language) {
28
+ return LANGUAGE_STRINGS[language] ?? LANGUAGE_STRINGS[language.split('-')[0]] ?? LANGUAGE_STRINGS.en;
29
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * On-card text per UI language: the recap title and the plural forms of the cover chip
3
+ * ("4 steps · 27 seconds"), keyed by Intl.PluralRules category. demo.config.json `language`
4
+ * picks one; `strings` in demo.config.json still overrides any of it. Forms are the ones
5
+ * used right after a number (Hungarian and Turkish keep the singular, Finnish the partitive).
6
+ */
7
+ import type { DemoConfig } from './config.ts'
8
+
9
+ type Strings = DemoConfig['strings']
10
+
11
+ const table = (recapTitle: string, steps: Record<string, string>, seconds: Record<string, string>): Strings => ({
12
+ recapTitle,
13
+ stepsLabel: steps as Strings['stepsLabel'],
14
+ secondsLabel: seconds as Strings['secondsLabel'],
15
+ })
16
+
17
+ export const LANGUAGE_STRINGS: Record<string, Strings> = {
18
+ en: table('In short', { one: 'step', other: 'steps' }, { one: 'second', other: 'seconds' }),
19
+ ro: table('Pe scurt', { one: 'pas', few: 'pași', other: 'de pași' }, { one: 'secundă', few: 'secunde', other: 'de secunde' }),
20
+ de: table('Kurz gesagt', { one: 'Schritt', other: 'Schritte' }, { one: 'Sekunde', other: 'Sekunden' }),
21
+ fr: table('En bref', { one: 'étape', other: 'étapes' }, { one: 'seconde', other: 'secondes' }),
22
+ es: table('En resumen', { one: 'paso', other: 'pasos' }, { one: 'segundo', other: 'segundos' }),
23
+ it: table('In breve', { one: 'passaggio', other: 'passaggi' }, { one: 'secondo', other: 'secondi' }),
24
+ pt: table('Em resumo', { one: 'passo', other: 'passos' }, { one: 'segundo', other: 'segundos' }),
25
+ nl: table('Kort gezegd', { one: 'stap', other: 'stappen' }, { one: 'seconde', other: 'seconden' }),
26
+ pl: table('W skrócie', { one: 'krok', few: 'kroki', many: 'kroków', other: 'kroku' }, { one: 'sekunda', few: 'sekundy', many: 'sekund', other: 'sekundy' }),
27
+ ru: table('Коротко', { one: 'шаг', few: 'шага', many: 'шагов', other: 'шага' }, { one: 'секунда', few: 'секунды', many: 'секунд', other: 'секунды' }),
28
+ uk: table('Коротко', { one: 'крок', few: 'кроки', many: 'кроків', other: 'кроку' }, { one: 'секунда', few: 'секунди', many: 'секунд', other: 'секунди' }),
29
+ cs: table('Ve zkratce', { one: 'krok', few: 'kroky', many: 'kroku', other: 'kroků' }, { one: 'sekunda', few: 'sekundy', many: 'sekundy', other: 'sekund' }),
30
+ sv: table('I korthet', { one: 'steg', other: 'steg' }, { one: 'sekund', other: 'sekunder' }),
31
+ da: table('Kort fortalt', { one: 'trin', other: 'trin' }, { one: 'sekund', other: 'sekunder' }),
32
+ nb: table('Kort oppsummert', { one: 'steg', other: 'steg' }, { one: 'sekund', other: 'sekunder' }),
33
+ fi: table('Lyhyesti', { one: 'vaihe', other: 'vaihetta' }, { one: 'sekunti', other: 'sekuntia' }),
34
+ hu: table('Röviden', { one: 'lépés', other: 'lépés' }, { one: 'másodperc', other: 'másodperc' }),
35
+ tr: table('Kısaca', { one: 'adım', other: 'adım' }, { one: 'saniye', other: 'saniye' }),
36
+ }
37
+
38
+ /** The built-in strings for `language` ("pt-BR" falls back to "pt"), or English. */
39
+ export function stringsFor(language: string): Strings {
40
+ return LANGUAGE_STRINGS[language] ?? LANGUAGE_STRINGS[language.split('-')[0]] ?? LANGUAGE_STRINGS.en
41
+ }