@aicopycoders/exodus 2026.9.2000-beta.17 → 2026.9.2000-beta.19
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.
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
// the voice, trimmed when the clip runs longer and held on its last frame when
|
|
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
|
-
// and after the last is cut back to a short beat, read from its word times.
|
|
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,
|
|
20
|
+
// picture and sound together, unless the script writes that pause in. A
|
|
19
21
|
// scene with no clip but a keyframe becomes a still for the length of its voice
|
|
20
22
|
// track, else the storyboard's planned duration. Every segment is
|
|
21
23
|
// loudness-normalized to -16 LUFS before the join.
|
|
@@ -75,6 +77,8 @@ export const REACTION_TAIL_GAIN = 0.35;
|
|
|
75
77
|
export const JOIN_TAIL_SEC = 0.15;
|
|
76
78
|
export const JOIN_LEAD_SAME_SPEAKER_SEC = 0.1;
|
|
77
79
|
export const JOIN_LEAD_NEW_SPEAKER_SEC = 0.25;
|
|
80
|
+
/** The gap a cut keeps from an extra heard sound it leaves out (#2541). */
|
|
81
|
+
const EXTRA_CLEARANCE_SEC = 0.05;
|
|
78
82
|
|
|
79
83
|
/** The word rule the planner sized every line with (scout models.ts wordCount). */
|
|
80
84
|
export function wordCount(text) {
|
|
@@ -90,6 +94,130 @@ export function lineWordCounts(storyboard) {
|
|
|
90
94
|
return counts;
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
const wordToken = (word) => String(word ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* #2541: the indexes of the heard words that deliver the first and last
|
|
101
|
+
* scripted word, matching the script in order at its earliest delivery, or null
|
|
102
|
+
* when the whole script is not heard that way.
|
|
103
|
+
*/
|
|
104
|
+
export function scriptedSpan(words, lineTexts) {
|
|
105
|
+
const script = lineTexts.flatMap((text) => (text ?? "").split(/\s+/)).map(wordToken).filter(Boolean);
|
|
106
|
+
const at = heardAt(words, script);
|
|
107
|
+
return at && at.length > 0 ? { first: at[0], last: at.at(-1) } : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The heard word that delivers each script token, in order at its earliest delivery, or null. */
|
|
111
|
+
function heardAt(words, tokens) {
|
|
112
|
+
const at = [];
|
|
113
|
+
let j = 0;
|
|
114
|
+
for (const token of tokens) {
|
|
115
|
+
while (j < words.length && wordToken(words[j].w) !== token) j++;
|
|
116
|
+
if (j === words.length) return null;
|
|
117
|
+
at.push(j++);
|
|
118
|
+
}
|
|
119
|
+
return at;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** #2544: a silence between two sentences longer than this is cut down to the kept length. */
|
|
123
|
+
export const SENTENCE_GAP_MAX_SEC = 0.9;
|
|
124
|
+
export const SENTENCE_GAP_KEEP_SEC = 0.8;
|
|
125
|
+
/** A pause the script writes in: an ellipsis, a dash, or a hyphen standing between words. */
|
|
126
|
+
const WRITTEN_PAUSE = /…|\.{3}|[—–-]/;
|
|
127
|
+
const SENTENCE_END = /[.?!]/;
|
|
128
|
+
/** The directions that ask for a pause (scout clip-qc.ts PAUSE_DIRECTION, NO_PAUSE_DIRECTION, lineWrittenPause). */
|
|
129
|
+
const PAUSE_DIRECTION =
|
|
130
|
+
/\bpaus(e|es|ed|ing)\b|\bsilence\b|\(beat\)|\b(a|one|brief|short|quick|small|little|long)\s+beat\b|\bbeat\s*(,|before|after|then)/i;
|
|
131
|
+
const NO_PAUSE_DIRECTION =
|
|
132
|
+
/\b(no|not|without|never|don'?t|avoid\w*)\b[^.;]*\b(beats?|paus\w*|silence)\b|\bpause-?(free|less)\b/i;
|
|
133
|
+
|
|
134
|
+
export function asksPause(line) {
|
|
135
|
+
const direction = `${line?.stageDirection ?? ""} ${line?.performanceDirection ?? ""}`;
|
|
136
|
+
return PAUSE_DIRECTION.test(direction) && !NO_PAUSE_DIRECTION.test(direction);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* #2544: the silences inside one clip to cut, as [{ s, e, after, gap }] in the
|
|
141
|
+
* clip's own time. A heard silence over SENTENCE_GAP_MAX_SEC between two
|
|
142
|
+
* scripted words at a sentence break (a new line, or . ? ! between them) keeps
|
|
143
|
+
* half of SENTENCE_GAP_KEEP_SEC after the earlier word and half before the
|
|
144
|
+
* later one; the rest goes. Kept: a pause the script writes in (a mark between
|
|
145
|
+
* the words, or a direction on either line asking for one), the laugh beat
|
|
146
|
+
* after a line a reaction is cued to, and any gap with another heard sound in
|
|
147
|
+
* it. Nothing is cut when the script is not heard in order.
|
|
148
|
+
*
|
|
149
|
+
* lines: [{ text, asksPause, cuesBeat }] in the order the clip speaks them.
|
|
150
|
+
*/
|
|
151
|
+
export function sentenceGapCuts(words, lines) {
|
|
152
|
+
const tokens = [];
|
|
153
|
+
let between = "";
|
|
154
|
+
lines.forEach((line, li) => {
|
|
155
|
+
for (const piece of (line.text ?? "").split(/\s+/)) {
|
|
156
|
+
const token = wordToken(piece);
|
|
157
|
+
if (!token) {
|
|
158
|
+
between += ` ${piece}`;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const [, lead, trail] = piece.match(/^([^\p{L}\p{N}]*).*?([^\p{L}\p{N}]*)$/u);
|
|
162
|
+
tokens.push({ token, li, before: between + lead });
|
|
163
|
+
between = trail;
|
|
164
|
+
}
|
|
165
|
+
between += " ";
|
|
166
|
+
});
|
|
167
|
+
const at = heardAt(words ?? [], tokens.map((t) => t.token));
|
|
168
|
+
if (!at) return [];
|
|
169
|
+
const cuts = [];
|
|
170
|
+
for (let t = 0; t + 1 < tokens.length; t++) {
|
|
171
|
+
const [a, b] = [tokens[t], tokens[t + 1]];
|
|
172
|
+
if (at[t + 1] !== at[t] + 1) continue;
|
|
173
|
+
const newLine = a.li !== b.li;
|
|
174
|
+
if (!newLine && !SENTENCE_END.test(b.before)) continue;
|
|
175
|
+
if (WRITTEN_PAUSE.test(b.before) || lines[a.li].asksPause || lines[b.li].asksPause) continue;
|
|
176
|
+
if (newLine && lines[a.li].cuesBeat) continue;
|
|
177
|
+
const [earlier, later] = [words[at[t]], words[at[t + 1]]];
|
|
178
|
+
const gap = later.s - earlier.e;
|
|
179
|
+
if (gap <= SENTENCE_GAP_MAX_SEC + 1e-6) continue;
|
|
180
|
+
// Frame edges only ever widen what is kept, so the silence left stays
|
|
181
|
+
// between the kept length and the longest allowed.
|
|
182
|
+
const s = ceilFrame(earlier.e + SENTENCE_GAP_KEEP_SEC / 2);
|
|
183
|
+
const e = floorFrame(later.s - SENTENCE_GAP_KEEP_SEC / 2);
|
|
184
|
+
if (e > s) cuts.push({ s, e, after: earlier.w, gap });
|
|
185
|
+
}
|
|
186
|
+
return cuts;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Take each own-sound clip's long sentence gaps out of its timeline: its words
|
|
191
|
+
* move onto the cut timeline and it runs shorter, so every later step (join
|
|
192
|
+
* trims, reaction splits, cue times, captions) works in cut time. The renderer
|
|
193
|
+
* removes seg.cuts from the picture and the sound before anything else.
|
|
194
|
+
*/
|
|
195
|
+
function tightenSentenceGaps(spine, reactionCues, scriptLines) {
|
|
196
|
+
for (const seg of spine) {
|
|
197
|
+
if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) continue;
|
|
198
|
+
const lines = seg.lineIds.map((id) => ({
|
|
199
|
+
text: scriptLines.get(id)?.text,
|
|
200
|
+
asksPause: asksPause(scriptLines.get(id)),
|
|
201
|
+
cuesBeat: reactionCues.has(id),
|
|
202
|
+
}));
|
|
203
|
+
const cuts = sentenceGapCuts(seg.words, lines);
|
|
204
|
+
if (cuts.length === 0) continue;
|
|
205
|
+
const removedBefore = (t) => cuts.reduce((sum, c) => sum + (c.e <= t ? c.e - c.s : 0), 0);
|
|
206
|
+
const removed = removedBefore(Infinity);
|
|
207
|
+
seg.cuts = cuts.map(({ s, e }) => ({ s, e }));
|
|
208
|
+
seg.words = seg.words.map((w) => ({ ...w, s: w.s - removedBefore(w.s), e: w.e - removedBefore(w.s) }));
|
|
209
|
+
seg.seconds -= removed;
|
|
210
|
+
seg.clipSeconds -= removed;
|
|
211
|
+
seg.note = withHow(
|
|
212
|
+
seg.note,
|
|
213
|
+
cuts.map((c) => `pause after "${c.after}" tightened ${c.gap.toFixed(2)}s -> ${(c.gap - (c.e - c.s)).toFixed(2)}s`),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const withHow = (note, how) =>
|
|
219
|
+
note.endsWith(")") ? `${note.slice(0, -1)}, ${how.join(", ")})` : `${note} (${how.join(", ")})`;
|
|
220
|
+
|
|
93
221
|
const CUTAWAY_KINDS = new Set(["cutaway", "insert"]);
|
|
94
222
|
|
|
95
223
|
const hasText = (value) => typeof value === "string" && value.trim().length > 0;
|
|
@@ -199,27 +327,49 @@ export function cueEndInSegment(seg, counts, cueLineId) {
|
|
|
199
327
|
* and leaves the ad's first lead-in and last tail alone; a clip whose closing
|
|
200
328
|
* line cues a reaction is not the ad's end, since the beat follows it. Trims
|
|
201
329
|
* land on whole frames so the picture and its sound stay the same length.
|
|
330
|
+
*
|
|
331
|
+
* The beat is measured from the clip's scripted words when the script is heard
|
|
332
|
+
* in order, and a cut stops short of a sound made up before or after the line
|
|
333
|
+
* ("Hmm", "Um"), even at the ad's own edges (#2541). Sounds inside the line stay.
|
|
202
334
|
*/
|
|
203
|
-
function trimJoins(spine, reactionCues) {
|
|
335
|
+
function trimJoins(spine, reactionCues, scriptLines) {
|
|
204
336
|
spine.forEach((seg, k) => {
|
|
205
337
|
if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) return;
|
|
206
338
|
const prev = spine[k - 1];
|
|
207
339
|
const sameSpeaker = Boolean(prev?.lastSpeaker) && prev.lastSpeaker === seg.firstSpeaker;
|
|
208
340
|
const lead = sameSpeaker ? JOIN_LEAD_SAME_SPEAKER_SEC : JOIN_LEAD_NEW_SPEAKER_SEC;
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
const
|
|
341
|
+
const span = scriptedSpan(seg.words, seg.lineIds.map((id) => scriptLines.get(id)?.text));
|
|
342
|
+
const line = span ? seg.words.slice(span.first, span.last + 1) : seg.words;
|
|
343
|
+
const before = span ? seg.words.slice(0, span.first) : [];
|
|
344
|
+
const after = span ? seg.words.slice(span.last + 1) : [];
|
|
345
|
+
const firstWord = Math.min(...line.map((w) => w.s));
|
|
346
|
+
const lastWord = Math.max(...line.map((w) => w.e));
|
|
347
|
+
let inSec = prev ? Math.max(0, floorFrame(firstWord - lead)) : 0;
|
|
348
|
+
if (before.length > 0) {
|
|
349
|
+
const clear = ceilFrame(Math.max(...before.map((w) => w.e)) + EXTRA_CLEARANCE_SEC);
|
|
350
|
+
inSec = Math.max(inSec, Math.min(clear, floorFrame(firstWord)));
|
|
351
|
+
}
|
|
212
352
|
const endsAd = k === spine.length - 1 && !reactionCues.has(seg.lineIds.at(-1));
|
|
213
|
-
|
|
353
|
+
let outSec = endsAd
|
|
354
|
+
? after.length > 0 ? ceilFrame(lastWord + TAIL_AIR_SEC) : seg.seconds
|
|
355
|
+
: ceilFrame(lastWord + JOIN_TAIL_SEC);
|
|
356
|
+
if (after.length > 0) {
|
|
357
|
+
const clear = floorFrame(Math.min(...after.map((w) => w.s)) - EXTRA_CLEARANCE_SEC);
|
|
358
|
+
outSec = Math.min(outSec, Math.max(clear, ceilFrame(lastWord)));
|
|
359
|
+
}
|
|
360
|
+
outSec = Math.min(seg.seconds, outSec);
|
|
214
361
|
if (outSec <= inSec) return;
|
|
215
362
|
const how = [];
|
|
216
363
|
if (inSec > 0) how.push(`lead-in trimmed ${inSec.toFixed(2)}s`);
|
|
217
364
|
if (outSec < seg.seconds) how.push(`tail trimmed ${(seg.seconds - outSec).toFixed(2)}s`);
|
|
218
365
|
if (how.length === 0) return;
|
|
366
|
+
const kept = seg.words.filter((w) => w.s < outSec && w.e > inSec);
|
|
367
|
+
if (before.some((w) => !kept.includes(w))) how.push("extra sound before the line trimmed");
|
|
368
|
+
if (after.some((w) => !kept.includes(w))) how.push("extra sound after the line trimmed");
|
|
219
369
|
seg.inSec = inSec;
|
|
220
370
|
seg.seconds = outSec - inSec;
|
|
221
|
-
seg.words =
|
|
222
|
-
seg.note =
|
|
371
|
+
seg.words = kept.map((w) => ({ ...w, s: w.s - inSec, e: w.e - inSec }));
|
|
372
|
+
seg.note = withHow(seg.note, how);
|
|
223
373
|
});
|
|
224
374
|
}
|
|
225
375
|
|
|
@@ -382,10 +532,13 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
382
532
|
const noCue = (c) =>
|
|
383
533
|
c.cueLineId ? `${c.cueLineId} is spoken by no scene in the cut` : "the storyboard names no cue line";
|
|
384
534
|
|
|
385
|
-
//
|
|
386
|
-
// ends in the trimmed clip. Reactions change the
|
|
387
|
-
// any overlay is placed on it.
|
|
388
|
-
|
|
535
|
+
// Sentence gaps and then joins are closed first so a reaction beat lands
|
|
536
|
+
// right after its cue line ends in the trimmed clip. Reactions change the
|
|
537
|
+
// spine, so they go in before any overlay is placed on it.
|
|
538
|
+
const scriptLines = new Map((storyboard?.script ?? []).map((line) => [line?.lineId, line]));
|
|
539
|
+
const reactionCues = new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId));
|
|
540
|
+
tightenSentenceGaps(spine, reactionCues, scriptLines);
|
|
541
|
+
trimJoins(spine, reactionCues, scriptLines);
|
|
389
542
|
for (const c of waiting.filter((w) => w.reaction)) {
|
|
390
543
|
if (!c.cueLineId || placeReaction(spine, counts, c) === null) {
|
|
391
544
|
dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
|
|
@@ -456,6 +609,25 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
456
609
|
: addInput("-loop", "1", "-t", String(seg.seconds), "-i", resolve(seg.file));
|
|
457
610
|
const fit = [];
|
|
458
611
|
const inSec = seg.inSec ?? 0;
|
|
612
|
+
// #2544: sentence gaps come out first, the picture by frame number and the
|
|
613
|
+
// sound by the same times, so everything after works on the cut timeline.
|
|
614
|
+
const cuts = seg.cuts ?? [];
|
|
615
|
+
let heard = `[${i}:a]${stereo},`;
|
|
616
|
+
if (cuts.length > 0) {
|
|
617
|
+
const frames = cuts.map((c) => `between(n\\,${Math.round(c.s * FPS)}\\,${Math.round(c.e * FPS) - 1})`);
|
|
618
|
+
fit.push(`select=not(${frames.join("+")}),setpts=N/FRAME_RATE/TB`);
|
|
619
|
+
const kept = [0, ...cuts.flatMap((c) => [c.s, c.e])];
|
|
620
|
+
const pieces = cuts.length + 1;
|
|
621
|
+
filters.push(`[${i}:a]${stereo},asplit=${pieces}${Array.from({ length: pieces }, (_, p) => `[ap${i}_${p}]`).join("")}`);
|
|
622
|
+
for (let p = 0; p < pieces; p++) {
|
|
623
|
+
const end = p < cuts.length ? `:end=${kept[2 * p + 1].toFixed(6)}` : "";
|
|
624
|
+
filters.push(`[ap${i}_${p}]atrim=start=${kept[2 * p].toFixed(6)}${end},asetpts=PTS-STARTPTS[aq${i}_${p}]`);
|
|
625
|
+
}
|
|
626
|
+
filters.push(
|
|
627
|
+
`${Array.from({ length: pieces }, (_, p) => `[aq${i}_${p}]`).join("")}concat=n=${pieces}:v=0:a=1[ac${i}]`,
|
|
628
|
+
);
|
|
629
|
+
heard = `[ac${i}]`;
|
|
630
|
+
}
|
|
459
631
|
if (seg.source === "clip" && seg.clipSeconds !== null) {
|
|
460
632
|
const shown = seg.seconds - seg.holdSec;
|
|
461
633
|
if (inSec > 0.001 || shown < seg.clipSeconds - 0.01) {
|
|
@@ -481,7 +653,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
481
653
|
// ducked, faded out, and laid in at the join; the next line's own sound
|
|
482
654
|
// plays at full level over it (#2406).
|
|
483
655
|
const t = seg.reaction.tail;
|
|
484
|
-
filters.push(
|
|
656
|
+
filters.push(`${heard}${skipIn}${LOUDNORM},asplit=2[as${i}][at${i}]`);
|
|
485
657
|
filters.push(`[as${i}]${fitLength}[a${i}]`);
|
|
486
658
|
filters.push(
|
|
487
659
|
`[at${i}]atrim=start=${sec(t.fromSec - inSec)}:duration=${sec(t.seconds)},asetpts=PTS-STARTPTS,` +
|
|
@@ -490,7 +662,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
490
662
|
);
|
|
491
663
|
tails.push(`[tail${i}]`);
|
|
492
664
|
} else if (seg.audio === "clip") {
|
|
493
|
-
filters.push(
|
|
665
|
+
filters.push(`${heard}${skipIn}${fitSound}[a${i}]`);
|
|
494
666
|
} else {
|
|
495
667
|
filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${sec(seg.seconds)}[a${i}]`);
|
|
496
668
|
}
|