@aicopycoders/exodus 2026.9.2000-beta.13 → 2026.9.2000-beta.15
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.
|
@@ -631,9 +631,12 @@ depends on the scene; see "Which scenes carry their voice where" below.
|
|
|
631
631
|
terms. `script[]` is the ad's spoken words in order — `{lineId, speakerId, text}`,
|
|
632
632
|
with `speakerId: null` meaning narrator VO. Each scene carries `sceneIndex`,
|
|
633
633
|
`durationSec`, `dialogue[]`, `voText`, `notes`, and `lineIds` (the script lines
|
|
634
|
-
that scene renders). A scene with `kind: "cutaway"` carries `cueLineId`
|
|
635
|
-
|
|
636
|
-
keeps running, so
|
|
634
|
+
that scene renders). A scene with `kind: "cutaway"` carries `cueLineId` and a
|
|
635
|
+
`cutawayType` instead. Most cutaways are a picture-only takeover of the frame
|
|
636
|
+
while the spoken track underneath keeps running, so they add no audio and take
|
|
637
|
+
no place in the running order. A `cutawayType: "reaction"` cutaway is the room
|
|
638
|
+
reacting out loud to its cue line: it is its own short beat after that line,
|
|
639
|
+
heard with its own sound while the speaker is silent. The
|
|
637
640
|
envelope also carries `overlays[]` (each with a `cueLineId` and a `placement`)
|
|
638
641
|
and `audio` (`roomTone`, `music`, `sfxCues[]`) — the planner's notes on what
|
|
639
642
|
should sit on top. The image and motion prompts (`framePrompt`, `videoPrompt`,
|
|
@@ -780,7 +783,7 @@ them. If the script's words are wrong, that is a new run.
|
|
|
780
783
|
|
|
781
784
|
**`--voice-first` fixes a clip that said its line wrong** (#2299). It is for a
|
|
782
785
|
clip whose findings include `speech-mismatch`, `speech-repeat`,
|
|
783
|
-
`speech-after-line` or `speech-long-pause`. The scene's line is recorded in each
|
|
786
|
+
`speech-after-line`, `speech-extra-words` or `speech-long-pause`. The scene's line is recorded in each
|
|
784
787
|
speaker's pinned ElevenLabs voice, and MiniMax (`minimax-h3/reference-to-video`)
|
|
785
788
|
makes the clip to match the recording. Only that clip changes; the word check
|
|
786
789
|
and the other checks still run on it. It needs an ElevenLabs key and a voice for
|
|
@@ -898,6 +901,13 @@ Read these out of `storyboard.json` before you build anything, in any tool:
|
|
|
898
901
|
running order. To find the moment, locate the A-roll scene whose `lineIds`
|
|
899
902
|
contain the cue line, then read the start time of the first word of that line
|
|
900
903
|
out of that scene's `words.json`.
|
|
904
|
+
- **Reactions.** A cutaway with `cutawayType: "reaction"` is the exception. Do
|
|
905
|
+
not lay it over the speech. Put it into the running order as its own beat
|
|
906
|
+
right after its cue line ends, play its own sound (the audience laughing),
|
|
907
|
+
keep it to about 1.5 s, then carry on with the speaker. Nobody talks during
|
|
908
|
+
it. When more lines follow the cue line in the same scene, cut that scene
|
|
909
|
+
where the cue line's last word ends in its `words.json` and put the reaction
|
|
910
|
+
between the two halves.
|
|
901
911
|
- **Overlays and SFX.** `overlays[]` and `audio.sfxCues[]` are cued the same
|
|
902
912
|
way, by `cueLineId`. Nothing renders them for you.
|
|
903
913
|
- **Captions.** Nothing is burned into any clip. Captions belong in post; the
|
|
@@ -914,12 +924,20 @@ the pieces:
|
|
|
914
924
|
holds its last frame for the difference. The narration is never sped up,
|
|
915
925
|
slowed down or cut short, so every scene plays at the speed it was recorded,
|
|
916
926
|
and the word times are handed on as they were measured.
|
|
927
|
+
- **It closes the dead air at the joins.** A clip that plays its own dialogue
|
|
928
|
+
keeps about 0.15 s after its last word and about 0.1 s before its first (0.25 s
|
|
929
|
+
when a different speaker answers), read from its `words.json`, so one clip
|
|
930
|
+
runs into the next like one conversation. It only ever shortens a clip's
|
|
931
|
+
silence and never cuts into a word. The ad's first lead-in and last tail, a
|
|
932
|
+
narrated scene, a still and a reaction beat are left as they are.
|
|
917
933
|
- **It ignores the continuous master.** It reads only `scene-NN.voice` files;
|
|
918
934
|
it never opens `narration.mp3` or `manifest.narration`. A tool that lays the
|
|
919
935
|
master down whole and cuts the pictures to it keeps the read intact.
|
|
920
|
-
- Otherwise: A-roll in manifest order as the spine, each
|
|
921
|
-
|
|
922
|
-
|
|
936
|
+
- Otherwise: A-roll in manifest order as the spine, each reaction put in as its
|
|
937
|
+
own beat of at most 1.5 s right after its cue line with its own sound, each
|
|
938
|
+
other cutaway laid over the spine at its cued word (falling back to a
|
|
939
|
+
proportional estimate when the scene has no word times, and saying which
|
|
940
|
+
method it used), a narrated scene's voice
|
|
923
941
|
replacing whatever the clip recorded, a dialogue scene keeping its performed
|
|
924
942
|
line, a clip-less scene becoming a still, everything normalized to 1080×1920
|
|
925
943
|
at 24 fps and −16 LUFS per segment, a generated room-tone bed under the whole
|
|
@@ -931,14 +949,15 @@ the pieces:
|
|
|
931
949
|
ad with its start and end) and `cut.srt` (the same words grouped into subtitle
|
|
932
950
|
lines), named from the `--out` basename. Those times are on the finished ad's
|
|
933
951
|
timeline, each scene's own word times offset by where that scene starts in the
|
|
934
|
-
cut and otherwise unaltered. Scenes with no word times leave
|
|
952
|
+
cut, less any lead-in trimmed at the join, and otherwise unaltered. Scenes with no word times leave
|
|
935
953
|
gaps and are named in the report; when no scene delivered any, neither file is
|
|
936
954
|
written. These sidecars are the script's own output — another tool will not
|
|
937
955
|
produce them.
|
|
938
956
|
|
|
939
957
|
Before uploading anything, from any tool, check what you made:
|
|
940
958
|
`ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 cut.mp4`
|
|
941
|
-
should be close to the sum of the scene durations you kept
|
|
959
|
+
should be close to the sum of the scene durations you kept (the bundled
|
|
960
|
+
script's cut runs somewhat shorter, by the silence it trimmed at the joins). A cut that is a few
|
|
942
961
|
seconds long when five clips went in means a filter dropped inputs; do not
|
|
943
962
|
upload it.
|
|
944
963
|
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// A plain first cut from a folder written by `exodus video pull`: the A-roll
|
|
3
3
|
// scenes in the manifest's order make the spine, each cutaway lays over the
|
|
4
4
|
// spine at the moment its cued line is spoken, a room-tone bed and the music
|
|
5
|
-
// bed go underneath, and one MP4 comes out ready to upload.
|
|
5
|
+
// bed go underneath, and one MP4 comes out ready to upload. A reaction
|
|
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.
|
|
6
8
|
//
|
|
7
9
|
// node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
|
|
8
10
|
//
|
|
@@ -11,9 +13,11 @@
|
|
|
11
13
|
// recorded: the voice replaces the clip's audio, and the picture is fitted to
|
|
12
14
|
// the voice, trimmed when the clip runs longer and held on its last frame when
|
|
13
15
|
// the voice runs longer. The voice itself is never altered. A dialogue scene
|
|
14
|
-
// keeps the audio it performed
|
|
15
|
-
//
|
|
16
|
-
//
|
|
16
|
+
// keeps the audio it performed; at each join its silence before the first word
|
|
17
|
+
// and after the last is cut back to a short beat, read from its word times. A
|
|
18
|
+
// scene with no clip but a keyframe becomes a still for the length of its voice
|
|
19
|
+
// track, else the storyboard's planned duration. Every segment is
|
|
20
|
+
// loudness-normalized to -16 LUFS before the join.
|
|
17
21
|
// --skip drops scenes.
|
|
18
22
|
//
|
|
19
23
|
// After the render it writes the whole ad's word times beside the MP4, as
|
|
@@ -30,15 +34,39 @@ const FPS = 24;
|
|
|
30
34
|
const norm = (label) =>
|
|
31
35
|
`[${label}]scale=${WIDTH}:${HEIGHT}:force_original_aspect_ratio=decrease,` +
|
|
32
36
|
`pad=${WIDTH}:${HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${FPS},format=yuv420p`;
|
|
37
|
+
/**
|
|
38
|
+
* Edges the cut chooses (join trims, reaction splits, narration lengths) land
|
|
39
|
+
* on whole frames, so the picture runs as long as its sound there and the
|
|
40
|
+
* timeline's times are the cut's times.
|
|
41
|
+
*/
|
|
42
|
+
const floorFrame = (t) => Math.floor(t * FPS + 1e-6) / FPS;
|
|
43
|
+
const ceilFrame = (t) => Math.ceil(t * FPS - 1e-6) / FPS;
|
|
33
44
|
const stereo = "aresample=48000,aformat=channel_layouts=stereo";
|
|
34
|
-
/**
|
|
35
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Per-segment loudness, the level every clip was normalized to before the join.
|
|
47
|
+
* loudnorm stamps its output about 0.1 s late (its look-ahead), so a later
|
|
48
|
+
* atrim by time kept that much less sound than picture and every join pulled
|
|
49
|
+
* the sound further ahead (#2387). Re-stamping by sample count undoes it.
|
|
50
|
+
*/
|
|
51
|
+
const LOUDNORM = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000,asetpts=N/SR/TB";
|
|
52
|
+
/** A fade this short at each edge of a segment's sound stops a hard cut in the room tone clicking. */
|
|
53
|
+
const EDGE_FADE_SEC = 0.015;
|
|
36
54
|
/** Air after the last spoken word of a narrated scene, so the line lands. */
|
|
37
55
|
export const TAIL_AIR_SEC = 0.8;
|
|
38
56
|
/** A continuous low bed under the whole ad, so the joins between clips do not read as dead air. */
|
|
39
57
|
const ROOM_TONE =
|
|
40
58
|
"anoisesrc=color=brown:amplitude=0.0035:r=48000,highpass=f=60,lowpass=f=900,aformat=channel_layouts=stereo";
|
|
41
59
|
const MUSIC_VOLUME = 0.18;
|
|
60
|
+
/** The longest a reaction beat holds the spine (#2357); older pulls carry 4 s reaction clips. */
|
|
61
|
+
export const REACTION_MAX_SEC = 1.5;
|
|
62
|
+
/**
|
|
63
|
+
* The air a join keeps around its words (#2388): after one clip's last word, and
|
|
64
|
+
* before the next clip's first, less when the same speaker carries on than when
|
|
65
|
+
* someone new answers.
|
|
66
|
+
*/
|
|
67
|
+
export const JOIN_TAIL_SEC = 0.15;
|
|
68
|
+
export const JOIN_LEAD_SAME_SPEAKER_SEC = 0.1;
|
|
69
|
+
export const JOIN_LEAD_NEW_SPEAKER_SEC = 0.25;
|
|
42
70
|
|
|
43
71
|
/** The word rule the planner sized every line with (scout models.ts wordCount). */
|
|
44
72
|
export function wordCount(text) {
|
|
@@ -77,11 +105,17 @@ export function scenePlans(storyboard) {
|
|
|
77
105
|
const plans = new Map();
|
|
78
106
|
for (const scene of storyboard?.scenes ?? []) {
|
|
79
107
|
if (typeof scene?.sceneIndex !== "number") continue;
|
|
108
|
+
const speakers = (Array.isArray(scene.dialogue) ? scene.dialogue : [])
|
|
109
|
+
.filter((turn) => hasText(turn?.line) || hasText(turn?.text))
|
|
110
|
+
.map((turn) => (hasText(turn.speaker) ? turn.speaker.trim() : null));
|
|
80
111
|
plans.set(scene.sceneIndex, {
|
|
112
|
+
firstSpeaker: speakers[0] ?? null,
|
|
113
|
+
lastSpeaker: speakers.at(-1) ?? null,
|
|
81
114
|
cutaway: CUTAWAY_KINDS.has(scene.kind),
|
|
82
115
|
narrated: narratedScene(scene, storyboard),
|
|
83
116
|
lineIds: Array.isArray(scene.lineIds) ? scene.lineIds : [],
|
|
84
117
|
cueLineId: typeof scene.cueLineId === "string" ? scene.cueLineId : null,
|
|
118
|
+
cutawayType: typeof scene.cutawayType === "string" ? scene.cutawayType : null,
|
|
85
119
|
durationSec: typeof scene.durationSec === "number" ? scene.durationSec : null,
|
|
86
120
|
});
|
|
87
121
|
}
|
|
@@ -90,12 +124,12 @@ export function scenePlans(storyboard) {
|
|
|
90
124
|
|
|
91
125
|
/**
|
|
92
126
|
* Fit a picture to its voice track. The segment always runs as long as the
|
|
93
|
-
* voice plus a beat of air, because the voice is never
|
|
94
|
-
* runs longer is trimmed; a clip that runs shorter holds
|
|
95
|
-
* difference. A still (no clip) simply runs the voice's length.
|
|
127
|
+
* voice plus a beat of air, to the next whole frame, because the voice is never
|
|
128
|
+
* altered. A clip that runs longer is trimmed; a clip that runs shorter holds
|
|
129
|
+
* its last frame for the difference. A still (no clip) simply runs the voice's length.
|
|
96
130
|
*/
|
|
97
131
|
export function fitNarration({ clipSeconds, voiceSeconds }) {
|
|
98
|
-
const target = voiceSeconds + TAIL_AIR_SEC;
|
|
132
|
+
const target = ceilFrame(voiceSeconds + TAIL_AIR_SEC);
|
|
99
133
|
if (clipSeconds === null) return { seconds: target, holdSec: 0, trimmedBy: 0 };
|
|
100
134
|
if (target <= clipSeconds) {
|
|
101
135
|
return { seconds: target, holdSec: 0, trimmedBy: clipSeconds - target };
|
|
@@ -126,6 +160,108 @@ export function cueOffsetOnSpine(spine, counts, cueLineId) {
|
|
|
126
160
|
return null;
|
|
127
161
|
}
|
|
128
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Where a segment's spoken cue line ends, in seconds into the segment, or null
|
|
165
|
+
* when the segment does not speak it. A cue line that closes the segment ends
|
|
166
|
+
* where the segment does.
|
|
167
|
+
*/
|
|
168
|
+
export function cueEndInSegment(seg, counts, cueLineId) {
|
|
169
|
+
const idx = seg.lineIds.indexOf(cueLineId);
|
|
170
|
+
if (idx === -1) return null;
|
|
171
|
+
if (idx === seg.lineIds.length - 1) return seg.seconds;
|
|
172
|
+
let through = 0;
|
|
173
|
+
let totalWords = 0;
|
|
174
|
+
seg.lineIds.forEach((id, i) => {
|
|
175
|
+
const n = counts.get(id) ?? 0;
|
|
176
|
+
totalWords += n;
|
|
177
|
+
if (i <= idx) through += n;
|
|
178
|
+
});
|
|
179
|
+
if (seg.words && seg.words.length > 0) {
|
|
180
|
+
const last = seg.words[Math.min(Math.max(through - 1, 0), seg.words.length - 1)];
|
|
181
|
+
return Math.min(Math.max(0, last?.e ?? 0), seg.seconds);
|
|
182
|
+
}
|
|
183
|
+
return totalWords > 0 ? (through / totalWords) * seg.seconds : seg.seconds;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Cut the dead air out of each join between clips that play their own sound: a
|
|
188
|
+
* clip's lead-in silence stacked on the previous clip's tail ran past a second
|
|
189
|
+
* (#2388). Each side comes back to a beat that reads as one conversation, a
|
|
190
|
+
* longer one when someone new answers. Only ever shortens, never into a word,
|
|
191
|
+
* and leaves the ad's first lead-in and last tail alone; a clip whose closing
|
|
192
|
+
* line cues a reaction is not the ad's end, since the beat follows it. Trims
|
|
193
|
+
* land on whole frames so the picture and its sound stay the same length.
|
|
194
|
+
*/
|
|
195
|
+
function trimJoins(spine, reactionCues) {
|
|
196
|
+
spine.forEach((seg, k) => {
|
|
197
|
+
if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) return;
|
|
198
|
+
const prev = spine[k - 1];
|
|
199
|
+
const sameSpeaker = Boolean(prev?.lastSpeaker) && prev.lastSpeaker === seg.firstSpeaker;
|
|
200
|
+
const lead = sameSpeaker ? JOIN_LEAD_SAME_SPEAKER_SEC : JOIN_LEAD_NEW_SPEAKER_SEC;
|
|
201
|
+
const firstWord = Math.min(...seg.words.map((w) => w.s));
|
|
202
|
+
const lastWord = Math.max(...seg.words.map((w) => w.e));
|
|
203
|
+
const inSec = prev ? Math.max(0, floorFrame(firstWord - lead)) : 0;
|
|
204
|
+
const endsAd = k === spine.length - 1 && !reactionCues.has(seg.lineIds.at(-1));
|
|
205
|
+
const outSec = endsAd ? seg.seconds : Math.min(seg.seconds, ceilFrame(lastWord + JOIN_TAIL_SEC));
|
|
206
|
+
if (outSec <= inSec) return;
|
|
207
|
+
const how = [];
|
|
208
|
+
if (inSec > 0) how.push(`lead-in trimmed ${inSec.toFixed(2)}s`);
|
|
209
|
+
if (outSec < seg.seconds) how.push(`tail trimmed ${(seg.seconds - outSec).toFixed(2)}s`);
|
|
210
|
+
if (how.length === 0) return;
|
|
211
|
+
seg.inSec = inSec;
|
|
212
|
+
seg.seconds = outSec - inSec;
|
|
213
|
+
seg.words = seg.words.map((w) => ({ ...w, s: w.s - inSec, e: w.e - inSec }));
|
|
214
|
+
seg.note = `${seg.note} (${how.join(", ")})`;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const holdFor = (seg) =>
|
|
219
|
+
seg.source === "clip" ? Math.max(0, seg.seconds - Math.max(0, seg.clipSeconds - seg.inSec)) : 0;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Put a reaction beat into the spine right after its cue line ends, splitting
|
|
223
|
+
* the segment that speaks it when more lines follow in that segment. Returns
|
|
224
|
+
* the scene that speaks the cue, or null when no segment does.
|
|
225
|
+
*/
|
|
226
|
+
function placeReaction(spine, counts, reaction) {
|
|
227
|
+
for (let k = 0; k < spine.length; k++) {
|
|
228
|
+
const seg = spine[k];
|
|
229
|
+
if (seg.reaction) continue;
|
|
230
|
+
const cueEnd = cueEndInSegment(seg, counts, reaction.cueLineId);
|
|
231
|
+
if (cueEnd === null) continue;
|
|
232
|
+
const cut = Math.min(seg.seconds, ceilFrame(cueEnd));
|
|
233
|
+
const beat = {
|
|
234
|
+
sceneIndex: reaction.sceneIndex, source: reaction.source, file: reaction.file,
|
|
235
|
+
clipSeconds: reaction.source === "clip" ? reaction.seconds : null, inSec: 0,
|
|
236
|
+
seconds: Math.min(reaction.seconds, REACTION_MAX_SEC), holdSec: 0,
|
|
237
|
+
audio: reaction.hasAudio ? "clip" : "silence", voice: null, words: null, lineIds: [],
|
|
238
|
+
reaction: { cueLineId: reaction.cueLineId, cueSceneIndex: seg.sceneIndex },
|
|
239
|
+
};
|
|
240
|
+
if (cut >= seg.seconds - 0.05) {
|
|
241
|
+
spine.splice(k + 1, 0, beat);
|
|
242
|
+
return seg.sceneIndex;
|
|
243
|
+
}
|
|
244
|
+
const idx = seg.lineIds.indexOf(reaction.cueLineId);
|
|
245
|
+
const head = {
|
|
246
|
+
...seg, seconds: cut, lineIds: seg.lineIds.slice(0, idx + 1),
|
|
247
|
+
words: seg.words ? seg.words.filter((w) => w.s < cut) : null,
|
|
248
|
+
note: `${seg.note}, until ${reaction.cueLineId} ends at ${cut.toFixed(2)}s`,
|
|
249
|
+
};
|
|
250
|
+
const tail = {
|
|
251
|
+
...seg, inSec: seg.inSec + cut, seconds: seg.seconds - cut, lineIds: seg.lineIds.slice(idx + 1),
|
|
252
|
+
words: seg.words
|
|
253
|
+
? seg.words.filter((w) => w.s >= cut).map((w) => ({ ...w, s: w.s - cut, e: w.e - cut }))
|
|
254
|
+
: null,
|
|
255
|
+
note: `${seg.note}, resumed at ${cut.toFixed(2)}s after reaction ${reaction.sceneIndex}`,
|
|
256
|
+
};
|
|
257
|
+
head.holdSec = holdFor(head);
|
|
258
|
+
tail.holdSec = holdFor(tail);
|
|
259
|
+
spine.splice(k, 1, head, beat, tail);
|
|
260
|
+
return seg.sceneIndex;
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
129
265
|
export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords }) {
|
|
130
266
|
const plans = scenePlans(storyboard);
|
|
131
267
|
const counts = lineWordCounts(storyboard);
|
|
@@ -133,11 +269,13 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
133
269
|
const cutaways = [];
|
|
134
270
|
const waiting = [];
|
|
135
271
|
const dropped = [];
|
|
136
|
-
let startSec = 0;
|
|
137
272
|
|
|
138
273
|
for (const scene of manifest?.scenes ?? []) {
|
|
139
274
|
const n = scene.sceneIndex;
|
|
140
|
-
const plan = plans.get(n) ?? {
|
|
275
|
+
const plan = plans.get(n) ?? {
|
|
276
|
+
cutaway: false, lineIds: [], cueLineId: null, cutawayType: null, durationSec: null,
|
|
277
|
+
firstSpeaker: null, lastSpeaker: null,
|
|
278
|
+
};
|
|
141
279
|
if (skip.has(n)) {
|
|
142
280
|
dropped.push({ n, reason: `scene ${n}: skipped by --skip` });
|
|
143
281
|
continue;
|
|
@@ -145,15 +283,17 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
145
283
|
const nothing = `nothing to cut with (clip ${scene.clipStatus}${scene.error ? `: ${scene.error}` : ""})`;
|
|
146
284
|
|
|
147
285
|
if (plan.cutaway) {
|
|
286
|
+
const cue = { cueLineId: plan.cueLineId, reaction: plan.cutawayType === "reaction" };
|
|
148
287
|
if (exists(scene.clip)) {
|
|
288
|
+
const clip = probe(scene.clip);
|
|
149
289
|
waiting.push({
|
|
150
290
|
sceneIndex: n, source: "clip", file: scene.clip,
|
|
151
|
-
seconds:
|
|
291
|
+
seconds: clip.seconds, hasAudio: clip.hasAudio, ...cue,
|
|
152
292
|
});
|
|
153
293
|
} else if (exists(scene.keyframe)) {
|
|
154
294
|
waiting.push({
|
|
155
295
|
sceneIndex: n, source: "still", file: scene.keyframe,
|
|
156
|
-
seconds: plan.durationSec ?? 4,
|
|
296
|
+
seconds: plan.durationSec ?? 4, hasAudio: false, ...cue,
|
|
157
297
|
});
|
|
158
298
|
} else {
|
|
159
299
|
dropped.push({ n, reason: `cutaway ${n}: ${nothing}` });
|
|
@@ -168,8 +308,8 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
168
308
|
if (exists(scene.clip)) {
|
|
169
309
|
const clip = probe(scene.clip);
|
|
170
310
|
const base = {
|
|
171
|
-
sceneIndex: n, source: "clip", file: scene.clip, clipSeconds: clip.seconds,
|
|
172
|
-
|
|
311
|
+
sceneIndex: n, source: "clip", file: scene.clip, clipSeconds: clip.seconds, inSec: 0,
|
|
312
|
+
lineIds: plan.lineIds, firstSpeaker: plan.firstSpeaker, lastSpeaker: plan.lastSpeaker,
|
|
173
313
|
};
|
|
174
314
|
if (voice && (narrated || !clip.hasAudio)) {
|
|
175
315
|
const fit = fitNarration({ clipSeconds: clip.seconds, voiceSeconds: probe(voice).seconds });
|
|
@@ -198,7 +338,6 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
198
338
|
words: null, note: `${flag}, silent clip`,
|
|
199
339
|
});
|
|
200
340
|
}
|
|
201
|
-
startSec += spine[spine.length - 1].seconds;
|
|
202
341
|
continue;
|
|
203
342
|
}
|
|
204
343
|
if (exists(scene.keyframe)) {
|
|
@@ -206,39 +345,59 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
206
345
|
? fitNarration({ clipSeconds: null, voiceSeconds: probe(voice).seconds })
|
|
207
346
|
: { seconds: plan.durationSec ?? 4, holdSec: 0 };
|
|
208
347
|
spine.push({
|
|
209
|
-
sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null,
|
|
348
|
+
sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null, inSec: 0,
|
|
210
349
|
seconds: fit.seconds, audio: voice ? "narration" : "silence", voice, holdSec: 0,
|
|
211
|
-
|
|
350
|
+
lineIds: plan.lineIds,
|
|
212
351
|
// #1689: on a narrated scene the words are the voice track's, and the
|
|
213
352
|
// voice starts where the segment does, so they need no offset here.
|
|
214
353
|
words: voice ? readWords(scene.words) : null,
|
|
215
354
|
note: `${flag}${voice ? ` + ${voice}` : ", silent"} (${fit.seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
|
|
216
355
|
});
|
|
217
|
-
startSec += fit.seconds;
|
|
218
356
|
continue;
|
|
219
357
|
}
|
|
220
358
|
dropped.push({ n, reason: `scene ${n}: ${nothing}` });
|
|
221
359
|
}
|
|
222
360
|
|
|
223
|
-
|
|
361
|
+
const noCue = (c) =>
|
|
362
|
+
c.cueLineId ? `${c.cueLineId} is spoken by no scene in the cut` : "the storyboard names no cue line";
|
|
363
|
+
|
|
364
|
+
// Joins are closed first so a reaction beat lands right after its cue line
|
|
365
|
+
// ends in the trimmed clip. Reactions change the spine, so they go in before
|
|
366
|
+
// any overlay is placed on it.
|
|
367
|
+
trimJoins(spine, new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId)));
|
|
368
|
+
for (const c of waiting.filter((w) => w.reaction)) {
|
|
369
|
+
if (!c.cueLineId || placeReaction(spine, counts, c) === null) {
|
|
370
|
+
dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
let total = 0;
|
|
374
|
+
for (const seg of spine) {
|
|
375
|
+
seg.startSec = total;
|
|
376
|
+
total += seg.seconds;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for (const c of waiting.filter((w) => !w.reaction)) {
|
|
224
380
|
const cue = c.cueLineId ? cueOffsetOnSpine(spine, counts, c.cueLineId) : null;
|
|
225
381
|
if (!cue) {
|
|
226
|
-
|
|
227
|
-
? `${c.cueLineId} is spoken by no scene in the cut`
|
|
228
|
-
: "the storyboard names no cue line";
|
|
229
|
-
dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${why}` });
|
|
382
|
+
dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
|
|
230
383
|
continue;
|
|
231
384
|
}
|
|
232
385
|
cutaways.push({ ...c, startSec: cue.startSec, method: cue.method, spineSceneIndex: cue.spineSceneIndex });
|
|
233
386
|
}
|
|
234
387
|
cutaways.sort((a, b) => a.startSec - b.startSec);
|
|
388
|
+
const beats = spine.filter((seg) => seg.reaction);
|
|
235
389
|
for (let i = 0; i < cutaways.length; i++) {
|
|
236
390
|
const c = cutaways[i];
|
|
391
|
+
const stops = [];
|
|
237
392
|
const next = cutaways[i + 1];
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
393
|
+
if (next) stops.push({ at: next.startSec, by: `cutaway ${next.sceneIndex} starts` });
|
|
394
|
+
const beat = beats.find((b) => b.startSec >= c.startSec);
|
|
395
|
+
if (beat) stops.push({ at: beat.startSec, by: `reaction ${beat.sceneIndex} starts` });
|
|
396
|
+
stops.push({ at: total, by: "the ad ends" });
|
|
397
|
+
const stop = stops.reduce((a, b) => (b.at < a.at ? b : a));
|
|
398
|
+
if (c.startSec + c.seconds > stop.at) {
|
|
399
|
+
c.seconds = Math.max(0, stop.at - c.startSec);
|
|
400
|
+
c.cutShortBy = stop.by;
|
|
242
401
|
}
|
|
243
402
|
}
|
|
244
403
|
|
|
@@ -269,16 +428,28 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
269
428
|
? addInput("-i", resolve(seg.file))
|
|
270
429
|
: addInput("-loop", "1", "-t", String(seg.seconds), "-i", resolve(seg.file));
|
|
271
430
|
const fit = [];
|
|
272
|
-
|
|
273
|
-
|
|
431
|
+
const inSec = seg.inSec ?? 0;
|
|
432
|
+
if (seg.source === "clip" && seg.clipSeconds !== null) {
|
|
433
|
+
const shown = seg.seconds - seg.holdSec;
|
|
434
|
+
if (inSec > 0.001 || shown < seg.clipSeconds - 0.01) {
|
|
435
|
+
// By frame number: a time printed to the millisecond can round past the
|
|
436
|
+
// frame it names and drop it.
|
|
437
|
+
const from = Math.round(inSec * FPS);
|
|
438
|
+
fit.push(`trim=start_frame=${from}:end_frame=${Math.round((inSec + shown) * FPS)},setpts=PTS-STARTPTS`);
|
|
439
|
+
}
|
|
274
440
|
}
|
|
275
441
|
if (seg.holdSec > 0.01) fit.push(`tpad=stop_mode=clone:stop_duration=${sec(seg.holdSec)}`);
|
|
276
442
|
filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
|
|
443
|
+
// A segment split around a reaction beat starts partway into its sound too.
|
|
444
|
+
const skipIn = inSec > 0.001 ? `atrim=start=${sec(inSec)},asetpts=PTS-STARTPTS,` : "";
|
|
445
|
+
const fitSound =
|
|
446
|
+
`${LOUDNORM},apad,atrim=0:${sec(seg.seconds)},afade=t=in:d=${EDGE_FADE_SEC},` +
|
|
447
|
+
`afade=t=out:st=${sec(seg.seconds - EDGE_FADE_SEC)}:d=${EDGE_FADE_SEC}`;
|
|
277
448
|
if (seg.audio === "narration") {
|
|
278
449
|
const v = addInput("-i", resolve(seg.voice));
|
|
279
|
-
filters.push(`[${v}:a]${stereo},${
|
|
450
|
+
filters.push(`[${v}:a]${stereo},${skipIn}${fitSound}[a${i}]`);
|
|
280
451
|
} else if (seg.audio === "clip") {
|
|
281
|
-
filters.push(`[${i}:a]${stereo},${
|
|
452
|
+
filters.push(`[${i}:a]${stereo},${skipIn}${fitSound}[a${i}]`);
|
|
282
453
|
} else {
|
|
283
454
|
filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${sec(seg.seconds)}[a${i}]`);
|
|
284
455
|
}
|
|
@@ -398,6 +569,16 @@ export function inTheCut(timeline) {
|
|
|
398
569
|
const rows = [];
|
|
399
570
|
for (const seg of timeline.spine) {
|
|
400
571
|
const label = seg.source === "clip" ? seg.file : `still ${seg.file}`;
|
|
572
|
+
if (seg.reaction) {
|
|
573
|
+
const sound = seg.audio === "clip" ? "its own sound" : "silent: the clip has no sound";
|
|
574
|
+
rows.push({
|
|
575
|
+
n: seg.sceneIndex,
|
|
576
|
+
line:
|
|
577
|
+
`reaction ${seg.sceneIndex}: ${label} for ${seg.seconds.toFixed(1)}s after ` +
|
|
578
|
+
`${seg.reaction.cueLineId} in scene ${seg.reaction.cueSceneIndex}, ${sound}`,
|
|
579
|
+
});
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
401
582
|
rows.push({ n: seg.sceneIndex, line: `scene ${seg.sceneIndex}: ${label}${seg.note}` });
|
|
402
583
|
}
|
|
403
584
|
for (const c of timeline.cutaways) {
|
|
@@ -420,7 +601,7 @@ export function inTheCut(timeline) {
|
|
|
420
601
|
export function noWordTimes(timeline) {
|
|
421
602
|
const rows = timeline.spine
|
|
422
603
|
.filter((seg) => !seg.words || seg.words.length === 0)
|
|
423
|
-
.map((seg) => `scene ${seg.sceneIndex} (no words.json)`);
|
|
604
|
+
.map((seg) => `scene ${seg.sceneIndex} (${seg.reaction ? "reaction" : "no words.json"})`);
|
|
424
605
|
for (const c of timeline.cutaways) rows.push(`scene ${c.sceneIndex} (cutaway)`);
|
|
425
606
|
return rows;
|
|
426
607
|
}
|
|
@@ -478,11 +659,14 @@ function main() {
|
|
|
478
659
|
};
|
|
479
660
|
const probe = (file) => {
|
|
480
661
|
const raw = execFileSync("ffprobe", [
|
|
481
|
-
"-v", "error", "-show_entries", "stream=codec_type:format=duration", "-of", "json", here(file),
|
|
662
|
+
"-v", "error", "-show_entries", "stream=codec_type,duration:stream_disposition=attached_pic:format=duration", "-of", "json", here(file),
|
|
482
663
|
]).toString();
|
|
483
664
|
const info = JSON.parse(raw);
|
|
665
|
+
// A clip's container runs a few ms past its picture on the audio's padding;
|
|
666
|
+
// the picture is what the cut shows, so its length is the clip's length.
|
|
667
|
+
const picture = Number((info.streams ?? []).find((s) => s.codec_type === "video" && !s.disposition?.attached_pic)?.duration);
|
|
484
668
|
return {
|
|
485
|
-
seconds: Number(info.format?.duration ?? 0),
|
|
669
|
+
seconds: picture > 0 ? picture : Number(info.format?.duration ?? 0),
|
|
486
670
|
hasAudio: (info.streams ?? []).some((s) => s.codec_type === "audio"),
|
|
487
671
|
};
|
|
488
672
|
};
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -257,7 +257,7 @@ export declare function resolveStopAtPark(stop: RunStop, runId: string, deps: Pi
|
|
|
257
257
|
export declare function stageWord(stage: string): string;
|
|
258
258
|
export declare function stepName(kind: string | undefined): string;
|
|
259
259
|
export declare function isShowAd(run: Pick<VideoRun, "moduleOwned">): boolean;
|
|
260
|
-
export declare function reviewUrl(dashboardUrl: string, run: Pick<VideoRun, "_id" | "workflowId"
|
|
260
|
+
export declare function reviewUrl(dashboardUrl: string, run: Pick<VideoRun, "_id" | "workflowId">): string;
|
|
261
261
|
export declare function stopLines(stop: ResolvedStop, runId: string, runUrl: string): string[];
|
|
262
262
|
export declare function failedStoryboardNode(run: VideoRun): VideoRunNode | undefined;
|
|
263
263
|
export declare function errorEchoesReasons(error: string | undefined, record: PlanFailureRecord | undefined): boolean;
|
package/dist/commands/video.js
CHANGED
|
@@ -410,8 +410,6 @@ export function isShowAd(run) {
|
|
|
410
410
|
return run.moduleOwned === true;
|
|
411
411
|
}
|
|
412
412
|
export function reviewUrl(dashboardUrl, run) {
|
|
413
|
-
if (isShowAd(run))
|
|
414
|
-
return `${dashboardUrl}/video?ad=${run._id}`;
|
|
415
413
|
if (run.workflowId)
|
|
416
414
|
return `${dashboardUrl}/workflows/${run.workflowId}/runs/${run._id}`;
|
|
417
415
|
return `${dashboardUrl}/runs/${run._id}`;
|