@homie-rocks/studio 0.51.4 → 0.51.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +10 -29
  3. package/apps/APPS.md +16 -1
  4. package/bin/homie-studio.mjs +28 -30
  5. package/guides/app/NEEDS.md +160 -0
  6. package/guides/app/SKILL.md +20 -4
  7. package/guides/art/SKILL.md +68 -140
  8. package/guides/art/scripts/art.mjs +6 -79
  9. package/guides/models/SKILL.md +73 -177
  10. package/guides/models/references/RIGHTS.md +1 -1
  11. package/guides/models/scripts/models.mjs +7 -288
  12. package/guides/music/SKILL.md +74 -183
  13. package/guides/music/references/COMPOSITION.md +21 -26
  14. package/guides/music/references/GAME-AUDIO.md +3 -3
  15. package/guides/music/references/RIGHTS.md +12 -15
  16. package/guides/music/scripts/music.mjs +11 -202
  17. package/guides/parts/SKILL.md +1 -1
  18. package/guides/shop/SKILL.md +68 -77
  19. package/guides/sound/SKILL.md +70 -1
  20. package/guides/sound/scripts/player/sound.js +233 -0
  21. package/guides/studio-setup/SKILL.md +11 -27
  22. package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
  23. package/guides/style/SKILL.md +66 -3
  24. package/guides/video/SKILL.md +83 -0
  25. package/guides/video/references/DELIVERY.md +73 -0
  26. package/guides/video/references/HONESTY.md +40 -0
  27. package/guides/video/references/METHOD.md +144 -0
  28. package/guides/video/references/RECORD.md +137 -0
  29. package/guides/video/references/TRAILER.md +154 -0
  30. package/guides/video/references/examples/site-walkthrough.json +14 -0
  31. package/guides/video/references/examples/studio-play-phone.json +15 -0
  32. package/guides/video/references/examples/studio-play.json +17 -0
  33. package/guides/video/scripts/capture-game.mjs +175 -0
  34. package/guides/video/scripts/clock.js +136 -0
  35. package/guides/video/scripts/drawover/film.js +58 -0
  36. package/guides/video/scripts/drawover/index.html +9 -0
  37. package/guides/video/scripts/drawover/look.js +115 -0
  38. package/guides/video/scripts/drawover/shots.json +12 -0
  39. package/guides/video/scripts/lib/browser.mjs +87 -0
  40. package/guides/video/scripts/lib/frames.mjs +170 -0
  41. package/guides/video/scripts/lib/remix.mjs +159 -0
  42. package/guides/video/scripts/qa.mjs +127 -0
  43. package/guides/video/scripts/record-fixed.mjs +403 -0
  44. package/guides/video/scripts/record-page.mjs +477 -0
  45. package/guides/video/scripts/tap.js +108 -0
  46. package/guides/video/scripts/video.mjs +888 -0
  47. package/lib/app-needs.mjs +86 -18
  48. package/lib/app-ready.mjs +21 -0
  49. package/lib/build.mjs +12 -9
  50. package/lib/check.mjs +16 -7
  51. package/lib/cloudflare.mjs +6 -11
  52. package/lib/codex.mjs +7 -5
  53. package/lib/dev.mjs +1 -2
  54. package/lib/doctor.mjs +10 -54
  55. package/lib/mcp-tools.mjs +42 -21
  56. package/lib/mcp.mjs +1 -1
  57. package/lib/media-import.mjs +41 -0
  58. package/lib/media-workflows.mjs +13 -0
  59. package/lib/needs-headless.mjs +34 -0
  60. package/lib/official-media.mjs +15 -0
  61. package/lib/rules-build.mjs +13 -5
  62. package/lib/scaffold.mjs +2 -2
  63. package/lib/shop-links.mjs +81 -19
  64. package/lib/shop-listen.mjs +43 -0
  65. package/lib/shop-local.mjs +1 -1
  66. package/lib/shop-mcp.mjs +58 -0
  67. package/lib/stripe-connect.mjs +36 -15
  68. package/lib/template-history.json +87 -0
  69. package/lib/upgrade-media.mjs +13 -0
  70. package/lib/upgrade.mjs +10 -0
  71. package/package.json +1 -1
  72. package/parts/PARTS.md +1 -1
  73. package/rules/view.ts +4 -0
  74. package/shop/SHOP.md +110 -34
  75. package/worker/app-records.mjs +1 -0
  76. package/worker/build-id.mjs +1 -1
  77. package/worker/event-resources.mjs +9 -3
  78. package/worker/index.mjs +5 -1
  79. package/worker/local-test.mjs +4 -0
  80. package/worker/mcp-events.mjs +2 -2
  81. package/worker/mcp-tools.mjs +19 -1
  82. package/worker/mcp.mjs +1 -1
  83. package/worker/purchase-reconciliation.mjs +16 -1
  84. package/worker/shop-links.mjs +1 -1
  85. package/worker/shop-rules.mjs +3 -0
  86. package/worker/shop-subscriptions.mjs +2 -0
  87. package/worker/shop.mjs +58 -8
  88. package/worker/stripe-ownership.mjs +7 -0
  89. package/worker/version.mjs +1 -1
  90. package/guides/models/references/models.json +0 -175
  91. package/guides/music/scripts/lib/eleven.mjs +0 -260
  92. package/lib/projects-env.mjs +0 -68
  93. package/lib/projects.mjs +0 -311
  94. package/lib/provider-key.mjs +0 -21
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Honest frames and the page's own sound, shared by capture-game.mjs (a game's big screen) and record-page.mjs
3
+ * (any page, driven by a script):
4
+ *
5
+ * startScreencast the compositor's own frame stream (CDP Page.startScreencast): every frame kept as a JPEG
6
+ * with the time Chrome presented it.
7
+ * constantRate those frames onto a constant frame rate: output frame k shows the newest frame presented by
8
+ * t0 + k/fps. A frame the page did not paint in time is HELD (and counted), never invented: no
9
+ * interpolation, no blending.
10
+ * TapReader tap.js's copy of what a frame sends to its speaker (WebAudio), stamped with the audio clock.
11
+ * fitAudio that sound laid onto the picture's clock (a straight-line fit of the audio clock to the page's).
12
+ * encodeMp4 H.264 (+ AAC when there was sound), limited-range BT.709 (converted and tagged), faststart.
13
+ * BT709_TAIL the end of a filter chain that makes a picture limited-range BT.709 yuv420p, and says so
14
+ * BT709_FLAGS the encoder flags that carry those tags into the file
15
+ * frameSegments an edit's segments as whole frames, each cut on the frame nearest its time
16
+ */
17
+ import { spawnSync } from 'node:child_process';
18
+ import { closeSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ /** Start keeping every frame the compositor presents; `stop()` ends it. `frames` fills as they arrive. */
22
+ export async function startScreencast(page, { dir, width, height, quality = 88 }) {
23
+ const frames = [];
24
+ const cdp = await page.createCDPSession();
25
+ cdp.on('Page.screencastFrame', (e) => {
26
+ const file = join(dir, `${String(frames.length).padStart(6, '0')}.jpg`);
27
+ writeFileSync(file, Buffer.from(e.data, 'base64'));
28
+ frames.push({ file, t: e.metadata.timestamp });
29
+ cdp.send('Page.screencastFrameAck', { sessionId: e.sessionId }).catch(() => {});
30
+ });
31
+ await cdp.send('Page.startScreencast', { format: 'jpeg', quality, maxWidth: width, maxHeight: height, everyNthFrame: 1 });
32
+ return { frames, stop: () => cdp.send('Page.stopScreencast').catch(() => {}) };
33
+ }
34
+
35
+ /**
36
+ * Frames (each `{ file, t }`, t in epoch seconds) onto a constant frame rate. `until` (epoch seconds) holds the last
37
+ * frame to that time: a page that did not change at the end still lasts as long as it was recorded.
38
+ */
39
+ export function constantRate(frames, fps, { until = null } = {}) {
40
+ const sorted = [...frames].sort((a, b) => a.t - b.t);
41
+ const t0 = sorted[0].t;
42
+ const end = Math.max(sorted.at(-1).t, until ?? 0);
43
+ const total = Math.floor((end - t0) * fps);
44
+ const list = [];
45
+ let j = 0; let held = 0; let last = -1;
46
+ for (let k = 0; k < total; k++) {
47
+ const t = t0 + k / fps;
48
+ while (j + 1 < sorted.length && sorted[j + 1].t <= t + 1e-4) j++;
49
+ if (j === last) held++;
50
+ last = j;
51
+ list.push(sorted[j].file);
52
+ }
53
+ return { t0, total, list, held, distinct: new Set(list).size, frames: sorted };
54
+ }
55
+
56
+ /** An ffconcat list: each output frame for exactly 1/fps. */
57
+ export function writeConcat(file, list, fps) {
58
+ writeFileSync(file, `ffconcat version 1.0\n${list.map((f) => `file '${f}'\nduration ${(1 / fps).toFixed(6)}`).join('\n')}\nfile '${list.at(-1)}'\n`);
59
+ return file;
60
+ }
61
+
62
+ /** Drains tap.js in one frame (window.__homieTap) into a raw 16-bit stereo file, with the audio clock's samples. */
63
+ export class TapReader {
64
+ constructor(pcmFile) {
65
+ this.pcmFile = pcmFile;
66
+ this.fd = openSync(pcmFile, 'w');
67
+ this.pulls = []; this.clock = []; this.frames = 0; this.rate = null;
68
+ }
69
+
70
+ async poll(frame) {
71
+ const got = await frame.evaluate(() => ({ runs: window.__homieTap ? window.__homieTap.take() : [], c: window.__homieTap ? window.__homieTap.clock() : null }));
72
+ if (got.c) { this.clock.push(got.c); this.rate = got.c.rate ?? this.rate; }
73
+ for (const r of got.runs) {
74
+ this.pulls.push({ ctxFrame: r.f, fileFrame: this.frames, frames: r.frames });
75
+ writeSync(this.fd, Buffer.from(r.b64, 'base64'));
76
+ this.frames += r.frames;
77
+ }
78
+ }
79
+
80
+ close() { try { closeSync(this.fd); } catch { /* closed */ } }
81
+ }
82
+
83
+ /**
84
+ * The tap's sound onto the picture's clock, written as raw s16le stereo at the context's rate (`out`). Fits
85
+ * epoch(ms) = a + b * contextTime from the output timestamps (or the context clock against the wall when there are
86
+ * too few). Returns null when there was no sound to fit.
87
+ */
88
+ export function fitAudio(tap, { t0, total, fps, out }) {
89
+ if (!(tap.frames > 0 && tap.rate)) return null;
90
+ const pts = tap.clock.filter((c) => c.ot && c.state === 'running').map((c) => c.ot);
91
+ const use = pts.length >= 8 ? pts : tap.clock.filter((c) => c.now != null).map((c) => [c.now, c.wall]);
92
+ const n = use.length; const mx = use.reduce((s, p) => s + p[0], 0) / n; const my = use.reduce((s, p) => s + p[1], 0) / n;
93
+ let sxy = 0; let sxx = 0; for (const [x, y] of use) { sxy += (x - mx) * (y - my); sxx += (x - mx) ** 2; }
94
+ // The audio clock runs at real time (a sound card drifts by parts per million). A straight line fitted over a short
95
+ // span of jittery timestamps can be percents off (measured: 1037.9 ms per second from 14 samples over 3.5 s), which
96
+ // walks the sound away from the picture; then only the offset is fitted (the median, which ignores outliers).
97
+ const span = use.length ? Math.max(...use.map((p) => p[0])) - Math.min(...use.map((p) => p[0])) : 0;
98
+ const line = sxx ? sxy / sxx : null;
99
+ const trust = line !== null && span >= 20 && Math.abs(line - 1000) < 2;
100
+ const b = trust ? line : 1000;
101
+ const offsets = use.map(([x, y]) => y - b * x).sort((p, q) => p - q);
102
+ const a = trust ? my - b * mx : offsets[Math.floor(offsets.length / 2)];
103
+ const raw = readFileSync(tap.pcmFile);
104
+ const src = new Int16Array(raw.buffer, raw.byteOffset, Math.floor(raw.byteLength / 2));
105
+ const outFrames = Math.round((total / fps) * tap.rate);
106
+ const dst = new Int16Array(outFrames * 2);
107
+ let filled = 0;
108
+ const pulls = [...tap.pulls].sort((x, y) => x.ctxFrame - y.ctxFrame);
109
+ let q = -1;
110
+ for (let i = 0; i < outFrames; i++) {
111
+ const epochMs = (t0 + i / tap.rate) * 1000;
112
+ const ctxT = (epochMs - a) / b;
113
+ const F = Math.round(ctxT * tap.rate);
114
+ while (q + 1 < pulls.length && pulls[q + 1].ctxFrame <= F) q++;
115
+ const p = q >= 0 ? pulls[q] : null;
116
+ if (!p || F - p.ctxFrame >= p.frames) continue;
117
+ const at = p.fileFrame + (F - p.ctxFrame);
118
+ dst[i * 2] = src[at * 2]; dst[i * 2 + 1] = src[at * 2 + 1]; filled++;
119
+ }
120
+ let peak = 0; for (let i = 0; i < dst.length; i++) peak = Math.max(peak, Math.abs(dst[i]));
121
+ writeFileSync(out, Buffer.from(dst.buffer));
122
+ const audio = { rate: tap.rate, seconds: +(outFrames / tap.rate).toFixed(3), covered: +(filled / Math.max(1, outFrames)).toFixed(3), peakDb: peak ? +(20 * Math.log10(peak / 32767)).toFixed(1) : null, fitMsPerS: +b.toFixed(3), fit: trust ? 'line' : 'offset', samples: use.length, raw: out };
123
+ if (!peak) audio.note = 'the page made no sound while it was recorded';
124
+ return audio;
125
+ }
126
+
127
+ /**
128
+ * The last filters of every picture this skill encodes: limited-range ("tv") BT.709 in yuv420p, with the frames
129
+ * tagged so. A frame that comes from a JPEG (every captured frame does) is FULL range with BT.601 coefficients.
130
+ * `format=yuv420p` alone changes neither: the encoder then writes a full-range file that ffprobe calls yuvj420p,
131
+ * with no primaries and no transfer tag, and a phone that assumes limited range shows it washed out or crushed.
132
+ * `-colorspace bt709` on the encoder only relabels the matrix without converting it. So: scale converts the
133
+ * range and the matrix from what the frame says it is, format picks the layout, setparams writes the tags.
134
+ */
135
+ export const BT709_TAIL = 'scale=out_range=tv:out_color_matrix=bt709,format=yuv420p,setparams=range=tv:color_primaries=bt709:color_trc=bt709:colorspace=bt709';
136
+ export const BT709_FLAGS = ['-pix_fmt', 'yuv420p', '-color_range', 'tv', '-color_primaries', 'bt709', '-color_trc', 'bt709', '-colorspace', 'bt709'];
137
+
138
+ /**
139
+ * An edit's segments as whole frames. Each cut is put on the frame nearest its time in the music, and a segment
140
+ * is the frames between two cuts: 1.875 s at 30 fps is 56.25 frames, so the segments run 56, 56, 57, 56 … and
141
+ * no cut is ever more than half a frame from its beat.
142
+ *
143
+ * Trimming each segment by its duration instead (what this did before) rounds every segment by itself: nine
144
+ * 1.875 s segments came out a quarter of a frame short each, and the cuts walked away from a bed that plays at
145
+ * its own speed, up to five frames by the end of a trailer.
146
+ */
147
+ export function frameSegments(segments, fps) {
148
+ let t = 0; let done = 0;
149
+ return segments.map((s) => {
150
+ t += s.dur;
151
+ const end = Math.round(t * fps + 1e-6);
152
+ const frames = Math.max(1, end - done);
153
+ const out = { startFrame: done, frames, at: t - s.dur };
154
+ done += frames;
155
+ return out;
156
+ });
157
+ }
158
+
159
+ /** The film: the concat list's frames at `fps`, scaled to w x h, with the fitted sound when it had any. */
160
+ export function encodeMp4({ concat, audio, fps, width, height, seconds, out, crf = 16 }) {
161
+ const sound = audio?.peakDb != null;
162
+ const args = ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', concat];
163
+ if (sound) args.push('-f', 's16le', '-ar', String(audio.rate), '-ac', '2', '-i', audio.raw);
164
+ args.push('-map', '0:v', ...(sound ? ['-map', '1:a', '-c:a', 'aac', '-b:a', '256k', '-ar', '48000'] : []),
165
+ '-vf', `fps=${fps},scale=${width}:${height}:flags=lanczos,${BT709_TAIL}`, '-c:v', 'libx264', '-preset', 'medium', '-crf', String(crf), '-r', String(fps),
166
+ ...BT709_FLAGS, '-movflags', '+faststart', '-t', seconds.toFixed(3), out);
167
+ const r = spawnSync('ffmpeg', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
168
+ if (audio?.raw) rmSync(audio.raw, { force: true });
169
+ return { ok: r.status === 0, why: r.status === 0 ? null : (r.stderr ?? '').trim().split('\n').pop() };
170
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The sound of a film that was rendered frame by frame, rebuilt offline.
3
+ *
4
+ * A page stepped on a virtual clock (record-fixed.mjs) has no speaker worth recording: its audio clock
5
+ * runs at the wall's speed while the picture runs at the film's. What it has is the capture log the sound
6
+ * player keeps when a recorder asks (sound.js: window.__homieSoundCapture): every sound it scheduled, with
7
+ * the file, the gain, the rate, the pan, the loop and the time. This mixes those events again, from the
8
+ * game's own files, onto the film's clock:
9
+ *
10
+ * start the file decoded (ffmpeg), resampled by its playback rate, panned, faded in, looped
11
+ * stop a hard cut, a straight fade, or the exponential fall of setTargetAtTime (time constant fade / 3)
12
+ * duck the music bus down to `to` (time constant 15 ms) and back after `seconds` (120 ms)
13
+ * levels the master, effect and music faders (and mute), smoothed as the player smooths them
14
+ *
15
+ * Out: three stereo buffers at 48 kHz: `sfx`, `music` (after the duck and its fader) and `mix`, so an
16
+ * edit can keep the music running under shots whose own effects are cut with the picture.
17
+ *
18
+ * What it is NOT: a recording. The player's safety compressor (threshold -6 dB, ratio 8) is stood in for
19
+ * by a peak limiter at -1 dBFS that only acts when the sum goes over; anything the game plays outside
20
+ * the log (its own oscillators, an <audio> element, speech synthesis) is not in the mix at all.
21
+ */
22
+ import { run } from '../../../music/scripts/lib/audio.mjs';
23
+ import { dbToGain, limit } from '../../../sound/scripts/lib/synth.mjs';
24
+
25
+ export const MIX_RATE = 48000;
26
+
27
+ /** A sound file as float stereo at 48 kHz, with how many channels the file really has (a mono file pans differently). */
28
+ export function decodeFile(file) {
29
+ const p = run('ffprobe', ['-v', 'error', '-select_streams', 'a:0', '-show_entries', 'stream=channels', '-of', 'csv=p=0', file]);
30
+ const channels = Number(String(p.stdout).trim()) || 2;
31
+ const out = run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', file, '-vn', '-ac', '2', '-ar', String(MIX_RATE), '-f', 'f32le', '-']);
32
+ if (out.code !== 0) throw new Error(`ffmpeg could not decode ${file}: ${out.stderr.trim().split('\n').pop()}`);
33
+ const b = out.stdout;
34
+ const all = new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength - (b.byteLength % 8)));
35
+ const n = all.length / 2;
36
+ const L = new Float32Array(n); const R = new Float32Array(n);
37
+ for (let i = 0; i < n; i++) { L[i] = all[2 * i]; R[i] = all[2 * i + 1]; }
38
+ return { L, R, channels };
39
+ }
40
+
41
+ /** A fader the player moves with setTargetAtTime: a list of { at (s), to, tc (s) } read as one smoothed curve. */
42
+ function fader(moves, n, first = 1) {
43
+ const out = new Float32Array(n);
44
+ const list = [...moves].sort((a, b) => a.at - b.at);
45
+ let v = first; let target = first; let k = 0; let coef = 0;
46
+ for (let i = 0; i < n; i++) {
47
+ const t = i / MIX_RATE;
48
+ while (k < list.length && list[k].at <= t) { target = list[k].to; coef = 1 - Math.exp(-1 / (Math.max(1e-4, list[k].tc) * MIX_RATE)); k++; }
49
+ v += (target - v) * coef;
50
+ out[i] = v;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * Mix a capture log. `events` have `t` in SECONDS on the film's clock (the recorder converts them);
57
+ * `fileFor(url)` gives the local copy of a file, or null. Returns { sfx, music, mix, report }.
58
+ */
59
+ export function remix(events, { seconds, fileFor, decode = decodeFile }) {
60
+ const n = Math.max(1, Math.round(seconds * MIX_RATE));
61
+ const sfx = { L: new Float32Array(n), R: new Float32Array(n) };
62
+ const music = { L: new Float32Array(n), R: new Float32Array(n) };
63
+ const cache = new Map();
64
+ const missing = new Set();
65
+ const stops = new Map();
66
+ for (const e of events) if (e.type === 'stop' && e.id != null && !stops.has(e.id)) stops.set(e.id, e);
67
+ let placed = 0; let dropped = 0;
68
+ for (const e of events) {
69
+ if (e.type === 'drop') { dropped++; continue; }
70
+ if (e.type !== 'start') continue;
71
+ const file = fileFor(e.url);
72
+ if (!file) { missing.add(e.url); continue; }
73
+ if (!cache.has(file)) { try { cache.set(file, decode(file)); } catch { cache.set(file, null); } }
74
+ const src = cache.get(file);
75
+ if (!src || !src.L.length) { missing.add(e.url); continue; }
76
+ const bus = e.bus === 'music' ? music : sfx;
77
+ const from = e.t + (e.delay ?? 0);
78
+ const rate = Number(e.rate) > 0 ? Number(e.rate) : 1;
79
+ const gain = Number(e.gain ?? 1);
80
+ // The player's panner: a mono file is placed between the speakers at equal power; a stereo one has the far
81
+ // channel folded into the near one. With no pan there is no panner, and a mono file plays in both at full level.
82
+ const pan = Math.max(-1, Math.min(1, Number(e.pan) || 0));
83
+ let ll = 1; let lr = 0; let rl = 0; let rr = 1;
84
+ if (pan !== 0 && src.channels === 1) { const x = ((pan + 1) / 2) * (Math.PI / 2); ll = Math.cos(x); rr = Math.sin(x); lr = 0; rl = 0; }
85
+ else if (pan < 0) { const x = (pan + 1) * (Math.PI / 2); ll = 1; lr = Math.cos(x); rl = 0; rr = Math.sin(x); }
86
+ else if (pan > 0) { const x = pan * (Math.PI / 2); ll = Math.cos(x); lr = 0; rl = Math.sin(x); rr = 1; }
87
+ const stop = stops.get(e.id) ?? null;
88
+ const stopAt = stop ? stop.t + (stop.delay ?? 0) : Infinity;
89
+ const fade = stop ? Math.max(0, Number(stop.fade) || 0) : 0;
90
+ const endAt = !stop ? Infinity : stop.curve === 'target' ? stopAt + fade + 0.1 : stop.curve === 'linear' ? stopAt + fade : stopAt;
91
+ const fadeIn = Math.max(0, Number(e.fadeIn) || 0);
92
+ const len = src.L.length;
93
+ const i0 = Math.max(0, Math.round(from * MIX_RATE));
94
+ for (let i = i0; i < n; i++) {
95
+ const t = i / MIX_RATE;
96
+ if (t >= endAt) break;
97
+ let pos = (i - from * MIX_RATE) * rate;
98
+ if (pos < 0) continue;
99
+ if (e.loop) pos %= len; else if (pos >= len - 1) break;
100
+ const a = Math.floor(pos); const f = pos - a; const b = e.loop ? (a + 1) % len : a + 1;
101
+ let g = gain;
102
+ if (fadeIn > 0 && t - from < fadeIn) g *= (t - from) / fadeIn;
103
+ if (t >= stopAt) {
104
+ if (stop.curve === 'linear') g *= Math.max(0, 1 - (t - stopAt) / Math.max(1e-4, fade));
105
+ else if (stop.curve === 'target') g *= Math.exp(-(t - stopAt) / Math.max(1e-4, fade / 3));
106
+ }
107
+ const l = (src.L[a] + (src.L[b] - src.L[a]) * f) * g;
108
+ const r = (src.R[a] + (src.R[b] - src.R[a]) * f) * g;
109
+ if (src.channels === 1 && pan !== 0) { bus.L[i] += l * ll; bus.R[i] += l * rr; } else { bus.L[i] += l * ll + r * lr; bus.R[i] += l * rl + r * rr; }
110
+ }
111
+ placed++;
112
+ }
113
+ // The faders, as the player moves them. Before the first `levels` event the player's own defaults hold.
114
+ const lv = events.filter((e) => e.type === 'levels');
115
+ const first = lv[0] ?? { master: 1, sfx: 1, music: 0.7, muted: false };
116
+ const master = fader(lv.map((e) => ({ at: e.t, to: e.muted ? 0 : e.master, tc: 0.02 })), n, first.muted ? 0 : first.master);
117
+ const sfxG = fader(lv.map((e) => ({ at: e.t, to: e.sfx, tc: 0.02 })), n, first.sfx);
118
+ const musG = fader(lv.map((e) => ({ at: e.t, to: e.music, tc: 0.05 })), n, first.music);
119
+ const duck = fader(events.filter((e) => e.type === 'duck').flatMap((e) => [{ at: e.t, to: e.to, tc: 0.015 }, { at: e.t + e.seconds, to: 1, tc: 0.12 }]), n, 1);
120
+ const mix = { L: new Float32Array(n), R: new Float32Array(n) };
121
+ for (let i = 0; i < n; i++) {
122
+ const s = sfxG[i] * master[i]; const m = musG[i] * duck[i] * master[i];
123
+ sfx.L[i] *= s; sfx.R[i] *= s; music.L[i] *= m; music.R[i] *= m;
124
+ mix.L[i] = sfx.L[i] + music.L[i]; mix.R[i] = sfx.R[i] + music.R[i];
125
+ }
126
+ let peak = 0; for (let i = 0; i < n; i++) { const p = Math.max(Math.abs(mix.L[i]), Math.abs(mix.R[i])); if (p > peak) peak = p; }
127
+ const ceiling = dbToGain(-1);
128
+ const limited = peak > ceiling;
129
+ if (limited) limit(mix, { ceiling, releaseMs: 150 });
130
+ return {
131
+ sfx, music, mix,
132
+ report: {
133
+ seconds: n / MIX_RATE, placed, dropped, files: cache.size, missing: [...missing],
134
+ peakDb: peak > 0 ? +(20 * Math.log10(peak)).toFixed(1) : null, limited,
135
+ note: 'Rebuilt from the game\'s own sound files and its log of what it scheduled; the player\'s compressor is stood in for by a peak limiter at -1 dBFS. Sound the game makes outside the log is not here.',
136
+ },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Where the highlights are, by what the game played: each effect's start counts, a rare sound more than a
142
+ * common one (one win outweighs fifty footsteps), a louder one more than a quiet one. Returns a score for
143
+ * every tenth of a second (the same grid as the picture's motion), and the events by tenth.
144
+ */
145
+ export function eventScores(events, seconds) {
146
+ const n = Math.max(1, Math.ceil(seconds * 10));
147
+ const scores = new Float32Array(n);
148
+ const names = Array.from({ length: n }, () => []);
149
+ const starts = events.filter((e) => e.type === 'start' && e.bus !== 'music');
150
+ const count = new Map();
151
+ for (const e of starts) count.set(e.name, (count.get(e.name) ?? 0) + 1);
152
+ for (const e of starts) {
153
+ const k = Math.floor((e.t + (e.delay ?? 0)) * 10);
154
+ if (k < 0 || k >= n) continue;
155
+ scores[k] += Math.min(1.5, Math.max(0.2, Number(e.gain ?? 1))) / Math.sqrt(count.get(e.name));
156
+ names[k].push(e.name);
157
+ }
158
+ return { scores, names };
159
+ }
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * qa.mjs: check a finished video file before anyone sees it, from the decoded file itself (an
4
+ * encoder that exits 0 has not proved the picture is there).
5
+ *
6
+ * node qa.mjs <video.mp4> [--kind trailer|clip|music-video] [--max-mb 25] [--json]
7
+ *
8
+ * It decodes every frame (errors are counted), reads the streams (codec, size, frame rate, pixel
9
+ * format, BT.709 colour tags), checks the file starts playing before it has downloaded (faststart:
10
+ * the moov atom before the media data), finds black stretches and frozen stretches, measures the
11
+ * loudness and true peak, finds silent stretches, and checks the size against the studio site's
12
+ * 25 MiB file limit. PASS, WARN or FAIL, each with the fix.
13
+ */
14
+ import { spawnSync } from 'node:child_process';
15
+ import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
16
+ import { resolve } from 'node:path';
17
+
18
+ const argv = process.argv.slice(2);
19
+ const flags = new Map(); const pos = [];
20
+ for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith('--')) { const v = argv[i + 1]; if (v === undefined || v.startsWith('--')) flags.set(a.slice(2), true); else { flags.set(a.slice(2), v); i++; } } else pos.push(a); }
21
+
22
+ const run = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
23
+
24
+ /** Top-level MP4 atoms in order (enough to see whether moov comes before mdat). */
25
+ function atoms(file) {
26
+ const fd = openSync(file, 'r');
27
+ const size = statSync(file).size;
28
+ const out = [];
29
+ try {
30
+ let at = 0;
31
+ const head = Buffer.alloc(16);
32
+ while (at + 8 <= size && out.length < 64) {
33
+ readSync(fd, head, 0, 16, at);
34
+ let len = head.readUInt32BE(0);
35
+ const type = head.toString('latin1', 4, 8);
36
+ if (len === 1) len = Number(head.readBigUInt64BE(8));
37
+ else if (len === 0) len = size - at;
38
+ if (len < 8) break;
39
+ out.push(type);
40
+ at += len;
41
+ }
42
+ } finally { closeSync(fd); }
43
+ return out;
44
+ }
45
+
46
+ export function qa(file, { kind = 'trailer', maxMb = 25 } = {}) {
47
+ if (!existsSync(file)) throw new Error(`no such file: ${file}`);
48
+ const fails = []; const warns = [];
49
+ const pr = run('ffprobe', ['-v', 'error', '-show_entries', 'format=duration,size,format_name:stream=codec_type,codec_name,width,height,r_frame_rate,avg_frame_rate,pix_fmt,color_primaries,color_transfer,color_space,sample_rate,channels', '-of', 'json', file]);
50
+ if (pr.status !== 0) throw new Error(`ffprobe could not read it: ${pr.stderr.trim().split('\n').pop()}`);
51
+ const j = JSON.parse(pr.stdout);
52
+ const v = (j.streams ?? []).find((s) => s.codec_type === 'video');
53
+ const a = (j.streams ?? []).find((s) => s.codec_type === 'audio');
54
+ const duration = Number(j.format?.duration ?? 0);
55
+ const mb = +(Number(j.format?.size ?? statSync(file).size) / 1024 / 1024).toFixed(2);
56
+ if (!v) fails.push('no video stream');
57
+ const [fn, fd] = String(v?.avg_frame_rate ?? v?.r_frame_rate ?? '0/1').split('/').map(Number);
58
+ const fps = fd ? +(fn / fd).toFixed(3) : null;
59
+ if (v && v.codec_name !== 'h264') warns.push(`video codec ${v.codec_name}: H.264 plays on every phone and in every browser`);
60
+ if (v && v.pix_fmt !== 'yuv420p') fails.push(`pixel format ${v.pix_fmt}: Safari and many phones need yuv420p${/^yuvj/.test(String(v.pix_fmt)) ? ' in limited range; this file is FULL range, which -pix_fmt alone does not change: convert it (scale=out_range=tv:out_color_matrix=bt709,format=yuv420p, with -color_range tv), as `cut` does' : ' (-pix_fmt yuv420p)'}`);
61
+ if (v && (v.width % 2 || v.height % 2)) fails.push(`odd frame size ${v.width}x${v.height}: H.264 needs even sizes`);
62
+ if (v && (v.color_primaries !== 'bt709' || v.color_transfer !== 'bt709' || v.color_space !== 'bt709')) warns.push(`colour tags ${v.color_primaries ?? 'unset'}/${v.color_transfer ?? 'unset'}/${v.color_space ?? 'unset'}: tag BT.709 (-colorspace bt709 -color_primaries bt709 -color_trc bt709) or players guess and the colours shift`);
63
+ const ratio = v ? v.width / v.height : null;
64
+ const shape = ratio === null ? null : Math.abs(ratio - 16 / 9) < 0.02 ? '16:9' : Math.abs(ratio - 9 / 16) < 0.02 ? '9:16' : Math.abs(ratio - 1) < 0.02 ? '1:1' : `${v.width}x${v.height}`;
65
+ if (shape && !['16:9', '9:16', '1:1'].includes(shape)) warns.push(`frame ${shape}: deliver 16:9 (1920x1080) and, for vertical feeds, 9:16 (1080x1920)`);
66
+ if (fps !== null && (fps < 23.9 || fps > 60.1)) warns.push(`${fps} fps: deliver 24, 25, 30 or 60`);
67
+ const at = atoms(file);
68
+ const moov = at.indexOf('moov'); const mdat = at.indexOf('mdat');
69
+ const faststart = moov >= 0 && (mdat < 0 || moov < mdat);
70
+ if (!faststart) fails.push('not faststart (the index is at the end): a phone downloads the whole file before playing; re-mux with -movflags +faststart');
71
+ if (mb > maxMb) fails.push(`${mb} MB is over the ${maxMb} MB a studio site serves itself: a lower bitrate, or the studio's storage`);
72
+ // A full decode: every frame, every error counted.
73
+ const dec = run('ffmpeg', ['-hide_banner', '-v', 'error', '-i', file, '-f', 'null', '-']);
74
+ const decodeErrors = String(dec.stderr).split('\n').filter(Boolean);
75
+ if (decodeErrors.length) fails.push(`${decodeErrors.length} decode error(s), first: ${decodeErrors[0].slice(0, 160)}`);
76
+ // Black and frozen stretches, silence, loudness: one pass.
77
+ const filters = ['-vf', 'blackdetect=d=0.4:pix_th=0.08,freezedetect=n=0.002:d=1.5'];
78
+ const af = a ? ['-af', 'ebur128=peak=true,silencedetect=n=-50dB:d=1.0'] : [];
79
+ const det = run('ffmpeg', ['-hide_banner', '-nostats', '-i', file, ...filters, ...af, '-f', 'null', '-']);
80
+ const text = String(det.stderr);
81
+ const blacks = [...text.matchAll(/black_start:([\d.]+) black_end:([\d.]+) black_duration:([\d.]+)/g)].map((m) => ({ from: +m[1], to: +m[2], seconds: +m[3] }));
82
+ const freezes = []; let fStart = null;
83
+ for (const line of text.split('\n')) {
84
+ const s = /freeze_start: ([\d.]+)/.exec(line); if (s) fStart = +s[1];
85
+ const e = /freeze_end: ([\d.]+)/.exec(line); if (e && fStart !== null) { freezes.push({ from: fStart, to: +e[1], seconds: +(+e[1] - fStart).toFixed(2) }); fStart = null; }
86
+ }
87
+ if (fStart !== null) freezes.push({ from: fStart, to: duration, seconds: +(duration - fStart).toFixed(2) });
88
+ const silences = [...text.matchAll(/silence_start: ([\d.]+)[\s\S]*?silence_end: ([\d.]+) \| silence_duration: ([\d.]+)/g)].map((m) => ({ from: +m[1], to: +m[2], seconds: +m[3] }));
89
+ const sum = text.slice(text.lastIndexOf('Summary:'));
90
+ const lufs = /I:\s+(-?[\d.]+) LUFS/.exec(sum); const tp = /Peak:\s+(-?[\d.]+) dBFS/.exec(sum);
91
+ const loud = { lufs: lufs ? Number(lufs[1]) : null, truePeakDb: tp ? Number(tp[1]) : null };
92
+ // A fade from or to black at the very ends is a choice; black in the middle is a hole.
93
+ const midBlack = blacks.filter((b) => b.from > 0.3 && b.to < duration - 0.3);
94
+ if (midBlack.length) warns.push(`${midBlack.length} black stretch(es) inside it, first at ${midBlack[0].from}s for ${midBlack[0].seconds}s: a capture that lost the picture, or a cut to nothing`);
95
+ if (blacks.some((b) => b.seconds > duration * 0.5)) fails.push('more than half of it is black');
96
+ const longFreeze = freezes.filter((f) => f.seconds >= (kind === 'music-video' ? 3 : 1.5));
97
+ if (longFreeze.length) warns.push(`${longFreeze.length} frozen stretch(es), first at ${longFreeze[0].from}s for ${longFreeze[0].seconds}s: held frames from a capture that could not keep up, or a still left too long (a card is fine: check it is one)`);
98
+ if (!a) warns.push('no sound: a trailer without sound loses most of its effect; a muted social clip needs captions instead');
99
+ else {
100
+ if (loud.lufs !== null && (loud.lufs < -18 || loud.lufs > -10)) warns.push(`loudness ${loud.lufs} LUFS: deliver about -14 LUFS for the web and social`);
101
+ if (loud.truePeakDb !== null && loud.truePeakDb > -1) warns.push(`true peak ${loud.truePeakDb} dBTP: keep it under -1 dBTP or it clips after the platform re-encodes it`);
102
+ const midSilence = silences.filter((s) => s.from > 0.5 && s.to < duration - 0.5);
103
+ if (midSilence.length) warns.push(`${midSilence.length} silent stretch(es), first at ${midSilence[0].from}s for ${midSilence[0].seconds}s`);
104
+ }
105
+ const verdict = fails.length ? 'FAIL' : warns.length ? 'WARN' : 'PASS';
106
+ return {
107
+ ok: !fails.length, command: 'qa', verdict, file, seconds: +duration.toFixed(3), mb, shape, fps,
108
+ video: v ? { codec: v.codec_name, size: `${v.width}x${v.height}`, pixFmt: v.pix_fmt, colour: `${v.color_primaries ?? '-'}/${v.color_transfer ?? '-'}/${v.color_space ?? '-'}` } : null,
109
+ audio: a ? { codec: a.codec_name, rate: Number(a.sample_rate), channels: a.channels, ...loud } : null,
110
+ faststart, decodeErrors: decodeErrors.length, blacks, freezes, silences: silences.slice(0, 10),
111
+ fails, warnings: warns,
112
+ look: 'QA is not a review: open the contact sheet (video.mjs sheet) and watch it once, start to end, before anyone else does',
113
+ };
114
+ }
115
+
116
+ if (process.argv[1]?.endsWith('qa.mjs')) {
117
+ try {
118
+ const file = resolve(String(pos[0] ?? ''));
119
+ const r = qa(file, { kind: String(flags.get('kind') ?? 'trailer'), maxMb: Number(flags.get('max-mb') ?? 25) });
120
+ if (flags.has('json')) process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
121
+ else process.stdout.write(`${r.verdict}: ${r.file} (${r.seconds}s, ${r.shape}, ${r.fps} fps, ${r.mb} MB${r.audio ? `, ${r.audio.lufs} LUFS, ${r.audio.truePeakDb} dBTP` : ''})\n${[...r.fails.map((f) => ` FAIL ${f}`), ...r.warnings.map((w) => ` WARN ${w}`)].join('\n')}\n`);
122
+ if (!r.ok) process.exitCode = 1;
123
+ } catch (error) {
124
+ process.stdout.write(`qa: ${error instanceof Error ? error.message : String(error)}\n`);
125
+ process.exitCode = 1;
126
+ }
127
+ }