creator-editing-studio 1.3.1 → 1.3.3
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
|
@@ -134,6 +134,51 @@ The plan is there to save them a rebuild, not to put a form in front of them.
|
|
|
134
134
|
The failure this is guarding against is a whole edit rendered the wrong way. Anything that does
|
|
135
135
|
not risk that should move fast.
|
|
136
136
|
|
|
137
|
+
## What makes motion read as expensive
|
|
138
|
+
|
|
139
|
+
The twelve motion laws stop an edit looking broken. These seven are what separate motion that
|
|
140
|
+
looks competent from motion that looks paid for. They were taken from studying reference edits
|
|
141
|
+
frame by frame, and they apply to UI, graphics and footage alike.
|
|
142
|
+
|
|
143
|
+
**1. Blur anything that moves fast.** In the edits that feel most expensive, nothing travels
|
|
144
|
+
sharp. An element crossing the frame in six frames should be a smear that only resolves as it
|
|
145
|
+
slows — and that includes digits rolling from one number to another, and a line redrawing on a
|
|
146
|
+
chart. Sharp fast motion is the single clearest tell of cheap animation, because real cameras and
|
|
147
|
+
real eyes do not work that way. Use the velocity blur helpers rather than deciding per element.
|
|
148
|
+
|
|
149
|
+
**2. Morph, don't replace.** When something becomes something else, animate the thing itself
|
|
150
|
+
rather than fading one out and another in. A row that is clicked and confirmed should stretch and
|
|
151
|
+
turn into the confirmation; it should not be covered by a new element that happens to sit in the
|
|
152
|
+
same place. An element that exists in two moments travels between them. This is the law that most
|
|
153
|
+
reliably makes a sequence feel connected rather than assembled.
|
|
154
|
+
|
|
155
|
+
**3. Overshoot, then settle.** Things that arrive exactly at their final size and stop look
|
|
156
|
+
pasted on. Let an arrival come in slightly too large, or slightly past its mark, and ease back.
|
|
157
|
+
The overshoot is small — a few per cent — and it is the difference between landing and being
|
|
158
|
+
placed.
|
|
159
|
+
|
|
160
|
+
**4. Animate the data, not the container.** When a value, a chart or a list changes, hold the
|
|
161
|
+
frame still and change only what actually changed. Re-entering the whole panel to show a new
|
|
162
|
+
number throws away the viewer's orientation and makes the change read as a new scene instead of
|
|
163
|
+
an update.
|
|
164
|
+
|
|
165
|
+
**5. Reveal one thing at a time.** Rows, cards, icons and bullets arrive in reading order with a
|
|
166
|
+
few frames between them, never as a group. If a container also needs to grow, grow it slowly
|
|
167
|
+
underneath while the contents arrive — two speeds, not one.
|
|
168
|
+
|
|
169
|
+
**6. Earn the hard cut.** In a piece built from continuous motion, a single hard cut to a flat
|
|
170
|
+
colour lands hard precisely because it is the only one. Bursts of hard cuts are their own beat
|
|
171
|
+
and work too, but mixing the two at random wastes both. Decide which the piece is.
|
|
172
|
+
|
|
173
|
+
**7. Show what is coming.** Small anticipations make an interface feel alive: text typing into a
|
|
174
|
+
pill that grows with it, with the next word already faintly present; a cursor that starts moving
|
|
175
|
+
before the thing it will click appears. The viewer reads intent, not just motion.
|
|
176
|
+
|
|
177
|
+
A note on where these come from. Every one of them was found by studying an edit frame by frame
|
|
178
|
+
and writing down what it actually did — not by reading about animation. When something looks good
|
|
179
|
+
to you, save the clip and the timecode and do the same. That habit is worth more than any list,
|
|
180
|
+
including this one.
|
|
181
|
+
|
|
137
182
|
## Verify the render before you call it done
|
|
138
183
|
|
|
139
184
|
You have not seen the video. The timeline in your head is not evidence. Before you say an
|
|
@@ -180,6 +225,14 @@ is the thing most likely to be wrong, not them. Three rules, learned the hard wa
|
|
|
180
225
|
If the audio really is clean, the repeat is visual — a held caption, a reused graphic, a duplicated
|
|
181
226
|
framing — and that is still a real fault, not a misunderstanding to explain away.
|
|
182
227
|
|
|
228
|
+
**A transcript is not evidence.** Whisper suppresses repeated phrases: when someone says a line
|
|
229
|
+
twice it transcribes it once and stretches the last recognised word across the speech it dropped,
|
|
230
|
+
so the transcript reads clean while the audio plainly repeats. Run `node scripts/coverage.mjs
|
|
231
|
+
<media> <words.json>` after every transcription and before planning anything — it flags any word
|
|
232
|
+
far longer than the median, and any stretch above the noise floor with no word on it. Both mean
|
|
233
|
+
the transcript is hiding speech. Check the energy across the flagged word: one decaying word is
|
|
234
|
+
fine, a dip to silence followed by a second loud onset is a repeat.
|
|
235
|
+
|
|
183
236
|
**Four generations of lossy audio.** The voice must be cut as PCM and encoded exactly once, at
|
|
184
237
|
delivery. Cutting an AAC file, trimming the result, and then letting the renderer re-encode it
|
|
185
238
|
puts the voice through four generations of loss, and raising the bitrate on the later passes
|
|
@@ -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);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* npm run refs -- --all redo every sheet
|
|
8
8
|
*
|
|
9
9
|
* Reads entries shaped like:
|
|
10
|
-
* ## 4. `
|
|
10
|
+
* ## 4. `clip-name.mp4` — Creator name
|
|
11
11
|
* - **00:11–00:14** — what happens...
|
|
12
12
|
* Writes work/refs/<clip>-<mm.ss>-<mm.ss>.jpg and work/refs/INDEX.md (moment -> sheet), and lists
|
|
13
13
|
* clips that are in the folder but have no entry yet, or entries whose clip is missing.
|
|
@@ -205,7 +205,7 @@ export const CONNECT_SEGMENTS: Segment[] = [
|
|
|
205
205
|
{kind: 'plain', text: 'claude mcp add '},
|
|
206
206
|
{kind: 'flag', text: '--scope user --transport http '},
|
|
207
207
|
{kind: 'accent', text: 'creator-editing-os'},
|
|
208
|
-
{kind: 'plain', text: '
|
|
208
|
+
{kind: 'plain', text: ' <your connector URL>'},
|
|
209
209
|
{kind: 'redacted', text: 'XXXX-XXXX-XXXX-XXXX'},
|
|
210
210
|
];
|
|
211
211
|
|