creator-editing-studio 1.3.0 → 1.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "creator-editing-studio",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Scaffolds a Creator Editing OS studio — components, scripts and rules for editing video from a plain-English brief.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -167,10 +167,33 @@ and not in the stills, it did not render — find out why before shipping.
167
167
  `OffthreadVideo` placed in one starts from its own frame 0 and plays its own audio over the
168
168
  live line. Pass an explicit `startFrom`, and mute it unless its sound is wanted.
169
169
 
170
- **Repetition the owner can hear but you cannot.** If the owner says a line repeats, transcribe
171
- the rendered output over that exact window before arguing. If the audio is clean, the repeat
172
- is visual — a held caption, a reused graphic, or a duplicated framing — and that is still a
173
- real fault worth fixing, not a misunderstanding to explain away.
170
+ **Something they can hear and you cannot.** When they report a fault you cannot find, your check
171
+ is the thing most likely to be wrong, not them. Three rules, learned the hard way:
172
+
173
+ - **Transcribe the WHOLE file, not the window they named.** A seven-second sample came back clean
174
+ and was used to tell the owner he was imagining a repeat that was really there.
175
+ - **Check every render in `out/`, not the one you just made.** They are probably playing an older
176
+ file, because nothing on their machine says which is current. Ask which file, or test them all.
177
+ - **If they say it more than once, stop checking and start believing.** A repeated complaint is
178
+ evidence your method is wrong. Change the method.
179
+
180
+ If the audio really is clean, the repeat is visual — a held caption, a reused graphic, a duplicated
181
+ framing — and that is still a real fault, not a misunderstanding to explain away.
182
+
183
+ **A transcript is not evidence.** Whisper suppresses repeated phrases: when someone says a line
184
+ twice it transcribes it once and stretches the last recognised word across the speech it dropped,
185
+ so the transcript reads clean while the audio plainly repeats. Run `node scripts/coverage.mjs
186
+ <media> <words.json>` after every transcription and before planning anything — it flags any word
187
+ far longer than the median, and any stretch above the noise floor with no word on it. Both mean
188
+ the transcript is hiding speech. Check the energy across the flagged word: one decaying word is
189
+ fine, a dip to silence followed by a second loud onset is a repeat.
190
+
191
+ **Four generations of lossy audio.** The voice must be cut as PCM and encoded exactly once, at
192
+ delivery. Cutting an AAC file, trimming the result, and then letting the renderer re-encode it
193
+ puts the voice through four generations of loss, and raising the bitrate on the later passes
194
+ preserves the damage rather than repairing it. It is clearly audible and it gets worse with
195
+ every extra render. Use `node scripts/voice.mjs` to pull the voice losslessly from the camera
196
+ file, render the picture muted, and mux the two together at the end.
174
197
 
175
198
  ## Non-negotiables
