@aicopycoders/exodus 2026.9.2000-beta.15 → 2026.9.2000-beta.17
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.
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// spine at the moment its cued line is spoken, a room-tone bed and the music
|
|
5
5
|
// bed go underneath, and one MP4 comes out ready to upload. A reaction
|
|
6
6
|
// cutaway is the exception: it is its own short beat in the spine right after
|
|
7
|
-
// its cued line ends, heard with its own sound while the speaker is silent
|
|
7
|
+
// its cued line ends, heard with its own sound while the speaker is silent,
|
|
8
|
+
// and its sound fades out quietly under the next line rather than stopping.
|
|
8
9
|
//
|
|
9
10
|
// node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
|
|
10
11
|
//
|
|
@@ -59,6 +60,13 @@ const ROOM_TONE =
|
|
|
59
60
|
const MUSIC_VOLUME = 0.18;
|
|
60
61
|
/** The longest a reaction beat holds the spine (#2357); older pulls carry 4 s reaction clips. */
|
|
61
62
|
export const REACTION_MAX_SEC = 1.5;
|
|
63
|
+
/**
|
|
64
|
+
* A reaction's laugh carries into the next line instead of stopping dead at
|
|
65
|
+
* the join (#2406): it starts well under the speaker, at this share of its own
|
|
66
|
+
* level (about -9 dB), and fades to silence over this long.
|
|
67
|
+
*/
|
|
68
|
+
export const REACTION_TAIL_SEC = 0.75;
|
|
69
|
+
export const REACTION_TAIL_GAIN = 0.35;
|
|
62
70
|
/**
|
|
63
71
|
* The air a join keeps around its words (#2388): after one clip's last word, and
|
|
64
72
|
* before the next clip's first, less when the same speaker carries on than when
|
|
@@ -262,6 +270,19 @@ function placeReaction(spine, counts, reaction) {
|
|
|
262
270
|
return null;
|
|
263
271
|
}
|
|
264
272
|
|
|
273
|
+
/**
|
|
274
|
+
* The sound a reaction beat carries under the next segment (#2406): the clip's
|
|
275
|
+
* own sound past the beat when it has some (older 4 s pulls), else its last
|
|
276
|
+
* stretch replayed, so a clip exactly as long as its beat still fades out.
|
|
277
|
+
*/
|
|
278
|
+
function reactionTail(seg, next) {
|
|
279
|
+
const clipLen = seg.clipSeconds ?? seg.seconds;
|
|
280
|
+
const beatEnd = seg.inSec + seg.seconds;
|
|
281
|
+
const fromSec = Math.max(0, Math.min(beatEnd, clipLen - REACTION_TAIL_SEC));
|
|
282
|
+
const seconds = Math.min(REACTION_TAIL_SEC, next.seconds, clipLen - fromSec);
|
|
283
|
+
return { atSec: next.startSec, fromSec, seconds, gain: REACTION_TAIL_GAIN };
|
|
284
|
+
}
|
|
285
|
+
|
|
265
286
|
export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords }) {
|
|
266
287
|
const plans = scenePlans(storyboard);
|
|
267
288
|
const counts = lineWordCounts(storyboard);
|
|
@@ -375,6 +396,11 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
375
396
|
seg.startSec = total;
|
|
376
397
|
total += seg.seconds;
|
|
377
398
|
}
|
|
399
|
+
spine.forEach((seg, k) => {
|
|
400
|
+
const next = spine[k + 1];
|
|
401
|
+
if (!seg.reaction || seg.audio !== "clip" || !next) return;
|
|
402
|
+
seg.reaction.tail = reactionTail(seg, next);
|
|
403
|
+
});
|
|
378
404
|
|
|
379
405
|
for (const c of waiting.filter((w) => !w.reaction)) {
|
|
380
406
|
const cue = c.cueLineId ? cueOffsetOnSpine(spine, counts, c.cueLineId) : null;
|
|
@@ -421,6 +447,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
421
447
|
};
|
|
422
448
|
const filters = [];
|
|
423
449
|
const pairs = [];
|
|
450
|
+
const tails = [];
|
|
424
451
|
|
|
425
452
|
for (const seg of timeline.spine) {
|
|
426
453
|
const i =
|
|
@@ -442,12 +469,26 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
442
469
|
filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
|
|
443
470
|
// A segment split around a reaction beat starts partway into its sound too.
|
|
444
471
|
const skipIn = inSec > 0.001 ? `atrim=start=${sec(inSec)},asetpts=PTS-STARTPTS,` : "";
|
|
445
|
-
const
|
|
446
|
-
|
|
472
|
+
const fitLength =
|
|
473
|
+
`apad,atrim=0:${sec(seg.seconds)},afade=t=in:d=${EDGE_FADE_SEC},` +
|
|
447
474
|
`afade=t=out:st=${sec(seg.seconds - EDGE_FADE_SEC)}:d=${EDGE_FADE_SEC}`;
|
|
475
|
+
const fitSound = `${LOUDNORM},${fitLength}`;
|
|
448
476
|
if (seg.audio === "narration") {
|
|
449
477
|
const v = addInput("-i", resolve(seg.voice));
|
|
450
478
|
filters.push(`[${v}:a]${stereo},${skipIn}${fitSound}[a${i}]`);
|
|
479
|
+
} else if (seg.audio === "clip" && seg.reaction?.tail) {
|
|
480
|
+
// The tail is cut from the same loudness-normalized sound as the beat,
|
|
481
|
+
// ducked, faded out, and laid in at the join; the next line's own sound
|
|
482
|
+
// plays at full level over it (#2406).
|
|
483
|
+
const t = seg.reaction.tail;
|
|
484
|
+
filters.push(`[${i}:a]${stereo},${skipIn}${LOUDNORM},asplit=2[as${i}][at${i}]`);
|
|
485
|
+
filters.push(`[as${i}]${fitLength}[a${i}]`);
|
|
486
|
+
filters.push(
|
|
487
|
+
`[at${i}]atrim=start=${sec(t.fromSec - inSec)}:duration=${sec(t.seconds)},asetpts=PTS-STARTPTS,` +
|
|
488
|
+
`volume=${t.gain},afade=t=in:d=${EDGE_FADE_SEC},afade=t=out:st=0:d=${sec(t.seconds)},` +
|
|
489
|
+
`adelay=delays=${Math.round(t.atSec * 1000)}:all=1[tail${i}]`,
|
|
490
|
+
);
|
|
491
|
+
tails.push(`[tail${i}]`);
|
|
451
492
|
} else if (seg.audio === "clip") {
|
|
452
493
|
filters.push(`[${i}:a]${stereo},${skipIn}${fitSound}[a${i}]`);
|
|
453
494
|
} else {
|
|
@@ -479,7 +520,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
479
520
|
});
|
|
480
521
|
|
|
481
522
|
filters.push(`${ROOM_TONE}[room]`);
|
|
482
|
-
const beds = ["[room]"];
|
|
523
|
+
const beds = ["[room]", ...tails];
|
|
483
524
|
if (music) {
|
|
484
525
|
const m = addInput("-stream_loop", "-1", "-i", music);
|
|
485
526
|
filters.push(`[${m}:a]${stereo},volume=${MUSIC_VOLUME}[bed]`);
|
|
@@ -571,11 +612,15 @@ export function inTheCut(timeline) {
|
|
|
571
612
|
const label = seg.source === "clip" ? seg.file : `still ${seg.file}`;
|
|
572
613
|
if (seg.reaction) {
|
|
573
614
|
const sound = seg.audio === "clip" ? "its own sound" : "silent: the clip has no sound";
|
|
615
|
+
const tail = seg.reaction.tail;
|
|
616
|
+
const fades = tail
|
|
617
|
+
? `, fading out under the next line at ${tail.atSec.toFixed(2)}s over ${tail.seconds.toFixed(2)}s`
|
|
618
|
+
: "";
|
|
574
619
|
rows.push({
|
|
575
620
|
n: seg.sceneIndex,
|
|
576
621
|
line:
|
|
577
622
|
`reaction ${seg.sceneIndex}: ${label} for ${seg.seconds.toFixed(1)}s after ` +
|
|
578
|
-
`${seg.reaction.cueLineId} in scene ${seg.reaction.cueSceneIndex}, ${sound}`,
|
|
623
|
+
`${seg.reaction.cueLineId} in scene ${seg.reaction.cueSceneIndex}, ${sound}${fades}`,
|
|
579
624
|
});
|
|
580
625
|
continue;
|
|
581
626
|
}
|
package/dist/commands/ads.js
CHANGED
|
@@ -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) {
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/video.js
CHANGED
|
@@ -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:`);
|
package/dist/lib/meta-ads.d.ts
CHANGED