@aicopycoders/exodus 2026.9.2000-beta.17 → 2026.9.2000-beta.18
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.
|
@@ -75,6 +75,8 @@ export const REACTION_TAIL_GAIN = 0.35;
|
|
|
75
75
|
export const JOIN_TAIL_SEC = 0.15;
|
|
76
76
|
export const JOIN_LEAD_SAME_SPEAKER_SEC = 0.1;
|
|
77
77
|
export const JOIN_LEAD_NEW_SPEAKER_SEC = 0.25;
|
|
78
|
+
/** The gap a cut keeps from an extra heard sound it leaves out (#2541). */
|
|
79
|
+
const EXTRA_CLEARANCE_SEC = 0.05;
|
|
78
80
|
|
|
79
81
|
/** The word rule the planner sized every line with (scout models.ts wordCount). */
|
|
80
82
|
export function wordCount(text) {
|
|
@@ -90,6 +92,27 @@ export function lineWordCounts(storyboard) {
|
|
|
90
92
|
return counts;
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
const wordToken = (word) => String(word ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* #2541: the indexes of the heard words that deliver the first and last
|
|
99
|
+
* scripted word, matching the script in order at its earliest delivery, or null
|
|
100
|
+
* when the whole script is not heard that way.
|
|
101
|
+
*/
|
|
102
|
+
export function scriptedSpan(words, lineTexts) {
|
|
103
|
+
const script = lineTexts.flatMap((text) => (text ?? "").split(/\s+/)).map(wordToken).filter(Boolean);
|
|
104
|
+
if (script.length === 0) return null;
|
|
105
|
+
let first = -1;
|
|
106
|
+
let j = 0;
|
|
107
|
+
for (const token of script) {
|
|
108
|
+
while (j < words.length && wordToken(words[j].w) !== token) j++;
|
|
109
|
+
if (j === words.length) return null;
|
|
110
|
+
if (first < 0) first = j;
|
|
111
|
+
j++;
|
|
112
|
+
}
|
|
113
|
+
return { first, last: j - 1 };
|
|
114
|
+
}
|
|
115
|
+
|
|
93
116
|
const CUTAWAY_KINDS = new Set(["cutaway", "insert"]);
|
|
94
117
|
|
|
95
118
|
const hasText = (value) => typeof value === "string" && value.trim().length > 0;
|
|
@@ -199,26 +222,48 @@ export function cueEndInSegment(seg, counts, cueLineId) {
|
|
|
199
222
|
* and leaves the ad's first lead-in and last tail alone; a clip whose closing
|
|
200
223
|
* line cues a reaction is not the ad's end, since the beat follows it. Trims
|
|
201
224
|
* land on whole frames so the picture and its sound stay the same length.
|
|
225
|
+
*
|
|
226
|
+
* The beat is measured from the clip's scripted words when the script is heard
|
|
227
|
+
* in order, and a cut stops short of a sound made up before or after the line
|
|
228
|
+
* ("Hmm", "Um"), even at the ad's own edges (#2541). Sounds inside the line stay.
|
|
202
229
|
*/
|
|
203
|
-
function trimJoins(spine, reactionCues) {
|
|
230
|
+
function trimJoins(spine, reactionCues, lineTexts) {
|
|
204
231
|
spine.forEach((seg, k) => {
|
|
205
232
|
if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) return;
|
|
206
233
|
const prev = spine[k - 1];
|
|
207
234
|
const sameSpeaker = Boolean(prev?.lastSpeaker) && prev.lastSpeaker === seg.firstSpeaker;
|
|
208
235
|
const lead = sameSpeaker ? JOIN_LEAD_SAME_SPEAKER_SEC : JOIN_LEAD_NEW_SPEAKER_SEC;
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
const
|
|
236
|
+
const span = scriptedSpan(seg.words, seg.lineIds.map((id) => lineTexts.get(id)));
|
|
237
|
+
const line = span ? seg.words.slice(span.first, span.last + 1) : seg.words;
|
|
238
|
+
const before = span ? seg.words.slice(0, span.first) : [];
|
|
239
|
+
const after = span ? seg.words.slice(span.last + 1) : [];
|
|
240
|
+
const firstWord = Math.min(...line.map((w) => w.s));
|
|
241
|
+
const lastWord = Math.max(...line.map((w) => w.e));
|
|
242
|
+
let inSec = prev ? Math.max(0, floorFrame(firstWord - lead)) : 0;
|
|
243
|
+
if (before.length > 0) {
|
|
244
|
+
const clear = ceilFrame(Math.max(...before.map((w) => w.e)) + EXTRA_CLEARANCE_SEC);
|
|
245
|
+
inSec = Math.max(inSec, Math.min(clear, floorFrame(firstWord)));
|
|
246
|
+
}
|
|
212
247
|
const endsAd = k === spine.length - 1 && !reactionCues.has(seg.lineIds.at(-1));
|
|
213
|
-
|
|
248
|
+
let outSec = endsAd
|
|
249
|
+
? after.length > 0 ? ceilFrame(lastWord + TAIL_AIR_SEC) : seg.seconds
|
|
250
|
+
: ceilFrame(lastWord + JOIN_TAIL_SEC);
|
|
251
|
+
if (after.length > 0) {
|
|
252
|
+
const clear = floorFrame(Math.min(...after.map((w) => w.s)) - EXTRA_CLEARANCE_SEC);
|
|
253
|
+
outSec = Math.min(outSec, Math.max(clear, ceilFrame(lastWord)));
|
|
254
|
+
}
|
|
255
|
+
outSec = Math.min(seg.seconds, outSec);
|
|
214
256
|
if (outSec <= inSec) return;
|
|
215
257
|
const how = [];
|
|
216
258
|
if (inSec > 0) how.push(`lead-in trimmed ${inSec.toFixed(2)}s`);
|
|
217
259
|
if (outSec < seg.seconds) how.push(`tail trimmed ${(seg.seconds - outSec).toFixed(2)}s`);
|
|
218
260
|
if (how.length === 0) return;
|
|
261
|
+
const kept = seg.words.filter((w) => w.s < outSec && w.e > inSec);
|
|
262
|
+
if (before.some((w) => !kept.includes(w))) how.push("extra sound before the line trimmed");
|
|
263
|
+
if (after.some((w) => !kept.includes(w))) how.push("extra sound after the line trimmed");
|
|
219
264
|
seg.inSec = inSec;
|
|
220
265
|
seg.seconds = outSec - inSec;
|
|
221
|
-
seg.words =
|
|
266
|
+
seg.words = kept.map((w) => ({ ...w, s: w.s - inSec, e: w.e - inSec }));
|
|
222
267
|
seg.note = `${seg.note} (${how.join(", ")})`;
|
|
223
268
|
});
|
|
224
269
|
}
|
|
@@ -385,7 +430,8 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
385
430
|
// Joins are closed first so a reaction beat lands right after its cue line
|
|
386
431
|
// ends in the trimmed clip. Reactions change the spine, so they go in before
|
|
387
432
|
// any overlay is placed on it.
|
|
388
|
-
|
|
433
|
+
const lineTexts = new Map((storyboard?.script ?? []).map((line) => [line?.lineId, line?.text]));
|
|
434
|
+
trimJoins(spine, new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId)), lineTexts);
|
|
389
435
|
for (const c of waiting.filter((w) => w.reaction)) {
|
|
390
436
|
if (!c.cueLineId || placeReaction(spine, counts, c) === null) {
|
|
391
437
|
dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
|