@aicopycoders/exodus 2026.9.2000-beta.18 → 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.
|
|
@@ -101,18 +103,121 @@ const wordToken = (word) => String(word ?? "").toLowerCase().replace(/[^\p{L}\p{
|
|
|
101
103
|
*/
|
|
102
104
|
export function scriptedSpan(words, lineTexts) {
|
|
103
105
|
const script = lineTexts.flatMap((text) => (text ?? "").split(/\s+/)).map(wordToken).filter(Boolean);
|
|
104
|
-
|
|
105
|
-
|
|
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 = [];
|
|
106
113
|
let j = 0;
|
|
107
|
-
for (const token of
|
|
114
|
+
for (const token of tokens) {
|
|
108
115
|
while (j < words.length && wordToken(words[j].w) !== token) j++;
|
|
109
116
|
if (j === words.length) return null;
|
|
110
|
-
|
|
111
|
-
|
|
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 });
|
|
112
185
|
}
|
|
113
|
-
return
|
|
186
|
+
return cuts;
|
|
114
187
|
}
|
|
115
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
|
+
|
|
116
221
|
const CUTAWAY_KINDS = new Set(["cutaway", "insert"]);
|
|
117
222
|
|
|
118
223
|
const hasText = (value) => typeof value === "string" && value.trim().length > 0;
|
|
@@ -227,13 +332,13 @@ export function cueEndInSegment(seg, counts, cueLineId) {
|
|
|
227
332
|
* in order, and a cut stops short of a sound made up before or after the line
|
|
228
333
|
* ("Hmm", "Um"), even at the ad's own edges (#2541). Sounds inside the line stay.
|
|
229
334
|
*/
|
|
230
|
-
function trimJoins(spine, reactionCues,
|
|
335
|
+
function trimJoins(spine, reactionCues, scriptLines) {
|
|
231
336
|
spine.forEach((seg, k) => {
|
|
232
337
|
if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) return;
|
|
233
338
|
const prev = spine[k - 1];
|
|
234
339
|
const sameSpeaker = Boolean(prev?.lastSpeaker) && prev.lastSpeaker === seg.firstSpeaker;
|
|
235
340
|
const lead = sameSpeaker ? JOIN_LEAD_SAME_SPEAKER_SEC : JOIN_LEAD_NEW_SPEAKER_SEC;
|
|
236
|
-
const span = scriptedSpan(seg.words, seg.lineIds.map((id) =>
|
|
341
|
+
const span = scriptedSpan(seg.words, seg.lineIds.map((id) => scriptLines.get(id)?.text));
|
|
237
342
|
const line = span ? seg.words.slice(span.first, span.last + 1) : seg.words;
|
|
238
343
|
const before = span ? seg.words.slice(0, span.first) : [];
|
|
239
344
|
const after = span ? seg.words.slice(span.last + 1) : [];
|
|
@@ -264,7 +369,7 @@ function trimJoins(spine, reactionCues, lineTexts) {
|
|
|
264
369
|
seg.inSec = inSec;
|
|
265
370
|
seg.seconds = outSec - inSec;
|
|
266
371
|
seg.words = kept.map((w) => ({ ...w, s: w.s - inSec, e: w.e - inSec }));
|
|
267
|
-
seg.note =
|
|
372
|
+
seg.note = withHow(seg.note, how);
|
|
268
373
|
});
|
|
269
374
|
}
|
|
270
375
|
|
|
@@ -427,11 +532,13 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
427
532
|
const noCue = (c) =>
|
|
428
533
|
c.cueLineId ? `${c.cueLineId} is spoken by no scene in the cut` : "the storyboard names no cue line";
|
|
429
534
|
|
|
430
|
-
//
|
|
431
|
-
// ends in the trimmed clip. Reactions change the
|
|
432
|
-
// any overlay is placed on it.
|
|
433
|
-
const
|
|
434
|
-
|
|
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);
|
|
435
542
|
for (const c of waiting.filter((w) => w.reaction)) {
|
|
436
543
|
if (!c.cueLineId || placeReaction(spine, counts, c) === null) {
|
|
437
544
|
dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
|
|
@@ -502,6 +609,25 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
502
609
|
: addInput("-loop", "1", "-t", String(seg.seconds), "-i", resolve(seg.file));
|
|
503
610
|
const fit = [];
|
|
504
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
|
+
}
|
|
505
631
|
if (seg.source === "clip" && seg.clipSeconds !== null) {
|
|
506
632
|
const shown = seg.seconds - seg.holdSec;
|
|
507
633
|
if (inSec > 0.001 || shown < seg.clipSeconds - 0.01) {
|
|
@@ -527,7 +653,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
527
653
|
// ducked, faded out, and laid in at the join; the next line's own sound
|
|
528
654
|
// plays at full level over it (#2406).
|
|
529
655
|
const t = seg.reaction.tail;
|
|
530
|
-
filters.push(
|
|
656
|
+
filters.push(`${heard}${skipIn}${LOUDNORM},asplit=2[as${i}][at${i}]`);
|
|
531
657
|
filters.push(`[as${i}]${fitLength}[a${i}]`);
|
|
532
658
|
filters.push(
|
|
533
659
|
`[at${i}]atrim=start=${sec(t.fromSec - inSec)}:duration=${sec(t.seconds)},asetpts=PTS-STARTPTS,` +
|
|
@@ -536,7 +662,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
536
662
|
);
|
|
537
663
|
tails.push(`[tail${i}]`);
|
|
538
664
|
} else if (seg.audio === "clip") {
|
|
539
|
-
filters.push(
|
|
665
|
+
filters.push(`${heard}${skipIn}${fitSound}[a${i}]`);
|
|
540
666
|
} else {
|
|
541
667
|
filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${sec(seg.seconds)}[a${i}]`);
|
|
542
668
|
}
|