@aicopycoders/exodus 2026.9.2000-beta.16 → 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 firstWord = Math.min(...seg.words.map((w) => w.s));
210
- const lastWord = Math.max(...seg.words.map((w) => w.e));
211
- const inSec = prev ? Math.max(0, floorFrame(firstWord - lead)) : 0;
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
- const outSec = endsAd ? seg.seconds : Math.min(seg.seconds, ceilFrame(lastWord + JOIN_TAIL_SEC));
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 = seg.words.map((w) => ({ ...w, s: w.s - inSec, e: w.e - inSec }));
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
- trimJoins(spine, new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId)));
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)}` });
@@ -268,7 +268,7 @@ function windowBlock(label, stats) {
268
268
  ` spend: ${spendCell(stats)}`,
269
269
  ` ctr: ${ctrCell(stats)}`,
270
270
  ` cpc: ${cpcCell(stats)}`,
271
- ` impressions: ${count(stats.impressions)} · clicks ${count(stats.clicks)}`,
271
+ ` impressions: ${count(stats.impressions)} · reach ${count(stats.reach)} · clicks ${count(stats.clicks)}`,
272
272
  ];
273
273
  }
274
274
  function copyBlock(label, text) {
@@ -30,6 +30,7 @@ export interface ClipQcTake {
30
30
  code: string;
31
31
  wording: string;
32
32
  }[];
33
+ heardText?: string;
33
34
  }
34
35
  export interface ClipQc {
35
36
  verdict: "pass" | "fail";
@@ -75,6 +76,7 @@ export type ArtifactSubset = {
75
76
  tailTrimmed?: boolean;
76
77
  rawStorageId?: string;
77
78
  voiceMode?: "voice-first";
79
+ voiceFirstBlockedBy?: string[];
78
80
  } | {
79
81
  type: "audio";
80
82
  sceneIndex?: number;
@@ -284,6 +286,7 @@ export interface ManifestScene {
284
286
  speechTrimmed: boolean | null;
285
287
  rawStorageId: string | null;
286
288
  voiceMode: "voice-first" | null;
289
+ voiceFirstBlockedBy: string[] | null;
287
290
  clipStatus: string;
288
291
  error: string | null;
289
292
  flagged: boolean;
@@ -21,6 +21,9 @@ function joinedOr(items) {
21
21
  return items[0] ?? "";
22
22
  return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
23
23
  }
24
+ function cantSayNames(names) {
25
+ return `the recorded voice can't say ${joinedOr(names.map((n) => `"${n}"`))}`;
26
+ }
24
27
  export const helpText = `
25
28
  exodus video — make a video ad from a saved workflow, pull every piece, upload your cut
26
29
 
@@ -194,7 +197,7 @@ export function qcWithoutJudgeWording(qc) {
194
197
  return qc;
195
198
  return {
196
199
  ...qc,
197
- takes: qc.takes.map(({ judgeWording: _w, ...rest }) => rest),
200
+ takes: qc.takes.map(({ judgeWording: _w, heardText: _h, ...rest }) => rest),
198
201
  };
199
202
  }
200
203
  export const NO_TAKE_HISTORY_LINE = "no history recorded for this clip";
@@ -587,6 +590,7 @@ function clipFromArtifact(sceneIndex, artifact) {
587
590
  speechTrimmed: artifact.speechTrimmed === true,
588
591
  rawStorageId: artifact.rawStorageId ?? null,
589
592
  voiceMode: artifact.voiceMode ?? null,
593
+ voiceFirstBlockedBy: artifact.voiceFirstBlockedBy ?? null,
590
594
  };
591
595
  }
592
596
  function keyframeDownload(sceneIndex, imageUrl) {
@@ -897,6 +901,7 @@ export function planPull(run, items, opts) {
897
901
  speechTrimmed: clip ? clip.speechTrimmed : null,
898
902
  rawStorageId: clip ? clip.rawStorageId : null,
899
903
  voiceMode: clip ? clip.voiceMode : null,
904
+ voiceFirstBlockedBy: clip ? clip.voiceFirstBlockedBy : null,
900
905
  clipStatus: item?.status ?? "missing",
901
906
  error: item?.error ?? null,
902
907
  flagged: item?.flagged === true,
@@ -971,6 +976,7 @@ export function markPullFailure(manifest, failure) {
971
976
  scene.speechTrimmed = null;
972
977
  scene.rawStorageId = null;
973
978
  scene.voiceMode = null;
979
+ scene.voiceFirstBlockedBy = null;
974
980
  }
975
981
  if (scene.voice === failure.file)
976
982
  scene.voice = null;
@@ -1469,6 +1475,7 @@ export async function statusFlow(runId, json, deps) {
1469
1475
  }
1470
1476
  const revoicedByScene = new Set();
1471
1477
  const voiceFirstByScene = new Set();
1478
+ const voiceFirstBlockedByScene = new Map();
1472
1479
  const timedOnOriginalVoice = new Set();
1473
1480
  for (const artifact of outputsOfNodeKind(run, "video")) {
1474
1481
  if (artifact.type === "video" &&
@@ -1476,6 +1483,11 @@ export async function statusFlow(runId, json, deps) {
1476
1483
  typeof artifact.sceneIndex === "number") {
1477
1484
  voiceFirstByScene.add(artifact.sceneIndex);
1478
1485
  }
1486
+ if (artifact.type === "video" &&
1487
+ artifact.voiceFirstBlockedBy?.length &&
1488
+ typeof artifact.sceneIndex === "number") {
1489
+ voiceFirstBlockedByScene.set(artifact.sceneIndex, artifact.voiceFirstBlockedBy);
1490
+ }
1479
1491
  if (artifact.type === "video" && artifact.revoiced === true && typeof artifact.sceneIndex === "number") {
1480
1492
  revoicedByScene.add(artifact.sceneIndex);
1481
1493
  if ((artifact.words?.length ?? 0) > 0)
@@ -1546,6 +1558,10 @@ export async function statusFlow(runId, json, deps) {
1546
1558
  lines.push(" voice: cast voice applied");
1547
1559
  if (voiceFirstByScene.has(sceneIndex))
1548
1560
  lines.push(" clip: redone voice first");
1561
+ const blockedBy = voiceFirstBlockedByScene.get(sceneIndex);
1562
+ if (blockedBy) {
1563
+ lines.push(` clip: redone the usual way — ${cantSayNames(blockedBy)}`);
1564
+ }
1549
1565
  if (timedOnOriginalVoice.has(sceneIndex)) {
1550
1566
  lines.push(" word timings: measured on the original voice (close, not frame-exact)");
1551
1567
  }
@@ -1904,7 +1920,9 @@ export async function retryClipFlow(runId, target, { note, voiceFirst = false },
1904
1920
  lines: [
1905
1921
  voiceFirst
1906
1922
  ? `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}, voice first: the line is ` +
1907
- "recorded in the character's own voice, then the clip is made to match it."
1923
+ "recorded in the character's own voice, then the clip is made to match it. " +
1924
+ "If the line has a made-up name the recorded voice can't say, the clip may be made " +
1925
+ `the usual way instead, and exodus video status ${runId} will say so.`
1908
1926
  : `Redoing scene ${plan.sceneIndex}'s clip on ${plan.nodeId}.`,
