@gentbajko/slopify 3.1.0 → 3.2.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/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/patch-notes/3.1.1.md +18 -0
- package/dist/patch-notes/3.2.0.md +24 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/rebuild/recipe-appearance.js +4 -1
- package/dist/slices/rebuild/recipe-build.js +1 -1
- package/dist/slices/rebuild/recipe-shorts.js +22 -3
- package/dist/slices/rebuild/runtime-export.js +2 -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/tutorials/Play-Images.md +1 -1
- package/dist/tutorials/Prompts.md +25 -1
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Slopify 3.1.1
|
|
2
|
+
|
|
3
|
+
Released 30 September 2026.
|
|
4
|
+
|
|
5
|
+
3.1.1 lets the shorts draw figures the way the video does.
|
|
6
|
+
|
|
7
|
+
## Shorts
|
|
8
|
+
|
|
9
|
+
- The shorts' image prompt (**Shorts → Image prompt**) takes `{{Appearance}}`, like the video's image and thumbnail prompts. Each short gets the subject's researched look and those of the figures its clip names, so a figure looks the same in the shorts as in the video.
|
|
10
|
+
- A `{{Scene}}` line in the shorts' image prompt is left out: each short's images already show what is said at that moment.
|
|
11
|
+
|
|
12
|
+
## Help
|
|
13
|
+
|
|
14
|
+
- The **Prompts** page of the wiki explains the two keywords Slopify fills itself, `{{Scene}}` and `{{Appearance}}`: what each becomes, where it works, and an example.
|
|
15
|
+
|
|
16
|
+
## Upgrading from 3.1.0
|
|
17
|
+
|
|
18
|
+
- **Nothing becomes outdated.** Only a shorts image prompt that names `{{Appearance}}` changes, the next time its shorts are made.
|
|
@@ -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.
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "3.2.0",
|
|
4
|
+
"title": "Slopify 3.2.0",
|
|
5
|
+
"version": "3.2.0",
|
|
6
|
+
"date": "2026-09-30"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "3.1.1",
|
|
10
|
+
"title": "Slopify 3.1.1",
|
|
11
|
+
"version": "3.1.1",
|
|
12
|
+
"date": "2026-09-30"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"id": "3.1.0",
|
|
4
16
|
"title": "Slopify 3.1.0",
|
|
@@ -4,7 +4,7 @@ import { recipe, selectedReference, } from "./recipe-model.js";
|
|
|
4
4
|
import { llmInput, llmInputFingerprint } from "./recipe-text.js";
|
|
5
5
|
export const imageAppearanceKey = "images:appearance";
|
|
6
6
|
// The prompt bodies a run draws pictures from: the generated images', the thumbnail's when it
|
|
7
|
-
// is drawn from a Library prompt,
|
|
7
|
+
// is drawn from a Library prompt, the establishing image's and the shorts' image prompt.
|
|
8
8
|
function pictureBodies(context) {
|
|
9
9
|
const { config, content } = context;
|
|
10
10
|
const images = config.sources.images === "generate"
|
|
@@ -27,6 +27,9 @@ function pictureBodies(context) {
|
|
|
27
27
|
...(config.sources.images === "generate" && config.reference?.source === "prompt"
|
|
28
28
|
? [content.promptTemplates.referencePrompt ?? config.rendered.referencePrompt]
|
|
29
29
|
: []),
|
|
30
|
+
...(config.shorts?.enabled === true && config.shorts.imagePrompt?.trim()
|
|
31
|
+
? [content.promptTemplates.shortsImage ?? config.rendered.shortsImage]
|
|
32
|
+
: []),
|
|
30
33
|
];
|
|
31
34
|
}
|
|
32
35
|
export function imageAppearance(context, text) {
|
|
@@ -43,7 +43,7 @@ export function buildRecipes(context) {
|
|
|
43
43
|
...exports,
|
|
44
44
|
...youtube,
|
|
45
45
|
...(reference === undefined ? [] : [reference]),
|
|
46
|
-
...shortsRecipes(context, exports, drawnFrom, audio.cards ?? [], masterPlan(context, audio.levels, "video")),
|
|
46
|
+
...shortsRecipes(context, exports, drawnFrom, audio.cards ?? [], masterPlan(context, audio.levels, "video"), appearance),
|
|
47
47
|
...thumbnail,
|
|
48
48
|
...documentRecipes(context, text, thumbnail),
|
|
49
49
|
...visualAssets(context, visualRecipes(context.config, context.content, audio.mediaFingerprint, captions?.fingerprint ?? null, (images) => editPlan(context, exports, youtube, images, audio.cards ?? []), drawnFrom, timing === undefined ? null : resourceIdentity(context, timing), withLines(masterPlan(context, audio.levels, "video"), linePlan(context, timing)), scenes, appearance)),
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { fingerprint } from "../../kernel/runner/work.js";
|
|
3
3
|
import { usesShorts } from "../admission/rules.js";
|
|
4
|
+
import { withoutScene } from "../images/scenes.js";
|
|
4
5
|
import { effectiveClips, pickedShortsOf } from "../shorts/clips.js";
|
|
5
6
|
import { defaultShortsImagePrompt, musicVolumeOf, shortImageCount, shortsSpeedOf, } from "../shorts/model.js";
|
|
6
7
|
import { imagePromptMessages } from "../shorts/prompts.js";
|
|
8
|
+
import { lookWait, withLooks } from "./recipe-appearance.js";
|
|
7
9
|
import { castFor } from "./recipe-cast.js";
|
|
8
10
|
import { noMaster } from "./recipe-loudness.js";
|
|
9
11
|
import { recipe, resourceIdentity, selectedReference, } from "./recipe-model.js";
|
|
@@ -28,7 +30,9 @@ reference,
|
|
|
28
30
|
// "Show tables and figures on screen": the cards a short shows where it describes one.
|
|
29
31
|
cards = [],
|
|
30
32
|
// Level the volume: the levelled narration and the master (`recipe-loudness.ts`).
|
|
31
|
-
master = noMaster
|
|
33
|
+
master = noMaster,
|
|
34
|
+
// How the figures look, for a shorts image prompt with `{{Appearance}}` (`recipe-appearance.ts`).
|
|
35
|
+
appearance) {
|
|
32
36
|
const { config } = context;
|
|
33
37
|
const shorts = config.shorts;
|
|
34
38
|
const timing = exports.find((value) => value.key === "subtitles:timing");
|
|
@@ -58,8 +62,10 @@ master = noMaster) {
|
|
|
58
62
|
{ kind: "provider" });
|
|
59
63
|
const picked = savedPicked(context, pick);
|
|
60
64
|
const images = config.images ?? { provider: "", model: "" };
|
|
65
|
+
// A shorts image prompt may name `{{Scene}}` like the video's: the prompts call already writes
|
|
66
|
+
// each image's moment from what the clip says, so that line is left out here.
|
|
61
67
|
const style = shorts.imagePrompt?.trim()
|
|
62
|
-
? renderedPrompt(context, "shortsImage")
|
|
68
|
+
? withoutScene(renderedPrompt(context, "shortsImage"))
|
|
63
69
|
: defaultShortsImagePrompt;
|
|
64
70
|
if (picked === undefined)
|
|
65
71
|
return [
|
|
@@ -91,9 +97,22 @@ master = noMaster) {
|
|
|
91
97
|
const prefix = `shorts:${String(clip.number)}`;
|
|
92
98
|
const token = clipToken(context, clip);
|
|
93
99
|
const count = shortImageCount(clip.end - clip.start, config.imageSeconds);
|
|
100
|
+
// The looks of the figures the clip names, and always the subject's: a short stands for
|
|
101
|
+
// the video. Until they are looked up the clip's prompts wait for them.
|
|
102
|
+
const looked = withLooks(style, appearance, clip.text, { subjectAlways: true });
|
|
103
|
+
if (looked === null) {
|
|
104
|
+
const wait = lookWait(style, appearance);
|
|
105
|
+
recipes.push(recipe(context, `${prefix}:prompts`, "video", {
|
|
106
|
+
kind: "deferred",
|
|
107
|
+
version: 1,
|
|
108
|
+
operation: "shorts",
|
|
109
|
+
template: [...wait.template, style, clip.text, count],
|
|
110
|
+
}, [pick.key, ...wait.dependsOn], { token }));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
94
113
|
const prompts = {
|
|
95
114
|
...recipe(context, `${prefix}:prompts`, "video", llmInput(context, imagePromptMessages({
|
|
96
|
-
style,
|
|
115
|
+
style: looked,
|
|
97
116
|
videoTitle: config.title,
|
|
98
117
|
shortTitle: clip.title,
|
|
99
118
|
text: clip.text,
|
|
@@ -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,
|
|
@@ -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
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
272
|
+
...(audio ? ["-map", sound === undefined ? "[a]" : "1:a"] : []),
|
|
292
273
|
...(burnSubtitles ? [...videoCodec, ...lookEncoding(edit.look)] : ["-c:v", "copy"]),
|
|
293
|
-
...(
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -124,7 +124,7 @@ The style, composition and everything else in the prompt stay as written; only t
|
|
|
124
124
|
|
|
125
125
|
## Looks looked up: {{Appearance}}
|
|
126
126
|
|
|
127
|
-
An image model draws a named figure from whatever it half remembers, so the same character can look different in every image. Put `{{Appearance}}` in an image, thumbnail or
|
|
127
|
+
An image model draws a named figure from whatever it half remembers, so the same character can look different in every image. Put `{{Appearance}}` in an image, thumbnail, establishing or shorts image prompt, for example a line `Looks: {{Appearance}}`, and Slopify looks the looks up instead.
|
|
128
128
|
|
|
129
129
|
- Once the article exists, one call to the project's AI model **searches the web** for how the video's subject and every named character in the article look: build, face, clothing, colours and the marks that make them recognisable. Where depictions disagree, it describes the most iconic one.
|
|
130
130
|
- With **Scenes from the article** on, each picture gets the looks of the figures its scene names (up to three besides the subject), so a scene of a place or of someone else isn't redrawn with the subject in it; a scene that names no one leaves the looks line out. A thumbnail always gets the subject's look. Without scenes, every picture gets the subject's look.
|
|
@@ -50,7 +50,7 @@ When the body already has text, Slopify asks "Replace this prompt body?" first.
|
|
|
50
50
|
|
|
51
51
|
## Keywords (slots)
|
|
52
52
|
|
|
53
|
-
Every `{{keyword}}` in the body becomes one field on Play.
|
|
53
|
+
Every `{{keyword}}` in the body becomes one field on Play, except the two Slopify fills itself (below).
|
|
54
54
|
|
|
55
55
|
- You type a keyword's value once per video, and it is filled into every prompt, intro and outro that names it.
|
|
56
56
|
- Names are case-sensitive: `{{Topic}}` and `{{topic}}` are two fields.
|
|
@@ -63,6 +63,30 @@ Mistakes the editor refuses, with the line and column:
|
|
|
63
63
|
- a keyword with no name,
|
|
64
64
|
- a keyword placed inside another keyword.
|
|
65
65
|
|
|
66
|
+
### Keywords Slopify fills: {{Scene}} and {{Appearance}}
|
|
67
|
+
|
|
68
|
+
Two keywords never become fields. The project's AI model writes them during the run, once the article exists, and the picture waits for them:
|
|
69
|
+
|
|
70
|
+
| Keyword | Filled with | Works in | Needs |
|
|
71
|
+
| --- | --- | --- | --- |
|
|
72
|
+
| `{{Scene}}` | This picture's moment from the article: for images, in order from the article's opening to its ending; for thumbnails, its most striking moment, a different one for each of three. | Image and thumbnail prompts | Images → **Scenes from the article** on |
|
|
73
|
+
| `{{Appearance}}` | How the figures look, looked up on the web (most iconic depiction): the ones the picture's scene names, or the video's subject; a thumbnail or a short always gets the subject. | Image, thumbnail, establishing and shorts image prompts | Nothing: the keyword turns it on |
|
|
74
|
+
|
|
75
|
+
Put each on a line of its own with a label, for example:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
Scene: {{Scene}}
|
|
79
|
+
|
|
80
|
+
Looks: {{Appearance}}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- An image prompt without `{{Scene}}` still gets its scene, after its first paragraph, while Scenes from the article is on. A thumbnail prompt without it gets none.
|
|
84
|
+
- With Scenes from the article off, a line holding `{{Scene}}` is left out. A `{{Appearance}}` line whose scene names no one is left out too.
|
|
85
|
+
- Each costs one more AI call per video (the looks call searches the web).
|
|
86
|
+
- In the shorts' image prompt (**Shorts → Image prompt**), `{{Appearance}}` gives each short the subject's look and those of the figures its clip names. A `{{Scene}}` line is left out there: each short's images already show what is said at that moment.
|
|
87
|
+
|
|
88
|
+
See [Play Images](Play-Images#scenes-from-the-article) for how the scenes and looks are chosen.
|
|
89
|
+
|
|
66
90
|
## Mark an image prompt as photorealistic
|
|
67
91
|
|
|
68
92
|
YouTube asks whether a video contains realistic-looking AI scenes. Slopify answers for you from your marks.
|