@aicopycoders/exodus 2026.9.2000 → 2026.10.1200-beta.1

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.
@@ -7,7 +7,7 @@
7
7
  // its cued line ends, heard with its own sound while the speaker is silent,
8
8
  // fading in across any quiet start and out rather than stopping dead.
9
9
  //
10
- // node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
10
+ // node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music] [--still-fill]
11
11
  //
12
12
  // The audio track comes first. A narrated scene (the storyboard gives it
13
13
  // narration and no on-camera line) plays its voice track and NOTHING the clip
@@ -16,23 +16,27 @@
16
16
  // the voice runs longer. The voice itself is never altered. A dialogue scene
17
17
  // keeps the audio it performed; at each join its silence before the first word
18
18
  // and after the last is cut back to a short beat, read from its word times.
19
- // Inside it, a silence over 0.9 s between two sentences is cut down to 0.8 s,
19
+ // Inside it, a silence over 0.6 s between two sentences is cut down to 0.5 s,
20
20
  // picture and sound together, unless the script writes that pause in. A
21
21
  // sound in such a break that holds no heard word (a burp, a click) is cut
22
- // out, read from the clip's loudness, and the same rule then applies. A
23
- // scene with no clip but a keyframe becomes a still for the length of its voice
24
- // track, else the storyboard's planned duration. Every segment is
25
- // loudness-normalized to -16 LUFS before the join.
26
- // --skip drops scenes.
22
+ // out, read from the clip's loudness, and the same rule then applies. Every
23
+ // segment is loudness-normalized to -16 LUFS before the join.
24
+ // A scene with no clip stops the cut (#3365: a customer's ad once ended on 16 s
25
+ // of frozen, silent keyframes). --skip drops scenes. --still-fill instead turns
26
+ // a scene with no clip but a keyframe into a still for the length of its voice
27
+ // track, else the storyboard's planned duration. A run whose Pacing is jump cuts (manifest.shots)
28
+ // punches in on every other cut between one speaker's scenes; a montage does
29
+ // the same where one speaker's scenes in a row play one base shot.
27
30
  //
28
31
  // After the render it writes the whole ad's word times beside the MP4, as
29
- // <out>.words.json and <out>.srt, for captioning in post.
32
+ // <out>.words.json and <out>.srt, for captioning in post, and where each scene
33
+ // plays in the cut as <out>.scenes.json (#3300).
30
34
  import { execFileSync, spawnSync } from "node:child_process";
31
35
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
32
36
  import path from "node:path";
33
37
  import { pathToFileURL } from "node:url";
34
38
 
35
- const USAGE = "usage: first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]";
39
+ const USAGE = "usage: first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music] [--still-fill]";
36
40
  const WIDTH = 1080;
37
41
  const HEIGHT = 1920;
38
42
  const FPS = 24;
@@ -44,6 +48,16 @@ const norm = (label) =>
44
48
  * on whole frames, so the picture runs as long as its sound there and the
45
49
  * timeline's times are the cut's times.
46
50
  */
51
+ /**
52
+ * #3304: the jump cut's punch-in, a centre crop of the frame scaled up this
53
+ * much. The planner holds one framing across a jump-cut run's scenes, and a
54
+ * montage's line-cut pieces keep their parent's (#3323), so without it those
55
+ * cuts would be the same picture.
56
+ */
57
+ export const PUNCH_IN_SCALE = 1.15;
58
+ const even = (n) => 2 * Math.round(n / 2);
59
+ const PUNCH_IN =
60
+ `scale=${even(WIDTH * PUNCH_IN_SCALE)}:${even(HEIGHT * PUNCH_IN_SCALE)},crop=${WIDTH}:${HEIGHT},setsar=1`;
47
61
  const floorFrame = (t) => Math.floor(t * FPS + 1e-6) / FPS;
48
62
  const ceilFrame = (t) => Math.ceil(t * FPS - 1e-6) / FPS;
49
63
  const stereo = "aresample=48000,aformat=channel_layouts=stereo";
@@ -58,8 +72,17 @@ const stereo = "aresample=48000,aformat=channel_layouts=stereo";
58
72
  const LOUDNORM = "apad=whole_dur=3,loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000,asetpts=N/SR/TB";
59
73
  /** A fade this short at each edge of a segment's sound stops a hard cut in the room tone clicking. */
60
74
  const EDGE_FADE_SEC = 0.015;
61
- /** Air after the last spoken word of a narrated scene, so the line lands. */
62
- export const TAIL_AIR_SEC = 0.8;
75
+ /**
76
+ * Air after a narrator's last word. Sizing by the voice file plus 0.8 s left
77
+ * ~1.1 s of silence at every narrator join, since the file already runs on
78
+ * ~0.3 s past the word (#3384).
79
+ */
80
+ export const NARRATION_JOIN_AIR_SEC = 0.25;
81
+ /**
82
+ * Air after the last word of the line that ends the ad, spoken or narrated, so
83
+ * it lands. A dialogue clip's own tail ran ~1.1 s of dead air (#3423).
84
+ */
85
+ export const END_AIR_SEC = 0.5;
63
86
  /** A continuous low bed under the whole ad, so the joins between clips do not read as dead air. */
64
87
  const ROOM_TONE =
65
88
  "anoisesrc=color=brown:amplitude=0.0035:r=48000,highpass=f=60,lowpass=f=900,aformat=channel_layouts=stereo";