1909
1927
  `triggerRunId: ${triggerRunId ?? "-"}`,
1910
1928
  ...(voiceFirst
@@ -2286,6 +2304,13 @@ export async function pullFlow(runId, dir, json, deps) {
2286
2304
  lines.push("", `Redone voice first (the line recorded, then the clip made to match it): ` +
2287
2305
  `scene${voiceFirst.length === 1 ? "" : "s"} ${voiceFirst.map((s) => s.sceneIndex).join(", ")}`);
2288
2306
  }
2307
+ const redoneUsualWay = plan.manifest.scenes.filter((s) => s.voiceFirstBlockedBy?.length);
2308
+ if (redoneUsualWay.length > 0) {
2309
+ lines.push("", "Asked for voice first, but redone the usual way:");
2310
+ for (const scene of redoneUsualWay) {
2311
+ lines.push(` scene ${scene.sceneIndex}: ${cantSayNames(scene.voiceFirstBlockedBy ?? [])}`);
2312
+ }
2313
+ }
2289
2314
  const flagged = plan.manifest.scenes.filter((s) => s.flagged);
2290
2315
  if (flagged.length > 0) {
2291
2316
  lines.push("", `${flagged.length} clip${flagged.length === 1 ? "" : "s"} came back flagged — usable, but look before you cut:`);
@@ -22,6 +22,7 @@ export interface AdWindowStats {
22
22
  spend?: number | null;
23
23
  spendDisplay?: string | null;
24
24
  impressions?: number | null;
25
+ reach?: number | null;
25
26
  clicks?: number | null;
26
27
  currency?: string | null;
27
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.2000-beta.16",
3
+ "version": "2026.9.2000-beta.18",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",