@gentbajko/slopify 2.3.1 → 2.4.0
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/dist/adapter-registry.js +1 -1
- package/dist/adapters/image/fal.js +77 -6
- package/dist/adapters/image/replicate.js +41 -2
- package/dist/adapters/image/video.js +28 -0
- package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
- package/dist/assets/fonts/SOURCE.txt +6 -0
- package/dist/assets/models.yaml +82 -1
- package/dist/catalog/schema.js +10 -0
- package/dist/catalog/store.js +2 -2
- package/dist/catalog/validate.js +32 -4
- package/dist/edge/docker-launch.js +4 -0
- package/dist/edge/docker-projects/engine.js +57 -1
- package/dist/edge/docker-projects/install.js +10 -2
- package/dist/edge/docker-projects/prune.js +66 -0
- package/dist/edge/http/providers.js +8 -0
- package/dist/edge/http/timeouts.js +38 -0
- package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
- package/dist/kernel/runner/attempt.js +4 -1
- package/dist/kernel/runner/providers.js +20 -0
- package/dist/main.js +3 -0
- package/dist/slices/admission/rules.js +44 -2
- package/dist/slices/admission/schema.js +31 -0
- package/dist/slices/estimate/index.js +57 -2
- package/dist/slices/fonts/catalog.js +23 -0
- package/dist/slices/fonts/index.js +1 -1
- package/dist/slices/library/model.js +8 -1
- package/dist/slices/library/slots.js +6 -1
- package/dist/slices/play-drafts/convert.js +37 -1
- package/dist/slices/play-drafts/readiness.js +7 -3
- package/dist/slices/play-drafts/schema.js +24 -1
- package/dist/slices/project-templates/from-project.js +22 -0
- package/dist/slices/project-templates/setup.js +2 -0
- package/dist/slices/rebuild/preview-details.js +29 -1
- package/dist/slices/rebuild/preview-plan.js +2 -2
- package/dist/slices/rebuild/recipe-build.js +6 -2
- package/dist/slices/rebuild/recipe-edit.js +178 -0
- package/dist/slices/rebuild/recipe-exports.js +9 -4
- package/dist/slices/rebuild/recipe-input-schema.js +1 -0
- package/dist/slices/rebuild/recipe-legacy.js +9 -0
- package/dist/slices/rebuild/recipe-model.js +7 -1
- package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
- package/dist/slices/rebuild/recipe-save.js +25 -1
- package/dist/slices/rebuild/recipe-shorts.js +179 -0
- package/dist/slices/rebuild/recipe-validation.js +19 -2
- package/dist/slices/rebuild/recipe-visual.js +10 -1
- package/dist/slices/rebuild/recipe-work.js +83 -1
- package/dist/slices/rebuild/runtime-animate.js +110 -0
- package/dist/slices/rebuild/runtime-export-edit.js +119 -0
- package/dist/slices/rebuild/runtime-export.js +13 -1
- package/dist/slices/rebuild/runtime-materialize.js +48 -0
- package/dist/slices/rebuild/runtime-piece-label.js +15 -0
- package/dist/slices/rebuild/runtime-publication.js +25 -4
- package/dist/slices/rebuild/runtime-run.js +11 -5
- package/dist/slices/rebuild/runtime-shorts.js +330 -0
- package/dist/slices/rebuild/runtime-store.js +7 -3
- package/dist/slices/rebuild/transition-repo.js +39 -0
- package/dist/slices/revisions/mutation-assets.js +18 -0
- package/dist/slices/revisions/mutation-prepare.js +9 -7
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/publish.js +20 -2
- package/dist/slices/revisions/schema.js +11 -0
- package/dist/slices/shorts/captions.js +272 -0
- package/dist/slices/shorts/clips.js +89 -0
- package/dist/slices/shorts/model.js +186 -0
- package/dist/slices/shorts/pick.js +232 -0
- package/dist/slices/shorts/prompts.js +68 -0
- package/dist/slices/shorts/render.js +163 -0
- package/dist/slices/storage/asset-name.js +9 -2
- package/dist/slices/storage/layout.js +8 -0
- package/dist/slices/storage/model.js +6 -0
- package/dist/slices/storage/schema.js +3 -0
- package/dist/slices/telemetry/model.js +3 -0
- package/dist/slices/video/cards.js +72 -0
- package/dist/slices/video/chapters.js +89 -0
- package/dist/slices/video/cuts.js +141 -0
- package/dist/slices/video/edit-list.js +36 -6
- package/dist/slices/video/edit-settings.js +191 -0
- package/dist/slices/video/ffmpeg.js +155 -29
- package/dist/slices/video/look.js +164 -0
- package/dist/slices/video/plan.js +68 -11
- package/dist/slices/video/slideshow.js +22 -6
- package/dist/slices/video/transitions.js +51 -0
- package/dist/slices/youtube/transcript.js +54 -0
- package/dist/web/assets/index-BGgYMm2u.css +1 -0
- package/dist/web/assets/index-BVVgtntL.js +171 -0
- package/dist/web/assets/{pdf-DR97Byps.js → pdf-CAJNT254.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DfXmBzCk.js +0 -168
- package/dist/web/assets/index-Y3RhfTEh.css +0 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { isAbsolute, resolve as resolvePath } from "node:path";
|
|
3
|
+
import { gradeFilter, lookChain, lookEncoding, placed } from "./look.js";
|
|
3
4
|
import { decimal, zoomRange } from "./plan.js";
|
|
5
|
+
import { segments, transitionHalves } from "./transitions.js";
|
|
4
6
|
// Hand-rolled per the standards: the filtergraph is the load-bearing part of this slice
|
|
5
7
|
// and a wrapper would hide it. Every value goes into an argument array, never a shell
|
|
6
8
|
// string, so a title or a filename cannot become a command.
|
|
@@ -33,49 +35,173 @@ export function resolveFfmpeg(env, bundled) {
|
|
|
33
35
|
export function slideshowClips(edit) {
|
|
34
36
|
const clips = [];
|
|
35
37
|
const named = new Map();
|
|
36
|
-
const order = edit.shots.map((
|
|
37
|
-
|
|
38
|
-
// alike.
|
|
39
|
-
const key = JSON.stringify([shot.source, shot.motion, shot.frames]);
|
|
38
|
+
const order = segments(edit.shots).map((segment) => {
|
|
39
|
+
const key = clipKey(edit, segment);
|
|
40
40
|
let name = named.get(key);
|
|
41
41
|
if (name === undefined) {
|
|
42
42
|
name = `c${clips.length + 1}.mp4`;
|
|
43
43
|
named.set(key, name);
|
|
44
|
-
|
|
44
|
+
const shot = edit.shots[segment.kind === "shot" ? segment.shot : segment.to];
|
|
45
|
+
if (shot === undefined)
|
|
46
|
+
throw new Error("Slopify hit an internal error (the video's edit list lost a shot). Use Re-run section on Video; if it happens again, use Download diagnostics in Settings and report it.");
|
|
47
|
+
clips.push({ name, segment, shot, frames: segment.count });
|
|
45
48
|
}
|
|
46
49
|
return name;
|
|
47
50
|
});
|
|
48
51
|
return { clips, order };
|
|
49
52
|
}
|
|
53
|
+
// What makes two clips the same encode. The motion is plain data built in a fixed key order,
|
|
54
|
+
// so equal motions stringify alike. A whole shot keeps the key it always had, so an edit
|
|
55
|
+
// list without transitions or a Look dedupes exactly as before.
|
|
56
|
+
function clipKey(edit, segment) {
|
|
57
|
+
const at = placed(edit, segment.start, segment.count) ? segment.start : null;
|
|
58
|
+
if (segment.kind === "transition") {
|
|
59
|
+
const from = edit.shots[segment.from];
|
|
60
|
+
const to = edit.shots[segment.to];
|
|
61
|
+
return JSON.stringify([
|
|
62
|
+
"transition",
|
|
63
|
+
from?.source,
|
|
64
|
+
from?.motion,
|
|
65
|
+
from?.frames,
|
|
66
|
+
to?.source,
|
|
67
|
+
to?.motion,
|
|
68
|
+
to?.frames,
|
|
69
|
+
to?.transition,
|
|
70
|
+
at,
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
const shot = edit.shots[segment.shot];
|
|
74
|
+
const whole = segment.from === 0 && segment.count === shot?.frames;
|
|
75
|
+
return JSON.stringify(whole && at === null
|
|
76
|
+
? [shot?.source, shot?.motion, shot?.frames]
|
|
77
|
+
: [shot?.source, shot?.motion, shot?.frames, segment.from, segment.count, at]);
|
|
78
|
+
}
|
|
50
79
|
// Clips that are joined by copying get the final encode's settings. Clips that will be
|
|
51
80
|
// decoded again under burned-in captions are kept closer to the source, so the second
|
|
52
81
|
// encode is not a visible second generation.
|
|
53
82
|
const intermediateCrf = "16";
|
|
83
|
+
// A whole shot as one clip.
|
|
54
84
|
export function clipArgs(frame, shot, output, intermediate = false) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
85
|
+
return segmentArgs({ ...frame, shots: [shot] }, { kind: "shot", shot: 0, from: 0, count: shot.frames, start: 0 }, output, intermediate);
|
|
86
|
+
}
|
|
87
|
+
// One clip of the slideshow: part of a shot, or a transition between two, with the Look.
|
|
88
|
+
export function segmentArgs(edit, segment, output, intermediate = false,
|
|
89
|
+
// The clip's own chapter-card script, beside it in the working folder (`cards.ts`).
|
|
90
|
+
cardsFile = "cards.ass") {
|
|
91
|
+
const inputs = [];
|
|
92
|
+
const chains = [];
|
|
93
|
+
const picture = (shot, from, label) => {
|
|
94
|
+
if (shot === undefined)
|
|
95
|
+
throw new Error("Slopify hit an internal error (the video's edit list lost a shot). Use Re-run section on Video; if it happens again, use Download diagnostics in Settings and report it.");
|
|
96
|
+
const at = inputs.filter((one) => one === "-i").length;
|
|
97
|
+
const built = pictureChain(edit, shot, from, segment.count, at, label, gradeFilter(edit.look));
|
|
98
|
+
inputs.push(...built.inputs);
|
|
99
|
+
chains.push(built.chain);
|
|
100
|
+
};
|
|
101
|
+
const look = lookChain(edit, segment.start, segment.count, "[p]", "[v]", cardsFile);
|
|
102
|
+
const picked = look === undefined ? "[v]" : "[p]";
|
|
103
|
+
if (segment.kind === "shot")
|
|
104
|
+
picture(edit.shots[segment.shot], segment.from, picked);
|
|
105
|
+
else {
|
|
106
|
+
const before = edit.shots[segment.from];
|
|
107
|
+
const after = edit.shots[segment.to];
|
|
108
|
+
const halves = transitionHalves(segment.count);
|
|
109
|
+
picture(before, (before?.frames ?? 0) - halves.tail, "[ta]");
|
|
110
|
+
picture(after, -halves.tail, "[tb]");
|
|
111
|
+
const style = after?.transition?.kind ?? "crossfade";
|
|
112
|
+
chains.push(`[ta][tb]xfade=transition=${xfadeNames[style]}:duration=${seconds6(segment.count / edit.fps)}:offset=0${picked}`);
|
|
113
|
+
}
|
|
114
|
+
if (look !== undefined)
|
|
115
|
+
chains.push(look);
|
|
60
116
|
return [
|
|
61
117
|
...progressArgs,
|
|
62
|
-
|
|
63
|
-
shot.source.path,
|
|
118
|
+
...inputs,
|
|
64
119
|
"-filter_complex",
|
|
65
|
-
|
|
66
|
-
// Cover the frame and centre-crop, never letterbox.
|
|
67
|
-
`scale=${wide}:${tall}:force_original_aspect_ratio=increase,crop=${wide}:${tall},` +
|
|
68
|
-
`zoompan=z='${move?.z ?? "1"}':d=${shot.frames}:` +
|
|
69
|
-
`x='${move?.x ?? centredX}':y='${move?.y ?? centredY}':` +
|
|
70
|
-
`s=${frame.width}x${frame.height}:fps=${frame.fps},setsar=1[v]`,
|
|
120
|
+
chains.join(";"),
|
|
71
121
|
"-map",
|
|
72
122
|
"[v]",
|
|
123
|
+
// The clip is exactly its frames, whatever a filter's rounding of seconds would give.
|
|
124
|
+
...(segment.kind === "shot" && segment.from === 0 && look === undefined
|
|
125
|
+
? []
|
|
126
|
+
: ["-frames:v", String(segment.count)]),
|
|
73
127
|
...videoCodec,
|
|
128
|
+
...lookEncoding(edit.look),
|
|
74
129
|
...(intermediate ? ["-crf", intermediateCrf] : []),
|
|
75
130
|
"-an",
|
|
76
131
|
output,
|
|
77
132
|
];
|
|
78
133
|
}
|
|
134
|
+
const xfadeNames = {
|
|
135
|
+
crossfade: "fade",
|
|
136
|
+
fadeblack: "fadeblack",
|
|
137
|
+
slide: "slideleft",
|
|
138
|
+
wipe: "wipeleft",
|
|
139
|
+
};
|
|
140
|
+
// The picture of `count` frames of `shot` from its own frame `from`, into `label`. A frame
|
|
141
|
+
// before the shot's first shows its first, and one past its last shows its last (a still's
|
|
142
|
+
// motion holds) or plays on (a clip keeps running), which is what a transition overlapping
|
|
143
|
+
// the cut shows.
|
|
144
|
+
function pictureChain(frame, shot, from, count, input, label, grade) {
|
|
145
|
+
const source = shot.source;
|
|
146
|
+
if (source.kind === "video") {
|
|
147
|
+
const { width, height, fps } = frame;
|
|
148
|
+
const pad = Math.max(0, -from);
|
|
149
|
+
const first = from + pad;
|
|
150
|
+
return {
|
|
151
|
+
// Looped for as long as the shot needs; trim ends the stream.
|
|
152
|
+
inputs: ["-stream_loop", "-1", "-i", source.path],
|
|
153
|
+
chain: `[${String(input)}:v]setpts=(PTS-STARTPTS)*${slowdown(source.seconds, shot.frames / fps)},` +
|
|
154
|
+
`fps=${String(fps)},` +
|
|
155
|
+
`scale=${String(width)}:${String(height)}:force_original_aspect_ratio=increase,` +
|
|
156
|
+
`crop=${String(width)}:${String(height)},setsar=1,` +
|
|
157
|
+
(grade === undefined ? "" : `${grade},`) +
|
|
158
|
+
(pad > 0 ? `tpad=start=${String(pad)}:start_mode=clone,` : "") +
|
|
159
|
+
`trim=start_frame=${String(first)}:end_frame=${String(first + count)},` +
|
|
160
|
+
// Padding and trimming drop the declared rate, which a transition needs to match.
|
|
161
|
+
`setpts=PTS-STARTPTS,fps=${String(fps)},format=yuv420p${label}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const move = zoompan(shot.motion, shot.frames, frameExpression(from, count, shot.frames));
|
|
165
|
+
// A still that does not move has no sub-pixel steps to smooth, so it is not prescaled.
|
|
166
|
+
const factor = move === undefined ? 1 : prescale;
|
|
167
|
+
const wide = frame.width * factor;
|
|
168
|
+
const tall = frame.height * factor;
|
|
169
|
+
// Clips that meet in a transition are brought to one pixel format first, since xfade
|
|
170
|
+
// refuses to blend two that differ; a plain shot is left as it always was.
|
|
171
|
+
const plain = from === 0 && count === shot.frames;
|
|
172
|
+
return {
|
|
173
|
+
inputs: ["-i", source.path],
|
|
174
|
+
chain: `[${String(input)}:v]trim=end_frame=1,setpts=PTS-STARTPTS,` +
|
|
175
|
+
// The grade is applied here, to the one still frame, rather than to every frame after.
|
|
176
|
+
(grade === undefined ? "" : `${grade},`) +
|
|
177
|
+
// Cover the frame and centre-crop, never letterbox.
|
|
178
|
+
`scale=${wide}:${tall}:force_original_aspect_ratio=increase,crop=${wide}:${tall},` +
|
|
179
|
+
`zoompan=z='${move?.z ?? "1"}':d=${count}:` +
|
|
180
|
+
`x='${move?.x ?? centredX}':y='${move?.y ?? centredY}':` +
|
|
181
|
+
`s=${frame.width}x${frame.height}:fps=${frame.fps},setsar=1${plain ? "" : ",format=yuv420p"}${label}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// ceiling: a clip shorter than its shot is slowed to at most half speed, which still reads
|
|
185
|
+
// as natural motion; past that it loops.
|
|
186
|
+
const slowestSpeed = 2;
|
|
187
|
+
// How much a clip of `clip` seconds is stretched to fill a shot of `shot` seconds, as exact
|
|
188
|
+
// decimal text: 1 when it is long enough.
|
|
189
|
+
export function slowdown(clip, shot) {
|
|
190
|
+
if (!(clip > 0) || shot <= clip)
|
|
191
|
+
return "1";
|
|
192
|
+
return decimal(Math.round(Math.min(slowestSpeed, shot / clip) * 1000));
|
|
193
|
+
}
|
|
194
|
+
// zoompan's `on` counts the clip's own frames from 0; a clip that starts `from` frames into
|
|
195
|
+
// its shot adds that, and one that reaches before or past the shot holds at its ends.
|
|
196
|
+
function frameExpression(from, count, frames) {
|
|
197
|
+
const shifted = from === 0 ? "on" : `(on${from > 0 ? "+" : "-"}${String(Math.abs(from))})`;
|
|
198
|
+
return from < 0 || from + count > frames
|
|
199
|
+
? `clip(${shifted},0,${String(Math.max(0, frames - 1))})`
|
|
200
|
+
: shifted;
|
|
201
|
+
}
|
|
202
|
+
function seconds6(value) {
|
|
203
|
+
return value.toFixed(6);
|
|
204
|
+
}
|
|
79
205
|
// The concat demuxer's own format. Names are resolved beside the list and are this
|
|
80
206
|
// module's `cN.mp4`, so nothing in them needs quoting.
|
|
81
207
|
export function concatList(order) {
|
|
@@ -110,7 +236,7 @@ export function joinArgs(edit, output, list, burnSubtitles = false) {
|
|
|
110
236
|
"-map",
|
|
111
237
|
burnSubtitles ? "[v]" : "0:v",
|
|
112
238
|
...(edit.audio.length > 0 ? ["-map", "[a]"] : []),
|
|
113
|
-
...(burnSubtitles ? videoCodec : ["-c:v", "copy"]),
|
|
239
|
+
...(burnSubtitles ? [...videoCodec, ...lookEncoding(edit.look)] : ["-c:v", "copy"]),
|
|
114
240
|
...(edit.audio.length > 0 ? ["-c:a", "aac"] : ["-an"]),
|
|
115
241
|
"-movflags",
|
|
116
242
|
"+faststart",
|
|
@@ -132,10 +258,10 @@ const videoCodec = ["-c:v", "libx264", "-pix_fmt", "yuv420p"];
|
|
|
132
258
|
const centredX = "iw/2-(iw/zoom/2)";
|
|
133
259
|
const centredY = "ih/2-(ih/zoom/2)";
|
|
134
260
|
// zoompan's expressions for a motion, or undefined for one that does not move. `on` is
|
|
135
|
-
// zoompan's output frame counter, 0 to d-1
|
|
136
|
-
// by, so it holds where it starts. `zoom` in x and y is
|
|
137
|
-
// is the room the crop window has to travel in.
|
|
138
|
-
function zoompan(motion, frames) {
|
|
261
|
+
// zoompan's output frame counter, 0 to d-1 (`at` shifts it for part of a shot), and a
|
|
262
|
+
// one-frame shot has no span to divide by, so it holds where it starts. `zoom` in x and y is
|
|
263
|
+
// the current zoom, so iw-iw/zoom is the room the crop window has to travel in.
|
|
264
|
+
function zoompan(motion, frames, at = "on") {
|
|
139
265
|
if (motion.kind === "still")
|
|
140
266
|
return undefined;
|
|
141
267
|
const range = zoomRange(motion.percent);
|
|
@@ -147,27 +273,27 @@ function zoompan(motion, frames) {
|
|
|
147
273
|
const z = motion.direction === "in"
|
|
148
274
|
? span < 1
|
|
149
275
|
? range.from
|
|
150
|
-
: `${range.from}+${range.by}
|
|
276
|
+
: `${range.from}+${range.by}*${at}/${span}`
|
|
151
277
|
: span < 1
|
|
152
278
|
? range.to
|
|
153
|
-
: `${range.to}-${range.by}
|
|
279
|
+
: `${range.to}-${range.by}*${at}/${span}`;
|
|
154
280
|
return { z, x: centredX, y: centredY };
|
|
155
281
|
}
|
|
156
282
|
return {
|
|
157
283
|
z: range.to,
|
|
158
|
-
x: `(iw-iw/zoom)*(${travel(motion.from.x, motion.to.x, span)})`,
|
|
159
|
-
y: `(ih-ih/zoom)*(${travel(motion.from.y, motion.to.y, span)})`,
|
|
284
|
+
x: `(iw-iw/zoom)*(${travel(motion.from.x, motion.to.x, span, at)})`,
|
|
285
|
+
y: `(ih-ih/zoom)*(${travel(motion.from.y, motion.to.y, span, at)})`,
|
|
160
286
|
};
|
|
161
287
|
}
|
|
162
288
|
// A share of the room from `from` to `to`, linear over the shot. Built in whole
|
|
163
289
|
// thousandths as decimal text, like the zoom, so 1 - 0.5 reads 0.5 and never
|
|
164
290
|
// 0.49999999999999994.
|
|
165
|
-
function travel(from, to, span) {
|
|
291
|
+
function travel(from, to, span, at) {
|
|
166
292
|
const start = Math.round(from * 1000);
|
|
167
293
|
const by = Math.round(to * 1000) - start;
|
|
168
294
|
if (span < 1 || by === 0)
|
|
169
295
|
return decimal(start);
|
|
170
|
-
return `${decimal(start)}${by > 0 ? "+" : "-"}${decimal(Math.abs(by))}
|
|
296
|
+
return `${decimal(start)}${by > 0 ? "+" : "-"}${decimal(Math.abs(by))}*${at}/${span}`;
|
|
171
297
|
}
|
|
172
298
|
function seconds(value) {
|
|
173
299
|
return value.toFixed(3);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The Look: filters appended to each clip's picture before it is encoded, so they cost one
|
|
2
|
+
// pass over frames that are being made anyway and the render stays clip by clip. Everything
|
|
3
|
+
// is ffmpeg's own filters with fixed numbers: no footage, no LUT files, and the same project
|
|
4
|
+
// renders the same frames every time.
|
|
5
|
+
//
|
|
6
|
+
// The colour grade is applied to each source before its picture is built (`gradeFilter`):
|
|
7
|
+
// once on a still, before zoompan turns it into frames, rather than on every frame. So the
|
|
8
|
+
// overlay and the vignette sit on graded colour. Order matters for the rest: the atmosphere
|
|
9
|
+
// first; then the vignette darkens both; the chapter card is drawn on top of that
|
|
10
|
+
// so its text stays clean; the grain last, over everything, as film grain is.
|
|
11
|
+
// ceiling: the owner's eye on test renders. Subtle darkens the corners a little, strong
|
|
12
|
+
// makes a clear frame of shadow.
|
|
13
|
+
const vignetteAngle = {
|
|
14
|
+
subtle: "0.45",
|
|
15
|
+
strong: "0.75",
|
|
16
|
+
};
|
|
17
|
+
// Temporal noise on brightness only, so the grain moves like film and never tints.
|
|
18
|
+
const grainStrength = {
|
|
19
|
+
subtle: 8,
|
|
20
|
+
strong: 16,
|
|
21
|
+
};
|
|
22
|
+
// The encoder settings a Look asks for: x264 smooths fine noise away at its default tuning,
|
|
23
|
+
// so grain is encoded with its grain tuning. Every clip of one render has the same Look, so
|
|
24
|
+
// the clips still join by copying.
|
|
25
|
+
export function lookEncoding(look) {
|
|
26
|
+
return look !== undefined && look.grain !== "off" ? ["-tune", "grain"] : [];
|
|
27
|
+
}
|
|
28
|
+
const grades = {
|
|
29
|
+
// Golden highlights, a touch of amber in the mids, cooler shadows kept for depth.
|
|
30
|
+
warm: "colorbalance=rs=0.02:bs=0.04:rm=0.08:gm=0.02:bm=-0.07:rh=0.07:gh=0.03:bh=-0.08," +
|
|
31
|
+
"eq=saturation=1.12:contrast=1.04",
|
|
32
|
+
// Blue-green shadows and mids, highlights barely touched.
|
|
33
|
+
cold: "colorbalance=rs=-0.06:bs=0.08:rm=-0.05:gm=0.01:bm=0.06:rh=-0.02:bh=0.03,eq=saturation=0.9",
|
|
34
|
+
desaturated: "eq=saturation=0.55:contrast=1.06",
|
|
35
|
+
// The classic sepia matrix.
|
|
36
|
+
sepia: "colorchannelmixer=rr=0.393:rg=0.769:rb=0.189:gr=0.349:gg=0.686:gb=0.168:br=0.272:bg=0.534:bb=0.131",
|
|
37
|
+
};
|
|
38
|
+
// The grade as a filter for a picture's source, or undefined for none: a still is graded once,
|
|
39
|
+
// a clip frame by frame.
|
|
40
|
+
export function gradeFilter(look) {
|
|
41
|
+
return look === undefined || look.grade === "none" ? undefined : grades[look.grade];
|
|
42
|
+
}
|
|
43
|
+
export function hasLook(look) {
|
|
44
|
+
return (look !== undefined &&
|
|
45
|
+
(look.vignette !== "off" ||
|
|
46
|
+
look.grain !== "off" ||
|
|
47
|
+
look.grade !== "none" ||
|
|
48
|
+
look.atmosphere !== "none"));
|
|
49
|
+
}
|
|
50
|
+
// The cards a clip at `start` of `count` frames shows any part of.
|
|
51
|
+
export function cardsIn(cards, start, count) {
|
|
52
|
+
return (cards ?? []).filter((card) => card.startFrame < start + count && card.startFrame + card.frames > start);
|
|
53
|
+
}
|
|
54
|
+
// Whether a clip's pixels depend on where it sits on the timeline: an atmosphere drifts with
|
|
55
|
+
// the video's clock and a card is drawn at its own time, so such a clip is never reused at
|
|
56
|
+
// another place.
|
|
57
|
+
export function placed(edit, start, count) {
|
|
58
|
+
return ((edit.look !== undefined && edit.look.atmosphere !== "none") ||
|
|
59
|
+
cardsIn(edit.cards, start, count).length > 0);
|
|
60
|
+
}
|
|
61
|
+
// The filters that take the clip's picture from `input` to `output`, or undefined when there
|
|
62
|
+
// is nothing to add. `start` is the clip's first frame on the timeline. Cards are read from
|
|
63
|
+
// `cardsFile`, the clip's own script with its times counted from the clip's first frame
|
|
64
|
+
// (`cards.ts`), and `fonts/`, both in the render's working folder.
|
|
65
|
+
export function lookChain(edit, start, count, input, output, cardsFile = "cards.ass") {
|
|
66
|
+
const look = edit.look;
|
|
67
|
+
const steps = [];
|
|
68
|
+
let at = input;
|
|
69
|
+
let n = 0;
|
|
70
|
+
const next = () => {
|
|
71
|
+
n += 1;
|
|
72
|
+
return `[look${String(n)}]`;
|
|
73
|
+
};
|
|
74
|
+
const simple = (filters) => {
|
|
75
|
+
if (filters.length === 0)
|
|
76
|
+
return;
|
|
77
|
+
const label = next();
|
|
78
|
+
steps.push(`${at}${filters.join(",")}${label}`);
|
|
79
|
+
at = label;
|
|
80
|
+
};
|
|
81
|
+
if (look !== undefined && look.atmosphere !== "none") {
|
|
82
|
+
const texture = next();
|
|
83
|
+
const blended = next();
|
|
84
|
+
steps.push(`${atmosphereSource(edit, look.atmosphere, start)}${texture}`);
|
|
85
|
+
// Laid over the picture by its alpha, in the picture's own YUV: converting every frame
|
|
86
|
+
// to RGB for a screen blend doubled a clip's render time.
|
|
87
|
+
steps.push(`${at}${texture}overlay=shortest=1:format=yuv420${blended}`);
|
|
88
|
+
at = blended;
|
|
89
|
+
}
|
|
90
|
+
const after = [];
|
|
91
|
+
if (look !== undefined && look.vignette !== "off")
|
|
92
|
+
after.push(`vignette=angle=${vignetteAngle[look.vignette]}`);
|
|
93
|
+
if (cardsIn(edit.cards, start, count).length > 0)
|
|
94
|
+
after.push(`ass=filename=${cardsFile}:fontsdir=fonts`);
|
|
95
|
+
if (look !== undefined && look.grain !== "off")
|
|
96
|
+
after.push(`noise=c0s=${String(grainStrength[look.grain])}:c0f=t+u`);
|
|
97
|
+
simple(after);
|
|
98
|
+
if (steps.length === 0)
|
|
99
|
+
return undefined;
|
|
100
|
+
const last = steps.length - 1;
|
|
101
|
+
steps[last] = `${(steps[last] ?? "").slice(0, -at.length)}${output}`;
|
|
102
|
+
return steps.join(";");
|
|
103
|
+
}
|
|
104
|
+
const atmosphereOpacity = {
|
|
105
|
+
embers: "1",
|
|
106
|
+
dust: "0.6",
|
|
107
|
+
fog: "1",
|
|
108
|
+
};
|
|
109
|
+
// A still texture made once per clip, then moved: embers rise and drift left, dust wanders,
|
|
110
|
+
// fog rolls sideways along the bottom of the frame. `scroll` wraps the texture round, which
|
|
111
|
+
// leaves no seam for sparse specks, and the fog is mirrored side to side first so its wrap
|
|
112
|
+
// has none either. The scroll starts where the timeline has it at the clip's first frame, so
|
|
113
|
+
// the overlay runs on unbroken across cuts.
|
|
114
|
+
//
|
|
115
|
+
// The specks come from ffmpeg's `noise` (geq's random() repeats in visible columns): one
|
|
116
|
+
// grey level of uniform noise marks about 1% of the pixels, and multiplying by a second,
|
|
117
|
+
// flipped noise field thins them and varies their brightness. Everything is fixed numbers,
|
|
118
|
+
// so the texture is the same on every run.
|
|
119
|
+
function atmosphereSource(frame, atmosphere, start) {
|
|
120
|
+
const { width, height, fps } = frame;
|
|
121
|
+
const size = `${String(width)}x${String(height)}`;
|
|
122
|
+
const grid = (divisor) => `${String(Math.max(2, Math.round(width / divisor)))}x${String(Math.max(2, Math.round(height / divisor)))}`;
|
|
123
|
+
// A grey field of `grid` with about one pixel in a hundred lit, thinned by a second field
|
|
124
|
+
// cut at `cut` and scaled by `gain`; into `label`, still the grid's size.
|
|
125
|
+
const specks = (at, seed, cut, gain, label) => `color=c=0x808080:s=${at}:r=${String(fps)}:d=1,format=gbrp,split[${label}a][${label}b];` +
|
|
126
|
+
`[${label}a]noise=c0s=100:c0f=u:c0_seed=${String(seed)},lutrgb=g='if(eq(val,177),255,0)'[${label}c];` +
|
|
127
|
+
`[${label}b]noise=c0s=100:c0f=u:c0_seed=${String(seed + 2)},` +
|
|
128
|
+
`lutrgb=g='max(0,min(255,(val-${String(cut)})*${String(gain)}))',hflip,vflip[${label}d];` +
|
|
129
|
+
`[${label}c][${label}d]blend=all_mode=multiply[${label}]`;
|
|
130
|
+
// The green plane carries the texture; the mixer tints it into all three.
|
|
131
|
+
const tint = (r, g, b) => `colorchannelmixer=rr=0:rg=${String(r)}:rb=0:gr=0:gg=${String(g)}:gb=0:br=0:bg=${String(b)}:bb=0`;
|
|
132
|
+
// The texture as light over a transparent frame, made once before it loops: each pixel keeps
|
|
133
|
+
// its hue at full brightness, and how bright it was (times the atmosphere's strength)
|
|
134
|
+
// becomes how opaque it is, which is a screen blend's look for a dark background.
|
|
135
|
+
const peak = "max(r(X,Y),max(g(X,Y),b(X,Y)))";
|
|
136
|
+
const lit = (plane) => `if(gt(${peak},0),255*${plane}(X,Y)/${peak},0)`;
|
|
137
|
+
const light = `format=gbrap,geq=r='${lit("r")}':g='${lit("g")}':b='${lit("b")}':` +
|
|
138
|
+
`a='${peak}*${atmosphereOpacity[atmosphere]}',format=yuva420p`;
|
|
139
|
+
const moving = (h, v) => `${light},loop=loop=-1:size=1,setpts=N/(${String(fps)}*TB),` +
|
|
140
|
+
`scroll=h=${String(h)}:v=${String(v)}:hpos=${position(h, start)}:vpos=${position(v, start)}`;
|
|
141
|
+
if (atmosphere === "embers")
|
|
142
|
+
// Small sparks and a few large soft glows, orange, rising.
|
|
143
|
+
return (`${specks(grid(4), 7, 168, 24, "es")};${specks(grid(20), 3, 142, 7, "eb")};` +
|
|
144
|
+
`[es]scale=${size}:flags=bicubic,gblur=sigma=1.3,lutrgb=g='min(255,val*2.4)'[esx];` +
|
|
145
|
+
`[eb]scale=${size}:flags=bicubic,gblur=sigma=14,lutrgb=g='min(255,val*6)'[ebx];` +
|
|
146
|
+
`[esx][ebx]blend=all_mode=addition,${tint(1, 0.48, 0.12)},${moving(0.0003, 0.0022)}`);
|
|
147
|
+
if (atmosphere === "dust")
|
|
148
|
+
// Fine warm-white motes drifting slowly across.
|
|
149
|
+
return (`${specks(grid(3), 17, 160, 12, "ds")};` +
|
|
150
|
+
`[ds]scale=${size}:flags=bicubic,gblur=sigma=1.1,lutrgb=g='min(255,val*2)',` +
|
|
151
|
+
`${tint(1, 0.95, 0.85)},${moving(0.0005, -0.00025)}`);
|
|
152
|
+
// Soft blotches from a tiny noise field blown up and blurred, faded out towards the top.
|
|
153
|
+
return (`color=c=0x808080:s=${grid(80)}:r=${String(fps)}:d=1,format=gbrp,` +
|
|
154
|
+
"noise=c0s=100:c0f=u:c0_seed=23," +
|
|
155
|
+
`scale=${size}:flags=bicubic,gblur=sigma=70,lutrgb=g='max(0,min(255,(val-95)*3.2))',` +
|
|
156
|
+
"geq=r='g(X,Y)*pow(Y/H,1.6)':g='g(X,Y)*pow(Y/H,1.6)':b='g(X,Y)*pow(Y/H,1.6)'," +
|
|
157
|
+
"colorchannelmixer=rr=0.92:gg=0.95:bb=1,split[fa][fb];[fb]hflip[fc];[fa][fc]hstack," +
|
|
158
|
+
`${moving(0.0006, 0)},crop=${String(width)}:${String(height)}:0:0`);
|
|
159
|
+
}
|
|
160
|
+
// Where a texture scrolling `speed` of its size per frame has got to by frame `start`.
|
|
161
|
+
function position(speed, start) {
|
|
162
|
+
const travelled = (((speed * start) % 1) + 1) % 1;
|
|
163
|
+
return travelled.toFixed(6);
|
|
164
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { chapterCuts, narrationShotFrames } from "./cuts.js";
|
|
1
2
|
import { editListVersion, } from "./edit-list.js";
|
|
3
|
+
import { chapterCardSeconds } from "./edit-settings.js";
|
|
4
|
+
import { hasLook } from "./look.js";
|
|
2
5
|
import { motionFor } from "./motion.js";
|
|
6
|
+
import { withTransitions } from "./transitions.js";
|
|
3
7
|
// 30 fps, 1920×1080 for 16:9 and 1080×1920 for 9:16.
|
|
4
8
|
export const fps = 30;
|
|
5
9
|
const frames = {
|
|
@@ -39,6 +43,22 @@ export function planRender(input) {
|
|
|
39
43
|
? input.images.length * input.imageSeconds
|
|
40
44
|
: audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
41
45
|
const totalFrames = Math.max(1, Math.round(totalSeconds * fps));
|
|
46
|
+
const edit = input.edit;
|
|
47
|
+
const narration = input.body === undefined ? undefined : edit?.narration;
|
|
48
|
+
const lengths = narration === undefined
|
|
49
|
+
? everyLengths(Math.max(1, Math.round(input.imageSeconds * fps)), totalFrames)
|
|
50
|
+
: narrationShotFrames({
|
|
51
|
+
totalFrames,
|
|
52
|
+
fps,
|
|
53
|
+
imageSeconds: input.imageSeconds,
|
|
54
|
+
cutPoints: narration.cutPoints,
|
|
55
|
+
chapterStarts: narration.chapterStarts,
|
|
56
|
+
});
|
|
57
|
+
const planned = shots(input, lengths);
|
|
58
|
+
const transition = edit?.transition;
|
|
59
|
+
const cards = edit?.cards === undefined
|
|
60
|
+
? []
|
|
61
|
+
: chapterCards(edit.cards.chapters, narration?.cutPoints ?? [], totalFrames);
|
|
42
62
|
return {
|
|
43
63
|
gapSeconds: input.gapSeconds,
|
|
44
64
|
edgeSeconds: input.edgeSeconds,
|
|
@@ -51,13 +71,39 @@ export function planRender(input) {
|
|
|
51
71
|
height: frame.height,
|
|
52
72
|
fps,
|
|
53
73
|
audio,
|
|
54
|
-
shots:
|
|
74
|
+
shots: transition === undefined
|
|
75
|
+
? planned
|
|
76
|
+
: withTransitions(planned, transition.kind, Math.round(transition.seconds * fps)),
|
|
77
|
+
...(edit?.look !== undefined && hasLook(edit.look) ? { look: edit.look } : {}),
|
|
78
|
+
...(cards.length > 0 && edit?.cards !== undefined
|
|
79
|
+
? { cards, cardFont: edit.cards.font }
|
|
80
|
+
: {}),
|
|
55
81
|
},
|
|
56
82
|
totalFrames,
|
|
57
83
|
totalSeconds,
|
|
58
84
|
output: input.output,
|
|
59
85
|
};
|
|
60
86
|
}
|
|
87
|
+
// A card at each chapter start, on the cut the chapter's shot starts with (a chapter at the
|
|
88
|
+
// very start stays there), for 2.5 s or what is left of the video.
|
|
89
|
+
function chapterCards(chapters, cutPoints, totalFrames) {
|
|
90
|
+
const length = Math.round(chapterCardSeconds * fps);
|
|
91
|
+
const cards = [];
|
|
92
|
+
for (const chapter of chapters) {
|
|
93
|
+
const title = chapter.title.trim();
|
|
94
|
+
const moved = chapter.start < 1 / fps ? [] : chapterCuts([chapter.start], cutPoints, Infinity);
|
|
95
|
+
const startFrame = Math.max(0, Math.round((moved[0] ?? chapter.start) * fps));
|
|
96
|
+
const frames = Math.min(length, totalFrames - startFrame);
|
|
97
|
+
if (title === "" || frames < 1)
|
|
98
|
+
continue;
|
|
99
|
+
// Two chapters inside 2.5 s would stack their cards; the second is left out.
|
|
100
|
+
const previous = cards.at(-1);
|
|
101
|
+
if (previous !== undefined && startFrame < previous.startFrame + previous.frames)
|
|
102
|
+
continue;
|
|
103
|
+
cards.push({ title, startFrame, frames });
|
|
104
|
+
}
|
|
105
|
+
return cards;
|
|
106
|
+
}
|
|
61
107
|
export function spoken(kind) {
|
|
62
108
|
return kind !== "gap" && kind !== "edge";
|
|
63
109
|
}
|
|
@@ -92,15 +138,26 @@ export function audioTimeline(input, minimumGap = 1 / fps) {
|
|
|
92
138
|
}
|
|
93
139
|
return segments;
|
|
94
140
|
}
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
// (`motion.ts`), so an image that comes round again may move another way.
|
|
99
|
-
function shots(input, each, totalFrames) {
|
|
141
|
+
// Every N seconds: each shot `each` frames until the timeline is full; the last shot is cut
|
|
142
|
+
// to what is left. A timeline shorter than one shot is a single shot.
|
|
143
|
+
function everyLengths(each, totalFrames) {
|
|
100
144
|
const count = Math.ceil(totalFrames / each);
|
|
101
|
-
return Array.from({ length: count }, (_value, at) => (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
145
|
+
return Array.from({ length: count }, (_value, at) => Math.min(each, totalFrames - each * at));
|
|
146
|
+
}
|
|
147
|
+
// The images take turns in slideshow order, one per shot, starting over after the last until
|
|
148
|
+
// the timeline is full. The motion goes by the shot's place, not the image (`motion.ts`), so
|
|
149
|
+
// an image that comes round again may move another way. A moving clip in an image's place
|
|
150
|
+
// plays as it is, with no motion of its own.
|
|
151
|
+
function shots(input, lengths) {
|
|
152
|
+
return lengths.map((frames, at) => {
|
|
153
|
+
const index = at % input.images.length;
|
|
154
|
+
const clip = input.edit?.clips?.[index];
|
|
155
|
+
return clip === undefined
|
|
156
|
+
? {
|
|
157
|
+
source: { kind: "image", path: input.images[index] ?? "" },
|
|
158
|
+
frames,
|
|
159
|
+
motion: motionFor(input.motionStyle, at, input.zoomPercent),
|
|
160
|
+
}
|
|
161
|
+
: { source: clip, frames, motion: { kind: "still" } };
|
|
162
|
+
});
|
|
106
163
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import {
|
|
1
|
+
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { extname, join } from "node:path";
|
|
3
|
+
import { cardsAss } from "./cards.js";
|
|
4
|
+
import { concatList, joinArgs, runFfmpeg, segmentArgs, slideshowClips } from "./ffmpeg.js";
|
|
5
|
+
import { cardsIn } from "./look.js";
|
|
4
6
|
// estimate: a zoompan frame from a 4x still costs several plain encoded frames, and a
|
|
5
7
|
// copying join is nearly free, so progress is weighted by that rather than by clock time.
|
|
6
8
|
const clipWeight = 4;
|
|
@@ -12,24 +14,38 @@ export async function renderSlideshow(run) {
|
|
|
12
14
|
const joinMs = msOf(edit.shots.reduce((sum, shot) => sum + shot.frames, 0));
|
|
13
15
|
// Progress is reported against the video's length, the longer of picture and sound.
|
|
14
16
|
const totalMs = Math.max(joinMs, edit.audio.reduce((sum, segment) => sum + segment.seconds * 1000, 0));
|
|
15
|
-
const clipMs = clips.reduce((sum, clip) => sum + msOf(clip.
|
|
17
|
+
const clipMs = clips.reduce((sum, clip) => sum + msOf(clip.frames), 0);
|
|
16
18
|
const work = clipMs * clipWeight + joinMs * (run.burnSubtitles ? 1 : copyWeight);
|
|
17
19
|
const report = (done) => {
|
|
18
20
|
run.onProgress(work > 0 ? Math.round((Math.min(done, work) / work) * totalMs) : 0);
|
|
19
21
|
};
|
|
20
22
|
const workspace = mkdtempSync(join(run.scratch, "render-"));
|
|
21
23
|
try {
|
|
24
|
+
// Chapter cards are drawn by the clips from a script and a font beside them.
|
|
25
|
+
const cards = edit.cards ?? [];
|
|
26
|
+
const font = edit.cardFont;
|
|
27
|
+
if (cards.length > 0) {
|
|
28
|
+
if (font === undefined)
|
|
29
|
+
throw new Error("Slopify hit an internal error (the chapter cards have no font). Use Re-run section on Video; if it happens again, use Download diagnostics in Settings and report it.");
|
|
30
|
+
mkdirSync(join(workspace, "fonts"), { mode: 0o700 });
|
|
31
|
+
copyFileSync(font.path, join(workspace, "fonts", `card${extname(font.path)}`));
|
|
32
|
+
}
|
|
22
33
|
let rendered = 0;
|
|
23
34
|
for (const clip of clips) {
|
|
24
35
|
const before = rendered;
|
|
36
|
+
const script = clip.name.replace(/\.mp4$/, ".ass");
|
|
37
|
+
const shown = cardsIn(cards, clip.segment.start, clip.segment.count);
|
|
38
|
+
if (shown.length > 0 && font !== undefined)
|
|
39
|
+
writeFileSync(join(workspace, script), cardsAss(edit, shown, font.name, clip.segment.start), { mode: 0o600 });
|
|
25
40
|
await runFfmpeg({
|
|
26
41
|
bin: run.bin,
|
|
27
|
-
|
|
42
|
+
...(cards.length > 0 ? { cwd: workspace } : {}),
|
|
43
|
+
args: segmentArgs(edit, clip.segment, join(workspace, clip.name), run.burnSubtitles, script),
|
|
28
44
|
signal: run.signal,
|
|
29
45
|
log: run.log,
|
|
30
46
|
onProgress: (elapsedMs) => report(before + elapsedMs * clipWeight),
|
|
31
47
|
});
|
|
32
|
-
rendered += msOf(clip.
|
|
48
|
+
rendered += msOf(clip.frames) * clipWeight;
|
|
33
49
|
}
|
|
34
50
|
const list = join(workspace, "slides.ffconcat");
|
|
35
51
|
writeFileSync(list, concatList(order), { mode: 0o600 });
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Transitions without a longer render. The slideshow is still encoded clip by clip and
|
|
2
|
+
// joined by the concat demuxer (`ffmpeg.ts`); a transition is one more short clip between
|
|
3
|
+
// two shots. The shot before gives up its last floor(T / 2) frames and the shot after its
|
|
4
|
+
// first ceil(T / 2), and the T-frame transition clip plays the first shot's picture from
|
|
5
|
+
// where it was cut, running on past its end, blended into the second's from half a
|
|
6
|
+
// transition before its start. Every frame of the timeline is still played exactly once, so
|
|
7
|
+
// the video stays as long as its sound.
|
|
8
|
+
// The frames a transition of `frames` takes from the shot before and the shot after.
|
|
9
|
+
export function transitionHalves(frames) {
|
|
10
|
+
const tail = Math.floor(frames / 2);
|
|
11
|
+
return { tail, head: frames - tail };
|
|
12
|
+
}
|
|
13
|
+
// Gives every shot after the first a transition of `frames` frames, shortened where a shot is
|
|
14
|
+
// too short to give up its share and still show at least one frame of its own, and left a
|
|
15
|
+
// hard cut where under two frames would remain.
|
|
16
|
+
export function withTransitions(shots, kind, frames) {
|
|
17
|
+
return shots.map((shot, index) => {
|
|
18
|
+
const before = shots[index - 1];
|
|
19
|
+
if (before === undefined)
|
|
20
|
+
return shot;
|
|
21
|
+
const room = Math.floor((Math.min(before.frames, shot.frames) - 1) / 2);
|
|
22
|
+
const length = Math.min(Math.round(frames), room);
|
|
23
|
+
return length < 2 ? shot : { ...shot, transition: { kind, frames: length } };
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
// The clips in timeline order, adding up to the shots' frames exactly.
|
|
27
|
+
export function segments(shots) {
|
|
28
|
+
const out = [];
|
|
29
|
+
let start = 0;
|
|
30
|
+
for (const [index, shot] of shots.entries()) {
|
|
31
|
+
const entering = shot.transition;
|
|
32
|
+
const leaving = shots[index + 1]?.transition;
|
|
33
|
+
const head = entering === undefined ? 0 : transitionHalves(entering.frames).head;
|
|
34
|
+
const tail = leaving === undefined ? 0 : transitionHalves(leaving.frames).tail;
|
|
35
|
+
if (entering !== undefined) {
|
|
36
|
+
const before = transitionHalves(entering.frames).tail;
|
|
37
|
+
out.push({
|
|
38
|
+
kind: "transition",
|
|
39
|
+
from: index - 1,
|
|
40
|
+
to: index,
|
|
41
|
+
count: entering.frames,
|
|
42
|
+
start: start - before,
|
|
43
|
+
});
|
|
44
|
+
start += head;
|
|
45
|
+
}
|
|
46
|
+
const count = shot.frames - head - tail;
|
|
47
|
+
out.push({ kind: "shot", shot: index, from: head, count, start });
|
|
48
|
+
start += count + tail;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|