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
package/template/CLAUDE.md
CHANGED
|
@@ -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
|
-
**
|
|
171
|
-
the
|
|
172
|
-
|
|
173
|
-
|
|
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.');
|