@openvidstudio/mcp-server 0.1.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 (94) hide show
  1. package/dist/beats.d.ts +47 -0
  2. package/dist/beats.js +9 -0
  3. package/dist/capture.d.ts +116 -0
  4. package/dist/capture.js +148 -0
  5. package/dist/config.d.ts +58 -0
  6. package/dist/config.js +130 -0
  7. package/dist/paths.d.ts +20 -0
  8. package/dist/paths.js +57 -0
  9. package/dist/scenes/pngSize.d.ts +11 -0
  10. package/dist/scenes/pngSize.js +75 -0
  11. package/dist/scenes/templates.d.ts +42 -0
  12. package/dist/scenes/templates.js +468 -0
  13. package/dist/server.d.ts +16 -0
  14. package/dist/server.js +37 -0
  15. package/dist/stdio.d.ts +2 -0
  16. package/dist/stdio.js +33 -0
  17. package/dist/tools/captureScreenRecording.d.ts +28 -0
  18. package/dist/tools/captureScreenRecording.js +181 -0
  19. package/dist/tools/captureScreenshot.d.ts +34 -0
  20. package/dist/tools/captureScreenshot.js +148 -0
  21. package/dist/tools/contactSheet.d.ts +36 -0
  22. package/dist/tools/contactSheet.js +116 -0
  23. package/dist/tools/generateNarration.d.ts +54 -0
  24. package/dist/tools/generateNarration.js +185 -0
  25. package/dist/tools/importHiggsfieldClip.d.ts +41 -0
  26. package/dist/tools/importHiggsfieldClip.js +108 -0
  27. package/dist/tools/index.d.ts +13 -0
  28. package/dist/tools/index.js +47 -0
  29. package/dist/tools/initProject.d.ts +37 -0
  30. package/dist/tools/initProject.js +166 -0
  31. package/dist/tools/mcp.d.ts +33 -0
  32. package/dist/tools/mcp.js +36 -0
  33. package/dist/tools/planBeats.d.ts +40 -0
  34. package/dist/tools/planBeats.js +143 -0
  35. package/dist/tools/preflight.d.ts +30 -0
  36. package/dist/tools/preflight.js +170 -0
  37. package/dist/tools/qcExtractFrames.d.ts +25 -0
  38. package/dist/tools/qcExtractFrames.js +129 -0
  39. package/dist/tools/renderVideo.d.ts +43 -0
  40. package/dist/tools/renderVideo.js +120 -0
  41. package/dist/tools/scaffoldScene.d.ts +23 -0
  42. package/dist/tools/scaffoldScene.js +147 -0
  43. package/dist/tools/stitchComposition.d.ts +24 -0
  44. package/dist/tools/stitchComposition.js +230 -0
  45. package/dist/tools/validateBeats.d.ts +14 -0
  46. package/dist/tools/validateBeats.js +164 -0
  47. package/dist/tools/validateScenes.d.ts +31 -0
  48. package/dist/tools/validateScenes.js +191 -0
  49. package/dist/tools/writeBeatsFile.d.ts +20 -0
  50. package/dist/tools/writeBeatsFile.js +81 -0
  51. package/dist/util.d.ts +43 -0
  52. package/dist/util.js +163 -0
  53. package/docs/API.md +344 -0
  54. package/docs/CAPTURE.md +106 -0
  55. package/docs/HIGGSFIELD.md +118 -0
  56. package/docs/NARRATION.md +111 -0
  57. package/docs/OVERVIEW.md +112 -0
  58. package/docs/PIPELINE.md +173 -0
  59. package/docs/PLANNING.md +206 -0
  60. package/docs/SCRIPT.md +50 -0
  61. package/docs/STYLE.md +88 -0
  62. package/package.json +54 -0
  63. package/templates/default/CLAUDE.md +63 -0
  64. package/templates/default/openvidstudio-core/package.json +51 -0
  65. package/templates/default/openvidstudio-core/scripts/gen-sfx.sh +74 -0
  66. package/templates/default/openvidstudio-core/src/ChecklistPanel.tsx +97 -0
  67. package/templates/default/openvidstudio-core/src/CinematicScene.tsx +53 -0
  68. package/templates/default/openvidstudio-core/src/CodePanel.tsx +122 -0
  69. package/templates/default/openvidstudio-core/src/CursorActor.tsx +90 -0
  70. package/templates/default/openvidstudio-core/src/DeviceFrame.tsx +74 -0
  71. package/templates/default/openvidstudio-core/src/KineticText.tsx +142 -0
  72. package/templates/default/openvidstudio-core/src/RepoCta.tsx +128 -0
  73. package/templates/default/openvidstudio-core/src/TerminalReplay.tsx +263 -0
  74. package/templates/default/openvidstudio-core/src/camera.tsx +141 -0
  75. package/templates/default/openvidstudio-core/src/index.ts +60 -0
  76. package/templates/default/openvidstudio-core/src/look.tsx +90 -0
  77. package/templates/default/openvidstudio-core/src/motion.ts +50 -0
  78. package/templates/default/openvidstudio-core/src/sfx.tsx +106 -0
  79. package/templates/default/openvidstudio-core/src/tokens.ts +41 -0
  80. package/templates/default/package.json +31 -0
  81. package/templates/default/public/sfx/bell.wav +0 -0
  82. package/templates/default/public/sfx/blip.wav +0 -0
  83. package/templates/default/public/sfx/click.wav +0 -0
  84. package/templates/default/public/sfx/key_a.wav +0 -0
  85. package/templates/default/public/sfx/key_b.wav +0 -0
  86. package/templates/default/public/sfx/key_c.wav +0 -0
  87. package/templates/default/public/sfx/key_enter.wav +0 -0
  88. package/templates/default/public/sfx/music-bed.mp3 +0 -0
  89. package/templates/default/public/sfx/success.wav +0 -0
  90. package/templates/default/public/sfx/whoosh.wav +0 -0
  91. package/templates/default/remotion.config.ts +13 -0
  92. package/templates/default/src/Root.tsx +9 -0
  93. package/templates/default/src/index.ts +4 -0
  94. package/templates/default/tsconfig.json +16 -0
