@aicopycoders/exodus 2026.9.2000 → 2026.10.1200-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/skills/exodus-video/SKILL.md +40 -14
- package/assets/skills/exodus-video/scripts/first-cut.d.mts +18 -1
- package/assets/skills/exodus-video/scripts/first-cut.mjs +434 -93
- package/dist/commands/video-customer.d.ts +3 -0
- package/dist/commands/video-customer.js +12 -1
- package/dist/commands/video.d.ts +35 -2
- package/dist/commands/video.js +126 -25
- package/package.json +3 -3
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// its cued line ends, heard with its own sound while the speaker is silent,
|
|
8
8
|
// fading in across any quiet start and out rather than stopping dead.
|
|
9
9
|
//
|
|
10
|
-
// node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]
|
|
10
|
+
// node first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music] [--still-fill]
|
|
11
11
|
//
|
|
12
12
|
// The audio track comes first. A narrated scene (the storyboard gives it
|
|
13
13
|
// narration and no on-camera line) plays its voice track and NOTHING the clip
|
|
@@ -16,23 +16,27 @@
|
|
|
16
16
|
// the voice runs longer. The voice itself is never altered. A dialogue scene
|
|
17
17
|
// keeps the audio it performed; at each join its silence before the first word
|
|
18
18
|
// and after the last is cut back to a short beat, read from its word times.
|
|
19
|
-
// Inside it, a silence over 0.
|
|
19
|
+
// Inside it, a silence over 0.6 s between two sentences is cut down to 0.5 s,
|
|
20
20
|
// picture and sound together, unless the script writes that pause in. A
|
|
21
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.
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
22
|
+
// out, read from the clip's loudness, and the same rule then applies. Every
|
|
23
|
+
// segment is loudness-normalized to -16 LUFS before the join.
|
|
24
|
+
// A scene with no clip stops the cut (#3365: a customer's ad once ended on 16 s
|
|
25
|
+
// of frozen, silent keyframes). --skip drops scenes. --still-fill instead turns
|
|
26
|
+
// a scene with no clip but a keyframe into a still for the length of its voice
|
|
27
|
+
// track, else the storyboard's planned duration. A run whose Pacing is jump cuts (manifest.shots)
|
|
28
|
+
// punches in on every other cut between one speaker's scenes; a montage does
|
|
29
|
+
// the same where one speaker's scenes in a row play one base shot.
|
|
27
30
|
//
|
|
28
31
|
// After the render it writes the whole ad's word times beside the MP4, as
|
|
29
|
-
// <out>.words.json and <out>.srt, for captioning in post
|
|
32
|
+
// <out>.words.json and <out>.srt, for captioning in post, and where each scene
|
|
33
|
+
// plays in the cut as <out>.scenes.json (#3300).
|
|
30
34
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
31
35
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
32
36
|
import path from "node:path";
|
|
33
37
|
import { pathToFileURL } from "node:url";
|
|
34
38
|
|
|
35
|
-
const USAGE = "usage: first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music]";
|
|
39
|
+
const USAGE = "usage: first-cut.mjs <pulled-dir> [--out cut.mp4] [--skip 2,5] [--no-music] [--still-fill]";
|
|
36
40
|
const WIDTH = 1080;
|
|
37
41
|
const HEIGHT = 1920;
|
|
38
42
|
const FPS = 24;
|
|
@@ -44,6 +48,16 @@ const norm = (label) =>
|
|
|
44
48
|
* on whole frames, so the picture runs as long as its sound there and the
|
|
45
49
|
* timeline's times are the cut's times.
|
|
46
50
|
*/
|
|
51
|
+
/**
|
|
52
|
+
* #3304: the jump cut's punch-in, a centre crop of the frame scaled up this
|
|
53
|
+
* much. The planner holds one framing across a jump-cut run's scenes, and a
|
|
54
|
+
* montage's line-cut pieces keep their parent's (#3323), so without it those
|
|
55
|
+
* cuts would be the same picture.
|
|
56
|
+
*/
|
|
57
|
+
export const PUNCH_IN_SCALE = 1.15;
|
|
58
|
+
const even = (n) => 2 * Math.round(n / 2);
|
|
59
|
+
const PUNCH_IN =
|
|
60
|
+
`scale=${even(WIDTH * PUNCH_IN_SCALE)}:${even(HEIGHT * PUNCH_IN_SCALE)},crop=${WIDTH}:${HEIGHT},setsar=1`;
|
|
47
61
|
const floorFrame = (t) => Math.floor(t * FPS + 1e-6) / FPS;
|
|
48
62
|
const ceilFrame = (t) => Math.ceil(t * FPS - 1e-6) / FPS;
|
|
49
63
|
const stereo = "aresample=48000,aformat=channel_layouts=stereo";
|
|
@@ -58,8 +72,17 @@ const stereo = "aresample=48000,aformat=channel_layouts=stereo";
|
|
|
58
72
|
const LOUDNORM = "apad=whole_dur=3,loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000,asetpts=N/SR/TB";
|
|
59
73
|
/** A fade this short at each edge of a segment's sound stops a hard cut in the room tone clicking. */
|
|
60
74
|
const EDGE_FADE_SEC = 0.015;
|
|
61
|
-
/**
|
|
62
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Air after a narrator's last word. Sizing by the voice file plus 0.8 s left
|
|
77
|
+
* ~1.1 s of silence at every narrator join, since the file already runs on
|
|
78
|
+
* ~0.3 s past the word (#3384).
|
|
79
|
+
*/
|
|
80
|
+
export const NARRATION_JOIN_AIR_SEC = 0.25;
|
|
81
|
+
/**
|
|
82
|
+
* Air after the last word of the line that ends the ad, spoken or narrated, so
|
|
83
|
+
* it lands. A dialogue clip's own tail ran ~1.1 s of dead air (#3423).
|
|
84
|
+
*/
|
|
85
|
+
export const END_AIR_SEC = 0.5;
|
|
63
86
|
/** A continuous low bed under the whole ad, so the joins between clips do not read as dead air. */
|
|
64
87
|
const ROOM_TONE =
|
|
65
88
|
"anoisesrc=color=brown:amplitude=0.0035:r=48000,highpass=f=60,lowpass=f=900,aformat=channel_layouts=stereo";
|
|
@@ -105,34 +128,188 @@ export function lineWordCounts(storyboard) {
|
|
|
105
128
|
const wordToken = (word) => String(word ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
106
129
|
|
|
107
130
|
/**
|
|
108
|
-
* #
|
|
109
|
-
*
|
|
110
|
-
* when the whole script is not heard that way.
|
|
131
|
+
* #3175: one spoken word a script and a scribe spell differently ("ok", "Okay").
|
|
132
|
+
* #3460: "alright" maps to the scribe's "All right" once its halves are joined.
|
|
111
133
|
*/
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
134
|
+
const SAME_WORD_SPELLINGS = new Map([
|
|
135
|
+
["ok", "okay"],
|
|
136
|
+
["alright", "allright"],
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
/** The spoken tokens of one written word; empty for pure punctuation. */
|
|
140
|
+
function spokenWords(word) {
|
|
141
|
+
const core = String(word ?? "").toLowerCase().replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
|
|
142
|
+
// #3133: a scribe writes a spoken "glowmist dot com" as "Glowmist.com".
|
|
143
|
+
const parts = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z]{2,})+$/.test(core)
|
|
144
|
+
? core.split(".").flatMap((part, i) => (i ? ["dot", part] : [part]))
|
|
145
|
+
: [core];
|
|
146
|
+
return parts.map(wordToken).filter(Boolean).map((t) => SAME_WORD_SPELLINGS.get(t) ?? t);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** { tokens, sourceIndex }: tokens[i] came from written word sourceIndex[i]. */
|
|
150
|
+
function spokenTokens(words) {
|
|
151
|
+
const tokens = [];
|
|
152
|
+
const sourceIndex = [];
|
|
153
|
+
words.forEach((word, i) => {
|
|
154
|
+
for (const token of spokenWords(word)) {
|
|
155
|
+
tokens.push(token);
|
|
156
|
+
sourceIndex.push(i);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
return { tokens, sourceIndex };
|
|
116
160
|
}
|
|
117
161
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
162
|
+
/** An ellipsis or a dash, which a writer may type with no space around it (scout scribe.ts PAUSE_MARK). */
|
|
163
|
+
const PAUSE_MARK = /…|\.{3,}|[—–]|-{2,}/g;
|
|
164
|
+
const scriptWords = (text) => (text ?? "").replace(PAUSE_MARK, " $& ").split(/\s+/).filter(Boolean);
|
|
165
|
+
|
|
166
|
+
const MAX_JOINED_WORDS = 3;
|
|
167
|
+
const MIN_SLIP_WORD_LENGTH = 7;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* #2300: a scribe writes a made-up name the script spells as one word
|
|
171
|
+
* ("FreshKeep") as two ("Fresh keep"), or the other way round. Joins each run
|
|
172
|
+
* of 2-3 neighbouring tokens whose concatenation is a token of the other side,
|
|
173
|
+
* longest first. #2520: a run one slip off one of the other side's names joins
|
|
174
|
+
* too, unless a join also starts at the next token, or (#3058) its first token
|
|
175
|
+
* is a word the other side already has. Mirrors scout scribe.ts joinSplitWords.
|
|
176
|
+
*/
|
|
177
|
+
function joinSplitWords(side, otherSide, otherNames = []) {
|
|
178
|
+
const other = new Set(otherSide);
|
|
179
|
+
const fuzzy = (r) => otherNames.some((name) => sameWord(name, r, true));
|
|
180
|
+
const run = (i, k) => (i + k <= side.tokens.length ? side.tokens.slice(i, i + k).join("") : undefined);
|
|
181
|
+
const joinStartsAt = (i) => {
|
|
182
|
+
for (let k = MAX_JOINED_WORDS; k >= 2; k--) {
|
|
183
|
+
const r = run(i, k);
|
|
184
|
+
if (r !== undefined && (other.has(r) || fuzzy(r))) return true;
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
};
|
|
188
|
+
const tokens = [];
|
|
189
|
+
const sourceIndex = [];
|
|
190
|
+
const lastSourceIndex = [];
|
|
191
|
+
for (let i = 0; i < side.tokens.length; ) {
|
|
192
|
+
let take = 1;
|
|
193
|
+
for (let k = MAX_JOINED_WORDS; k >= 2; k--) {
|
|
194
|
+
const r = run(i, k);
|
|
195
|
+
if (r === undefined) continue;
|
|
196
|
+
if (other.has(r) || (fuzzy(r) && !joinStartsAt(i + 1) && !other.has(side.tokens[i]))) {
|
|
197
|
+
take = k;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
tokens.push(side.tokens.slice(i, i + take).join(""));
|
|
202
|
+
sourceIndex.push(side.sourceIndex[i]);
|
|
203
|
+
lastSourceIndex.push(side.sourceIndex[i + take - 1]);
|
|
204
|
+
i += take;
|
|
126
205
|
}
|
|
127
|
-
return
|
|
206
|
+
return { tokens, sourceIndex, lastSourceIndex };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Which written words read as names: a capital inside the word, or a leading
|
|
211
|
+
* capital where no sentence starts. "I" and "I'm" are never names.
|
|
212
|
+
*/
|
|
213
|
+
function nameLikeWords(words) {
|
|
214
|
+
return words.map((word, i) => {
|
|
215
|
+
const bare = word.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, "");
|
|
216
|
+
if (/^I(['’][a-z]+)?$/.test(bare)) return false;
|
|
217
|
+
if (/[A-Z]/.test(bare.slice(1))) return true;
|
|
218
|
+
const sentenceStart = i === 0 || /[.!?]["')\]]*$/.test(words[i - 1]);
|
|
219
|
+
return /^[A-Z]/.test(bare) && !sentenceStart;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* #2441: one letter off on a long name is a slip; #3035: so is a spelling of
|
|
225
|
+
* the same sound, and #3415 a final z after a vowel for an s ("Inez", "Ines").
|
|
226
|
+
*/
|
|
227
|
+
function sameWord(scripted, heard, nameLike) {
|
|
228
|
+
if (scripted === heard || soundKey(scripted) === soundKey(heard)) return true;
|
|
229
|
+
if (!nameLike || scripted.length < MIN_SLIP_WORD_LENGTH) return false;
|
|
230
|
+
const [short, long] = scripted.length <= heard.length ? [scripted, heard] : [heard, scripted];
|
|
231
|
+
if (long.length - short.length > 1) return false;
|
|
232
|
+
let i = 0;
|
|
233
|
+
while (i < short.length && short[i] === long[i]) i++;
|
|
234
|
+
const rest = short.length === long.length ? i + 1 : i;
|
|
235
|
+
return short.slice(rest) === long.slice(i + 1);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const soundKey = (token) =>
|
|
239
|
+
token
|
|
240
|
+
.replace(/ph/g, "f")
|
|
241
|
+
.replace(/ck|q|c(?![eiy])/g, "k")
|
|
242
|
+
.replace(/(?<=\w{3})oe$/, "o")
|
|
243
|
+
.replace(/(?<=[aeiou])z$/, "s");
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Where the script's first complete, in-order delivery sits in the heard
|
|
247
|
+
* words, or null. heardTokenOf/heardWordOf give the heard token and word that
|
|
248
|
+
* deliver each (joined) script token; spokenWords lists every heard word that
|
|
249
|
+
* carries a token. #3140: one scripted word misheard as another is paired with
|
|
250
|
+
* the heard word in its place when the next scripted word follows it, but an
|
|
251
|
+
* exact delivery wins. Mirrors scout scribe.ts alignScript.
|
|
252
|
+
*/
|
|
253
|
+
function alignScript(words, written) {
|
|
254
|
+
const scripted = spokenTokens(written);
|
|
255
|
+
const heardWords = spokenTokens(words.map((w) => w.w));
|
|
256
|
+
const nameLike = nameLikeWords(written);
|
|
257
|
+
const names = scripted.tokens.filter((_, t) => nameLike[scripted.sourceIndex[t]]);
|
|
258
|
+
const heard = joinSplitWords(heardWords, scripted.tokens, names);
|
|
259
|
+
const expected = joinSplitWords(scripted, heard.tokens);
|
|
260
|
+
if (expected.tokens.length === 0 || heard.tokens.length === 0) return null;
|
|
261
|
+
const matches = (e, h) =>
|
|
262
|
+
h < heard.tokens.length && sameWord(expected.tokens[e], heard.tokens[h], nameLike[expected.sourceIndex[e]]);
|
|
263
|
+
const pair = (allowMisheard) => {
|
|
264
|
+
const tokenOf = [];
|
|
265
|
+
let j = 0;
|
|
266
|
+
for (let e = 0; e < expected.tokens.length; e++) {
|
|
267
|
+
const misheard = allowMisheard && !matches(e, j) && e + 1 < expected.tokens.length && matches(e + 1, j + 1);
|
|
268
|
+
if (!misheard) while (j < heard.tokens.length && !matches(e, j)) j++;
|
|
269
|
+
if (j === heard.tokens.length) return null;
|
|
270
|
+
tokenOf.push(j++);
|
|
271
|
+
}
|
|
272
|
+
return tokenOf;
|
|
273
|
+
};
|
|
274
|
+
const heardTokenOf = pair(false) ?? pair(true);
|
|
275
|
+
if (!heardTokenOf) return null;
|
|
276
|
+
return {
|
|
277
|
+
expected,
|
|
278
|
+
heard,
|
|
279
|
+
heardTokenOf,
|
|
280
|
+
heardWordOf: heardTokenOf.map((j) => heard.sourceIndex[j]),
|
|
281
|
+
spokenWords: [...new Set(heardWords.sourceIndex)],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* #2541: the indexes of the heard words that deliver the first and last
|
|
287
|
+
* scripted word, matching the script in order at its earliest delivery, or null
|
|
288
|
+
* when the whole script is not heard that way. #3058: a last word heard split
|
|
289
|
+
* in two ends on its second half.
|
|
290
|
+
*/
|
|
291
|
+
export function scriptedSpan(words, lineTexts) {
|
|
292
|
+
const aligned = alignScript(words, lineTexts.flatMap(scriptWords));
|
|
293
|
+
if (!aligned) return null;
|
|
294
|
+
const { heard, heardWordOf, heardTokenOf } = aligned;
|
|
295
|
+
return { first: heardWordOf[0], last: heard.lastSourceIndex[heardTokenOf.at(-1)] };
|
|
128
296
|
}
|
|
129
297
|
|
|
130
|
-
/** #2544: a silence between two sentences longer than this is cut down to the kept length. */
|
|
131
|
-
export const SENTENCE_GAP_MAX_SEC = 0.
|
|
132
|
-
export const SENTENCE_GAP_KEEP_SEC = 0.
|
|
298
|
+
/** #2544, #3423: a silence between two sentences longer than this is cut down to the kept length. */
|
|
299
|
+
export const SENTENCE_GAP_MAX_SEC = 0.6;
|
|
300
|
+
export const SENTENCE_GAP_KEEP_SEC = 0.5;
|
|
133
301
|
/** A pause the script writes in: an ellipsis, a dash, or a hyphen standing between words. */
|
|
134
302
|
const WRITTEN_PAUSE = /…|\.{3}|[—–-]/;
|
|
135
303
|
const SENTENCE_END = /[.?!]/;
|
|
304
|
+
/** A heard word that ends a sentence; a heard ellipsis does not (scout scribe.ts HEARD_SENTENCE_END). */
|
|
305
|
+
const HEARD_SENTENCE_END = /(?<!\.)[.?!]["'”’)\]]*$/;
|
|
306
|
+
/** A heard abbreviation keeps its period mid-sentence ("2p.m.", "U.S.", "Dr."). */
|
|
307
|
+
const HEARD_ABBREVIATION = /^[\d:]*(?:\p{L}\.){2,}$|^(?:mr|mrs|ms|dr|st|vs|etc|jr|sr)\.$/iu;
|
|
308
|
+
|
|
309
|
+
function heardSentenceEnd(word) {
|
|
310
|
+
const bare = String(word ?? "").replace(/^["'“‘(\[]+|["'”’)\]]+$/g, "");
|
|
311
|
+
return HEARD_SENTENCE_END.test(bare) && !HEARD_ABBREVIATION.test(bare);
|
|
312
|
+
}
|
|
136
313
|
/**
|
|
137
314
|
* #2554: loudness windows, and the levels that read as a sound and as quiet;
|
|
138
315
|
* SETTLED_WINDOWS quiet windows in a row (200 ms) end a sound (scout trim.ts).
|
|
@@ -164,8 +341,10 @@ export function asksPause(line) {
|
|
|
164
341
|
* clip's own time. A heard silence over SENTENCE_GAP_MAX_SEC between two
|
|
165
342
|
* scripted words at a sentence break (a new line, or . ? ! between them) keeps
|
|
166
343
|
* half of SENTENCE_GAP_KEEP_SEC after the earlier word and half before the
|
|
167
|
-
* later one; the rest goes.
|
|
168
|
-
*
|
|
344
|
+
* later one; the rest goes. #3460: a speaker can end a sentence where the
|
|
345
|
+
* script writes a comma, so an earlier word the scribe heard end one counts.
|
|
346
|
+
* Kept: a pause the script writes in (a mark between the words, or a
|
|
347
|
+
* direction on either line asking for one), the laugh beat
|
|
169
348
|
* after a line a reaction is cued to, and any gap with another heard sound in
|
|
170
349
|
* it. Nothing is cut when the script is not heard in order.
|
|
171
350
|
*
|
|
@@ -177,32 +356,39 @@ export function asksPause(line) {
|
|
|
177
356
|
* lines: [{ text, asksPause, cuesBeat }] in the order the clip speaks them.
|
|
178
357
|
*/
|
|
179
358
|
export function sentenceGapCuts(words, lines, levels = null) {
|
|
180
|
-
const
|
|
359
|
+
const written = [];
|
|
360
|
+
const pieces = [];
|
|
181
361
|
let between = "";
|
|
182
362
|
lines.forEach((line, li) => {
|
|
183
|
-
for (const piece of (line.text
|
|
184
|
-
|
|
185
|
-
if (!
|
|
363
|
+
for (const piece of scriptWords(line.text)) {
|
|
364
|
+
written.push(piece);
|
|
365
|
+
if (!wordToken(piece)) {
|
|
186
366
|
between += ` ${piece}`;
|
|
367
|
+
pieces.push(null);
|
|
187
368
|
continue;
|
|
188
369
|
}
|
|
189
370
|
const [, lead, trail] = piece.match(/^([^\p{L}\p{N}]*).*?([^\p{L}\p{N}]*)$/u);
|
|
190
|
-
|
|
371
|
+
pieces.push({ li, before: between + lead });
|
|
191
372
|
between = trail;
|
|
192
373
|
}
|
|
193
374
|
between += " ";
|
|
194
375
|
});
|
|
195
|
-
const
|
|
196
|
-
if (!
|
|
376
|
+
const aligned = alignScript(words ?? [], written);
|
|
377
|
+
if (!aligned) return [];
|
|
378
|
+
const { expected, heardTokenOf, heardWordOf, spokenWords: spoken } = aligned;
|
|
197
379
|
const cuts = [];
|
|
198
|
-
for (let t = 0; t + 1 <
|
|
199
|
-
|
|
200
|
-
|
|
380
|
+
for (let t = 0; t + 1 < heardWordOf.length; t++) {
|
|
381
|
+
if (heardTokenOf[t + 1] !== heardTokenOf[t] + 1) continue;
|
|
382
|
+
const to = heardWordOf[t + 1];
|
|
383
|
+
if (heardWordOf[t] === to) continue;
|
|
384
|
+
// A joined script token keeps its first word's line and lead-in.
|
|
385
|
+
const [a, b] = [pieces[expected.sourceIndex[t]], pieces[expected.sourceIndex[t + 1]]];
|
|
201
386
|
const newLine = a.li !== b.li;
|
|
202
|
-
|
|
387
|
+
// A name heard split in two (#2300) ends on its last heard word.
|
|
388
|
+
const [earlier, later] = [words[spoken.findLast((w) => w < to)], words[to]];
|
|
389
|
+
if (!newLine && !SENTENCE_END.test(b.before) && !heardSentenceEnd(earlier.w)) continue;
|
|
203
390
|
if (WRITTEN_PAUSE.test(b.before) || lines[a.li].asksPause || lines[b.li].asksPause) continue;
|
|
204
391
|
if (newLine && lines[a.li].cuesBeat) continue;
|
|
205
|
-
const [earlier, later] = [words[at[t]], words[at[t + 1]]];
|
|
206
392
|
const gap = later.s - earlier.e;
|
|
207
393
|
const heard = levels && soundBetween(levels, earlier.e, later.s);
|
|
208
394
|
// A sound's frame edges widen the cut, so none of it is left in.
|
|
@@ -367,6 +553,7 @@ export function scenePlans(storyboard) {
|
|
|
367
553
|
plans.set(scene.sceneIndex, {
|
|
368
554
|
firstSpeaker: speakers[0] ?? null,
|
|
369
555
|
lastSpeaker: speakers.at(-1) ?? null,
|
|
556
|
+
baseShotId: hasText(scene.baseShotId) ? scene.baseShotId.trim() : null,
|
|
370
557
|
cutaway: CUTAWAY_KINDS.has(scene.kind),
|
|
371
558
|
narrated: narratedScene(scene, storyboard),
|
|
372
559
|
lineIds: Array.isArray(scene.lineIds) ? scene.lineIds : [],
|
|
@@ -380,17 +567,18 @@ export function scenePlans(storyboard) {
|
|
|
380
567
|
}
|
|
381
568
|
|
|
382
569
|
/**
|
|
383
|
-
* Fit a picture to its voice track. The segment
|
|
384
|
-
*
|
|
385
|
-
* altered
|
|
386
|
-
*
|
|
387
|
-
* A
|
|
388
|
-
*
|
|
570
|
+
* Fit a picture to its voice track. The segment runs to where the voice stops
|
|
571
|
+
* speaking (spokenEndSec) plus airSec, to the next whole frame; the voice is
|
|
572
|
+
* never altered, only cut in the silence after that point. A clip that runs
|
|
573
|
+
* longer is trimmed; a clip that runs shorter holds its last frame for the
|
|
574
|
+
* difference. A still (no clip) simply runs that length. A scene marked with
|
|
575
|
+
* actionTailSec keeps that long after the voice, up to the clip's end, so the
|
|
576
|
+
* set-down it ends on is seen (#3199).
|
|
389
577
|
*/
|
|
390
|
-
export function fitNarration({ clipSeconds,
|
|
391
|
-
const aired = ceilFrame(
|
|
578
|
+
export function fitNarration({ clipSeconds, spokenEndSec, airSec, actionTailSec = 0 }) {
|
|
579
|
+
const aired = ceilFrame(spokenEndSec + airSec);
|
|
392
580
|
if (clipSeconds === null) return { seconds: aired, holdSec: 0, trimmedBy: 0 };
|
|
393
|
-
const target = Math.max(aired, Math.min(clipSeconds, ceilFrame(
|
|
581
|
+
const target = Math.max(aired, Math.min(clipSeconds, ceilFrame(spokenEndSec + actionTailSec)));
|
|
394
582
|
if (target <= clipSeconds) {
|
|
395
583
|
return { seconds: target, holdSec: 0, trimmedBy: clipSeconds - target };
|
|
396
584
|
}
|
|
@@ -449,10 +637,10 @@ export function cueEndInSegment(seg, counts, cueLineId) {
|
|
|
449
637
|
* (#2388). Each side comes back to a beat that reads as one conversation, a
|
|
450
638
|
* longer one when someone new answers. Only ever shortens, never into a word.
|
|
451
639
|
* The ad's first lead-in comes back to the same-speaker beat too, since a clip
|
|
452
|
-
* can hum before its first word (#2666); its last tail
|
|
453
|
-
* clip whose closing line cues a reaction is not the
|
|
454
|
-
* follows it. Trims land on whole frames so the
|
|
455
|
-
* same length.
|
|
640
|
+
* can hum before its first word (#2666); its last tail comes back to
|
|
641
|
+
* END_AIR_SEC (#3423), and a clip whose closing line cues a reaction is not the
|
|
642
|
+
* ad's end, since the beat follows it. Trims land on whole frames so the
|
|
643
|
+
* picture and its sound stay the same length.
|
|
456
644
|
*
|
|
457
645
|
* The beat is measured from the clip's scripted words when the script is heard
|
|
458
646
|
* in order, and a cut stops short of a sound made up before or after the line
|
|
@@ -478,9 +666,8 @@ function trimJoins(spine, reactionCues, scriptLines) {
|
|
|
478
666
|
inSec = Math.max(inSec, Math.min(clear, floorFrame(firstWord)));
|
|
479
667
|
}
|
|
480
668
|
const endsAd = k === spine.length - 1 && !reactionCues.has(seg.lineIds.at(-1));
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
: ceilFrame(lastWord + Math.max(JOIN_TAIL_SEC, seg.actionTailSec ?? 0));
|
|
669
|
+
const tailSec = Math.max(endsAd ? END_AIR_SEC : JOIN_TAIL_SEC, seg.actionTailSec ?? 0);
|
|
670
|
+
let outSec = ceilFrame(lastWord + tailSec);
|
|
484
671
|
if (after.length > 0) {
|
|
485
672
|
const clear = floorFrame(Math.min(...after.map((w) => w.s)) - EXTRA_CLEARANCE_SEC);
|
|
486
673
|
outSec = Math.min(outSec, Math.max(clear, ceilFrame(lastWord)));
|
|
@@ -501,6 +688,19 @@ function trimJoins(spine, reactionCues, scriptLines) {
|
|
|
501
688
|
});
|
|
502
689
|
}
|
|
503
690
|
|
|
691
|
+
/**
|
|
692
|
+
* Where a narrated segment stops speaking, and the air it keeps after (#3384).
|
|
693
|
+
* voiceWords are word times taken from the voice track itself. Without them
|
|
694
|
+
* the end of the voice file stands in for the last word, its own trailing
|
|
695
|
+
* silence counted as part of the air.
|
|
696
|
+
*/
|
|
697
|
+
function narrationEnd(voiceSeconds, voiceWords, endsAd) {
|
|
698
|
+
return {
|
|
699
|
+
spokenEndSec: voiceWords?.length ? Math.max(...voiceWords.map((w) => w.e)) : voiceSeconds,
|
|
700
|
+
airSec: endsAd ? END_AIR_SEC : NARRATION_JOIN_AIR_SEC,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
504
704
|
const holdFor = (seg) =>
|
|
505
705
|
seg.source === "clip" ? Math.max(0, seg.seconds - Math.max(0, seg.clipSeconds - seg.inSec)) : 0;
|
|
506
706
|
|
|
@@ -576,13 +776,66 @@ function quietStart(levels, seconds) {
|
|
|
576
776
|
return first > 0 ? Math.min(seconds, Math.max(EDGE_FADE_SEC, first * levels.windowSec)) : EDGE_FADE_SEC;
|
|
577
777
|
}
|
|
578
778
|
|
|
579
|
-
|
|
779
|
+
/**
|
|
780
|
+
* Alternate one speaker's scenes in a row between the plain frame and the
|
|
781
|
+
* punch-in, so every cut between them visibly moves in or out. Any other cut
|
|
782
|
+
* starts the alternation again. Marked before reactions split a scene, so both
|
|
783
|
+
* halves keep their scene's frame.
|
|
784
|
+
*/
|
|
785
|
+
function markPunchIns(spine, sameShot) {
|
|
786
|
+
let punched = false;
|
|
787
|
+
spine.forEach((seg, k) => {
|
|
788
|
+
const prev = spine[k - 1];
|
|
789
|
+
punched = Boolean(prev) && sameShot(prev, seg) && !punched;
|
|
790
|
+
if (punched) seg.punchIn = true;
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const sameSpeaker = (prev, seg) => Boolean(prev.lastSpeaker && seg.firstSpeaker === prev.lastSpeaker);
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* The pacings whose edit punches in, each with when a scene plays on in the
|
|
798
|
+
* shot of the one before it. Jump cuts film every scene in one framing. A
|
|
799
|
+
* montage changes shot on every cut, except where the line cut split a long
|
|
800
|
+
* line into pieces that keep their parent's base shot (#3317, #3323).
|
|
801
|
+
*/
|
|
802
|
+
const PUNCH_IN_RUNS = new Map([
|
|
803
|
+
["jump-cuts", sameSpeaker],
|
|
804
|
+
["montage", (prev, seg) =>
|
|
805
|
+
sameSpeaker(prev, seg) && Boolean(seg.baseShotId) && seg.baseShotId === prev.baseShotId],
|
|
806
|
+
]);
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* #3365: the scenes the cut cannot be made without, by number. A scene the
|
|
810
|
+
* storyboard plans needs a clip unless --skip names it, even one the pull has
|
|
811
|
+
* no row for. A scene it does not plan is a row a re-board left behind, so it
|
|
812
|
+
* is no loss. With no storyboard, every scene the pull lists counts.
|
|
813
|
+
*/
|
|
814
|
+
export function scenesWithNoClip({ manifest, storyboard, skip, exists = Boolean }) {
|
|
815
|
+
const planned = [...scenePlans(storyboard).keys()];
|
|
816
|
+
const listed = (manifest?.scenes ?? []).map((scene) => scene.sceneIndex);
|
|
817
|
+
const clipped = new Set(
|
|
818
|
+
(manifest?.scenes ?? []).filter((scene) => exists(scene.clip)).map((scene) => scene.sceneIndex),
|
|
819
|
+
);
|
|
820
|
+
return [...new Set(planned.length > 0 ? planned : listed)]
|
|
821
|
+
.filter((n) => !skip.has(n) && !clipped.has(n))
|
|
822
|
+
.sort((a, b) => a - b);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export function buildTimeline({
|
|
826
|
+
manifest, storyboard, skip, probe, exists, readWords, readLevels = () => null, stillFill = false,
|
|
827
|
+
}) {
|
|
580
828
|
const plans = scenePlans(storyboard);
|
|
581
829
|
const counts = lineWordCounts(storyboard);
|
|
582
830
|
const spine = [];
|
|
583
831
|
const cutaways = [];
|
|
584
832
|
const waiting = [];
|
|
585
833
|
const dropped = [];
|
|
834
|
+
const missing = new Set(scenesWithNoClip({ manifest, storyboard, skip, exists }));
|
|
835
|
+
const filled = new Set();
|
|
836
|
+
// A narrated segment is first fitted as a join; the one that ends the ad is
|
|
837
|
+
// refitted once the spine is known.
|
|
838
|
+
const refits = new Map();
|
|
586
839
|
|
|
587
840
|
for (const scene of manifest?.scenes ?? []) {
|
|
588
841
|
const n = scene.sceneIndex;
|
|
@@ -604,12 +857,13 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
604
857
|
sceneIndex: n, source: "clip", file: scene.clip,
|
|
605
858
|
seconds: clip.seconds, hasAudio: clip.hasAudio, ...cue,
|
|
606
859
|
});
|
|
607
|
-
} else if (exists(scene.keyframe)) {
|
|
860
|
+
} else if (stillFill && exists(scene.keyframe)) {
|
|
861
|
+
filled.add(n);
|
|
608
862
|
waiting.push({
|
|
609
863
|
sceneIndex: n, source: "still", file: scene.keyframe,
|
|
610
864
|
seconds: plan.durationSec ?? 4, hasAudio: false, ...cue,
|
|
611
865
|
});
|
|
612
|
-
} else {
|
|
866
|
+
} else if (!missing.has(n)) {
|
|
613
867
|
dropped.push({ n, reason: `cutaway ${n}: ${nothing}` });
|
|
614
868
|
}
|
|
615
869
|
continue;
|
|
@@ -624,20 +878,29 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
624
878
|
const base = {
|
|
625
879
|
sceneIndex: n, source: "clip", file: scene.clip, clipSeconds: clip.seconds, inSec: 0,
|
|
626
880
|
lineIds: plan.lineIds, firstSpeaker: plan.firstSpeaker, lastSpeaker: plan.lastSpeaker,
|
|
627
|
-
actionTailSec: plan.actionTailSec ?? 0,
|
|
881
|
+
baseShotId: plan.baseShotId ?? null, actionTailSec: plan.actionTailSec ?? 0,
|
|
628
882
|
};
|
|
629
883
|
if (voice && (narrated || !clip.hasAudio)) {
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
884
|
+
const voiceSeconds = probe(voice).seconds;
|
|
885
|
+
const words = readWords(scene.words);
|
|
886
|
+
// A clip's own word times describe the clip, not the voice that replaces its audio.
|
|
887
|
+
const voiceWords = scene.wordsFrom === "voice" ? words : null;
|
|
888
|
+
const fitted = (endsAd) => {
|
|
889
|
+
const fit = fitNarration({
|
|
890
|
+
clipSeconds: clip.seconds, ...narrationEnd(voiceSeconds, voiceWords, endsAd),
|
|
891
|
+
actionTailSec: plan.actionTailSec ?? 0,
|
|
892
|
+
});
|
|
893
|
+
const how = [];
|
|
894
|
+
if (fit.trimmedBy > 0.05) how.push(`clip trimmed ${clip.seconds.toFixed(1)}s -> ${fit.seconds.toFixed(1)}s`);
|
|
895
|
+
if (fit.holdSec > 0.05) how.push(`last frame held ${fit.holdSec.toFixed(1)}s`);
|
|
896
|
+
return {
|
|
897
|
+
seconds: fit.seconds, holdSec: fit.holdSec,
|
|
898
|
+
note: `${flag}, ${voice} replaces the clip's audio${how.length ? ` (${how.join(", ")})` : ""}`,
|
|
899
|
+
};
|
|
900
|
+
};
|
|
901
|
+
const seg = { ...base, audio: "narration", voice, words, ...fitted(false) };
|
|
902
|
+
spine.push(seg);
|
|
903
|
+
refits.set(seg, fitted);
|
|
641
904
|
} else if (narrated) {
|
|
642
905
|
spine.push({
|
|
643
906
|
...base, audio: "silence", voice: null, seconds: clip.seconds, holdSec: 0,
|
|
@@ -657,24 +920,50 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
657
920
|
}
|
|
658
921
|
continue;
|
|
659
922
|
}
|
|
660
|
-
if (exists(scene.keyframe)) {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
923
|
+
if (stillFill && exists(scene.keyframe)) {
|
|
924
|
+
filled.add(n);
|
|
925
|
+
const voiceSeconds = voice ? probe(voice).seconds : 0;
|
|
926
|
+
// #1689: on a narrated scene the words are the voice track's, and the
|
|
927
|
+
// voice starts where the segment does, so they need no offset here.
|
|
928
|
+
const words = voice ? readWords(scene.words) : null;
|
|
929
|
+
const voiceWords = scene.wordsFrom === "voice" ? words : null;
|
|
930
|
+
const fitted = (endsAd) => {
|
|
931
|
+
const seconds = voice
|
|
932
|
+
? fitNarration({ clipSeconds: null, ...narrationEnd(voiceSeconds, voiceWords, endsAd) }).seconds
|
|
933
|
+
: plan.durationSec ?? 4;
|
|
934
|
+
return {
|
|
935
|
+
seconds,
|
|
936
|
+
note: `${flag}${voice ? ` + ${voice}` : ", silent"} (${seconds.toFixed(1)}s, clip ${scene.clipStatus})`,
|
|
937
|
+
};
|
|
938
|
+
};
|
|
939
|
+
const seg = {
|
|
665
940
|
sceneIndex: n, source: "still", file: scene.keyframe, clipSeconds: null, inSec: 0,
|
|
666
|
-
|
|
667
|
-
lineIds: plan.lineIds,
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
});
|
|
941
|
+
audio: voice ? "narration" : "silence", voice, holdSec: 0,
|
|
942
|
+
lineIds: plan.lineIds, firstSpeaker: plan.firstSpeaker, lastSpeaker: plan.lastSpeaker,
|
|
943
|
+
baseShotId: plan.baseShotId ?? null, words, ...fitted(false),
|
|
944
|
+
};
|
|
945
|
+
spine.push(seg);
|
|
946
|
+
if (voice) refits.set(seg, fitted);
|
|
673
947
|
continue;
|
|
674
948
|
}
|
|
675
|
-
dropped.push({ n, reason: `scene ${n}: ${nothing}` });
|
|
949
|
+
if (!missing.has(n)) dropped.push({ n, reason: `scene ${n}: ${nothing}` });
|
|
950
|
+
}
|
|
951
|
+
const unfilled = [...missing].filter((n) => !filled.has(n));
|
|
952
|
+
if (unfilled.length > 0) {
|
|
953
|
+
const listed = new Map((manifest?.scenes ?? []).map((scene) => [scene.sceneIndex, scene]));
|
|
954
|
+
throw new FirstCutError(1, noClipRefusal(unfilled.map((n) => {
|
|
955
|
+
const scene = listed.get(n);
|
|
956
|
+
return {
|
|
957
|
+
n,
|
|
958
|
+
keyframe: Boolean(scene) && exists(scene.keyframe),
|
|
959
|
+
status: scene ? `clip ${scene.clipStatus}${scene.error ? `: ${scene.error}` : ""}` : "clip missing",
|
|
960
|
+
};
|
|
961
|
+
})));
|
|
676
962
|
}
|
|
677
963
|
|
|
964
|
+
const sameShot = PUNCH_IN_RUNS.get(manifest?.shots?.pacing);
|
|
965
|
+
if (sameShot) markPunchIns(spine, sameShot);
|
|
966
|
+
|
|
678
967
|
const noCue = (c) =>
|
|
679
968
|
c.cueLineId ? `${c.cueLineId} is spoken by no scene in the cut` : "the storyboard names no cue line";
|
|
680
969
|
|
|
@@ -683,6 +972,8 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
683
972
|
// spine, so they go in before any overlay is placed on it.
|
|
684
973
|
const scriptLines = new Map((storyboard?.script ?? []).map((line) => [line?.lineId, line]));
|
|
685
974
|
const reactionCues = new Set(waiting.filter((w) => w.reaction).map((w) => w.cueLineId));
|
|
975
|
+
const last = spine.at(-1);
|
|
976
|
+
if (refits.has(last) && !reactionCues.has(last.lineIds.at(-1))) Object.assign(last, refits.get(last)(true));
|
|
686
977
|
tightenSentenceGaps(spine, reactionCues, scriptLines, readLevels);
|
|
687
978
|
trimJoins(spine, reactionCues, scriptLines);
|
|
688
979
|
for (const c of waiting.filter((w) => w.reaction)) {
|
|
@@ -738,6 +1029,20 @@ export function buildTimeline({ manifest, storyboard, skip, probe, exists, readW
|
|
|
738
1029
|
};
|
|
739
1030
|
}
|
|
740
1031
|
|
|
1032
|
+
/** #3365: why the cut stopped on scenes with no clip, and the person's three ways on. */
|
|
1033
|
+
function noClipRefusal(missing) {
|
|
1034
|
+
const scenes = missing.map((m) => `scene ${m.n} (${m.status})`).join(", ");
|
|
1035
|
+
const skip = `--skip ${missing.map((m) => m.n).join(",")}`;
|
|
1036
|
+
const bare = missing.filter((m) => !m.keyframe).map((m) => m.n);
|
|
1037
|
+
const ways = bare.length === missing.length
|
|
1038
|
+
? `Wait for the clips and pull again, or leave them out with ${skip}.`
|
|
1039
|
+
: `Wait for the clips and pull again, leave them out with ${skip}, ` +
|
|
1040
|
+
"or hold each one's keyframe on screen instead with --still-fill (under its voice track if it has one, " +
|
|
1041
|
+
"else silent for its planned length)." +
|
|
1042
|
+
(bare.length > 0 ? ` --still-fill cannot cover scene ${bare.join(", ")}, which has no keyframe either.` : "");
|
|
1043
|
+
return `No clip for ${scenes}. ${ways}`;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
741
1046
|
function sec(n) {
|
|
742
1047
|
return (Number.isFinite(n) ? Math.max(0, n) : 0).toFixed(3);
|
|
743
1048
|
}
|
|
@@ -789,6 +1094,7 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
789
1094
|
}
|
|
790
1095
|
}
|
|
791
1096
|
if (seg.holdSec > 0.01) fit.push(`tpad=stop_mode=clone:stop_duration=${sec(seg.holdSec)}`);
|
|
1097
|
+
if (seg.punchIn) fit.push(PUNCH_IN);
|
|
792
1098
|
filters.push(`${norm(`${i}:v`)}${fit.length ? `,${fit.join(",")}` : ""}[v${i}]`);
|
|
793
1099
|
// A segment split around a reaction beat starts partway into its sound too.
|
|
794
1100
|
const skipIn = inSec > 0.001 ? `atrim=start=${sec(inSec)},asetpts=PTS-STARTPTS,` : "";
|
|
@@ -871,8 +1177,36 @@ export function buildFfmpegArgs(timeline, { resolve, music, out }) {
|
|
|
871
1177
|
];
|
|
872
1178
|
}
|
|
873
1179
|
|
|
1180
|
+
const roundMs = (n) => Math.round(n * 1000) / 1000;
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* #3300: every stretch of the cut where a scene is the picture, in order. A
|
|
1184
|
+
* cutaway covers the whole frame, so the spine scene under it is off screen
|
|
1185
|
+
* there and its placement splits around it. A scene split around a reaction
|
|
1186
|
+
* beat, or shown twice, has one entry per stretch.
|
|
1187
|
+
*/
|
|
1188
|
+
export function sceneTimes(timeline) {
|
|
1189
|
+
const covers = timeline.cutaways
|
|
1190
|
+
.filter((c) => c.seconds > 0)
|
|
1191
|
+
.map((c) => ({ from: c.startSec, to: c.startSec + c.seconds }));
|
|
1192
|
+
const visible = timeline.spine.flatMap((seg) => {
|
|
1193
|
+
let pieces = [{ from: seg.startSec, to: seg.startSec + seg.seconds }];
|
|
1194
|
+
for (const c of covers) {
|
|
1195
|
+
pieces = pieces.flatMap((p) =>
|
|
1196
|
+
c.to <= p.from || c.from >= p.to
|
|
1197
|
+
? [p]
|
|
1198
|
+
: [{ from: p.from, to: c.from }, { from: c.to, to: p.to }].filter((q) => q.to > q.from),
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
return pieces.map((p) => ({ sceneIndex: seg.sceneIndex, startSec: p.from, seconds: p.to - p.from }));
|
|
1202
|
+
});
|
|
1203
|
+
return [...visible, ...timeline.cutaways]
|
|
1204
|
+
.map((p) => ({ scene: p.sceneIndex, startSec: roundMs(p.startSec), seconds: roundMs(p.seconds) }))
|
|
1205
|
+
.filter((p) => p.seconds > 0)
|
|
1206
|
+
.sort((a, b) => a.startSec - b.startSec);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
874
1209
|
export function wordRows(timeline) {
|
|
875
|
-
const ms = (n) => Math.round(n * 1000) / 1000;
|
|
876
1210
|
const rows = [];
|
|
877
1211
|
for (const seg of timeline.spine) {
|
|
878
1212
|
for (const w of seg.words ?? []) {
|
|
@@ -880,7 +1214,7 @@ export function wordRows(timeline) {
|
|
|
880
1214
|
const start = seg.startSec + w.s;
|
|
881
1215
|
const end = seg.startSec + w.e;
|
|
882
1216
|
if (text && end > start) {
|
|
883
|
-
rows.push({ text, start:
|
|
1217
|
+
rows.push({ text, start: roundMs(start), end: roundMs(end), scene: seg.sceneIndex });
|
|
884
1218
|
}
|
|
885
1219
|
}
|
|
886
1220
|
}
|
|
@@ -931,7 +1265,11 @@ export function toSrt(rows) {
|
|
|
931
1265
|
export function sidecarPaths(outPath) {
|
|
932
1266
|
const dir = path.dirname(outPath);
|
|
933
1267
|
const base = path.basename(outPath, path.extname(outPath));
|
|
934
|
-
return {
|
|
1268
|
+
return {
|
|
1269
|
+
words: path.join(dir, `${base}.words.json`),
|
|
1270
|
+
srt: path.join(dir, `${base}.srt`),
|
|
1271
|
+
scenes: path.join(dir, `${base}.scenes.json`),
|
|
1272
|
+
};
|
|
935
1273
|
}
|
|
936
1274
|
|
|
937
1275
|
export function inTheCut(timeline) {
|
|
@@ -1099,12 +1437,13 @@ export class FirstCutError extends Error {
|
|
|
1099
1437
|
}
|
|
1100
1438
|
|
|
1101
1439
|
function parseArgs(argv) {
|
|
1102
|
-
const opts = { dir: undefined, out: undefined, skip: new Set(), music: true };
|
|
1440
|
+
const opts = { dir: undefined, out: undefined, skip: new Set(), music: true, stillFill: false };
|
|
1103
1441
|
for (let i = 0; i < argv.length; i++) {
|
|
1104
1442
|
const a = argv[i];
|
|
1105
1443
|
if (a === "--out") opts.out = argv[++i];
|
|
1106
1444
|
else if (a === "--skip") opts.skip = new Set((argv[++i] ?? "").split(",").filter(Boolean).map(Number));
|
|
1107
1445
|
else if (a === "--no-music") opts.music = false;
|
|
1446
|
+
else if (a === "--still-fill") opts.stillFill = true;
|
|
1108
1447
|
else if (a.startsWith("--")) fail(2, `unknown flag ${a}\n${USAGE}`);
|
|
1109
1448
|
else if (opts.dir === undefined) opts.dir = a;
|
|
1110
1449
|
else fail(2, `unexpected argument ${a}\n${USAGE}`);
|
|
@@ -1119,7 +1458,7 @@ function parseArgs(argv) {
|
|
|
1119
1458
|
* sidecars, and throws FirstCutError where the CLI would exit. The CLI's report
|
|
1120
1459
|
* is printed by main() from what this returns.
|
|
1121
1460
|
*/
|
|
1122
|
-
export function firstCut({ dir, out: outOpt, skip = new Set(), music: musicOn = true }) {
|
|
1461
|
+
export function firstCut({ dir, out: outOpt, skip = new Set(), music: musicOn = true, stillFill = false }) {
|
|
1123
1462
|
for (const bin of ["ffmpeg", "ffprobe"]) {
|
|
1124
1463
|
if (spawnSync(bin, ["-version"]).status !== 0) {
|
|
1125
1464
|
throw new FirstCutError(2, `${bin} is not installed. Install ffmpeg (brew install ffmpeg / apt install ffmpeg).`);
|
|
@@ -1169,6 +1508,7 @@ export function firstCut({ dir, out: outOpt, skip = new Set(), music: musicOn =
|
|
|
1169
1508
|
manifest,
|
|
1170
1509
|
storyboard,
|
|
1171
1510
|
skip,
|
|
1511
|
+
stillFill,
|
|
1172
1512
|
probe,
|
|
1173
1513
|
exists,
|
|
1174
1514
|
readWords: (file) => {
|
|
@@ -1202,6 +1542,7 @@ export function firstCut({ dir, out: outOpt, skip = new Set(), music: musicOn =
|
|
|
1202
1542
|
rmSync(sidecars.words, { force: true });
|
|
1203
1543
|
rmSync(sidecars.srt, { force: true });
|
|
1204
1544
|
}
|
|
1545
|
+
writeFileSync(sidecars.scenes, `${JSON.stringify(sceneTimes(timeline), null, 2)}\n`);
|
|
1205
1546
|
|
|
1206
1547
|
const duration = execFileSync("ffprobe", [
|
|
1207
1548
|
"-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", out,
|