@aicopycoders/exodus 2026.9.2000-beta.2 → 2026.9.2000-beta.21
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 +2 -1
- package/assets/skills/exodus-meme/SKILL.md +4 -3
- package/assets/skills/exodus-template/SKILL.md +2 -1
- package/assets/skills/exodus-video/SKILL.md +201 -39
- package/assets/skills/exodus-video/scripts/first-cut.mjs +538 -38
- package/assets/skills/exodus-workflow/SKILL.md +1 -1
- package/dist/commands/ads.js +1 -1
- package/dist/commands/image.js +1 -1
- package/dist/commands/template.js +11 -4
- package/dist/commands/video.d.ts +137 -7
- package/dist/commands/video.js +778 -64
- package/dist/commands/workflow.d.ts +53 -4
- package/dist/commands/workflow.js +76 -10
- package/dist/lib/helperLedgerBase.d.ts +3 -0
- package/dist/lib/helperLedgerBase.js +3 -0
- package/dist/lib/meta-ads.d.ts +1 -0
- package/package.json +1 -1
|
@@ -2,7 +2,10 @@
|
|
|
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,
|
|
8
|
+
// and its sound fades out quietly under the next line rather than stopping.
|
|
6
9
|
//
|
|
7
10
|
// node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
|
|
8
11
|
//
|
|
@@ -11,9 +14,15 @@
|
|
|
11
14
|
// recorded: the voice replaces the clip's audio, and the picture is fitted to
|
|
12
15
|
// the voice, trimmed when the clip runs longer and held on its last frame when
|
|
13
16
|
// the voice runs longer. The voice itself is never altered. A dialogue scene
|
|
14
|
-
// keeps the audio it performed
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
+
// keeps the audio it performed; at each join its silence before the first word
|
|
18
|
+
// and after the last is cut back to a short beat, read from its word times.
|
|
19
|
+
// Inside it, a silence over 0.9 s between two sentences is cut down to 0.8 s,
|
|
20
|
+
// picture and sound together, unless the script writes that pause in. A
|
|
21
|
+
// sound in such a break that holds no heard word (a burp, a click) is cut
|
|
22
|
+
// out, read from the clip's loudness, and the same rule then applies. A
|
|
23
|
+
// scene with no clip but a keyframe becomes a still for the length of its voice
|
|
24
|
+
// track, else the storyboard's planned duration. Every segment is
|
|
25
|
+
// loudness-normalized to -16 LUFS before the join.
|
|
17
26
|
// --skip drops scenes.
|
|
18
27
|
//
|
|
19
28
|
// After the render it writes the whole ad's word times beside the MP4, as
|
|
@@ -30,15 +39,48 @@ const FPS = 24;
|
|
|
30
39
|
const norm = (label) =>
|
|
31
40
|
`[${label}]scale=${WIDTH}:${HEIGHT}:force_original_aspect_ratio=decrease,` +
|
|
32
41
|
`pad=${WIDTH}:${HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${FPS},format=yuv420p`;
|
|
42
|
+
/**
|
|
43
|
+
* Edges the cut chooses (join trims, reaction splits, narration lengths) land
|
|
44
|
+
* on whole frames, so the picture runs as long as its sound there and the
|
|
45
|
+
* timeline's times are the cut's times.
|
|
46
|
+
*/
|
|
47
|
+
const floorFrame = (t) => Math.floor(t * FPS + 1e-6) / FPS;
|
|
48
|
+
const ceilFrame = (t) => Math.ceil(t * FPS - 1e-6) / FPS;
|
|
33
49
|
const stereo = "aresample=48000,aformat=channel_layouts=stereo";
|
|
34
|
-
/**
|
|
35
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Per-segment loudness, the level every clip was normalized to before the join.
|
|
52
|
+
* loudnorm stamps its output about 0.1 s late (its look-ahead), so a later
|
|
53
|
+
* atrim by time kept that much less sound than picture and every join pulled
|
|
54
|
+
* the sound further ahead (#2387). Re-stamping by sample count undoes it.
|
|
55
|
+
*/
|
|
56
|
+
const LOUDNORM = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000,asetpts=N/SR/TB";
|
|
57
|
+
/** A fade this short at each edge of a segment's sound stops a hard cut in the room tone clicking. */
|
|
58
|
+
const EDGE_FADE_SEC = 0.015;
|
|
36
59
|
/** Air after the last spoken word of a narrated scene, so the line lands. */
|
|
37
60
|
export const TAIL_AIR_SEC = 0.8;
|
|
38
61
|
/** A continuous low bed under the whole ad, so the joins between clips do not read as dead air. */
|
|
39
62
|
const ROOM_TONE =
|
|
40
63
|
"anoisesrc=color=brown:amplitude=0.0035:r=48000,highpass=f=60,lowpass=f=900,aformat=channel_layouts=stereo";
|
|
41
64
|
const MUSIC_VOLUME = 0.18;
|
|
65
|
+
/** The longest a reaction beat holds the spine (#2357); older pulls carry 4 s reaction clips. */
|
|
66
|
+
export const REACTION_MAX_SEC = 1.5;
|
|
67
|
+
/**
|
|
68
|
+
* A reaction's laugh carries into the next line instead of stopping dead at
|
|
69
|
+
* the join (#2406): it starts well under the speaker, at this share of its own
|
|
70
|
+
* level (about -9 dB), and fades to silence over this long.
|
|
71
|
+
*/
|
|
72
|
+
export const REACTION_TAIL_SEC = 0.75;
|
|
73
|
+
export const REACTION_TAIL_GAIN = 0.35;
|
|
74
|
+
/**
|
|
75
|
+
* The air a join keeps around its words (#2388): after one clip's last word, and
|
|
76
|
+
* before the next clip's first, less when the same speaker carries on than when
|
|
77
|
+
* someone new answers.
|
|
78
|
+
*/
|
|
79
|
+
export const JOIN_TAIL_SEC = 0.15;
|
|
80
|
+
export const JOIN_LEAD_SAME_SPEAKER_SEC = 0.1;
|
|
81
|
+
export const JOIN_LEAD_NEW_SPEAKER_SEC = 0.25;
|
|
82
|
+
/** The gap a cut keeps from an extra heard sound it leaves out (#2541). */
|
|
83
|
+
const EXTRA_CLEARANCE_SEC = 0.05;
|
|
42
84
|
|
|
43
85
|
/** The word rule the planner sized every line with (scout models.ts wordCount). */
|
|
44
86
|
export function wordCount(text) {
|
|
@@ -54,6 +96,226 @@ export function lineWordCounts(storyboard) {
|
|
|
54
96
|
return counts;
|
|
55
97
|
}
|
|
56
98
|
|
|
99
|
+
const wordToken = (word) => String(word ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* #2541: the indexes of the heard words that deliver the first and last
|
|
103
|
+
* scripted word, matching the script in order at its earliest delivery, or null
|
|
104
|
+
* when the whole script is not heard that way.
|
|
105
|
+
*/
|
|
106
|
+
export function scriptedSpan(words, lineTexts) {
|
|
107
|
+
const script = lineTexts.flatMap((text) => (text ?? "").split(/\s+/)).map(wordToken).filter(Boolean);
|
|
108
|
+
const at = heardAt(words, script);
|
|
109
|
+
return at && at.length > 0 ? { first: at[0], last: at.at(-1) } : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The heard word that delivers each script token, in order at its earliest delivery, or null. */
|
|
113
|
+
function heardAt(words, tokens) {
|
|
114
|
+
const at = [];
|
|
115
|
+
let j = 0;
|
|
116
|
+
for (const token of tokens) {
|
|
117
|
+
while (j < words.length && wordToken(words[j].w) !== token) j++;
|
|
118
|
+
if (j === words.length) return null;
|
|
119
|
+
at.push(j++);
|
|
120
|
+
}
|
|
121
|
+
return at;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** #2544: a silence between two sentences longer than this is cut down to the kept length. */
|
|
125
|
+
export const SENTENCE_GAP_MAX_SEC = 0.9;
|
|
126
|
+
export const SENTENCE_GAP_KEEP_SEC = 0.8;
|
|
127
|
+
/** A pause the script writes in: an ellipsis, a dash, or a hyphen standing between words. */
|
|
128
|
+
const WRITTEN_PAUSE = /…|\.{3}|[—–-]/;
|
|
129
|
+
const SENTENCE_END = /[.?!]/;
|
|
130
|
+
/**
|
|
131
|
+
* #2554: loudness windows, and the levels that read as a sound and as quiet;
|
|
132
|
+
* SETTLED_WINDOWS quiet windows in a row (200 ms) end a sound (scout trim.ts).
|
|
133
|
+
*/
|
|
134
|
+
const LEVEL_WINDOW_SEC = 0.02;
|
|
135
|
+
const SOUND_DB = -35;
|
|
136
|
+
const QUIET_DB = -50;
|
|
137
|
+
const SETTLED_WINDOWS = 10;
|
|
138
|
+
/** The directions that ask for a pause (scout clip-qc.ts PAUSE_DIRECTION, NO_PAUSE_DIRECTION, lineWrittenPause). */
|
|
139
|
+
const PAUSE_DIRECTION =
|
|
140
|
+
/\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;
|
|
141
|
+
const NO_PAUSE_DIRECTION =
|
|
142
|
+
/\b(no|not|without|never|don'?t|avoid\w*)\b[^.;]*\b(beats?|paus\w*|silence)\b|\bpause-?(free|less)\b/i;
|
|
143
|
+
|
|
144
|
+
export function asksPause(line) {
|
|
145
|
+
const direction = `${line?.stageDirection ?? ""} ${line?.performanceDirection ?? ""}`;
|
|
146
|
+
return PAUSE_DIRECTION.test(direction) && !NO_PAUSE_DIRECTION.test(direction);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* #2544: the silences inside one clip to cut, as [{ s, e, after, gap }] in the
|
|
151
|
+
* clip's own time. A heard silence over SENTENCE_GAP_MAX_SEC between two
|
|
152
|
+
* scripted words at a sentence break (a new line, or . ? ! between them) keeps
|
|
153
|
+
* half of SENTENCE_GAP_KEEP_SEC after the earlier word and half before the
|
|
154
|
+
* later one; the rest goes. Kept: a pause the script writes in (a mark between
|
|
155
|
+
* the words, or a direction on either line asking for one), the laugh beat
|
|
156
|
+
* after a line a reaction is cued to, and any gap with another heard sound in
|
|
157
|
+
* it. Nothing is cut when the script is not heard in order.
|
|
158
|
+
*
|
|
159
|
+
* #2554: given the clip's loudness ({ windowSec, db } from 0), a sound in a
|
|
160
|
+
* break that holds no heard word is cut too, whatever the break's length, and
|
|
161
|
+
* the silence left follows the same rule. Every span of one break carries that
|
|
162
|
+
* break's gap, the silence it keeps, and whether a sound went.
|
|
163
|
+
*
|
|
164
|
+
* lines: [{ text, asksPause, cuesBeat }] in the order the clip speaks them.
|
|
165
|
+
*/
|
|
166
|
+
export function sentenceGapCuts(words, lines, levels = null) {
|
|
167
|
+
const tokens = [];
|
|
168
|
+
let between = "";
|
|
169
|
+
lines.forEach((line, li) => {
|
|
170
|
+
for (const piece of (line.text ?? "").split(/\s+/)) {
|
|
171
|
+
const token = wordToken(piece);
|
|
172
|
+
if (!token) {
|
|
173
|
+
between += ` ${piece}`;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const [, lead, trail] = piece.match(/^([^\p{L}\p{N}]*).*?([^\p{L}\p{N}]*)$/u);
|
|
177
|
+
tokens.push({ token, li, before: between + lead });
|
|
178
|
+
between = trail;
|
|
179
|
+
}
|
|
180
|
+
between += " ";
|
|
181
|
+
});
|
|
182
|
+
const at = heardAt(words ?? [], tokens.map((t) => t.token));
|
|
183
|
+
if (!at) return [];
|
|
184
|
+
const cuts = [];
|
|
185
|
+
for (let t = 0; t + 1 < tokens.length; t++) {
|
|
186
|
+
const [a, b] = [tokens[t], tokens[t + 1]];
|
|
187
|
+
if (at[t + 1] !== at[t] + 1) continue;
|
|
188
|
+
const newLine = a.li !== b.li;
|
|
189
|
+
if (!newLine && !SENTENCE_END.test(b.before)) continue;
|
|
190
|
+
if (WRITTEN_PAUSE.test(b.before) || lines[a.li].asksPause || lines[b.li].asksPause) continue;
|
|
191
|
+
if (newLine && lines[a.li].cuesBeat) continue;
|
|
192
|
+
const [earlier, later] = [words[at[t]], words[at[t + 1]]];
|
|
193
|
+
const gap = later.s - earlier.e;
|
|
194
|
+
const heard = levels && soundBetween(levels, earlier.e, later.s);
|
|
195
|
+
// A sound's frame edges widen the cut, so none of it is left in.
|
|
196
|
+
const sound = heard && { s: Math.max(floorFrame(heard.s), ceilFrame(earlier.e)), e: ceilFrame(heard.e) };
|
|
197
|
+
const removed = sound ? sound.e - sound.s : 0;
|
|
198
|
+
const spans = sound ? [sound] : [];
|
|
199
|
+
if (gap - removed > SENTENCE_GAP_MAX_SEC + 1e-6) {
|
|
200
|
+
// Placed on the gap with the sound already out, then moved back past it.
|
|
201
|
+
// Frame edges only ever widen what is kept, so the silence left stays
|
|
202
|
+
// between the kept length and the longest allowed.
|
|
203
|
+
const back = (t) => (sound && t > sound.s ? t + removed : t);
|
|
204
|
+
const s = back(ceilFrame(earlier.e + SENTENCE_GAP_KEEP_SEC / 2));
|
|
205
|
+
const e = back(floorFrame(later.s - removed - SENTENCE_GAP_KEEP_SEC / 2));
|
|
206
|
+
if (e > s) spans.push({ s, e });
|
|
207
|
+
}
|
|
208
|
+
const merged = [];
|
|
209
|
+
for (const span of spans.sort((x, y) => x.s - y.s)) {
|
|
210
|
+
const prev = merged.at(-1);
|
|
211
|
+
if (prev && span.s <= prev.e + 1e-6) prev.e = Math.max(prev.e, span.e);
|
|
212
|
+
else merged.push({ ...span });
|
|
213
|
+
}
|
|
214
|
+
const kept = gap - merged.reduce((sum, c) => sum + c.e - c.s, 0);
|
|
215
|
+
for (const { s, e } of merged) cuts.push({ s, e, after: earlier.w, gap, kept, sound: Boolean(sound) });
|
|
216
|
+
}
|
|
217
|
+
return cuts;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* #2554: a sound between two words that holds no heard word, from where the
|
|
222
|
+
* earlier word fades under QUIET_DB to where SETTLED_WINDOWS of quiet resume
|
|
223
|
+
* before the later word, or null when there is none or it runs into the later
|
|
224
|
+
* word. Mirrors scout trim.ts soundBetween.
|
|
225
|
+
*/
|
|
226
|
+
function soundBetween(levels, fromSec, toSec) {
|
|
227
|
+
const { db, windowSec } = levels;
|
|
228
|
+
const windowAt = (sec) => Math.floor(sec / windowSec + 1e-9);
|
|
229
|
+
const last = Math.min(windowAt(toSec), db.length);
|
|
230
|
+
let quiet = windowAt(fromSec);
|
|
231
|
+
while (quiet < last && db[quiet] >= QUIET_DB) quiet++;
|
|
232
|
+
let resumed = -1;
|
|
233
|
+
for (let i = quiet; i < last; i = resumed + SETTLED_WINDOWS) {
|
|
234
|
+
while (i < last && db[i] < SOUND_DB) i++;
|
|
235
|
+
if (i >= last) break;
|
|
236
|
+
const settled = settledQuiet(db, i);
|
|
237
|
+
if (settled < 0 || settled + SETTLED_WINDOWS > last) break;
|
|
238
|
+
resumed = settled;
|
|
239
|
+
}
|
|
240
|
+
return resumed < 0 ? null : { s: quiet * windowSec, e: resumed * windowSec };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Scanning forward from `from`, the first window of the first run of SETTLED_WINDOWS quiet windows, or -1. */
|
|
244
|
+
function settledQuiet(db, from) {
|
|
245
|
+
let run = 0;
|
|
246
|
+
for (let i = from; i < db.length; i++) {
|
|
247
|
+
run = db[i] < QUIET_DB ? run + 1 : 0;
|
|
248
|
+
if (run === SETTLED_WINDOWS) return i - (SETTLED_WINDOWS - 1);
|
|
249
|
+
}
|
|
250
|
+
return -1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Per-20 ms RMS loudness (dBFS) of mono s16 PCM, digital silence reading -120 (scout ffmpeg-clip.ts audioLevelsFromPcm). */
|
|
254
|
+
export function levelsFromPcm(pcm, sampleRate) {
|
|
255
|
+
const perWindow = Math.round(sampleRate * LEVEL_WINDOW_SEC);
|
|
256
|
+
const db = [];
|
|
257
|
+
for (let from = 0; from < pcm.length; from += perWindow) {
|
|
258
|
+
const to = Math.min(pcm.length, from + perWindow);
|
|
259
|
+
let sumSq = 0;
|
|
260
|
+
for (let i = from; i < to; i++) sumSq += pcm[i] * pcm[i];
|
|
261
|
+
const rms = Math.sqrt(sumSq / (to - from)) / 32768;
|
|
262
|
+
db.push(rms > 0 ? Math.max(-120, 20 * Math.log10(rms)) : -120);
|
|
263
|
+
}
|
|
264
|
+
return { windowSec: LEVEL_WINDOW_SEC, db };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A clip's loudness windows, or null when its audio cannot be decoded. */
|
|
268
|
+
function clipLevels(file) {
|
|
269
|
+
const res = spawnSync(
|
|
270
|
+
"ffmpeg",
|
|
271
|
+
["-v", "error", "-i", file, "-map", "0:a:0", "-ac", "1", "-ar", "16000", "-f", "s16le", "-"],
|
|
272
|
+
{ maxBuffer: 1 << 28 },
|
|
273
|
+
);
|
|
274
|
+
if (res.status !== 0 || !res.stdout?.length) return null;
|
|
275
|
+
const bytes = new Uint8Array(res.stdout.subarray(0, res.stdout.length & ~1));
|
|
276
|
+
return levelsFromPcm(new Int16Array(bytes.buffer), 16000);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Take each own-sound clip's long sentence gaps out of its timeline: its words
|
|
281
|
+
* move onto the cut timeline and it runs shorter, so every later step (join
|
|
282
|
+
* trims, reaction splits, cue times, captions) works in cut time. The renderer
|
|
283
|
+
* removes seg.cuts from the picture and the sound before anything else.
|
|
284
|
+
*/
|
|
285
|
+
function tightenSentenceGaps(spine, reactionCues, scriptLines, readLevels) {
|
|
286
|
+
for (const seg of spine) {
|
|
287
|
+
if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) continue;
|
|
288
|
+
const lines = seg.lineIds.map((id) => ({
|
|
289
|
+
text: scriptLines.get(id)?.text,
|
|
290
|
+
asksPause: asksPause(scriptLines.get(id)),
|
|
291
|
+
cuesBeat: reactionCues.has(id),
|
|
292
|
+
}));
|
|
293
|
+
const cuts = sentenceGapCuts(seg.words, lines, readLevels(seg.file));
|
|
294
|
+
if (cuts.length === 0) continue;
|
|
295
|
+
const removedBefore = (t) => cuts.reduce((sum, c) => sum + (c.e <= t ? c.e - c.s : 0), 0);
|
|
296
|
+
const removed = removedBefore(Infinity);
|
|
297
|
+
seg.cuts = cuts.map(({ s, e }) => ({ s, e }));
|
|
298
|
+
seg.words = seg.words.map((w) => ({ ...w, s: w.s - removedBefore(w.s), e: w.e - removedBefore(w.s) }));
|
|
299
|
+
seg.seconds -= removed;
|
|
300
|
+
seg.clipSeconds -= removed;
|
|
301
|
+
seg.note = withHow(
|
|
302
|
+
seg.note,
|
|
303
|
+
[
|
|
304
|
+
...new Set(
|
|
305
|
+
cuts.map((c) =>
|
|
306
|
+
c.sound
|
|
307
|
+
? `sound after "${c.after}" cut, pause ${c.gap.toFixed(2)}s -> ${c.kept.toFixed(2)}s`
|
|
308
|
+
: `pause after "${c.after}" tightened ${c.gap.toFixed(2)}s -> ${c.kept.toFixed(2)}s`,
|
|
309
|
+
),
|
|
310
|
+
),
|
|
311
|
+
],
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const withHow = (note, how) =>
|
|
317
|
+
note.endsWith(")") ? `${note.slice(0, -1)}, ${how.join(", ")})` : `${note} (${how.join(", ")})`;
|
|
318
|
+
|
|
57
319
|
const CUTAWAY_KINDS = new Set(["cutaway", "insert"]);
|
|
58
320
|
|
|
59
321
|
const hasText = (value) => typeof value === "string" && value.trim().length > 0;
|
|
@@ -77,11 +339,17 @@ export function scenePlans(storyboard) {
|
|
|
77
339
|
const plans = new Map();
|
|
78
340
|
for (const scene of storyboard?.scenes ?? []) {
|
|
79
341
|
if (typeof scene?.sceneIndex !== "number") continue;
|
|
342
|
+
const speakers = (Array.isArray(scene.dialogue) ? scene.dialogue : [])
|
|
343
|
+
.filter((turn) => hasText(turn?.line) || hasText(turn?.text))
|
|
344
|
+
.map((turn) => (hasText(turn.speaker) ? turn.speaker.trim() : null));
|
|
80
345
|
plans.set(scene.sceneIndex, {
|
|
346
|
+
firstSpeaker: speakers[0] ?? null,
|
|
347
|
+
lastSpeaker: speakers.at(-1) ?? null,
|
|
81
348
|
cutaway: CUTAWAY_KINDS.has(scene.kind),
|
|
82
349
|
narrated: narratedScene(scene, storyboard),
|
|
83
350
|
lineIds: Array.isArray(scene.lineIds) ? scene.lineIds : [],
|
|
84
351
|
cueLineId: typeof scene.cueLineId === "string" ? scene.cueLineId : null,
|
|
352
|
+
cutawayType: typeof scene.cutawayType === "string" ? scene.cutawayType : null,
|
|
85
353
|
durationSec: typeof scene.durationSec === "number" ? scene.durationSec : null,
|
|
86
354
|
});
|
|
87
355
|
}
|
|
@@ -90,12 +358,12 @@ export function scenePlans(storyboard) {
|
|
|
90
358
|
|
|
91
359
|
/**
|
|
92
360
|
* 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.
|
|
361
|
+
* voice plus a beat of air, to the next whole frame, because the voice is never
|
|
362
|
+
* altered. A clip that runs longer is trimmed; a clip that runs shorter holds
|
|
363
|
+
* its last frame for the difference. A still (no clip) simply runs the voice's length.
|
|
96
364
|
*/
|
|
97
365
|
export function fitNarration({ clipSeconds, voiceSeconds }) {
|
|
98
|
-
const target = voiceSeconds + TAIL_AIR_SEC;
|
|
366
|
+
const target = ceilFrame(voiceSeconds + TAIL_AIR_SEC);
|
|
99
367
|
if (clipSeconds === null) return { seconds: target, holdSec: 0, trimmedBy: 0 };
|
|
100
368
|
if (target <= clipSeconds) {
|
|
101
369
|
return { seconds: target, holdSec: 0, trimmedBy: clipSeconds - target };
|
|
@@ -126,18 +394,157 @@ export function cueOffsetOnSpine(spine, counts, cueLineId) {
|
|
|
126
394
|
return null;
|
|
127
395
|
}
|
|
128
396
|
|
|
129
|
-
|
|
397
|
+
/**
|
|
398
|
+
* Where a segment's spoken cue line ends, in seconds into the segment, or null
|
|
399
|
+
* when the segment does not speak it. A cue line that closes the segment ends
|
|
400
|
+
* where the segment does.
|
|
401
|
+
*/
|
|
402
|
+
export function cueEndInSegment(seg, counts, cueLineId) {
|
|
403
|
+
const idx = seg.lineIds.indexOf(cueLineId);
|
|
404
|
+
if (idx === -1) return null;
|
|
405
|
+
if (idx === seg.lineIds.length - 1) return seg.seconds;
|
|
406
|
+
let through = 0;
|
|
407
|
+
let totalWords = 0;
|
|
408
|
+
seg.lineIds.forEach((id, i) => {
|
|
409
|
+
const n = counts.get(id) ?? 0;
|
|
410
|
+
totalWords += n;
|
|
411
|
+
if (i <= idx) through += n;
|
|
412
|
+
});
|
|
413
|
+
if (seg.words && seg.words.length > 0) {
|
|
414
|
+
const last = seg.words[Math.min(Math.max(through - 1, 0), seg.words.length - 1)];
|
|
415
|
+
return Math.min(Math.max(0, last?.e ?? 0), seg.seconds);
|
|
416
|
+
}
|
|
417
|
+
return totalWords > 0 ? (through / totalWords) * seg.seconds : seg.seconds;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Cut the dead air out of each join between clips that play their own sound: a
|
|
422
|
+
* clip's lead-in silence stacked on the previous clip's tail ran past a second
|
|
423
|
+
* (#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
|
+
* and leaves the ad's first lead-in and last tail alone; a clip whose closing
|
|
426
|
+
* line cues a reaction is not the ad's end, since the beat follows it. Trims
|
|
427
|
+
* land on whole frames so the picture and its sound stay the same length.
|
|
428
|
+
*
|
|
429
|
+
* The beat is measured from the clip's scripted words when the script is heard
|
|
430
|
+
* in order, and a cut stops short of a sound made up before or after the line
|
|
431
|
+
* ("Hmm", "Um"), even at the ad's own edges (#2541). Sounds inside the line stay.
|
|
432
|
+
*/
|
|
433
|
+
function trimJoins(spine, reactionCues, scriptLines) {
|
|
434
|
+
spine.forEach((seg, k) => {
|
|
435
|
+
if (seg.source !== "clip" || seg.audio !== "clip" || !seg.words?.length) return;
|
|
436
|
+
const prev = spine[k - 1];
|
|
437
|
+
const sameSpeaker = Boolean(prev?.lastSpeaker) && prev.lastSpeaker === seg.firstSpeaker;
|
|
438
|
+
const lead = sameSpeaker ? JOIN_LEAD_SAME_SPEAKER_SEC : JOIN_LEAD_NEW_SPEAKER_SEC;
|
|
439
|
+
const span = scriptedSpan(seg.words, seg.lineIds.map((id) => scriptLines.get(id)?.text));
|
|
440
|
+
const line = span ? seg.words.slice(span.first, span.last + 1) : seg.words;
|
|
441
|
+
const before = span ? seg.words.slice(0, span.first) : [];
|
|
442
|
+
const after = span ? seg.words.slice(span.last + 1) : [];
|
|
443
|
+
const firstWord = Math.min(...line.map((w) => w.s));
|
|
444
|
+
const lastWord = Math.max(...line.map((w) => w.e));
|
|
445
|
+
let inSec = prev ? Math.max(0, floorFrame(firstWord - lead)) : 0;
|
|
446
|
+
if (before.length > 0) {
|
|
447
|
+
const clear = ceilFrame(Math.max(...before.map((w) => w.e)) + EXTRA_CLEARANCE_SEC);
|
|
448
|
+
inSec = Math.max(inSec, Math.min(clear, floorFrame(firstWord)));
|
|
449
|
+
}
|
|
450
|
+
const endsAd = k === spine.length - 1 && !reactionCues.has(seg.lineIds.at(-1));
|
|
451
|
+
let outSec = endsAd
|
|
452
|
+
? after.length > 0 ? ceilFrame(lastWord + TAIL_AIR_SEC) : seg.seconds
|
|
453
|
+
: ceilFrame(lastWord + JOIN_TAIL_SEC);
|
|
454
|
+
if (after.length > 0) {
|
|
455
|
+
const clear = floorFrame(Math.min(...after.map((w) => w.s)) - EXTRA_CLEARANCE_SEC);
|
|
456
|
+
outSec = Math.min(outSec, Math.max(clear, ceilFrame(lastWord)));
|
|
457
|
+
}
|
|
458
|
+
outSec = Math.min(seg.seconds, outSec);
|
|
459
|
+
if (outSec <= inSec) return;
|
|
460
|
+
const how = [];
|
|
461
|
+
if (inSec > 0) how.push(`lead-in trimmed ${inSec.toFixed(2)}s`);
|
|
462
|
+
if (outSec < seg.seconds) how.push(`tail trimmed ${(seg.seconds - outSec).toFixed(2)}s`);
|
|
463
|
+
if (how.length === 0) return;
|
|
464
|
+
const kept = seg.words.filter((w) => w.s < outSec && w.e > inSec);
|
|
465
|
+
if (before.some((w) => !kept.includes(w))) how.push("extra sound before the line trimmed");
|
|
466
|
+
if (after.some((w) => !kept.includes(w))) how.push("extra sound after the line trimmed");
|
|
467
|
+
seg.inSec = inSec;
|
|
468
|
+
seg.seconds = outSec - inSec;
|
|
469
|
+
seg.words = kept.map((w) => ({ ...w, s: w.s - inSec, e: w.e - inSec }));
|
|
470
|
+
seg.note = withHow(seg.note, how);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const holdFor = (seg) =>
|
|
475
|
+
seg.source === "clip" ? Math.max(0, seg.seconds - Math.max(0, seg.clipSeconds - seg.inSec)) : 0;
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Put a reaction beat into the spine right after its cue line ends, splitting
|
|
479
|
+
* the segment that speaks it when more lines follow in that segment. Returns
|
|
480
|
+
* the scene that speaks the cue, or null when no segment does.
|
|
481
|
+
*/
|
|
482
|
+
function placeReaction(spine, counts, reaction) {
|
|
483
|
+
for (let k = 0; k < spine.length; k++) {
|
|
484
|
+
const seg = spine[k];
|
|
485
|
+
if (seg.reaction) continue;
|
|
486
|
+
const cueEnd = cueEndInSegment(seg, counts, reaction.cueLineId);
|
|
487
|
+
if (cueEnd === null) continue;
|
|
488
|
+
const cut = Math.min(seg.seconds, ceilFrame(cueEnd));
|
|
489
|
+
const beat = {
|
|
490
|
+
sceneIndex: reaction.sceneIndex, source: reaction.source, file: reaction.file,
|
|
491
|
+
clipSeconds: reaction.source === "clip" ? reaction.seconds : null, inSec: 0,
|
|
492
|
+
seconds: Math.min(reaction.seconds, REACTION_MAX_SEC), holdSec: 0,
|
|
493
|
+
audio: reaction.hasAudio ? "clip" : "silence", voice: null, words: null, lineIds: [],
|
|
494
|
+
reaction: { cueLineId: reaction.cueLineId, cueSceneIndex: seg.sceneIndex },
|
|
495
|
+
};
|
|
496
|
+
if (cut >= seg.seconds - 0.05) {
|
|
497
|
+
spine.splice(k + 1, 0, beat);
|
|
498
|
+
return seg.sceneIndex;
|
|
499
|
+
}
|
|
500
|
+
const idx = seg.lineIds.indexOf(reaction.cueLineId);
|
|
501
|
+
const head = {
|
|
502
|
+
...seg, seconds: cut, lineIds: seg.lineIds.slice(0, idx + 1),
|
|
503
|
+
words: seg.words ? seg.words.filter((w) => w.s < cut) : null,
|
|
504
|
+
note: `${seg.note}, until ${reaction.cueLineId} ends at ${cut.toFixed(2)}s`,
|
|
505
|
+
};
|
|
506
|
+
const tail = {
|
|
507
|
+
...seg, inSec: seg.inSec + cut, seconds: seg.seconds - cut, lineIds: seg.lineIds.slice(idx + 1),
|
|
508
|
+
words: seg.words
|
|
509
|
+
? seg.words.filter((w) => w.s >= cut).map((w) => ({ ...w, s: w.s - cut, e: w.e - cut }))
|
|
510
|
+
: null,
|
|
511
|
+
note: `${seg.note}, resumed at ${cut.toFixed(2)}s after reaction ${reaction.sceneIndex}`,
|
|
512
|
+
};
|
|
513
|
+
head.holdSec = holdFor(head);
|
|
514
|
+
tail.holdSec = holdFor(tail);
|
|
515
|
+
spine.splice(k, 1, head, beat, tail);
|
|
516
|
+
return seg.sceneIndex;
|
|
517
|
+
}
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The sound a reaction beat carries under the next segment (#2406): the clip's
|
|
523
|
+
* own sound past the beat when it has some (older 4 s pulls), else its last
|
|
524
|
+
* stretch replayed, so a clip exactly as long as its beat still fades out.
|
|
525
|
+
*/
|
|
526
|
+
function reactionTail(seg, next) {
|
|
527
|
+
const clipLen = seg.clipSeconds ?? seg.seconds;
|
|
528
|
+
const beatEnd = seg.inSec + seg.seconds;
|
|
529
|
+
const fromSec = Math.max(0, Math.min(beatEnd, clipLen - REACTION_TAIL_SEC));
|
|
530
|
+
const seconds = Math.min(REACTION_TAIL_SEC, next.seconds, clipLen - fromSec);
|
|
531
|
+
return { atSec: next.startSec, fromSec, seconds, gain: REACTION_TAIL_GAIN };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function buildTimeline({ manifest, storyboard, skip, probe, exists, readWords, readLevels = () => null }) {
|
|
130
535
|
const plans = scenePlans(storyboard);
|
|
131
536
|
const counts = lineWordCounts(storyboard);
|
|
132
537
|
const spine = [];
|
|
133
538
|
const cutaways = [];
|
|
134
539
|
const waiting = [];
|
|
135
540
|
const dropped = [];
|
|
136
|
-
let startSec = 0;
|
|
137
541
|
|
|
138
542
|
for (const scene of manifest?.scenes ?? []) {
|
|
139
543
|
const n = scene.sceneIndex;
|
|
140
|
-
const plan = plans.get(n) ?? {
|
|
544
|
+
const plan = plans.get(n) ?? {
|
|
545
|
+
cutaway: false, lineIds: [], cueLineId: null, cutawayType: null, durationSec: null,
|
|
546
|
+
firstSpeaker: null, lastSpeaker: null,
|
|
547
|
+
};
|
|
141
548
|
if (skip.has(n)) {
|
|
142
549
|
dropped.push({ n, reason: `scene ${n}: skipped by --skip` });
|
|
143
550
|
continue;
|
|
@@ -145,15 +552,17 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
145
552
|
const nothing = `nothing to cut with (clip ${scene.clipStatus}${scene.error ? `: ${scene.error}` : ""})`;
|
|
146
553
|
|
|
147
554
|
if (plan.cutaway) {
|
|
555
|
+
const cue = { cueLineId: plan.cueLineId, reaction: plan.cutawayType === "reaction" };
|
|
148
556
|
if (exists(scene.clip)) {
|
|
557
|
+
const clip = probe(scene.clip);
|
|
149
558
|
waiting.push({
|
|
150
559
|
sceneIndex: n, source: "clip", file: scene.clip,
|
|
151
|
-
seconds:
|
|
560
|
+
seconds: clip.seconds, hasAudio: clip.hasAudio, ...cue,
|
|
152
561
|
});
|
|
153
562
|
} else if (exists(scene.keyframe)) {
|
|
154
563
|
waiting.push({
|
|
155
564
|
sceneIndex: n, source: "still", file: scene.keyframe,
|
|
156
|
-
seconds: plan.durationSec ?? 4,
|
|
565
|
+
seconds: plan.durationSec ?? 4, hasAudio: false, ...cue,
|
|
157
566
|
});
|
|
158
567
|
} else {
|
|
159
568
|
dropped.push({ n, reason: `cutaway ${n}: ${nothing}` });
|
|
@@ -168,8 +577,8 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
168
577
|
if (exists(scene.clip)) {
|
|
169
578
|
const clip = probe(scene.clip);
|
|
170
579
|
const base = {
|
|
171
|
-
sceneIndex: n, source: "clip", file: scene.clip, clipSeconds: clip.seconds,
|
|
172
|
-
|
|
580
|
+
sceneIndex: n, source: "clip", file: scene.clip, clipSeconds: clip.seconds, inSec: 0,
|
|
581
|
+
lineIds: plan.lineIds, firstSpeaker: plan.firstSpeaker, lastSpeaker: plan.lastSpeaker,
|
|
173
582
|
};
|
|
174
583
|
if (voice && (narrated || !clip.hasAudio)) {
|
|
175
584
|
const fit = fitNarration({ clipSeconds: clip.seconds, voiceSeconds: probe(voice).seconds });
|
|
@@ -198,7 +607,6 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
198
607
|
words: null, note: `${flag}, silent clip`,
|
|
199
608
|
});
|
|
200
609
|
}
|
|
201
|
-
startSec += spine[spine.length - 1].seconds;
|
|
202
610
|
continue;
|
|
203
611
|
}
|
|
204
612
|
if (exists(scene.keyframe)) {
|
|
@@ -206,39 +614,67 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
206
614
|
? fitNarration({ clipSeconds: null, voiceSeconds: probe(voice).seconds })
|
|
207
615
|
: { seconds: plan.durationSec ?? 4, holdSec: 0 };
|
|
208
616
|
spine.push({
|
|
209
|
-
sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null,
|
|
617
|
+
sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null, inSec: 0,
|
|
210
618
|
seconds: fit.seconds, audio: voice ? "narration" : "silence", voice, holdSec: 0,
|
|
211
|
-
|
|
619
|
+
lineIds: plan.lineIds,
|
|
212
620
|
// #1689: on a narrated scene the words are the voice track's, and the
|
|
213
621
|
// voice starts where the segment does, so they need no offset here.
|
|
214
622
|
words: voice ? readWords(scene.words) : null,
|
|
215
623
|
note: `${flag}${voice ? ` + ${voice}` : ", silent"} (${fit.seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
|
|
216
624
|
});
|
|
217
|
-
startSec += fit.seconds;
|
|
218
625
|
continue;
|
|
219
626
|
}
|
|
220
627
|
dropped.push({ n, reason: `scene ${n}: ${nothing}` });
|
|
221
628
|
}
|
|
222
629
|
|
|
223
|
-
|
|
630
|
+
const noCue = (c) =>
|
|
631
|
+
c.cueLineId ? `${c.cueLineId} is spoken by no scene in the cut` : "the storyboard names no cue line";
|
|
632
|
+
|
|
633
|
+
// Sentence gaps and then joins are closed first so a reaction beat lands
|
|
634
|
+
// right after its cue line ends in the trimmed clip. Reactions change the
|
|
635
|
+
// spine, so they go in before any overlay is placed on it.
|
|
636
|
+
const scriptLines = new Map((storyboard?.script ?? []).map((line) => [line?.lineId, line]));
|
|
637
|
+
const reactionCues = new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId));
|
|
638
|
+
tightenSentenceGaps(spine, reactionCues, scriptLines, readLevels);
|
|
639
|
+
trimJoins(spine, reactionCues, scriptLines);
|
|
640
|
+
for (const c of waiting.filter((w) => w.reaction)) {
|
|
641
|
+
if (!c.cueLineId || placeReaction(spine, counts, c) === null) {
|
|
642
|
+
dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
let total = 0;
|
|
646
|
+
for (const seg of spine) {
|
|
647
|
+
seg.startSec = total;
|
|
648
|
+
total += seg.seconds;
|
|
649
|
+
}
|
|
650
|
+
spine.forEach((seg, k) => {
|
|
651
|
+
const next = spine[k + 1];
|
|
652
|
+
if (!seg.reaction || seg.audio !== "clip" || !next) return;
|
|
653
|
+
seg.reaction.tail = reactionTail(seg, next);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
for (const c of waiting.filter((w) => !w.reaction)) {
|
|
224
657
|
const cue = c.cueLineId ? cueOffsetOnSpine(spine, counts, c.cueLineId) : null;
|
|
225
658
|
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}` });
|
|
659
|
+
dropped.push({ n: c.sceneIndex, reason: `cutaway ${c.sceneIndex}: ${noCue(c)}` });
|
|
230
660
|
continue;
|
|
231
661
|
}
|
|
232
662
|
cutaways.push({ ...c, startSec: cue.startSec, method: cue.method, spineSceneIndex: cue.spineSceneIndex });
|
|
233
663
|
}
|
|
234
664
|
cutaways.sort((a, b) => a.startSec - b.startSec);
|
|
665
|
+
const beats = spine.filter((seg) => seg.reaction);
|
|
235
666
|
for (let i = 0; i < cutaways.length; i++) {
|
|
236
667
|
const c = cutaways[i];
|
|
668
|
+
const stops = [];
|
|
237
669
|
const next = cutaways[i + 1];
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
670
|
+
if (next) stops.push({ at: next.startSec, by: `cutaway ${next.sceneIndex} starts` });
|
|
671
|
+
const beat = beats.find((b) => b.startSec >= c.startSec);
|
|
672
|
+
if (beat) stops.push({ at: beat.startSec, by: `reaction ${beat.sceneIndex} starts` });
|
|
673
|
+
stops.push({ at: total, by: "the ad ends" });
|
|
674
|
+
const stop = stops.reduce((a, b) => (b.at < a.at ? b : a));
|
|
675
|
+
if (c.startSec + c.seconds > stop.at) {
|
|
676
|
+
c.seconds = Math.max(0, stop.at - c.startSec);
|
|
677
|
+
c.cutShortBy = stop.by;
|
|
242
678
|
}
|
|
243
679
|
}
|
|
244
680
|
|
|
@@ -262,6 +698,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
262
698
|
};
|
|
263
699
|
const filters = [];
|
|
264
700
|
const pairs = [];
|
|
701
|
+
const tails = [];
|
|
265
702
|
|
|
266
703
|
for (const seg of timeline.spine) {
|
|
267
704
|
const i =
|
|
@@ -269,16 +706,61 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
269
706
|
? addInput("-i", resolve(seg.file))
|
|
270
707
|
: addInput("-loop", "1", "-t", String(seg.seconds), "-i", resolve(seg.file));
|
|
271
708
|
const fit = [];
|
|
272
|
-
|
|
273
|
-
|
|
709
|
+
const inSec = seg.inSec ?? 0;
|
|
710
|
+
// #2544: sentence gaps come out first, the picture by frame number and the
|
|
711
|
+
// sound by the same times, so everything after works on the cut timeline.
|
|
712
|
+
const cuts = seg.cuts ?? [];
|
|
713
|
+
let heard = `[${i}:a]${stereo},`;
|
|
714
|
+
if (cuts.length > 0) {
|
|
715
|
+
const frames = cuts.map((c) => `between(n\\,${Math.round(c.s * FPS)}\\,${Math.round(c.e * FPS) - 1})`);
|
|
716
|
+
fit.push(`select=not(${frames.join("+")}),setpts=N/FRAME_RATE/TB`);
|
|
717
|
+
const kept = [0, ...cuts.flatMap((c) => [c.s, c.e])];
|
|
718
|
+
const pieces = cuts.length + 1;
|
|
719
|
+
filters.push(`[${i}:a]${stereo},asplit=${pieces}${Array.from({ length: pieces }, (_, p) => `[ap${i}_${p}]`).join("")}`);
|
|
720
|
+
for (let p = 0; p < pieces; p++) {
|
|
721
|
+
const end = p < cuts.length ? `:end=${kept[2 * p + 1].toFixed(6)}` : "";
|
|
722
|
+
filters.push(`[ap${i}_${p}]atrim=start=${kept[2 * p].toFixed(6)}${end},asetpts=PTS-STARTPTS[aq${i}_${p}]`);
|
|
723
|
+
}
|
|
724
|
+
filters.push(
|
|
725
|
+
`${Array.from({ length: pieces }, (_, p) => `[aq${i}_${p}]`).join("")}concat=n=${pieces}:v=0:a=1[ac${i}]`,
|
|
726
|
+
);
|
|
727
|
+
heard = `[ac${i}]`;
|
|
728
|
+
}
|
|
729
|
+
if (seg.source === "clip" && seg.clipSeconds !== null) {
|
|
730
|
+
const shown = seg.seconds - seg.holdSec;
|
|
731
|
+
if (inSec > 0.001 || shown < seg.clipSeconds - 0.01) {
|
|
732
|
+
// By frame number: a time printed to the millisecond can round past the
|
|
733
|
+
// frame it names and drop it.
|
|
734
|
+
const from = Math.round(inSec * FPS);
|
|
735
|
+
fit.push(`trim=start_frame=${from}:end_frame=${Math.round((inSec + shown) * FPS)},setpts=PTS-STARTPTS`);
|
|
736
|
+
}
|
|
274
737
|
}
|
|
275
738
|
if (seg.holdSec > 0.01) fit.push(`tpad=stop_mode=clone:stop_duration=${sec(seg.holdSec)}`);
|
|
276
739
|
filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
|
|
740
|
+
// A segment split around a reaction beat starts partway into its sound too.
|
|
741
|
+
const skipIn = inSec > 0.001 ? `atrim=start=${sec(inSec)},asetpts=PTS-STARTPTS,` : "";
|
|
742
|
+
const fitLength =
|
|
743
|
+
`apad,atrim=0:${sec(seg.seconds)},afade=t=in:d=${EDGE_FADE_SEC},` +
|
|
744
|
+
`afade=t=out:st=${sec(seg.seconds - EDGE_FADE_SEC)}:d=${EDGE_FADE_SEC}`;
|
|
745
|
+
const fitSound = `${LOUDNORM},${fitLength}`;
|
|
277
746
|
if (seg.audio === "narration") {
|
|
278
747
|
const v = addInput("-i", resolve(seg.voice));
|
|
279
|
-
filters.push(`[${v}:a]${stereo},${
|
|
748
|
+
filters.push(`[${v}:a]${stereo},${skipIn}${fitSound}[a${i}]`);
|
|
749
|
+
} else if (seg.audio === "clip" && seg.reaction?.tail) {
|
|
750
|
+
// The tail is cut from the same loudness-normalized sound as the beat,
|
|
751
|
+
// ducked, faded out, and laid in at the join; the next line's own sound
|
|
752
|
+
// plays at full level over it (#2406).
|
|
753
|
+
const t = seg.reaction.tail;
|
|
754
|
+
filters.push(`${heard}${skipIn}${LOUDNORM},asplit=2[as${i}][at${i}]`);
|
|
755
|
+
filters.push(`[as${i}]${fitLength}[a${i}]`);
|
|
756
|
+
filters.push(
|
|
757
|
+
`[at${i}]atrim=start=${sec(t.fromSec - inSec)}:duration=${sec(t.seconds)},asetpts=PTS-STARTPTS,` +
|
|
758
|
+
`volume=${t.gain},afade=t=in:d=${EDGE_FADE_SEC},afade=t=out:st=0:d=${sec(t.seconds)},` +
|
|
759
|
+
`adelay=delays=${Math.round(t.atSec * 1000)}:all=1[tail${i}]`,
|
|
760
|
+
);
|
|
761
|
+
tails.push(`[tail${i}]`);
|
|
280
762
|
} else if (seg.audio === "clip") {
|
|
281
|
-
filters.push(
|
|
763
|
+
filters.push(`${heard}${skipIn}${fitSound}[a${i}]`);
|
|
282
764
|
} else {
|
|
283
765
|
filters.push(`anullsrc=r=48000:cl=stereo,atrim=0:${sec(seg.seconds)}[a${i}]`);
|
|
284
766
|
}
|
|
@@ -308,7 +790,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
308
790
|
});
|
|
309
791
|
|
|
310
792
|
filters.push(`${ROOM_TONE}[room]`);
|
|
311
|
-
const beds = ["[room]"];
|
|
793
|
+
const beds = ["[room]", ...tails];
|
|
312
794
|
if (music) {
|
|
313
795
|
const m = addInput("-stream_loop", "-1", "-i", music);
|
|
314
796
|
filters.push(`[${m}:a]${stereo},volume=${MUSIC_VOLUME}[bed]`);
|
|
@@ -398,6 +880,20 @@ export function inTheCut(timeline) {
|
|
|
398
880
|
const rows = [];
|
|
399
881
|
for (const seg of timeline.spine) {
|
|
400
882
|
const label = seg.source === "clip" ? seg.file : `still ${seg.file}`;
|
|
883
|
+
if (seg.reaction) {
|
|
884
|
+
const sound = seg.audio === "clip" ? "its own sound" : "silent: the clip has no sound";
|
|
885
|
+
const tail = seg.reaction.tail;
|
|
886
|
+
const fades = tail
|
|
887
|
+
? `, fading out under the next line at ${tail.atSec.toFixed(2)}s over ${tail.seconds.toFixed(2)}s`
|
|
888
|
+
: "";
|
|
889
|
+
rows.push({
|
|
890
|
+
n: seg.sceneIndex,
|
|
891
|
+
line:
|
|
892
|
+
`reaction ${seg.sceneIndex}: ${label} for ${seg.seconds.toFixed(1)}s after ` +
|
|
893
|
+
`${seg.reaction.cueLineId} in scene ${seg.reaction.cueSceneIndex}, ${sound}${fades}`,
|
|
894
|
+
});
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
401
897
|
rows.push({ n: seg.sceneIndex, line: `scene ${seg.sceneIndex}: ${label}${seg.note}` });
|
|
402
898
|
}
|
|
403
899
|
for (const c of timeline.cutaways) {
|
|
@@ -420,7 +916,7 @@ export function inTheCut(timeline) {
|
|
|
420
916
|
export function noWordTimes(timeline) {
|
|
421
917
|
const rows = timeline.spine
|
|
422
918
|
.filter((seg) => !seg.words || seg.words.length === 0)
|
|
423
|
-
.map((seg) => `scene ${seg.sceneIndex} (no words.json)`);
|
|
919
|
+
.map((seg) => `scene ${seg.sceneIndex} (${seg.reaction ? "reaction" : "no words.json"})`);
|
|
424
920
|
for (const c of timeline.cutaways) rows.push(`scene ${c.sceneIndex} (cutaway)`);
|
|
425
921
|
return rows;
|
|
426
922
|
}
|
|
@@ -478,11 +974,14 @@ function main() {
|
|
|
478
974
|
};
|
|
479
975
|
const probe = (file) => {
|
|
480
976
|
const raw = execFileSync("ffprobe", [
|
|
481
|
-
"-v", "error", "-show_entries", "stream=codec_type:format=duration", "-of", "json", here(file),
|
|
977
|
+
"-v", "error", "-show_entries", "stream=codec_type,duration:stream_disposition=attached_pic:format=duration", "-of", "json", here(file),
|
|
482
978
|
]).toString();
|
|
483
979
|
const info = JSON.parse(raw);
|
|
980
|
+
// A clip's container runs a few ms past its picture on the audio's padding;
|
|
981
|
+
// the picture is what the cut shows, so its length is the clip's length.
|
|
982
|
+
const picture = Number((info.streams ?? []).find((s) => s.codec_type === "video" && !s.disposition?.attached_pic)?.duration);
|
|
484
983
|
return {
|
|
485
|
-
seconds: Number(info.format?.duration ?? 0),
|
|
984
|
+
seconds: picture > 0 ? picture : Number(info.format?.duration ?? 0),
|
|
486
985
|
hasAudio: (info.streams ?? []).some((s) => s.codec_type === "audio"),
|
|
487
986
|
};
|
|
488
987
|
};
|
|
@@ -501,6 +1000,7 @@ function main() {
|
|
|
501
1000
|
const rows = readJson(file);
|
|
502
1001
|
return Array.isArray(rows) && rows.length > 0 ? rows : null;
|
|
503
1002
|
},
|
|
1003
|
+
readLevels: (file) => clipLevels(here(file)),
|
|
504
1004
|
});
|
|
505
1005
|
if (timeline.spine.length === 0) {
|
|
506
1006
|
fail(1, `No scene has a clip or a keyframe. Check: exodus video status ${manifest.runId}`);
|