@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.
- package/dist/beats.d.ts +47 -0
- package/dist/beats.js +9 -0
- package/dist/capture.d.ts +116 -0
- package/dist/capture.js +148 -0
- package/dist/config.d.ts +58 -0
- package/dist/config.js +130 -0
- package/dist/paths.d.ts +20 -0
- package/dist/paths.js +57 -0
- package/dist/scenes/pngSize.d.ts +11 -0
- package/dist/scenes/pngSize.js +75 -0
- package/dist/scenes/templates.d.ts +42 -0
- package/dist/scenes/templates.js +468 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.js +37 -0
- package/dist/stdio.d.ts +2 -0
- package/dist/stdio.js +33 -0
- package/dist/tools/captureScreenRecording.d.ts +28 -0
- package/dist/tools/captureScreenRecording.js +181 -0
- package/dist/tools/captureScreenshot.d.ts +34 -0
- package/dist/tools/captureScreenshot.js +148 -0
- package/dist/tools/contactSheet.d.ts +36 -0
- package/dist/tools/contactSheet.js +116 -0
- package/dist/tools/generateNarration.d.ts +54 -0
- package/dist/tools/generateNarration.js +185 -0
- package/dist/tools/importHiggsfieldClip.d.ts +41 -0
- package/dist/tools/importHiggsfieldClip.js +108 -0
- package/dist/tools/index.d.ts +13 -0
- package/dist/tools/index.js +47 -0
- package/dist/tools/initProject.d.ts +37 -0
- package/dist/tools/initProject.js +166 -0
- package/dist/tools/mcp.d.ts +33 -0
- package/dist/tools/mcp.js +36 -0
- package/dist/tools/planBeats.d.ts +40 -0
- package/dist/tools/planBeats.js +143 -0
- package/dist/tools/preflight.d.ts +30 -0
- package/dist/tools/preflight.js +170 -0
- package/dist/tools/qcExtractFrames.d.ts +25 -0
- package/dist/tools/qcExtractFrames.js +129 -0
- package/dist/tools/renderVideo.d.ts +43 -0
- package/dist/tools/renderVideo.js +120 -0
- package/dist/tools/scaffoldScene.d.ts +23 -0
- package/dist/tools/scaffoldScene.js +147 -0
- package/dist/tools/stitchComposition.d.ts +24 -0
- package/dist/tools/stitchComposition.js +230 -0
- package/dist/tools/validateBeats.d.ts +14 -0
- package/dist/tools/validateBeats.js +164 -0
- package/dist/tools/validateScenes.d.ts +31 -0
- package/dist/tools/validateScenes.js +191 -0
- package/dist/tools/writeBeatsFile.d.ts +20 -0
- package/dist/tools/writeBeatsFile.js +81 -0
- package/dist/util.d.ts +43 -0
- package/dist/util.js +163 -0
- package/docs/API.md +344 -0
- package/docs/CAPTURE.md +106 -0
- package/docs/HIGGSFIELD.md +118 -0
- package/docs/NARRATION.md +111 -0
- package/docs/OVERVIEW.md +112 -0
- package/docs/PIPELINE.md +173 -0
- package/docs/PLANNING.md +206 -0
- package/docs/SCRIPT.md +50 -0
- package/docs/STYLE.md +88 -0
- package/package.json +54 -0
- package/templates/default/CLAUDE.md +63 -0
- package/templates/default/openvidstudio-core/package.json +51 -0
- package/templates/default/openvidstudio-core/scripts/gen-sfx.sh +74 -0
- package/templates/default/openvidstudio-core/src/ChecklistPanel.tsx +97 -0
- package/templates/default/openvidstudio-core/src/CinematicScene.tsx +53 -0
- package/templates/default/openvidstudio-core/src/CodePanel.tsx +122 -0
- package/templates/default/openvidstudio-core/src/CursorActor.tsx +90 -0
- package/templates/default/openvidstudio-core/src/DeviceFrame.tsx +74 -0
- package/templates/default/openvidstudio-core/src/KineticText.tsx +142 -0
- package/templates/default/openvidstudio-core/src/RepoCta.tsx +128 -0
- package/templates/default/openvidstudio-core/src/TerminalReplay.tsx +263 -0
- package/templates/default/openvidstudio-core/src/camera.tsx +141 -0
- package/templates/default/openvidstudio-core/src/index.ts +60 -0
- package/templates/default/openvidstudio-core/src/look.tsx +90 -0
- package/templates/default/openvidstudio-core/src/motion.ts +50 -0
- package/templates/default/openvidstudio-core/src/sfx.tsx +106 -0
- package/templates/default/openvidstudio-core/src/tokens.ts +41 -0
- package/templates/default/package.json +31 -0
- package/templates/default/public/sfx/bell.wav +0 -0
- package/templates/default/public/sfx/blip.wav +0 -0
- package/templates/default/public/sfx/click.wav +0 -0
- package/templates/default/public/sfx/key_a.wav +0 -0
- package/templates/default/public/sfx/key_b.wav +0 -0
- package/templates/default/public/sfx/key_c.wav +0 -0
- package/templates/default/public/sfx/key_enter.wav +0 -0
- package/templates/default/public/sfx/music-bed.mp3 +0 -0
- package/templates/default/public/sfx/success.wav +0 -0
- package/templates/default/public/sfx/whoosh.wav +0 -0
- package/templates/default/remotion.config.ts +13 -0
- package/templates/default/src/Root.tsx +9 -0
- package/templates/default/src/index.ts +4 -0
- 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.
|
package/docs/CAPTURE.md
ADDED
|
@@ -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.
|