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 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.0",
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": {
@@ -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 (drawings re-seeded on twos) reads as jitter on video, so it is off unless ?boil=1.
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
@@ -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 [s, x, y] = z; const cx = x * unit, cy = y * unit;
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
- const x264 = (fps, crf, preset) => ['-c:v', 'libx264', '-preset', preset, '-crf', String(crf), '-x264-params', 'aq-mode=3:aq-strength=0.9', '-pix_fmt', 'yuv420p', '-profile:v', 'high', '-g', String(fps * 2), '-movflags', '+faststart'];
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');
@@ -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');
@@ -4,3 +4,4 @@ audio/events.json
4
4
  audio/mix.wav
5
5
  audio/mix-raw.wav
6
6
  audio/vo/resp.json
7
+ stability/
@@ -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; will-change: transform, opacity; }
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 breathes on a fixed clock, so it is the same at every render of the same t
53
- const b = 1 + 0.06 * Math.sin(t * Math.PI * 1.5);
54
- $('dot').style.transform = `scale(${b * out(prog(t, 0, 0.6))})`;
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
 
@@ -4,3 +4,4 @@ audio/events.json
4
4
  audio/mix.wav
5
5
  audio/mix-raw.wav
6
6
  audio/vo/resp.json
7
+ stability/
@@ -5,3 +5,4 @@ audio/events.json
5
5
  audio/mix.wav
6
6
  audio/mix-raw.wav
7
7
  audio/vo/resp.json
8
+ stability/