@aicopycoders/exodus 2026.10.2100 → 2026.10.2300-beta.1
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.
package/assets/docs/PIPELINES.md
CHANGED
|
@@ -268,6 +268,7 @@ Then:
|
|
|
268
268
|
exodus video voices <runId> [--set <who>=<voiceId>] [--clear <who>] [--from <f>] [--json] who speaks, with whose voice
|
|
269
269
|
exodus video approve <runId> [--json] approve the storyboard, or the uploaded cut
|
|
270
270
|
exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "<t>"] [--json] redo one still at the pixel gate
|
|
271
|
+
exodus video broll <runId> [add --line <id> | move --scene <n> --line <id> | remove --scene <n>] [--note "<t>"] [--json] direct a B-roll run's shots at the storyboard
|
|
271
272
|
exodus video status <runId> [--json] the park and each scene's clip/voice/picture
|
|
272
273
|
exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
|
|
273
274
|
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "<t>"] [--json] redo one clip at the final watch
|
|
@@ -128,8 +128,10 @@ CLI words it the way the run page does. On their own brands they can:
|
|
|
128
128
|
|
|
129
129
|
Admin-only, answered "video isn't enabled for this key" (or, for retry-clip and
|
|
130
130
|
revoice, a sentence saying they are for admins): `director`,
|
|
131
|
-
`status --rejected-draft`, `retry-clip`, `revoice`, and the start flags
|
|
131
|
+
`status --rejected-draft`, `retry-clip`, `revoice`, `broll`, and the start flags
|
|
132
132
|
`--video-model`, `--voice`, `--voice-path` and `--director`.
|
|
133
|
+
`--broll` works for whoever B-roll is switched on for, beta testers included,
|
|
134
|
+
but nothing open to them changes the shots the app plans (see "B-roll").
|
|
133
135
|
- Other failures: `npx @aicopycoders/exodus doctor` first, then follow what it prints.
|
|
134
136
|
- `ffprobe` on the PATH lets `upload` read the cut's length by itself. Without
|
|
135
137
|
it, `upload` falls back to parsing an MP4's own header, and for anything else
|
|
@@ -158,13 +160,18 @@ exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voic
|
|
|
158
160
|
exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json --wait
|
|
159
161
|
same, with the video model speaking voices you WROTE
|
|
160
162
|
exodus video status <runId> where it is, per scene
|
|
161
|
-
exodus video start --script <file> --conceit <kind> --style <look> [--direction "…"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice native] [--music] [--one-shot] [--director] [--stop-at-script] [--rewrite-to-fit] [--wait]
|
|
163
|
+
exodus video start --script <file> --conceit <kind> --style <look> [--direction "…"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice native] [--music] [--broll] [--one-shot] [--director] [--stop-at-script] [--rewrite-to-fit] [--wait]
|
|
162
164
|
start from a script and the kinds the server offers.
|
|
163
165
|
The file can be a plain transcript, a draft or a
|
|
164
166
|
description: the server tidies it (see "The script
|
|
165
167
|
file"). --stop-at-script prints the tidied or
|
|
166
168
|
written script and starts nothing.
|
|
167
169
|
No music bed unless you pass --music.
|
|
170
|
+
--broll cuts away to B-roll shots, for
|
|
171
|
+
accounts with B-roll switched on. At the
|
|
172
|
+
storyboard, `video broll` adds, moves or
|
|
173
|
+
removes a shot (see "Reading the storyboard
|
|
174
|
+
gate").
|
|
168
175
|
--frame pov (conversation conceits) and --pacing
|
|
169
176
|
(presenter conceits) change the shots; a conceit
|
|
170
177
|
that can't follow one refuses it.
|
|
@@ -193,6 +200,11 @@ exodus video voices <runId> --set <character>=<voiceId> give a character a voi
|
|
|
193
200
|
exodus video approve <runId> keep going
|
|
194
201
|
exodus video retry-frame <runId> --node <nodeId> --scene <n> redo one still at the pixel gate
|
|
195
202
|
(a beta tester leaves --node off)
|
|
203
|
+
exodus video broll <runId> B-roll run: the shots, and the script lines by id
|
|
204
|
+
exodus video broll add <runId> --line <id> [--note "…"] plan a new B-roll shot on that line
|
|
205
|
+
exodus video broll move <runId> --scene <n> --line <id> [--note "…"]
|
|
206
|
+
move B-roll scene n to another line
|
|
207
|
+
exodus video broll remove <runId> --scene <n> take B-roll scene n out
|
|
196
208
|
exodus video status <runId> where it is, per scene
|
|
197
209
|
exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
|
|
198
210
|
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "…"] [--voice-first]
|
|
@@ -225,7 +237,11 @@ under it:
|
|
|
225
237
|
|
|
226
238
|
- storyboard gate: needs `approve`. When the stop block prints a `Redo one
|
|
227
239
|
frame:` line with a node id, one still can be redone first with `retry-frame`;
|
|
228
|
-
a gate parked before the stills are drawn has nothing to redraw.
|
|
240
|
+
a gate parked before the stills are drawn has nothing to redraw. When the run
|
|
241
|
+
has B-roll (`storyboard` ends with a `change the B-roll shots:` line), offer
|
|
242
|
+
to add a shot on a line with a note, move one, or remove one with `video
|
|
243
|
+
broll`. An add or a move takes about a minute and redraws the pictures, so
|
|
244
|
+
wait for it to finish before you approve.
|
|
229
245
|
- final watch: every piece is made; needs `pull`, a cut, and `upload`. Once a
|
|
230
246
|
cut is uploaded the stop block leads with `approve` instead, and `status
|
|
231
247
|
--json` says which state it is in (`stop.cutAttached`, plus the same next-step
|
|
@@ -580,9 +596,65 @@ changing the workflow. Approving is what releases the voices and the clips — t
|
|
|
580
596
|
expensive part — so do not approve a storyboard you have just told the user is
|
|
581
597
|
wrong.
|
|
582
598
|
|
|
599
|
+
**When the run has B-roll.** The storyboard ends with `change the B-roll shots:
|
|
600
|
+
exodus video broll <runId>`. Run that: it lists each B-roll shot (its scene
|
|
601
|
+
number, the line it sits on, the words it covers and its seconds) and every
|
|
602
|
+
script line with its id. Tell the user which lines have a shot, then offer three
|
|
603
|
+
changes:
|
|
604
|
+
|
|
605
|
+
- add a shot on a line, with a note on what it should show:
|
|
606
|
+
`exodus video broll add <runId> --line <id> --note "what it shows"`
|
|
607
|
+
- move a shot to another line:
|
|
608
|
+
`exodus video broll move <runId> --scene <n> --line <id>`, with an optional
|
|
609
|
+
`--note`
|
|
610
|
+
- remove a shot: `exodus video broll remove <runId> --scene <n>`
|
|
611
|
+
|
|
612
|
+
An add or a move plans the shot on a worker. It takes about a minute, and then
|
|
613
|
+
the storyboard pictures are redrawn. A remove saves at once and also redraws the
|
|
614
|
+
pictures. While a shot is being planned, `video broll` says so and the server
|
|
615
|
+
refuses another change. Run `video broll <runId>` again until it no longer says
|
|
616
|
+
it is planning, then read the storyboard again before you ask about approving.
|
|
617
|
+
If it says the last change didn't work, pass its sentence to the user as it
|
|
618
|
+
came. Do not approve while a shot is still being planned.
|
|
619
|
+
|
|
583
620
|
Do not approve on the user's behalf unless they told you to run the whole loop
|
|
584
621
|
unattended.
|
|
585
622
|
|
|
623
|
+
## B-roll
|
|
624
|
+
|
|
625
|
+
B-roll is a short shot the picture cuts away to while the speaker's voice keeps
|
|
626
|
+
going: the product, how it works inside the body, a result, a proof. Most top
|
|
627
|
+
ads use it. A run makes none unless it was started with `--broll`, and only an
|
|
628
|
+
account with B-roll switched on can pass it. The choice is fixed when the run
|
|
629
|
+
starts; it cannot be turned on afterwards.
|
|
630
|
+
|
|
631
|
+
**When to start with `--broll`.** Offer it whenever the script talks about
|
|
632
|
+
something a picture could show: the product, the mechanism, a before and after,
|
|
633
|
+
a number or a result. Leave it off when the user wants nothing but the person
|
|
634
|
+
talking. The app plans the shots and decides how many; it may plan few or none.
|
|
635
|
+
To direct the shots, start Guided (the default). `--one-shot` approves its own
|
|
636
|
+
storyboard, so nobody gets to change the B-roll.
|
|
637
|
+
|
|
638
|
+
**Reviewing and directing it.** At the storyboard gate, `video broll` lists the
|
|
639
|
+
shots and the lines, and adds, moves or removes one (see "Reading the
|
|
640
|
+
storyboard gate"). That is admin-only: a beta tester sees the shots in the
|
|
641
|
+
storyboard and can approve it, or stop the run on the video's page, but cannot
|
|
642
|
+
change them. A note on an add or a move says what the
|
|
643
|
+
shot should show, in one or two plain sentences ("close on the jar lid
|
|
644
|
+
twisting off", "the cream sinking into the skin, seen from inside"). Keep it
|
|
645
|
+
under 500 characters.
|
|
646
|
+
|
|
647
|
+
**The look.** A stylized ad (claymation, Pixar, anime, any animated style)
|
|
648
|
+
keeps its own look in every B-roll shot and goes closer or inside the body
|
|
649
|
+
instead. It changes look only when a note asks for a different one. A realistic, live-action ad may use any look a shot needs: a clean 3D
|
|
650
|
+
medical render, an animated diagram, a stock-footage look. Pass the user's
|
|
651
|
+
wish in the note; the app keeps or drops it by this rule.
|
|
652
|
+
|
|
653
|
+
**A shot that fails.** The app first tries a new idea for the shot, then a still
|
|
654
|
+
picture with a slow zoom. If both fail it leaves the shot out and the speaker
|
|
655
|
+
stays on screen. An admin's `pull` names those scenes and lists them under `dropped` in the
|
|
656
|
+
manifest. They have no clip, so cut with `--skip <those numbers>`.
|
|
657
|
+
|
|
586
658
|
## Giving the characters voices
|
|
587
659
|
|
|
588
660
|
By default a workflow run's characters have no voice of their own. A character
|
|
@@ -834,6 +906,10 @@ pendingRedo[] scene numbers the app is redoing right
|
|
|
834
906
|
because the file on the run is the take
|
|
835
907
|
being replaced: run `exodus video wait`,
|
|
836
908
|
then pull again
|
|
909
|
+
dropped[] B-roll scene numbers the app left out
|
|
910
|
+
after a new idea and a still both failed.
|
|
911
|
+
They have no clip on purpose: cut with
|
|
912
|
+
`--skip` and stay on the speaker
|
|
837
913
|
narration { file, timing }, or null on a per-scene run
|
|
838
914
|
cast[] one per identity still
|
|
839
915
|
characterId, name from the run's cast lock; null for an
|
|
@@ -1160,7 +1236,10 @@ Read these out of `storyboard.json` before you build anything, in any tool:
|
|
|
1160
1236
|
at the moment that script line is spoken, taking no audio and no place in the
|
|
1161
1237
|
running order. To find the moment, locate the A-roll scene whose `lineIds`
|
|
1162
1238
|
contain the cue line, then read the start time of the first word of that line
|
|
1163
|
-
out of that scene's `words.json`.
|
|
1239
|
+
out of that scene's `words.json`. A B-roll cutaway (one with `broll`) starts
|
|
1240
|
+
on the first word of `broll.covers` instead and holds through its last word,
|
|
1241
|
+
0.8 s to 4 s; a shot planned longer than 4 s (a talking character) holds at
|
|
1242
|
+
least `broll.plannedSec`.
|
|
1164
1243
|
- **Reactions.** A cutaway with `cutawayType: "reaction"` is the exception. Do
|
|
1165
1244
|
not lay it over the speech. Put it into the running order as its own beat
|
|
1166
1245
|
right after its cue line ends, play its own sound (the audience laughing),
|
|
@@ -5,7 +5,8 @@
|
|
|
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
|
-
// fading in across any quiet start and out rather than stopping dead.
|
|
8
|
+
// fading in across any quiet start and out rather than stopping dead. A B-roll
|
|
9
|
+
// cutaway lays over just the words it covers, from the first to the last.
|
|
9
10
|
//
|
|
10
11
|
// node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music] [--still-fill]
|
|
11
12
|
//
|
|
@@ -110,6 +111,13 @@ export const JOIN_LEAD_SAME_SPEAKER_SEC = 0.1;
|
|
|
110
111
|
export const JOIN_LEAD_NEW_SPEAKER_SEC = 0.25;
|
|
111
112
|
/** The gap a cut keeps from an extra heard sound it leaves out (#2541). */
|
|
112
113
|
const EXTRA_CLEARANCE_SEC = 0.05;
|
|
114
|
+
/**
|
|
115
|
+
* A B-roll shot holds over the words it covers (#3912), but never shorter than
|
|
116
|
+
* the eye needs to read it nor so long it drags; a shot planned past the
|
|
117
|
+
* longest hold (a talking character) keeps its planned length.
|
|
118
|
+
*/
|
|
119
|
+
export const BROLL_MIN_HOLD_SEC = 0.8;
|
|
120
|
+
export const BROLL_MAX_HOLD_SEC = 4;
|
|
113
121
|
|
|
114
122
|
/** The word rule the planner sized every line with (scout models.ts wordCount). */
|
|
115
123
|
export function wordCount(text) {
|
|
@@ -685,6 +693,10 @@ export function scenePlans(storyboard) {
|
|
|
685
693
|
durationSec: typeof scene.durationSec === "number" ? scene.durationSec : null,
|
|
686
694
|
actionTailSec: typeof scene.actionTailSec === "number" && scene.actionTailSec > 0 ? scene.actionTailSec : 0,
|
|
687
695
|
actionLeadSec: typeof scene.actionLeadSec === "number" && scene.actionLeadSec > 0 ? scene.actionLeadSec : 0,
|
|
696
|
+
broll:
|
|
697
|
+
hasText(scene.broll?.covers) && Number.isFinite(scene.broll?.plannedSec) && scene.broll.plannedSec > 0
|
|
698
|
+
? { covers: scene.broll.covers, plannedSec: scene.broll.plannedSec }
|
|
699
|
+
: null,
|
|
688
700
|
});
|
|
689
701
|
}
|
|
690
702
|
return plans;
|
|
@@ -709,25 +721,45 @@ export function fitNarration({ clipSeconds, spokenEndSec, airSec, actionTailSec
|
|
|
709
721
|
return { seconds: target, holdSec: target - clipSeconds, trimmedBy: 0 };
|
|
710
722
|
}
|
|
711
723
|
|
|
724
|
+
/** Where a segment's cue line sits among its words, or null when the segment does not speak it. */
|
|
725
|
+
function lineInSegment(seg, counts, cueLineId) {
|
|
726
|
+
const idx = seg.lineIds.indexOf(cueLineId);
|
|
727
|
+
if (idx === -1) return null;
|
|
728
|
+
let before = 0;
|
|
729
|
+
let total = 0;
|
|
730
|
+
seg.lineIds.forEach((id, i) => {
|
|
731
|
+
const n = counts.get(id) ?? 0;
|
|
732
|
+
total += n;
|
|
733
|
+
if (i < idx) before += n;
|
|
734
|
+
});
|
|
735
|
+
return { idx, before, words: counts.get(cueLineId) ?? 0, total };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* When the segment's word number `i` starts (edge "s") or ends ("e"), in
|
|
740
|
+
* seconds into the segment: from its word times, else its share of the words.
|
|
741
|
+
*/
|
|
742
|
+
function wordTimeInSegment(seg, i, total, edge) {
|
|
743
|
+
let at = 0;
|
|
744
|
+
if (seg.words && seg.words.length > 0) {
|
|
745
|
+
at = Math.max(0, seg.words[Math.min(Math.max(i, 0), seg.words.length - 1)]?.[edge] ?? 0);
|
|
746
|
+
} else if (total > 0) {
|
|
747
|
+
at = ((edge === "e" ? i + 1 : i) / total) * seg.seconds;
|
|
748
|
+
}
|
|
749
|
+
return Math.min(at, seg.seconds);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const timesFrom = (seg) => (seg.words && seg.words.length > 0 ? "words" : "proportion");
|
|
753
|
+
|
|
712
754
|
export function cueOffsetOnSpine(spine, counts, cueLineId) {
|
|
713
755
|
for (const seg of spine) {
|
|
714
|
-
const
|
|
715
|
-
if (
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
let within = 0;
|
|
722
|
-
let method = "proportion";
|
|
723
|
-
if (seg.words && seg.words.length > 0) {
|
|
724
|
-
within = Math.max(0, seg.words[Math.min(precedingWords, seg.words.length - 1)]?.s ?? 0);
|
|
725
|
-
method = "words";
|
|
726
|
-
} else if (totalWords > 0) {
|
|
727
|
-
within = (precedingWords / totalWords) * seg.seconds;
|
|
728
|
-
}
|
|
729
|
-
within = Math.min(within, seg.seconds);
|
|
730
|
-
return { startSec: seg.startSec + within, method, spineSceneIndex: seg.sceneIndex };
|
|
756
|
+
const line = lineInSegment(seg, counts, cueLineId);
|
|
757
|
+
if (!line) continue;
|
|
758
|
+
return {
|
|
759
|
+
startSec: seg.startSec + wordTimeInSegment(seg, line.before, line.total, "s"),
|
|
760
|
+
method: timesFrom(seg),
|
|
761
|
+
spineSceneIndex: seg.sceneIndex,
|
|
762
|
+
};
|
|
731
763
|
}
|
|
732
764
|
return null;
|
|
733
765
|
}
|
|
@@ -738,21 +770,83 @@ export function cueOffsetOnSpine(spine, counts, cueLineId) {
|
|
|
738
770
|
* where the segment does.
|
|
739
771
|
*/
|
|
740
772
|
export function cueEndInSegment(seg, counts, cueLineId) {
|
|
773
|
+
const line = lineInSegment(seg, counts, cueLineId);
|
|
774
|
+
if (!line) return null;
|
|
775
|
+
if (line.idx === seg.lineIds.length - 1) return seg.seconds;
|
|
776
|
+
const through = line.before + line.words;
|
|
777
|
+
if (seg.words && seg.words.length > 0) return wordTimeInSegment(seg, through - 1, line.total, "e");
|
|
778
|
+
return line.total > 0 ? (through / line.total) * seg.seconds : seg.seconds;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** The sub-words of a text the way scout contract.ts brollCoversLine splits them. */
|
|
782
|
+
const coverParts = (text) => String(text ?? "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* The first and last of `words` that hold the covered words, or null. Matched
|
|
786
|
+
* on sub-words as the contract does, so "proven answer" is found in
|
|
787
|
+
* "clinically-proven answer" and starts on "clinically-proven".
|
|
788
|
+
*/
|
|
789
|
+
function coveredRun(words, covers) {
|
|
790
|
+
const parts = words.flatMap((word, i) => coverParts(word).map((part) => ({ part, i })));
|
|
791
|
+
const want = coverParts(covers);
|
|
792
|
+
if (want.length === 0) return null;
|
|
793
|
+
for (let k = 0; k + want.length <= parts.length; k++) {
|
|
794
|
+
if (want.every((w, j) => parts[k + j].part === w)) {
|
|
795
|
+
return { first: parts[k].i, last: parts[k + want.length - 1].i };
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* When the covered words are heard, in seconds into the segment, read by
|
|
803
|
+
* aligning the script to the transcript: "$29" is heard as three words, so the
|
|
804
|
+
* script's word count points at the wrong one (#3912). Null when the segment
|
|
805
|
+
* has no word times or the script does not align.
|
|
806
|
+
*/
|
|
807
|
+
function heardCovers(seg, texts, cueLineId, covers) {
|
|
808
|
+
if (!seg.words || seg.words.length === 0) return null;
|
|
809
|
+
const lines = seg.lineIds.map((id) => scriptWords(texts.get(id)));
|
|
741
810
|
const idx = seg.lineIds.indexOf(cueLineId);
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
811
|
+
const run = coveredRun(lines[idx], covers);
|
|
812
|
+
const aligned = run ? alignScript(seg.words, lines.flat()) : null;
|
|
813
|
+
if (!aligned) return null;
|
|
814
|
+
const offset = lines.slice(0, idx).reduce((n, words) => n + words.length, 0);
|
|
815
|
+
const { expected, heard, heardTokenOf, heardWordOf } = aligned;
|
|
816
|
+
const first = expected.lastSourceIndex.findIndex((i) => i >= offset + run.first);
|
|
817
|
+
const last = expected.sourceIndex.findLastIndex((i) => i <= offset + run.last);
|
|
818
|
+
if (first === -1 || last < first) return null;
|
|
819
|
+
const at = (i, edge) => Math.min(Math.max(0, seg.words[i]?.[edge] ?? 0), seg.seconds);
|
|
820
|
+
return { startSec: at(heardWordOf[first], "s"), endSec: at(heard.lastSourceIndex[heardTokenOf[last]], "e") };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* #3912: a B-roll shot's place in its segment. It starts on the first word it
|
|
825
|
+
* covers and holds through the last, between BROLL_MIN_HOLD_SEC and
|
|
826
|
+
* BROLL_MAX_HOLD_SEC, or for its planned length when that runs longer than the
|
|
827
|
+
* cap. Covered words the line does not say leave it at the line's start for
|
|
828
|
+
* its planned length. `texts` maps each line id to its script text. Null when
|
|
829
|
+
* the segment does not speak the cue line.
|
|
830
|
+
*/
|
|
831
|
+
export function brollWindow(seg, counts, cueLineId, texts, broll) {
|
|
832
|
+
const line = lineInSegment(seg, counts, cueLineId);
|
|
833
|
+
if (!line) return null;
|
|
834
|
+
const method = timesFrom(seg);
|
|
835
|
+
const run = coveredRun((texts.get(cueLineId) ?? "").trim().split(/\s+/), broll.covers);
|
|
836
|
+
if (!run) {
|
|
837
|
+
return {
|
|
838
|
+
startSec: wordTimeInSegment(seg, line.before, line.total, "s"),
|
|
839
|
+
holdSec: broll.plannedSec, method, found: false,
|
|
840
|
+
};
|
|
754
841
|
}
|
|
755
|
-
|
|
842
|
+
const heard = heardCovers(seg, texts, cueLineId, broll.covers);
|
|
843
|
+
const startSec = heard?.startSec ?? wordTimeInSegment(seg, line.before + run.first, line.total, "s");
|
|
844
|
+
const endSec = heard?.endSec ?? wordTimeInSegment(seg, line.before + run.last, line.total, "e");
|
|
845
|
+
const span = Math.max(0, endSec - startSec);
|
|
846
|
+
const holdSec = broll.plannedSec > BROLL_MAX_HOLD_SEC
|
|
847
|
+
? Math.max(span, broll.plannedSec)
|
|
848
|
+
: Math.min(Math.max(span, BROLL_MIN_HOLD_SEC), BROLL_MAX_HOLD_SEC);
|
|
849
|
+
return { startSec, holdSec, method, found: true };
|
|
756
850
|
}
|
|
757
851
|
|
|
758
852
|
/**
|
|
@@ -976,7 +1070,9 @@ export function buildTimeline({
|
|
|
976
1070
|
const nothing = `nothing to cut with (clip ${scene.clipStatus}${scene.error ? `: ${scene.error}` : ""})`;
|
|
977
1071
|
|
|
978
1072
|
if (plan.cutaway) {
|
|
979
|
-
const cue = {
|
|
1073
|
+
const cue = {
|
|
1074
|
+
cueLineId: plan.cueLineId, reaction: plan.cutawayType === "reaction", broll: plan.broll ?? null,
|
|
1075
|
+
};
|
|
980
1076
|
if (exists(scene.clip)) {
|
|
981
1077
|
const clip = probe(scene.clip);
|
|
982
1078
|
waiting.push({
|
|
@@ -1124,13 +1220,25 @@ export function buildTimeline({
|
|
|
1124
1220
|
if (seg.reaction?.tail) seg.reaction.tail.atSec = spine[k + 1].startSec;
|
|
1125
1221
|
});
|
|
1126
1222
|
|
|
1223
|
+
const lineTexts = new Map([...scriptLines].map(([id, line]) => [id, line?.text]));
|
|
1127
1224
|
for (const c of waiting.filter((w) => !w.reaction)) {
|
|
1128
1225
|
const cue = c.cueLineId ? cueOffsetOnSpine(spine, counts, c.cueLineId) : null;
|
|
1129
1226
|
if (!cue) {
|
|
1130
1227
|
dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
|
|
1131
1228
|
continue;
|
|
1132
1229
|
}
|
|
1133
|
-
|
|
1230
|
+
const placed = { ...c, startSec: cue.startSec, method: cue.method, spineSceneIndex: cue.spineSceneIndex };
|
|
1231
|
+
const seg = c.broll ? spine.find((s) => s.lineIds.includes(c.cueLineId)) : null;
|
|
1232
|
+
const win = seg
|
|
1233
|
+
? brollWindow(seg, counts, c.cueLineId, lineTexts, c.broll)
|
|
1234
|
+
: null;
|
|
1235
|
+
if (win) {
|
|
1236
|
+
placed.startSec = seg.startSec + win.startSec;
|
|
1237
|
+
placed.method = win.method;
|
|
1238
|
+
placed.seconds = c.source === "clip" ? Math.min(win.holdSec, c.seconds) : win.holdSec;
|
|
1239
|
+
placed.coversFound = win.found;
|
|
1240
|
+
}
|
|
1241
|
+
cutaways.push(placed);
|
|
1134
1242
|
}
|
|
1135
1243
|
cutaways.sort((a, b) => a.startSec - b.startSec);
|
|
1136
1244
|
const beats = spine.filter((seg) => seg.reaction);
|
|
@@ -1429,11 +1537,16 @@ export function inTheCut(timeline) {
|
|
|
1429
1537
|
? "word times"
|
|
1430
1538
|
: `proportion: scene ${c.spineSceneIndex} has no word times`;
|
|
1431
1539
|
const cut = c.cutShortBy ? `, cut short where ${c.cutShortBy}` : "";
|
|
1540
|
+
const covers = !c.broll
|
|
1541
|
+
? ""
|
|
1542
|
+
: c.coversFound
|
|
1543
|
+
? `, B-roll covering "${c.broll.covers}"`
|
|
1544
|
+
: `, B-roll: "${c.broll.covers}" is not in the line, so it starts with the line for its planned ${c.broll.plannedSec}s`;
|
|
1432
1545
|
rows.push({
|
|
1433
1546
|
n: c.sceneIndex,
|
|
1434
1547
|
line:
|
|
1435
1548
|
`cutaway ${c.sceneIndex}: ${label} at ${c.startSec.toFixed(2)}s for ${c.seconds.toFixed(1)}s, ` +
|
|
1436
|
-
`cued to ${c.cueLineId} in scene ${c.spineSceneIndex} (${how})${cut}`,
|
|
1549
|
+
`cued to ${c.cueLineId} in scene ${c.spineSceneIndex} (${how})${covers}${cut}`,
|
|
1437
1550
|
});
|
|
1438
1551
|
}
|
|
1439
1552
|
return rows.sort((a, b) => a.n - b.n).map((r) => r.line);
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -400,6 +400,7 @@ export interface VideoManifest {
|
|
|
400
400
|
musicBed: MusicBedState;
|
|
401
401
|
musicHeardScenes: number[];
|
|
402
402
|
pendingRedo: number[];
|
|
403
|
+
dropped: number[];
|
|
403
404
|
cast: ManifestCastRef[];
|
|
404
405
|
narration: {
|
|
405
406
|
file: string;
|
|
@@ -455,6 +456,7 @@ export interface ScriptStartOptions {
|
|
|
455
456
|
music?: boolean;
|
|
456
457
|
reviewStoryboard?: true;
|
|
457
458
|
director?: true;
|
|
459
|
+
broll?: true;
|
|
458
460
|
shots?: RunShots;
|
|
459
461
|
stopAtScript?: true;
|
|
460
462
|
rewriteToFit?: true;
|
|
@@ -505,6 +507,34 @@ export declare function approveFlow(runId: string, opts: {
|
|
|
505
507
|
}, deps: VideoDeps): Promise<FlowResult>;
|
|
506
508
|
export declare function flagFlow(runId: string, note: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
507
509
|
export declare function retryFrameFlow(runId: string, nodeId: string | undefined, sceneIndex: number, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
510
|
+
export declare const BROLL_EDIT_STALE_MS: number;
|
|
511
|
+
export type BrollEditAsk = {
|
|
512
|
+
op: "add";
|
|
513
|
+
lineId: string;
|
|
514
|
+
note?: string;
|
|
515
|
+
} | {
|
|
516
|
+
op: "move";
|
|
517
|
+
sceneIndex: number;
|
|
518
|
+
lineId: string;
|
|
519
|
+
note?: string;
|
|
520
|
+
} | {
|
|
521
|
+
op: "remove";
|
|
522
|
+
sceneIndex: number;
|
|
523
|
+
};
|
|
524
|
+
export type BrollCommand = {
|
|
525
|
+
kind: "list";
|
|
526
|
+
runId: string;
|
|
527
|
+
} | {
|
|
528
|
+
kind: "edit";
|
|
529
|
+
runId: string;
|
|
530
|
+
edit: BrollEditAsk;
|
|
531
|
+
} | {
|
|
532
|
+
kind: "usage";
|
|
533
|
+
line: string;
|
|
534
|
+
};
|
|
535
|
+
export declare function planBrollCommand(rest: readonly string[], flags: Record<string, string | boolean>): BrollCommand;
|
|
536
|
+
export declare function brollFlow(runId: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
537
|
+
export declare function brollEditFlow(runId: string, edit: BrollEditAsk, json: boolean, deps: VideoDeps): Promise<FlowResult>;
|
|
508
538
|
export type ClipRedoPlan = {
|
|
509
539
|
ok: true;
|
|
510
540
|
nodeId: string;
|
package/dist/commands/video.js
CHANGED
|
@@ -74,6 +74,11 @@ The whole loop, in order:
|
|
|
74
74
|
here only when its checks find a problem.
|
|
75
75
|
exodus video retry-frame <runId> --node <nodeId> --scene <n>
|
|
76
76
|
Redo ONE still at the pixel gate. Neighbours stay. The gate holds.
|
|
77
|
+
exodus video broll <runId>
|
|
78
|
+
On a run started with --broll: the B-roll shots and the script lines.
|
|
79
|
+
Add a shot on a line, move one, or remove one while the storyboard
|
|
80
|
+
waits. An add or a move takes about a minute and redraws the pictures,
|
|
81
|
+
so wait for it before you approve.
|
|
77
82
|
|
|
78
83
|
7. exodus video status <runId>
|
|
79
84
|
Where the run is and how each scene's clip turned out. If the storyboard
|
|
@@ -115,13 +120,17 @@ Usage:
|
|
|
115
120
|
exodus video storyboard <runId> [--json]
|
|
116
121
|
exodus video approve <runId> [--approve-stale-cut] [--json]
|
|
117
122
|
exodus video retry-frame <runId> [--node <nodeId>] --scene <n> [--note "..."] [--json]
|
|
123
|
+
exodus video broll <runId> [--json]
|
|
124
|
+
exodus video broll add <runId> --line <id> [--note "..."] [--json]
|
|
125
|
+
exodus video broll move <runId> --scene <n> --line <id> [--note "..."] [--json]
|
|
126
|
+
exodus video broll remove <runId> --scene <n> [--json]
|
|
118
127
|
exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--voice-first] [--json]
|
|
119
128
|
exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
|
|
120
129
|
exodus video voices <runId> [--set <character>=<voice>] [--clear <character>] [--from <file.json>] [--json]
|
|
121
130
|
exodus video voices [<runId>] --catalog [--json]
|
|
122
131
|
exodus video pull <runId> --out <dir> [--json]
|
|
123
132
|
exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
|
|
124
|
-
exodus video start --script <file> --conceit <kind> --style <look> [--direction "<note>"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--one-shot] [--director] [--wait] [--json]
|
|
133
|
+
exodus video start --script <file> --conceit <kind> --style <look> [--direction "<note>"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice <native|voice-first>] [--music] [--broll] [--one-shot] [--director] [--wait] [--json]
|
|
125
134
|
exodus video director <runId> <on|off|follow> [--json]
|
|
126
135
|
|
|
127
136
|
Options:
|
|
@@ -129,13 +138,17 @@ Options:
|
|
|
129
138
|
--file <cut.mp4> Your finished cut (upload). MP4, MOV or WebM, up to 200MB
|
|
130
139
|
--duration <sec> How long your cut is, in seconds. Only needed when the
|
|
131
140
|
length can't be read off the file itself
|
|
132
|
-
--note "<text>" How to steer one redo (retry-frame, retry-clip)
|
|
141
|
+
--note "<text>" How to steer one redo (retry-frame, retry-clip), or
|
|
142
|
+
what a B-roll shot should show (broll add, broll move)
|
|
143
|
+
--line <id> The script line a B-roll shot goes on (broll add,
|
|
144
|
+
broll move). exodus video broll <runId> lists the ids
|
|
133
145
|
--node <nodeId> Which scene-frames node holds the still (retry-frame;
|
|
134
146
|
admins pass it, beta testers leave it off).
|
|
135
147
|
Which video step holds the clip, when a scene has one on
|
|
136
148
|
more than one step (retry-clip, revoice)
|
|
137
149
|
--scene <n> Which scene to redo. One scene only, a whole number
|
|
138
|
-
(retry-frame, retry-clip, revoice)
|
|
150
|
+
(retry-frame, retry-clip, revoice). Which B-roll shot
|
|
151
|
+
to move or remove (broll move, broll remove)
|
|
139
152
|
--all Every finished clip that kept the video model's voice
|
|
140
153
|
(revoice)
|
|
141
154
|
--voice-first Record the scene's line in the character's own voice,
|
|
@@ -194,6 +207,8 @@ Options:
|
|
|
194
207
|
--music Put a music bed under the ad (start). An ad has no music
|
|
195
208
|
unless you ask for it here
|
|
196
209
|
--no-music Leave the music bed off (start), which it already is
|
|
210
|
+
--broll Cut away to B-roll shots in the ad (start, with
|
|
211
|
+
--conceit). Only for accounts with B-roll switched on
|
|
197
212
|
--one-shot Make the whole video with no stop (start, with
|
|
198
213
|
--conceit). The app approves its own storyboard when
|
|
199
214
|
its checks pass and goes straight on to the clips,
|
|
@@ -219,7 +234,8 @@ status, storyboard, approve (the storyboard), voices, retry-frame --scene <n> (r
|
|
|
219
234
|
picture while the storyboard waits), pull (the finished video, each scene's
|
|
220
235
|
clip and voice, and the music) and upload. These stay admin-only:
|
|
221
236
|
start --video-model / --voice / --voice-path / --director, director,
|
|
222
|
-
status --rejected-draft, retry-clip and
|
|
237
|
+
status --rejected-draft, retry-clip, revoice and broll. start --broll works
|
|
238
|
+
for whoever B-roll is switched on for.
|
|
223
239
|
If a command answers "video isn't enabled for this key", the run id is wrong,
|
|
224
240
|
the command is admin-only, or video isn't switched on for your dashboard user.
|
|
225
241
|
|
|
@@ -230,6 +246,8 @@ Examples:
|
|
|
230
246
|
exodus video storyboard run_123
|
|
231
247
|
exodus video approve run_123
|
|
232
248
|
exodus video retry-frame run_123 --node frames-1 --scene 2
|
|
249
|
+
exodus video broll run_123
|
|
250
|
+
exodus video broll add run_123 --line L4 --note "close on the jar lid"
|
|
233
251
|
exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
|
|
234
252
|
exodus video retry-clip run_123 --scene 3 --voice-first
|
|
235
253
|
exodus video revoice run_123 --all
|
|
@@ -253,6 +271,7 @@ const REJECTED_DRAFT_NOT_ON_THIS_SERVER = "This Exodus server does not keep reje
|
|
|
253
271
|
"with the next server update.";
|
|
254
272
|
const STORYBOARD_PATH = "/api/v2/video/storyboard";
|
|
255
273
|
const FLAG_PATH = "/api/v2/video/storyboard/flag";
|
|
274
|
+
const BROLL_PATH = "/api/v2/video/storyboard/broll";
|
|
256
275
|
const APPROVE_PATH = "/api/v2/workflow/approve";
|
|
257
276
|
const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
|
|
258
277
|
const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
|
|
@@ -370,7 +389,7 @@ export const defaultDeps = {
|
|
|
370
389
|
};
|
|
371
390
|
export const VIDEO_NOT_FOUND_MESSAGE = "video isn't enabled for this key\n" +
|
|
372
391
|
"One of three things: that run id doesn't exist on this brand, this command is for admins only " +
|
|
373
|
-
"(director, status --rejected-draft, retry-clip, revoice), or video isn't " +
|
|
392
|
+
"(director, status --rejected-draft, retry-clip, revoice, broll), or video isn't " +
|
|
374
393
|
"switched on for your dashboard user on this brand. Check the id first, then ask Brad.";
|
|
375
394
|
export function videoApiError(res) {
|
|
376
395
|
const behind = missingRouteLine(res, "exodus video");
|
|
@@ -1137,6 +1156,10 @@ export function planPull(run, items, opts) {
|
|
|
1137
1156
|
.filter((scene) => scene.findings.some((f) => f.code === MUSIC_HEARD_CODE))
|
|
1138
1157
|
.map((scene) => scene.sceneIndex),
|
|
1139
1158
|
pendingRedo,
|
|
1159
|
+
dropped: [...clipItemByScene.values()]
|
|
1160
|
+
.filter((item) => item.status === "rejected")
|
|
1161
|
+
.map((item) => item.sceneIndex)
|
|
1162
|
+
.sort((a, b) => a - b),
|
|
1140
1163
|
cast,
|
|
1141
1164
|
narration: narrationDownload
|
|
1142
1165
|
? { file: narrationDownload.file, timing: "narration.json" }
|
|
@@ -1450,6 +1473,14 @@ export function planVideoStart(flags, occurrences) {
|
|
|
1450
1473
|
};
|
|
1451
1474
|
}
|
|
1452
1475
|
const director = directorFlags.length > 0;
|
|
1476
|
+
const brollFlags = occurrences.filter((o) => o.flag === "broll");
|
|
1477
|
+
if (brollFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
|
|
1478
|
+
return {
|
|
1479
|
+
kind: "usage",
|
|
1480
|
+
line: "--broll takes no value. Leave it off to start without B-roll.",
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
const broll = brollFlags.length > 0;
|
|
1453
1484
|
const stopFlags = occurrences.filter((o) => o.flag === "stop-at-script");
|
|
1454
1485
|
if (stopFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
|
|
1455
1486
|
return {
|
|
@@ -1484,6 +1515,12 @@ export function planVideoStart(flags, occurrences) {
|
|
|
1484
1515
|
line: "--director works with --conceit, not --show.",
|
|
1485
1516
|
};
|
|
1486
1517
|
}
|
|
1518
|
+
if (showId && broll) {
|
|
1519
|
+
return {
|
|
1520
|
+
kind: "usage",
|
|
1521
|
+
line: "--broll works with --conceit, not --show.",
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1487
1524
|
if (showId && (frameFlag.value || pacingFlag.value)) {
|
|
1488
1525
|
return {
|
|
1489
1526
|
kind: "usage",
|
|
@@ -1550,6 +1587,8 @@ export function planVideoStart(flags, occurrences) {
|
|
|
1550
1587
|
opts.reviewStoryboard = true;
|
|
1551
1588
|
if (director)
|
|
1552
1589
|
opts.director = true;
|
|
1590
|
+
if (broll)
|
|
1591
|
+
opts.broll = true;
|
|
1553
1592
|
if (stopAtScript)
|
|
1554
1593
|
opts.stopAtScript = true;
|
|
1555
1594
|
if (rewriteToFit)
|
|
@@ -1841,6 +1880,7 @@ export async function startScriptFlow(opts, deps) {
|
|
|
1841
1880
|
...(opts.music === true ? { music: true } : {}),
|
|
1842
1881
|
...(opts.reviewStoryboard ? { reviewStoryboard: true } : {}),
|
|
1843
1882
|
...(opts.director ? { director: true } : {}),
|
|
1883
|
+
...(opts.broll ? { broll: true } : {}),
|
|
1844
1884
|
...(opts.shots ? { shots: opts.shots } : {}),
|
|
1845
1885
|
...(opts.stopAtScript ? { stopAtScript: true } : {}),
|
|
1846
1886
|
...(opts.rewriteToFit ? { rewriteToFit: true } : {}),
|
|
@@ -2019,6 +2059,7 @@ const ITEM_STATUS_WORD = {
|
|
|
2019
2059
|
running: "working",
|
|
2020
2060
|
idle: "waiting",
|
|
2021
2061
|
skipped: "skipped",
|
|
2062
|
+
rejected: "left out",
|
|
2022
2063
|
};
|
|
2023
2064
|
function itemWord(item) {
|
|
2024
2065
|
if (!item)
|
|
@@ -2373,6 +2414,9 @@ export async function storyboardFlow(runId, json, deps) {
|
|
|
2373
2414
|
if (cards.framesNodeId) {
|
|
2374
2415
|
lines.push(`redo one frame: exodus video retry-frame ${runId} --node ${cards.framesNodeId} --scene <n>`);
|
|
2375
2416
|
}
|
|
2417
|
+
if (cards.broll === true) {
|
|
2418
|
+
lines.push(`change the B-roll shots: exodus video broll ${runId}`);
|
|
2419
|
+
}
|
|
2376
2420
|
return { code: 0, lines };
|
|
2377
2421
|
}
|
|
2378
2422
|
function sceneCardLines(scene) {
|
|
@@ -2506,6 +2550,152 @@ export async function retryFrameFlow(runId, nodeId, sceneIndex, note, json, deps
|
|
|
2506
2550
|
],
|
|
2507
2551
|
};
|
|
2508
2552
|
}
|
|
2553
|
+
export const BROLL_EDIT_STALE_MS = 10 * 60 * 1000;
|
|
2554
|
+
const BROLL_OPS = ["add", "move", "remove"];
|
|
2555
|
+
export function planBrollCommand(rest, flags) {
|
|
2556
|
+
const op = BROLL_OPS.find((o) => o === rest[0]);
|
|
2557
|
+
if (!op) {
|
|
2558
|
+
if (!rest[0])
|
|
2559
|
+
return { kind: "usage", line: "video broll needs a run id: exodus video broll <runId>" };
|
|
2560
|
+
return { kind: "list", runId: rest[0] };
|
|
2561
|
+
}
|
|
2562
|
+
const runId = rest[1];
|
|
2563
|
+
if (!runId) {
|
|
2564
|
+
return { kind: "usage", line: `video broll ${op} needs a run id: exodus video broll ${op} <runId>` };
|
|
2565
|
+
}
|
|
2566
|
+
const lineId = nonBlank(flagString(flags, "line"))?.trim();
|
|
2567
|
+
const note = nonBlank(flagString(flags, "note"))?.trim();
|
|
2568
|
+
const sceneRaw = flagString(flags, "scene");
|
|
2569
|
+
const sceneIndex = sceneRaw === undefined ? undefined : Number(sceneRaw);
|
|
2570
|
+
if (sceneIndex !== undefined && !(Number.isInteger(sceneIndex) && sceneIndex >= 1)) {
|
|
2571
|
+
return { kind: "usage", line: `video broll ${op} --scene must be a scene number, not "${sceneRaw}".` };
|
|
2572
|
+
}
|
|
2573
|
+
const withNote = note ? { note } : {};
|
|
2574
|
+
if (op === "add") {
|
|
2575
|
+
if (!lineId) {
|
|
2576
|
+
return { kind: "usage", line: "video broll add needs --line <id>, the script line the shot goes on." };
|
|
2577
|
+
}
|
|
2578
|
+
return { kind: "edit", runId, edit: { op, lineId, ...withNote } };
|
|
2579
|
+
}
|
|
2580
|
+
if (sceneIndex === undefined) {
|
|
2581
|
+
return {
|
|
2582
|
+
kind: "usage",
|
|
2583
|
+
line: `video broll ${op} needs --scene <n>, the B-roll shot to ${op}.`,
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2586
|
+
if (op === "remove") {
|
|
2587
|
+
if (note)
|
|
2588
|
+
return { kind: "usage", line: "video broll remove takes no --note." };
|
|
2589
|
+
return { kind: "edit", runId, edit: { op, sceneIndex } };
|
|
2590
|
+
}
|
|
2591
|
+
if (!lineId) {
|
|
2592
|
+
return { kind: "usage", line: "video broll move needs --line <id>, the script line to move the shot to." };
|
|
2593
|
+
}
|
|
2594
|
+
return { kind: "edit", runId, edit: { op, sceneIndex, lineId, ...withNote } };
|
|
2595
|
+
}
|
|
2596
|
+
function seconds(value) {
|
|
2597
|
+
return `${Number(value.toFixed(1))}s`;
|
|
2598
|
+
}
|
|
2599
|
+
function brollEditLines(edit, now) {
|
|
2600
|
+
if (!edit || edit.landed)
|
|
2601
|
+
return [];
|
|
2602
|
+
const asked = edit.op === "move"
|
|
2603
|
+
? `moving B-roll scene ${edit.sceneIndex} to line ${edit.lineId}`
|
|
2604
|
+
: `a new B-roll shot on line ${edit.lineId}`;
|
|
2605
|
+
if (edit.error !== undefined) {
|
|
2606
|
+
return [`The last B-roll change (${asked}) didn't work: ${edit.error}`];
|
|
2607
|
+
}
|
|
2608
|
+
if (now - edit.requestedAt >= BROLL_EDIT_STALE_MS) {
|
|
2609
|
+
return [`The last B-roll change (${asked}) never finished. You can ask for it again.`];
|
|
2610
|
+
}
|
|
2611
|
+
return [
|
|
2612
|
+
`Planning now: ${asked}. It takes about a minute, then the storyboard pictures are redrawn.`,
|
|
2613
|
+
"Wait for it to finish before you change another shot or approve the storyboard.",
|
|
2614
|
+
];
|
|
2615
|
+
}
|
|
2616
|
+
export async function brollFlow(runId, json, deps) {
|
|
2617
|
+
const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
|
|
2618
|
+
if (!res.ok)
|
|
2619
|
+
return errorResult(res, json);
|
|
2620
|
+
if (customerAnswer(res.data)) {
|
|
2621
|
+
return adminOnlyResult("Changing the B-roll from the CLI isn't open to this key.", json);
|
|
2622
|
+
}
|
|
2623
|
+
const gate = res.data;
|
|
2624
|
+
if (gate.broll !== true) {
|
|
2625
|
+
if (json)
|
|
2626
|
+
return { code: 0, lines: [JSON.stringify({ runId, broll: false })] };
|
|
2627
|
+
return { code: 0, lines: ["This video was made without B-roll, so there are no B-roll shots to change."] };
|
|
2628
|
+
}
|
|
2629
|
+
const shots = gate.brollShots ?? [];
|
|
2630
|
+
const scriptLines = gate.scriptLines ?? [];
|
|
2631
|
+
const edit = gate.brollEdit ?? null;
|
|
2632
|
+
const now = deps.now();
|
|
2633
|
+
const planning = !!edit && edit.error === undefined && !edit.landed && now - edit.requestedAt < BROLL_EDIT_STALE_MS;
|
|
2634
|
+
if (json) {
|
|
2635
|
+
return {
|
|
2636
|
+
code: 0,
|
|
2637
|
+
lines: [JSON.stringify({ runId, broll: true, planning, brollEdit: edit, brollShots: shots, scriptLines })],
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
if (scriptLines.length === 0) {
|
|
2641
|
+
return {
|
|
2642
|
+
code: 0,
|
|
2643
|
+
lines: [
|
|
2644
|
+
"The storyboard isn't finished yet, so there are no B-roll shots to change yet.",
|
|
2645
|
+
`Check where it is: exodus video status ${runId}`,
|
|
2646
|
+
],
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2649
|
+
const lines = brollEditLines(edit, now);
|
|
2650
|
+
if (lines.length > 0)
|
|
2651
|
+
lines.push("");
|
|
2652
|
+
lines.push("B-roll shots");
|
|
2653
|
+
if (shots.length === 0)
|
|
2654
|
+
lines.push(" None yet.");
|
|
2655
|
+
for (const shot of shots) {
|
|
2656
|
+
lines.push(` Scene ${shot.sceneIndex}, on line ${shot.lineId}, ${seconds(shot.plannedSec)}: "${shot.covers}"`);
|
|
2657
|
+
}
|
|
2658
|
+
lines.push("", "Script lines (pick one by its id)");
|
|
2659
|
+
for (const line of scriptLines) {
|
|
2660
|
+
lines.push(` ${line.lineId} ${line.speakerId ?? "narrator"}: ${line.text}`);
|
|
2661
|
+
}
|
|
2662
|
+
lines.push("");
|
|
2663
|
+
if (planning) {
|
|
2664
|
+
lines.push(`Check again: exodus video broll ${runId}`);
|
|
2665
|
+
}
|
|
2666
|
+
else {
|
|
2667
|
+
lines.push(`Add a shot: exodus video broll add ${runId} --line <id> --note "what it shows"`, `Move a shot: exodus video broll move ${runId} --scene <n> --line <id>`, `Remove a shot: exodus video broll remove ${runId} --scene <n>`);
|
|
2668
|
+
}
|
|
2669
|
+
return { code: 0, lines };
|
|
2670
|
+
}
|
|
2671
|
+
export async function brollEditFlow(runId, edit, json, deps) {
|
|
2672
|
+
const res = await deps.post(BROLL_PATH, { runId, ...edit });
|
|
2673
|
+
if (!res.ok)
|
|
2674
|
+
return errorResult(res, json);
|
|
2675
|
+
if (json)
|
|
2676
|
+
return { code: 0, lines: [JSON.stringify({ runId, ...res.data })] };
|
|
2677
|
+
if (edit.op === "remove") {
|
|
2678
|
+
return {
|
|
2679
|
+
code: 0,
|
|
2680
|
+
lines: [
|
|
2681
|
+
`B-roll scene ${edit.sceneIndex} is gone. The storyboard pictures will be redrawn.`,
|
|
2682
|
+
`See them: exodus video storyboard ${runId}`,
|
|
2683
|
+
],
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
return {
|
|
2687
|
+
code: 0,
|
|
2688
|
+
lines: [
|
|
2689
|
+
edit.op === "add"
|
|
2690
|
+
? `Planning a B-roll shot on line ${edit.lineId}.`
|
|
2691
|
+
: `Moving B-roll scene ${edit.sceneIndex} to line ${edit.lineId}.`,
|
|
2692
|
+
"It takes about a minute. Then the storyboard pictures are redrawn.",
|
|
2693
|
+
"Wait for it to finish before you approve the storyboard.",
|
|
2694
|
+
`Check on it: exodus video broll ${runId}`,
|
|
2695
|
+
`See the pictures: exodus video storyboard ${runId}`,
|
|
2696
|
+
],
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2509
2699
|
const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the new clip. If this redo replaces the clip, " +
|
|
2510
2700
|
"exodus video approve stops until you pull the pieces again, re-cut and upload again, " +
|
|
2511
2701
|
"or pass --approve-stale-cut to deliver the cut you uploaded as it is.";
|
|
@@ -3194,6 +3384,9 @@ export async function pullFlow(runId, dir, json, deps) {
|
|
|
3194
3384
|
`Every piece is indexed in ${path.join(dir, "manifest.json")} — scene numbers there are the run's own.`,
|
|
3195
3385
|
...musicLines(plan.manifest),
|
|
3196
3386
|
];
|
|
3387
|
+
if (plan.manifest.dropped.length > 0) {
|
|
3388
|
+
lines.push("", `B-roll left out on purpose (no clip; the edit stays on the speaker): scene${plan.manifest.dropped.length === 1 ? "" : "s"} ${plan.manifest.dropped.join(", ")}. Cut with --skip ${plan.manifest.dropped.join(",")}.`);
|
|
3389
|
+
}
|
|
3197
3390
|
for (const sceneIndex of plan.manifest.pendingRedo) {
|
|
3198
3391
|
lines.push("", `Scene ${sceneIndex} is being redone right now; its file will change. Run \`exodus video wait ${runId}\`, then pull again.`);
|
|
3199
3392
|
}
|
|
@@ -3541,6 +3734,7 @@ const VALUE_FLAGS = new Set([
|
|
|
3541
3734
|
"duration",
|
|
3542
3735
|
"node",
|
|
3543
3736
|
"scene",
|
|
3737
|
+
"line",
|
|
3544
3738
|
"set",
|
|
3545
3739
|
"clear",
|
|
3546
3740
|
"from",
|
|
@@ -3599,6 +3793,14 @@ export async function run(flags, occurrences) {
|
|
|
3599
3793
|
usage(started.lines[0] ?? "");
|
|
3600
3794
|
return printResult(started);
|
|
3601
3795
|
}
|
|
3796
|
+
if (sub === "broll") {
|
|
3797
|
+
const plan = planBrollCommand(rest, flags);
|
|
3798
|
+
if (plan.kind === "usage")
|
|
3799
|
+
usage(plan.line);
|
|
3800
|
+
if (plan.kind === "list")
|
|
3801
|
+
return printResult(await brollFlow(plan.runId, json, defaultDeps));
|
|
3802
|
+
return printResult(await brollEditFlow(plan.runId, plan.edit, json, defaultDeps));
|
|
3803
|
+
}
|
|
3602
3804
|
const runId = rest[0];
|
|
3603
3805
|
const needsRunId = [
|
|
3604
3806
|
"status",
|