@aicopycoders/exodus 2026.9.2000-beta.21 → 2026.9.2000-beta.22
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.
|
@@ -85,7 +85,7 @@ exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voic
|
|
|
85
85
|
exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json --wait
|
|
86
86
|
same, with the video model speaking voices you WROTE
|
|
87
87
|
exodus video status <runId> where it is, per scene
|
|
88
|
-
exodus video start --script <file> --conceit <podcast|ugc|stage|street|personification> --style <slug> [--direction "…"] [--voice-path <path>] [--video-model <id>] [--voice native] [--music] [--review-storyboard] [--wait]
|
|
88
|
+
exodus video start --script <file> --conceit <podcast|ugc|stage|street|personification> --style <slug> [--direction "…"] [--voice-path <path>] [--video-model <id>] [--voice native] [--music] [--review-storyboard] [--director] [--wait]
|
|
89
89
|
start from a script, a conceit and a look.
|
|
90
90
|
No music bed unless you pass --music.
|
|
91
91
|
The app approves its own storyboard when its
|
|
@@ -94,7 +94,11 @@ exodus video start --script <file> --conceit <podcast|ugc|stage|street|personifi
|
|
|
94
94
|
gate only when a check finds a problem, and says
|
|
95
95
|
why. --review-storyboard always stops there.
|
|
96
96
|
Each conceit has its own video model and voice;
|
|
97
|
-
--video-model / --voice override them for one run
|
|
97
|
+
--video-model / --voice override them for one run.
|
|
98
|
+
--director turns the trial director on (admins only)
|
|
99
|
+
exodus video director <runId> on|off|follow the trial director for one run (admins only);
|
|
100
|
+
follow = the brand's own setting. status shows
|
|
101
|
+
what it is doing and its last three notes
|
|
98
102
|
```
|
|
99
103
|
|
|
100
104
|
**From the storyboard on.**
|
|
@@ -952,8 +956,14 @@ the pieces:
|
|
|
952
956
|
keeps about 0.15 s after its last word and about 0.1 s before its first (0.25 s
|
|
953
957
|
when a different speaker answers), read from its `words.json`, so one clip
|
|
954
958
|
runs into the next like one conversation. It only ever shortens a clip's
|
|
955
|
-
silence and never cuts into a word. The ad's first
|
|
956
|
-
|
|
959
|
+
silence and never cuts into a word. The ad's first clip also opens about 0.1 s
|
|
960
|
+
before its first word, so a hum a clip makes before anyone speaks is not the
|
|
961
|
+
first thing heard. The ad's last tail, a narrated scene, a still and a
|
|
962
|
+
reaction beat keep their length.
|
|
963
|
+
- **It fades the audience reaction in and out.** A reaction beat fades in
|
|
964
|
+
across any quiet start before its laugh (a generated laugh clip can hum
|
|
965
|
+
there) and fades out over its last 0.4 s. Only a reaction clip longer than
|
|
966
|
+
its beat carries its own sound on under the next line.
|
|
957
967
|
- **It ignores the continuous master.** It reads only `scene-NN.voice` files;
|
|
958
968
|
it never opens `narration.mp3` or `manifest.narration`. A tool that lays the
|
|
959
969
|
master down whole and cuts the pictures to it keeps the read intact.
|
|
@@ -968,7 +978,10 @@ the pieces:
|
|
|
968
978
|
ad, and the music bed underneath at low level. Output is H.264 + AAC in an MP4
|
|
969
979
|
with `-movflags +faststart`.
|
|
970
980
|
- Flags: `--out <file>`, `--skip 2,5`, `--no-music`. It prints what went in,
|
|
971
|
-
what was left out and why, and the upload command.
|
|
981
|
+
what was left out and why, and the upload command. When a clip hums or
|
|
982
|
+
clicks where nobody speaks at an edge the cut plays, it lists each one under
|
|
983
|
+
"Sound at the edges" with its time in the cut. That is a warning to listen
|
|
984
|
+
there; the cut is made anyway.
|
|
972
985
|
- Beside the MP4 it writes `cut.words.json` (every spoken word of the finished
|
|
973
986
|
ad with its start and end) and `cut.srt` (the same words grouped into subtitle
|
|
974
987
|
lines), named from the `--out` basename. Those times are on the finished ad's
|
|
@@ -5,7 +5,7 @@
|
|
|
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
7
|
// its cued line ends, heard with its own sound while the speaker is silent,
|
|
8
|
-
//
|
|
8
|
+
// fading in across any quiet start and out rather than stopping dead.
|
|
9
9
|
//
|
|
10
10
|
// node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
|
|
11
11
|
//
|
|
@@ -71,6 +71,8 @@ export const REACTION_MAX_SEC = 1.5;
|
|
|
71
71
|
*/
|
|
72
72
|
export const REACTION_TAIL_SEC = 0.75;
|
|
73
73
|
export const REACTION_TAIL_GAIN = 0.35;
|
|
74
|
+
/** A reaction clip that ends with its beat fades out over this long instead (#2666). */
|
|
75
|
+
export const REACTION_FADE_OUT_SEC = 0.4;
|
|
74
76
|
/**
|
|
75
77
|
* The air a join keeps around its words (#2388): after one clip's last word, and
|
|
76
78
|
* before the next clip's first, less when the same speaker carries on than when
|
|
@@ -135,6 +137,13 @@ const LEVEL_WINDOW_SEC = 0.02;
|
|
|
135
137
|
const SOUND_DB = -35;
|
|
136
138
|
const QUIET_DB = -50;
|
|
137
139
|
const SETTLED_WINDOWS = 10;
|
|
140
|
+
/**
|
|
141
|
+
* #2666: in a gap gone quiet, a fainter sound counts when it stands this far
|
|
142
|
+
* over the gap's median level and reaches FAINT_SOUND_DB, so a grunt in near
|
|
143
|
+
* silence goes while a soft breath and room tone stay (scout trim.ts).
|
|
144
|
+
*/
|
|
145
|
+
const SOUND_OVER_FLOOR_DB = 25;
|
|
146
|
+
const FAINT_SOUND_DB = -45;
|
|
138
147
|
/** The directions that ask for a pause (scout clip-qc.ts PAUSE_DIRECTION, NO_PAUSE_DIRECTION, lineWrittenPause). */
|
|
139
148
|
const PAUSE_DIRECTION =
|
|
140
149
|
/\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;
|
|
@@ -221,7 +230,9 @@ export function sentenceGapCuts(words, lines, levels = null) {
|
|
|
221
230
|
* #2554: a sound between two words that holds no heard word, from where the
|
|
222
231
|
* earlier word fades under QUIET_DB to where SETTLED_WINDOWS of quiet resume
|
|
223
232
|
* before the later word, or null when there is none or it runs into the later
|
|
224
|
-
* word.
|
|
233
|
+
* word. A sound heard only against the gap's quiet (#2666) must come after the
|
|
234
|
+
* gap settles, so the earlier word's own release stays. Mirrors scout trim.ts
|
|
235
|
+
* soundBetween.
|
|
225
236
|
*/
|
|
226
237
|
function soundBetween(levels, fromSec, toSec) {
|
|
227
238
|
const { db, windowSec } = levels;
|
|
@@ -229,9 +240,13 @@ function soundBetween(levels, fromSec, toSec) {
|
|
|
229
240
|
const last = Math.min(windowAt(toSec), db.length);
|
|
230
241
|
let quiet = windowAt(fromSec);
|
|
231
242
|
while (quiet < last && db[quiet] >= QUIET_DB) quiet++;
|
|
243
|
+
const gap = db.slice(quiet, last).sort((a, b) => a - b);
|
|
244
|
+
const faint = Math.min(SOUND_DB, Math.max(FAINT_SOUND_DB, gap[gap.length >> 1] + SOUND_OVER_FLOOR_DB));
|
|
245
|
+
const calm = settledQuiet(db, quiet);
|
|
246
|
+
const loud = (i) => db[i] >= (calm >= 0 && i >= calm + SETTLED_WINDOWS ? faint : SOUND_DB);
|
|
232
247
|
let resumed = -1;
|
|
233
248
|
for (let i = quiet; i < last; i = resumed + SETTLED_WINDOWS) {
|
|
234
|
-
while (i < last &&
|
|
249
|
+
while (i < last && !loud(i)) i++;
|
|
235
250
|
if (i >= last) break;
|
|
236
251
|
const settled = settledQuiet(db, i);
|
|
237
252
|
if (settled < 0 || settled + SETTLED_WINDOWS > last) break;
|
|
@@ -264,11 +279,14 @@ export function levelsFromPcm(pcm, sampleRate) {
|
|
|
264
279
|
return { windowSec: LEVEL_WINDOW_SEC, db };
|
|
265
280
|
}
|
|
266
281
|
|
|
267
|
-
/** A clip's loudness windows, or null when its audio cannot be decoded. */
|
|
268
|
-
function clipLevels(file) {
|
|
282
|
+
/** A clip's loudness windows, or null when its audio cannot be decoded; `filter` shapes the sound first. */
|
|
283
|
+
function clipLevels(file, filter = null) {
|
|
269
284
|
const res = spawnSync(
|
|
270
285
|
"ffmpeg",
|
|
271
|
-
[
|
|
286
|
+
[
|
|
287
|
+
"-v", "error", "-i", file, "-map", "0:a:0", "-ac", "1", "-ar", "16000",
|
|
288
|
+
...(filter ? ["-af", filter] : []), "-f", "s16le", "-",
|
|
289
|
+
],
|
|
272
290
|
{ maxBuffer: 1 << 28 },
|
|
273
291
|
);
|
|
274
292
|
if (res.status !== 0 || !res.stdout?.length) return null;
|
|
@@ -421,10 +439,12 @@ export function cueEndInSegment(seg, counts, cueLineId) {
|
|
|
421
439
|
* Cut the dead air out of each join between clips that play their own sound: a
|
|
422
440
|
* clip's lead-in silence stacked on the previous clip's tail ran past a second
|
|
423
441
|
* (#2388). Each side comes back to a beat that reads as one conversation, a
|
|
424
|
-
* longer one when someone new answers. Only ever shortens, never into a word
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
442
|
+
* longer one when someone new answers. Only ever shortens, never into a word.
|
|
443
|
+
* The ad's first lead-in comes back to the same-speaker beat too, since a clip
|
|
444
|
+
* can hum before its first word (#2666); its last tail is left alone, and a
|
|
445
|
+
* clip whose closing line cues a reaction is not the ad's end, since the beat
|
|
446
|
+
* follows it. Trims land on whole frames so the picture and its sound stay the
|
|
447
|
+
* same length.
|
|
428
448
|
*
|
|
429
449
|
* The beat is measured from the clip's scripted words when the script is heard
|
|
430
450
|
* in order, and a cut stops short of a sound made up before or after the line
|
|
@@ -435,14 +455,14 @@ function trimJoins(spine, reactionCues, scriptLines) {
|
|
|
435
455
|
if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) return;
|
|
436
456
|
const prev = spine[k - 1];
|
|
437
457
|
const sameSpeaker = Boolean(prev?.lastSpeaker) && prev.lastSpeaker === seg.firstSpeaker;
|
|
438
|
-
const lead = sameSpeaker ? JOIN_LEAD_SAME_SPEAKER_SEC : JOIN_LEAD_NEW_SPEAKER_SEC;
|
|
458
|
+
const lead = sameSpeaker || !prev ? JOIN_LEAD_SAME_SPEAKER_SEC : JOIN_LEAD_NEW_SPEAKER_SEC;
|
|
439
459
|
const span = scriptedSpan(seg.words, seg.lineIds.map((id) => scriptLines.get(id)?.text));
|
|
440
460
|
const line = span ? seg.words.slice(span.first, span.last + 1) : seg.words;
|
|
441
461
|
const before = span ? seg.words.slice(0, span.first) : [];
|
|
442
462
|
const after = span ? seg.words.slice(span.last + 1) : [];
|
|
443
463
|
const firstWord = Math.min(...line.map((w) => w.s));
|
|
444
464
|
const lastWord = Math.max(...line.map((w) => w.e));
|
|
445
|
-
let inSec =
|
|
465
|
+
let inSec = Math.max(0, floorFrame(firstWord - lead));
|
|
446
466
|
if (before.length > 0) {
|
|
447
467
|
const clear = ceilFrame(Math.max(...before.map((w) => w.e)) + EXTRA_CLEARANCE_SEC);
|
|
448
468
|
inSec = Math.max(inSec, Math.min(clear, floorFrame(firstWord)));
|
|
@@ -520,17 +540,28 @@ function placeReaction(spine, counts, reaction) {
|
|
|
520
540
|
|
|
521
541
|
/**
|
|
522
542
|
* The sound a reaction beat carries under the next segment (#2406): the clip's
|
|
523
|
-
* own sound past the beat
|
|
524
|
-
*
|
|
543
|
+
* own sound past the beat (older 4 s pulls), or null when the clip ends with
|
|
544
|
+
* the beat and the beat fades itself out. Replaying the beat's last stretch
|
|
545
|
+
* there played its laugh twice, heard as a stutter (#2666).
|
|
525
546
|
*/
|
|
526
547
|
function reactionTail(seg, next) {
|
|
527
548
|
const clipLen = seg.clipSeconds ?? seg.seconds;
|
|
528
|
-
const
|
|
529
|
-
|
|
549
|
+
const fromSec = seg.inSec + seg.seconds;
|
|
550
|
+
if (clipLen - fromSec < REACTION_FADE_OUT_SEC) return null;
|
|
530
551
|
const seconds = Math.min(REACTION_TAIL_SEC, next.seconds, clipLen - fromSec);
|
|
531
552
|
return { atSec: next.startSec, fromSec, seconds, gain: REACTION_TAIL_GAIN };
|
|
532
553
|
}
|
|
533
554
|
|
|
555
|
+
/**
|
|
556
|
+
* #2666: how long a reaction beat fades in. A generated laugh clip can hum
|
|
557
|
+
* before the laugh starts, so the fade runs across that quiet start, up to its
|
|
558
|
+
* first sound; without the clip's loudness it is the plain edge fade.
|
|
559
|
+
*/
|
|
560
|
+
function quietStart(levels, seconds) {
|
|
561
|
+
const first = levels ? levels.db.findIndex((d) => d >= SOUND_DB) : -1;
|
|
562
|
+
return first > 0 ? Math.min(seconds, Math.max(EDGE_FADE_SEC, first * levels.windowSec)) : EDGE_FADE_SEC;
|
|
563
|
+
}
|
|
564
|
+
|
|
534
565
|
export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords, readLevels = () => null }) {
|
|
535
566
|
const plans = scenePlans(storyboard);
|
|
536
567
|
const counts = lineWordCounts(storyboard);
|
|
@@ -648,9 +679,11 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
648
679
|
total += seg.seconds;
|
|
649
680
|
}
|
|
650
681
|
spine.forEach((seg, k) => {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
682
|
+
if (!seg.reaction || seg.audio !== "clip") return;
|
|
683
|
+
seg.fadeInSec = quietStart(readLevels(seg.file), seg.seconds);
|
|
684
|
+
const tail = spine[k + 1] ? reactionTail(seg, spine[k + 1]) : null;
|
|
685
|
+
if (tail) seg.reaction.tail = tail;
|
|
686
|
+
else seg.fadeOutSec = Math.min(REACTION_FADE_OUT_SEC, seg.seconds);
|
|
654
687
|
});
|
|
655
688
|
|
|
656
689
|
for (const c of waiting.filter((w) => !w.reaction)) {
|
|
@@ -739,9 +772,12 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
739
772
|
filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
|
|
740
773
|
// A segment split around a reaction beat starts partway into its sound too.
|
|
741
774
|
const skipIn = inSec > 0.001 ? `atrim=start=${sec(inSec)},asetpts=PTS-STARTPTS,` : "";
|
|
775
|
+
const [fadeIn, fadeOut] = [seg.fadeInSec ?? EDGE_FADE_SEC, seg.fadeOutSec ?? EDGE_FADE_SEC];
|
|
776
|
+
// A cubic rise keeps a reaction's quiet start near silent until its laugh (#2666).
|
|
777
|
+
const rise = fadeIn > EDGE_FADE_SEC ? ":curve=cub" : "";
|
|
742
778
|
const fitLength =
|
|
743
|
-
`apad,atrim=0:${sec(seg.seconds)},afade=t=in:d=${
|
|
744
|
-
`afade=t=out:st=${sec(seg.seconds -
|
|
779
|
+
`apad,atrim=0:${sec(seg.seconds)},afade=t=in:d=${sec(fadeIn)}${rise},` +
|
|
780
|
+
`afade=t=out:st=${sec(seg.seconds - fadeOut)}:d=${sec(fadeOut)}`;
|
|
745
781
|
const fitSound = `${LOUDNORM},${fitLength}`;
|
|
746
782
|
if (seg.audio === "narration") {
|
|
747
783
|
const v = addInput("-i", resolve(seg.voice));
|
|
@@ -883,9 +919,12 @@ export function inTheCut(timeline) {
|
|
|
883
919
|
if (seg.reaction) {
|
|
884
920
|
const sound = seg.audio === "clip" ? "its own sound" : "silent: the clip has no sound";
|
|
885
921
|
const tail = seg.reaction.tail;
|
|
886
|
-
const
|
|
922
|
+
const fadeIn =
|
|
923
|
+
seg.fadeInSec > EDGE_FADE_SEC ? `, fading in over its quiet first ${seg.fadeInSec.toFixed(2)}s` : "";
|
|
924
|
+
const fadeOut = tail
|
|
887
925
|
? `, fading out under the next line at ${tail.atSec.toFixed(2)}s over ${tail.seconds.toFixed(2)}s`
|
|
888
|
-
: "";
|
|
926
|
+
: seg.fadeOutSec ? `, fading out over its last ${seg.fadeOutSec.toFixed(2)}s` : "";
|
|
927
|
+
const fades = fadeIn + fadeOut;
|
|
889
928
|
rows.push({
|
|
890
929
|
n: seg.sceneIndex,
|
|
891
930
|
line:
|
|
@@ -913,6 +952,108 @@ export function inTheCut(timeline) {
|
|
|
913
952
|
return rows.sort((a, b) => a.n - b.n).map((r) => r.line);
|
|
914
953
|
}
|
|
915
954
|
|
|
955
|
+
/**
|
|
956
|
+
* #2666: a generated clip can hum or click where nobody speaks, and the cut
|
|
957
|
+
* plays that stretch with nothing over it. A hum is HUM_BAND's share of the
|
|
958
|
+
* sound (the 1 kHz whine heard before a first word) holding for HUM_MIN_SEC
|
|
959
|
+
* over HUM_FLOOR_DB; a click is a sound of at most CLICK_MAX_WINDOWS with
|
|
960
|
+
* quiet on both sides, loud as the gap cutter reads a sound in near silence.
|
|
961
|
+
*/
|
|
962
|
+
export const HUM_BAND = "highpass=f=800,highpass=f=800,lowpass=f=1500,lowpass=f=1500";
|
|
963
|
+
// White noise measures about -13.5 dB through HUM_BAND at 16 kHz (-10.6 dB on
|
|
964
|
+
// paper), and the four hums #2666 was calibrated on held -6 to -10 dB.
|
|
965
|
+
const HUM_SHARE_DB = -10;
|
|
966
|
+
const HUM_FLOOR_DB = -70;
|
|
967
|
+
const HUM_MIN_SEC = 0.16;
|
|
968
|
+
const CLICK_MAX_WINDOWS = 3;
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Where the cut plays a clip's sound with no word in it, in the clip's own
|
|
972
|
+
* time: before a dialogue clip's first word, after its last, and a reaction's
|
|
973
|
+
* quiet start (under its cubic fade-in when the cut fades it).
|
|
974
|
+
*/
|
|
975
|
+
function edgeStretches(seg, levels) {
|
|
976
|
+
const inSec = seg.inSec ?? 0;
|
|
977
|
+
if (seg.reaction) {
|
|
978
|
+
const quiet = quietStart(levels, seg.seconds);
|
|
979
|
+
if (quiet <= EDGE_FADE_SEC) return [];
|
|
980
|
+
const fadeIn = seg.fadeInSec ?? 0;
|
|
981
|
+
return [{ side: "before its laugh", from: inSec, to: inSec + quiet, at: seg.startSec, fadeIn }];
|
|
982
|
+
}
|
|
983
|
+
if (!seg.words?.length) return [];
|
|
984
|
+
// inSec and the words are on the clip's timeline with its sentence gaps
|
|
985
|
+
// taken out; the levels are the file's own, so each edge goes back through
|
|
986
|
+
// the cuts before it.
|
|
987
|
+
const raw = (t) => {
|
|
988
|
+
let at = t;
|
|
989
|
+
for (const c of seg.cuts ?? []) if (c.s <= at) at += c.e - c.s;
|
|
990
|
+
return at;
|
|
991
|
+
};
|
|
992
|
+
const first = seg.words[0].s;
|
|
993
|
+
const last = Math.max(...seg.words.map((w) => w.e));
|
|
994
|
+
const shown = seg.seconds - (seg.holdSec ?? 0);
|
|
995
|
+
return [
|
|
996
|
+
{ side: "before its first word", from: raw(inSec), to: raw(inSec + first), at: seg.startSec, fadeIn: 0 },
|
|
997
|
+
{
|
|
998
|
+
side: "after its last word", from: raw(inSec + last), to: raw(inSec + shown),
|
|
999
|
+
at: seg.startSec + last, fadeIn: 0,
|
|
1000
|
+
},
|
|
1001
|
+
];
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* The hums and clicks the cut plays at the edges of its clips, one line each,
|
|
1006
|
+
* for the caller to listen to. It never changes the cut. readEdgeLevels(file)
|
|
1007
|
+
* gives { windowSec, db, bandDb } from time 0, or null.
|
|
1008
|
+
*/
|
|
1009
|
+
export function edgeSounds(timeline, readEdgeLevels) {
|
|
1010
|
+
const found = [];
|
|
1011
|
+
for (const seg of timeline.spine) {
|
|
1012
|
+
if (seg.source !== "clip" || seg.audio !== "clip") continue;
|
|
1013
|
+
const levels = readEdgeLevels(seg.file);
|
|
1014
|
+
if (!levels) continue;
|
|
1015
|
+
const { windowSec, db, bandDb } = levels;
|
|
1016
|
+
for (const stretch of edgeStretches(seg, levels)) {
|
|
1017
|
+
const from = Math.ceil(stretch.from / windowSec - 1e-9);
|
|
1018
|
+
const to = Math.min(db.length, Math.floor(stretch.to / windowSec + 1e-9));
|
|
1019
|
+
if (to - from < 2) continue;
|
|
1020
|
+
const heard = [];
|
|
1021
|
+
for (let i = from; i < to; i++) {
|
|
1022
|
+
const into = (i + 0.5) * windowSec - stretch.from;
|
|
1023
|
+
const faded = stretch.fadeIn > 0 && into < stretch.fadeIn;
|
|
1024
|
+
const gain = faded ? 60 * Math.log10(Math.max(1e-3, into / stretch.fadeIn)) : 0;
|
|
1025
|
+
heard.push({ i, db: db[i] + gain, share: bandDb[i] - db[i] });
|
|
1026
|
+
}
|
|
1027
|
+
const cutAt = (i) => stretch.at + i * windowSec - stretch.from;
|
|
1028
|
+
const where = `scene ${seg.sceneIndex}, ${stretch.side}`;
|
|
1029
|
+
let run = 0;
|
|
1030
|
+
let longest = { length: 0, end: 0 };
|
|
1031
|
+
heard.forEach((w, k) => {
|
|
1032
|
+
run = w.db >= HUM_FLOOR_DB && w.share >= HUM_SHARE_DB ? run + 1 : 0;
|
|
1033
|
+
if (run > longest.length) longest = { length: run, end: k };
|
|
1034
|
+
});
|
|
1035
|
+
if (longest.length * windowSec >= HUM_MIN_SEC - 1e-9) {
|
|
1036
|
+
const start = heard[longest.end - longest.length + 1].i;
|
|
1037
|
+
found.push(
|
|
1038
|
+
`${where}: a hum for ${(longest.length * windowSec).toFixed(2)}s at ${cutAt(start).toFixed(2)}s in the cut`,
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
const sorted = heard.map((w) => w.db).sort((a, b) => a - b);
|
|
1042
|
+
const loud = Math.max(FAINT_SOUND_DB, sorted[sorted.length >> 1] + SOUND_OVER_FLOOR_DB);
|
|
1043
|
+
for (let k = 1; k < heard.length - 1; k++) {
|
|
1044
|
+
if (heard[k].db < loud || heard[k - 1].db >= QUIET_DB) continue;
|
|
1045
|
+
let end = k;
|
|
1046
|
+
while (end < heard.length && heard[end].db >= loud) end++;
|
|
1047
|
+
if (end < heard.length && heard[end].db < QUIET_DB && end - k <= CLICK_MAX_WINDOWS) {
|
|
1048
|
+
found.push(`${where}: a click at ${cutAt(heard[k].i).toFixed(2)}s in the cut`);
|
|
1049
|
+
}
|
|
1050
|
+
k = end;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return found;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
916
1057
|
export function noWordTimes(timeline) {
|
|
917
1058
|
const rows = timeline.spine
|
|
918
1059
|
.filter((seg) => !seg.words || seg.words.length === 0)
|
|
@@ -1048,6 +1189,15 @@ function main() {
|
|
|
1048
1189
|
}
|
|
1049
1190
|
const silent = noWordTimes(timeline);
|
|
1050
1191
|
if (silent.length > 0) console.log(`No word times: ${silent.join(", ")}`);
|
|
1192
|
+
const edges = edgeSounds(timeline, (file) => {
|
|
1193
|
+
const levels = clipLevels(here(file));
|
|
1194
|
+
const band = levels && clipLevels(here(file), HUM_BAND);
|
|
1195
|
+
return band ? { ...levels, bandDb: band.db } : null;
|
|
1196
|
+
});
|
|
1197
|
+
if (edges.length > 0) {
|
|
1198
|
+
console.log("Sound at the edges (listen before uploading; the cut is unchanged):");
|
|
1199
|
+
for (const line of edges) console.log(" " + line);
|
|
1200
|
+
}
|
|
1051
1201
|
console.log(`Room tone: under the whole ad. Music bed: ${music ?? "none"}`);
|
|
1052
1202
|
console.log(`Upload it: exodus video upload ${manifest.runId} --file ${out}`);
|
|
1053
1203
|
}
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -63,6 +63,7 @@ export type ArtifactSubset = {
|
|
|
63
63
|
type: "image";
|
|
64
64
|
imageUrl?: string;
|
|
65
65
|
storageId?: string;
|
|
66
|
+
anchor?: "set-plate" | "product-sheet";
|
|
66
67
|
} | {
|
|
67
68
|
type: "video";
|
|
68
69
|
sceneIndex?: number;
|
|
@@ -159,6 +160,22 @@ export interface VideoRun {
|
|
|
159
160
|
};
|
|
160
161
|
workflowId?: string;
|
|
161
162
|
moduleOwned?: boolean;
|
|
163
|
+
director?: RunDirector;
|
|
164
|
+
}
|
|
165
|
+
export interface RunDirector {
|
|
166
|
+
on: boolean;
|
|
167
|
+
runSetting: boolean | null;
|
|
168
|
+
workspaceOn: boolean;
|
|
169
|
+
status: "reviewing" | "waiting" | "done" | "stopped" | null;
|
|
170
|
+
redosUsed: number;
|
|
171
|
+
redoCap: number;
|
|
172
|
+
spendUsd: number;
|
|
173
|
+
spendCapUsd: number;
|
|
174
|
+
log: {
|
|
175
|
+
at: number;
|
|
176
|
+
phase: "storyboard" | "final-watch";
|
|
177
|
+
text: string;
|
|
178
|
+
}[];
|
|
162
179
|
}
|
|
163
180
|
export declare function asVideoRun(data: unknown): VideoRun;
|
|
164
181
|
export interface NodeItem {
|
|
@@ -331,6 +348,8 @@ export interface VideoManifest {
|
|
|
331
348
|
dashboardUrl: string;
|
|
332
349
|
storyboard: string | null;
|
|
333
350
|
reference: string | null;
|
|
351
|
+
productSheet: string | null;
|
|
352
|
+
setPlate: string | null;
|
|
334
353
|
music: string | null;
|
|
335
354
|
musicBed: MusicBedState;
|
|
336
355
|
musicHeardScenes: number[];
|
|
@@ -382,6 +401,7 @@ export interface ScriptStartOptions {
|
|
|
382
401
|
voiceMode?: VoiceMode;
|
|
383
402
|
music?: boolean;
|
|
384
403
|
reviewStoryboard?: true;
|
|
404
|
+
director?: true;
|
|
385
405
|
wait: boolean;
|
|
386
406
|
json: boolean;
|
|
387
407
|
}
|
|
@@ -413,6 +433,12 @@ export declare function waitFlow(runId: string, opts: {
|
|
|
413
433
|
maxPolls?: number;
|
|
414
434
|
}, deps: VideoDeps): Promise<FlowResult>;
|
|
415
435
|
export declare function statusFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
436
|
+
export declare function directorLines(director: RunDirector): string[];
|
|
437
|
+
export type DirectorSwitch = boolean | null;
|
|
438
|
+
export declare function parseDirectorSwitch(word: string | undefined): {
|
|
439
|
+
on: DirectorSwitch;
|
|
440
|
+
} | null;
|
|
441
|
+
export declare function directorFlow(runId: string, on: DirectorSwitch, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
416
442
|
export declare function rejectedDraftFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
417
443
|
export declare function storyboardFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
418
444
|
export declare function approveFlow(runId: string, opts: {
|
package/dist/commands/video.js
CHANGED
|
@@ -103,7 +103,8 @@ Usage:
|
|
|
103
103
|
exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
|
|
104
104
|
exodus video pull <runId> --out <dir> [--json]
|
|
105
105
|
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
106
|
-
exodus video start --script <file> --conceit <${CONCEIT_KEYS.join("|")}> --style <${STYLE_SLUGS.join("|")}> [--direction "<note>"] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--review-storyboard] [--wait] [--json]
|
|
106
|
+
exodus video start --script <file> --conceit <${CONCEIT_KEYS.join("|")}> --style <${STYLE_SLUGS.join("|")}> [--direction "<note>"] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--review-storyboard] [--director] [--wait] [--json]
|
|
107
|
+
exodus video director <runId> <on|off|follow> [--json]
|
|
107
108
|
|
|
108
109
|
Options:
|
|
109
110
|
--out <dir> Folder to write the pulled pieces into (pull)
|
|
@@ -147,6 +148,10 @@ Options:
|
|
|
147
148
|
--review-storyboard Stop at the storyboard so you can approve it yourself
|
|
148
149
|
(start, with --conceit). By default the app approves its
|
|
149
150
|
own storyboard when its checks pass
|
|
151
|
+
--director Turn the trial director on for this ad (start, with
|
|
152
|
+
--conceit). Admins only. To switch it on or off later:
|
|
153
|
+
exodus video director <runId> on|off|follow, where
|
|
154
|
+
follow goes back to the brand's own setting
|
|
150
155
|
--wait Wait until the run needs you (start): at the
|
|
151
156
|
storyboard, or once every piece is made
|
|
152
157
|
--json Machine-readable output
|
|
@@ -170,10 +175,12 @@ Examples:
|
|
|
170
175
|
exodus video voices run_123 --from voices.json
|
|
171
176
|
exodus video pull run_123 --out ./ad-run_123
|
|
172
177
|
exodus video upload run_123 --file ./cut.mp4
|
|
178
|
+
exodus video director run_123 on
|
|
173
179
|
`.trim();
|
|
174
180
|
const SHOWS_PATH = "/api/v2/shows";
|
|
175
181
|
const RUNS_PATH = "/api/v2/video/runs";
|
|
176
182
|
const SCRIPT_RUNS_PATH = "/api/v2/video/script-runs";
|
|
183
|
+
const DIRECTOR_PATH = "/api/v2/video/director";
|
|
177
184
|
const RUN_PATH = "/api/v2/workflow";
|
|
178
185
|
const ITEMS_PATH = "/api/v2/workflow/items";
|
|
179
186
|
const REJECTED_DRAFT_PATH = "/api/v2/workflow/rejected-draft";
|
|
@@ -781,13 +788,19 @@ export function planPull(run, items, opts) {
|
|
|
781
788
|
}
|
|
782
789
|
}
|
|
783
790
|
let reference = null;
|
|
791
|
+
let productSheet = null;
|
|
792
|
+
let setPlate = null;
|
|
784
793
|
for (const artifact of outputsOfNodeKind(run, "reference")) {
|
|
785
794
|
if (artifact.type !== "image" || !artifact.imageUrl)
|
|
786
795
|
continue;
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
796
|
+
const stem = artifact.anchor ?? "reference";
|
|
797
|
+
const download = { file: `${stem}.${extFor(artifact.imageUrl, "image")}`, url: artifact.imageUrl };
|
|
798
|
+
if (artifact.anchor === "product-sheet")
|
|
799
|
+
productSheet = download;
|
|
800
|
+
else if (artifact.anchor === "set-plate")
|
|
801
|
+
setPlate = download;
|
|
802
|
+
else if (!artifact.anchor)
|
|
803
|
+
reference = download;
|
|
791
804
|
}
|
|
792
805
|
const keyframeByScene = new Map();
|
|
793
806
|
for (const artifact of run.nodes.flatMap((n) => n.outputs ?? [])) {
|
|
@@ -954,6 +967,8 @@ export function planPull(run, items, opts) {
|
|
|
954
967
|
const { cast, downloads: castDownloads } = planCastRefs(run, items, castVoicePins(storyboardArtifact));
|
|
955
968
|
const downloads = [
|
|
956
969
|
...(reference ? [reference] : []),
|
|
970
|
+
...(productSheet ? [productSheet] : []),
|
|
971
|
+
...(setPlate ? [setPlate] : []),
|
|
957
972
|
...keyframeByScene.values(),
|
|
958
973
|
...[...voiceByScene.values()].map((v) => v.download),
|
|
959
974
|
...(narrationDownload ? [narrationDownload] : []),
|
|
@@ -970,6 +985,8 @@ export function planPull(run, items, opts) {
|
|
|
970
985
|
dashboardUrl: reviewUrl(opts.dashboardUrl, run),
|
|
971
986
|
storyboard,
|
|
972
987
|
reference: reference?.file ?? null,
|
|
988
|
+
productSheet: productSheet?.file ?? null,
|
|
989
|
+
setPlate: setPlate?.file ?? null,
|
|
973
990
|
music: music?.file ?? null,
|
|
974
991
|
musicBed: musicBedState(run),
|
|
975
992
|
musicHeardScenes: scenes
|
|
@@ -1002,6 +1019,10 @@ export function markPullFailure(manifest, failure) {
|
|
|
1002
1019
|
manifest.storyboard = null;
|
|
1003
1020
|
if (manifest.reference === failure.file)
|
|
1004
1021
|
manifest.reference = null;
|
|
1022
|
+
if (manifest.productSheet === failure.file)
|
|
1023
|
+
manifest.productSheet = null;
|
|
1024
|
+
if (manifest.setPlate === failure.file)
|
|
1025
|
+
manifest.setPlate = null;
|
|
1005
1026
|
if (manifest.music === failure.file)
|
|
1006
1027
|
manifest.music = null;
|
|
1007
1028
|
for (const ref of manifest.cast) {
|
|
@@ -1238,6 +1259,20 @@ export function planVideoStart(flags, occurrences) {
|
|
|
1238
1259
|
line: "--review-storyboard works with --conceit, not --show. A Show's ad always stops at the storyboard for you.",
|
|
1239
1260
|
};
|
|
1240
1261
|
}
|
|
1262
|
+
const directorFlags = occurrences.filter((o) => o.flag === "director");
|
|
1263
|
+
if (directorFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
|
|
1264
|
+
return {
|
|
1265
|
+
kind: "usage",
|
|
1266
|
+
line: "--director takes no value. Leave it off to start without the director.",
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
const director = directorFlags.length > 0;
|
|
1270
|
+
if (showId && director) {
|
|
1271
|
+
return {
|
|
1272
|
+
kind: "usage",
|
|
1273
|
+
line: "--director works with --conceit, not --show.",
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1241
1276
|
if (showId && (videoModel || voiceMode)) {
|
|
1242
1277
|
return {
|
|
1243
1278
|
kind: "usage",
|
|
@@ -1302,6 +1337,8 @@ export function planVideoStart(flags, occurrences) {
|
|
|
1302
1337
|
opts.music = musicChoice.music;
|
|
1303
1338
|
if (reviewStoryboard)
|
|
1304
1339
|
opts.reviewStoryboard = true;
|
|
1340
|
+
if (director)
|
|
1341
|
+
opts.director = true;
|
|
1305
1342
|
return { kind: "script", opts };
|
|
1306
1343
|
}
|
|
1307
1344
|
function isRecord(value) {
|
|
@@ -1331,7 +1368,7 @@ function startedVideoChoice(value) {
|
|
|
1331
1368
|
return null;
|
|
1332
1369
|
return { videoModel: value.videoModel, voiceMode: value.voiceMode };
|
|
1333
1370
|
}
|
|
1334
|
-
function scriptStartedLines(runId, url, card, video) {
|
|
1371
|
+
function scriptStartedLines(runId, url, card, video, director) {
|
|
1335
1372
|
return [
|
|
1336
1373
|
`Started ad run ${runId}`,
|
|
1337
1374
|
...(card
|
|
@@ -1343,6 +1380,7 @@ function scriptStartedLines(runId, url, card, video) {
|
|
|
1343
1380
|
]
|
|
1344
1381
|
: ["The server did not include a resolved card."]),
|
|
1345
1382
|
...(video ? [`Video model: ${video.videoModel}`, `Voice: ${video.voiceMode}`] : []),
|
|
1383
|
+
...(director ? ["Director: on for this ad"] : []),
|
|
1346
1384
|
`Watch it: ${url}`,
|
|
1347
1385
|
];
|
|
1348
1386
|
}
|
|
@@ -1390,6 +1428,7 @@ export async function startScriptFlow(opts, deps) {
|
|
|
1390
1428
|
...(opts.voiceMode ? { voiceMode: opts.voiceMode } : {}),
|
|
1391
1429
|
...(opts.music === true ? { music: true } : {}),
|
|
1392
1430
|
...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
|
|
1431
|
+
...(opts.director ? { director: true } : {}),
|
|
1393
1432
|
});
|
|
1394
1433
|
if (!res.ok)
|
|
1395
1434
|
return errorResult(res, opts.json);
|
|
@@ -1407,7 +1446,7 @@ export async function startScriptFlow(opts, deps) {
|
|
|
1407
1446
|
lines: opts.json
|
|
1408
1447
|
? [JSON.stringify({ runId, url, card, ...(video ? { videoChoice: video } : {}) })]
|
|
1409
1448
|
: [
|
|
1410
|
-
...scriptStartedLines(runId, url, card, video),
|
|
1449
|
+
...scriptStartedLines(runId, url, card, video, opts.director === true),
|
|
1411
1450
|
"",
|
|
1412
1451
|
...(opts.reviewStoryboard
|
|
1413
1452
|
? ["Wait for the storyboard here instead: exodus video start … --wait"]
|
|
@@ -1424,7 +1463,7 @@ export async function startScriptFlow(opts, deps) {
|
|
|
1424
1463
|
return waitJsonWithCard(waited, card, video);
|
|
1425
1464
|
return {
|
|
1426
1465
|
code: waited.code,
|
|
1427
|
-
lines: [...scriptStartedLines(runId, url, card, video), "", ...waited.lines],
|
|
1466
|
+
lines: [...scriptStartedLines(runId, url, card, video, opts.director === true), "", ...waited.lines],
|
|
1428
1467
|
};
|
|
1429
1468
|
}
|
|
1430
1469
|
const POLL_INTERVAL_MS = 5000;
|
|
@@ -1549,6 +1588,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1549
1588
|
? { storyboardNodeId: failedStoryboard.nodeId }
|
|
1550
1589
|
: {}),
|
|
1551
1590
|
...(failedStoryboard?.hasRejectedDraft === true ? { hasRejectedDraft: true } : {}),
|
|
1591
|
+
...(run.director ? { director: run.director } : {}),
|
|
1552
1592
|
}),
|
|
1553
1593
|
],
|
|
1554
1594
|
};
|
|
@@ -1598,6 +1638,7 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1598
1638
|
...(stop.at === "running" && run.pauseAhead ? [pauseAheadLine(run.pauseAhead)] : []),
|
|
1599
1639
|
...planFailureLines(failedStoryboard, runId),
|
|
1600
1640
|
...warnings.map((w) => `Heads-up (${w.step}): ${w.warning}`),
|
|
1641
|
+
...(run.director ? ["", ...directorLines(run.director)] : []),
|
|
1601
1642
|
];
|
|
1602
1643
|
if (byScene.size === 0) {
|
|
1603
1644
|
lines.push("", "No scenes yet — this run hasn't made anything to look at.");
|
|
@@ -1660,6 +1701,72 @@ export async function statusFlow(runId, json, deps) {
|
|
|
1660
1701
|
: "Final cut: not uploaded yet.");
|
|
1661
1702
|
return { code: 0, lines };
|
|
1662
1703
|
}
|
|
1704
|
+
const DIRECTOR_STATUS_WORDS = {
|
|
1705
|
+
reviewing: "Watching this run",
|
|
1706
|
+
waiting: "Waiting for redos to finish",
|
|
1707
|
+
done: "Done: ready for your approval",
|
|
1708
|
+
stopped: "Stopped: see the last note",
|
|
1709
|
+
};
|
|
1710
|
+
const DIRECTOR_PHASE_WORDS = {
|
|
1711
|
+
storyboard: "Storyboard",
|
|
1712
|
+
"final-watch": "Final watch",
|
|
1713
|
+
};
|
|
1714
|
+
const DIRECTOR_LOG_LINES = 3;
|
|
1715
|
+
export function directorLines(director) {
|
|
1716
|
+
const switchWords = director.runSetting === null
|
|
1717
|
+
? `following the brand's setting (${director.workspaceOn ? "on" : "off"})`
|
|
1718
|
+
: "switched for this ad";
|
|
1719
|
+
const log = director.log.slice(-DIRECTOR_LOG_LINES);
|
|
1720
|
+
return [
|
|
1721
|
+
`Director: ${director.on ? "on" : "off"}, ${switchWords}`,
|
|
1722
|
+
...(director.on
|
|
1723
|
+
? [` ${DIRECTOR_STATUS_WORDS[director.status ?? "reviewing"]}`]
|
|
1724
|
+
: []),
|
|
1725
|
+
` Redos: ${director.redosUsed} of ${director.redoCap}. Spent: $${director.spendUsd.toFixed(2)} of $${director.spendCapUsd.toFixed(2)}`,
|
|
1726
|
+
...(log.length > 0
|
|
1727
|
+
? [
|
|
1728
|
+
" Latest notes:",
|
|
1729
|
+
...log.map((entry) => ` ${DIRECTOR_PHASE_WORDS[entry.phase]}: ${entry.text}`),
|
|
1730
|
+
]
|
|
1731
|
+
: []),
|
|
1732
|
+
];
|
|
1733
|
+
}
|
|
1734
|
+
export function parseDirectorSwitch(word) {
|
|
1735
|
+
if (word === "on")
|
|
1736
|
+
return { on: true };
|
|
1737
|
+
if (word === "off")
|
|
1738
|
+
return { on: false };
|
|
1739
|
+
if (word === "follow")
|
|
1740
|
+
return { on: null };
|
|
1741
|
+
return null;
|
|
1742
|
+
}
|
|
1743
|
+
export async function directorFlow(runId, on, json, deps) {
|
|
1744
|
+
const res = await deps.post(DIRECTOR_PATH, { runId, on });
|
|
1745
|
+
const behind = missingRouteLine(res, "exodus video director");
|
|
1746
|
+
if (behind) {
|
|
1747
|
+
return {
|
|
1748
|
+
code: 1,
|
|
1749
|
+
lines: json ? [JSON.stringify({ ok: false, status: 404, error: behind })] : [behind],
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
if (!res.ok)
|
|
1753
|
+
return errorResult(res, json);
|
|
1754
|
+
if (json)
|
|
1755
|
+
return { code: 0, lines: [JSON.stringify({ runId, on })] };
|
|
1756
|
+
const said = on === null
|
|
1757
|
+
? `The director on ad run ${runId} now follows the brand's own setting.`
|
|
1758
|
+
: `The director is ${on ? "on" : "off"} for ad run ${runId}.`;
|
|
1759
|
+
return {
|
|
1760
|
+
code: 0,
|
|
1761
|
+
lines: [
|
|
1762
|
+
said,
|
|
1763
|
+
...(on === true
|
|
1764
|
+
? ["If the ad is already waiting at the storyboard or the final watch, a review starts now."]
|
|
1765
|
+
: []),
|
|
1766
|
+
`See what it is doing: exodus video status ${runId}`,
|
|
1767
|
+
],
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1663
1770
|
export async function rejectedDraftFlow(runId, json, deps) {
|
|
1664
1771
|
const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
1665
1772
|
if (!runRes.ok)
|
|
@@ -2729,6 +2836,7 @@ export async function run(flags, occurrences) {
|
|
|
2729
2836
|
"voices",
|
|
2730
2837
|
"pull",
|
|
2731
2838
|
"upload",
|
|
2839
|
+
"director",
|
|
2732
2840
|
];
|
|
2733
2841
|
if (needsRunId.includes(sub) && !runId) {
|
|
2734
2842
|
usage(`video ${sub} needs a run id: exodus video ${sub} <runId>`);
|
|
@@ -2741,6 +2849,12 @@ export async function run(flags, occurrences) {
|
|
|
2741
2849
|
}
|
|
2742
2850
|
if (sub === "storyboard")
|
|
2743
2851
|
return printResult(await storyboardFlow(runId, json, defaultDeps));
|
|
2852
|
+
if (sub === "director") {
|
|
2853
|
+
const choice = parseDirectorSwitch(rest[1]);
|
|
2854
|
+
if (!choice)
|
|
2855
|
+
usage(`video director needs on, off or follow: exodus video director ${runId} on`);
|
|
2856
|
+
return printResult(await directorFlow(runId, choice.on, json, defaultDeps));
|
|
2857
|
+
}
|
|
2744
2858
|
if (sub === "approve") {
|
|
2745
2859
|
return printResult(await approveFlow(runId, { json, approveStaleCut: flags["approve-stale-cut"] === true }, defaultDeps));
|
|
2746
2860
|
}
|