motigenjs 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +191 -2
- package/bin/motigen.js +7 -0
- package/docs/audio.md +54 -0
- package/docs/canvas.md +54 -0
- package/docs/guide.md +100 -0
- package/package.json +60 -4
- package/runtime/canvas/ink.js +314 -0
- package/runtime/canvas/kit.js +392 -0
- package/runtime/guide/player.css +54 -0
- package/runtime/guide/player.js +366 -0
- package/runtime/preview/index.html +28 -0
- package/runtime/preview/preview.css +17 -0
- package/runtime/preview/preview.js +127 -0
- package/skill/SKILL.md +40 -0
- package/src/audio/decode.js +26 -0
- package/src/audio/index.js +7 -0
- package/src/audio/loudness.js +161 -0
- package/src/audio/mix.js +175 -0
- package/src/audio/pcm.js +57 -0
- package/src/audio/srt.js +29 -0
- package/src/audio/vo.js +148 -0
- package/src/audio/wav.js +52 -0
- package/src/browser.js +34 -0
- package/src/cli.js +270 -0
- package/src/index.js +4 -0
- package/src/init.js +38 -0
- package/src/preview.js +59 -0
- package/src/project.js +42 -0
- package/src/record/browser.js +167 -0
- package/src/record/docker.js +38 -0
- package/src/record/hooks.js +19 -0
- package/src/record/index.js +4 -0
- package/src/record/term.js +165 -0
- package/src/render.js +201 -0
- package/src/serve.js +114 -0
- package/src/stability.js +87 -0
- package/templates/canvas/audio/lines.txt +3 -0
- package/templates/canvas/film.html +15 -0
- package/templates/canvas/film.js +77 -0
- package/templates/canvas/gitignore +7 -0
- package/templates/canvas/motigen.json +14 -0
- package/templates/dom/film.html +67 -0
- package/templates/dom/gitignore +7 -0
- package/templates/dom/motigen.json +10 -0
- package/templates/guide/audio/lines.txt +4 -0
- package/templates/guide/brand.svg +5 -0
- package/templates/guide/edit.js +32 -0
- package/templates/guide/film.html +34 -0
- package/templates/guide/gitignore +8 -0
- package/templates/guide/motigen.json +11 -0
- package/templates/guide/site/index.html +47 -0
- package/templates/guide/take.mjs +46 -0
- package/templates/guide/theme.css +5 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 crocone
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,192 @@
|
|
|
1
|
-
#
|
|
1
|
+
# motigen
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Video as code, for developers and their coding agents.
|
|
4
|
+
|
|
5
|
+
A web page is the film: it draws frame `t` on demand, in plain HTML, Canvas or anything else a browser runs.
|
|
6
|
+
motigen opens it headless, renders every frame in order and pipes them into ffmpeg, so one project gives the same
|
|
7
|
+
MP4 on every run. Real terminals and real browsers are recorded and replayed inside the same page; the narration,
|
|
8
|
+
the sound effects and the music are mixed from the timeline the page exports; the subtitles come from the same
|
|
9
|
+
cues. Everything is a file in git and every step is one command, which is exactly what a coding agent needs to
|
|
10
|
+
make a product video on its own.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm i -g motigenjs && npx playwright install chromium # Node 22+, ffmpeg in PATH; the command is `motigen`
|
|
14
|
+
motigen init guide my-guide && cd my-guide
|
|
15
|
+
motigen record take.mjs # a real browser and a real terminal, recorded into take/
|
|
16
|
+
motigen vo # narration from audio/lines.txt (ElevenLabs), one file per line
|
|
17
|
+
motigen build # events → mix → render → subtitles: out/my-guide.mp4 + .srt
|
|
18
|
+
motigen preview # scrub it, play it with sound, render from a button
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What you get
|
|
22
|
+
|
|
23
|
+
- **Three kinds of film, one contract.** A page registers `window.__film = { ready, dur, renderAt(t) }`. The
|
|
24
|
+
`dom` template is that contract in 80 lines; the `guide` template is a screencast player (browser window,
|
|
25
|
+
terminal window, chapters, key badges, cards) driven by one edit list; the `canvas` template is a hand-drawn
|
|
26
|
+
2D engine (paper, letterpress type, ink strokes, a thread, cards, a pencil) with its own offline mix.
|
|
27
|
+
- **A recorder for the real thing.** `Term` records a shell through a pty with `script`-style timing, so the
|
|
28
|
+
player replays the actual bytes through xterm.js; `Browser` records a CDP screencast with the mouse path, the
|
|
29
|
+
clicks, the keys and the URLs, and can serve a local folder or a local instance of your site under its real
|
|
30
|
+
domain.
|
|
31
|
+
- **Sound from the timeline.** `motigen events` asks the page where the voice lines, clicks and key presses fall;
|
|
32
|
+
`motigen vo` makes the narration (one ElevenLabs call with timestamps, split per line, or your own recordings);
|
|
33
|
+
`motigen mix` lays everything out, ducks the music under the voice and masters to −14 LUFS / −1 dBTP;
|
|
34
|
+
`motigen srt` writes the subtitles.
|
|
35
|
+
- **Deterministic, parallel rendering.** Every frame depends on `t` alone, so `-j 4` renders four slices at once
|
|
36
|
+
and joins them losslessly, and the preview scrubs to any moment instantly.
|
|
37
|
+
- **Made for agents.** `skill/SKILL.md` tells a coding agent how to take a script to a finished video with these
|
|
38
|
+
commands; the whole project is text it can read and change.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
Node 22 or newer and ffmpeg in your PATH. Playwright downloads its Chromium once; `--channel chrome` uses an
|
|
43
|
+
installed Chrome instead.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm i -g motigenjs
|
|
47
|
+
npx playwright install chromium
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The npm package is `motigenjs`; the command it installs is `motigen`. As a project dependency, `npx motigen …`
|
|
51
|
+
works the same way.
|
|
52
|
+
|
|
53
|
+
The terminal recorder uses `node-pty` (an optional dependency with prebuilt binaries for Windows, macOS and
|
|
54
|
+
Linux). Without it, record in a container: `backend: 'docker'`.
|
|
55
|
+
|
|
56
|
+
## Commands
|
|
57
|
+
|
|
58
|
+
| Command | What it does |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `motigen init <guide\|dom\|canvas> <dir>` | A new project from a template. |
|
|
61
|
+
| `motigen preview [--port 4310] [--open]` | A player over the project: scrub, play with `audio/mix.wav`, switch formats, render and export events from buttons. |
|
|
62
|
+
| `motigen frames -t 1,2.5,10` | Stills into `shots/` (`--png`, `--every 5`, `--element '#terminal'` with a transparent background, `--scale 2`). |
|
|
63
|
+
| `motigen render` | The video to `out/<name>.mp4`: `-j N` pages in parallel (default: half the cores, at most 4), `--range 10-20`, `--audio file.wav` or the project's `audio/mix.wav`, `--crf`, `--preset`. |
|
|
64
|
+
| `motigen stability -t 3,10` | Renders consecutive frames at those moments and reports what changed between them (`--every 2`, `--frames 3`, `--still` to fail on any change). |
|
|
65
|
+
| `motigen events` | The page's `events()` into `audio/events.json`: voice cues, clicks, keys, Enter presses, effects. |
|
|
66
|
+
| `motigen record [take.mjs]` | Runs a take script; it imports `{ Term, Browser, sleep }` from `motigenjs/record`. |
|
|
67
|
+
| `motigen vo [lines.txt]` | Narration: one ElevenLabs call for the whole script, split per line into `audio/vo/` with `vo.json`. `vo split` uses a saved response, `vo import` your own recordings, `vo check` flags breathy or garbled lines. |
|
|
68
|
+
| `motigen mix` | `audio/mix.wav` from `events.json`, `vo/`, `sfx/` and `bed.*`; or the canvas kit's own mix when `"mix": {"engine": "page"}`. |
|
|
69
|
+
| `motigen srt` | Subtitles from the voice cues, with the written forms from `"spellings"`. |
|
|
70
|
+
| `motigen build` | `events` → `mix` → `render` (every format) → `srt`. |
|
|
71
|
+
| `motigen skill [dir]` | Installs `skill/SKILL.md` into `.claude/skills/motigen/`, so a coding agent in that folder knows the workflow. |
|
|
72
|
+
|
|
73
|
+
Common options: `-C dir` (the project), `-p page`, `--fmt name`, `--size WxH`, `--query "fmt=v"`, `--channel chrome`,
|
|
74
|
+
`--no-gpu`, `--ffmpeg path`, `--timeout s`. `motigen help` lists everything.
|
|
75
|
+
|
|
76
|
+
## The contract
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
window.__film = {
|
|
80
|
+
ready: true, // fonts and assets are loaded
|
|
81
|
+
dur: 38, // seconds
|
|
82
|
+
fps: 30, // optional
|
|
83
|
+
renderAt(t) {}, // draw the frame at t seconds; sync or async; a pure function of t
|
|
84
|
+
events() {}, // optional: { dur, vo: [{id, t}], clicks: [t], keys: [t], enters: [t], sfx: [{id, t, db}] }
|
|
85
|
+
canvas, // optional: an HTMLCanvasElement to read frames from, at its own size
|
|
86
|
+
};
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A frame must depend on `t` alone: no `Date.now()`, no CSS animations or transitions, no random numbers without a
|
|
90
|
+
seed, nothing that keeps running between calls. Then any frame can be rendered at any time in any order, which is
|
|
91
|
+
what parallel rendering and scrubbing rely on.
|
|
92
|
+
|
|
93
|
+
## A project
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
my-guide/
|
|
97
|
+
motigen.json page, fps, size, formats, audio folder, out, vo / mix / spellings settings
|
|
98
|
+
film.html the page; the guide template adds edit.js, theme.css, brand.svg, take.mjs, site/
|
|
99
|
+
take/ recordings: take/<clip>/ (frames + clip.json), take/term.out + term.tm
|
|
100
|
+
audio/ lines.txt → vo/ (L01.wav… + vo.json); bed.wav; sfx/; events.json, mix.wav (generated)
|
|
101
|
+
out/ renders and subtitles
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{ "page": "film.html", "fps": 30, "size": [1920, 1080], "out": "out/my-film.mp4",
|
|
106
|
+
"formats": { "h": { "query": "fmt=h" }, "v": { "size": [1080, 1920], "query": "fmt=v" } },
|
|
107
|
+
"vo": { "voice": "<elevenlabs voice id>", "model": "eleven_v4", "seed": 7042 },
|
|
108
|
+
"mix": { "voiceDb": -3, "duckDb": -9, "target": { "lufs": -14, "dbtp": -1 } },
|
|
109
|
+
"spellings": [["Acme Cloud dot com", "acmecloud.com"], ["Pie", "Pi"]] }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## The three templates
|
|
113
|
+
|
|
114
|
+
**guide** — a screencast. `take.mjs` records the demo site in `site/` (served inside the recorder as
|
|
115
|
+
`https://demo.local`) and a shell on your machine; `edit.js` cuts them:
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
export default {
|
|
119
|
+
cols: 112, rows: 30, take: 'take', term: 'take/term',
|
|
120
|
+
shots: [
|
|
121
|
+
{ card: intro, dur: 4, vo: [['L01', -1, 0.5]] },
|
|
122
|
+
{ browser: 'site', segs: [[0, 9.5]], chapter: ['Step 1', 'Connect'], zoom: [[0, 1, 800, 450], [1.4, 1.35, 700, 420]] },
|
|
123
|
+
{ term: true, segs: [[0, 'edit.js|+1.2'], [null, 'node --version|+1.6']], keys: [['node --version|-0.2', 1.4, 'Enter', 'run it']] },
|
|
124
|
+
{ card: outro, dur: 4 },
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Segments play source seconds back to back and can be stretched or squeezed; terminal bounds can be text anchors
|
|
130
|
+
(`'Installed|+0.8'` = the moment that text appears, plus 0.8 s); the window zooms along keyframes; cards, chapters,
|
|
131
|
+
key badges and notes are overlays. [docs/guide.md](docs/guide.md) has the whole format and the recorder API.
|
|
132
|
+
|
|
133
|
+
**dom** — the contract and nothing else: a title, a bar and three typed lines in one HTML file. Start here for a
|
|
134
|
+
film in plain HTML and CSS.
|
|
135
|
+
|
|
136
|
+
**canvas** — a 12-second film on the hand-drawn engine from a real promo series: paper, a letterpress title, an
|
|
137
|
+
ink thread with a needle, a rule card with a highlighter, tally marks, a pencil that underlines, a stamped name.
|
|
138
|
+
Both formats come from one `LAYOUTS` table. [docs/canvas.md](docs/canvas.md) lists the kit.
|
|
139
|
+
|
|
140
|
+
## Sound
|
|
141
|
+
|
|
142
|
+
`audio/lines.txt` is the script, one line per cue. `motigen vo` sends it to ElevenLabs in one call with character
|
|
143
|
+
timestamps (`ELEVENLABS_API_KEY`, the voice id in `motigen.json`) and cuts the take at the quietest point between
|
|
144
|
+
lines, so pauses sound natural and every line is its own file. Recorded your own? `motigen vo import` writes
|
|
145
|
+
`vo.json` for the files in `audio/vo/`. Then `motigen mix` places each line where the edit put its cue, adds the
|
|
146
|
+
click / key / Enter sounds for what the recorder logged (synthesised when `audio/sfx/` is empty), loops the music
|
|
147
|
+
bed with a crossfade, ducks it under the voice and masters with ffmpeg's two-pass loudnorm. The levels, the
|
|
148
|
+
ducking and the target live under `"mix"` in `motigen.json`. [docs/audio.md](docs/audio.md).
|
|
149
|
+
|
|
150
|
+
## Stillness
|
|
151
|
+
|
|
152
|
+
A still shot has to repeat the same pixels frame after frame; the smallest shimmer on text reads as jitter once
|
|
153
|
+
it is encoded. motigen is built so that it does not happen, and ships the check:
|
|
154
|
+
|
|
155
|
+
- Every frame is a pure function of `t`: nothing on screen may depend on the clock, on CSS animations or on
|
|
156
|
+
unseeded randomness.
|
|
157
|
+
- The guide player snaps its camera to whole pixels and a fixed scale grid, so a slow pan moves in clean steps
|
|
158
|
+
and a hold repeats one transform; the canvas kit keeps hand-drawn strokes on one seed (no "line boil"); the
|
|
159
|
+
templates settle every animated value to an exact number.
|
|
160
|
+
- The encoder puts keyframes every 2 s and nowhere else (`--gop`), so fine texture does not pulse on holds.
|
|
161
|
+
- `motigen stability -t 3,10,20` renders consecutive frames at those moments and reports the share of pixels
|
|
162
|
+
that changed, with a box around the change and a `-diff.png` that shows it brightened; `--still` fails when a
|
|
163
|
+
moment is not perfectly still. A cursor, a caret or a spinner show up as small boxes; jitter shows up as a
|
|
164
|
+
small change spread across text that should not move.
|
|
165
|
+
|
|
166
|
+
## Why not …
|
|
167
|
+
|
|
168
|
+
- **Remotion**: React, a bundler and a licence with limits for companies. motigen takes any page, needs no build
|
|
169
|
+
step, and is MIT.
|
|
170
|
+
- **Motion Canvas**: its own editor and generator syntax. motigen is a CLI over an ordinary HTML page.
|
|
171
|
+
- **VHS, asciinema**: the terminal only. motigen puts a real terminal next to a real browser, cuts them with one
|
|
172
|
+
edit list, and adds narration, sound and subtitles from the same timeline.
|
|
173
|
+
- **Screen recorders**: a GUI and one take. A motigen project is text in git; change one line and render again.
|
|
174
|
+
|
|
175
|
+
## Where it comes from
|
|
176
|
+
|
|
177
|
+
The format and the tools were extracted from a pipeline that produced a real series of product guides and promo
|
|
178
|
+
films. Every port was checked against that pipeline's own output: the guide player gives the same events and the
|
|
179
|
+
same frames (SSIM 0.99 against the earlier render), the mix the same loudness and the same waveform within the
|
|
180
|
+
random jitter of the key clicks, the subtitles the same file byte for byte, the canvas films the same frames.
|
|
181
|
+
|
|
182
|
+
## Development
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
git clone https://github.com/crocone/motigen && cd motigen
|
|
186
|
+
npm i && npx playwright install chromium
|
|
187
|
+
npm test
|
|
188
|
+
node bin/motigen.js build -C examples/hello-dom
|
|
189
|
+
cd examples/hello-guide && node ../../bin/motigen.js record take.mjs && node ../../bin/motigen.js build
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
MIT.
|
package/bin/motigen.js
ADDED
package/docs/audio.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Sound: narration, the mix, subtitles
|
|
2
|
+
|
|
3
|
+
Everything sits in the project's audio folder (`"audio"` in motigen.json, default `audio/`):
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
audio/
|
|
7
|
+
lines.txt the script, one line per cue (L01, L02, …); blank lines and # comments are skipped
|
|
8
|
+
vo/ L01.wav … + vo.json [{id, dur, text, speechStart, speechEnd}]; resp.json (the TTS response)
|
|
9
|
+
bed.wav|mp3|… the music, optional
|
|
10
|
+
sfx/ click.wav, key.wav, enter.wav and any id used in events.sfx; sfx.json with each file's `hit`
|
|
11
|
+
events.json from `motigen events`
|
|
12
|
+
mix.wav from `motigen mix` (mix-raw.wav is the unmastered sum)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Narration
|
|
16
|
+
|
|
17
|
+
`motigen vo` reads `lines.txt`, sends the whole script to ElevenLabs in one `with-timestamps` call
|
|
18
|
+
(`ELEVENLABS_API_KEY`; `"vo": { "voice", "model", "seed", "settings" }` in motigen.json or `--voice`, `--model`,
|
|
19
|
+
`--seed`), saves the response as `audio/vo/resp.json` and splits it: each line is cut at the quietest point between
|
|
20
|
+
it and the next (the alignment runs a little late), silence is trimmed, and `vo.json` records the duration, the
|
|
21
|
+
text and the measured speech span of every line.
|
|
22
|
+
|
|
23
|
+
- `motigen vo split resp.json lines.txt` splits a saved response (no API call).
|
|
24
|
+
- `motigen vo import [dir] [lines.txt]` takes files you recorded or generated elsewhere (any format, sorted by
|
|
25
|
+
name), renames them to L01…, and writes `vo.json`.
|
|
26
|
+
- `motigen vo check` prints the spectral flatness of every line and flags the breathy or garbled ones (`--limit`,
|
|
27
|
+
default 0.12); exit code 2 when any is flagged.
|
|
28
|
+
|
|
29
|
+
Spelling for the voice is not spelling for the screen: write "Acme Cloud dot com" in `lines.txt` and put
|
|
30
|
+
`["Acme Cloud dot com", "acmecloud.com"]` under `"spellings"` so the subtitles read right.
|
|
31
|
+
|
|
32
|
+
## The mix
|
|
33
|
+
|
|
34
|
+
`motigen mix` builds `audio/mix.wav` from `events.json`:
|
|
35
|
+
|
|
36
|
+
1. Voice: every line peak-normalised to `voiceDb` (−3 dBFS) and placed at its cue.
|
|
37
|
+
2. Effects: `click` at every logged click (`clickDb` −14), `key` at every key press (`keyDb` −27 with a seeded
|
|
38
|
+
±2.5 dB jitter), `enter` at every Enter badge (`enterDb` −18), plus `events.sfx` entries by id. Files come from
|
|
39
|
+
`audio/sfx/<id>.wav` (or `sfx.json`'s `file`, placed so the transient at `hit` lands on the event); missing
|
|
40
|
+
click / key / enter are synthesised.
|
|
41
|
+
3. Music: the bed, looped from `bedLoopFrom` (8 s) with a `bedCrossfade` (2 s) when the film is longer, faded out
|
|
42
|
+
over `bedFadeOut` (3 s), levelled to `bedRmsDb` (−36 dB RMS), then ducked under the voice by `duckDb` (−9) with
|
|
43
|
+
`duckAttack` 120 ms and `duckRelease` 450 ms.
|
|
44
|
+
4. Master: `"master": "loudnorm"` (default) runs ffmpeg's EBU R128 normaliser in two passes to `target.lufs` −14 /
|
|
45
|
+
`target.dbtp` −1 / `target.lra` 11; `"master": "limiter"` applies a linear gain and a look-ahead limiter instead
|
|
46
|
+
(no ffmpeg, flatter dynamics).
|
|
47
|
+
|
|
48
|
+
All of these are keys under `"mix"` in motigen.json. The canvas kit mixes inside the page with its own levels and
|
|
49
|
+
the effects library; `"mix": { "engine": "page" }` makes `motigen mix` save that instead.
|
|
50
|
+
|
|
51
|
+
## Subtitles
|
|
52
|
+
|
|
53
|
+
`motigen srt` writes one cue per voice line, from its event time to that plus its duration, with the written forms
|
|
54
|
+
from `"spellings"` put back. `motigen build` runs it after the render, next to the video.
|
package/docs/canvas.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# The canvas template: ink.js and kit.js
|
|
2
|
+
|
|
3
|
+
`motigen init canvas <dir>` makes a film on a 2D canvas. `film.js` imports two modules served by motigen:
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import * as K from '/_motigen/canvas/ink.js'; // drawing primitives
|
|
7
|
+
import * as kit from '/_motigen/canvas/kit.js'; // the stage, cards, the mix, the contract
|
|
8
|
+
await kit.boot({ id, load, render, mix, logo, fonts, paper, maxDur });
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`load()` returns `{ dur }` after loading what the film needs; `render(ctx, f)` draws frame `f` (30 fps, so
|
|
12
|
+
`f = t · 30`) and must be a pure function of `f`; `mix()` returns the sound spec. `boot` loads the fonts (Geist from
|
|
13
|
+
the package by default), the optional logo, the sound library (`audio/sfx/sfx.json`), registers `window.__film` and
|
|
14
|
+
draws frame 0. Formats: `?fmt=h` is 1920×1080, `?fmt=v` is 1080×1920 (`kit.FMT`, `kit.W`, `kit.H`); keep one
|
|
15
|
+
`LAYOUTS` table per format.
|
|
16
|
+
|
|
17
|
+
## ink.js
|
|
18
|
+
|
|
19
|
+
Everything is a pure function of its inputs; strokes wobble by a seed, so a frame is the same every time.
|
|
20
|
+
|
|
21
|
+
- Math: `clamp`, `lerp`, `prog(t, a, b)`, `ease.{out, inOut, outBack, …}`, `keys(t, ks)`, `mulberry32(seed)`, `noise1`.
|
|
22
|
+
- Paths: `linePts`, `bezierPts`, `splinePts(pts, perSeg, tension)`, `ellipsePts`, `resample`, `pointAt(pts, u)` → `{x, y, a}`, `pathLength`.
|
|
23
|
+
- Strokes: `inkStroke(ctx, pts, {w, seed, boilT, taper, alpha, color, wobble})`, `inkLine`, `inkRect`, `inkEllipse`, `hatch`.
|
|
24
|
+
- The thread: `thread(ctx, pts, {to, w, color})`, `needle(ctx, x, y, a)`.
|
|
25
|
+
- Paper and marks: `paperTexture(w, h, base, seed)`, `card(ctx, x, y, w, h, {r, seed, boilT, lw, shadow})`, `underline`, `highlight(ctx, x, y, w, h, {p, color, seed})`, `checkBadge`, `tally(ctx, x, y, n)`.
|
|
26
|
+
- Type: `text(ctx, str, x, y, {kind: 'heavy'|'sans'|'serif'|'mono'|'hand', size, weight, color, align, track, alpha, spread})`, `measure`, `typed(str, t, cps)`, `typedTimes`, `stampWords`.
|
|
27
|
+
- Images and camera: `drawImage`, `camera(ctx, W, H, {cx, cy, z, rot})`.
|
|
28
|
+
- Colours `C` (paper, ink, muted, orange, yellow, blue, card, …) and fonts `F`.
|
|
29
|
+
|
|
30
|
+
## kit.js
|
|
31
|
+
|
|
32
|
+
- Stage: `bg(ctx, color)`, `label(ctx, text, {x, y, fs})`, `superLines(ctx, lines, x, y, f, f0, f1, {size, maxW, lift})` (letterpress landing, optional lift-off), `stampLine`, `stampScale(age)`, `fitSize`, `emphasis(ctx, x, y, angle, age, size, seed)`, `deckle`.
|
|
33
|
+
- Images: `loadImg`, `tryImg`, `loadSvg`, `tryJson`, `feather(img, px)` (no rectangle edge), `inkLayer` / `drawInk` (art as ink on the paper, whatever its background), `warpImage(ctx, img, w, h, quad)`.
|
|
34
|
+
- Logo: `drawLogo(ctx, x, y, w)`, `stampLogo(ctx, x, y, w, age)`, `logoH(w)` — when `boot({ logo })` was given an image.
|
|
35
|
+
- Geometry: `place(ctx, {cx, cy, s, rot})`, `mapPt`, `lerpT`, `toScreen`, `applyCam`, `camPt`, `keyPose(f, ks)`.
|
|
36
|
+
- Props and cards: `drawPencil(ctx, x, y, angle, boilT)`, `successChip`, `ruleCard(ctx, rect, age, f, label, body, {hl, hlText})`, `wordGroups`.
|
|
37
|
+
- Sound: `mix(spec)` (below), `keyClicks`, `enterClick`; `boot` exposes `__film.buildMix()`, `__film.wav()` (what `motigen mix` saves when `"mix": {"engine": "page"}`), `__film.exportMP4()` (in-browser, WebCodecs through mediabunny) and `__film.events()` (derived from the spec: voice cues and effects on the output clock).
|
|
38
|
+
|
|
39
|
+
### The mix spec
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
const mixSpec = () => ({
|
|
43
|
+
music: { file: 'audio/bed.wav', lufs: -18.5 }, // levelled before ducking; null for none
|
|
44
|
+
duck: t => (t < 14 ? -6 : -10), // duck depth under the voice at that time
|
|
45
|
+
vo: { json: VO, cues: { L01: 10, L02: { end: 261 }, L03: { f: 280, db: 4 } } }, // frames: first word on it, or last sound ends on it
|
|
46
|
+
sfx: [['FX4', 130, -8, { align: 'enter', rate: 1, maxDur: 1, fadeIn: 0, fadeOut: 0, at0: false }]],
|
|
47
|
+
extra: async ({ ac, sfxBus, voBus, decode, lib }) => {}, // film-specific sound
|
|
48
|
+
target: { lufs: -14, dbtp: -1 },
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`VO` is `audio/vo/vo.json` from `motigen vo` (or the older `{ voices: { name: { lines } } }` shape); `speechStart`
|
|
53
|
+
and `speechEnd` in it put the first word on the frame. The effects library `audio/sfx/sfx.json` maps an id to
|
|
54
|
+
`{ file, hit, dur, keys?, enter? }`, where `hit` is the transient's position inside the file so it lands on the cue.
|
package/docs/guide.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# The guide template: recording and the edit
|
|
2
|
+
|
|
3
|
+
A guide is a screencast of the real thing: a browser window and a terminal window on a 1920×1080 stage, cut by one
|
|
4
|
+
edit list, with chapters, key badges, notes and full-screen cards. `motigen init guide <dir>` sets one up.
|
|
5
|
+
|
|
6
|
+
## Recording a take
|
|
7
|
+
|
|
8
|
+
`take.mjs` is a Node script run by `motigen record take.mjs`. It imports the recorder:
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
import { Term, Browser, sleep, freshContainer, waitFile } from 'motigenjs/record';
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
### Browser
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
const b = await Browser.open('take', {
|
|
18
|
+
width: 1600, height: 900, scale: 1.5, // the viewport the player assumes (zoom coordinates are in these CSS px)
|
|
19
|
+
theme: 'light', headless: true, channel: undefined,
|
|
20
|
+
map: { 'example.com': '127.0.0.1' }, // film a local instance under its real domain (local certificate accepted)
|
|
21
|
+
proxy: 'http://127.0.0.1:2081', // everything but the mapped hosts goes through it
|
|
22
|
+
blockAnalytics: true, // the usual analytics hosts are aborted
|
|
23
|
+
cookies: [{ name: 'consent', value: 'none', domain: 'example.com', path: '/', secure: true }],
|
|
24
|
+
guard: async page => { /* throw if example.com is not the local stack */ },
|
|
25
|
+
permissions: ['clipboard-read', 'clipboard-write'], origin: 'https://example.com',
|
|
26
|
+
});
|
|
27
|
+
await b.serve('https://demo.local', 'site'); // a folder as a site, inside this browser only
|
|
28
|
+
await b.goto('https://demo.local/');
|
|
29
|
+
const clip = await b.clip('site'); // frames + clip.json → take/site/
|
|
30
|
+
await b.moveTo('#start', { ms: 900 }); // eased mouse path with a slight arc, logged
|
|
31
|
+
await b.click('#start'); // moveTo + a logged click
|
|
32
|
+
await b.typeInto('#name', 'maya'); // each key logged for the mix
|
|
33
|
+
await b.scroll(500, 1400); await b.scrollTo('#more', 120, 1000);
|
|
34
|
+
await clip.stop();
|
|
35
|
+
await b.close();
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A clip is a folder of JPEG screencast frames and `clip.json`: `frames: [{t, file, scrollY}]`, `mouse: [{t, x, y}]`,
|
|
39
|
+
`clicks`, `keys`, `urls: [{t, url}]`, `viewport`, `scale`, `dur`. Times are seconds from the clip's start.
|
|
40
|
+
|
|
41
|
+
### Terminal
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
const t = new Term('take', { cols: 112, rows: 30, cwd: '/path', ps1: '\\w $ ' });
|
|
45
|
+
await t.start(); // bash through node-pty (or $SHELL; PowerShell on Windows without bash)
|
|
46
|
+
await t.waitFor('$'); // until the text appears after the previous match
|
|
47
|
+
t.mark('install'); // a named moment, saved to term.marks.json
|
|
48
|
+
await t.type('ls', { cps: 13 }); // like a person, with jitter
|
|
49
|
+
await t.enter(); await t.paste('curl -fsSL https://example.com/install.sh | sh');
|
|
50
|
+
await t.waitIdle(3000); // until the stream is quiet (a TUI keeps redrawing while it works)
|
|
51
|
+
t.send('\x03'); // raw bytes
|
|
52
|
+
await t.stop();
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Output: `take/term.out` (the raw stream after one header line) and `take/term.tm` (`script -T` timing: seconds
|
|
56
|
+
since the previous chunk and its byte count, one line per chunk). The player replays this through xterm.js, so the
|
|
57
|
+
screen is the real one at every moment.
|
|
58
|
+
|
|
59
|
+
For a clean machine, record in a container: `freshContainer({ share: 'take', image: 'debian:bookworm-slim',
|
|
60
|
+
setup: 'laptop/setup.sh' })` starts one with `take/` mounted at `/share`, and `new Term('take', { backend: 'docker',
|
|
61
|
+
container: 'motigen-take' })` records through `script -T` inside it. Build your own image with the tools the guide
|
|
62
|
+
shows.
|
|
63
|
+
|
|
64
|
+
## The edit
|
|
65
|
+
|
|
66
|
+
`edit.js` exports one object. Top level: `cols`, `rows` (the recorded terminal's size), `fps` (30), `take`
|
|
67
|
+
('take'), `term` ('take/term'), `brand` (image url for the top-right mark), `termTitle`, `host` (the address bar;
|
|
68
|
+
default: the clip's own), `maskPath` (`[[pattern, replacement]]` for the shown path), `shots`.
|
|
69
|
+
|
|
70
|
+
Shots, in order:
|
|
71
|
+
|
|
72
|
+
| Shot | Fields |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `{ card: '<html>', dur }` | a full-screen card (fades in and out) |
|
|
75
|
+
| `{ browser: '<clip>', segs, zoom?, chapter?, keys?, note?, vo?, typed? }` | a browser clip |
|
|
76
|
+
| `{ term: true, segs, zoom?, chapter?, keys?, note?, vo?, typing? }` | the terminal stream |
|
|
77
|
+
|
|
78
|
+
- `segs: [[src0, src1, dur?], …]` — source seconds played back to back; `dur` stretches or squeezes that piece.
|
|
79
|
+
For terminal shots a bound may be a text anchor: `'Installed acme|-0.3'` is the moment that text appears
|
|
80
|
+
in the stream (searched forward from the previous anchor, so repeated words resolve in order) plus an offset;
|
|
81
|
+
`null` is where the previous piece ended.
|
|
82
|
+
- `zoom: [[t, scale, cx, cy], …]` — in shot time; `cx`, `cy` is the point brought to the middle of the window, in
|
|
83
|
+
page CSS px for browser shots (the recorded viewport) and in window px (1520×855) for terminal shots. Only the
|
|
84
|
+
window's content zooms.
|
|
85
|
+
- `chapter: ['Step 1', 'Install']` — the pill and title top-left; it stays until the next chapter.
|
|
86
|
+
- `keys: [[t0 | 'anchor|off', len, 'Enter', 'label']]` — a key badge for `len` seconds.
|
|
87
|
+
- `note: 'text'` — a line at the bottom of the stage for the shot.
|
|
88
|
+
- `vo: [[lineId, seg, offset]]` — voice cues: line `L03` starts `offset` seconds after segment `seg` begins
|
|
89
|
+
(`-1` = the shot's start). They become `events.vo` for the mix and the subtitles.
|
|
90
|
+
- `typing: [[src0, src1]]` — terminal ranges where a TUI echoes typed keys (counted as key presses).
|
|
91
|
+
- `typed: [[src0, src1, n]]` — browser shots: n key presses spread over that range, when the recorder did not log them.
|
|
92
|
+
|
|
93
|
+
The sound events (`motigen events`) come out of the edit: voice cues, clicks (from the clip's log), key presses
|
|
94
|
+
(from the terminal's one-byte echoes, the logged keys, or `typed`), and Enter badges.
|
|
95
|
+
|
|
96
|
+
## Look
|
|
97
|
+
|
|
98
|
+
`/_motigen/guide/player.css` holds the geometry and the defaults; `theme.css` in the project overrides the colours
|
|
99
|
+
and fonts (`--paper`, `--ink`, `--accent`, `--term`, `--font-sans`, …). The stage is 1920×1080; the window is
|
|
100
|
+
1520×901 at (200, 128) with a 46 px bar. Keep the geometry: the zoom coordinates refer to it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "motigenjs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Video as code for developers and their coding agents: a web page draws frame t, headless Chrome renders it to MP4; real terminals and browsers are recorded, narration and the mix come from the same timeline.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"motigen": "bin/motigen.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./render": "./src/render.js",
|
|
12
|
+
"./browser": "./src/browser.js",
|
|
13
|
+
"./serve": "./src/serve.js",
|
|
14
|
+
"./project": "./src/project.js",
|
|
15
|
+
"./record": "./src/record/index.js",
|
|
16
|
+
"./audio": "./src/audio/index.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"src",
|
|
21
|
+
"runtime",
|
|
22
|
+
"templates",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"skill",
|
|
26
|
+
"docs"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@fontsource/geist-mono": "^5.3.0",
|
|
36
|
+
"@fontsource/geist-sans": "^5.3.0",
|
|
37
|
+
"@xterm/xterm": "^5.5.0",
|
|
38
|
+
"mediabunny": "^1.65.2",
|
|
39
|
+
"playwright": "^1.63.0"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"video",
|
|
43
|
+
"render",
|
|
44
|
+
"headless",
|
|
45
|
+
"playwright",
|
|
46
|
+
"ffmpeg",
|
|
47
|
+
"screencast",
|
|
48
|
+
"terminal",
|
|
49
|
+
"canvas",
|
|
50
|
+
"motion",
|
|
51
|
+
"narration",
|
|
52
|
+
"tts"
|
|
53
|
+
],
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/crocone/motigen.git"
|
|
57
|
+
},
|
|
58
|
+
"license": "MIT",
|
|
59
|
+
"optionalDependencies": {
|
|
60
|
+
"node-pty": "^1.1.0"
|
|
61
|
+
}
|
|
62
|
+
}
|