@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.
@@ -0,0 +1,24 @@
1
+ # Slopify 3.2.0
2
+
3
+ Released 30 September 2026.
4
+
5
+ 3.2.0 renders videos several times faster, with the same picture and sound.
6
+
7
+ ## Highlights
8
+
9
+ - **Faster renders.** A 2 h 22 min video with transitions and burned-in captions took about 2 hours to render. It now takes 36 minutes on the same computer.
10
+ - **Faster re-renders.** Rendering again after an edit makes only the clips that changed; making the clips was 20 of those 36 minutes.
11
+
12
+ ## Video
13
+
14
+ - The picture's clips are made several at a time instead of one after another: up to eight at once, depending on your computer's cores and memory.
15
+ - With burned-in captions, a video of two minutes or more is encoded in parts side by side and then joined, instead of in one long pass.
16
+ - The sound is levelled and encoded while the picture renders, instead of after it.
17
+ - The clips of each project's last render are kept, so a render after an edit (captions, a few images, chapter cards) reuses every clip that did not change. A render that is canceled keeps the clips it finished, so the next one picks up where it stopped.
18
+ - The kept clips live in a hidden `.render-cache` folder in your Projects folder. All projects together keep at most 30 GB. Older projects' clips are removed first, and all of them are removed if your disk gets short on space. Deleting a project removes its clips, and backups and moving your files leave them out.
19
+ - Zooms and pans look exactly as before.
20
+
21
+ ## Upgrading from 3.1.1
22
+
23
+ - **Nothing becomes outdated.** Videos you already rendered stay as they are. The first render of each project after upgrading makes all its clips; later renders reuse them.
24
+ - A render uses more of your processor and memory while it runs (up to eight runs of about 1.5 GB each on a large computer). Two videos rendering at the same time share it.
@@ -0,0 +1,13 @@
1
+ # Slopify 3.2.1
2
+
3
+ Released 30 September 2026.
4
+
5
+ 3.2.1 lets one narration chunk be re-tagged without re-recording the whole narration.
6
+
7
+ ## Narration
8
+
9
+ - A chunk whose text is edited in Edit project → Narration can carry its own delivery note. The note is added to the narration prep prompt for that chunk only, so its delivery cues can change (a calmer opening, say) while every other chunk keeps its recording.
10
+
11
+ ## Upgrading from 3.2.0
12
+
13
+ - **Nothing becomes outdated.**
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.2.1",
4
+ "title": "Slopify 3.2.1",
5
+ "version": "3.2.1",
6
+ "date": "2026-09-30"
7
+ },
8
+ {
9
+ "id": "3.2.0",
10
+ "title": "Slopify 3.2.0",
11
+ "version": "3.2.0",
12
+ "date": "2026-09-30"
13
+ },
2
14
  {
3
15
  "id": "3.1.1",
4
16
  "title": "Slopify 3.1.1",
@@ -26,8 +26,12 @@ export function preparationFuture(context, segment, dependency, additionalDepend
26
26
  }, [...new Set([dependency.key, ...additionalDependencies.map((row) => row.key)])]);
27
27
  }
