@gentbajko/slopify 3.1.1 → 3.2.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/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/patch-notes/3.2.0.md +24 -0
- package/dist/patch-notes/3.2.1.md +13 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/rebuild/recipe-preparation.js +5 -1
- package/dist/slices/rebuild/runtime-export.js +2 -1
- package/dist/slices/revisions/schema.js +7 -1
- package/dist/slices/storage/delete-project.js +2 -1
- package/dist/slices/storage/files-location.js +2 -2
- package/dist/slices/storage/layout.js +7 -0
- package/dist/slices/video/clip-cache.js +118 -0
- package/dist/slices/video/ffmpeg.js +57 -31
- package/dist/slices/video/pool.js +56 -0
- package/dist/slices/video/slideshow.js +205 -50
- package/dist/web/assets/{index-DBLI2o4h.js → index-D4sTCEIy.js} +2 -2
- package/dist/web/assets/{pdf-CViHVF9X.js → pdf-DjcINuld.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
|
@@ -2,12 +2,16 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "nod
|
|
|
2
2
|
import { extname, join } from "node:path";
|
|
3
3
|
import { masterFile, masterReport } from "../loudness/loudnorm.js";
|
|
4
4
|
import { cardsAss } from "./cards.js";
|
|
5
|
-
import {
|
|
5
|
+
import { cacheKey, fileIdentity, keepClip, pruneClips, reuseClip } from "./clip-cache.js";
|
|
6
|
+
import { audioMixArgs, burnPartArgs, concatList, joinArgs, runFfmpeg, segmentArgs, slideshowClips, } from "./ffmpeg.js";
|
|
6
7
|
import { cardsIn } from "./look.js";
|
|
8
|
+
import { inPool, renderJobs } from "./pool.js";
|
|
7
9
|
// estimate: a zoompan frame from a 4x still costs several plain encoded frames, and a
|
|
8
10
|
// copying join is nearly free, so progress is weighted by that rather than by clock time.
|
|
9
11
|
const clipWeight = 4;
|
|
10
12
|
const copyWeight = 0.05;
|
|
13
|
+
// floor: a burned-in part shorter than a minute is not worth its own run.
|
|
14
|
+
const leastPartFrames = 60 * 30;
|
|
11
15
|
// The sound the join plays, as the join itself brings every segment to it.
|
|
12
16
|
const masterFormat = { sampleRate: 44100, channels: 2 };
|
|
13
17
|
// What the master measured, when the run asked for one.
|
|
@@ -19,7 +23,11 @@ export async function renderSlideshow(run) {
|
|
|
19
23
|
// Progress is reported against the video's length, the longer of picture and sound.
|
|
20
24
|
const totalMs = Math.max(joinMs, edit.audio.reduce((sum, segment) => sum + segment.seconds * 1000, 0));
|
|
21
25
|
const clipMs = clips.reduce((sum, clip) => sum + msOf(clip.frames), 0);
|
|
22
|
-
const
|
|
26
|
+
const jobs = run.jobs ?? renderJobs();
|
|
27
|
+
const parts = run.burnSubtitles ? burnParts(order, clips, jobs) : [];
|
|
28
|
+
const work = clipMs * clipWeight +
|
|
29
|
+
joinMs * (run.burnSubtitles ? 1 : copyWeight) +
|
|
30
|
+
(parts.length > 1 ? joinMs * copyWeight : 0);
|
|
23
31
|
const report = (done) => {
|
|
24
32
|
run.onProgress(work > 0 ? Math.round((Math.min(done, work) / work) * totalMs) : 0);
|
|
25
33
|
};
|
|
@@ -34,66 +42,213 @@ export async function renderSlideshow(run) {
|
|
|
34
42
|
mkdirSync(join(workspace, "fonts"), { mode: 0o700 });
|
|
35
43
|
copyFileSync(font.path, join(workspace, "fonts", `card${extname(font.path)}`));
|
|
36
44
|
}
|
|
45
|
+
// The sound is mixed, levelled and encoded while the picture renders, on its own run; the
|
|
46
|
+
// join then only copies it in. Either failing stops the other, and the first failure is
|
|
47
|
+
// the one reported.
|
|
48
|
+
const stop = new AbortController();
|
|
49
|
+
const follow = () => {
|
|
50
|
+
stop.abort();
|
|
51
|
+
};
|
|
52
|
+
if (run.signal.aborted)
|
|
53
|
+
stop.abort();
|
|
54
|
+
else
|
|
55
|
+
run.signal.addEventListener("abort", follow, { once: true });
|
|
56
|
+
let failed;
|
|
57
|
+
const watched = (work) => work.catch((error) => {
|
|
58
|
+
failed ??= { error };
|
|
59
|
+
stop.abort();
|
|
60
|
+
throw error;
|
|
61
|
+
});
|
|
62
|
+
const sounding = edit.audio.length > 0
|
|
63
|
+
? watched(renderSound(run, edit, workspace, stop.signal))
|
|
64
|
+
: Promise.resolve(undefined);
|
|
65
|
+
// Handled now, since the picture is awaited first and the sound may fail meanwhile.
|
|
66
|
+
sounding.catch(() => { });
|
|
37
67
|
let rendered = 0;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
68
|
+
const keys = new Set();
|
|
69
|
+
const picture = async (halt) => {
|
|
70
|
+
// Each run's own progress, summed: runs side by side each report their own elapsed time.
|
|
71
|
+
const running = new Map();
|
|
72
|
+
const tell = () => {
|
|
73
|
+
report(rendered + [...running.values()].reduce((sum, one) => sum + one, 0));
|
|
74
|
+
};
|
|
75
|
+
await inPool(clips, jobs, halt, async (clip, signal) => {
|
|
76
|
+
const script = clip.name.replace(/\.mp4$/, ".ass");
|
|
77
|
+
const shown = cardsIn(cards, clip.segment.start, clip.segment.count);
|
|
78
|
+
const drawn = shown.length > 0 && font !== undefined
|
|
79
|
+
? cardsAss(edit, shown, font.name, clip.segment.start, edit.cardColor)
|
|
80
|
+
: undefined;
|
|
81
|
+
if (drawn !== undefined)
|
|
82
|
+
writeFileSync(join(workspace, script), drawn, { mode: 0o600 });
|
|
83
|
+
const output = join(workspace, clip.name);
|
|
84
|
+
const args = segmentArgs(edit, clip.segment, output, run.burnSubtitles, script);
|
|
85
|
+
const weight = msOf(clip.frames) * clipWeight;
|
|
86
|
+
const cache = run.cache;
|
|
87
|
+
let key;
|
|
88
|
+
// The cache only saves time: a clip it cannot name or give back (another project's render
|
|
89
|
+
// let it go a moment ago) is encoded as if there were none.
|
|
90
|
+
try {
|
|
91
|
+
if (cache !== undefined) {
|
|
92
|
+
key = cacheKey(run.bin, args.map((value) => drawn === undefined ? value : value.replaceAll(script, "\u0000cards")), output, drawn === undefined || font === undefined ? [] : [drawn, fileIdentity(font.path)]);
|
|
93
|
+
keys.add(key);
|
|
94
|
+
if (reuseClip(cache, key, output)) {
|
|
95
|
+
rendered += weight;
|
|
96
|
+
tell();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
run.log.write("warn", "video.cache", { detail: messageOf(error) });
|
|
103
|
+
rmSync(output, { force: true });
|
|
104
|
+
}
|
|
105
|
+
await runFfmpeg({
|
|
106
|
+
bin: run.bin,
|
|
107
|
+
...(cards.length > 0 ? { cwd: workspace } : {}),
|
|
108
|
+
args,
|
|
109
|
+
signal,
|
|
110
|
+
log: run.log,
|
|
111
|
+
onProgress: (elapsedMs) => {
|
|
112
|
+
running.set(clip.name, Math.min(elapsedMs * clipWeight, weight));
|
|
113
|
+
tell();
|
|
78
114
|
},
|
|
79
|
-
|
|
80
|
-
|
|
115
|
+
});
|
|
116
|
+
running.delete(clip.name);
|
|
117
|
+
rendered += weight;
|
|
118
|
+
if (cache !== undefined && key !== undefined) {
|
|
119
|
+
try {
|
|
120
|
+
keepClip(cache, key, output);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
run.log.write("warn", "video.cache", { detail: messageOf(error) });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
const list = join(workspace, "slides.ffconcat");
|
|
128
|
+
writeFileSync(list, concatList(order), { mode: 0o600 });
|
|
129
|
+
if (parts.length <= 1)
|
|
130
|
+
return list;
|
|
131
|
+
// A long video's captions are burned in parts side by side, and the parts joined by
|
|
132
|
+
// copying, as the clips are without captions.
|
|
133
|
+
const done = new Map();
|
|
134
|
+
const burned = () => {
|
|
135
|
+
report(rendered + [...done.values()].reduce((sum, one) => sum + one, 0));
|
|
81
136
|
};
|
|
82
|
-
|
|
137
|
+
await inPool(parts, jobs, halt, async (part, signal) => {
|
|
138
|
+
const partList = join(workspace, `${part.name}.ffconcat`);
|
|
139
|
+
writeFileSync(partList, concatList(part.names), { mode: 0o600 });
|
|
140
|
+
await runFfmpeg({
|
|
141
|
+
bin: run.bin,
|
|
142
|
+
cwd: run.cwd,
|
|
143
|
+
args: burnPartArgs(edit, partList, join(workspace, `${part.name}.mp4`), part.start, run.portraits),
|
|
144
|
+
signal,
|
|
145
|
+
log: run.log,
|
|
146
|
+
onProgress: (elapsedMs) => {
|
|
147
|
+
done.set(part.name, Math.min(elapsedMs, msOf(part.frames)));
|
|
148
|
+
burned();
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
done.set(part.name, msOf(part.frames));
|
|
152
|
+
burned();
|
|
153
|
+
});
|
|
154
|
+
rendered += joinMs;
|
|
155
|
+
const joinedList = join(workspace, "parts.ffconcat");
|
|
156
|
+
writeFileSync(joinedList, concatList(parts.map((part) => `${part.name}.mp4`)), {
|
|
157
|
+
mode: 0o600,
|
|
158
|
+
});
|
|
159
|
+
return joinedList;
|
|
160
|
+
};
|
|
161
|
+
const played = await watched(picture(stop.signal)).catch(() => undefined);
|
|
162
|
+
const sound = await sounding.catch(() => undefined);
|
|
163
|
+
run.signal.removeEventListener("abort", follow);
|
|
164
|
+
if (failed !== undefined)
|
|
165
|
+
throw failed.error;
|
|
166
|
+
if (played === undefined)
|
|
167
|
+
throw new Error("the render was canceled");
|
|
168
|
+
const burnsHere = run.burnSubtitles && parts.length <= 1;
|
|
83
169
|
await runFfmpeg({
|
|
84
170
|
bin: run.bin,
|
|
85
171
|
cwd: run.cwd,
|
|
86
|
-
args: joinArgs(
|
|
172
|
+
args: joinArgs(edit, run.output, played, burnsHere, run.portraits, sound),
|
|
87
173
|
signal: run.signal,
|
|
88
174
|
log: run.log,
|
|
89
|
-
onProgress: (elapsedMs) => report(rendered + elapsedMs * (
|
|
175
|
+
onProgress: (elapsedMs) => report(rendered + elapsedMs * (burnsHere ? 1 : copyWeight)),
|
|
90
176
|
});
|
|
177
|
+
if (run.cache !== undefined) {
|
|
178
|
+
try {
|
|
179
|
+
pruneClips(run.cache, keys);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
run.log.write("warn", "video.cache", { detail: messageOf(error) });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
91
185
|
// What the finished video measures, after its AAC encode.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
186
|
+
return run.master !== undefined && edit.audio.length > 0
|
|
187
|
+
? await masterReport(run, run.output, run.master)
|
|
188
|
+
: undefined;
|
|
95
189
|
}
|
|
96
190
|
finally {
|
|
97
191
|
rmSync(workspace, { recursive: true, force: true });
|
|
98
192
|
}
|
|
99
193
|
}
|
|
194
|
+
// The video's finished sound as AAC in the working folder: the narration, silences and bed
|
|
195
|
+
// mixed, and with Level the volume mastered first, alone and then as one file.
|
|
196
|
+
async function renderSound(run, edit, workspace, signal) {
|
|
197
|
+
const quiet = { bin: run.bin, signal, log: run.log, onProgress: () => { } };
|
|
198
|
+
let played = edit;
|
|
199
|
+
if (run.master !== undefined) {
|
|
200
|
+
const mixed = join(workspace, "mix.wav");
|
|
201
|
+
await runFfmpeg({ ...quiet, args: audioMixArgs(edit, mixed) });
|
|
202
|
+
const mastered = join(workspace, "master.wav");
|
|
203
|
+
await masterFile({ ...run, signal }, mixed, mastered, run.master, masterFormat);
|
|
204
|
+
played = {
|
|
205
|
+
...edit,
|
|
206
|
+
audio: [
|
|
207
|
+
{
|
|
208
|
+
kind: "body",
|
|
209
|
+
path: mastered,
|
|
210
|
+
seconds: edit.audio.reduce((sum, segment) => sum + segment.seconds, 0),
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
bed: undefined,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const sound = join(workspace, "sound.m4a");
|
|
217
|
+
await runFfmpeg({ ...quiet, args: audioMixArgs(played, sound, "aac") });
|
|
218
|
+
return sound;
|
|
219
|
+
}
|
|
220
|
+
// The timeline cut at clip boundaries into at most `jobs` parts of about equal length, a
|
|
221
|
+
// minute or more each; one part means the captions are burned in the join as before.
|
|
222
|
+
export function burnParts(order, clips, jobs) {
|
|
223
|
+
const framesOf = new Map(clips.map((clip) => [clip.name, clip.frames]));
|
|
224
|
+
const total = order.reduce((sum, name) => sum + (framesOf.get(name) ?? 0), 0);
|
|
225
|
+
const count = Math.max(1, Math.min(jobs, Math.floor(total / leastPartFrames)));
|
|
226
|
+
const parts = [];
|
|
227
|
+
let names = [];
|
|
228
|
+
let start = 0;
|
|
229
|
+
let at = 0;
|
|
230
|
+
for (const name of order) {
|
|
231
|
+
const frames = framesOf.get(name) ?? 0;
|
|
232
|
+
const target = (total * (parts.length + 1)) / count;
|
|
233
|
+
const last = parts.length === count - 1;
|
|
234
|
+
// A part ends at the clip boundary nearest its share of the timeline.
|
|
235
|
+
if (!last && names.length > 0 && at + frames >= target && target - at < at + frames - target) {
|
|
236
|
+
parts.push({ name: `p${String(parts.length + 1)}`, names, start, frames: at - start });
|
|
237
|
+
names = [];
|
|
238
|
+
start = at;
|
|
239
|
+
}
|
|
240
|
+
names.push(name);
|
|
241
|
+
at += frames;
|
|
242
|
+
if (parts.length < count - 1 && at >= (total * (parts.length + 1)) / count) {
|
|
243
|
+
parts.push({ name: `p${String(parts.length + 1)}`, names, start, frames: at - start });
|
|
244
|
+
names = [];
|
|
245
|
+
start = at;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (names.length > 0)
|
|
249
|
+
parts.push({ name: `p${String(parts.length + 1)}`, names, start, frames: at - start });
|
|
250
|
+
return parts;
|
|
251
|
+
}
|
|
252
|
+
function messageOf(error) {
|
|
253
|
+
return error instanceof Error ? error.message : String(error);
|
|
254
|
+
}
|