@@ -105,34 +128,188 @@ export function lineWordCounts(storyboard) {
105
128
  const wordToken = (word) => String(word ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
106
129
 
107
130
  /**
108
- * #2541: the indexes of the heard words that deliver the first and last
109
- * scripted word, matching the script in order at its earliest delivery, or null
110
- * when the whole script is not heard that way.
131
+ * #3175: one spoken word a script and a scribe spell differently ("ok", "Okay").
132
+ * #3460: "alright" maps to the scribe's "All right" once its halves are joined.
111
133
  */
112
- export function scriptedSpan(words, lineTexts) {
113
- const script = lineTexts.flatMap((text) => (text ?? "").split(/\s+/)).map(wordToken).filter(Boolean);
114
- const at = heardAt(words, script);
115
- return at && at.length > 0 ? { first: at[0], last: at.at(-1) } : null;
134
+ const SAME_WORD_SPELLINGS = new Map([
135
+ ["ok", "okay"],
136
+ ["alright", "allright"],
137
+ ]);
138
+
139
+ /** The spoken tokens of one written word; empty for pure punctuation. */
140
+ function spokenWords(word) {
141
+ const core = String(word ?? "").toLowerCase().replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
142
+ // #3133: a scribe writes a spoken "glowmist dot com" as "Glowmist.com".
143
+ const parts = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z]{2,})+$/.test(core)
144
+ ? core.split(".").flatMap((part, i) => (i ? ["dot", part] : [part]))
145
+ : [core];
146
+ return parts.map(wordToken).filter(Boolean).map((t) => SAME_WORD_SPELLINGS.get(t) ?? t);
147
+ }
148
+
149
+ /** { tokens, sourceIndex }: tokens[i] came from written word sourceIndex[i]. */
150
+ function spokenTokens(words) {
151
+ const tokens = [];
152
+ const sourceIndex = [];
153
+ words.forEach((word, i) => {
154
+ for (const token of spokenWords(word)) {
155
+ tokens.push(token);
156
+ sourceIndex.push(i);
157
+ }
158
+ });
159
+ return { tokens, sourceIndex };
116
160
  }
117
161
 
118
- /** The heard word that delivers each script token, in order at its earliest delivery, or null. */
119
- function heardAt(words, tokens) {
120
- const at = [];
121
- let j = 0;
122
- for (const token of tokens) {
123
- while (j < words.length && wordToken(words[j].w) !== token) j++;
124
- if (j === words.length) return null;
125
- at.push(j++);
162
+ /** An ellipsis or a dash, which a writer may type with no space around it (scout scribe.ts PAUSE_MARK). */
163
+ const PAUSE_MARK = /…|\.{3,}|[—–]|-{2,}/g;
164
+ const scriptWords = (text) => (text ?? "").replace(PAUSE_MARK, " $& ").split(/\s+/).filter(Boolean);
165
+
166
+ const MAX_JOINED_WORDS = 3;
167
+ const MIN_SLIP_WORD_LENGTH = 7;
168
+
169
+ /**
170
+ * #2300: a scribe writes a made-up name the script spells as one word
171
+ * ("FreshKeep") as two ("Fresh keep"), or the other way round. Joins each run
172
+ * of 2-3 neighbouring tokens whose concatenation is a token of the other side,
173
+ * longest first. #2520: a run one slip off one of the other side's names joins
174
+ * too, unless a join also starts at the next token, or (#3058) its first token
175
+ * is a word the other side already has. Mirrors scout scribe.ts joinSplitWords.
176
+ */
177
+ function joinSplitWords(side, otherSide, otherNames = []) {
178
+ const other = new Set(otherSide);
179
+ const fuzzy = (r) => otherNames.some((name) => sameWord(name, r, true));
180
+ const run = (i, k) => (i + k <= side.tokens.length ? side.tokens.slice(i, i + k).join("") : undefined);
181
+ const joinStartsAt = (i) => {
182
+ for (let k = MAX_JOINED_WORDS; k >= 2; k--) {
183
+ const r = run(i, k);
184
+ if (r !== undefined && (other.has(r) || fuzzy(r))) return true;
185
+ }
186
+ return false;
187
+ };
188
+ const tokens = [];
189
+ const sourceIndex = [];
190
+ const lastSourceIndex = [];
191
+ for (let i = 0; i < side.tokens.length; ) {
192
+ let take = 1;
193
+ for (let k = MAX_JOINED_WORDS; k >= 2; k--) {
194
+ const r = run(i, k);
195
+ if (r === undefined) continue;
196
+ if (other.has(r) || (fuzzy(r) && !joinStartsAt(i + 1) && !other.has(side.tokens[i]))) {
197
+ take = k;
198
+ break;
199
+ }
200
+ }
201
+ tokens.push(side.tokens.slice(i, i + take).join(""));
202
+ sourceIndex.push(side.sourceIndex[i]);
203
+ lastSourceIndex.push(side.sourceIndex[i + take - 1]);
204
+ i += take;
126
205
  }
127
- return at;
206
+ return { tokens, sourceIndex, lastSourceIndex };
207
+ }
208
+
209
+ /**
210
+ * Which written words read as names: a capital inside the word, or a leading
211
+ * capital where no sentence starts. "I" and "I'm" are never names.
212
+ */
213
+ function nameLikeWords(words) {
214
+ return words.map((word, i) => {
215
+ const bare = word.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, "");
216
+ if (/^I(['’][a-z]+)?$/.test(bare)) return false;
217
+ if (/[A-Z]/.test(bare.slice(1))) return true;
218
+ const sentenceStart = i === 0 || /[.!?]["')\]]*$/.test(words[i - 1]);
219
+ return /^[A-Z]/.test(bare) && !sentenceStart;
220
+ });
221
+ }
222
+
223
+ /**
224
+ * #2441: one letter off on a long name is a slip; #3035: so is a spelling of
225
+ * the same sound, and #3415 a final z after a vowel for an s ("Inez", "Ines").
226
+ */
227
+ function sameWord(scripted, heard, nameLike) {
228
+ if (scripted === heard || soundKey(scripted) === soundKey(heard)) return true;
229
+ if (!nameLike || scripted.length < MIN_SLIP_WORD_LENGTH) return false;
230
+ const [short, long] = scripted.length <= heard.length ? [scripted, heard] : [heard, scripted];
231
+ if (long.length - short.length > 1) return false;
232
+ let i = 0;
233
+ while (i < short.length && short[i] === long[i]) i++;
234
+ const rest = short.length === long.length ? i + 1 : i;
235
+ return short.slice(rest) === long.slice(i + 1);
236
+ }
237
+
238
+ const soundKey = (token) =>
239
+ token
240
+ .replace(/ph/g, "f")
241
+ .replace(/ck|q|c(?![eiy])/g, "k")
242
+ .replace(/(?<=\w{3})oe$/, "o")
243
+ .replace(/(?<=[aeiou])z$/, "s");
244
+
245
+ /**
246
+ * Where the script's first complete, in-order delivery sits in the heard
247
+ * words, or null. heardTokenOf/heardWordOf give the heard token and word that
248
+ * deliver each (joined) script token; spokenWords lists every heard word that
249
+ * carries a token. #3140: one scripted word misheard as another is paired with
250
+ * the heard word in its place when the next scripted word follows it, but an
251
+ * exact delivery wins. Mirrors scout scribe.ts alignScript.
252
+ */
253
+ function alignScript(words, written) {
254
+ const scripted = spokenTokens(written);
255
+ const heardWords = spokenTokens(words.map((w) => w.w));
256
+ const nameLike = nameLikeWords(written);
257
+ const names = scripted.tokens.filter((_, t) => nameLike[scripted.sourceIndex[t]]);
258
+ const heard = joinSplitWords(heardWords, scripted.tokens, names);
259
+ const expected = joinSplitWords(scripted, heard.tokens);
260
+ if (expected.tokens.length === 0 || heard.tokens.length === 0) return null;
261
+ const matches = (e, h) =>
262
+ h < heard.tokens.length && sameWord(expected.tokens[e], heard.tokens[h], nameLike[expected.sourceIndex[e]]);
263
+ const pair = (allowMisheard) => {
264
+ const tokenOf = [];
265
+ let j = 0;
266
+ for (let e = 0; e < expected.tokens.length; e++) {
267
+ const misheard = allowMisheard && !matches(e, j) && e + 1 < expected.tokens.length && matches(e + 1, j + 1);
268
+ if (!misheard) while (j < heard.tokens.length && !matches(e, j)) j++;
269
+ if (j === heard.tokens.length) return null;
270
+ tokenOf.push(j++);
271
+ }
272
+ return tokenOf;
273
+ };
274
+ const heardTokenOf = pair(false) ?? pair(true);
275
+ if (!heardTokenOf) return null;
276
+ return {
277
+ expected,
278
+ heard,
279
+ heardTokenOf,
280
+ heardWordOf: heardTokenOf.map((j) => heard.sourceIndex[j]),
281
+ spokenWords: [...new Set(heardWords.sourceIndex)],
282
+ };
283
+ }
284
+
285
+ /**
286
+ * #2541: the indexes of the heard words that deliver the first and last
287
+ * scripted word, matching the script in order at its earliest delivery, or null
288
+ * when the whole script is not heard that way. #3058: a last word heard split
289
+ * in two ends on its second half.
290
+ */
291
+ export function scriptedSpan(words, lineTexts) {
292
+ const aligned = alignScript(words, lineTexts.flatMap(scriptWords));
293
+ if (!aligned) return null;
294
+ const { heard, heardWordOf, heardTokenOf } = aligned;
295
+ return { first: heardWordOf[0], last: heard.lastSourceIndex[heardTokenOf.at(-1)] };
128
296
  }
129
297
 
130
- /** #2544: a silence between two sentences longer than this is cut down to the kept length. */
131
- export const SENTENCE_GAP_MAX_SEC = 0.9;
132
- export const SENTENCE_GAP_KEEP_SEC = 0.8;
298
+ /** #2544, #3423: a silence between two sentences longer than this is cut down to the kept length. */
299
+ export const SENTENCE_GAP_MAX_SEC = 0.6;
300
+ export const SENTENCE_GAP_KEEP_SEC = 0.5;
133
301
  /** A pause the script writes in: an ellipsis, a dash, or a hyphen standing between words. */
134
302
  const WRITTEN_PAUSE = /…|\.{3}|[—–-]/;
135
303
  const SENTENCE_END = /[.?!]/;
304
+ /** A heard word that ends a sentence; a heard ellipsis does not (scout scribe.ts HEARD_SENTENCE_END). */
305
+ const HEARD_SENTENCE_END = /(?<!\.)[.?!]["'”’)\]]*$/;
306
+ /** A heard abbreviation keeps its period mid-sentence ("2p.m.", "U.S.", "Dr."). */
307
+ const HEARD_ABBREVIATION = /^[\d:]*(?:\p{L}\.){2,}$|^(?:mr|mrs|ms|dr|st|vs|etc|jr|sr)\.$/iu;
308
+
309
+ function heardSentenceEnd(word) {
310
+ const bare = String(word ?? "").replace(/^["'“‘(\[]+|["'”’)\]]+$/g, "");
311
+ return HEARD_SENTENCE_END.test(bare) && !HEARD_ABBREVIATION.test(bare);
312
+ }
136
313
  /**
137
314
  * #2554: loudness windows, and the levels that read as a sound and as quiet;
138
315
  * SETTLED_WINDOWS quiet windows in a row (200 ms) end a sound (scout trim.ts).
@@ -164,8 +341,10 @@ export function asksPause(line) {
164
341
  * clip's own time. A heard silence over SENTENCE_GAP_MAX_SEC between two
165
342
  * scripted words at a sentence break (a new line, or . ? ! between them) keeps
166
343
  * half of SENTENCE_GAP_KEEP_SEC after the earlier word and half before the
167
- * later one; the rest goes. Kept: a pause the script writes in (a mark between
168
- * the words, or a direction on either line asking for one), the laugh beat
344
+ * later one; the rest goes. #3460: a speaker can end a sentence where the
345
+ * script writes a comma, so an earlier word the scribe heard end one counts.
346
+ * Kept: a pause the script writes in (a mark between the words, or a
347
+ * direction on either line asking for one), the laugh beat
169
348
  * after a line a reaction is cued to, and any gap with another heard sound in
170
349
  * it. Nothing is cut when the script is not heard in order.
171
350
  *
@@ -177,32 +356,39 @@ export function asksPause(line) {
177
356
  * lines: [{ text, asksPause, cuesBeat }] in the order the clip speaks them.
178
357
  */
179
358
  export function sentenceGapCuts(words, lines, levels = null) {
180
- const tokens = [];
359
+ const written = [];
360
+ const pieces = [];
181
361
  let between = "";
182
362
  lines.forEach((line, li) => {
183
- for (const piece of (line.text ?? "").split(/\s+/)) {
184
- const token = wordToken(piece);
185
- if (!token) {
363
+ for (const piece of scriptWords(line.text)) {
364
+ written.push(piece);
365
+ if (!wordToken(piece)) {
186
366
  between += ` ${piece}`;
367
+ pieces.push(null);
187
368
  continue;
188
369
  }
189
370
  const [, lead, trail] = piece.match(/^([^\p{L}\p{N}]*).*?([^\p{L}\p{N}]*)$/u);
190
- tokens.push({ token, li, before: between + lead });
371
+ pieces.push({ li, before: between + lead });
191
372
  between = trail;
192
373
  }
193
374
  between += " ";
194
375
  });
195
- const at = heardAt(words ?? [], tokens.map((t) => t.token));
196
- if (!at) return [];
376
+ const aligned = alignScript(words ?? [], written);
377
+ if (!aligned) return [];
378
+ const { expected, heardTokenOf, heardWordOf, spokenWords: spoken } = aligned;
197
379
  const cuts = [];
198
- for (let t = 0; t + 1 < tokens.length; t++) {
199
- const [a, b] = [tokens[t], tokens[t + 1]];
200
- if (at[t + 1] !== at[t] + 1) continue;
380
+ for (let t = 0; t + 1 < heardWordOf.length; t++) {
381
+ if (heardTokenOf[t + 1] !== heardTokenOf[t] + 1) continue;
382
+ const to = heardWordOf[t + 1];
383
+ if (heardWordOf[t] === to) continue;
384
+ // A joined script token keeps its first word's line and lead-in.
385
+ const [a, b] = [pieces[expected.sourceIndex[t]], pieces[expected.sourceIndex[t + 1]]];
201
386
  const newLine = a.li !== b.li;
202
- if (!newLine && !SENTENCE_END.test(b.before)) continue;
387
+ // A name heard split in two (#2300) ends on its last heard word.
388
+ const [earlier, later] = [words[spoken.findLast((w) => w < to)], words[to]];
389
+ if (!newLine && !SENTENCE_END.test(b.before) && !heardSentenceEnd(earlier.w)) continue;
203
390
  if (WRITTEN_PAUSE.test(b.before) || lines[a.li].asksPause || lines[b.li].asksPause) continue;
204
391
  if (newLine && lines[a.li].cuesBeat) continue;
205
- const [earlier, later] = [words[at[t]], words[at[t + 1]]];
206
392
  const gap = later.s - earlier.e;
207
393
  const heard = levels && soundBetween(levels, earlier.e, later.s);
208
394
  // A sound's frame edges widen the cut, so none of it is left in.
@@ -367,6 +553,7 @@ export function scenePlans(storyboard) {
367
553
  plans.set(scene.sceneIndex, {
368
554
  firstSpeaker: speakers[0] ?? null,
369
555
  lastSpeaker: speakers.at(-1) ?? null,
556
+ baseShotId: hasText(scene.baseShotId) ? scene.baseShotId.trim() : null,
370
557
  cutaway: CUTAWAY_KINDS.has(scene.kind),
371
558
  narrated: narratedScene(scene, storyboard),
372
559
  lineIds: Array.isArray(scene.lineIds) ? scene.lineIds : [],
@@ -380,17 +567,18 @@ export function scenePlans(storyboard) {
380
567
  }
381
568
 
382
569
  /**
383
- * Fit a picture to its voice track. The segment always runs as long as the
384
- * voice plus a beat of air, to the next whole frame, because the voice is never
385
- * altered. A clip that runs longer is trimmed; a clip that runs shorter holds
386
- * its last frame for the difference. A still (no clip) simply runs the voice's length.
387
- * A scene marked with actionTailSec keeps that long after the voice, up to the clip's
388
- * end, so the set-down it ends on is seen (#3199).
570
+ * Fit a picture to its voice track. The segment runs to where the voice stops
571
+ * speaking (spokenEndSec) plus airSec, to the next whole frame; the voice is
572
+ * never altered, only cut in the silence after that point. A clip that runs
573
+ * longer is trimmed; a clip that runs shorter holds its last frame for the
574
+ * difference. A still (no clip) simply runs that length. A scene marked with
575
+ * actionTailSec keeps that long after the voice, up to the clip's end, so the
576
+ * set-down it ends on is seen (#3199).
389
577
  */
390
- export function fitNarration({ clipSeconds, voiceSeconds, actionTailSec = 0 }) {
391
- const aired = ceilFrame(voiceSeconds + TAIL_AIR_SEC);
578
+ export function fitNarration({ clipSeconds, spokenEndSec, airSec, actionTailSec = 0 }) {
579
+ const aired = ceilFrame(spokenEndSec + airSec);
392
580
  if (clipSeconds === null) return { seconds: aired, holdSec: 0, trimmedBy: 0 };
393
- const target = Math.max(aired, Math.min(clipSeconds, ceilFrame(voiceSeconds + actionTailSec)));
581
+ const target = Math.max(aired, Math.min(clipSeconds, ceilFrame(spokenEndSec + actionTailSec)));
394
582
  if (target <= clipSeconds) {
395
583
  return { seconds: target, holdSec: 0, trimmedBy: clipSeconds - target };
396
584
  }
@@ -449,10 +637,10 @@ export function cueEndInSegment(seg, counts, cueLineId) {
449
637
  * (#2388). Each side comes back to a beat that reads as one conversation, a
450
638
  * longer one when someone new answers. Only ever shortens, never into a word.
451
639
  * The ad's first lead-in comes back to the same-speaker beat too, since a clip
452
- * can hum before its first word (#2666); its last tail is left alone, and a
453
- * clip whose closing line cues a reaction is not the ad's end, since the beat
454
- * follows it. Trims land on whole frames so the picture and its sound stay the
455
- * same length.
640
+ * can hum before its first word (#2666); its last tail comes back to
641
+ * END_AIR_SEC (#3423), and a clip whose closing line cues a reaction is not the
642
+ * ad's end, since the beat follows it. Trims land on whole frames so the
643
+ * picture and its sound stay the same length.
456
644
  *
457
645
  * The beat is measured from the clip's scripted words when the script is heard
458
646
  * in order, and a cut stops short of a sound made up before or after the line
@@ -478,9 +666,8 @@ function trimJoins(spine, reactionCues, scriptLines) {
478
666
  inSec = Math.max(inSec, Math.min(clear, floorFrame(firstWord)));
479
667
  }
480
668
  const endsAd = k === spine.length - 1 && !reactionCues.has(seg.lineIds.at(-1));
481
- let outSec = endsAd
482
- ? after.length > 0 ? ceilFrame(lastWord + TAIL_AIR_SEC) : seg.seconds
483
- : ceilFrame(lastWord + Math.max(JOIN_TAIL_SEC, seg.actionTailSec ?? 0));
669
+ const tailSec = Math.max(endsAd ? END_AIR_SEC : JOIN_TAIL_SEC, seg.actionTailSec ?? 0);
670
+ let outSec = ceilFrame(lastWord + tailSec);
484
671
  if (after.length > 0) {
485
672
  const clear = floorFrame(Math.min(...after.map((w) => w.s)) - EXTRA_CLEARANCE_SEC);
486
673
  outSec = Math.min(outSec, Math.max(clear, ceilFrame(lastWord)));
@@ -501,6 +688,19 @@ function trimJoins(spine, reactionCues, scriptLines) {
501
688
  });
502
689
  }
503
690
 
691
+ /**
692
+ * Where a narrated segment stops speaking, and the air it keeps after (#3384).
693
+ * voiceWords are word times taken from the voice track itself. Without them
694
+ * the end of the voice file stands in for the last word, its own trailing
695
+ * silence counted as part of the air.
696
+ */
697
+ function narrationEnd(voiceSeconds, voiceWords, endsAd) {
698
+ return {
699
+ spokenEndSec: voiceWords?.length ? Math.max(...voiceWords.map((w) => w.e)) : voiceSeconds,
700
+ airSec: endsAd ? END_AIR_SEC : NARRATION_JOIN_AIR_SEC,
701
+ };
702
+ }
703
+
504
704
  const holdFor = (seg) =>
505
705
  seg.source === "clip" ? Math.max(0, seg.seconds - Math.max(0, seg.clipSeconds - seg.inSec)) : 0;
506
706
 
@@ -576,13 +776,66 @@ function quietStart(levels, seconds) {
576
776
  return first > 0 ? Math.min(seconds, Math.max(EDGE_FADE_SEC, first * levels.windowSec)) : EDGE_FADE_SEC;
577
777
  }
578
778
 
579
- export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords, readLevels = () => null }) {
779
+ /**
780
+ * Alternate one speaker's scenes in a row between the plain frame and the
781
+ * punch-in, so every cut between them visibly moves in or out. Any other cut
782
+ * starts the alternation again. Marked before reactions split a scene, so both
783
+ * halves keep their scene's frame.
784
+ */
785
+ function markPunchIns(spine, sameShot) {
786
+ let punched = false;
787
+ spine.forEach((seg, k) => {
788
+ const prev = spine[k - 1];
789
+ punched = Boolean(prev) && sameShot(prev, seg) && !punched;
790
+ if (punched) seg.punchIn = true;
791
+ });
792
+ }
793
+
794
+ const sameSpeaker = (prev, seg) => Boolean(prev.lastSpeaker && seg.firstSpeaker === prev.lastSpeaker);
795
+
796
+ /**
797
+ * The pacings whose edit punches in, each with when a scene plays on in the
798
+ * shot of the one before it. Jump cuts film every scene in one framing. A
799
+ * montage changes shot on every cut, except where the line cut split a long
800
+ * line into pieces that keep their parent's base shot (#3317, #3323).
801
+ */
802
+ const PUNCH_IN_RUNS = new Map([
803
+ ["jump-cuts", sameSpeaker],
804
+ ["montage", (prev, seg) =>
805
+ sameSpeaker(prev, seg) && Boolean(seg.baseShotId) && seg.baseShotId === prev.baseShotId],
806
+ ]);
807
+
808
+ /**
809
+ * #3365: the scenes the cut cannot be made without, by number. A scene the
810
+ * storyboard plans needs a clip unless --skip names it, even one the pull has
811
+ * no row for. A scene it does not plan is a row a re-board left behind, so it
812
+ * is no loss. With no storyboard, every scene the pull lists counts.
813
+ */
814
+ export function scenesWithNoClip({ manifest, storyboard, skip, exists = Boolean }) {
815
+ const planned = [...scenePlans(storyboard).keys()];
816
+ const listed = (manifest?.scenes ?? []).map((scene) => scene.sceneIndex);
817
+ const clipped = new Set(
818
+ (manifest?.scenes ?? []).filter((scene) => exists(scene.clip)).map((scene) => scene.sceneIndex),
819
+ );
820
+ return [...new Set(planned.length > 0 ? planned : listed)]
821
+ .filter((n) => !skip.has(n) && !clipped.has(n))
822
+ .sort((a, b) => a - b);
823
+ }
824
+
825
+ export function buildTimeline({
826
+ manifest, storyboard, skip, probe, exists, readWords, readLevels = () => null, stillFill = false,
827
+ }) {
580
828
  const plans = scenePlans(storyboard);
581
829
  const counts = lineWordCounts(storyboard);
582
830
  const spine = [];
583
831
  const cutaways = [];
584
832
  const waiting = [];
585
833
  const dropped = [];
834
+ const missing = new Set(scenesWithNoClip({ manifest, storyboard, skip, exists }));
835
+ const filled = new Set();
836
+ // A narrated segment is first fitted as a join; the one that ends the ad is
837
+ // refitted once the spine is known.
838
+ const refits = new Map();
586
839
 
587
840
  for (const scene of manifest?.scenes ?? []) {
588
841
  const n = scene.sceneIndex;
@@ -604,12 +857,13 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
604
857
  sceneIndex: n, source: "clip", file: scene.clip,
605
858
  seconds: clip.seconds, hasAudio: clip.hasAudio, ...cue,
606
859
  });
607
- } else if (exists(scene.keyframe)) {
860
+ } else if (stillFill && exists(scene.keyframe)) {
861
+ filled.add(n);
608
862
  waiting.push({
609
863
  sceneIndex: n, source: "still", file: scene.keyframe,
610
864
  seconds: plan.durationSec ?? 4, hasAudio: false, ...cue,
611
865
  });
612
- } else {
866
+ } else if (!missing.has(n)) {
613
867
  dropped.push({ n, reason: `cutaway ${n}: ${nothing}` });
614
868
  }
615
869
  continue;
@@ -624,20 +878,29 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
624
878
  const base = {
625
879
  sceneIndex: n, source: "clip", file: scene.clip, clipSeconds: clip.seconds, inSec: 0,
626
880
  lineIds: plan.lineIds, firstSpeaker: plan.firstSpeaker, lastSpeaker: plan.lastSpeaker,
627
- actionTailSec: plan.actionTailSec ?? 0,
881
+ baseShotId: plan.baseShotId ?? null, actionTailSec: plan.actionTailSec ?? 0,
628
882
  };
629
883
  if (voice && (narrated || !clip.hasAudio)) {
630
- const fit = fitNarration({
631
- clipSeconds: clip.seconds, voiceSeconds: probe(voice).seconds, actionTailSec: plan.actionTailSec ?? 0,
632
- });
633
- const how = [];
634
- if (fit.trimmedBy > 0.05) how.push(`clip trimmed ${clip.seconds.toFixed(1)}s -> ${fit.seconds.toFixed(1)}s`);
635
- if (fit.holdSec > 0.05) how.push(`last frame held ${fit.holdSec.toFixed(1)}s`);
636
- spine.push({
637
- ...base, audio: "narration", voice, seconds: fit.seconds,
638
- holdSec: fit.holdSec, words: readWords(scene.words),
639
- note: `${flag}, ${voice} replaces the clip's audio${how.length ? ` (${how.join(", ")})` : ""}`,
640
- });
884
+ const voiceSeconds = probe(voice).seconds;
885
+ const words = readWords(scene.words);
886
+ // A clip's own word times describe the clip, not the voice that replaces its audio.
887
+ const voiceWords = scene.wordsFrom === "voice" ? words : null;
888
+ const fitted = (endsAd) => {
889
+ const fit = fitNarration({
890
+ clipSeconds: clip.seconds, ...narrationEnd(voiceSeconds, voiceWords, endsAd),
891
+ actionTailSec: plan.actionTailSec ?? 0,
892
+ });
893
+ const how = [];
894
+ if (fit.trimmedBy > 0.05) how.push(`clip trimmed ${clip.seconds.toFixed(1)}s -> ${fit.seconds.toFixed(1)}s`);
895
+ if (fit.holdSec > 0.05) how.push(`last frame held ${fit.holdSec.toFixed(1)}s`);
896
+ return {
897
+ seconds: fit.seconds, holdSec: fit.holdSec,
898
+ note: `${flag}, ${voice} replaces the clip's audio${how.length ? ` (${how.join(", ")})` : ""}`,
899
+ };
900
+ };
901
+ const seg = { ...base, audio: "narration", voice, words, ...fitted(false) };
902
+ spine.push(seg);
903
+ refits.set(seg, fitted);
641
904
  } else if (narrated) {
642
905
  spine.push({
643
906
  ...base, audio: "silence", voice: null, seconds: clip.seconds, holdSec: 0,
@@ -657,24 +920,50 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
657
920
  }
658
921
  continue;
659
922
  }
660
- if (exists(scene.keyframe)) {
661
- const fit = voice
662
- ? fitNarration({ clipSeconds: null, voiceSeconds: probe(voice).seconds })
663
- : { seconds: plan.durationSec ?? 4, holdSec: 0 };
664
- spine.push({
923
+ if (stillFill && exists(scene.keyframe)) {
924
+ filled.add(n);
925
+ const voiceSeconds = voice ? probe(voice).seconds : 0;
926
+ // #1689: on a narrated scene the words are the voice track's, and the
927
+ // voice starts where the segment does, so they need no offset here.
928
+ const words = voice ? readWords(scene.words) : null;
929
+ const voiceWords = scene.wordsFrom === "voice" ? words : null;
930
+ const fitted = (endsAd) => {
931
+ const seconds = voice
932
+ ? fitNarration({ clipSeconds: null, ...narrationEnd(voiceSeconds, voiceWords, endsAd) }).seconds
933
+ : plan.durationSec ?? 4;
934
+ return {
935
+ seconds,
936
+ note: `${flag}${voice ? ` + ${voice}` : ", silent"} (${seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
937
+ };
938
+ };
939
+ const seg = {
665
940
  sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null, inSec: 0,
666
- seconds: fit.seconds, audio: voice ? "narration" : "silence", voice, holdSec: 0,
667
- lineIds: plan.lineIds,
668
- // #1689: on a narrated scene the words are the voice track's, and the
669
- // voice starts where the segment does, so they need no offset here.
670
- words: voice ? readWords(scene.words) : null,
671
- note: `${flag}${voice ? ` + ${voice}` : ", silent"} (${fit.seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
672
- });
941
+ audio: voice ? "narration" : "silence", voice, holdSec: 0,
942
+ lineIds: plan.lineIds, firstSpeaker: plan.firstSpeaker, lastSpeaker: plan.lastSpeaker,
943
+ baseShotId: plan.baseShotId ?? null, words, ...fitted(false),
944
+ };
945
+ spine.push(seg);
946
+ if (voice) refits.set(seg, fitted);
673
947
  continue;
674
948
  }
675
- dropped.push({ n, reason: `scene ${n}: ${nothing}` });
949
+ if (!missing.has(n)) dropped.push({ n, reason: `scene ${n}: ${nothing}` });
950
+ }
951
+ const unfilled = [...missing].filter((n) => !filled.has(n));
952
+ if (unfilled.length > 0) {
953
+ const listed = new Map((manifest?.scenes ?? []).map((scene) => [scene.sceneIndex, scene]));
954
+ throw new FirstCutError(1, noClipRefusal(unfilled.map((n) => {
955
+ const scene = listed.get(n);
956
+ return {
957
+ n,
958
+ keyframe: Boolean(scene) && exists(scene.keyframe),
959
+ status: scene ? `clip ${scene.clipStatus}${scene.error ? `: ${scene.error}` : ""}` : "clip missing",
960
+ };
961
+ })));
676
962
  }
677
963
 
964
+ const sameShot = PUNCH_IN_RUNS.get(manifest?.shots?.pacing);
965
+ if (sameShot) markPunchIns(spine, sameShot);
966
+
678
967
  const noCue = (c) =>
679
968
  c.cueLineId ? `${c.cueLineId} is spoken by no scene in the cut` : "the storyboard names no cue line";
680
969
 
@@ -683,6 +972,8 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
683
972
  // spine, so they go in before any overlay is placed on it.
684
973
  const scriptLines = new Map((storyboard?.script ?? []).map((line) => [line?.lineId, line]));
685
974
  const reactionCues = new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId));
975
+ const last = spine.at(-1);
976
+ if (refits.has(last) && !reactionCues.has(last.lineIds.at(-1))) Object.assign(last, refits.get(last)(true));
686
977
  tightenSentenceGaps(spine, reactionCues, scriptLines, readLevels);
687
978
  trimJoins(spine, reactionCues, scriptLines);
688
979
  for (const c of waiting.filter((w) => w.reaction)) {
@@ -738,6 +1029,20 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
738
1029
  };
739
1030
  }
740
1031
 
1032
+ /** #3365: why the cut stopped on scenes with no clip, and the person's three ways on. */
1033
+ function noClipRefusal(missing) {
1034
+ const scenes = missing.map((m) => `scene ${m.n} (${m.status})`).join(", ");
1035
+ const skip = `--skip ${missing.map((m) => m.n).join(",")}`;
1036
+ const bare = missing.filter((m) => !m.keyframe).map((m) => m.n);
1037
+ const ways = bare.length === missing.length
1038
+ ? `Wait for the clips and pull again, or leave them out with ${skip}.`
1039
+ : `Wait for the clips and pull again, leave them out with ${skip}, ` +
1040
+ "or hold each one's keyframe on screen instead with --still-fill (under its voice track if it has one, " +
1041
+ "else silent for its planned length)." +
1042
+ (bare.length > 0 ? ` --still-fill cannot cover scene ${bare.join(", ")}, which has no keyframe either.` : "");
1043
+ return `No clip for ${scenes}. ${ways}`;
1044
+ }
1045
+
741
1046
  function sec(n) {
742
1047
  return (Number.isFinite(n) ? Math.max(0, n) : 0).toFixed(3);
743
1048
  }
@@ -789,6 +1094,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
789
1094
  }
790
1095
  }
791
1096
  if (seg.holdSec > 0.01) fit.push(`tpad=stop_mode=clone:stop_duration=${sec(seg.holdSec)}`);
1097
+ if (seg.punchIn) fit.push(PUNCH_IN);
792
1098
  filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
793
1099
  // A segment split around a reaction beat starts partway into its sound too.
794
1100
  const skipIn = inSec > 0.001 ? `atrim=start=${sec(inSec)},asetpts=PTS-STARTPTS,` : "";
@@ -871,8 +1177,36 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
871
1177
  ];
872
1178
  }
873
1179
 
1180
+ const roundMs = (n) => Math.round(n * 1000) / 1000;
1181
+
1182
+ /**
1183
+ * #3300: every stretch of the cut where a scene is the picture, in order. A
1184
+ * cutaway covers the whole frame, so the spine scene under it is off screen
1185
+ * there and its placement splits around it. A scene split around a reaction
1186
+ * beat, or shown twice, has one entry per stretch.
1187
+ */
1188
+ export function sceneTimes(timeline) {
1189
+ const covers = timeline.cutaways
1190
+ .filter((c) => c.seconds > 0)
1191
+ .map((c) => ({ from: c.startSec, to: c.startSec + c.seconds }));
1192
+ const visible = timeline.spine.flatMap((seg) => {
1193
+ let pieces = [{ from: seg.startSec, to: seg.startSec + seg.seconds }];
1194
+ for (const c of covers) {
1195
+ pieces = pieces.flatMap((p) =>
1196
+ c.to <= p.from || c.from >= p.to
1197
+ ? [p]
1198
+ : [{ from: p.from, to: c.from }, { from: c.to, to: p.to }].filter((q) => q.to > q.from),
1199
+ );
1200
+ }
1201
+ return pieces.map((p) => ({ sceneIndex: seg.sceneIndex, startSec: p.from, seconds: p.to - p.from }));
1202
+ });
1203
+ return [...visible, ...timeline.cutaways]
1204
+ .map((p) => ({ scene: p.sceneIndex, startSec: roundMs(p.startSec), seconds: roundMs(p.seconds) }))
1205
+ .filter((p) => p.seconds > 0)
1206
+ .sort((a, b) => a.startSec - b.startSec);
1207
+ }
1208
+
874
1209
  export function wordRows(timeline) {
875
- const ms = (n) => Math.round(n * 1000) / 1000;
876
1210
  const rows = [];
877
1211
  for (const seg of timeline.spine) {
878
1212
  for (const w of seg.words ?? []) {
@@ -880,7 +1214,7 @@ export function wordRows(timeline) {
880
1214
  const start = seg.startSec + w.s;
881
1215
  const end = seg.startSec + w.e;
882
1216
  if (text && end > start) {
883
- rows.push({ text, start: ms(start), end: ms(end), scene: seg.sceneIndex });
1217
+ rows.push({ text, start: roundMs(start), end: roundMs(end), scene: seg.sceneIndex });
884
1218
  }
885
1219
  }
886
1220
  }
@@ -931,7 +1265,11 @@ export function toSrt(rows) {
931
1265
  export function sidecarPaths(outPath) {
932
1266
  const dir = path.dirname(outPath);
933
1267
  const base = path.basename(outPath, path.extname(outPath));
934
- return { words: path.join(dir, `${base}.words.json`), srt: path.join(dir, `${base}.srt`) };
1268
+ return {
1269
+ words: path.join(dir, `${base}.words.json`),
1270
+ srt: path.join(dir, `${base}.srt`),
1271
+ scenes: path.join(dir, `${base}.scenes.json`),
1272
+ };
935
1273
  }
936
1274
 
937
1275
  export function inTheCut(timeline) {
@@ -1099,12 +1437,13 @@ export class FirstCutError extends Error {
1099
1437
  }
1100
1438
 
1101
1439
  function parseArgs(argv) {
1102
- const opts = { dir: undefined, out: undefined, skip: new Set(), music: true };
1440
+ const opts = { dir: undefined, out: undefined, skip: new Set(), music: true, stillFill: false };
1103
1441
  for (let i = 0; i < argv.length; i++) {
1104
1442
  const a = argv[i];
1105
1443
  if (a === "--out") opts.out = argv[++i];
1106
1444
  else if (a === "--skip") opts.skip = new Set((argv[++i] ?? "").split(",").filter(Boolean).map(Number));
1107
1445
  else if (a === "--no-music") opts.music = false;
1446
+ else if (a === "--still-fill") opts.stillFill = true;
1108
1447
  else if (a.startsWith("--")) fail(2, `unknown flag ${a}\n${USAGE}`);
1109
1448
  else if (opts.dir === undefined) opts.dir = a;
1110
1449
  else fail(2, `unexpected argument ${a}\n${USAGE}`);
@@ -1119,7 +1458,7 @@ function parseArgs(argv) {
1119
1458
  * sidecars, and throws FirstCutError where the CLI would exit. The CLI's report
1120
1459
  * is printed by main() from what this returns.
1121
1460
  */
1122
- export function firstCut({ dir, out: outOpt, skip = new Set(), music: musicOn = true }) {
1461
+ export function firstCut({ dir, out: outOpt, skip = new Set(), music: musicOn = true, stillFill = false }) {
1123
1462
  for (const bin of ["ffmpeg", "ffprobe"]) {
1124
1463
  if (spawnSync(bin, ["-version"]).status !== 0) {
1125
1464
  throw new FirstCutError(2, `${bin} is not installed. Install ffmpeg (brew install ffmpeg / apt install ffmpeg).`);
@@ -1169,6 +1508,7 @@ export function firstCut({ dir, out: outOpt, skip = new Set(), music: musicOn =
1169
1508
  manifest,
1170
1509
  storyboard,
1171
1510
  skip,
1511
+ stillFill,
1172
1512
  probe,
1173
1513
  exists,
1174
1514
  readWords: (file) => {
@@ -1202,6 +1542,7 @@ export function firstCut({ dir, out: outOpt, skip = new Set(), music: musicOn =
1202
1542
  rmSync(sidecars.words, { force: true });
1203
1543
  rmSync(sidecars.srt, { force: true });
1204
1544
  }
1545
+ writeFileSync(sidecars.scenes, `${JSON.stringify(sceneTimes(timeline), null, 2)}\n`);
1205
1546
 
1206
1547
  const duration = execFileSync("ffprobe", [
1207
1548
  "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", out,