28
28
  export function preparationForGroup(context, logicalKey, source, segment, dependsOn, maxCharacters, spans = [], aliases = [], speaker) {
29
+ // A delivery note for this chunk alone (Edit project → Narration), after the project's prompt.
30
+ const override = context.content.narrationOverrides[logicalKey];
31
+ const note = override?.kind === "text" ? override.direction : undefined;
32
+ const prompt = renderedPrompt(context, "narration");
29
33
  const preparation = recipe(context, `narration:prepare:${segment}:${logicalKey}`, "audio", {
30
- ...llmInput(context, preparationMessages(renderedPrompt(context, "narration"), source, aliases, context.config.language, speaker)),
34
+ ...llmInput(context, preparationMessages(note === undefined ? prompt : `${prompt}\n\n${note}`, source, aliases, context.config.language, speaker)),
31
35
  preparation: { format: "inworld-tts-2", version: 1, source, logicalKey, segment },
32
36
  }, dependsOn, { unresolved: source.trim() === "" });
33
37
  if (source.trim() === "")
@@ -4,7 +4,7 @@ import { usesShortMode } from "../admission/short-mode.js";
4
4
  import { masterFile, masterReport } from "../loudness/loudnorm.js";
5
5
  import { masterGoal } from "../loudness/model.js";
6
6
  import { allocateAsset, discardPreparedAssets, sealAsset } from "../storage/assets.js";
7
- import { outputPath, projectDir } from "../storage/layout.js";
7
+ import { outputPath, projectDir, renderCacheDir } from "../storage/layout.js";
8
8
  import { audioExportArgs } from "../video/audio-export.js";
9
9
  import { withPaths } from "../video/edit-list.js";
10
10
  import { runFfmpeg } from "../video/ffmpeg.js";
@@ -188,6 +188,7 @@ export async function executeExportRecipe(deps, context, piece) {
188
188
  cwd: directory,
189
189
  ...(portraits === undefined ? {} : { portraits: portraits.overlays }),
190
190
  scratch: projectDirectory,
191
+ cache: renderCacheDir(deps.paths, context.work.projectId),
191
192
  signal: context.signal,
192
193
  log: deps.log,
193
194
  onProgress,
@@ -55,7 +55,13 @@ export const revisionContentSchema = z
55
55
  .strict()),
56
56
  narrationOverrides: z.record(workKey, z.discriminatedUnion("kind", [
57
57
  z.object({ kind: z.literal("asset"), assetId: id }).strict(),
58
- z.object({ kind: z.literal("text"), text: z.string().trim().min(1).max(500000) }).strict(),
58
+ z
59
+ .object({
60
+ kind: z.literal("text"),
61
+ text: z.string().trim().min(1).max(500000),
62
+ direction: z.string().trim().min(1).max(20000).optional(),
63
+ })
64
+ .strict(),
59
65
  ])),
60
66
  narrationSources: z
61
67
  .record(workKey, z
@@ -1,7 +1,7 @@
1
1
  import { rmSync } from "node:fs";
2
2
  import { derive } from "../../kernel/runner/graph.js";
3
3
  import { stagesOf } from "../admission/repo.js";
4
- import { projectDir } from "./layout.js";
4
+ import { projectDir, renderCacheDir } from "./layout.js";
5
5
  // A run in flight: a call the runner holds, or a stage running or waiting out a retry.
6
6
  export function projectBusy(deps, projectId) {
7
7
  return (deps.hasInflight?.(projectId) === true || derive(stagesOf(deps.db, projectId)) === "running");
@@ -18,6 +18,7 @@ export function deleteProject(deps, projectId) {
18
18
  const dir = projectDir(deps.paths, projectId);
19
19
  try {
20
20
  rmSync(dir, { recursive: true, force: true });
21
+ rmSync(renderCacheDir(deps.paths, projectId), { recursive: true, force: true });
21
22
  }
22
23
  catch (error) {
23
24
  deps.log.write("warn", "project.delete", {
@@ -5,7 +5,7 @@ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:p
5
5
  import { z } from "zod";
6
6
  import { repoint } from "../../kernel/paths.js";
7
7
  import { readSetting, writeSetting } from "../settings/repo.js";
8
- import { backupsFolderName } from "./layout.js";
8
+ import { backupsFolderName, renderCacheFolder } from "./layout.js";
9
9
  // Where the files a user looks at live: project folders, scheduled backups and anything else
10
10
  // Slopify saves for them. New installs keep them in <Documents>/Slopify; installs from before
11
11
  // 3.0 keep them where they were, inside the data dir, until the user moves them from Settings →
@@ -300,7 +300,7 @@ function sourceFiles(pair) {
300
300
  }
301
301
  for (const entry of entries) {
302
302
  const full = join(dir, entry.name);
303
- if (dir === pair.from && entry.name === pair.skip)
303
+ if (dir === pair.from && (entry.name === pair.skip || entry.name === renderCacheFolder))
304
304
  continue;
305
305
  if (entry.isDirectory())
306
306
  walk(full);
@@ -2,6 +2,13 @@ import { extname, isAbsolute, relative, resolve } from "node:path";
2
2
  export function projectDir(paths, projectId) {
3
3
  return contained(paths.projects, projectId);
4
4
  }
5
+ // The clips of each project's last render (`slices/video/clip-cache.ts`). Hidden, so
6
+ // reconcile, backups and the user's file browser leave it alone; disposable, so moving the
7
+ // files leaves it behind and a render simply encodes its clips again.
8
+ export const renderCacheFolder = ".render-cache";
9
+ export function renderCacheDir(paths, projectId) {
10
+ return contained(paths.projects, `${renderCacheFolder}/${projectId}`);
11
+ }
5
12
  export function outputPath(paths, projectId, relativePath) {
6
13
  return contained(projectDir(paths, projectId), relativePath);
7
14
  }
@@ -0,0 +1,118 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants, copyFileSync, linkSync, mkdirSync, readdirSync, renameSync, rmSync, statfsSync, statSync, utimesSync, } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ // The clips of a project's last render, kept so the next render encodes only the clips that
5
+ // changed. A clip is named by what went into its ffmpeg run: the binary, the arguments with
6
+ // its own output left out, every input file as it is on disk, and its chapter-card script and
7
+ // font. Any change to those is another name, so a clip is never reused for a picture it
8
+ // would not have drawn.
9
+ //
10
+ // The folder is one per project under a hidden folder of Projects, the same disk as the
11
+ // render's working folder so a clip is linked, not copied; storage reconcile and backups
12
+ // leave hidden folders alone, and deleting the project removes it.
13
+ const version = "clip-cache-v1";
14
+ const outputMark = "\u0000output";
15
+ // ceiling: the caches of every project together. A 2.5-hour video with transitions keeps
16
+ // about 10 GB of clips, so this holds the last few projects rendered.
17
+ const mostBytes = 30 * 1024 ** 3;
18
+ // floor: the free space a disk keeps before the caches give way; a tenth of a small disk.
19
+ const leastFree = 20 * 1024 ** 3;
20
+ const leastFreeShare = 0.1;
21
+ export function cacheKey(bin, args, output, extra = []) {
22
+ const inputs = [];
23
+ args.forEach((value, at) => {
24
+ if (args[at - 1] === "-i")
25
+ inputs.push(fileIdentity(value));
26
+ });
27
+ const named = args.map((value) => (value === output ? outputMark : value));
28
+ return createHash("sha256")
29
+ .update(JSON.stringify([version, bin, named, inputs, extra]))
30
+ .digest("hex");
31
+ }
32
+ // A file as a key part: its path, size and last change. lavfi sources and other inputs that
33
+ // are not files are keyed by their text alone.
34
+ export function fileIdentity(path) {
35
+ const stat = statSync(path, { throwIfNoEntry: false });
36
+ return stat === undefined ? path : `${path}|${String(stat.size)}|${String(stat.mtimeMs)}`;
37
+ }
38
+ // Puts the cached clip at `target`; false when there is none.
39
+ export function reuseClip(dir, key, target) {
40
+ const cached = join(dir, `${key}.mp4`);
41
+ if (statSync(cached, { throwIfNoEntry: false })?.isFile() !== true)
42
+ return false;
43
+ place(cached, target);
44
+ return true;
45
+ }
46
+ // Keeps a finished clip under its key, whole or not at all.
47
+ export function keepClip(dir, key, source) {
48
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
49
+ const pending = join(dir, `${key}.${String(process.pid)}.part`);
50
+ rmSync(pending, { force: true });
51
+ place(source, pending);
52
+ renameSync(pending, join(dir, `${key}.mp4`));
53
+ }
54
+ // After a finished render: the project keeps only that render's clips, and the other
55
+ // projects' caches give way, least recently rendered first, until all of them fit under the
56
+ // ceiling and the disk keeps its floor.
57
+ export function pruneClips(dir, used, limits = { mostBytes }) {
58
+ for (const name of list(dir)) {
59
+ if (!used.has(name.replace(/\.mp4$/, "")))
60
+ rmSync(join(dir, name), { force: true });
61
+ }
62
+ const root = dirname(dir);
63
+ const now = new Date();
64
+ if (list(dir).length > 0)
65
+ utimesSync(dir, now, now);
66
+ const others = list(root)
67
+ .map((name) => join(root, name))
68
+ .filter((path) => path !== dir && statSync(path, { throwIfNoEntry: false })?.isDirectory())
69
+ .map((path) => ({ path, at: statSync(path).mtimeMs, bytes: bytesOf(path) }))
70
+ .toSorted((a, b) => a.at - b.at);
71
+ const floor = limits.leastFree ?? Math.min(leastFree, diskBytes(root) * leastFreeShare);
72
+ let total = bytesOf(dir) + others.reduce((sum, one) => sum + one.bytes, 0);
73
+ for (const other of others) {
74
+ if (total <= limits.mostBytes && freeBytes(root) >= floor)
75
+ break;
76
+ rmSync(other.path, { recursive: true, force: true });
77
+ total -= other.bytes;
78
+ }
79
+ if (freeBytes(root) < floor)
80
+ rmSync(dir, { recursive: true, force: true });
81
+ }
82
+ function place(from, to) {
83
+ try {
84
+ linkSync(from, to);
85
+ }
86
+ catch {
87
+ copyFileSync(from, to, constants.COPYFILE_FICLONE);
88
+ }
89
+ }
90
+ function list(dir) {
91
+ try {
92
+ return readdirSync(dir);
93
+ }
94
+ catch {
95
+ return [];
96
+ }
97
+ }
98
+ function bytesOf(dir) {
99
+ return list(dir).reduce((sum, name) => sum + (statSync(join(dir, name), { throwIfNoEntry: false })?.size ?? 0), 0);
100
+ }
101
+ function diskBytes(path) {
102
+ try {
103
+ const stats = statfsSync(path);
104
+ return stats.blocks * stats.bsize;
105
+ }
106
+ catch {
107
+ return Number.POSITIVE_INFINITY;
108
+ }
109
+ }
110
+ function freeBytes(path) {
111
+ try {
112
+ const stats = statfsSync(path);
113
+ return stats.bavail * stats.bsize;
114
+ }
115
+ catch {
116
+ return Number.POSITIVE_INFINITY;
117
+ }
118
+ }
@@ -240,9 +240,9 @@ function audioMix(edit, first) {
240
240
  }
241
241
  return { inputs, chains };
242
242
  }
243
- // The video's sound alone, as a WAV: what Level the volume masters before the join plays it
244
- // (`slideshow.ts`).
245
- export function audioMixArgs(edit, output) {
243
+ // The video's sound alone: as a WAV, what Level the volume masters, or as AAC, the finished
244
+ // sound the join copies in (`slideshow.ts`, which makes it while the picture renders).
245
+ export function audioMixArgs(edit, output, codec = "wav") {
246
246
  const mix = audioMix(edit, 0);
247
247
  return [
248
248
  ...progressArgs,
@@ -251,51 +251,77 @@ export function audioMixArgs(edit, output) {
251
251
  mix.chains.join(";"),
252
252
  "-map",
253
253
  "[a]",
254
- "-c:a",
255
- "pcm_s16le",
256
- "-f",
257
- "wav",
254
+ ...(codec === "wav" ? ["-c:a", "pcm_s16le", "-f", "wav"] : ["-c:a", "aac", "-f", "mp4"]),
258
255
  output,
259
256
  ];
260
257
  }
261
- export function joinArgs(edit, output, list, burnSubtitles = false, portraits = []) {
262
- const mix = audioMix(edit, 1);
258
+ // `sound`, when given, is the finished AAC (`audioMixArgs`), copied in; without it the join
259
+ // mixes and encodes the edit list's audio itself.
260
+ export function joinArgs(edit, output, list, burnSubtitles = false, portraits = [], sound) {
261
+ const mix = sound === undefined ? audioMix(edit, 1) : { inputs: ["-i", sound], chains: [] };
263
262
  const inputs = ["-f", "concat", "-i", list, ...mix.inputs];
264
- const chains = [];
265
- const overlaid = burnSubtitles && portraits.length > 0;
266
- if (burnSubtitles && !overlaid)
267
- chains.push("[0:v]ass=filename=subtitles.ass:fontsdir=fonts[v]");
263
+ const chains = burnSubtitles ? burnIn(inputs, portraits) : [];
268
264
  chains.push(...mix.chains);
269
- // The portraits go in after every other input and under the captions, so the panel's lit
270
- // outline is drawn over them; a video without any is joined exactly as before. A still
271
- // image is one frame, which overlay holds to the end (`eof_action=repeat`).
272
- if (overlaid) {
273
- let first = inputs.filter((value) => value === "-i").length;
274
- let source = "[0:v]";
275
- const video = [];
276
- portraits.forEach((portrait, at) => {
277
- inputs.push("-i", portrait.path);
278
- const size = String(portrait.size);
279
- video.push(`[${String(first)}:v]scale=${size}:${size}:force_original_aspect_ratio=increase,crop=${size}:${size},setsar=1[pic${String(at)}]`, `${source}[pic${String(at)}]overlay=x=${String(portrait.x)}:y=${String(portrait.y)}:eof_action=repeat[panel${String(at)}]`);
280
- source = `[panel${String(at)}]`;
281
- first += 1;
282
- });
283
- chains.unshift(...video, `${source}ass=filename=subtitles.ass:fontsdir=fonts[v]`);
284
- }
265
+ const audio = sound !== undefined || edit.audio.length > 0;
285
266
  return [
286
267
  ...progressArgs,
287
268
  ...inputs,
288
269
  ...(chains.length > 0 ? ["-filter_complex", chains.join(";")] : []),
289
270
  "-map",
290
271
  burnSubtitles ? "[v]" : "0:v",
291
- ...(edit.audio.length > 0 ? ["-map", "[a]"] : []),
272
+ ...(audio ? ["-map", sound === undefined ? "[a]" : "1:a"] : []),
292
273
  ...(burnSubtitles ? [...videoCodec, ...lookEncoding(edit.look)] : ["-c:v", "copy"]),
293
- ...(edit.audio.length > 0 ? ["-c:a", "aac"] : ["-an"]),
274
+ ...(audio ? ["-c:a", sound === undefined ? "aac" : "copy"] : ["-an"]),
294
275
  "-movflags",
295
276
  "+faststart",
296
277
  output,
297
278
  ];
298
279
  }
280
+ // One part of a long video's picture with its captions burned in, from frame `start` of the
281
+ // video (`slideshow.ts` encodes the parts side by side, then joins them by copying). The part's
282
+ // frames are moved to their place in the video for the captions and back to zero after them.
283
+ export function burnPartArgs(edit, list, output, start, portraits = []) {
284
+ const inputs = ["-f", "concat", "-i", list];
285
+ const shifted = start === 0
286
+ ? { before: "", after: "" }
287
+ : {
288
+ before: `setpts=PTS+${String(start)}/(${String(edit.fps)}*TB),`,
289
+ // Moving the timestamps drops the declared rate, and without one the output falls
290
+ // back to 25 fps and drops frames.
291
+ after: `,setpts=PTS-STARTPTS,fps=${String(edit.fps)}`,
292
+ };
293
+ const chains = burnIn(inputs, portraits, shifted.before, shifted.after);
294
+ return [
295
+ ...progressArgs,
296
+ ...inputs,
297
+ "-filter_complex",
298
+ chains.join(";"),
299
+ "-map",
300
+ "[v]",
301
+ ...videoCodec,
302
+ ...lookEncoding(edit.look),
303
+ "-an",
304
+ output,
305
+ ];
306
+ }
307
+ // The captions over the picture in input 0, into `[v]`. The portraits go in after every other
308
+ // input and under the captions, so the panel's lit outline is drawn over them; a video without
309
+ // any is burned exactly as before. A still image is one frame, which overlay holds to the end
310
+ // (`eof_action=repeat`).
311
+ function burnIn(inputs, portraits, before = "", after = "") {
312
+ let first = inputs.filter((value) => value === "-i").length;
313
+ let source = "[0:v]";
314
+ const video = [];
315
+ portraits.forEach((portrait, at) => {
316
+ inputs.push("-i", portrait.path);
317
+ const size = String(portrait.size);
318
+ video.push(`[${String(first)}:v]scale=${size}:${size}:force_original_aspect_ratio=increase,crop=${size}:${size},setsar=1[pic${String(at)}]`, `${source}[pic${String(at)}]overlay=x=${String(portrait.x)}:y=${String(portrait.y)}:eof_action=repeat[panel${String(at)}]`);
319
+ source = `[panel${String(at)}]`;
320
+ first += 1;
321
+ });
322
+ video.push(`${source}${before}ass=filename=subtitles.ass:fontsdir=fonts${after}[v]`);
323
+ return video;
324
+ }
299
325
  const progressArgs = [
300
326
  "-hide_banner",
301
327
  "-nostdin",
@@ -0,0 +1,56 @@
1
+ import { availableParallelism, totalmem } from "node:os";
2
+ // zoompan runs on one core, so a render of one clip at a time leaves most of the machine
3
+ // idle. Measured on 32 cores with ffmpeg 7 and 15-second clips from a 4x still: one at a time
4
+ // 8.1 s a clip, four at once 2.9 s, eight 1.9 s, sixteen 1.6 s. Eight is where the gain
5
+ // flattens, and each run holds about 1.4 GB (a transition, two stills, twice that), so the
6
+ // memory the process may use caps it as well.
7
+ const mostJobs = 8;
8
+ const coresPerJob = 4;
9
+ const bytesPerJob = 3 * 1024 ** 3;
10
+ export function renderJobs(cores = availableParallelism(), memory = usableMemory()) {
11
+ return Math.max(1, Math.min(mostJobs, Math.floor(cores / coresPerJob), Math.floor(memory / 2 / bytesPerJob)));
12
+ }
13
+ // A container's memory limit, when it has one below the machine's.
14
+ function usableMemory() {
15
+ const limit = process.constrainedMemory();
16
+ const total = totalmem();
17
+ return limit > 0 && limit < total ? limit : total;
18
+ }
19
+ // Runs `work` over every item, at most `limit` at once, in the items' order. The first failure
20
+ // stops the rest: nothing new starts, the runs already going are aborted through the signal
21
+ // each was given, and once they have all ended the first failure is the one thrown, not the
22
+ // cancellations it caused.
23
+ export async function inPool(items, limit, signal, work) {
24
+ if (signal.aborted)
25
+ throw new Error("the render was canceled before it started");
26
+ const stop = new AbortController();
27
+ const follow = () => {
28
+ stop.abort();
29
+ };
30
+ signal.addEventListener("abort", follow, { once: true });
31
+ let next = 0;
32
+ let failed;
33
+ const runner = async () => {
34
+ while (failed === undefined && !stop.signal.aborted && next < items.length) {
35
+ const at = next;
36
+ next += 1;
37
+ try {
38
+ await work(items[at], stop.signal);
39
+ }
40
+ catch (error) {
41
+ failed ??= { error };
42
+ stop.abort();
43
+ }
44
+ }
45
+ };
46
+ try {
47
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, runner));
48
+ }
49
+ finally {
50
+ signal.removeEventListener("abort", follow);
51
+ }
52
+ if (failed !== undefined)
53
+ throw failed.error;
54
+ if (signal.aborted)
55
+ throw new Error("the render was canceled");
56
+ }