package/dist/util.js ADDED
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.resolveProjectRoot = resolveProjectRoot;
37
+ exports.sanitizeSegment = sanitizeSegment;
38
+ exports.pascalCase = pascalCase;
39
+ exports.sanitizeRelativeOutPath = sanitizeRelativeOutPath;
40
+ exports.copyTemplateTree = copyTemplateTree;
41
+ exports.spawnCapture = spawnCapture;
42
+ const fs = __importStar(require("node:fs"));
43
+ const path = __importStar(require("node:path"));
44
+ const node_child_process_1 = require("node:child_process");
45
+ function resolveProjectRoot(projectRoot) {
46
+ return projectRoot ? path.resolve(projectRoot) : process.cwd();
47
+ }
48
+ const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
49
+ /**
50
+ * Validates a value used as a single path segment (project/video name, beat id)
51
+ * before it's joined into any filesystem path or process argv. Rejects `..`,
52
+ * `/`, `\`, and anything else outside a conservative safe set.
53
+ */
54
+ function sanitizeSegment(value, label) {
55
+ if (!SAFE_SEGMENT.test(value)) {
56
+ throw new Error(`${label} "${value}" is not a safe path segment. Use letters, numbers, "-", or "_", starting with a letter or number.`);
57
+ }
58
+ return value;
59
+ }
60
+ /**
61
+ * PascalCases an id for use as a component/file name: split on any run of
62
+ * non-alphanumeric characters, capitalize the first letter of each remaining
63
+ * segment, join. A result starting with a digit gets an "S" prefix (invalid
64
+ * JS identifier otherwise); an empty result (all-symbol id) falls back to "Scene".
65
+ * scaffold_scene and stitch_composition both call this, so a beat's scene file name
66
+ * and its import in the generated composition always agree.
67
+ */
68
+ function pascalCase(id) {
69
+ const parts = id.split(/[^A-Za-z0-9]+/).filter(Boolean);
70
+ const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
71
+ const safe = pascal.length > 0 ? pascal : "Scene";
72
+ return /^[0-9]/.test(safe) ? `S${safe}` : safe;
73
+ }
74
+ // Blocks shell metacharacters relevant on POSIX (;&|`$<>) and on Windows cmd.exe
75
+ // specifically (%^"), which matters here because render_video spawns npx.cmd --
76
+ // Windows can only execute a .cmd/.bat file via cmd.exe, and spawnCapture (below)
77
+ // explicitly routes that one case through shell:true, so cmd.exe's own
78
+ // metacharacters are in scope for any argument reaching that spawn call.
79
+ const DANGEROUS_CHARS = /[;&|`$<>\n\r%^"]/;
80
+ /**
81
+ * Validates a path argument that will be handed to a spawned child process
82
+ * (npx/ffmpeg): no shell metacharacters, and must resolve inside `root` so a
83
+ * crafted `../../` can't point the command at an arbitrary filesystem location.
84
+ */
85
+ function sanitizeRelativeOutPath(root, relPath, label) {
86
+ if (DANGEROUS_CHARS.test(relPath)) {
87
+ throw new Error(`${label} "${relPath}" contains disallowed characters.`);
88
+ }
89
+ const rootResolved = path.resolve(root);
90
+ const resolved = path.resolve(rootResolved, relPath);
91
+ if (resolved !== rootResolved && !resolved.startsWith(rootResolved + path.sep)) {
92
+ throw new Error(`${label} "${relPath}" must resolve inside the project root.`);
93
+ }
94
+ return relPath;
95
+ }
96
+ /**
97
+ * Copies a directory tree, skipping any `node_modules` subtree and never
98
+ * overwriting a file that already exists at the destination -- used by
99
+ * init_project to lay the bundled template shell into a user's project without
100
+ * clobbering anything the dev (or a prior init_project call, for a second video
101
+ * in the same project) has already touched.
102
+ */
103
+ function copyTemplateTree(src, dest) {
104
+ if (!fs.existsSync(src)) {
105
+ throw new Error(`Template directory not found at ${src}. This package's build step (scripts/copy-template.mjs) may not have run.`);
106
+ }
107
+ fs.mkdirSync(dest, { recursive: true });
108
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
109
+ if (entry.name === "node_modules")
110
+ continue;
111
+ const s = path.join(src, entry.name);
112
+ const d = path.join(dest, entry.name);
113
+ if (entry.isDirectory()) {
114
+ copyTemplateTree(s, d);
115
+ }
116
+ else if (!fs.existsSync(d)) {
117
+ fs.copyFileSync(s, d);
118
+ }
119
+ }
120
+ }
121
+ // On Windows, spawning a `.cmd`/`.bat` shim (npx.cmd, the only such command any caller of
122
+ // spawnCapture ever passes -- ffmpeg/ffprobe are real .exe) with shell:false throws a
123
+ // synchronous EINVAL: CreateProcess cannot launch a batch file directly, and (verified
124
+ // against this repo's actual Node runtime, Task 4's real end-to-end pipeline run) Node's
125
+ // spawn() does not transparently shell out for it the way an older assumption in this file
126
+ // believed. shell:true routes exactly that one case through cmd.exe, which is also why
127
+ // sanitizeRelativeOutPath/sanitizeSegment already block shell metacharacters including `%^"`
128
+ // (cmd.exe's own specials) everywhere a value can reach this function -- that defense was
129
+ // already written for a cmd.exe hop, this just makes the hop actually happen for the case
130
+ // that needs it, without changing behavior for the real-.exe (ffmpeg/ffprobe) callers.
131
+ const WINDOWS_SHIM_RE = /\.(cmd|bat)$/i;
132
+ /**
133
+ * Runs a child process to completion and captures its stdout/stderr in memory.
134
+ * `spawn` with an argv array, never `exec`/`execSync` with an interpolated shell string --
135
+ * render_video and qc_extract_frames both go through this. Capturing the child's output
136
+ * here has nothing to do with *this* process's own stdout (the MCP transport); it's
137
+ * returned as normal tool-result content by the caller.
138
+ */
139
+ function spawnCapture(command, args, cwd) {
140
+ return new Promise((resolve, reject) => {
141
+ const shell = process.platform === "win32" && WINDOWS_SHIM_RE.test(command);
142
+ // Node's own shell:true + args-array combination does NOT escape/quote args for the
143
+ // caller (it just concatenates them into one command line before handing it to
144
+ // cmd.exe -- Node emits DEP0190 warning about exactly this), so an argument containing
145
+ // a space (a legal, unsanitized character in e.g. outPathRel) would silently word-split
146
+ // into two argv entries once shell:true is in effect. Every arg that reaches this
147
+ // function is already filtered through DANGEROUS_CHARS/SAFE_SEGMENT, which blocks `"`
148
+ // itself, so wrapping each in double quotes here is always safe and closes that gap
149
+ // without touching the non-shell (ffmpeg/ffprobe) call path at all.
150
+ const finalArgs = shell ? args.map((a) => `"${a}"`) : args;
151
+ const child = (0, node_child_process_1.spawn)(command, finalArgs, { cwd, shell });
152
+ let stdout = "";
153
+ let stderr = "";
154
+ child.stdout?.on("data", (chunk) => {
155
+ stdout += chunk.toString();
156
+ });
157
+ child.stderr?.on("data", (chunk) => {
158
+ stderr += chunk.toString();
159
+ });
160
+ child.on("error", reject);
161
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
162
+ });
163
+ }
package/docs/API.md ADDED
@@ -0,0 +1,344 @@
1
+ # API.md: everything `@openvidstudio/core` exports
2
+
3
+ Written because the alternative is reading the library source. If you are an agent
4
+ about to write a scene, this file has every prop you need and you should not have to
5
+ open `packages/core/src`.
6
+
7
+ Import everything from the package root:
8
+
9
+ ```tsx
10
+ import { CinematicScene, Layer, TerminalReplay, E, color } from "@openvidstudio/core";
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Scene wrapper
16
+
17
+ ### `CinematicScene`
18
+
19
+ Wraps every scene. Grain, vignette and colour grade are always on, and the camera rig
20
+ lives inside it. Nothing renders outside one.
21
+
22
+ | Prop | Type | Default | Notes |
23
+ |---|---|---|---|
24
+ | `camera` | `CamKeyframe[]` | required | At least two, or nothing moves |
25
+ | `stageWidth` | `number` | `1920` | |
26
+ | `stageHeight` | `number` | `1080` | |
27
+ | `variant` | `"dark" \| "light"` | `"dark"` | |
28
+ | `grain` | `number` | `0.05` | |
29
+ | `vignette` | `number` | `0.32` | |
30
+ | `overlay` | `ReactNode` | | Screen space, not camera transformed. Captions and title cards go here |
31
+ | `children` | `ReactNode` | required | |
32
+
33
+ **The overlay is not optional knowledge.** Anything inside `children` moves with the
34
+ camera, so a caption placed there drifts off screen as the shot pushes in. Captions,
35
+ title cards and dips belong in `overlay`.
36
+
37
+ ---
38
+
39
+ ## Camera
40
+
41
+ ### `CamKeyframe`
42
+
43
+ ```ts
44
+ type CamKeyframe = {
45
+ frame: number;
46
+ x: number; // focal point on the stage, lands at frame centre
47
+ y: number;
48
+ scale: number; // 1 = stage px equals output px, 1.6 = 160% close up
49
+ rotX?: number;
50
+ rotY?: number;
51
+ rotZ?: number;
52
+ easing?: (t: number) => number; // easing INTO this keyframe
53
+ };
54
+ ```
55
+
56
+ **The one thing that catches everyone.** The visible area is the stage divided by
57
+ `scale`. At `scale: 1.4` only 1371x771 of a 1920x1080 stage is on screen, so content
58
+ laid out at full stage width is cropped at the frame edge, and the render does not
59
+ warn. The binding number is the scene's **highest** scale, not its first.
60
+
61
+ Rules of thumb:
62
+
63
+ - Real captures: 1.2 to 2.5. STYLE.md requires the camera to sit inside the UI
64
+ rather than showing a flat full-screen grab.
65
+ - Constructed panels: 1.04 to 1.15. They are already built at stage size, so they
66
+ need a gentle push, not a zoom.
67
+ - Run `validate_scenes` and it will tell you which scenes are cropping.
68
+
69
+ ### `Layer`
70
+
71
+ | Prop | Type | Default | Notes |
72
+ |---|---|---|---|
73
+ | `depth` | `number` | `0` | Parallax and blur. `0` is the focal plane |
74
+ | `maxBlur` | `number` | `10` | Blur in px at `depth` 1 |
75
+ | `style` | `CSSProperties` | | |
76
+
77
+ One focal plane per shot. Put background elements on a non-zero depth so they blur.
78
+
79
+ ### `CameraRig`, `useCamera`
80
+
81
+ `CinematicScene` sets these up for you. Reach for them only when building a scene
82
+ that needs the camera state directly.
83
+
84
+ ---
85
+
86
+ ## Motion
87
+
88
+ ### `E` and `SPRING`
89
+
90
+ ```ts
91
+ E.cinematic // default move: slow out, soft landing
92
+ E.whip // aggressive, for cuts and fast pans
93
+ E.drift // gentle ambient motion inside a shot
94
+ E.snap // snappy UI reaction, button press, blip reveal
95
+
96
+ SPRING.soft SPRING.pop SPRING.heavy
97
+ ```
98
+
99
+ **Linear easing fails QC.** There is no linear preset on purpose.
100
+
101
+ ### `tween`, `pop`, `jitter`, `staggerDelay`
102
+
103
+ ```ts
104
+ tween(frame, [fromFrame, toFrame], [fromValue, toValue], E.cinematic): number
105
+ pop(frame, fps, atFrame): number // spring entrance scalar
106
+ jitter(seed, n): number // deterministic 0..1, same every render
107
+ staggerDelay(index, step = 3): number // 3 frames at 30fps is 100ms
108
+ ```
109
+
110
+ `jitter` is deterministic. Never use `Math.random()` in a scene: every frame renders
111
+ in a separate process and random values differ per frame, which shows up as flicker.
112
+
113
+ ---
114
+
115
+ ## Text
116
+
117
+ ### `TitleSlam`
118
+
119
+ | Prop | Type | Default |
120
+ |---|---|---|
121
+ | `text` | `string` | required |
122
+ | `at` | `number` | required |
123
+ | `fontSize` | `number` | `120` |
124
+ | `color` | `string` | `color.textPrimary` |
125
+ | `glowColor` | `string` | |
126
+ | `align` | `"center" \| "left"` | `"center"` |
127
+ | `sfx` | `boolean` | `true` |
128
+
129
+ Per word entrance. Renders nothing before `at`.
130
+
131
+ ### `Caption`
132
+
133
+ | Prop | Type | Default |
134
+ |---|---|---|
135
+ | `text` | `string` | required |
136
+ | `at` | `number` | required |
137
+ | `out` | `number` | |
138
+ | `fontSize` | `number` | `34` |
139
+
140
+ Sits 84px from the bottom. Put it in `overlay`, not `children`.
141
+
142
+ ---
143
+
144
+ ## Panels
145
+
146
+ ### `BrowserFrame`
147
+
148
+ | Prop | Type | Default |
149
+ |---|---|---|
150
+ | `url` | `string` | required |
151
+ | `width` | `number` | `1360` |
152
+ | `height` | `number` | `850` |
153
+ | `dark` | `boolean` | `true` |
154
+ | `children` | `ReactNode` | required |
155
+
156
+ Chrome bar is 56px, so `height` must be the capture height plus 56 or the page is
157
+ letterboxed. `scaffold_scene` computes this from the real PNG for you.
158
+
159
+ Screenshots may only appear inside a `BrowserFrame`. Recordings and AI clips must
160
+ not: wrapping generated footage in browser chrome makes it look like a captured
161
+ page, which STYLE.md's provenance rule forbids.
162
+
163
+ ### `CodePanel`
164
+
165
+ | Prop | Type | Default |
166
+ |---|---|---|
167
+ | `title` | `string` | required |
168
+ | `lines` | `CodeLine[]` | required |
169
+ | `width` | `number` | `860` |
170
+ | `height` | `number` | `640` |
171
+ | `fontSize` | `number` | `22` |
172
+ | `scroll` | `number` | `0` | px translateY, drive with a tween |
173
+ | `highlight` | `number[]` | `[]` | 0-based line indexes |
174
+
175
+ ```ts
176
+ type Token = { t: string; c?: "kw" | "str" | "fn" | "cm" | "num" | "plain" };
177
+ type CodeLine = Token[];
178
+ ```
179
+
180
+ ```tsx
181
+ lines={[
182
+ [{ t: "{", c: "plain" }],
183
+ [{ t: ' "name"', c: "str" }, { t: ": ", c: "plain" }, { t: '"value"', c: "str" }],
184
+ ]}
185
+ ```
186
+
187
+ ### `ChecklistPanel`
188
+
189
+ | Prop | Type | Default |
190
+ |---|---|---|
191
+ | `title` | `string` | `"Community Standards"` |
192
+ | `items` | `ChecklistItem[]` | required |
193
+ | `width` | `number` | `760` |
194
+ | `fontSize` | `number` | `27` |
195
+ | `sfx` | `boolean` | `true` |
196
+
197
+ ```ts
198
+ type ChecklistItem = { label: string; flipAt?: number }; // omit flipAt to stay unchecked
199
+ ```
200
+
201
+ `flipAt` is an absolute scene frame.
202
+
203
+ ### `TerminalReplay`
204
+
205
+ | Prop | Type | Default |
206
+ |---|---|---|
207
+ | `steps` | `TermStep[]` | required |
208
+ | `title` | `string` | `"Project"` |
209
+ | `width` | `number` | `1280` |
210
+ | `fontSize` | `number` | `26` |
211
+ | `cps` | `number` | `16` | characters per second |
212
+ | `sfx` | `boolean` | `true` |
213
+ | `trailingPrompt` | `boolean` | `true` |
214
+
215
+ ```ts
216
+ type TermLine = { text: string; color?: string; glow?: boolean; bold?: boolean };
217
+ type TermStep =
218
+ | { type: "cmd"; text: string }
219
+ | { type: "out"; lines: TermLine[]; stagger?: number; preDelay?: number }
220
+ | { type: "pause"; frames: number };
221
+ ```
222
+
223
+ Terminals are always this component, never a screen recording of a terminal.
224
+
225
+ `computeTerminalTimeline(steps, fps, cps)` returns `{ items, totalFrames }`, so camera
226
+ keyframes can land frame-exact on content events rather than being guessed.
227
+
228
+ ### `CursorActor`
229
+
230
+ | Prop | Type | Default |
231
+ |---|---|---|
232
+ | `path` | `CursorKeyframe[]` | required |
233
+ | `clicks` | `number[]` | `[]` | frames where a click ring fires |
234
+ | `scale` | `number` | `1.6` |
235
+ | `sfx` | `boolean` | `true` |
236
+
237
+ ```ts
238
+ type CursorKeyframe = { frame: number; x: number; y: number };
239
+ ```
240
+
241
+ Coordinates are stage space. Movement is eased automatically; never linear.
242
+
243
+ ### `RepoCta`
244
+
245
+ | Prop | Type | Default |
246
+ |---|---|---|
247
+ | `owner` | `string` | required |
248
+ | `name` | `string` | required |
249
+ | `description` | `string` | required |
250
+ | `chips` | `string[]` | required |
251
+ | `titleText` | `string` | required |
252
+ | `durationInFrames` | `number` | `270` |
253
+
254
+ A whole scene, not a panel. Do not wrap it in a `CinematicScene`; it brings its own.
255
+ No star action by default.
256
+
257
+ ---
258
+
259
+ ## Sound
260
+
261
+ All synthesized by `scripts/gen-sfx.sh` with ffmpeg, so a rendered video carries no
262
+ third-party audio rights. Regenerate with `bash scripts/gen-sfx.sh`.
263
+
264
+ | Component | Props | Use |
265
+ |---|---|---|
266
+ | `KeySound` | `at`, `seed?`, `volume?` (0.16) | One keystroke |
267
+ | `TypingSfx` | `charFrames`, `volume?` | One key per typed character |
268
+ | `EnterKey` | `at`, `volume?` (0.2) | End of a typed command |
269
+ | `Click` | `at`, `volume?` | Cursor click |
270
+ | `Whoosh` | `at`, `volume?` | Camera move or cut |
271
+ | `Blip` | `at`, `volume?` (0.3) | UI reveal |
272
+ | `SuccessChime` | `at`, `volume?` (0.35) | Completion |
273
+ | `Bell` | `at`, `volume?` (0.3) | Heavier completion |
274
+ | `MusicBed` | `durationInFrames`, `volume?` (0.16) | Ambient bed, loops |
275
+
276
+ `TerminalReplay` emits its own typing sounds. Pass `sfx={false}` to silence it.
277
+
278
+ Narration is separate: `generate_narration` writes
279
+ `public/audio/vo/<beatId>.mp3` and `stitch_composition` picks it up.
280
+
281
+ ---
282
+
283
+ ## Look pass
284
+
285
+ `FilmGrain`, `Vignette`, `ColorGrade`, `LookPass`. `CinematicScene` applies these, so
286
+ reach for them only when compositing outside a scene.
287
+
288
+ ---
289
+
290
+ ## Tokens
291
+
292
+ ```ts
293
+ color.bg0 color.bg1 color.panel color.panelBorder
294
+ color.textPrimary color.textSecondary
295
+ color.accent color.accentAlt color.success color.warn color.danger
296
+ color.lightBg color.lightCard color.lightShadow
297
+
298
+ font.ui // "Inter"
299
+ font.mono // "JetBrains Mono"
300
+
301
+ radius.window // 12
302
+ radius.card // 10
303
+ radius.pill // 999
304
+
305
+ glow(color, strength = 1) // returns { textShadow }
306
+ panelShadow(dark = true) // returns a box-shadow string
307
+ ```
308
+
309
+ Use tokens rather than literal hex, so a per-video theme can override them.
310
+
311
+ ---
312
+
313
+ ## A scene, end to end
314
+
315
+ ```tsx
316
+ import React from "react";
317
+ import { CinematicScene, Layer, TerminalReplay, Caption, color, E } from "@openvidstudio/core";
318
+
319
+ export const Install: React.FC = () => (
320
+ <CinematicScene
321
+ camera={[
322
+ { frame: 0, x: 960, y: 540, scale: 1.05, rotZ: -0.4 },
323
+ { frame: 300, x: 960, y: 548, scale: 1.15, rotZ: 0.35, easing: E.drift },
324
+ ]}
325
+ overlay={<Caption text="one command" at={40} out={240} />}
326
+ >
327
+ <Layer depth={0}>
328
+ <div style={{ position: "absolute", inset: 0, display: "flex",
329
+ alignItems: "center", justifyContent: "center" }}>
330
+ <TerminalReplay
331
+ width={1400}
332
+ steps={[
333
+ { type: "cmd", text: "npx -y @openvidstudio/mcp-server" },
334
+ { type: "out", lines: [{ text: "connected over stdio", color: color.success, glow: true }] },
335
+ ]}
336
+ />
337
+ </div>
338
+ </Layer>
339
+ </CinematicScene>
340
+ );
341
+ ```
342
+
343
+ Two keyframes so it moves, content centred inside the safe area, caption in the
344
+ overlay, tokens instead of hex.
@@ -0,0 +1,106 @@
1
+ # CAPTURE.md: real screenshots and recordings for scenes
2
+
3
+ Binding for every scene with `captureMethod: "screenshot"` or
4
+ `"recording"` (see `PLANNING.md`). This protocol exists because
5
+ uncorrected captures reliably come out small, soft, or bleed in content
6
+ from a neighboring page section; here's why, and the fix. This is also
7
+ what the `capture_screenshot` MCP tool does internally and why: if a
8
+ capture from that tool ever comes out wrong, this is the mechanism to
9
+ debug against.
10
+
11
+ ## The zoom-desync bug
12
+
13
+ Playwright MCP's `browser_resize` sets a *requested* viewport, but a
14
+ browser profile can carry a per-origin zoom level (set once, persisted,
15
+ invisible in the tool output) that desyncs the requested size from the
16
+ effective CSS layout Chrome actually renders. Confirmed on a real machine:
17
+ requesting 1600x1000 rendered at `window.innerWidth/innerHeight` of
18
+ 2000x1250 (zoom = 0.8). This is a *consistent ratio*, not noise, so it's
19
+ fully correctable, not something to route around by guessing crop
20
+ coordinates.
21
+
22
+ **Never trust a resize at face value. Always verify.**
23
+
24
+ ## Protocol
25
+
26
+ 1. `browser_resize` to your target size, `browser_navigate`, then
27
+ `page.evaluate(() => [window.innerWidth, window.innerHeight])`. If it
28
+ doesn't match what you requested, you have a desync.
29
+ `zoom = requestedWidth / actualInnerWidth`.
30
+ 2. Re-request a *compensated* viewport: `browser_resize(targetW * zoom,
31
+ targetH * zoom)`. Verify again: `innerWidth/innerHeight` should now
32
+ equal `targetW/targetH` exactly. This is the size you actually wanted,
33
+ once compensated.
34
+ 3. Interact with the page (click, select, scroll) to reach the exact state
35
+ the beat needs, in this same compensated viewport.
36
+ 4. Take a full-viewport screenshot (`scale: "css"`, no `fullPage`, no
37
+ element target, see below for why not element-scoped). This screenshot
38
+ is physically `targetW*zoom x targetH*zoom` px and contains `targetW x
39
+ targetH` worth of real layout, shrunk by `zoom`.
40
+ 5. Measure the DOM rect of exactly the content the beat wants, with
41
+ `el.getBoundingClientRect()`, in the same effective CSS space as step
42
+ 2. Don't eyeball pixel coordinates off the screenshot; measure the real
43
+ element.
44
+ 6. Run `python scripts/crop-shot.py <raw.png> <out.png> <x> <y> <w> <h>
45
+ <zoom>` (the rect from step 5, the zoom from step 1/2). It crops the
46
+ physical screenshot at `rect * zoom`, then upscales the crop back to
47
+ `rect`'s own size with Lanczos resampling, landing on a pixel-accurate,
48
+ unshrunk capture with no bleed, because the crop rect came from the
49
+ DOM, not a guess.
50
+ 7. `Read` the output PNG before using it in a scene. Confirm by eye: no
51
+ cut-off text, no chrome from an unrelated section, correct state
52
+ selected.
53
+
54
+ **Caveat when skipping `cropSelector`:** `capture_screenshot`'s output, with
55
+ no `cropSelector` given, is exactly the compensated viewport's own pixel
56
+ size (steps 1-2 above), not necessarily the size you originally requested.
57
+ On a machine with a real zoom desync (zoom != 1), that means the saved
58
+ image comes out smaller, and reads softer once a scene scales it back up,
59
+ than the size you asked for. Passing `cropSelector` sidesteps this because
60
+ the crop-and-upscale step (6 below) always lands back on the DOM rect's
61
+ own true size regardless of the measured zoom; without it, check the
62
+ tool's reported `zoom` before trusting the raw image at face value.
63
+
64
+ **Why not `page.locator(selector).screenshot()`** (element-scoped): it
65
+ re-measures and can auto-scroll the element into view at shot time,
66
+ independent of what you measured a moment earlier. That mismatch produces
67
+ a real bleed bug: an element-scoped shot picking up part of the next
68
+ section down. The full-viewport-then-crop approach in steps 4-6 is
69
+ deterministic: one screenshot, one measurement, one crop, no hidden
70
+ re-layout between them.
71
+
72
+ ## Screen recordings
73
+
74
+ For `captureMethod: "recording"` beats, the same zoom-desync check in step
75
+ 1 still applies before you start recording: verify the effective viewport
76
+ matches what you requested before you hit record, since the desync is set
77
+ once per origin and persists for the whole session. v1 scope is fixed
78
+ full-viewport recordings only: capture at the compensated viewport size
79
+ and composite the whole frame (no post-hoc DOM-rect cropping of a moving
80
+ recording, that's future work, see `PIPELINE.md`).
81
+
82
+ ## Sizing the frame from the capture
83
+
84
+ Once you have a clean capture, size `BrowserFrame` to the screenshot's
85
+ *native* aspect so the `<Img>` (or `OffthreadVideo`, for a recording)
86
+ needs no distortion:
87
+
88
+ ```
89
+ FRAME_W = <capture width>
90
+ FRAME_H = <capture height> + 56 // BrowserFrame's chrome bar is 56px, added on top of content
91
+ LEFT = (1920 - FRAME_W) / 2
92
+ TOP = (1080 - FRAME_H) / 2
93
+ ```
94
+
95
+ This centers the frame on the 1920x1080 stage exactly: `LEFT + FRAME_W/2`
96
+ is always `960` by construction. Push the camera to 115-160% (STYLE.md's
97
+ 120-250% range) with `x=960` as the base; adjust `y` only if the beat's
98
+ important content isn't at the frame's own vertical center.
99
+
100
+ **Check the bottom edge before locking a static crop.** A static camera
101
+ crop that looks fine at scale 1.0 can push important content off the
102
+ bottom of frame once you zoom in on it. If a screenshot is taller than
103
+ what one crop can show at a good zoom level, animate the camera `y` across
104
+ the beat instead of picking one static point: pan from the top
105
+ (headline/context) to the bottom (the payoff) rather than compressing
106
+ everything into a single frame that's forced to zoom out to fit it all.