motigenjs 0.1.0 → 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/README.md +17 -0
- package/package.json +1 -1
- package/runtime/canvas/kit.js +2 -1
- package/runtime/guide/player.js +9 -7
- package/skill/SKILL.md +3 -1
- package/src/cli.js +22 -2
- package/src/render.js +9 -6
- package/src/stability.js +87 -0
- package/templates/canvas/gitignore +1 -0
- package/templates/dom/film.html +7 -5
- package/templates/dom/gitignore +1 -0
- package/templates/guide/gitignore +1 -0
package/README.md
CHANGED
|
@@ -61,6 +61,7 @@ Linux). Without it, record in a container: `backend: 'docker'`.
|
|
|
61
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
62
|
| `motigen frames -t 1,2.5,10` | Stills into `shots/` (`--png`, `--every 5`, `--element '#terminal'` with a transparent background, `--scale 2`). |
|
|
63
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). |
|
|
64
65
|
| `motigen events` | The page's `events()` into `audio/events.json`: voice cues, clicks, keys, Enter presses, effects. |
|
|
65
66
|
| `motigen record [take.mjs]` | Runs a take script; it imports `{ Term, Browser, sleep }` from `motigenjs/record`. |
|
|
66
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. |
|
|
@@ -146,6 +147,22 @@ click / key / Enter sounds for what the recorder logged (synthesised when `audio
|
|
|
146
147
|
bed with a crossfade, ducks it under the voice and masters with ffmpeg's two-pass loudnorm. The levels, the
|
|
147
148
|
ducking and the target live under `"mix"` in `motigen.json`. [docs/audio.md](docs/audio.md).
|
|
148
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
|
+
|
|
149
166
|
## Why not …
|
|
150
167
|
|
|
151
168
|
- **Remotion**: React, a bundler and a licence with limits for companies. motigen takes any page, needs no build
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "motigenjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/runtime/canvas/kit.js
CHANGED
|
@@ -19,7 +19,8 @@ canvas.width = W; canvas.height = H;
|
|
|
19
19
|
export const ctx = canvas.getContext('2d', { alpha: false });
|
|
20
20
|
export const off = (w, h) => { const c = new OffscreenCanvas(w, h); return [c, c.getContext('2d')]; };
|
|
21
21
|
|
|
22
|
-
// Line boil (
|
|
22
|
+
// Line boil (hand-drawn strokes re-seeded 12 times a second) reads as jitter on video, so it is off: every stroke
|
|
23
|
+
// keeps one seed for the whole film and a still shot repeats the same pixels. ?boil=1 turns it on for experiments.
|
|
23
24
|
export const BOIL = Q.get('boil') === '1';
|
|
24
25
|
export const bt = f => (BOIL ? Math.floor(f / 2) : 0);
|
|
25
26
|
export const on2 = f => Math.floor(f / 2) * 2; // quantise a frame to twos
|
package/runtime/guide/player.js
CHANGED
|
@@ -213,8 +213,8 @@ async function renderAt(t) {
|
|
|
213
213
|
const k = switching ? ease(lt / 0.45) : 1;
|
|
214
214
|
const front = active === 'browser' ? winB : winT, back = active === 'browser' ? winT : winB;
|
|
215
215
|
front.style.zIndex = 2; back.style.zIndex = 1;
|
|
216
|
-
front.style.opacity = k; front.style.transform = `translateY(${(1 - k) * 36}px) scale(${0.985 + 0.015 * k})`;
|
|
217
|
-
back.style.opacity = switching ? 1 - k * 0.999 : 0; back.style.transform = `scale(${1 - 0.02 * k})`;
|
|
216
|
+
front.style.opacity = k; front.style.transform = k === 1 ? 'none' : `translateY(${Math.round((1 - k) * 36)}px) scale(${0.985 + 0.015 * k})`;
|
|
217
|
+
back.style.opacity = switching ? 1 - k * 0.999 : 0; back.style.transform = k === 1 ? 'none' : `scale(${1 - 0.02 * k})`;
|
|
218
218
|
|
|
219
219
|
// browser content
|
|
220
220
|
const bShot = shot.kind === 'browser' ? shot : switching && prev.kind === 'browser' ? prev : null;
|
|
@@ -261,11 +261,13 @@ async function renderAt(t) {
|
|
|
261
261
|
if (tShot) await termAt(tShot === shot ? srcOf(shot, lt) : srcOf(tShot, tShot.dur));
|
|
262
262
|
else await termAt(0);
|
|
263
263
|
|
|
264
|
-
// camera
|
|
264
|
+
// camera. The translation is snapped to whole pixels and the scale to 1/10000: a slow drift then moves in
|
|
265
|
+
// clean one-pixel steps and a hold repeats the exact same transform every frame, so text is never re-rasterised
|
|
266
|
+
// at a slightly different sub-pixel offset from one frame to the next (that reads as jitter on video).
|
|
265
267
|
const place = (el, z, unit) => {
|
|
266
|
-
const [
|
|
268
|
+
const [s0, x, y] = z; const s = Math.round(s0 * 1e4) / 1e4, cx = x * unit, cy = y * unit;
|
|
267
269
|
// keep the zoomed content inside the window: no empty edges
|
|
268
|
-
const tx = clamp(VW / 2 - cx * s, VW - VW * s, 0), ty = clamp(VH / 2 - cy * s, VH - VH * s, 0);
|
|
270
|
+
const tx = Math.round(clamp(VW / 2 - cx * s, VW - VW * s, 0)), ty = Math.round(clamp(VH / 2 - cy * s, VH - VH * s, 0));
|
|
269
271
|
el.style.transform = s === 1 ? 'none' : `translate(${tx}px, ${ty}px) scale(${s})`;
|
|
270
272
|
};
|
|
271
273
|
const zb = shot.kind === 'browser' ? zoomAt(shot, lt) : switching && prev.kind === 'browser' ? zoomAt(prev, prev.dur) : [1, 800, 450];
|
|
@@ -281,14 +283,14 @@ async function renderAt(t) {
|
|
|
281
283
|
if (ch) {
|
|
282
284
|
chapter.querySelector('.n').textContent = ch[0]; chapter.querySelector('.t').textContent = ch[1];
|
|
283
285
|
const kc = ease((t - chapterSince) / 0.4);
|
|
284
|
-
chapter.style.opacity = kc; chapter.style.transform = `translateX(${(1 - kc) * -24}px)`;
|
|
286
|
+
chapter.style.opacity = kc; chapter.style.transform = `translateX(${Math.round((1 - kc) * -24)}px)`;
|
|
285
287
|
}
|
|
286
288
|
const kb = (shot.keys || []).find(([a, b]) => lt >= a && lt < b);
|
|
287
289
|
key.style.display = kb ? 'flex' : 'none';
|
|
288
290
|
if (kb) {
|
|
289
291
|
key.querySelector('kbd').textContent = kb[2]; key.querySelector('span').textContent = kb[3] || '';
|
|
290
292
|
const kk = Math.min(ease((lt - kb[0]) / 0.25), 1 - ease((lt - (kb[1] - 0.25)) / 0.25));
|
|
291
|
-
key.style.opacity = kk; key.style.transform = `translateX(-50%) translateY(${(1 - kk) * -14}px)`;
|
|
293
|
+
key.style.opacity = kk; key.style.transform = `translateX(-50%) translateY(${Math.round((1 - kk) * -14)}px)`;
|
|
292
294
|
}
|
|
293
295
|
const nt = shot.note;
|
|
294
296
|
note.textContent = nt || ''; note.style.opacity = nt && shot.kind !== 'card' ? Math.min(ease(lt / 0.4), 1 - ease((lt - shot.dur + 0.4) / 0.4)) : 0;
|
package/skill/SKILL.md
CHANGED
|
@@ -22,7 +22,9 @@ Commands: `init`, `record`, `preview`, `frames`, `events`, `vo`, `mix`, `srt`, `
|
|
|
22
22
|
**Canvas:** write `film.js` (`load`, `LAYOUTS` for both formats, `render(ctx, f)`, `mixSpec`); `VO_CUES` put the
|
|
23
23
|
first word of each line on a frame. **Dom:** edit `film.html`.
|
|
24
24
|
4. **Look before rendering.** `motigen frames -t 1,5,12` and open the JPEGs; `motigen preview` for scrubbing.
|
|
25
|
-
Check both formats when the project has them (`--fmt v`).
|
|
25
|
+
Check both formats when the project has them (`--fmt v`). Then `motigen stability -t <a few hold moments>
|
|
26
|
+
--still`: every hold must report `still`; a `small` change on text that should not move is jitter — find the
|
|
27
|
+
value that still changes (a transform that never settles, a position computed from the clock, line boil).
|
|
26
28
|
5. **Sound.** `motigen vo` (needs `ELEVENLABS_API_KEY` and the voice id) or `motigen vo import` for recordings;
|
|
27
29
|
`motigen vo check`; `motigen events`; `motigen mix`. Listen: `audio/mix.wav` should sit at −14 LUFS.
|
|
28
30
|
6. **Build.** `motigen build` renders every format and writes the `.srt`. Report the output paths and lengths.
|
package/src/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ const USAGE = `motigen ${VERSION} — video as code
|
|
|
16
16
|
motigen preview [--port 4310] [--open] player: scrub, sound, formats, a render button
|
|
17
17
|
motigen frames -t 1,2.5,10 [-o shots] stills at those seconds (jpeg; --png; --element '#terminal')
|
|
18
18
|
motigen render [-o out.mp4] the video (--fps 30 --workers 4 --audio audio/mix.wav --range 10-20)
|
|
19
|
+
motigen stability -t 3,10 [--every 2] do still moments stay still? pixels changed between consecutive frames (--still: fail if not)
|
|
19
20
|
motigen events [-o audio/events.json] the timeline's sound events, for the mix
|
|
20
21
|
motigen vo [audio/lines.txt] narration: one ElevenLabs call, split per line into audio/vo/ ($ELEVENLABS_API_KEY)
|
|
21
22
|
motigen vo split <resp.json> <lines.txt> split a saved with-timestamps response instead of calling the API
|
|
@@ -36,7 +37,7 @@ Options:
|
|
|
36
37
|
--query "fmt=v" query string for the page
|
|
37
38
|
--scale N device scale factor for stills (default 1)
|
|
38
39
|
--no-audio render without sound even if audio/mix.wav exists
|
|
39
|
-
--crf N --preset P x264 quality (default 14, slow)
|
|
40
|
+
--crf N --preset P x264 quality (default 14, slow); --gop <s> keyframe interval in seconds (default 2)
|
|
40
41
|
-j, --workers N pages rendering in parallel (default: half the cores, at most 4)
|
|
41
42
|
--channel chrome installed Chrome instead of Playwright's Chromium
|
|
42
43
|
--no-gpu / --gpu GPU flags (default: on under Windows)
|
|
@@ -44,6 +45,7 @@ Options:
|
|
|
44
45
|
--timeout <s> seconds to wait for window.__film.ready (default 60)
|
|
45
46
|
--voice <id> --model <id> --seed <n> TTS settings (also "vo": {…} in motigen.json)
|
|
46
47
|
--limit <n> vo check: flatness above this is flagged (default 0.12)
|
|
48
|
+
--frames <n> stability: consecutive frames per sample (default 3); --threshold <0-255> pixel change that counts (8)
|
|
47
49
|
|
|
48
50
|
motigen.json may also hold "vo": { "voice", "model", "seed", "settings" }, "mix": { levels, ducking, target } and
|
|
49
51
|
"spellings": [["Acme Cloud dot com", "acmecloud.com"]] for the subtitles.
|
|
@@ -57,6 +59,7 @@ const OPTIONS = {
|
|
|
57
59
|
crf: { type: 'string' }, preset: { type: 'string' }, channel: { type: 'string' }, gpu: { type: 'boolean' }, 'no-gpu': { type: 'boolean' },
|
|
58
60
|
ffmpeg: { type: 'string' }, timeout: { type: 'string' }, port: { type: 'string' }, open: { type: 'boolean' }, force: { type: 'boolean' },
|
|
59
61
|
voice: { type: 'string' }, model: { type: 'string' }, seed: { type: 'string' }, limit: { type: 'string' },
|
|
62
|
+
frames: { type: 'string' }, threshold: { type: 'string' }, still: { type: 'boolean' }, gop: { type: 'string' },
|
|
60
63
|
help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
|
|
61
64
|
};
|
|
62
65
|
|
|
@@ -109,6 +112,23 @@ const commands = {
|
|
|
109
112
|
for (const f of files) console.log(show(f));
|
|
110
113
|
},
|
|
111
114
|
|
|
115
|
+
async stability(v, pos) {
|
|
116
|
+
const t = await target(v, v.fmt?.[0]);
|
|
117
|
+
const times = (v.times || pos[0] || '').split(/[,\s]+/).filter(Boolean).map(Number);
|
|
118
|
+
const every = num(v.every, 'every', 0);
|
|
119
|
+
if (!times.length && !every) fail('stability: give the moments, e.g. -t 3,10,20 (or --every 2)');
|
|
120
|
+
const { stability } = await import('./stability.js');
|
|
121
|
+
const rows = await stability({ ...open(t), fps: t.fps, times, every, frames: num(v.frames, 'frames', 3), threshold: num(v.threshold, 'threshold', 8), out: resolve(t.project, v.out || 'stability'), ffmpeg: t.ffmpeg });
|
|
122
|
+
let moving = 0;
|
|
123
|
+
for (const r of rows) {
|
|
124
|
+
const parts = r.pairs.map(p => `${p.pct.toFixed(2)}%${p.box ? ` @${p.box[0]},${p.box[1]}-${p.box[2]},${p.box[3]}` : ''}`);
|
|
125
|
+
console.log(`${r.t.toFixed(2).padStart(7)} s ${r.verdict.padEnd(7)} ${parts.join(' | ')}`);
|
|
126
|
+
if (r.verdict !== 'still') moving++;
|
|
127
|
+
}
|
|
128
|
+
console.log(`${rows.length} moments · ${rows.length - moving} still · ${moving} with changes (a -diff.png next to each changed frame shows what moved) · ${show(resolve(t.project, v.out || 'stability'))}`);
|
|
129
|
+
if (v.still && moving) { console.error(`--still: ${moving} of ${rows.length} moments are not still`); process.exitCode = 2; }
|
|
130
|
+
},
|
|
131
|
+
|
|
112
132
|
async events(v) {
|
|
113
133
|
const t = await target(v, v.fmt?.[0]);
|
|
114
134
|
const out = resolve(t.project, v.out || join(t.cfg.audio, 'events.json'));
|
|
@@ -121,7 +141,7 @@ const commands = {
|
|
|
121
141
|
const out = resolve(t.project, v.out || t.outDefault);
|
|
122
142
|
const audio = v['no-audio'] ? null : v.audio ? resolve(t.project, v.audio) : defaultAudio(t.cfg);
|
|
123
143
|
const bar = progress('render');
|
|
124
|
-
const r = await renderVideo({ ...open(t), out, fps: t.fps, audio, crf: num(v.crf, 'crf', 14), preset: v.preset || 'slow', range: parseRange(v.range), workers: num(v.workers, 'workers', defaultWorkers()), ffmpeg: t.ffmpeg, onProgress: bar });
|
|
144
|
+
const r = await renderVideo({ ...open(t), out, fps: t.fps, audio, crf: num(v.crf, 'crf', 14), preset: v.preset || 'slow', gop: num(v.gop, 'gop', 2), range: parseRange(v.range), workers: num(v.workers, 'workers', defaultWorkers()), ffmpeg: t.ffmpeg, onProgress: bar });
|
|
125
145
|
bar(r.frames, r.frames, true);
|
|
126
146
|
console.log(`${show(r.out)} · ${r.frames} frames @ ${r.fps} fps${audio ? ' · audio ' + relative(t.project, audio) : ' · silent'} · ${r.seconds.toFixed(0)} s`);
|
|
127
147
|
},
|
package/src/render.js
CHANGED
|
@@ -124,13 +124,16 @@ function run(cmd, args) {
|
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
// Keyframes every `gop` seconds, and only there (no scene-cut keyframes): each I-frame re-encodes fine texture a
|
|
128
|
+
// little differently, which reads as a pulse on a still shot, so they are kept rare and regular. 1 s made paper
|
|
129
|
+
// grain breathe; 2 s is quiet and still seeks fine.
|
|
130
|
+
const x264 = (fps, crf, preset, gop = 2) => ['-c:v', 'libx264', '-preset', preset, '-crf', String(crf), '-x264-params', 'aq-mode=3:aq-strength=0.9:scenecut=0', '-pix_fmt', 'yuv420p', '-profile:v', 'high', '-g', String(Math.max(1, Math.round(fps * gop))), '-movflags', '+faststart'];
|
|
128
131
|
// audio padded to the video's length, then cut to it: the video always decides the duration
|
|
129
132
|
const audioArgs = audio => (audio ? ['-i', audio, '-map', '0:v', '-map', '1:a', '-c:a', 'aac', '-b:a', '256k', '-ar', '48000', '-af', 'apad', '-shortest'] : []);
|
|
130
133
|
|
|
131
134
|
/** Frames [from, to) of one open film → x264 mp4 (with audio when given). */
|
|
132
|
-
export async function encodeRange(film, { from, to, fps, out, audio, crf = 14, preset = 'slow', ffmpeg = 'ffmpeg', onFrame }) {
|
|
133
|
-
const args = ['-v', 'error', '-y', '-f', 'image2pipe', '-framerate', String(fps), '-c:v', 'png', '-i', '-', ...audioArgs(audio), ...x264(fps, crf, preset), out];
|
|
135
|
+
export async function encodeRange(film, { from, to, fps, out, audio, crf = 14, preset = 'slow', gop = 2, ffmpeg = 'ffmpeg', onFrame }) {
|
|
136
|
+
const args = ['-v', 'error', '-y', '-f', 'image2pipe', '-framerate', String(fps), '-c:v', 'png', '-i', '-', ...audioArgs(audio), ...x264(fps, crf, preset, gop), out];
|
|
134
137
|
let err = '', failed = null;
|
|
135
138
|
const ff = spawn(ffmpeg, args, { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
136
139
|
ff.stderr.on('data', d => { err += d; });
|
|
@@ -157,7 +160,7 @@ export async function encodeRange(film, { from, to, fps, out, audio, crf = 14, p
|
|
|
157
160
|
* Render the whole film (or `range` = [a, b] seconds) to `out`.
|
|
158
161
|
* @returns {Promise<{ out: string, frames: number, fps: number, seconds: number }>}
|
|
159
162
|
*/
|
|
160
|
-
export async function renderVideo({ out, fps, audio, crf = 14, preset = 'slow', range, workers = 1, ffmpeg = 'ffmpeg', onProgress, ...open }) {
|
|
163
|
+
export async function renderVideo({ out, fps, audio, crf = 14, preset = 'slow', gop = 2, range, workers = 1, ffmpeg = 'ffmpeg', onProgress, ...open }) {
|
|
161
164
|
const t0 = Date.now();
|
|
162
165
|
out = resolve(out);
|
|
163
166
|
const browser = await launch({ channel: open.channel, gpu: open.gpu });
|
|
@@ -173,7 +176,7 @@ export async function renderVideo({ out, fps, audio, crf = 14, preset = 'slow',
|
|
|
173
176
|
workers = Math.max(1, Math.min(workers | 0 || 1, n));
|
|
174
177
|
await mkdir(dirname(out), { recursive: true });
|
|
175
178
|
if (workers === 1) {
|
|
176
|
-
await encodeRange(films[0], { from: first, to: last, fps, out, audio, crf, preset, ffmpeg, onFrame: i => onProgress?.(i, n) });
|
|
179
|
+
await encodeRange(films[0], { from: first, to: last, fps, out, audio, crf, preset, gop, ffmpeg, onFrame: i => onProgress?.(i, n) });
|
|
177
180
|
} else {
|
|
178
181
|
const chunk = Math.ceil(n / workers);
|
|
179
182
|
while (films.length < workers) films.push(await openFilm({ ...open, browser }));
|
|
@@ -183,7 +186,7 @@ export async function renderVideo({ out, fps, audio, crf = 14, preset = 'slow',
|
|
|
183
186
|
if (from >= to) return null;
|
|
184
187
|
const part = `${out}.part${String(w).padStart(2, '0')}.mp4`;
|
|
185
188
|
parts.push(part);
|
|
186
|
-
return encodeRange(film, { from, to, fps, out: part, crf, preset, ffmpeg, onFrame: i => { done[w] = i; onProgress?.(done.reduce((x, y) => x + y, 0), n); } });
|
|
189
|
+
return encodeRange(film, { from, to, fps, out: part, crf, preset, gop, ffmpeg, onFrame: i => { done[w] = i; onProgress?.(done.reduce((x, y) => x + y, 0), n); } });
|
|
187
190
|
}));
|
|
188
191
|
const list = `${out}.parts.txt`;
|
|
189
192
|
await writeFile(list, parts.map(p => `file '${p.replace(/\\/g, '/').replace(/'/g, "'\\''")}'`).join('\n') + '\n');
|
package/src/stability.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Frame-to-frame stability: render a few consecutive frames at chosen moments and count the pixels that change
|
|
2
|
+
// between them. A hold must give zero; a slow pan gives a steady, small, moving change; "jitter" is a small change
|
|
3
|
+
// that comes and goes in a region that should be still (text re-rasterised at sub-pixel offsets, line boil,
|
|
4
|
+
// anything that depends on wall-clock time). The difference rasters come from ffmpeg, so no image library is needed.
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import { openFilm } from './render.js';
|
|
9
|
+
|
|
10
|
+
/** Changed pixels between two PNG files: { pct, box: [x0, y0, x1, y1] | null }. */
|
|
11
|
+
export async function diffPng(a, b, { threshold = 8, ffmpeg = process.env.MOTIGEN_FFMPEG || 'ffmpeg' } = {}) {
|
|
12
|
+
const raw = await new Promise((res, rej) => {
|
|
13
|
+
const chunks = []; let err = '';
|
|
14
|
+
const p = spawn(ffmpeg, ['-v', 'error', '-i', a, '-i', b, '-lavfi', '[0:v][1:v]blend=all_mode=difference,format=gray', '-f', 'rawvideo', '-'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
15
|
+
p.stdout.on('data', d => chunks.push(d)); p.stderr.on('data', d => { err += d; });
|
|
16
|
+
p.on('error', e => rej(e.code === 'ENOENT' ? new Error(`${ffmpeg} not found: install ffmpeg and put it in PATH (or set --ffmpeg / $MOTIGEN_FFMPEG)`) : e));
|
|
17
|
+
p.on('close', code => (code === 0 ? res(Buffer.concat(chunks)) : rej(new Error(`ffmpeg could not diff ${a} and ${b}: ${err.trim()}`))));
|
|
18
|
+
});
|
|
19
|
+
return countDiff(raw, threshold);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The size of a PNG from its header. */
|
|
23
|
+
function pngSize(buf) { return [buf.readUInt32BE(16), buf.readUInt32BE(20)]; }
|
|
24
|
+
|
|
25
|
+
export function countDiff(gray, threshold, width) {
|
|
26
|
+
const n = gray.length, w = width || Math.round(Math.sqrt(n * 16 / 9)) || 1;
|
|
27
|
+
let changed = 0, x0 = Infinity, y0 = Infinity, x1 = -1, y1 = -1;
|
|
28
|
+
for (let i = 0; i < n; i++) {
|
|
29
|
+
if (gray[i] > threshold) { changed++; const x = i % w, y = (i / w) | 0; if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
|
|
30
|
+
}
|
|
31
|
+
return { pct: +(100 * changed / Math.max(1, n)).toFixed(3), changed, box: changed ? [x0, y0, x1, y1] : null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} o
|
|
36
|
+
* @param {number[]} [o.times] seconds to sample
|
|
37
|
+
* @param {number} [o.every] sample every N seconds instead
|
|
38
|
+
* @param {number} [o.frames] consecutive frames per sample (default 3)
|
|
39
|
+
* @param {string} o.out folder for the PNGs
|
|
40
|
+
* @returns rows [{ t, pairs: [{ from, to, pct, box }], verdict }]
|
|
41
|
+
*/
|
|
42
|
+
export async function stability({ times = [], every = 0, frames = 3, out, threshold = 8, ffmpeg = process.env.MOTIGEN_FFMPEG || 'ffmpeg', fps: fpsOpt, ...open }) {
|
|
43
|
+
const film = await openFilm(open);
|
|
44
|
+
try {
|
|
45
|
+
const fps = fpsOpt || film.fps || 30;
|
|
46
|
+
if (every > 0) { times = []; for (let t = 0; t < film.dur; t += every) times.push(+t.toFixed(3)); }
|
|
47
|
+
await mkdir(out, { recursive: true });
|
|
48
|
+
const rows = [];
|
|
49
|
+
for (const t of times) {
|
|
50
|
+
const files = [];
|
|
51
|
+
let width = null;
|
|
52
|
+
for (let k = 0; k < frames; k++) {
|
|
53
|
+
const tk = Math.min(t + k / fps, film.dur - 1e-6);
|
|
54
|
+
await film.at(tk);
|
|
55
|
+
const buf = await film.shot('png');
|
|
56
|
+
width ||= pngSize(buf)[0];
|
|
57
|
+
const f = join(out, `t${t.toFixed(2).padStart(6, '0')}-${k}.png`);
|
|
58
|
+
await writeFile(f, buf);
|
|
59
|
+
files.push(f);
|
|
60
|
+
}
|
|
61
|
+
const pairs = [];
|
|
62
|
+
for (let k = 1; k < files.length; k++) {
|
|
63
|
+
// the difference raster (counted here) and, when anything changed, a picture of it with the changes
|
|
64
|
+
// brightened 16×, so the eye finds what moved
|
|
65
|
+
const diffFile = files[k].replace(/\.png$/, '-diff.png');
|
|
66
|
+
const raw = await new Promise((res, rej) => {
|
|
67
|
+
const chunks = []; let err = '';
|
|
68
|
+
const p = spawn(ffmpeg, ['-v', 'error', '-y', '-i', files[k - 1], '-i', files[k], '-filter_complex', "[0:v][1:v]blend=all_mode=difference,format=gray,split[a][b];[b]lutyuv=y='min(255,val*16)'[vis]", '-map', '[a]', '-f', 'rawvideo', '-', '-map', '[vis]', '-frames:v', '1', diffFile], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
69
|
+
p.stdout.on('data', d => chunks.push(d)); p.stderr.on('data', d => { err += d; });
|
|
70
|
+
p.on('error', e => rej(e.code === 'ENOENT' ? new Error(`${ffmpeg} not found: install ffmpeg and put it in PATH (or set --ffmpeg / $MOTIGEN_FFMPEG)`) : e));
|
|
71
|
+
p.on('close', code => (code === 0 ? res(Buffer.concat(chunks)) : rej(new Error(`ffmpeg could not diff frames: ${err.trim()}`))));
|
|
72
|
+
});
|
|
73
|
+
const d = countDiff(raw, threshold, width);
|
|
74
|
+
if (!d.changed) await rm(diffFile, { force: true });
|
|
75
|
+
pairs.push({ from: +(t + (k - 1) / fps).toFixed(3), to: +(t + k / fps).toFixed(3), ...d, diff: d.changed ? diffFile : null });
|
|
76
|
+
}
|
|
77
|
+
const max = Math.max(...pairs.map(p => p.pct));
|
|
78
|
+
// still: nothing changed. small: under 1 % of the frame changed (a cursor, a caret, a spinner, or jitter: look
|
|
79
|
+
// at the -diff.png). motion: more than that.
|
|
80
|
+
const verdict = max === 0 ? 'still' : max < 1 ? 'small' : 'motion';
|
|
81
|
+
rows.push({ t, pairs, verdict, files });
|
|
82
|
+
}
|
|
83
|
+
return rows;
|
|
84
|
+
} finally { await film.close(); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const resolveOut = (project, out) => resolve(project, out || 'stability');
|
package/templates/dom/film.html
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
<title>{{name}}</title>
|
|
6
6
|
<style>
|
|
7
7
|
/* The contract: window.__film = { ready, dur, fps, renderAt(t) }. Every value on screen is a pure function of t,
|
|
8
|
-
so motigen can render any frame in any order. No libraries, no build step: edit, reload, render.
|
|
8
|
+
so motigen can render any frame in any order. No libraries, no build step: edit, reload, render.
|
|
9
|
+
Stillness: positions are whole pixels, transforms settle to exact values (scale(1), translate 0) and no CSS
|
|
10
|
+
animation or transition runs on its own; `motigen stability -t 7.5` checks that a hold repeats the same pixels. */
|
|
9
11
|
@font-face { font-family: 'Geist'; font-weight: 500; font-display: block; src: url(/_motigen/vendor/geist-sans/files/geist-sans-latin-500-normal.woff2) format('woff2'); }
|
|
10
12
|
@font-face { font-family: 'Geist'; font-weight: 800; font-display: block; src: url(/_motigen/vendor/geist-sans/files/geist-sans-latin-800-normal.woff2) format('woff2'); }
|
|
11
13
|
@font-face { font-family: 'Geist Mono'; font-weight: 500; font-display: block; src: url(/_motigen/vendor/geist-mono/files/geist-mono-latin-500-normal.woff2) format('woff2'); }
|
|
12
14
|
:root { --paper: #f6f2ea; --ink: #1f1d1a; --muted: #6b665d; --accent: #ee6a4a; }
|
|
13
15
|
html, body { margin: 0; background: #000; }
|
|
14
16
|
#stage { position: relative; width: 1920px; height: 1080px; overflow: hidden; background: radial-gradient(1400px 900px at 30% 20%, #fbf8f2, var(--paper) 60%, #ebe5d8); font-family: Geist, system-ui, sans-serif; color: var(--ink); }
|
|
15
|
-
.abs { position: absolute;
|
|
17
|
+
.abs { position: absolute; }
|
|
16
18
|
#title { left: 160px; top: 300px; font-size: 128px; font-weight: 800; letter-spacing: -.045em; line-height: 1; transform-origin: 0 50%; }
|
|
17
19
|
#sub { left: 164px; top: 470px; font-size: 40px; font-weight: 500; color: var(--muted); }
|
|
18
20
|
.line { left: 164px; font-size: 34px; font-weight: 500; font-family: 'Geist Mono', monospace; }
|
|
@@ -49,9 +51,9 @@
|
|
|
49
51
|
$('sub').style.opacity = prog(t, 1.4, 2.0); $('sub').style.transform = `translateY(${16 * (1 - out(prog(t, 1.4, 2.0)))}px)`;
|
|
50
52
|
const lines = ['$ motigen frames -t 0,2,5', '$ motigen render', '$ motigen preview'];
|
|
51
53
|
lines.forEach((s, i) => { const k = prog(t, 2.2 + i * 1.2, 3.0 + i * 1.2); $('l' + (i + 1)).textContent = typed(s, k) + (k > 0 && k < 1 ? '▌' : ''); });
|
|
52
|
-
// the dot
|
|
53
|
-
|
|
54
|
-
$('dot').style.transform = `scale(${
|
|
54
|
+
// the dot pops in, then holds: once a thing has landed, its transform must stop changing, or its edges get
|
|
55
|
+
// re-rasterised a little differently every frame and the video shows a shimmer where nothing should move
|
|
56
|
+
$('dot').style.transform = `scale(${out(prog(t, 0, 0.6))})`;
|
|
55
57
|
$('end').style.opacity = prog(t, 6.5, 7.0);
|
|
56
58
|
}
|
|
57
59
|
|
package/templates/dom/gitignore
CHANGED