@gentbajko/slopify 2.0.3 → 2.1.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/SUBTITLES.md +1 -1
- package/dist/adapters/alignment/threads.js +11 -0
- package/dist/adapters/alignment/worker.js +38 -4
- package/dist/adapters/host-cli/index.js +25 -1
- package/dist/assets/document/SOURCE.txt +7 -0
- package/dist/assets/document/background.jpg +0 -0
- package/dist/assets/document/fonts/Cinzel-Black.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Bold.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Medium.ttf +0 -0
- package/dist/assets/document/fonts/Cinzel-Regular.ttf +0 -0
- package/dist/assets/document/fonts/OFL.txt +93 -0
- package/dist/assets/document/fonts/SOURCE.txt +11 -0
- package/dist/catalog/validate.js +3 -2
- package/dist/edge/docker-projects/state.js +5 -1
- package/dist/edge/host-cli.js +14 -0
- package/dist/edge/http/folder-location-schema.js +5 -8
- package/dist/edge/http/folder-location.js +10 -5
- package/dist/edge/http/host-cli.js +27 -2
- package/dist/edge/http/revision-files.js +5 -1
- package/dist/host-cli/open-folder.js +95 -0
- package/dist/host-cli/runtime.js +2 -0
- package/dist/kernel/db/migrate.js +25 -1
- package/dist/kernel/db/migrations/0014-document-stage.sql +44 -0
- package/dist/kernel/db/migrations/0015-description-prompts.sql +13 -0
- package/dist/kernel/pipeline.js +11 -1
- package/dist/kernel/ports/host-cli.js +14 -0
- package/dist/kernel/runner/graph.js +6 -1
- package/dist/main.js +6 -2
- package/dist/slices/admission/model.js +7 -1
- package/dist/slices/admission/rules.js +67 -3
- package/dist/slices/admission/schema.js +16 -3
- package/dist/slices/admission/start.js +3 -2
- package/dist/slices/document/blocks.js +129 -0
- package/dist/slices/document/flow.js +117 -0
- package/dist/slices/document/fonts.js +54 -0
- package/dist/slices/document/model.js +11 -0
- package/dist/slices/document/pages.js +198 -0
- package/dist/slices/document/render.js +242 -0
- package/dist/slices/document/sources.js +35 -0
- package/dist/slices/document/theme.js +158 -0
- package/dist/slices/document/writer.js +66 -0
- package/dist/slices/estimate/index.js +14 -2
- package/dist/slices/library/model.js +1 -1
- package/dist/slices/library/slots.js +4 -1
- package/dist/slices/play-drafts/convert.js +30 -1
- package/dist/slices/play-drafts/readiness.js +3 -2
- package/dist/slices/play-drafts/schema.js +22 -5
- package/dist/slices/project-templates/from-project.js +10 -0
- package/dist/slices/project-templates/setup.js +1 -0
- package/dist/slices/rebuild/preview-details.js +11 -1
- package/dist/slices/rebuild/recipe-audio.js +3 -0
- package/dist/slices/rebuild/recipe-build.js +6 -1
- package/dist/slices/rebuild/recipe-document.js +36 -0
- package/dist/slices/rebuild/recipe-exports.js +14 -2
- package/dist/slices/rebuild/recipe-fixture.js +4 -0
- package/dist/slices/rebuild/recipe-legacy.js +5 -0
- package/dist/slices/rebuild/recipe-model.js +2 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +3 -0
- package/dist/slices/rebuild/recipe-save.js +15 -3
- package/dist/slices/rebuild/recipe-validation.js +15 -2
- package/dist/slices/rebuild/recipe-visual.js +10 -1
- package/dist/slices/rebuild/recipe-work.js +15 -0
- package/dist/slices/rebuild/recipe-youtube.js +33 -0
- package/dist/slices/rebuild/recovery-selection.js +2 -0
- package/dist/slices/rebuild/recovery.js +10 -5
- package/dist/slices/rebuild/runtime-actions.js +2 -0
- package/dist/slices/rebuild/runtime-document.js +85 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +1 -0
- package/dist/slices/rebuild/runtime-export.js +35 -19
- package/dist/slices/rebuild/runtime-piece-label.js +4 -0
- package/dist/slices/rebuild/runtime-publication.js +1 -0
- package/dist/slices/rebuild/runtime-run.js +11 -3
- package/dist/slices/rebuild/runtime-store.js +7 -2
- package/dist/slices/rebuild/runtime-subtitles.js +6 -3
- package/dist/slices/rebuild/runtime-youtube.js +84 -0
- package/dist/slices/rebuild/transition-repo.js +2 -0
- package/dist/slices/reruns/cascade.js +6 -4
- package/dist/slices/revisions/mutation-cues.js +3 -1
- package/dist/slices/revisions/projection.js +2 -1
- package/dist/slices/storage/downloads.js +1 -0
- package/dist/slices/storage/layout.js +6 -0
- package/dist/slices/storage/model.js +3 -0
- package/dist/slices/subtitles/prepare.js +2 -1
- package/dist/slices/telemetry/model.js +1 -1
- package/dist/slices/video/audio-export.js +11 -3
- package/dist/slices/video/edit-list.js +73 -0
- package/dist/slices/video/ffmpeg.js +123 -57
- package/dist/slices/video/motion.js +51 -0
- package/dist/slices/video/plan.js +61 -27
- package/dist/slices/video/reuse-audio.js +2 -0
- package/dist/slices/video/run.js +21 -10
- package/dist/slices/video/slideshow.js +48 -0
- package/dist/slices/video/write-export.js +17 -25
- package/dist/slices/youtube/answer.js +199 -0
- package/dist/slices/youtube/model.js +27 -0
- package/dist/slices/youtube/timestamps.js +24 -0
- package/dist/slices/youtube/transcript.js +47 -0
- package/dist/web/assets/index-B6NbhpjD.css +1 -0
- package/dist/web/assets/index-ComF5gwP.js +142 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-Bwtmu9ei.css +0 -1
- package/dist/web/assets/index-DjhMJqMN.js +0 -141
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
// The edit list is the whole video as data: what plays, for how many frames, how it moves,
|
|
3
|
+
// and the audio under it. Planning (`plan.ts`) writes one and the renderer (`ffmpeg.ts`,
|
|
4
|
+
// `slideshow.ts`) reads nothing else, so a new kind of shot or movement is a new case
|
|
5
|
+
// here, in the planner and in the clip filter, and never a change to how the render runs.
|
|
6
|
+
// It is plain JSON, recorded in render.json, and carries a version so a later Slopify can
|
|
7
|
+
// tell an older list from its own.
|
|
8
|
+
//
|
|
9
|
+
// Extension points, none of them built yet: `Shot.source` gains a `video` kind for clips,
|
|
10
|
+
// `Shot.transition` says how a shot enters from the one before, and effects (a colour
|
|
11
|
+
// grade, a text card) become a list on the shot. Each is a new optional field or union
|
|
12
|
+
// case, so a version-1 list stays readable; a change that alters what an existing field
|
|
13
|
+
// means bumps the version instead.
|
|
14
|
+
export const editListVersion = 1;
|
|
15
|
+
// The same list with every file path passed through `map`: render.json records paths
|
|
16
|
+
// relative to the project folder.
|
|
17
|
+
export function withPaths(edit, map) {
|
|
18
|
+
return {
|
|
19
|
+
...edit,
|
|
20
|
+
audio: edit.audio.map((segment) => ({
|
|
21
|
+
...segment,
|
|
22
|
+
path: segment.path === null ? null : map(segment.path),
|
|
23
|
+
})),
|
|
24
|
+
shots: edit.shots.map((shot) => ({
|
|
25
|
+
...shot,
|
|
26
|
+
source: { ...shot.source, path: map(shot.source.path) },
|
|
27
|
+
})),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const share = z.number().min(0).max(1);
|
|
31
|
+
const point = z.object({ x: share, y: share }).strict();
|
|
32
|
+
const percent = z.number().min(0);
|
|
33
|
+
const motionSchema = z.discriminatedUnion("kind", [
|
|
34
|
+
z.object({ kind: z.literal("zoom"), direction: z.enum(["in", "out"]), percent }).strict(),
|
|
35
|
+
z.object({ kind: z.literal("pan"), from: point, to: point, percent }).strict(),
|
|
36
|
+
z.object({ kind: z.literal("still") }).strict(),
|
|
37
|
+
]);
|
|
38
|
+
const count = z.number().int().positive();
|
|
39
|
+
const editListSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
version: z.literal(editListVersion),
|
|
42
|
+
width: count,
|
|
43
|
+
height: count,
|
|
44
|
+
fps: count,
|
|
45
|
+
audio: z.array(z
|
|
46
|
+
.object({
|
|
47
|
+
kind: z.enum(["intro", "body", "outro", "gap", "edge"]),
|
|
48
|
+
path: z.string().nullable(),
|
|
49
|
+
seconds: z.number().nonnegative(),
|
|
50
|
+
})
|
|
51
|
+
.strict()),
|
|
52
|
+
shots: z
|
|
53
|
+
.array(z
|
|
54
|
+
.object({
|
|
55
|
+
source: z.object({ kind: z.literal("image"), path: z.string().min(1) }).strict(),
|
|
56
|
+
frames: count,
|
|
57
|
+
motion: motionSchema,
|
|
58
|
+
})
|
|
59
|
+
.strict())
|
|
60
|
+
.min(1),
|
|
61
|
+
})
|
|
62
|
+
.strict();
|
|
63
|
+
// Reads a recorded edit list back. Strict, so a list written by a newer Slopify, with a
|
|
64
|
+
// field or kind this one does not know, is refused rather than rendered without it.
|
|
65
|
+
export function readEditList(value) {
|
|
66
|
+
const version = typeof value === "object" && value !== null && "version" in value ? value.version : undefined;
|
|
67
|
+
if (version !== editListVersion)
|
|
68
|
+
throw new Error(`This video's edit list is version ${String(version)}, and this Slopify reads version ${editListVersion}. Update Slopify, or use Re-run section on Video to plan it again.`);
|
|
69
|
+
const parsed = editListSchema.safeParse(value);
|
|
70
|
+
if (!parsed.success)
|
|
71
|
+
throw new Error(`This video's edit list is damaged (${parsed.error.issues[0]?.path.join(".") || "the list"}: ${parsed.error.issues[0]?.message ?? "not readable"}). Use Re-run section on Video to plan it again.`);
|
|
72
|
+
return parsed.data;
|
|
73
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { isAbsolute, resolve as resolvePath } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { decimal, zoomRange } from "./plan.js";
|
|
4
4
|
// Hand-rolled per the standards: the filtergraph is the load-bearing part of this slice
|
|
5
5
|
// and a wrapper would hide it. Every value goes into an argument array, never a shell
|
|
6
6
|
// string, so a title or a filename cannot become a command.
|
|
7
7
|
// zoompan works on a still that has been pre-scaled, because it steps the zoom in
|
|
8
8
|
// sub-pixel increments and a source at output resolution visibly jitters where the zoom is
|
|
9
|
-
// meant to be smooth and linear.
|
|
9
|
+
// meant to be smooth and linear. A pan steps its crop window in whole source pixels, so
|
|
10
|
+
// the same four times gives it quarter-pixel steps on screen. Four times the frame is
|
|
11
|
+
// enough at 122.5%.
|
|
10
12
|
const prescale = 4;
|
|
11
13
|
const sampleRate = 44100;
|
|
12
14
|
const channelLayout = "stereo";
|
|
@@ -28,61 +30,71 @@ export function resolveFfmpeg(env, bundled) {
|
|
|
28
30
|
"missing for this computer. Reinstall Slopify, or set the SLOPIFY_FFMPEG environment " +
|
|
29
31
|
"variable to the path of an ffmpeg program.");
|
|
30
32
|
}
|
|
31
|
-
export function
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
export function slideshowClips(edit) {
|
|
34
|
+
const clips = [];
|
|
35
|
+
const named = new Map();
|
|
36
|
+
const order = edit.shots.map((shot) => {
|
|
37
|
+
// The motion is plain data built in a fixed key order, so equal motions stringify
|
|
38
|
+
// alike.
|
|
39
|
+
const key = JSON.stringify([shot.source, shot.motion, shot.frames]);
|
|
40
|
+
let name = named.get(key);
|
|
41
|
+
if (name === undefined) {
|
|
42
|
+
name = `c${clips.length + 1}.mp4`;
|
|
43
|
+
named.set(key, name);
|
|
44
|
+
clips.push({ name, shot });
|
|
42
45
|
}
|
|
43
|
-
|
|
44
|
-
}
|
|
46
|
+
return name;
|
|
47
|
+
});
|
|
48
|
+
return { clips, order };
|
|
49
|
+
}
|
|
50
|
+
// Clips that are joined by copying get the final encode's settings. Clips that will be
|
|
51
|
+
// decoded again under burned-in captions are kept closer to the source, so the second
|
|
52
|
+
// encode is not a visible second generation.
|
|
53
|
+
const intermediateCrf = "16";
|
|
54
|
+
export function clipArgs(frame, shot, output, intermediate = false) {
|
|
55
|
+
const move = zoompan(shot.motion, shot.frames);
|
|
56
|
+
// A still that does not move has no sub-pixel steps to smooth, so it is not prescaled.
|
|
57
|
+
const factor = move === undefined ? 1 : prescale;
|
|
58
|
+
const wide = frame.width * factor;
|
|
59
|
+
const tall = frame.height * factor;
|
|
45
60
|
return [
|
|
46
|
-
|
|
47
|
-
"-
|
|
48
|
-
|
|
49
|
-
"error",
|
|
50
|
-
// Progress on stdout keeps stderr free to carry only what went wrong.
|
|
51
|
-
"-progress",
|
|
52
|
-
"pipe:1",
|
|
53
|
-
"-nostats",
|
|
54
|
-
"-y",
|
|
55
|
-
...inputs,
|
|
61
|
+
...progressArgs,
|
|
62
|
+
"-i",
|
|
63
|
+
shot.source.path,
|
|
56
64
|
"-filter_complex",
|
|
57
|
-
|
|
65
|
+
`[0:v]trim=end_frame=1,setpts=PTS-STARTPTS,` +
|
|
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]`,
|
|
58
71
|
"-map",
|
|
59
72
|
"[v]",
|
|
60
|
-
...
|
|
61
|
-
"-
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
"yuv420p",
|
|
65
|
-
...(plan.audio.length > 0 ? ["-c:a", "aac"] : ["-an"]),
|
|
66
|
-
"-movflags",
|
|
67
|
-
"+faststart",
|
|
68
|
-
plan.output,
|
|
73
|
+
...videoCodec,
|
|
74
|
+
...(intermediate ? ["-crf", intermediateCrf] : []),
|
|
75
|
+
"-an",
|
|
76
|
+
output,
|
|
69
77
|
];
|
|
70
78
|
}
|
|
71
|
-
|
|
79
|
+
// The concat demuxer's own format. Names are resolved beside the list and are this
|
|
80
|
+
// module's `cN.mp4`, so nothing in them needs quoting.
|
|
81
|
+
export function concatList(order) {
|
|
82
|
+
return `ffconcat version 1.0\n${order.map((name) => `file ${name}`).join("\n")}\n`;
|
|
83
|
+
}
|
|
84
|
+
export function joinArgs(edit, output, list, burnSubtitles = false) {
|
|
85
|
+
const inputs = ["-f", "concat", "-i", list];
|
|
86
|
+
const audioAt = [];
|
|
87
|
+
for (const segment of edit.audio) {
|
|
88
|
+
audioAt.push(1 + audioAt.length);
|
|
89
|
+
if (segment.path === null) {
|
|
90
|
+
inputs.push("-f", "lavfi", "-t", seconds(segment.seconds), "-i", `anullsrc=r=${sampleRate}:cl=${channelLayout}`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
inputs.push("-i", segment.path);
|
|
94
|
+
}
|
|
72
95
|
const chains = [];
|
|
73
|
-
const wide = plan.width * prescale;
|
|
74
|
-
const tall = plan.height * prescale;
|
|
75
|
-
plan.images.forEach((slot, at) => {
|
|
76
|
-
chains.push(`[${at}:v]trim=end_frame=1,setpts=PTS-STARTPTS,` +
|
|
77
|
-
// Cover the frame and centre-crop, never letterbox.
|
|
78
|
-
`scale=${wide}:${tall}:force_original_aspect_ratio=increase,crop=${wide}:${tall},` +
|
|
79
|
-
`zoompan=z='${zoomExpression(slot)}':d=${slot.frames}:` +
|
|
80
|
-
`x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':` +
|
|
81
|
-
`s=${plan.width}x${plan.height}:fps=${plan.fps},setsar=1[v${at}]`);
|
|
82
|
-
});
|
|
83
|
-
chains.push(`${plan.images.map((_slot, at) => `[v${at}]`).join("")}concat=n=${plan.images.length}:v=1:a=0${burnSubtitles ? "[uncaptioned]" : "[v]"}`);
|
|
84
96
|
if (burnSubtitles)
|
|
85
|
-
chains.push("[
|
|
97
|
+
chains.push("[0:v]ass=filename=subtitles.ass:fontsdir=fonts[v]");
|
|
86
98
|
audioAt.forEach((input, at) => {
|
|
87
99
|
// The segments come from different files and the silence from lavfi, so they are
|
|
88
100
|
// brought to one format before concat, which refuses to join mismatched streams.
|
|
@@ -91,17 +103,71 @@ function filterGraph(plan, audioAt, burnSubtitles) {
|
|
|
91
103
|
if (audioAt.length > 0) {
|
|
92
104
|
chains.push(`${audioAt.map((_input, at) => `[a${at}]`).join("")}concat=n=${audioAt.length}:v=0:a=1[a]`);
|
|
93
105
|
}
|
|
94
|
-
return
|
|
106
|
+
return [
|
|
107
|
+
...progressArgs,
|
|
108
|
+
...inputs,
|
|
109
|
+
...(chains.length > 0 ? ["-filter_complex", chains.join(";")] : []),
|
|
110
|
+
"-map",
|
|
111
|
+
burnSubtitles ? "[v]" : "0:v",
|
|
112
|
+
...(edit.audio.length > 0 ? ["-map", "[a]"] : []),
|
|
113
|
+
...(burnSubtitles ? videoCodec : ["-c:v", "copy"]),
|
|
114
|
+
...(edit.audio.length > 0 ? ["-c:a", "aac"] : ["-an"]),
|
|
115
|
+
"-movflags",
|
|
116
|
+
"+faststart",
|
|
117
|
+
output,
|
|
118
|
+
];
|
|
95
119
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
120
|
+
const progressArgs = [
|
|
121
|
+
"-hide_banner",
|
|
122
|
+
"-nostdin",
|
|
123
|
+
"-loglevel",
|
|
124
|
+
"error",
|
|
125
|
+
// Progress on stdout keeps stderr free to carry only what went wrong.
|
|
126
|
+
"-progress",
|
|
127
|
+
"pipe:1",
|
|
128
|
+
"-nostats",
|
|
129
|
+
"-y",
|
|
130
|
+
];
|
|
131
|
+
const videoCodec = ["-c:v", "libx264", "-pix_fmt", "yuv420p"];
|
|
132
|
+
const centredX = "iw/2-(iw/zoom/2)";
|
|
133
|
+
const centredY = "ih/2-(ih/zoom/2)";
|
|
134
|
+
// 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, and a one-frame shot has no span to divide
|
|
136
|
+
// by, so it holds where it starts. `zoom` in x and y is the current zoom, so iw-iw/zoom
|
|
137
|
+
// is the room the crop window has to travel in.
|
|
138
|
+
function zoompan(motion, frames) {
|
|
139
|
+
if (motion.kind === "still")
|
|
140
|
+
return undefined;
|
|
141
|
+
const range = zoomRange(motion.percent);
|
|
142
|
+
if (range === undefined)
|
|
143
|
+
return undefined;
|
|
144
|
+
const span = frames - 1;
|
|
145
|
+
if (motion.kind === "zoom") {
|
|
146
|
+
// Zoom in rises from 100%, zoom out falls back to it, linear over the shot.
|
|
147
|
+
const z = motion.direction === "in"
|
|
148
|
+
? span < 1
|
|
149
|
+
? range.from
|
|
150
|
+
: `${range.from}+${range.by}*on/${span}`
|
|
151
|
+
: span < 1
|
|
152
|
+
? range.to
|
|
153
|
+
: `${range.to}-${range.by}*on/${span}`;
|
|
154
|
+
return { z, x: centredX, y: centredY };
|
|
103
155
|
}
|
|
104
|
-
return
|
|
156
|
+
return {
|
|
157
|
+
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)})`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// A share of the room from `from` to `to`, linear over the shot. Built in whole
|
|
163
|
+
// thousandths as decimal text, like the zoom, so 1 - 0.5 reads 0.5 and never
|
|
164
|
+
// 0.49999999999999994.
|
|
165
|
+
function travel(from, to, span) {
|
|
166
|
+
const start = Math.round(from * 1000);
|
|
167
|
+
const by = Math.round(to * 1000) - start;
|
|
168
|
+
if (span < 1 || by === 0)
|
|
169
|
+
return decimal(start);
|
|
170
|
+
return `${decimal(start)}${by > 0 ? "+" : "-"}${decimal(Math.abs(by))}*on/${span}`;
|
|
105
171
|
}
|
|
106
172
|
function seconds(value) {
|
|
107
173
|
return value.toFixed(3);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// floor: a pan needs room to travel, and a crop of 100% leaves none. Below 10% the
|
|
2
|
+
// travel is a few percent of the frame over the whole shot, which reads as a wobble
|
|
3
|
+
// rather than a pan, so a pan uses the project's zoom or 10%, whichever is more; with
|
|
4
|
+
// Zoom (%) at 0 the pans still move and zoom motions keep the still still.
|
|
5
|
+
export const panMinimumPercent = 10;
|
|
6
|
+
const left = { x: 0, y: 0.5 };
|
|
7
|
+
const right = { x: 1, y: 0.5 };
|
|
8
|
+
const top = { x: 0.5, y: 0 };
|
|
9
|
+
const bottom = { x: 0.5, y: 1 };
|
|
10
|
+
// The order the pans take turns in: across one way, back, down, back up.
|
|
11
|
+
const pans = [
|
|
12
|
+
[left, right],
|
|
13
|
+
[right, left],
|
|
14
|
+
[top, bottom],
|
|
15
|
+
[bottom, top],
|
|
16
|
+
];
|
|
17
|
+
// The motion for the slot at `at` (0-based) of the timeline. Everything turns on the
|
|
18
|
+
// slot's place and nothing else, so the same project plans the same list every time and
|
|
19
|
+
// a re-render is identical. Zoom: in, out, in, ... Pan: the four directions above in
|
|
20
|
+
// turn. Mixed: zoom and pan take turns, each keeping its own alternation, so the slots
|
|
21
|
+
// run zoom in, pan left to right, zoom out, pan right to left, zoom in, pan top to
|
|
22
|
+
// bottom, ...
|
|
23
|
+
export function motionFor(style, at, zoomPercent) {
|
|
24
|
+
switch (style) {
|
|
25
|
+
case "still":
|
|
26
|
+
return { kind: "still" };
|
|
27
|
+
case "zoom":
|
|
28
|
+
return zoom(at, zoomPercent);
|
|
29
|
+
case "pan":
|
|
30
|
+
return pan(at, zoomPercent);
|
|
31
|
+
case "mixed":
|
|
32
|
+
return at % 2 === 0 ? zoom(at / 2, zoomPercent) : pan((at - 1) / 2, zoomPercent);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function zoom(turn, percent) {
|
|
36
|
+
// Rounded to the half steps admission allows, which is what the renderer can write
|
|
37
|
+
// exactly; 0% is no zoom at all.
|
|
38
|
+
const halves = Math.round(percent * 2);
|
|
39
|
+
if (halves <= 0)
|
|
40
|
+
return { kind: "still" };
|
|
41
|
+
return { kind: "zoom", direction: turn % 2 === 0 ? "in" : "out", percent: halves / 2 };
|
|
42
|
+
}
|
|
43
|
+
function pan(turn, percent) {
|
|
44
|
+
const [from, to] = pans[turn % pans.length] ?? [left, right];
|
|
45
|
+
return {
|
|
46
|
+
kind: "pan",
|
|
47
|
+
from,
|
|
48
|
+
to,
|
|
49
|
+
percent: Math.max(Math.round(percent * 2) / 2, panMinimumPercent),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -1,15 +1,32 @@
|
|
|
1
|
+
import { editListVersion, } from "./edit-list.js";
|
|
2
|
+
import { motionFor } from "./motion.js";
|
|
1
3
|
// 30 fps, 1920×1080 for 16:9 and 1080×1920 for 9:16.
|
|
2
4
|
export const fps = 30;
|
|
3
5
|
const frames = {
|
|
4
6
|
"16:9": { width: 1920, height: 1080 },
|
|
5
7
|
"9:16": { width: 1080, height: 1920 },
|
|
6
8
|
};
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// expression
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
export const
|
|
9
|
+
// A zoom shot zooms linearly and centred between 100% and 100% + the project's zoom
|
|
10
|
+
// percent; a pan holds 100% + its percent while it travels. The ends are formatted into an ffmpeg
|
|
11
|
+
// expression, so they are built from whole thousandths as decimal text rather than by
|
|
12
|
+
// float arithmetic: 1 + 0.225 is exact, but 1.225 - 1 is not 0.225 in binary floating
|
|
13
|
+
// point, and a percent in half steps is always a whole number of thousandths.
|
|
14
|
+
export const zoomFrom = "1";
|
|
15
|
+
// undefined for 0%: the still does not move.
|
|
16
|
+
export function zoomRange(percent) {
|
|
17
|
+
const thousandths = Math.round(percent * 10);
|
|
18
|
+
if (thousandths <= 0)
|
|
19
|
+
return undefined;
|
|
20
|
+
return { from: zoomFrom, to: decimal(1000 + thousandths), by: decimal(thousandths) };
|
|
21
|
+
}
|
|
22
|
+
// Whole thousandths as decimal text, for anything that goes into an ffmpeg expression.
|
|
23
|
+
export function decimal(thousandths) {
|
|
24
|
+
const fraction = String(thousandths % 1000)
|
|
25
|
+
.padStart(3, "0")
|
|
26
|
+
.replace(/0+$/, "");
|
|
27
|
+
const whole = String(Math.floor(thousandths / 1000));
|
|
28
|
+
return fraction === "" ? whole : `${whole}.${fraction}`;
|
|
29
|
+
}
|
|
13
30
|
export function planRender(input) {
|
|
14
31
|
if (input.images.length === 0) {
|
|
15
32
|
// Admission permits Video only with an image source, so an empty set is a bug upstream.
|
|
@@ -17,30 +34,46 @@ export function planRender(input) {
|
|
|
17
34
|
}
|
|
18
35
|
const frame = frames[input.format];
|
|
19
36
|
const audio = input.body === undefined ? [] : audioTimeline({ ...input, body: input.body });
|
|
37
|
+
// A silent video shows every image once, which is the one length it has to go on.
|
|
20
38
|
const totalSeconds = input.body === undefined
|
|
21
|
-
? input.images.length *
|
|
39
|
+
? input.images.length * input.imageSeconds
|
|
22
40
|
: audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
23
|
-
const totalFrames = Math.max(
|
|
41
|
+
const totalFrames = Math.max(1, Math.round(totalSeconds * fps));
|
|
24
42
|
return {
|
|
25
|
-
width: frame.width,
|
|
26
|
-
height: frame.height,
|
|
27
|
-
fps,
|
|
28
43
|
gapSeconds: input.gapSeconds,
|
|
29
|
-
|
|
30
|
-
|
|
44
|
+
edgeSeconds: input.edgeSeconds,
|
|
45
|
+
imageSeconds: input.imageSeconds,
|
|
46
|
+
zoomPercent: input.zoomPercent,
|
|
47
|
+
motionStyle: input.motionStyle,
|
|
48
|
+
editList: {
|
|
49
|
+
version: editListVersion,
|
|
50
|
+
width: frame.width,
|
|
51
|
+
height: frame.height,
|
|
52
|
+
fps,
|
|
53
|
+
audio,
|
|
54
|
+
shots: shots(input, Math.max(1, Math.round(input.imageSeconds * fps)), totalFrames),
|
|
55
|
+
},
|
|
31
56
|
totalFrames,
|
|
32
57
|
totalSeconds,
|
|
33
58
|
output: input.output,
|
|
34
59
|
};
|
|
35
60
|
}
|
|
36
|
-
|
|
37
|
-
|
|
61
|
+
export function spoken(kind) {
|
|
62
|
+
return kind !== "gap" && kind !== "edge";
|
|
63
|
+
}
|
|
64
|
+
// Edge, intro, gap, body, gap, outro, edge, with a gap only where the segment on the other
|
|
65
|
+
// side of it exists. Captions walk these same segments, so the lead-in shifts every cue.
|
|
38
66
|
export function audioTimeline(input, minimumGap = 1 / fps) {
|
|
39
67
|
const segments = [];
|
|
40
68
|
const gap = { kind: "gap", path: null, seconds: input.gapSeconds };
|
|
69
|
+
const edge = { kind: "edge", path: null, seconds: input.edgeSeconds };
|
|
41
70
|
// Video keeps its existing minimum of one frame. WAV passes one sample instead,
|
|
42
71
|
// because its gap duration is independent of the slideshow's frame rate.
|
|
43
72
|
const audible = input.gapSeconds >= minimumGap;
|
|
73
|
+
const edged = input.edgeSeconds >= minimumGap;
|
|
74
|
+
if (edged) {
|
|
75
|
+
segments.push(edge);
|
|
76
|
+
}
|
|
44
77
|
if (input.intro !== undefined) {
|
|
45
78
|
segments.push({ kind: "intro", path: input.intro.path, seconds: input.intro.seconds });
|
|
46
79
|
if (audible) {
|
|
@@ -54,19 +87,20 @@ export function audioTimeline(input, minimumGap = 1 / fps) {
|
|
|
54
87
|
}
|
|
55
88
|
segments.push({ kind: "outro", path: input.outro.path, seconds: input.outro.seconds });
|
|
56
89
|
}
|
|
90
|
+
if (edged) {
|
|
91
|
+
segments.push(edge);
|
|
92
|
+
}
|
|
57
93
|
return segments;
|
|
58
94
|
}
|
|
59
|
-
// The
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
function
|
|
64
|
-
const
|
|
65
|
-
return
|
|
66
|
-
path,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// Odd images zoom in, even images zoom out.
|
|
70
|
-
zoom: at % 2 === 0 ? "in" : "out",
|
|
95
|
+
// The images take turns in slideshow order, each for `each` frames, starting over after the
|
|
96
|
+
// last until the timeline is full; the last shot is cut to what is left. A timeline shorter
|
|
97
|
+
// than one shot is a single shot. The motion goes by the shot's place, not the image
|
|
98
|
+
// (`motion.ts`), so an image that comes round again may move another way.
|
|
99
|
+
function shots(input, each, totalFrames) {
|
|
100
|
+
const count = Math.ceil(totalFrames / each);
|
|
101
|
+
return Array.from({ length: count }, (_value, at) => ({
|
|
102
|
+
source: { kind: "image", path: input.images[at % input.images.length] ?? "" },
|
|
103
|
+
frames: Math.min(each, totalFrames - each * at),
|
|
104
|
+
motion: motionFor(input.motionStyle, at, input.zoomPercent),
|
|
71
105
|
}));
|
|
72
106
|
}
|
|
@@ -6,6 +6,8 @@ const planSchema = z.object({
|
|
|
6
6
|
channels: z.number(),
|
|
7
7
|
codec: z.string(),
|
|
8
8
|
gapSeconds: z.number(),
|
|
9
|
+
// Absent from records written before the edge silence existed, which then never match.
|
|
10
|
+
edgeSeconds: z.number().optional(),
|
|
9
11
|
totalSeconds: z.number(),
|
|
10
12
|
audio: z.array(z.object({ kind: z.string(), path: z.string().nullable(), seconds: z.number() })),
|
|
11
13
|
output: z.string(),
|
package/dist/slices/video/run.js
CHANGED
|
@@ -5,8 +5,9 @@ import { outputsOf } from "../storage/repo.js";
|
|
|
5
5
|
import { prepareSubtitles } from "../subtitles/prepare.js";
|
|
6
6
|
import { exportAudioWav } from "./audio-export.js";
|
|
7
7
|
import { audioInputs } from "./audio-inputs.js";
|
|
8
|
-
import {
|
|
8
|
+
import { withPaths } from "./edit-list.js";
|
|
9
9
|
import { planRender } from "./plan.js";
|
|
10
|
+
import { renderSlideshow } from "./slideshow.js";
|
|
10
11
|
import { writeExport } from "./write-export.js";
|
|
11
12
|
export async function renderVideo(deps, context) {
|
|
12
13
|
const { projectId } = context.stage;
|
|
@@ -16,7 +17,7 @@ export async function renderVideo(deps, context) {
|
|
|
16
17
|
if (project.config.sources.video === "off") {
|
|
17
18
|
if (project.config.sources.audio === "off")
|
|
18
19
|
throw new Error("There is nothing to export because both video and narration are turned off for this project. Turn one on in Edit project, then Retry stage.");
|
|
19
|
-
await exportAudioWav(deps, context, project.config.silenceGapSeconds);
|
|
20
|
+
await exportAudioWav(deps, context, project.config.silenceGapSeconds, project.config.edgeSilenceSeconds);
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
23
|
const outputs = outputsOf(deps.db, projectId);
|
|
@@ -27,11 +28,15 @@ export async function renderVideo(deps, context) {
|
|
|
27
28
|
const plan = planRender({
|
|
28
29
|
format: project.format,
|
|
29
30
|
gapSeconds: project.config.silenceGapSeconds,
|
|
31
|
+
edgeSeconds: project.config.edgeSilenceSeconds,
|
|
32
|
+
imageSeconds: project.config.imageSeconds,
|
|
33
|
+
zoomPercent: project.config.zoomPercent,
|
|
34
|
+
motionStyle: project.config.motionStyle,
|
|
30
35
|
...narration,
|
|
31
36
|
images: slideshow(outputs).map((output) => outputPath(deps.paths, projectId, output.path)),
|
|
32
37
|
output: outputPath(deps.paths, projectId, "video.mp4"),
|
|
33
38
|
});
|
|
34
|
-
const subtitles = await prepareSubtitles(deps, context, plan.audio, plan);
|
|
39
|
+
const subtitles = await prepareSubtitles(deps, context, plan.editList.audio, plan.editList);
|
|
35
40
|
await writeExport(deps, context, {
|
|
36
41
|
role: "video",
|
|
37
42
|
filename: "video.mp4",
|
|
@@ -39,7 +44,17 @@ export async function renderVideo(deps, context) {
|
|
|
39
44
|
totalSeconds: plan.totalSeconds,
|
|
40
45
|
record: { ...recorded(plan, dir), subtitles: project.config.subtitles ?? null },
|
|
41
46
|
subtitles,
|
|
42
|
-
|
|
47
|
+
render: (part, onProgress) => renderSlideshow({
|
|
48
|
+
bin: deps.ffmpeg,
|
|
49
|
+
edit: plan.editList,
|
|
50
|
+
output: part,
|
|
51
|
+
burnSubtitles: subtitles?.burnIn ?? false,
|
|
52
|
+
cwd: subtitles?.directory,
|
|
53
|
+
scratch: dir,
|
|
54
|
+
signal: context.signal,
|
|
55
|
+
log: deps.log,
|
|
56
|
+
onProgress,
|
|
57
|
+
}),
|
|
43
58
|
});
|
|
44
59
|
deps.count("stage.completed", { stage: "video" });
|
|
45
60
|
}
|
|
@@ -48,15 +63,11 @@ export async function renderVideo(deps, context) {
|
|
|
48
63
|
function recorded(plan, dir) {
|
|
49
64
|
return {
|
|
50
65
|
...plan,
|
|
51
|
-
|
|
52
|
-
...segment,
|
|
53
|
-
path: segment.path === null ? null : relative(dir, segment.path),
|
|
54
|
-
})),
|
|
55
|
-
images: plan.images.map((slot) => ({ ...slot, path: relative(dir, slot.path) })),
|
|
66
|
+
editList: withPaths(plan.editList, (path) => relative(dir, path)),
|
|
56
67
|
output: relative(dir, plan.output),
|
|
57
68
|
};
|
|
58
69
|
}
|
|
59
|
-
// Every slideshow image
|
|
70
|
+
// Every slideshow image, once each, in slideshow order; the plan cycles through them.
|
|
60
71
|
// The thumbnail is a different role, so it is never in this list.
|
|
61
72
|
function slideshow(outputs) {
|
|
62
73
|
return outputs
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { clipArgs, concatList, joinArgs, runFfmpeg, slideshowClips } from "./ffmpeg.js";
|
|
4
|
+
// estimate: a zoompan frame from a 4x still costs several plain encoded frames, and a
|
|
5
|
+
// copying join is nearly free, so progress is weighted by that rather than by clock time.
|
|
6
|
+
const clipWeight = 4;
|
|
7
|
+
const copyWeight = 0.05;
|
|
8
|
+
export async function renderSlideshow(run) {
|
|
9
|
+
const { edit } = run;
|
|
10
|
+
const { clips, order } = slideshowClips(edit);
|
|
11
|
+
const msOf = (frames) => (frames / edit.fps) * 1000;
|
|
12
|
+
const joinMs = msOf(edit.shots.reduce((sum, shot) => sum + shot.frames, 0));
|
|
13
|
+
// Progress is reported against the video's length, the longer of picture and sound.
|
|
14
|
+
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.shot.frames), 0);
|
|
16
|
+
const work = clipMs * clipWeight + joinMs * (run.burnSubtitles ? 1 : copyWeight);
|
|
17
|
+
const report = (done) => {
|
|
18
|
+
run.onProgress(work > 0 ? Math.round((Math.min(done, work) / work) * totalMs) : 0);
|
|
19
|
+
};
|
|
20
|
+
const workspace = mkdtempSync(join(run.scratch, "render-"));
|
|
21
|
+
try {
|
|
22
|
+
let rendered = 0;
|
|
23
|
+
for (const clip of clips) {
|
|
24
|
+
const before = rendered;
|
|
25
|
+
await runFfmpeg({
|
|
26
|
+
bin: run.bin,
|
|
27
|
+
args: clipArgs(edit, clip.shot, join(workspace, clip.name), run.burnSubtitles),
|
|
28
|
+
signal: run.signal,
|
|
29
|
+
log: run.log,
|
|
30
|
+
onProgress: (elapsedMs) => report(before + elapsedMs * clipWeight),
|
|
31
|
+
});
|
|
32
|
+
rendered += msOf(clip.shot.frames) * clipWeight;
|
|
33
|
+
}
|
|
34
|
+
const list = join(workspace, "slides.ffconcat");
|
|
35
|
+
writeFileSync(list, concatList(order), { mode: 0o600 });
|
|
36
|
+
await runFfmpeg({
|
|
37
|
+
bin: run.bin,
|
|
38
|
+
cwd: run.cwd,
|
|
39
|
+
args: joinArgs(edit, run.output, list, run.burnSubtitles),
|
|
40
|
+
signal: run.signal,
|
|
41
|
+
log: run.log,
|
|
42
|
+
onProgress: (elapsedMs) => report(rendered + elapsedMs * (run.burnSubtitles ? 1 : copyWeight)),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
rmSync(workspace, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|