@aicopycoders/exodus 2026.9.2000-beta.19 → 2026.9.2000-beta.20
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.
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
// and after the last is cut back to a short beat, read from its word times.
|
|
19
19
|
// Inside it, a silence over 0.9 s between two sentences is cut down to 0.8 s,
|
|
20
20
|
// picture and sound together, unless the script writes that pause in. A
|
|
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
|
|
21
23
|
// scene with no clip but a keyframe becomes a still for the length of its voice
|
|
22
24
|
// track, else the storyboard's planned duration. Every segment is
|
|
23
25
|
// loudness-normalized to -16 LUFS before the join.
|
|
@@ -125,6 +127,14 @@ export const SENTENCE_GAP_KEEP_SEC = 0.8;
|
|
|
125
127
|
/** A pause the script writes in: an ellipsis, a dash, or a hyphen standing between words. */
|
|
126
128
|
const WRITTEN_PAUSE = /…|\.{3}|[—–-]/;
|
|
127
129
|
const SENTENCE_END = /[.?!]/;
|
|
130
|
+
/**
|
|
131
|
+
* #2554: loudness windows, and the levels that read as a sound and as quiet;
|
|
132
|
+
* SETTLED_WINDOWS quiet windows in a row (200 ms) end a sound (scout trim.ts).
|
|
133
|
+
*/
|
|
134
|
+
const LEVEL_WINDOW_SEC = 0.02;
|
|
135
|
+
const SOUND_DB = -35;
|
|
136
|
+
const QUIET_DB = -50;
|
|
137
|
+
const SETTLED_WINDOWS = 10;
|
|
128
138
|
/** The directions that ask for a pause (scout clip-qc.ts PAUSE_DIRECTION, NO_PAUSE_DIRECTION, lineWrittenPause). */
|
|
129
139
|
const PAUSE_DIRECTION =
|
|
130
140
|
/\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;
|
|
@@ -146,9 +156,14 @@ export function asksPause(line) {
|
|
|
146
156
|
* after a line a reaction is cued to, and any gap with another heard sound in
|
|
147
157
|
* it. Nothing is cut when the script is not heard in order.
|
|
148
158
|
*
|
|
159
|
+
* #2554: given the clip's loudness ({ windowSec, db } from 0), a sound in a
|
|
160
|
+
* break that holds no heard word is cut too, whatever the break's length, and
|
|
161
|
+
* the silence left follows the same rule. Every span of one break carries that
|
|
162
|
+
* break's gap, the silence it keeps, and whether a sound went.
|
|
163
|
+
*
|
|
149
164
|
* lines: [{ text, asksPause, cuesBeat }] in the order the clip speaks them.
|
|
150
165
|
*/
|
|
151
|
-
export function sentenceGapCuts(words, lines) {
|
|
166
|
+
export function sentenceGapCuts(words, lines, levels = null) {
|
|
152
167
|
const tokens = [];
|
|
153
168
|
let between = "";
|
|
154
169
|
lines.forEach((line, li) => {
|
|
@@ -176,23 +191,98 @@ export function sentenceGapCuts(words, lines) {
|
|
|
176
191
|
if (newLine && lines[a.li].cuesBeat) continue;
|
|
177
192
|
const [earlier, later] = [words[at[t]], words[at[t + 1]]];
|
|
178
193
|
const gap = later.s - earlier.e;
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
if (
|
|
194
|
+
const heard = levels && soundBetween(levels, earlier.e, later.s);
|
|
195
|
+
// A sound's frame edges widen the cut, so none of it is left in.
|
|
196
|
+
const sound = heard && { s: Math.max(floorFrame(heard.s), ceilFrame(earlier.e)), e: ceilFrame(heard.e) };
|
|
197
|
+
const removed = sound ? sound.e - sound.s : 0;
|
|
198
|
+
const spans = sound ? [sound] : [];
|
|
199
|
+
if (gap - removed > SENTENCE_GAP_MAX_SEC + 1e-6) {
|
|
200
|
+
// Placed on the gap with the sound already out, then moved back past it.
|
|
201
|
+
// Frame edges only ever widen what is kept, so the silence left stays
|
|
202
|
+
// between the kept length and the longest allowed.
|
|
203
|
+
const back = (t) => (sound && t > sound.s ? t + removed : t);
|
|
204
|
+
const s = back(ceilFrame(earlier.e + SENTENCE_GAP_KEEP_SEC / 2));
|
|
205
|
+
const e = back(floorFrame(later.s - removed - SENTENCE_GAP_KEEP_SEC / 2));
|
|
206
|
+
if (e > s) spans.push({ s, e });
|
|
207
|
+
}
|
|
208
|
+
const merged = [];
|
|
209
|
+
for (const span of spans.sort((x, y) => x.s - y.s)) {
|
|
210
|
+
const prev = merged.at(-1);
|
|
211
|
+
if (prev && span.s <= prev.e + 1e-6) prev.e = Math.max(prev.e, span.e);
|
|
212
|
+
else merged.push({ ...span });
|
|
213
|
+
}
|
|
214
|
+
const kept = gap - merged.reduce((sum, c) => sum + c.e - c.s, 0);
|
|
215
|
+
for (const { s, e } of merged) cuts.push({ s, e, after: earlier.w, gap, kept, sound: Boolean(sound) });
|
|
185
216
|
}
|
|
186
217
|
return cuts;
|
|
187
218
|
}
|
|
188
219
|
|
|
220
|
+
/**
|
|
221
|
+
* #2554: a sound between two words that holds no heard word, from where the
|
|
222
|
+
* earlier word fades under QUIET_DB to where SETTLED_WINDOWS of quiet resume
|
|
223
|
+
* before the later word, or null when there is none or it runs into the later
|
|
224
|
+
* word. Mirrors scout trim.ts soundBetween.
|
|
225
|
+
*/
|
|
226
|
+
function soundBetween(levels, fromSec, toSec) {
|
|
227
|
+
const { db, windowSec } = levels;
|
|
228
|
+
const windowAt = (sec) => Math.floor(sec / windowSec + 1e-9);
|
|
229
|
+
const last = Math.min(windowAt(toSec), db.length);
|
|
230
|
+
let quiet = windowAt(fromSec);
|
|
231
|
+
while (quiet < last && db[quiet] >= QUIET_DB) quiet++;
|
|
232
|
+
let resumed = -1;
|
|
233
|
+
for (let i = quiet; i < last; i = resumed + SETTLED_WINDOWS) {
|
|
234
|
+
while (i < last && db[i] < SOUND_DB) i++;
|
|
235
|
+
if (i >= last) break;
|
|
236
|
+
const settled = settledQuiet(db, i);
|
|
237
|
+
if (settled < 0 || settled + SETTLED_WINDOWS > last) break;
|
|
238
|
+
resumed = settled;
|
|
239
|
+
}
|
|
240
|
+
return resumed < 0 ? null : { s: quiet * windowSec, e: resumed * windowSec };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Scanning forward from `from`, the first window of the first run of SETTLED_WINDOWS quiet windows, or -1. */
|
|
244
|
+
function settledQuiet(db, from) {
|
|
245
|
+
let run = 0;
|
|
246
|
+
for (let i = from; i < db.length; i++) {
|
|
247
|
+
run = db[i] < QUIET_DB ? run + 1 : 0;
|
|
248
|
+
if (run === SETTLED_WINDOWS) return i - (SETTLED_WINDOWS - 1);
|
|
249
|
+
}
|
|
250
|
+
return -1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Per-20 ms RMS loudness (dBFS) of mono s16 PCM, digital silence reading -120 (scout ffmpeg-clip.ts audioLevelsFromPcm). */
|
|
254
|
+
export function levelsFromPcm(pcm, sampleRate) {
|
|
255
|
+
const perWindow = Math.round(sampleRate * LEVEL_WINDOW_SEC);
|
|
256
|
+
const db = [];
|
|
257
|
+
for (let from = 0; from < pcm.length; from += perWindow) {
|
|
258
|
+
const to = Math.min(pcm.length, from + perWindow);
|
|
259
|
+
let sumSq = 0;
|
|
260
|
+
for (let i = from; i < to; i++) sumSq += pcm[i] * pcm[i];
|
|
261
|
+
const rms = Math.sqrt(sumSq / (to - from)) / 32768;
|
|
262
|
+
db.push(rms > 0 ? Math.max(-120, 20 * Math.log10(rms)) : -120);
|
|
263
|
+
}
|
|
264
|
+
return { windowSec: LEVEL_WINDOW_SEC, db };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A clip's loudness windows, or null when its audio cannot be decoded. */
|
|
268
|
+
function clipLevels(file) {
|
|
269
|
+
const res = spawnSync(
|
|
270
|
+
"ffmpeg",
|
|
271
|
+
["-v", "error", "-i", file, "-map", "0:a:0", "-ac", "1", "-ar", "16000", "-f", "s16le", "-"],
|
|
272
|
+
{ maxBuffer: 1 << 28 },
|
|
273
|
+
);
|
|
274
|
+
if (res.status !== 0 || !res.stdout?.length) return null;
|
|
275
|
+
const bytes = new Uint8Array(res.stdout.subarray(0, res.stdout.length & ~1));
|
|
276
|
+
return levelsFromPcm(new Int16Array(bytes.buffer), 16000);
|
|
277
|
+
}
|
|
278
|
+
|
|
189
279
|
/**
|
|
190
280
|
* Take each own-sound clip's long sentence gaps out of its timeline: its words
|
|
191
281
|
* move onto the cut timeline and it runs shorter, so every later step (join
|
|
192
282
|
* trims, reaction splits, cue times, captions) works in cut time. The renderer
|
|
193
283
|
* removes seg.cuts from the picture and the sound before anything else.
|
|
194
284
|
*/
|
|
195
|
-
function tightenSentenceGaps(spine, reactionCues, scriptLines) {
|
|
285
|
+
function tightenSentenceGaps(spine, reactionCues, scriptLines, readLevels) {
|
|
196
286
|
for (const seg of spine) {
|
|
197
287
|
if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) continue;
|
|
198
288
|
const lines = seg.lineIds.map((id) => ({
|
|
@@ -200,7 +290,7 @@ function tightenSentenceGaps(spine, reactionCues, scriptLines) {
|
|
|
200
290
|
asksPause: asksPause(scriptLines.get(id)),
|
|
201
291
|
cuesBeat: reactionCues.has(id),
|
|
202
292
|
}));
|
|
203
|
-
const cuts = sentenceGapCuts(seg.words, lines);
|
|
293
|
+
const cuts = sentenceGapCuts(seg.words, lines, readLevels(seg.file));
|
|
204
294
|
if (cuts.length === 0) continue;
|
|
205
295
|
const removedBefore = (t) => cuts.reduce((sum, c) => sum + (c.e <= t ? c.e - c.s : 0), 0);
|
|
206
296
|
const removed = removedBefore(Infinity);
|
|
@@ -210,7 +300,15 @@ function tightenSentenceGaps(spine, reactionCues, scriptLines) {
|
|
|
210
300
|
seg.clipSeconds -= removed;
|
|
211
301
|
seg.note = withHow(
|
|
212
302
|
seg.note,
|
|
213
|
-
|
|
303
|
+
[
|
|
304
|
+
...new Set(
|
|
305
|
+
cuts.map((c) =>
|
|
306
|
+
c.sound
|
|
307
|
+
? `sound after "${c.after}" cut, pause ${c.gap.toFixed(2)}s -> ${c.kept.toFixed(2)}s`
|
|
308
|
+
: `pause after "${c.after}" tightened ${c.gap.toFixed(2)}s -> ${c.kept.toFixed(2)}s`,
|
|
309
|
+
),
|
|
310
|
+
),
|
|
311
|
+
],
|
|
214
312
|
);
|
|
215
313
|
}
|
|
216
314
|
}
|
|
@@ -433,7 +531,7 @@ function reactionTail(seg, next) {
|
|
|
433
531
|
return { atSec: next.startSec, fromSec, seconds, gain: REACTION_TAIL_GAIN };
|
|
434
532
|
}
|
|
435
533
|
|
|
436
|
-
export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords }) {
|
|
534
|
+
export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords, readLevels = () => null }) {
|
|
437
535
|
const plans = scenePlans(storyboard);
|
|
438
536
|
const counts = lineWordCounts(storyboard);
|
|
439
537
|
const spine = [];
|
|
@@ -537,7 +635,7 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
537
635
|
// spine, so they go in before any overlay is placed on it.
|
|
538
636
|
const scriptLines = new Map((storyboard?.script ?? []).map((line) => [line?.lineId, line]));
|
|
539
637
|
const reactionCues = new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId));
|
|
540
|
-
tightenSentenceGaps(spine, reactionCues, scriptLines);
|
|
638
|
+
tightenSentenceGaps(spine, reactionCues, scriptLines, readLevels);
|
|
541
639
|
trimJoins(spine, reactionCues, scriptLines);
|
|
542
640
|
for (const c of waiting.filter((w) => w.reaction)) {
|
|
543
641
|
if (!c.cueLineId || placeReaction(spine, counts, c) === null) {
|
|
@@ -902,6 +1000,7 @@ function main() {
|
|
|
902
1000
|
const rows = readJson(file);
|
|
903
1001
|
return Array.isArray(rows) && rows.length > 0 ? rows : null;
|
|
904
1002
|
},
|
|
1003
|
+
readLevels: (file) => clipLevels(here(file)),
|
|
905
1004
|
});
|
|
906
1005
|
if (timeline.spine.length === 0) {
|
|
907
1006
|
fail(1, `No scene has a clip or a keyframe. Check: exodus video status ${manifest.runId}`);
|