176
199
 
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Find speech the transcriber did not transcribe.
4
+ *
5
+ * node scripts/coverage.mjs <media> <words.json> [--gap 0.35] [--floor -38]
6
+ *
7
+ * WHY THIS EXISTS. Whisper suppresses repeated phrases. When a speaker says the same line twice,
8
+ * it transcribes it once and silently leaves a hole where the duplicate was — so a transcript can
9
+ * read perfectly clean while the audio plainly repeats. On demo-test that cost four rounds: the
10
+ * owner said three times that "adds a few zooms" repeated, the transcript said it did not, and the
11
+ * hole got mistaken for a pause and trimmed.
12
+ *
13
+ * A transcript is not evidence on its own. This checks it against the audio: any stretch that is
14
+ * loud enough to be speech but carries no word is either a dropped duplicate or a dropped line,
15
+ * and both are faults. Run it after every transcription, before planning anything.
16
+ */
17
+ import {spawnSync} from 'node:child_process';
18
+ import {readFileSync} from 'node:fs';
19
+ import {FFMPEG} from './lib/media.mjs';
20
+
21
+ const [, , media, wordsFile, ...rest] = process.argv;
22
+ if (!media || !wordsFile) {
23
+ console.error('Usage: node scripts/coverage.mjs <media> <words.json> [--gap 0.35] [--floor -38]');
24
+ process.exit(2);
25
+ }
26
+ const opt = (n, d) => {
27
+ const i = rest.indexOf(`--${n}`);
28
+ return i >= 0 ? Number(rest[i + 1]) : d;
29
+ };
30
+ /** Shortest unlabelled speech run worth reporting, in seconds. */
31
+ const MIN_GAP = opt('gap', 0.35);
32
+ /** Above this RMS it is speech, below it is room tone. */
33
+ const FLOOR_DB = opt('floor', -38);
34
+
35
+ const SR = 16000;
36
+ const WIN = Math.round(SR * 0.05);
37
+
38
+ const pcm = spawnSync(FFMPEG, ['-v', 'error', '-i', media, '-vn', '-ac', '1', '-ar', String(SR), '-f', 's16le', '-'], {
39
+ encoding: 'buffer',
40
+ maxBuffer: 1 << 28,
41
+ });
42
+ if (pcm.status !== 0) {
43
+ console.error(pcm.stderr?.toString().slice(0, 600));
44
+ process.exit(1);
45
+ }
46
+ const buf = pcm.stdout;
47
+ const samples = buf.length / 2;
48
+
49
+ const loud = (t) => {
50
+ const a = Math.round(t * SR);
51
+ let s = 0;
52
+ let c = 0;
53
+ for (let i = a; i < a + WIN && i < samples; i++) {
54
+ const v = buf.readInt16LE(i * 2);
55
+ s += v * v;
56
+ c++;
57
+ }
58
+ const r = Math.sqrt(s / Math.max(1, c));
59
+ return r > 0 ? 20 * Math.log10(r / 32768) : -99;
60
+ };
61
+
62
+ const data = JSON.parse(readFileSync(wordsFile, 'utf8'));
63
+ const words = data.words ?? data;
64
+ const fps = data.fps ?? 30;
65
+ /** Words may carry frames (f/t) or seconds (start/end). */
66
+ const spans = words.map((w) => ({
67
+ from: w.start ?? w.f / fps,
68
+ to: w.end ?? (w.t ?? w.f) / fps,
69
+ }));
70
+
71
+ const covered = (t) => spans.some((s) => t >= s.from - 0.06 && t <= s.to + 0.06);
72
+
73
+ const duration = samples / SR;
74
+ const holes = [];
75
+ let run = null;
76
+ for (let t = 0; t < duration; t += 0.05) {
77
+ const speech = loud(t) > FLOOR_DB;
78
+ if (speech && !covered(t)) {
79
+ if (!run) run = {from: t, to: t};
80
+ else run.to = t;
81
+ } else if (run) {
82
+ if (run.to - run.from >= MIN_GAP) holes.push(run);
83
+ run = null;
84
+ }
85
+ }
86
+ if (run && run.to - run.from >= MIN_GAP) holes.push(run);
87
+
88
+ /**
89
+ * The detector that actually works.
90
+ *
91
+ * A collapsed duplicate does not leave an untranscribed hole — Whisper stretches the LAST word it
92
+ * did recognise across the speech it dropped. On demo-test the word "adds" came back 0.68s long
93
+ * with the lowest confidence in the sentence, covering a whole repeated phrase, so a coverage test
94
+ * based on spans called the file clean. Word duration is what gives it away.
95
+ */
96
+ const lengths = words.map((w, i) => ({w, i, dur: spans[i].to - spans[i].from})).filter((x) => x.dur > 0);
97
+ const sortedDur = lengths.map((x) => x.dur).sort((a, b) => a - b);
98
+ const median = sortedDur[Math.floor(sortedDur.length / 2)] || 0.2;
99
+ const stretched = lengths.filter((x) => x.dur > median * 3);
100
+ if (stretched.length) {
101
+ console.log(
102
+ `${stretched.length} word(s) far longer than the ${median.toFixed(2)}s median — ` +
103
+ `each may be covering speech the transcriber dropped:\n`,
104
+ );
105
+ for (const x of stretched) {
106
+ const label = x.w.w ?? x.w.word;
107
+ console.log(
108
+ ` "${label}" ${spans[x.i].from.toFixed(2)}s - ${spans[x.i].to.toFixed(2)}s = ` +
109
+ `${x.dur.toFixed(2)}s (${(x.dur / median).toFixed(1)}x median)`,
110
+ );
111
+ }
112
+ console.log('\nCheck the energy across each one. A single decaying word is fine; a dip to');
113
+ console.log('silence followed by a second loud onset is a repeat the transcript is hiding.');
114
+ }
115
+
116
+ if (!holes.length && !stretched.length) {
117
+ console.log(`clean — every stretch above ${FLOOR_DB}dB has a word on it (${words.length} words, ${duration.toFixed(2)}s)`);
118
+ process.exit(0);
119
+ }
120
+
121
+ if (!holes.length) process.exit(1);
122
+
123
+ console.log(`${holes.length} stretch(es) of SPEECH WITH NO WORDS — the transcript is missing something:\n`);
124
+ for (const h of holes) {
125
+ const before = spans.filter((s) => s.to <= h.from).pop();
126
+ const after = spans.find((s) => s.from >= h.to);
127
+ const bw = before ? words[spans.indexOf(before)] : null;
128
+ const aw = after ? words[spans.indexOf(after)] : null;
129
+ console.log(
130
+ ` ${h.from.toFixed(2)}s - ${h.to.toFixed(2)}s (${(h.to - h.from).toFixed(2)}s)` +
131
+ ` between "${bw ? bw.w ?? bw.word : 'start'}" and "${aw ? aw.w ?? aw.word : 'end'}"`,
132
+ );
133
+ }
134
+ console.log('\nMost likely a repeated phrase the transcriber collapsed. Listen to it before cutting it.');
135
+ process.exit(1);
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pull EXACT frame numbers out of a rendered file.
4
+ *
5
+ * node scripts/exact-frames.mjs <file.mp4> <frame,frame,...> <outDir>
6
+ *
7
+ * Why this exists: `-ss` seeking is not frame accurate. Checking v4's cut boundaries with it
8
+ * returned frames 1-3 later than asked for, which made clean cuts look like defects and nearly
9
+ * sent a good edit back for rework. Decoding from the start and selecting by frame index is
10
+ * slower and is the only thing that can be trusted for a boundary check.
11
+ *
12
+ * The select expression is passed as one argv entry through spawnSync, never a shell — its
13
+ * commas are exactly what breaks a shell-quoted filter graph.
14
+ */
15
+ import {spawnSync} from 'node:child_process';
16
+ import {mkdirSync, rmSync} from 'node:fs';
17
+ import {join} from 'node:path';
18
+ import {FFMPEG} from './lib/media.mjs';
19
+
20
+ const [, , file, framesArg, outDir = 'work/exact'] = process.argv;
21
+ if (!file || !framesArg) {
22
+ console.error('Usage: node scripts/exact-frames.mjs <file> <frame,frame,...> [outDir]');
23
+ process.exit(2);
24
+ }
25
+
26
+ const frames = framesArg.split(',').map(Number).sort((a, b) => a - b);
27
+ rmSync(outDir, {recursive: true, force: true});
28
+ mkdirSync(outDir, {recursive: true});
29
+
30
+ // eq(n\,N) — the backslash has to survive into the filter string, so it is doubled here.
31
+ const expr = frames.map((f) => `eq(n\\,${f})`).join('+');
32
+
33
+ const r = spawnSync(
34
+ FFMPEG,
35
+ [
36
+ '-y', '-v', 'error',
37
+ '-i', file,
38
+ '-vf', `select='${expr}',scale=440:-1`,
39
+ '-fps_mode', 'passthrough',
40
+ '-frames:v', String(frames.length),
41
+ join(outDir, 'x%03d.png'),
42
+ ],
43
+ {encoding: 'utf8'},
44
+ );
45
+ if (r.status !== 0) {
46
+ console.error(r.stderr?.slice(0, 900));
47
+ process.exit(1);
48
+ }
49
+
50
+ console.log(frames.map((f, i) => `x${String(i + 1).padStart(3, '0')}.png = frame ${f}`).join('\n'));
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build the voice track ONCE, losslessly, straight from the camera file.
4
+ *
5
+ * node scripts/voice.mjs --source <clip.mp4> --takes <takes.json> [--drop 17.2333:17.6667] --out <voice.wav>
6
+ *
7
+ * WHY THIS EXISTS. The voice on demo-test went through FOUR lossy generations before it reached
8
+ * the viewer: the camera's own AAC 128k, then the cut, then a second trim, then the render — each
9
+ * one re-encoding what the last one had already damaged. Raising the bitrate on the later passes
10
+ * does not recover anything; it just preserves the damage at a bigger file size. It is audible,
11
+ * and it gets worse with every extra render.
12
+ *
13
+ * The rulebook already says it: render the picture muted and add the original voice back
14
+ * untouched. This is the tool that makes that easy. The voice is cut as PCM and stays PCM until
15
+ * the single encode at delivery, so the viewer hears one generation of loss instead of four.
16
+ *
17
+ * `atrim` takes colon-separated options, so the filter graph carries no commas inside a function
18
+ * call — and it is passed through spawnSync as one argv entry, never a shell.
19
+ */
20
+ import {spawnSync} from 'node:child_process';
21
+ import {readFileSync} from 'node:fs';
22
+ import {FFMPEG} from './lib/media.mjs';
23
+
24
+ const args = process.argv.slice(2);
25
+ const opt = (name) => {
26
+ const i = args.indexOf(`--${name}`);
27
+ return i >= 0 ? args[i + 1] : undefined;
28
+ };
29
+
30
+ const source = opt('source');
31
+ const takesFile = opt('takes');
32
+ const out = opt('out');
33
+ if (!source || !out) {
34
+ console.error('Usage: node scripts/voice.mjs --source <clip> [--takes <takes.json>] [--drop a:b] --out <voice.wav>');
35
+ process.exit(2);
36
+ }
37
+
38
+ /** The spans to keep, in source seconds. With no takes file, keep the whole clip. */
39
+ let keep = [{from: 0, to: null}];
40
+ if (takesFile) keep = JSON.parse(readFileSync(takesFile, 'utf8')).keep;
41
+
42
+ const parts = [];
43
+ keep.forEach((k, i) => {
44
+ const range = k.to == null ? `start=${k.from.toFixed(4)}` : `start=${k.from.toFixed(4)}:end=${k.to.toFixed(4)}`;
45
+ parts.push(`[0:a]atrim=${range},asetpts=PTS-STARTPTS[a${i}]`);
46
+ });
47
+ parts.push(`${keep.map((_, i) => `[a${i}]`).join('')}concat=n=${keep.length}:v=0:a=1[joined]`);
48
+
49
+ /**
50
+ * A second pass of removals, expressed in the CUT timeline rather than the source — that is where
51
+ * a tightened pause lives, because its position only exists after the first cut.
52
+ */
53
+ const drop = opt('drop');
54
+ let label = 'joined';
55
+ if (drop) {
56
+ const [a, b] = drop.split(':').map(Number);
57
+ parts.push(`[joined]asplit=2[d0][d1]`);
58
+ parts.push(`[d0]atrim=start=0:end=${a.toFixed(4)},asetpts=PTS-STARTPTS[p0]`);
59
+ parts.push(`[d1]atrim=start=${b.toFixed(4)},asetpts=PTS-STARTPTS[p1]`);
60
+ parts.push(`[p0][p1]concat=n=2:v=0:a=1[cut]`);
61
+ label = 'cut';
62
+ }
63
+
64
+ const r = spawnSync(
65
+ FFMPEG,
66
+ [
67
+ '-y', '-v', 'error',
68
+ '-i', source,
69
+ '-filter_complex', parts.join(';'),
70
+ '-map', `[${label}]`,
71
+ // PCM, and the source's own sample rate — no resampling, no encoding.
72
+ '-c:a', 'pcm_s16le',
73
+ out,
74
+ ],
75
+ {encoding: 'utf8'},
76
+ );
77
+ if (r.status !== 0) {
78
+ console.error(r.stderr?.slice(0, 900));
79
+ process.exit(1);
80
+ }
81
+
82
+ const probe = spawnSync(
83
+ FFMPEG.replace(/ffmpeg(\.exe)?$/i, (m) => (m.endsWith('.exe') ? 'ffprobe.exe' : 'ffprobe')),
84
+ ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', out],
85
+ {encoding: 'utf8'},
86
+ );
87
+ console.log(`voice written lossless -> ${out} (${Number(probe.stdout).toFixed(3)}s, pcm_s16le)`);
88
+ console.log('Render the picture muted, then mux this in with ONE encode at delivery.');