creator-editing-studio 1.2.0 → 1.2.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.
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Best-take selection: one messy take in, one clean cut plan out.
|
|
4
|
+
*
|
|
5
|
+
* npm run takes -- <words.json> [--media <video>] [--script <script.md>]
|
|
6
|
+
* [--project <name>] [--out <file>] [--fps 30]
|
|
7
|
+
* [--similar 0.72] [--window 90] [--gap-max 0.6] [--gap-keep 0.25]
|
|
8
|
+
*
|
|
9
|
+
* WHAT IT IS FOR. A talking head is full of second attempts. Somebody fumbles, stops, says the
|
|
10
|
+
* sentence again, and both versions end up in the cut — so a human watches the whole thing to
|
|
11
|
+
* find them. This finds them instead, keeps the better one, and proves afterwards that none are
|
|
12
|
+
* left.
|
|
13
|
+
*
|
|
14
|
+
* THE SPEECH IS THE TRUTH, NOT THE SCRIPT. An earlier version of this file anchored to the
|
|
15
|
+
* written script: every script sentence got one span and anything unmatched was dropped. It
|
|
16
|
+
* threw away six passages of real material on the first take it was pointed at — lines the
|
|
17
|
+
* speaker improvised on the day and meant to keep, including "you don't have to put the brief
|
|
18
|
+
* into the brief file". A repeat that survives is visible and fixable; a good line silently
|
|
19
|
+
* deleted is neither. So the default here is inverted:
|
|
20
|
+
*
|
|
21
|
+
* KEEP EVERYTHING, UNLESS IT IS A SECOND ATTEMPT AT SOMETHING ELSE THAT IS KEPT.
|
|
22
|
+
*
|
|
23
|
+
* Nothing has to justify its existence. A script, if there is one, is only a checker: it reports
|
|
24
|
+
* lines that never got said and breaks ties between close takes. It can never remove anything,
|
|
25
|
+
* and most people filming to camera do not have one.
|
|
26
|
+
*
|
|
27
|
+
* WHAT IT WRITES work/<project>/<stem>.takes.json
|
|
28
|
+
* keep[] {from, to, units} source seconds to use, in order
|
|
29
|
+
* groups[] every retake cluster, which attempt won and why the others lost
|
|
30
|
+
* dropped[] what went, with the reason
|
|
31
|
+
* checks{} verification, all of which must pass
|
|
32
|
+
*/
|
|
33
|
+
import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
|
|
34
|
+
import {basename, dirname, join} from 'node:path';
|
|
35
|
+
import {decodePcm} from './lib/media.mjs';
|
|
36
|
+
|
|
37
|
+
/* ------------------------------------------------------------------------ args */
|
|
38
|
+
|
|
39
|
+
const args = process.argv.slice(2);
|
|
40
|
+
const flag = (name, fallback) => {
|
|
41
|
+
const i = args.indexOf(`--${name}`);
|
|
42
|
+
return i === -1 ? fallback : args[i + 1];
|
|
43
|
+
};
|
|
44
|
+
const positional = args.filter((a, i) => !a.startsWith('--') && !String(args[i - 1] ?? '').startsWith('--'));
|
|
45
|
+
|
|
46
|
+
const wordsPath = positional[0];
|
|
47
|
+
if (!wordsPath) {
|
|
48
|
+
console.error('usage: npm run takes -- <words.json> [--media <video>] [--script <script.md>]');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const FPS = Number(flag('fps', 30));
|
|
53
|
+
/** How alike two passages must be before they count as attempts at the same thing. */
|
|
54
|
+
const SIMILAR = Number(flag('similar', 0.72));
|
|
55
|
+
/** Retakes happen close together. Two matching sentences minutes apart are both wanted. */
|
|
56
|
+
const WINDOW = Number(flag('window', 90));
|
|
57
|
+
/** A silence longer than this is dead air and gets collapsed. */
|
|
58
|
+
const GAP_MAX = Number(flag('gap-max', 0.6));
|
|
59
|
+
/** ...down to this. Not zero: speech with every gap removed sounds like a machine. */
|
|
60
|
+
const GAP_KEEP = Number(flag('gap-keep', 0.25));
|
|
61
|
+
/**
|
|
62
|
+
* Breathing room kept either side of a kept passage.
|
|
63
|
+
*
|
|
64
|
+
* 150ms, not the 60 an earlier version used. Below about 120 the cut lands close enough to the
|
|
65
|
+
* first phoneme to clip it, and the join reads as abrupt even when every word is intact.
|
|
66
|
+
*/
|
|
67
|
+
const PAD = 0.15;
|
|
68
|
+
|
|
69
|
+
/* ------------------------------------------------------------------- the words */
|
|
70
|
+
|
|
71
|
+
const key = (t) => t.toLowerCase().replace(/['‘’]/g, '').replace(/[^a-z0-9]/g, '');
|
|
72
|
+
|
|
73
|
+
const words = JSON.parse(readFileSync(wordsPath, 'utf8')).words.filter((w) => w.start != null && w.end != null);
|
|
74
|
+
if (words.length < 10) {
|
|
75
|
+
console.error('Not enough timed words to work with.');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Cues that announce a retake out loud.
|
|
81
|
+
*
|
|
82
|
+
* When someone says one of these, the passage BEFORE it was abandoned — they are telling you so.
|
|
83
|
+
* Cheaper and more certain than any similarity measure.
|
|
84
|
+
*/
|
|
85
|
+
const RESTART_CUES = [
|
|
86
|
+
'let me redo that', 'let me try that again', 'one more time', 'sorry let me',
|
|
87
|
+
'can i do that again', 'take two', 'scratch that', 'let me say that again',
|
|
88
|
+
'start again', 'i will say that again',
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/* --------------------------------------------------------------- units of speech
|
|
92
|
+
* A unit is one spoken thought: it ends where the speaker put a full stop, or where they left a
|
|
93
|
+
* gap long enough to be a boundary rather than a breath. Working in units rather than fixed word
|
|
94
|
+
* counts means a retake is compared against the whole thought it replaced. */
|
|
95
|
+
|
|
96
|
+
const units = [];
|
|
97
|
+
{
|
|
98
|
+
let start = 0;
|
|
99
|
+
for (let i = 0; i < words.length; i++) {
|
|
100
|
+
const w = words[i];
|
|
101
|
+
const next = words[i + 1];
|
|
102
|
+
const gapAfter = next ? next.start - w.end : Infinity;
|
|
103
|
+
const endsSentence = /[.?!]$/.test(w.word);
|
|
104
|
+
if (endsSentence || gapAfter > 0.7 || i === words.length - 1) {
|
|
105
|
+
const span = words.slice(start, i + 1);
|
|
106
|
+
if (span.length) {
|
|
107
|
+
units.push({
|
|
108
|
+
index: units.length,
|
|
109
|
+
startIndex: start,
|
|
110
|
+
endIndex: i,
|
|
111
|
+
from: span[0].start,
|
|
112
|
+
to: span[span.length - 1].end,
|
|
113
|
+
text: span.map((x) => x.word).join(' '),
|
|
114
|
+
keys: span.map((x) => key(x.word)).filter(Boolean),
|
|
115
|
+
confidence: span.reduce((a, x) => a + (x.score ?? 0.8), 0) / span.length,
|
|
116
|
+
complete: endsSentence,
|
|
117
|
+
worstGap: span.slice(1).reduce((m, x, k) => Math.max(m, x.start - span[k].end), 0),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
start = i + 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* -------------------------------------------------------------- energy and pace */
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Loudness per unit, so a flat read can lose to a committed one.
|
|
129
|
+
*
|
|
130
|
+
* Optional: without --media there is no audio to measure and the score simply does without it,
|
|
131
|
+
* rather than the tool refusing to run.
|
|
132
|
+
*/
|
|
133
|
+
let rmsOf = () => null;
|
|
134
|
+
const mediaPath = flag('media', null);
|
|
135
|
+
if (mediaPath && existsSync(mediaPath)) {
|
|
136
|
+
const RATE = 22050;
|
|
137
|
+
const pcm = decodePcm(mediaPath, RATE);
|
|
138
|
+
if (pcm) {
|
|
139
|
+
rmsOf = (from, to) => {
|
|
140
|
+
const a = Math.max(0, Math.floor(from * RATE));
|
|
141
|
+
const b = Math.min(pcm.length, Math.ceil(to * RATE));
|
|
142
|
+
if (b <= a) return null;
|
|
143
|
+
let sum = 0;
|
|
144
|
+
for (let i = a; i < b; i++) sum += pcm[i] * pcm[i];
|
|
145
|
+
return Math.sqrt(sum / (b - a));
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const u of units) {
|
|
151
|
+
u.seconds = u.to - u.from;
|
|
152
|
+
u.wordsPerSecond = u.keys.length / Math.max(0.3, u.seconds);
|
|
153
|
+
u.rms = rmsOf(u.from, u.to);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const median = (xs) => {
|
|
157
|
+
const s = xs.filter((x) => x != null).sort((a, b) => a - b);
|
|
158
|
+
return s.length ? s[Math.floor(s.length / 2)] : null;
|
|
159
|
+
};
|
|
160
|
+
const medianRms = median(units.map((u) => u.rms));
|
|
161
|
+
const medianPace = median(units.map((u) => u.wordsPerSecond)) ?? 3;
|
|
162
|
+
|
|
163
|
+
/* ------------------------------------------------------------------ similarity */
|
|
164
|
+
|
|
165
|
+
/** Longest common subsequence length — tolerant of an inserted stumble or a swapped word. */
|
|
166
|
+
const lcs = (a, b) => {
|
|
167
|
+
const prev = new Array(b.length + 1).fill(0);
|
|
168
|
+
const cur = new Array(b.length + 1).fill(0);
|
|
169
|
+
for (let i = 1; i <= a.length; i++) {
|
|
170
|
+
for (let j = 1; j <= b.length; j++) {
|
|
171
|
+
cur[j] = a[i - 1] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], cur[j - 1]);
|
|
172
|
+
}
|
|
173
|
+
for (let j = 0; j <= b.length; j++) prev[j] = cur[j];
|
|
174
|
+
}
|
|
175
|
+
return prev[b.length];
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* How alike two units are, measured against the SHORTER one.
|
|
180
|
+
*
|
|
181
|
+
* Against the shorter, because an abandoned start — "so the main reason, so the main reason is
|
|
182
|
+
* that…" — is a short fragment entirely contained in the full attempt. Measured against the
|
|
183
|
+
* longer it would score low and be kept; against the shorter it scores 1.0 and is correctly
|
|
184
|
+
* recognised as the same thought, begun twice.
|
|
185
|
+
*/
|
|
186
|
+
const similarity = (a, b) => {
|
|
187
|
+
if (!a.keys.length || !b.keys.length) return 0;
|
|
188
|
+
const shorter = Math.min(a.keys.length, b.keys.length);
|
|
189
|
+
// Short units match each other on function words alone — "give it three things" against
|
|
190
|
+
// "this is the time you give it that" shares "give it" and scores 0.5 on a four-word unit.
|
|
191
|
+
// Below five words there is not enough signal to call anything a retake.
|
|
192
|
+
if (shorter < 5) return 0;
|
|
193
|
+
const overlap = lcs(a.keys, b.keys);
|
|
194
|
+
// And the overlap has to be real words, not two articles and a pronoun.
|
|
195
|
+
if (overlap < 4) return 0;
|
|
196
|
+
return overlap / shorter;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/* ---------------------------------------------------------------------- groups */
|
|
200
|
+
|
|
201
|
+
const groupOf = new Array(units.length).fill(-1);
|
|
202
|
+
const groups = [];
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Grouping is against a representative, never transitive.
|
|
206
|
+
*
|
|
207
|
+
* Chaining members together — A matches B, B matches C, so group them all — merges passages that
|
|
208
|
+
* have nothing to do with each other as soon as one ambiguous unit sits between them. It put
|
|
209
|
+
* "give it three things" in the same group as "they just won't really feel like you". A unit
|
|
210
|
+
* joins a group only if it is similar to that group's FIRST member, which keeps a group to one
|
|
211
|
+
* thought said more than once.
|
|
212
|
+
*/
|
|
213
|
+
for (let i = 0; i < units.length; i++) {
|
|
214
|
+
if (groupOf[i] !== -1) continue;
|
|
215
|
+
const members = [i];
|
|
216
|
+
for (let j = i + 1; j < units.length; j++) {
|
|
217
|
+
if (groupOf[j] !== -1) continue;
|
|
218
|
+
if (units[j].from - units[i].to > WINDOW) break;
|
|
219
|
+
if (similarity(units[i], units[j]) < SIMILAR) continue;
|
|
220
|
+
members.push(j);
|
|
221
|
+
}
|
|
222
|
+
if (members.length < 2) continue;
|
|
223
|
+
groups.push({members});
|
|
224
|
+
for (const m of members) groupOf[m] = groups.length - 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A spoken cue abandons whatever came immediately before it. */
|
|
228
|
+
const abandoned = new Set();
|
|
229
|
+
for (const u of units) {
|
|
230
|
+
const flat = u.keys.join(' ');
|
|
231
|
+
if (!RESTART_CUES.some((c) => flat.includes(c.replace(/[^a-z0-9 ]/g, '')))) continue;
|
|
232
|
+
const prev = units[u.index - 1];
|
|
233
|
+
if (prev) abandoned.add(prev.index);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* --------------------------------------------------------------------- scoring */
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* One number per attempt.
|
|
240
|
+
*
|
|
241
|
+
* Completeness dominates — a finished sentence beats a smoother fragment every time. Everything
|
|
242
|
+
* else nudges. The tie-break at the end is deliberate and matches how people actually record:
|
|
243
|
+
* they repeat a line until they are happy, so the last attempt is usually the keeper.
|
|
244
|
+
*/
|
|
245
|
+
const score = (u) => {
|
|
246
|
+
let s = 0;
|
|
247
|
+
s += u.complete ? 6 : 0;
|
|
248
|
+
s += Math.min(u.keys.length / 12, 1) * 3;
|
|
249
|
+
s += u.confidence * 2;
|
|
250
|
+
s -= Math.max(0, u.worstGap - 0.45) * 3;
|
|
251
|
+
if (abandoned.has(u.index)) s -= 20;
|
|
252
|
+
// Pace: too fast reads as rushed, too slow as laboured. Either direction costs the same.
|
|
253
|
+
s -= Math.min(2, Math.abs(u.wordsPerSecond - medianPace) / Math.max(0.8, medianPace) * 2);
|
|
254
|
+
// Energy, only when there was audio to measure.
|
|
255
|
+
if (u.rms != null && medianRms) s += Math.max(-1.5, Math.min(1.5, (u.rms / medianRms - 1) * 2));
|
|
256
|
+
return s;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
for (const u of units) u.score = score(u);
|
|
260
|
+
|
|
261
|
+
/* ------------------------------------------------------- optional script checker
|
|
262
|
+
* A script never removes anything. It reports lines that were never said, and nudges a tie
|
|
263
|
+
* toward whichever attempt is closest to what was written. */
|
|
264
|
+
|
|
265
|
+
const scriptPath = flag('script', null);
|
|
266
|
+
let scriptMissing = [];
|
|
267
|
+
if (scriptPath && existsSync(scriptPath)) {
|
|
268
|
+
const raw = readFileSync(scriptPath, 'utf8');
|
|
269
|
+
const body = /^---\s*$/m.test(raw) ? raw.split(/^---\s*$/m).slice(1).join('\n') : raw;
|
|
270
|
+
const lines = body
|
|
271
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
272
|
+
.replace(/^#.*$/gm, '')
|
|
273
|
+
.replace(/`[^`]*`/g, ' ')
|
|
274
|
+
.split(/(?<=[.?!])\s+/)
|
|
275
|
+
.map((s) => s.replace(/\s+/g, ' ').trim())
|
|
276
|
+
.filter((s) => s.split(/\s+/).length >= 4);
|
|
277
|
+
|
|
278
|
+
for (const line of lines) {
|
|
279
|
+
const lk = line.split(/\s+/).map(key).filter(Boolean);
|
|
280
|
+
const best = Math.max(0, ...units.map((u) => lcs(lk, u.keys) / Math.max(1, lk.length)));
|
|
281
|
+
if (best < 0.6) scriptMissing.push({text: line, bestMatch: Number(best.toFixed(2))});
|
|
282
|
+
}
|
|
283
|
+
// Closest-to-script wins a close call, but only by a little.
|
|
284
|
+
for (const g of groups) {
|
|
285
|
+
for (const m of g.members) {
|
|
286
|
+
const u = units[m];
|
|
287
|
+
const best = Math.max(0, ...lines.map((l) => lcs(l.split(/\s+/).map(key).filter(Boolean), u.keys) / Math.max(1, u.keys.length)));
|
|
288
|
+
u.score += best * 1.2;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ----------------------------------------------------------------- the decision */
|
|
294
|
+
|
|
295
|
+
const drop = new Set();
|
|
296
|
+
const groupReport = [];
|
|
297
|
+
|
|
298
|
+
for (const g of groups) {
|
|
299
|
+
const members = [...new Set(g.members)].sort((a, b) => a - b);
|
|
300
|
+
let winner = members[0];
|
|
301
|
+
for (const m of members) {
|
|
302
|
+
const a = units[m];
|
|
303
|
+
const b = units[winner];
|
|
304
|
+
// Within a hair of each other, the later one wins.
|
|
305
|
+
if (a.score > b.score + 0.3 || (Math.abs(a.score - b.score) <= 0.3 && a.from > b.from)) winner = m;
|
|
306
|
+
}
|
|
307
|
+
for (const m of members) if (m !== winner) drop.add(m);
|
|
308
|
+
groupReport.push({
|
|
309
|
+
kept: {at: Number(units[winner].from.toFixed(2)), text: units[winner].text.slice(0, 90), score: Number(units[winner].score.toFixed(2))},
|
|
310
|
+
dropped: members.filter((m) => m !== winner).map((m) => ({
|
|
311
|
+
at: Number(units[m].from.toFixed(2)),
|
|
312
|
+
text: units[m].text.slice(0, 90),
|
|
313
|
+
score: Number(units[m].score.toFixed(2)),
|
|
314
|
+
})),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
for (const i of abandoned) drop.add(i);
|
|
318
|
+
|
|
319
|
+
const kept = units.filter((u) => !drop.has(u.index));
|
|
320
|
+
|
|
321
|
+
/* ------------------------------------------------------------- building the plan
|
|
322
|
+
* Consecutive survivors become one span, so the cut is not chopped at every sentence. A gap
|
|
323
|
+
* between spans is collapsed only when it is longer than a breath — and a pause in front of a
|
|
324
|
+
* short line is usually deliberate, so those are left alone. */
|
|
325
|
+
|
|
326
|
+
const keep = [];
|
|
327
|
+
for (const u of kept) {
|
|
328
|
+
const last = keep[keep.length - 1];
|
|
329
|
+
if (last && u.startIndex === last.endIndex + 1 && u.from - last.to < GAP_MAX) {
|
|
330
|
+
last.to = u.to;
|
|
331
|
+
last.endIndex = u.endIndex;
|
|
332
|
+
last.units.push(u.index);
|
|
333
|
+
} else {
|
|
334
|
+
keep.push({from: u.from, to: u.to, startIndex: u.startIndex, endIndex: u.endIndex, units: [u.index]});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const k of keep) {
|
|
339
|
+
k.from = Math.max(0, k.from - PAD);
|
|
340
|
+
k.to += PAD;
|
|
341
|
+
}
|
|
342
|
+
for (let i = 1; i < keep.length; i++) {
|
|
343
|
+
if (keep[i].from < keep[i - 1].to) {
|
|
344
|
+
const mid = (keep[i].from + keep[i - 1].to) / 2;
|
|
345
|
+
keep[i - 1].to = mid;
|
|
346
|
+
keep[i].from = mid;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Gaps between spans, after the cut: long dead air collapsed, deliberate beats kept. */
|
|
351
|
+
const gaps = [];
|
|
352
|
+
for (let i = 1; i < keep.length; i++) {
|
|
353
|
+
const gap = keep[i].from - keep[i - 1].to;
|
|
354
|
+
const nextIsShort = units[keep[i].units[0]].keys.length <= 6;
|
|
355
|
+
const target = gap > GAP_MAX && !nextIsShort ? GAP_KEEP : gap;
|
|
356
|
+
gaps.push({after: i - 1, was: Number(gap.toFixed(2)), becomes: Number(target.toFixed(2)), keptAsBeat: target === gap && gap > GAP_MAX});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const dropped = [...drop].sort((a, b) => a - b).map((i) => ({
|
|
360
|
+
from: Number(units[i].from.toFixed(2)),
|
|
361
|
+
to: Number(units[i].to.toFixed(2)),
|
|
362
|
+
reason: abandoned.has(i) ? 'announced-retake' : 'retake',
|
|
363
|
+
text: units[i].text.slice(0, 110),
|
|
364
|
+
}));
|
|
365
|
+
|
|
366
|
+
/* ----------------------------------------------------------------- verification */
|
|
367
|
+
|
|
368
|
+
const survivors = kept;
|
|
369
|
+
const stillAlike = [];
|
|
370
|
+
for (let i = 0; i < survivors.length; i++) {
|
|
371
|
+
for (let j = i + 1; j < survivors.length; j++) {
|
|
372
|
+
if (survivors[j].from - survivors[i].to > WINDOW) break;
|
|
373
|
+
if (similarity(survivors[i], survivors[j]) >= SIMILAR) {
|
|
374
|
+
stillAlike.push({
|
|
375
|
+
a: Number(survivors[i].from.toFixed(2)),
|
|
376
|
+
b: Number(survivors[j].from.toFixed(2)),
|
|
377
|
+
text: survivors[j].text.slice(0, 70),
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const keptSeconds = keep.reduce((a, k) => a + (k.to - k.from), 0);
|
|
384
|
+
const sourceSeconds = words[words.length - 1].end - words[0].start;
|
|
385
|
+
const checks = {
|
|
386
|
+
noRepeatsLeft: stillAlike.length === 0,
|
|
387
|
+
units: units.length,
|
|
388
|
+
retakeGroups: groups.length,
|
|
389
|
+
unitsDropped: drop.size,
|
|
390
|
+
spans: keep.length,
|
|
391
|
+
keptSeconds: Number(keptSeconds.toFixed(2)),
|
|
392
|
+
sourceSeconds: Number(sourceSeconds.toFixed(2)),
|
|
393
|
+
energyScored: medianRms != null,
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
/* ---------------------------------------------------------------------- output */
|
|
397
|
+
|
|
398
|
+
const stem = basename(wordsPath).replace(/\.?words\.json$|\.json$/, '') || 'take';
|
|
399
|
+
const project = flag('project', null);
|
|
400
|
+
const outPath = flag('out', null) ?? (project ? join('work', project, `${stem}.takes.json`) : join(dirname(wordsPath), `${stem}.takes.json`));
|
|
401
|
+
mkdirSync(dirname(outPath), {recursive: true});
|
|
402
|
+
writeFileSync(outPath, JSON.stringify({fps: FPS, gapMax: GAP_MAX, gapKeep: GAP_KEEP, pad: PAD, checks, keep, gaps, groups: groupReport, dropped, scriptMissing}, null, 2));
|
|
403
|
+
|
|
404
|
+
console.log(outPath);
|
|
405
|
+
console.log(` ${checks.units} units, ${checks.retakeGroups} retake group(s), ${checks.unitsDropped} dropped`);
|
|
406
|
+
console.log(` ${checks.keptSeconds}s kept of ${checks.sourceSeconds}s (${((keptSeconds / sourceSeconds) * 100).toFixed(0)}%) in ${checks.spans} span(s)`);
|
|
407
|
+
console.log(` energy scoring: ${checks.energyScored ? 'on' : 'off (pass --media to enable)'}`);
|
|
408
|
+
if (groupReport.length) {
|
|
409
|
+
console.log('\n retakes dropped:');
|
|
410
|
+
for (const g of groupReport.slice(0, 8)) {
|
|
411
|
+
for (const d of g.dropped) console.log(` ${String(d.at).padStart(7)}s "${d.text}"`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const beats = gaps.filter((g) => g.keptAsBeat).length;
|
|
415
|
+
if (beats) console.log(`\n ${beats} long pause(s) kept as deliberate beats before a short line.`);
|
|
416
|
+
if (scriptPath) {
|
|
417
|
+
console.log(`\n script check: ${scriptMissing.length} line(s) never said`);
|
|
418
|
+
for (const m of scriptMissing.slice(0, 6)) console.log(` - "${m.text.slice(0, 68)}"`);
|
|
419
|
+
}
|
|
420
|
+
if (stillAlike.length) {
|
|
421
|
+
console.error(`\n${stillAlike.length} near-duplicate(s) still in the cut:`);
|
|
422
|
+
for (const s of stillAlike.slice(0, 8)) console.error(` ${s.a}s and ${s.b}s — "${s.text}"`);
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {useCurrentFrame, useVideoConfig} from 'remotion';
|
|
3
|
+
import {COLOR, FONT, PALETTE, RADIUS, SHADOW, SPACE, TYPE, WEIGHT} from '../design/tokens';
|
|
4
|
+
import {EASE} from '../design/motion';
|
|
5
|
+
import {progress} from '../lib/animate';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A command, typed out, as a designed graphic rather than a screen recording.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists instead of zooming into the capture: the connect command carries the buyer's
|
|
11
|
+
* licence key, and the key IS the URL — anyone who can read it has a free studio. Blurring it in
|
|
12
|
+
* the capture is fragile, because the chat scrolls and the bubble moves, so a static mask misses
|
|
13
|
+
* it and a tracked one drifts. Rebuilding the command as type means there is nothing to miss: the
|
|
14
|
+
* key is never on screen in the first place, it is drawn as a redaction by design.
|
|
15
|
+
*
|
|
16
|
+
* It also reads better. A 1080p capture punched in to fill a 1920 frame is soft; this is sharp at
|
|
17
|
+
* any zoom, in the brand's own colours, and the Mac and Windows variants can sit side by side,
|
|
18
|
+
* which is impossible with one person's screen.
|
|
19
|
+
*
|
|
20
|
+
* MONOSPACE IS A DELIBERATE EXCEPTION. The design system says one family at every weight, and that
|
|
21
|
+
* holds everywhere except here: a command is code, and proportional type makes a URL ambiguous at
|
|
22
|
+
* exactly the moment someone is trying to read one. Nowhere else in the system gets this.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const MONO = 'ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
|
|
26
|
+
|
|
27
|
+
export type Segment =
|
|
28
|
+
/** Ordinary command text. */
|
|
29
|
+
| {kind: 'plain'; text: string}
|
|
30
|
+
/** Flags and switches — dimmed, because nobody needs to read them to follow along. */
|
|
31
|
+
| {kind: 'flag'; text: string}
|
|
32
|
+
/** The part that matters, in the accent. */
|
|
33
|
+
| {kind: 'accent'; text: string}
|
|
34
|
+
/**
|
|
35
|
+
* A secret. Never pass the real value: `text` is only used for its LENGTH, so the redaction
|
|
36
|
+
* is the right width. Pass dummy characters.
|
|
37
|
+
*/
|
|
38
|
+
| {kind: 'redacted'; text: string; label?: string};
|
|
39
|
+
|
|
40
|
+
const COLOR_FOR: Record<Segment['kind'], string> = {
|
|
41
|
+
plain: PALETTE.stone100,
|
|
42
|
+
flag: PALETTE.stone400,
|
|
43
|
+
accent: COLOR.accent,
|
|
44
|
+
redacted: 'transparent',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** How long the typing takes, from how much there is to type. Long commands do not crawl. */
|
|
48
|
+
const typeFrames = (chars: number, fps: number) => Math.round((chars / 42) * fps);
|
|
49
|
+
|
|
50
|
+
const Redaction: React.FC<{width: number; label?: string}> = ({width, label}) => (
|
|
51
|
+
<span
|
|
52
|
+
style={{
|
|
53
|
+
position: 'relative',
|
|
54
|
+
display: 'inline-flex',
|
|
55
|
+
alignItems: 'center',
|
|
56
|
+
justifyContent: 'center',
|
|
57
|
+
width,
|
|
58
|
+
height: '1.15em',
|
|
59
|
+
verticalAlign: 'middle',
|
|
60
|
+
borderRadius: RADIUS.sm / 2,
|
|
61
|
+
// Accent at low alpha, not a grey smear: it reads as a deliberate mark rather than a mistake.
|
|
62
|
+
backgroundColor: `${PALETTE.lime400}2e`,
|
|
63
|
+
border: `2px solid ${PALETTE.lime400}66`,
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<span
|
|
67
|
+
style={{
|
|
68
|
+
fontFamily: FONT.sans,
|
|
69
|
+
fontSize: '0.56em',
|
|
70
|
+
fontWeight: WEIGHT.semibold,
|
|
71
|
+
letterSpacing: '0.08em',
|
|
72
|
+
textTransform: 'uppercase',
|
|
73
|
+
color: COLOR.accent,
|
|
74
|
+
whiteSpace: 'nowrap',
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{label ?? 'your key'}
|
|
78
|
+
</span>
|
|
79
|
+
</span>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
export const CommandCard: React.FC<{
|
|
83
|
+
segments: Segment[];
|
|
84
|
+
/** Frame the card lands on. Typing starts once it has settled. */
|
|
85
|
+
at: number;
|
|
86
|
+
/** Shown in the title bar. Omit for a card that is the same on both platforms. */
|
|
87
|
+
platform?: 'windows' | 'mac';
|
|
88
|
+
/** Replaces the platform chip — e.g. "same on Mac and Windows". */
|
|
89
|
+
note?: string;
|
|
90
|
+
fontSize?: number;
|
|
91
|
+
width?: number;
|
|
92
|
+
}> = ({segments, at, platform, note, fontSize = 34, width}) => {
|
|
93
|
+
const frame = useCurrentFrame();
|
|
94
|
+
const {fps} = useVideoConfig();
|
|
95
|
+
|
|
96
|
+
// The card arrives, THEN types. Typing while the card is still moving reads as two
|
|
97
|
+
// competing animations and the eye does not know where to settle.
|
|
98
|
+
const land = progress(frame, at, Math.round(0.4 * fps), EASE.out);
|
|
99
|
+
|
|
100
|
+
const total = segments.reduce((n, s) => n + s.text.length, 0);
|
|
101
|
+
const typed = progress(frame, at + Math.round(0.3 * fps), typeFrames(total, fps), EASE.inOut);
|
|
102
|
+
const shown = Math.floor(typed * total);
|
|
103
|
+
|
|
104
|
+
// A caret that blinks only once typing has finished, the way a real prompt does.
|
|
105
|
+
const done = typed >= 1;
|
|
106
|
+
const caretOn = !done || Math.floor((frame / fps) * 2) % 2 === 0;
|
|
107
|
+
|
|
108
|
+
let consumed = 0;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div
|
|
112
|
+
style={{
|
|
113
|
+
width,
|
|
114
|
+
maxWidth: '100%',
|
|
115
|
+
borderRadius: RADIUS.md,
|
|
116
|
+
backgroundColor: PALETTE.ink900,
|
|
117
|
+
boxShadow: SHADOW.pop,
|
|
118
|
+
overflow: 'hidden',
|
|
119
|
+
opacity: land,
|
|
120
|
+
transform: `translateY(${(1 - land) * 28}px)`,
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<div
|
|
124
|
+
style={{
|
|
125
|
+
display: 'flex',
|
|
126
|
+
alignItems: 'center',
|
|
127
|
+
gap: SPACE[2],
|
|
128
|
+
padding: `${SPACE[2]}px ${SPACE[3]}px`,
|
|
129
|
+
backgroundColor: PALETTE.ink800,
|
|
130
|
+
borderBottom: `2px solid ${PALETTE.ink700}`,
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
{[PALETTE.stone600, PALETTE.stone600, PALETTE.stone600].map((c, i) => (
|
|
134
|
+
<span key={i} style={{width: 14, height: 14, borderRadius: RADIUS.pill, backgroundColor: c}} />
|
|
135
|
+
))}
|
|
136
|
+
<span style={{flex: 1}} />
|
|
137
|
+
<span
|
|
138
|
+
style={{
|
|
139
|
+
fontFamily: FONT.sans,
|
|
140
|
+
fontSize: TYPE.eyebrow.size * 0.62,
|
|
141
|
+
fontWeight: WEIGHT.medium,
|
|
142
|
+
letterSpacing: TYPE.eyebrow.tracking,
|
|
143
|
+
textTransform: 'uppercase',
|
|
144
|
+
color: note ? COLOR.accent : PALETTE.stone400,
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
{note ?? (platform === 'mac' ? 'macOS' : platform === 'windows' ? 'Windows' : '')}
|
|
148
|
+
</span>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div
|
|
152
|
+
style={{
|
|
153
|
+
padding: `${SPACE[4]}px ${SPACE[4]}px ${SPACE[4]}px`,
|
|
154
|
+
fontFamily: MONO,
|
|
155
|
+
fontSize,
|
|
156
|
+
lineHeight: 1.65,
|
|
157
|
+
letterSpacing: '-0.01em',
|
|
158
|
+
color: PALETTE.stone100,
|
|
159
|
+
wordBreak: 'break-word',
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
{segments.map((seg, i) => {
|
|
163
|
+
const start = consumed;
|
|
164
|
+
consumed += seg.text.length;
|
|
165
|
+
const visible = Math.max(0, Math.min(seg.text.length, shown - start));
|
|
166
|
+
if (visible === 0) return null;
|
|
167
|
+
|
|
168
|
+
if (seg.kind === 'redacted') {
|
|
169
|
+
// Reveal the redaction as one block once typing reaches it, rather than growing it
|
|
170
|
+
// character by character, which would look like the secret is being drawn in.
|
|
171
|
+
const w = seg.text.length * fontSize * 0.6;
|
|
172
|
+
return <Redaction key={i} width={w} label={seg.label} />;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<span key={i} style={{color: COLOR_FOR[seg.kind]}}>
|
|
177
|
+
{seg.text.slice(0, visible)}
|
|
178
|
+
</span>
|
|
179
|
+
);
|
|
180
|
+
})}
|
|
181
|
+
<span
|
|
182
|
+
style={{
|
|
183
|
+
display: 'inline-block',
|
|
184
|
+
width: fontSize * 0.55,
|
|
185
|
+
height: '1.05em',
|
|
186
|
+
marginLeft: 4,
|
|
187
|
+
verticalAlign: 'text-bottom',
|
|
188
|
+
backgroundColor: COLOR.accent,
|
|
189
|
+
opacity: caretOn ? 1 : 0,
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/* ------------------------------------------------------------------ the real commands */
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The connect line. Identical on Mac and Windows, which is worth SAYING rather than showing
|
|
201
|
+
* twice: a Mac viewer watching a Windows walkthrough is waiting to find out what is different
|
|
202
|
+
* for them, and "nothing, here" is the answer that keeps them watching.
|
|
203
|
+
*/
|
|
204
|
+
export const CONNECT_SEGMENTS: Segment[] = [
|
|
205
|
+
{kind: 'plain', text: 'claude mcp add '},
|
|
206
|
+
{kind: 'flag', text: '--scope user --transport http '},
|
|
207
|
+
{kind: 'accent', text: 'creator-editing-os'},
|
|
208
|
+
{kind: 'plain', text: ' https://creator-editing-os.mahipal-231.workers.dev/mcp/'},
|
|
209
|
+
{kind: 'redacted', text: 'XXXX-XXXX-XXXX-XXXX'},
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
/** Where the two platforms genuinely differ: installing Node when it is missing. */
|
|
213
|
+
export const NODE_INSTALL = {
|
|
214
|
+
windows: [
|
|
215
|
+
{kind: 'plain', text: 'winget install '},
|
|
216
|
+
{kind: 'accent', text: 'OpenJS.NodeJS.LTS'},
|
|
217
|
+
] as Segment[],
|
|
218
|
+
mac: [
|
|
219
|
+
{kind: 'plain', text: 'brew install '},
|
|
220
|
+
{kind: 'accent', text: 'node'},
|
|
221
|
+
] as Segment[],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/** And where the studio lands. */
|
|
225
|
+
export const STUDIO_PATH = {
|
|
226
|
+
windows: [{kind: 'plain', text: 'C:\\Users\\you\\'}, {kind: 'accent', text: 'VideoStudio'}] as Segment[],
|
|
227
|
+
mac: [{kind: 'plain', text: '/Users/you/'}, {kind: 'accent', text: 'VideoStudio'}] as Segment[],
|
|
228
|
+
};
|