@gentbajko/slopify 2.3.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter-registry.js +9 -2
- package/dist/adapters/host-cli/index.js +11 -0
- package/dist/adapters/host-cli/transport.js +6 -1
- package/dist/adapters/image/codex-output.js +59 -17
- package/dist/adapters/image/codex.js +92 -16
- package/dist/adapters/image/fal.js +99 -8
- package/dist/adapters/image/google.js +13 -1
- package/dist/adapters/image/openai.js +37 -15
- package/dist/adapters/image/reference.js +7 -0
- package/dist/adapters/image/replicate.js +48 -2
- package/dist/adapters/image/video.js +28 -0
- package/dist/adapters/llm/codex-models.js +2 -1
- package/dist/adapters/llm/codex-server-models.js +2 -1
- package/dist/adapters/llm/run-cli.js +14 -2
- package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
- package/dist/assets/fonts/SOURCE.txt +6 -0
- package/dist/assets/models.yaml +89 -1
- package/dist/catalog/registry.js +24 -3
- package/dist/catalog/runtime-models.js +1 -1
- package/dist/catalog/schema.js +20 -1
- package/dist/catalog/store.js +2 -2
- package/dist/catalog/validate.js +47 -4
- package/dist/edge/docker-launch.js +4 -0
- package/dist/edge/docker-projects/engine.js +57 -1
- package/dist/edge/docker-projects/install.js +10 -2
- package/dist/edge/docker-projects/prune.js +66 -0
- package/dist/edge/events/hub.js +11 -0
- package/dist/edge/http/app.js +2 -0
- package/dist/edge/http/backups.js +40 -0
- package/dist/edge/http/host-cli.js +17 -2
- package/dist/edge/http/problem.js +1 -0
- package/dist/edge/http/providers.js +8 -0
- package/dist/edge/http/schedules.js +1 -1
- package/dist/edge/http/settings.js +44 -0
- package/dist/edge/http/timeouts.js +38 -0
- package/dist/host-cli/runtime.js +3 -2
- package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
- package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
- package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
- package/dist/kernel/ports/host-cli.js +17 -2
- package/dist/kernel/ports/image.js +3 -1
- package/dist/kernel/ports/llm.js +3 -1
- package/dist/kernel/runner/attempt.js +5 -2
- package/dist/kernel/runner/providers.js +42 -2
- package/dist/main.js +55 -7
- package/dist/slices/admission/model.js +8 -0
- package/dist/slices/admission/rules.js +91 -2
- package/dist/slices/admission/schema.js +42 -1
- package/dist/slices/admission/start.js +53 -15
- package/dist/slices/backups/files.js +135 -0
- package/dist/slices/backups/folder.js +35 -0
- package/dist/slices/backups/model.js +65 -0
- package/dist/slices/backups/repo.js +28 -0
- package/dist/slices/backups/schedule.js +60 -0
- package/dist/slices/backups/service.js +311 -0
- package/dist/slices/batch/index.js +2 -0
- package/dist/slices/estimate/index.js +65 -2
- package/dist/slices/fonts/catalog.js +23 -0
- package/dist/slices/fonts/index.js +1 -1
- package/dist/slices/library/model.js +8 -1
- package/dist/slices/library/slots.js +13 -2
- package/dist/slices/library/snapshot.js +8 -2
- package/dist/slices/notifications/notifier.js +76 -0
- package/dist/slices/notifications/rules.js +81 -0
- package/dist/slices/notifications/send.js +40 -0
- package/dist/slices/notifications/settings.js +34 -0
- package/dist/slices/play-drafts/convert.js +72 -4
- package/dist/slices/play-drafts/readiness.js +7 -3
- package/dist/slices/play-drafts/repo.js +7 -1
- package/dist/slices/play-drafts/review-inputs.js +2 -0
- package/dist/slices/play-drafts/schema.js +44 -3
- package/dist/slices/play-drafts/service.js +6 -0
- package/dist/slices/play-drafts/uploads.js +2 -1
- package/dist/slices/project-templates/from-project.js +42 -0
- package/dist/slices/project-templates/setup.js +13 -0
- package/dist/slices/rebuild/preview-details.js +53 -3
- package/dist/slices/rebuild/preview-plan.js +2 -2
- package/dist/slices/rebuild/recipe-build.js +13 -3
- package/dist/slices/rebuild/recipe-edit.js +178 -0
- package/dist/slices/rebuild/recipe-exports.js +9 -4
- package/dist/slices/rebuild/recipe-input-schema.js +9 -1
- package/dist/slices/rebuild/recipe-legacy.js +12 -0
- package/dist/slices/rebuild/recipe-model.js +7 -1
- package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
- package/dist/slices/rebuild/recipe-reference.js +44 -0
- package/dist/slices/rebuild/recipe-save.js +25 -1
- package/dist/slices/rebuild/recipe-shorts.js +186 -0
- package/dist/slices/rebuild/recipe-validation.js +33 -2
- package/dist/slices/rebuild/recipe-visual.js +29 -8
- package/dist/slices/rebuild/recipe-work.js +83 -1
- package/dist/slices/rebuild/runtime-actions.js +9 -2
- package/dist/slices/rebuild/runtime-animate.js +110 -0
- package/dist/slices/rebuild/runtime-export-edit.js +119 -0
- package/dist/slices/rebuild/runtime-export.js +13 -1
- package/dist/slices/rebuild/runtime-image.js +50 -0
- package/dist/slices/rebuild/runtime-local.js +6 -3
- package/dist/slices/rebuild/runtime-materialize.js +48 -0
- package/dist/slices/rebuild/runtime-piece-label.js +17 -0
- package/dist/slices/rebuild/runtime-provider.js +17 -10
- package/dist/slices/rebuild/runtime-publication.js +25 -4
- package/dist/slices/rebuild/runtime-run.js +11 -5
- package/dist/slices/rebuild/runtime-shorts.js +328 -0
- package/dist/slices/rebuild/runtime-store.js +7 -3
- package/dist/slices/rebuild/service-readiness.js +15 -1
- package/dist/slices/rebuild/transition-repo.js +40 -1
- package/dist/slices/revisions/adopt-content.js +7 -0
- package/dist/slices/revisions/adopt.js +7 -2
- package/dist/slices/revisions/mutation-assets.js +51 -9
- package/dist/slices/revisions/mutation-prepare.js +14 -14
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/publish.js +20 -2
- package/dist/slices/revisions/schema.js +16 -1
- package/dist/slices/schedules/repo.js +1 -1
- package/dist/slices/schedules/service.js +6 -1
- package/dist/slices/settings/model.js +5 -0
- package/dist/slices/shorts/captions.js +272 -0
- package/dist/slices/shorts/clips.js +89 -0
- package/dist/slices/shorts/model.js +186 -0
- package/dist/slices/shorts/pick.js +232 -0
- package/dist/slices/shorts/prompts.js +68 -0
- package/dist/slices/shorts/render.js +163 -0
- package/dist/slices/storage/asset-name.js +9 -2
- package/dist/slices/storage/layout.js +18 -0
- package/dist/slices/storage/model.js +9 -0
- package/dist/slices/storage/portable.js +6 -3
- package/dist/slices/storage/reconcile.js +5 -1
- package/dist/slices/storage/schema.js +3 -0
- package/dist/slices/telemetry/model.js +3 -0
- package/dist/slices/video/cards.js +72 -0
- package/dist/slices/video/chapters.js +89 -0
- package/dist/slices/video/cuts.js +141 -0
- package/dist/slices/video/edit-list.js +36 -6
- package/dist/slices/video/edit-settings.js +191 -0
- package/dist/slices/video/ffmpeg.js +155 -29
- package/dist/slices/video/look.js +164 -0
- package/dist/slices/video/plan.js +68 -11
- package/dist/slices/video/slideshow.js +22 -6
- package/dist/slices/video/transitions.js +51 -0
- package/dist/slices/youtube/transcript.js +54 -0
- package/dist/web/assets/index-CktpxlJd.js +171 -0
- package/dist/web/assets/index-DB-vOnGU.css +1 -0
- package/dist/web/assets/{pdf-DR97Byps.js → pdf-BLF2i8c2.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DfXmBzCk.js +0 -168
- package/dist/web/assets/index-Y3RhfTEh.css +0 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// How the Video stage edits the slideshow beyond seconds per image, zoom and motion: where the
|
|
2
|
+
// cuts fall, how one shot hands over to the next, the Look (vignette, grain, colour, an
|
|
3
|
+
// atmosphere overlay and chapter cards), and which images come to life as short clips.
|
|
4
|
+
// Browser-safe: Play, Edit project, the review summary and the change list read these names,
|
|
5
|
+
// labels and limits too.
|
|
6
|
+
//
|
|
7
|
+
// A project saved before this existed has no settings at all, and absent reads as
|
|
8
|
+
// `legacyVideoEdit`: cuts every N seconds, hard cuts, no Look, nothing animated. That is what
|
|
9
|
+
// every video was, and the render recipe leaves every one of those values out of its
|
|
10
|
+
// fingerprint, so no finished video turns outdated. A new project starts from
|
|
11
|
+
// `defaultVideoEdit`, which follows the narration.
|
|
12
|
+
export const cutModes = ["interval", "narration"];
|
|
13
|
+
export const transitionKinds = ["cut", "crossfade", "fadeblack", "slide", "wipe"];
|
|
14
|
+
export const lookLevels = ["off", "subtle", "strong"];
|
|
15
|
+
export const colorGrades = ["none", "warm", "cold", "desaturated", "sepia"];
|
|
16
|
+
// Drawn procedurally by ffmpeg's own filters (`look.ts`); no third-party footage.
|
|
17
|
+
export const atmospheres = ["none", "embers", "dust", "fog"];
|
|
18
|
+
export const animateModes = ["off", "chapters", "every"];
|
|
19
|
+
export const legacyVideoEdit = {
|
|
20
|
+
cuts: "interval",
|
|
21
|
+
transition: "cut",
|
|
22
|
+
transitionSeconds: 0.6,
|
|
23
|
+
vignette: "off",
|
|
24
|
+
grain: "off",
|
|
25
|
+
grade: "none",
|
|
26
|
+
atmosphere: "none",
|
|
27
|
+
chapterCards: false,
|
|
28
|
+
animate: "off",
|
|
29
|
+
animateEvery: 3,
|
|
30
|
+
animateModel: "",
|
|
31
|
+
};
|
|
32
|
+
// What a new project starts with: the owner's pick is to follow the narration.
|
|
33
|
+
export const defaultVideoEdit = { ...legacyVideoEdit, cuts: "narration" };
|
|
34
|
+
// ceiling: under 0.2 s a transition is a flash rather than a move; over 2 s it eats a short
|
|
35
|
+
// shot. The steps are the ones Play and Edit project offer.
|
|
36
|
+
export const transitionSecondsMin = 0.2;
|
|
37
|
+
export const transitionSecondsMax = 2;
|
|
38
|
+
export const transitionSecondsSteps = [0.2, 0.4, 0.6, 0.8, 1, 1.2, 1.5, 2];
|
|
39
|
+
export const animateEveryMin = 2;
|
|
40
|
+
export const animateEveryMax = 10;
|
|
41
|
+
// Image-to-video models make 5-second clips at their cheapest; a longer shot slows or loops
|
|
42
|
+
// the clip (`video/clip-timing.ts`).
|
|
43
|
+
export const animatedClipSeconds = 5;
|
|
44
|
+
// What an image is animated with: keep the picture, move a little. Image-to-video models
|
|
45
|
+
// invent the most when told the least.
|
|
46
|
+
export const animatePrompt = "Subtle cinematic motion: a slow, steady camera push-in with gentle ambient movement in the scene. Keep the composition, colours, lighting and style of the image exactly. No new objects, no text, no cuts.";
|
|
47
|
+
// A cut never lands closer than this share of Seconds per image to the one before, unless a
|
|
48
|
+
// chapter starts there.
|
|
49
|
+
export const cutFloorShare = 0.4;
|
|
50
|
+
// How long a chapter card stays on screen, fades included.
|
|
51
|
+
export const chapterCardSeconds = 2.5;
|
|
52
|
+
export const cutModeLabels = {
|
|
53
|
+
interval: "Every N seconds",
|
|
54
|
+
narration: "Follow the narration",
|
|
55
|
+
};
|
|
56
|
+
export const transitionLabels = {
|
|
57
|
+
cut: "Cut",
|
|
58
|
+
crossfade: "Crossfade",
|
|
59
|
+
fadeblack: "Fade through black",
|
|
60
|
+
slide: "Slide",
|
|
61
|
+
wipe: "Wipe",
|
|
62
|
+
};
|
|
63
|
+
export const lookLevelLabels = {
|
|
64
|
+
off: "Off",
|
|
65
|
+
subtle: "Subtle",
|
|
66
|
+
strong: "Strong",
|
|
67
|
+
};
|
|
68
|
+
export const colorGradeLabels = {
|
|
69
|
+
none: "None",
|
|
70
|
+
warm: "Warm fantasy",
|
|
71
|
+
cold: "Cold",
|
|
72
|
+
desaturated: "Desaturated",
|
|
73
|
+
sepia: "Sepia",
|
|
74
|
+
};
|
|
75
|
+
export const atmosphereLabels = {
|
|
76
|
+
none: "None",
|
|
77
|
+
embers: "Embers",
|
|
78
|
+
dust: "Dust",
|
|
79
|
+
fog: "Fog",
|
|
80
|
+
};
|
|
81
|
+
export const animateModeLabels = {
|
|
82
|
+
off: "Off",
|
|
83
|
+
chapters: "Chapter openers",
|
|
84
|
+
every: "Every Nth image",
|
|
85
|
+
};
|
|
86
|
+
// The settings a project renders with: its own, or today's behaviour when it has none.
|
|
87
|
+
export function videoEditOf(config) {
|
|
88
|
+
return config.videoEdit ?? legacyVideoEdit;
|
|
89
|
+
}
|
|
90
|
+
// Following the narration needs narration to follow and a video to cut; otherwise the video
|
|
91
|
+
// falls back to Every N seconds, which is also what its fingerprint says.
|
|
92
|
+
export function usesNarrationCuts(config) {
|
|
93
|
+
return (videoEditOf(config).cuts === "narration" &&
|
|
94
|
+
config.sources.audio !== "off" &&
|
|
95
|
+
config.sources.video === "generate");
|
|
96
|
+
}
|
|
97
|
+
// Chapter cards are placed from the narration's word timing, so they need narration too.
|
|
98
|
+
export function usesChapterCards(config) {
|
|
99
|
+
return (videoEditOf(config).chapterCards &&
|
|
100
|
+
config.sources.audio !== "off" &&
|
|
101
|
+
config.sources.video === "generate");
|
|
102
|
+
}
|
|
103
|
+
// "Chapter openers" needs chapters, which come from the narration's word timing.
|
|
104
|
+
export function usesAnimation(config) {
|
|
105
|
+
const edit = videoEditOf(config);
|
|
106
|
+
return (edit.animate !== "off" &&
|
|
107
|
+
config.sources.video === "generate" &&
|
|
108
|
+
(edit.animate === "every" || config.sources.audio !== "off"));
|
|
109
|
+
}
|
|
110
|
+
// Whether the render needs the word timing: to cut on sentences, to place chapter cards, or to
|
|
111
|
+
// know which images open a chapter.
|
|
112
|
+
export function editNeedsTiming(config) {
|
|
113
|
+
return (usesNarrationCuts(config) ||
|
|
114
|
+
usesChapterCards(config) ||
|
|
115
|
+
(usesAnimation(config) && videoEditOf(config).animate === "chapters"));
|
|
116
|
+
}
|
|
117
|
+
// The words a problem with one of the settings is said in; shared by admission, Play's draft
|
|
118
|
+
// conversion and Edit project.
|
|
119
|
+
export function videoEditProblems(config) {
|
|
120
|
+
const edit = config.videoEdit;
|
|
121
|
+
if (edit === undefined || config.sources.video !== "generate")
|
|
122
|
+
return [];
|
|
123
|
+
const problems = [];
|
|
124
|
+
if (edit.transition !== "cut" &&
|
|
125
|
+
!(Number.isFinite(edit.transitionSeconds) &&
|
|
126
|
+
edit.transitionSeconds >= transitionSecondsMin &&
|
|
127
|
+
edit.transitionSeconds <= transitionSecondsMax))
|
|
128
|
+
problems.push({
|
|
129
|
+
field: "transitionSeconds",
|
|
130
|
+
message: `Choose a transition length between ${String(transitionSecondsMin)} and ${String(transitionSecondsMax)} seconds.`,
|
|
131
|
+
});
|
|
132
|
+
if (edit.chapterCards && config.sources.audio === "off")
|
|
133
|
+
problems.push({
|
|
134
|
+
field: "chapterCards",
|
|
135
|
+
message: "Chapter cards are placed from the narration. Turn narration on, or turn chapter cards off.",
|
|
136
|
+
});
|
|
137
|
+
if (edit.animate === "chapters" && config.sources.audio === "off")
|
|
138
|
+
problems.push({
|
|
139
|
+
field: "animate",
|
|
140
|
+
message: "Chapter openers are found from the narration. Turn narration on, or choose Every Nth image.",
|
|
141
|
+
});
|
|
142
|
+
if (edit.animate === "every" &&
|
|
143
|
+
!(Number.isInteger(edit.animateEvery) &&
|
|
144
|
+
edit.animateEvery >= animateEveryMin &&
|
|
145
|
+
edit.animateEvery <= animateEveryMax))
|
|
146
|
+
problems.push({
|
|
147
|
+
field: "animateEvery",
|
|
148
|
+
message: `Choose every 2nd to every ${String(animateEveryMax)}th image.`,
|
|
149
|
+
});
|
|
150
|
+
if (edit.animate !== "off" && edit.animateModel.trim() === "")
|
|
151
|
+
problems.push({
|
|
152
|
+
field: "animateModel",
|
|
153
|
+
message: "Choose an image-to-video model to animate images with, or turn Animate images off.",
|
|
154
|
+
});
|
|
155
|
+
return problems;
|
|
156
|
+
}
|
|
157
|
+
// The settings in words, one row each, as the change list and the review summary show them.
|
|
158
|
+
export function videoEditRows(edit) {
|
|
159
|
+
const nth = (n) => (n === 2 ? "2nd" : n === 3 ? "3rd" : `${String(n)}th`);
|
|
160
|
+
return [
|
|
161
|
+
["Cuts", cutModeLabels[edit.cuts]],
|
|
162
|
+
[
|
|
163
|
+
"Transition",
|
|
164
|
+
edit.transition === "cut"
|
|
165
|
+
? transitionLabels.cut
|
|
166
|
+
: `${transitionLabels[edit.transition]} · ${String(edit.transitionSeconds)} s`,
|
|
167
|
+
],
|
|
168
|
+
["Vignette", lookLevelLabels[edit.vignette]],
|
|
169
|
+
["Film grain", lookLevelLabels[edit.grain]],
|
|
170
|
+
["Colour grade", colorGradeLabels[edit.grade]],
|
|
171
|
+
["Atmosphere", atmosphereLabels[edit.atmosphere]],
|
|
172
|
+
["Chapter cards", edit.chapterCards ? "On" : "Off"],
|
|
173
|
+
[
|
|
174
|
+
"Animate images",
|
|
175
|
+
edit.animate === "off"
|
|
176
|
+
? animateModeLabels.off
|
|
177
|
+
: `${edit.animate === "every" ? `Every ${nth(edit.animateEvery)} image` : animateModeLabels.chapters}${edit.animateModel.trim() === "" ? "" : ` · ${edit.animateModel}`}`,
|
|
178
|
+
],
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
// Which images of the slideshow order (0-based) are animated: every Nth from the first, or
|
|
182
|
+
// the ones that open a chapter. `chapterImages` is the image index each chapter opens on.
|
|
183
|
+
export function animatedImageIndexes(edit, imageCount, chapterImages = []) {
|
|
184
|
+
if (edit.animate === "off" || imageCount <= 0)
|
|
185
|
+
return [];
|
|
186
|
+
if (edit.animate === "every") {
|
|
187
|
+
const every = Math.max(1, Math.round(edit.animateEvery));
|
|
188
|
+
return Array.from({ length: imageCount }, (_value, at) => at).filter((at) => at % every === 0);
|
|
189
|
+
}
|
|
190
|
+
return [...new Set(chapterImages.filter((at) => at >= 0 && at < imageCount))].toSorted((left, right) => left - right);
|
|
191
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { isAbsolute, resolve as resolvePath } from "node:path";
|
|
3
|
+
import { gradeFilter, lookChain, lookEncoding, placed } from "./look.js";
|
|
3
4
|
import { decimal, zoomRange } from "./plan.js";
|
|
5
|
+
import { segments, transitionHalves } from "./transitions.js";
|
|
4
6
|
// Hand-rolled per the standards: the filtergraph is the load-bearing part of this slice
|
|
5
7
|
// and a wrapper would hide it. Every value goes into an argument array, never a shell
|
|
6
8
|
// string, so a title or a filename cannot become a command.
|
|
@@ -33,49 +35,173 @@ export function resolveFfmpeg(env, bundled) {
|
|
|
33
35
|
export function slideshowClips(edit) {
|
|
34
36
|
const clips = [];
|
|
35
37
|
const named = new Map();
|
|
36
|
-
const order = edit.shots.map((
|
|
37
|
-
|
|
38
|
-
// alike.
|
|
39
|
-
const key = JSON.stringify([shot.source, shot.motion, shot.frames]);
|
|
38
|
+
const order = segments(edit.shots).map((segment) => {
|
|
39
|
+
const key = clipKey(edit, segment);
|
|
40
40
|
let name = named.get(key);
|
|
41
41
|
if (name === undefined) {
|
|
42
42
|
name = `c${clips.length + 1}.mp4`;
|
|
43
43
|
named.set(key, name);
|
|
44
|
-
|
|
44
|
+
const shot = edit.shots[segment.kind === "shot" ? segment.shot : segment.to];
|
|
45
|
+
if (shot === undefined)
|
|
46
|
+
throw new Error("Slopify hit an internal error (the video's edit list lost a shot). Use Re-run section on Video; if it happens again, use Download diagnostics in Settings and report it.");
|
|
47
|
+
clips.push({ name, segment, shot, frames: segment.count });
|
|
45
48
|
}
|
|
46
49
|
return name;
|
|
47
50
|
});
|
|
48
51
|
return { clips, order };
|
|
49
52
|
}
|
|
53
|
+
// What makes two clips the same encode. The motion is plain data built in a fixed key order,
|
|
54
|
+
// so equal motions stringify alike. A whole shot keeps the key it always had, so an edit
|
|
55
|
+
// list without transitions or a Look dedupes exactly as before.
|
|
56
|
+
function clipKey(edit, segment) {
|
|
57
|
+
const at = placed(edit, segment.start, segment.count) ? segment.start : null;
|
|
58
|
+
if (segment.kind === "transition") {
|
|
59
|
+
const from = edit.shots[segment.from];
|
|
60
|
+
const to = edit.shots[segment.to];
|
|
61
|
+
return JSON.stringify([
|
|
62
|
+
"transition",
|
|
63
|
+
from?.source,
|
|
64
|
+
from?.motion,
|
|
65
|
+
from?.frames,
|
|
66
|
+
to?.source,
|
|
67
|
+
to?.motion,
|
|
68
|
+
to?.frames,
|
|
69
|
+
to?.transition,
|
|
70
|
+
at,
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
const shot = edit.shots[segment.shot];
|
|
74
|
+
const whole = segment.from === 0 && segment.count === shot?.frames;
|
|
75
|
+
return JSON.stringify(whole && at === null
|
|
76
|
+
? [shot?.source, shot?.motion, shot?.frames]
|
|
77
|
+
: [shot?.source, shot?.motion, shot?.frames, segment.from, segment.count, at]);
|
|
78
|
+
}
|
|
50
79
|
// Clips that are joined by copying get the final encode's settings. Clips that will be
|
|
51
80
|
// decoded again under burned-in captions are kept closer to the source, so the second
|
|
52
81
|
// encode is not a visible second generation.
|
|
53
82
|
const intermediateCrf = "16";
|
|
83
|
+
// A whole shot as one clip.
|
|
54
84
|
export function clipArgs(frame, shot, output, intermediate = false) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
85
|
+
return segmentArgs({ ...frame, shots: [shot] }, { kind: "shot", shot: 0, from: 0, count: shot.frames, start: 0 }, output, intermediate);
|
|
86
|
+
}
|
|
87
|
+
// One clip of the slideshow: part of a shot, or a transition between two, with the Look.
|
|
88
|
+
export function segmentArgs(edit, segment, output, intermediate = false,
|
|
89
|
+
// The clip's own chapter-card script, beside it in the working folder (`cards.ts`).
|
|
90
|
+
cardsFile = "cards.ass") {
|
|
91
|
+
const inputs = [];
|
|
92
|
+
const chains = [];
|
|
93
|
+
const picture = (shot, from, label) => {
|
|
94
|
+
if (shot === undefined)
|
|
95
|
+
throw new Error("Slopify hit an internal error (the video's edit list lost a shot). Use Re-run section on Video; if it happens again, use Download diagnostics in Settings and report it.");
|
|
96
|
+
const at = inputs.filter((one) => one === "-i").length;
|
|
97
|
+
const built = pictureChain(edit, shot, from, segment.count, at, label, gradeFilter(edit.look));
|
|
98
|
+
inputs.push(...built.inputs);
|
|
99
|
+
chains.push(built.chain);
|
|
100
|
+
};
|
|
101
|
+
const look = lookChain(edit, segment.start, segment.count, "[p]", "[v]", cardsFile);
|
|
102
|
+
const picked = look === undefined ? "[v]" : "[p]";
|
|
103
|
+
if (segment.kind === "shot")
|
|
104
|
+
picture(edit.shots[segment.shot], segment.from, picked);
|
|
105
|
+
else {
|
|
106
|
+
const before = edit.shots[segment.from];
|
|
107
|
+
const after = edit.shots[segment.to];
|
|
108
|
+
const halves = transitionHalves(segment.count);
|
|
109
|
+
picture(before, (before?.frames ?? 0) - halves.tail, "[ta]");
|
|
110
|
+
picture(after, -halves.tail, "[tb]");
|
|
111
|
+
const style = after?.transition?.kind ?? "crossfade";
|
|
112
|
+
chains.push(`[ta][tb]xfade=transition=${xfadeNames[style]}:duration=${seconds6(segment.count / edit.fps)}:offset=0${picked}`);
|
|
113
|
+
}
|
|
114
|
+
if (look !== undefined)
|
|
115
|
+
chains.push(look);
|
|
60
116
|
return [
|
|
61
117
|
...progressArgs,
|
|
62
|
-
|
|
63
|
-
shot.source.path,
|
|
118
|
+
...inputs,
|
|
64
119
|
"-filter_complex",
|
|
65
|
-
|
|
66
|
-
// Cover the frame and centre-crop, never letterbox.
|
|
67
|
-
`scale=${wide}:${tall}:force_original_aspect_ratio=increase,crop=${wide}:${tall},` +
|
|
68
|
-
`zoompan=z='${move?.z ?? "1"}':d=${shot.frames}:` +
|
|
69
|
-
`x='${move?.x ?? centredX}':y='${move?.y ?? centredY}':` +
|
|
70
|
-
`s=${frame.width}x${frame.height}:fps=${frame.fps},setsar=1[v]`,
|
|
120
|
+
chains.join(";"),
|
|
71
121
|
"-map",
|
|
72
122
|
"[v]",
|
|
123
|
+
// The clip is exactly its frames, whatever a filter's rounding of seconds would give.
|
|
124
|
+
...(segment.kind === "shot" && segment.from === 0 && look === undefined
|
|
125
|
+
? []
|
|
126
|
+
: ["-frames:v", String(segment.count)]),
|
|
73
127
|
...videoCodec,
|
|
128
|
+
...lookEncoding(edit.look),
|
|
74
129
|
...(intermediate ? ["-crf", intermediateCrf] : []),
|
|
75
130
|
"-an",
|
|
76
131
|
output,
|
|
77
132
|
];
|
|
78
133
|
}
|
|
134
|
+
const xfadeNames = {
|
|
135
|
+
crossfade: "fade",
|
|
136
|
+
fadeblack: "fadeblack",
|
|
137
|
+
slide: "slideleft",
|
|
138
|
+
wipe: "wipeleft",
|
|
139
|
+
};
|
|
140
|
+
// The picture of `count` frames of `shot` from its own frame `from`, into `label`. A frame
|
|
141
|
+
// before the shot's first shows its first, and one past its last shows its last (a still's
|
|
142
|
+
// motion holds) or plays on (a clip keeps running), which is what a transition overlapping
|
|
143
|
+
// the cut shows.
|
|
144
|
+
function pictureChain(frame, shot, from, count, input, label, grade) {
|
|
145
|
+
const source = shot.source;
|
|
146
|
+
if (source.kind === "video") {
|
|
147
|
+
const { width, height, fps } = frame;
|
|
148
|
+
const pad = Math.max(0, -from);
|
|
149
|
+
const first = from + pad;
|
|
150
|
+
return {
|
|
151
|
+
// Looped for as long as the shot needs; trim ends the stream.
|
|
152
|
+
inputs: ["-stream_loop", "-1", "-i", source.path],
|
|
153
|
+
chain: `[${String(input)}:v]setpts=(PTS-STARTPTS)*${slowdown(source.seconds, shot.frames / fps)},` +
|
|
154
|
+
`fps=${String(fps)},` +
|
|
155
|
+
`scale=${String(width)}:${String(height)}:force_original_aspect_ratio=increase,` +
|
|
156
|
+
`crop=${String(width)}:${String(height)},setsar=1,` +
|
|
157
|
+
(grade === undefined ? "" : `${grade},`) +
|
|
158
|
+
(pad > 0 ? `tpad=start=${String(pad)}:start_mode=clone,` : "") +
|
|
159
|
+
`trim=start_frame=${String(first)}:end_frame=${String(first + count)},` +
|
|
160
|
+
// Padding and trimming drop the declared rate, which a transition needs to match.
|
|
161
|
+
`setpts=PTS-STARTPTS,fps=${String(fps)},format=yuv420p${label}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const move = zoompan(shot.motion, shot.frames, frameExpression(from, count, shot.frames));
|
|
165
|
+
// A still that does not move has no sub-pixel steps to smooth, so it is not prescaled.
|
|
166
|
+
const factor = move === undefined ? 1 : prescale;
|
|
167
|
+
const wide = frame.width * factor;
|
|
168
|
+
const tall = frame.height * factor;
|
|
169
|
+
// Clips that meet in a transition are brought to one pixel format first, since xfade
|
|
170
|
+
// refuses to blend two that differ; a plain shot is left as it always was.
|
|
171
|
+
const plain = from === 0 && count === shot.frames;
|
|
172
|
+
return {
|
|
173
|
+
inputs: ["-i", source.path],
|
|
174
|
+
chain: `[${String(input)}:v]trim=end_frame=1,setpts=PTS-STARTPTS,` +
|
|
175
|
+
// The grade is applied here, to the one still frame, rather than to every frame after.
|
|
176
|
+
(grade === undefined ? "" : `${grade},`) +
|
|
177
|
+
// Cover the frame and centre-crop, never letterbox.
|
|
178
|
+
`scale=${wide}:${tall}:force_original_aspect_ratio=increase,crop=${wide}:${tall},` +
|
|
179
|
+
`zoompan=z='${move?.z ?? "1"}':d=${count}:` +
|
|
180
|
+
`x='${move?.x ?? centredX}':y='${move?.y ?? centredY}':` +
|
|
181
|
+
`s=${frame.width}x${frame.height}:fps=${frame.fps},setsar=1${plain ? "" : ",format=yuv420p"}${label}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// ceiling: a clip shorter than its shot is slowed to at most half speed, which still reads
|
|
185
|
+
// as natural motion; past that it loops.
|
|
186
|
+
const slowestSpeed = 2;
|
|
187
|
+
// How much a clip of `clip` seconds is stretched to fill a shot of `shot` seconds, as exact
|
|
188
|
+
// decimal text: 1 when it is long enough.
|
|
189
|
+
export function slowdown(clip, shot) {
|
|
190
|
+
if (!(clip > 0) || shot <= clip)
|
|
191
|
+
return "1";
|
|
192
|
+
return decimal(Math.round(Math.min(slowestSpeed, shot / clip) * 1000));
|
|
193
|
+
}
|
|
194
|
+
// zoompan's `on` counts the clip's own frames from 0; a clip that starts `from` frames into
|
|
195
|
+
// its shot adds that, and one that reaches before or past the shot holds at its ends.
|
|
196
|
+
function frameExpression(from, count, frames) {
|
|
197
|
+
const shifted = from === 0 ? "on" : `(on${from > 0 ? "+" : "-"}${String(Math.abs(from))})`;
|
|
198
|
+
return from < 0 || from + count > frames
|
|
199
|
+
? `clip(${shifted},0,${String(Math.max(0, frames - 1))})`
|
|
200
|
+
: shifted;
|
|
201
|
+
}
|
|
202
|
+
function seconds6(value) {
|
|
203
|
+
return value.toFixed(6);
|
|
204
|
+
}
|
|
79
205
|
// The concat demuxer's own format. Names are resolved beside the list and are this
|
|
80
206
|
// module's `cN.mp4`, so nothing in them needs quoting.
|
|
81
207
|
export function concatList(order) {
|
|
@@ -110,7 +236,7 @@ export function joinArgs(edit, output, list, burnSubtitles = false) {
|
|
|
110
236
|
"-map",
|
|
111
237
|
burnSubtitles ? "[v]" : "0:v",
|
|
112
238
|
...(edit.audio.length > 0 ? ["-map", "[a]"] : []),
|
|
113
|
-
...(burnSubtitles ? videoCodec : ["-c:v", "copy"]),
|
|
239
|
+
...(burnSubtitles ? [...videoCodec, ...lookEncoding(edit.look)] : ["-c:v", "copy"]),
|
|
114
240
|
...(edit.audio.length > 0 ? ["-c:a", "aac"] : ["-an"]),
|
|
115
241
|
"-movflags",
|
|
116
242
|
"+faststart",
|
|
@@ -132,10 +258,10 @@ const videoCodec = ["-c:v", "libx264", "-pix_fmt", "yuv420p"];
|
|
|
132
258
|
const centredX = "iw/2-(iw/zoom/2)";
|
|
133
259
|
const centredY = "ih/2-(ih/zoom/2)";
|
|
134
260
|
// zoompan's expressions for a motion, or undefined for one that does not move. `on` is
|
|
135
|
-
// zoompan's output frame counter, 0 to d-1
|
|
136
|
-
// by, so it holds where it starts. `zoom` in x and y is
|
|
137
|
-
// is the room the crop window has to travel in.
|
|
138
|
-
function zoompan(motion, frames) {
|
|
261
|
+
// zoompan's output frame counter, 0 to d-1 (`at` shifts it for part of a shot), and a
|
|
262
|
+
// one-frame shot has no span to divide by, so it holds where it starts. `zoom` in x and y is
|
|
263
|
+
// the current zoom, so iw-iw/zoom is the room the crop window has to travel in.
|
|
264
|
+
function zoompan(motion, frames, at = "on") {
|
|
139
265
|
if (motion.kind === "still")
|
|
140
266
|
return undefined;
|
|
141
267
|
const range = zoomRange(motion.percent);
|
|
@@ -147,27 +273,27 @@ function zoompan(motion, frames) {
|
|
|
147
273
|
const z = motion.direction === "in"
|
|
148
274
|
? span < 1
|
|
149
275
|
? range.from
|
|
150
|
-
: `${range.from}+${range.by}
|
|
276
|
+
: `${range.from}+${range.by}*${at}/${span}`
|
|
151
277
|
: span < 1
|
|
152
278
|
? range.to
|
|
153
|
-
: `${range.to}-${range.by}
|
|
279
|
+
: `${range.to}-${range.by}*${at}/${span}`;
|
|
154
280
|
return { z, x: centredX, y: centredY };
|
|
155
281
|
}
|
|
156
282
|
return {
|
|
157
283
|
z: range.to,
|
|
158
|
-
x: `(iw-iw/zoom)*(${travel(motion.from.x, motion.to.x, span)})`,
|
|
159
|
-
y: `(ih-ih/zoom)*(${travel(motion.from.y, motion.to.y, span)})`,
|
|
284
|
+
x: `(iw-iw/zoom)*(${travel(motion.from.x, motion.to.x, span, at)})`,
|
|
285
|
+
y: `(ih-ih/zoom)*(${travel(motion.from.y, motion.to.y, span, at)})`,
|
|
160
286
|
};
|
|
161
287
|
}
|
|
162
288
|
// A share of the room from `from` to `to`, linear over the shot. Built in whole
|
|
163
289
|
// thousandths as decimal text, like the zoom, so 1 - 0.5 reads 0.5 and never
|
|
164
290
|
// 0.49999999999999994.
|
|
165
|
-
function travel(from, to, span) {
|
|
291
|
+
function travel(from, to, span, at) {
|
|
166
292
|
const start = Math.round(from * 1000);
|
|
167
293
|
const by = Math.round(to * 1000) - start;
|
|
168
294
|
if (span < 1 || by === 0)
|
|
169
295
|
return decimal(start);
|
|
170
|
-
return `${decimal(start)}${by > 0 ? "+" : "-"}${decimal(Math.abs(by))}
|
|
296
|
+
return `${decimal(start)}${by > 0 ? "+" : "-"}${decimal(Math.abs(by))}*${at}/${span}`;
|
|
171
297
|
}
|
|
172
298
|
function seconds(value) {
|
|
173
299
|
return value.toFixed(3);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The Look: filters appended to each clip's picture before it is encoded, so they cost one
|
|
2
|
+
// pass over frames that are being made anyway and the render stays clip by clip. Everything
|
|
3
|
+
// is ffmpeg's own filters with fixed numbers: no footage, no LUT files, and the same project
|
|
4
|
+
// renders the same frames every time.
|
|
5
|
+
//
|
|
6
|
+
// The colour grade is applied to each source before its picture is built (`gradeFilter`):
|
|
7
|
+
// once on a still, before zoompan turns it into frames, rather than on every frame. So the
|
|
8
|
+
// overlay and the vignette sit on graded colour. Order matters for the rest: the atmosphere
|
|
9
|
+
// first; then the vignette darkens both; the chapter card is drawn on top of that
|
|
10
|
+
// so its text stays clean; the grain last, over everything, as film grain is.
|
|
11
|
+
// ceiling: the owner's eye on test renders. Subtle darkens the corners a little, strong
|
|
12
|
+
// makes a clear frame of shadow.
|
|
13
|
+
const vignetteAngle = {
|
|
14
|
+
subtle: "0.45",
|
|
15
|
+
strong: "0.75",
|
|
16
|
+
};
|
|
17
|
+
// Temporal noise on brightness only, so the grain moves like film and never tints.
|
|
18
|
+
const grainStrength = {
|
|
19
|
+
subtle: 8,
|
|
20
|
+
strong: 16,
|
|
21
|
+
};
|
|
22
|
+
// The encoder settings a Look asks for: x264 smooths fine noise away at its default tuning,
|
|
23
|
+
// so grain is encoded with its grain tuning. Every clip of one render has the same Look, so
|
|
24
|
+
// the clips still join by copying.
|
|
25
|
+
export function lookEncoding(look) {
|
|
26
|
+
return look !== undefined && look.grain !== "off" ? ["-tune", "grain"] : [];
|
|
27
|
+
}
|
|
28
|
+
const grades = {
|
|
29
|
+
// Golden highlights, a touch of amber in the mids, cooler shadows kept for depth.
|
|
30
|
+
warm: "colorbalance=rs=0.02:bs=0.04:rm=0.08:gm=0.02:bm=-0.07:rh=0.07:gh=0.03:bh=-0.08," +
|
|
31
|
+
"eq=saturation=1.12:contrast=1.04",
|
|
32
|
+
// Blue-green shadows and mids, highlights barely touched.
|
|
33
|
+
cold: "colorbalance=rs=-0.06:bs=0.08:rm=-0.05:gm=0.01:bm=0.06:rh=-0.02:bh=0.03,eq=saturation=0.9",
|
|
34
|
+
desaturated: "eq=saturation=0.55:contrast=1.06",
|
|
35
|
+
// The classic sepia matrix.
|
|
36
|
+
sepia: "colorchannelmixer=rr=0.393:rg=0.769:rb=0.189:gr=0.349:gg=0.686:gb=0.168:br=0.272:bg=0.534:bb=0.131",
|
|
37
|
+
};
|
|
38
|
+
// The grade as a filter for a picture's source, or undefined for none: a still is graded once,
|
|
39
|
+
// a clip frame by frame.
|
|
40
|
+
export function gradeFilter(look) {
|
|
41
|
+
return look === undefined || look.grade === "none" ? undefined : grades[look.grade];
|
|
42
|
+
}
|
|
43
|
+
export function hasLook(look) {
|
|
44
|
+
return (look !== undefined &&
|
|
45
|
+
(look.vignette !== "off" ||
|
|
46
|
+
look.grain !== "off" ||
|
|
47
|
+
look.grade !== "none" ||
|
|
48
|
+
look.atmosphere !== "none"));
|
|
49
|
+
}
|
|
50
|
+
// The cards a clip at `start` of `count` frames shows any part of.
|
|
51
|
+
export function cardsIn(cards, start, count) {
|
|
52
|
+
return (cards ?? []).filter((card) => card.startFrame < start + count && card.startFrame + card.frames > start);
|
|
53
|
+
}
|
|
54
|
+
// Whether a clip's pixels depend on where it sits on the timeline: an atmosphere drifts with
|
|
55
|
+
// the video's clock and a card is drawn at its own time, so such a clip is never reused at
|
|
56
|
+
// another place.
|
|
57
|
+
export function placed(edit, start, count) {
|
|
58
|
+
return ((edit.look !== undefined && edit.look.atmosphere !== "none") ||
|
|
59
|
+
cardsIn(edit.cards, start, count).length > 0);
|
|
60
|
+
}
|
|
61
|
+
// The filters that take the clip's picture from `input` to `output`, or undefined when there
|
|
62
|
+
// is nothing to add. `start` is the clip's first frame on the timeline. Cards are read from
|
|
63
|
+
// `cardsFile`, the clip's own script with its times counted from the clip's first frame
|
|
64
|
+
// (`cards.ts`), and `fonts/`, both in the render's working folder.
|
|
65
|
+
export function lookChain(edit, start, count, input, output, cardsFile = "cards.ass") {
|
|
66
|
+
const look = edit.look;
|
|
67
|
+
const steps = [];
|
|
68
|
+
let at = input;
|
|
69
|
+
let n = 0;
|
|
70
|
+
const next = () => {
|
|
71
|
+
n += 1;
|
|
72
|
+
return `[look${String(n)}]`;
|
|
73
|
+
};
|
|
74
|
+
const simple = (filters) => {
|
|
75
|
+
if (filters.length === 0)
|
|
76
|
+
return;
|
|
77
|
+
const label = next();
|
|
78
|
+
steps.push(`${at}${filters.join(",")}${label}`);
|
|
79
|
+
at = label;
|
|
80
|
+
};
|
|
81
|
+
if (look !== undefined && look.atmosphere !== "none") {
|
|
82
|
+
const texture = next();
|
|
83
|
+
const blended = next();
|
|
84
|
+
steps.push(`${atmosphereSource(edit, look.atmosphere, start)}${texture}`);
|
|
85
|
+
// Laid over the picture by its alpha, in the picture's own YUV: converting every frame
|
|
86
|
+
// to RGB for a screen blend doubled a clip's render time.
|
|
87
|
+
steps.push(`${at}${texture}overlay=shortest=1:format=yuv420${blended}`);
|
|
88
|
+
at = blended;
|
|
89
|
+
}
|
|
90
|
+
const after = [];
|
|
91
|
+
if (look !== undefined && look.vignette !== "off")
|
|
92
|
+
after.push(`vignette=angle=${vignetteAngle[look.vignette]}`);
|
|
93
|
+
if (cardsIn(edit.cards, start, count).length > 0)
|
|
94
|
+
after.push(`ass=filename=${cardsFile}:fontsdir=fonts`);
|
|
95
|
+
if (look !== undefined && look.grain !== "off")
|
|
96
|
+
after.push(`noise=c0s=${String(grainStrength[look.grain])}:c0f=t+u`);
|
|
97
|
+
simple(after);
|
|
98
|
+
if (steps.length === 0)
|
|
99
|
+
return undefined;
|
|
100
|
+
const last = steps.length - 1;
|
|
101
|
+
steps[last] = `${(steps[last] ?? "").slice(0, -at.length)}${output}`;
|
|
102
|
+
return steps.join(";");
|
|
103
|
+
}
|
|
104
|
+
const atmosphereOpacity = {
|
|
105
|
+
embers: "1",
|
|
106
|
+
dust: "0.6",
|
|
107
|
+
fog: "1",
|
|
108
|
+
};
|
|
109
|
+
// A still texture made once per clip, then moved: embers rise and drift left, dust wanders,
|
|
110
|
+
// fog rolls sideways along the bottom of the frame. `scroll` wraps the texture round, which
|
|
111
|
+
// leaves no seam for sparse specks, and the fog is mirrored side to side first so its wrap
|
|
112
|
+
// has none either. The scroll starts where the timeline has it at the clip's first frame, so
|
|
113
|
+
// the overlay runs on unbroken across cuts.
|
|
114
|
+
//
|
|
115
|
+
// The specks come from ffmpeg's `noise` (geq's random() repeats in visible columns): one
|
|
116
|
+
// grey level of uniform noise marks about 1% of the pixels, and multiplying by a second,
|
|
117
|
+
// flipped noise field thins them and varies their brightness. Everything is fixed numbers,
|
|
118
|
+
// so the texture is the same on every run.
|
|
119
|
+
function atmosphereSource(frame, atmosphere, start) {
|
|
120
|
+
const { width, height, fps } = frame;
|
|
121
|
+
const size = `${String(width)}x${String(height)}`;
|
|
122
|
+
const grid = (divisor) => `${String(Math.max(2, Math.round(width / divisor)))}x${String(Math.max(2, Math.round(height / divisor)))}`;
|
|
123
|
+
// A grey field of `grid` with about one pixel in a hundred lit, thinned by a second field
|
|
124
|
+
// cut at `cut` and scaled by `gain`; into `label`, still the grid's size.
|
|
125
|
+
const specks = (at, seed, cut, gain, label) => `color=c=0x808080:s=${at}:r=${String(fps)}:d=1,format=gbrp,split[${label}a][${label}b];` +
|
|
126
|
+
`[${label}a]noise=c0s=100:c0f=u:c0_seed=${String(seed)},lutrgb=g='if(eq(val,177),255,0)'[${label}c];` +
|
|
127
|
+
`[${label}b]noise=c0s=100:c0f=u:c0_seed=${String(seed + 2)},` +
|
|
128
|
+
`lutrgb=g='max(0,min(255,(val-${String(cut)})*${String(gain)}))',hflip,vflip[${label}d];` +
|
|
129
|
+
`[${label}c][${label}d]blend=all_mode=multiply[${label}]`;
|
|
130
|
+
// The green plane carries the texture; the mixer tints it into all three.
|
|
131
|
+
const tint = (r, g, b) => `colorchannelmixer=rr=0:rg=${String(r)}:rb=0:gr=0:gg=${String(g)}:gb=0:br=0:bg=${String(b)}:bb=0`;
|
|
132
|
+
// The texture as light over a transparent frame, made once before it loops: each pixel keeps
|
|
133
|
+
// its hue at full brightness, and how bright it was (times the atmosphere's strength)
|
|
134
|
+
// becomes how opaque it is, which is a screen blend's look for a dark background.
|
|
135
|
+
const peak = "max(r(X,Y),max(g(X,Y),b(X,Y)))";
|
|
136
|
+
const lit = (plane) => `if(gt(${peak},0),255*${plane}(X,Y)/${peak},0)`;
|
|
137
|
+
const light = `format=gbrap,geq=r='${lit("r")}':g='${lit("g")}':b='${lit("b")}':` +
|
|
138
|
+
`a='${peak}*${atmosphereOpacity[atmosphere]}',format=yuva420p`;
|
|
139
|
+
const moving = (h, v) => `${light},loop=loop=-1:size=1,setpts=N/(${String(fps)}*TB),` +
|
|
140
|
+
`scroll=h=${String(h)}:v=${String(v)}:hpos=${position(h, start)}:vpos=${position(v, start)}`;
|
|
141
|
+
if (atmosphere === "embers")
|
|
142
|
+
// Small sparks and a few large soft glows, orange, rising.
|
|
143
|
+
return (`${specks(grid(4), 7, 168, 24, "es")};${specks(grid(20), 3, 142, 7, "eb")};` +
|
|
144
|
+
`[es]scale=${size}:flags=bicubic,gblur=sigma=1.3,lutrgb=g='min(255,val*2.4)'[esx];` +
|
|
145
|
+
`[eb]scale=${size}:flags=bicubic,gblur=sigma=14,lutrgb=g='min(255,val*6)'[ebx];` +
|
|
146
|
+
`[esx][ebx]blend=all_mode=addition,${tint(1, 0.48, 0.12)},${moving(0.0003, 0.0022)}`);
|
|
147
|
+
if (atmosphere === "dust")
|
|
148
|
+
// Fine warm-white motes drifting slowly across.
|
|
149
|
+
return (`${specks(grid(3), 17, 160, 12, "ds")};` +
|
|
150
|
+
`[ds]scale=${size}:flags=bicubic,gblur=sigma=1.1,lutrgb=g='min(255,val*2)',` +
|
|
151
|
+
`${tint(1, 0.95, 0.85)},${moving(0.0005, -0.00025)}`);
|
|
152
|
+
// Soft blotches from a tiny noise field blown up and blurred, faded out towards the top.
|
|
153
|
+
return (`color=c=0x808080:s=${grid(80)}:r=${String(fps)}:d=1,format=gbrp,` +
|
|
154
|
+
"noise=c0s=100:c0f=u:c0_seed=23," +
|
|
155
|
+
`scale=${size}:flags=bicubic,gblur=sigma=70,lutrgb=g='max(0,min(255,(val-95)*3.2))',` +
|
|
156
|
+
"geq=r='g(X,Y)*pow(Y/H,1.6)':g='g(X,Y)*pow(Y/H,1.6)':b='g(X,Y)*pow(Y/H,1.6)'," +
|
|
157
|
+
"colorchannelmixer=rr=0.92:gg=0.95:bb=1,split[fa][fb];[fb]hflip[fc];[fa][fc]hstack," +
|
|
158
|
+
`${moving(0.0006, 0)},crop=${String(width)}:${String(height)}:0:0`);
|
|
159
|
+
}
|
|
160
|
+
// Where a texture scrolling `speed` of its size per frame has got to by frame `start`.
|
|
161
|
+
function position(speed, start) {
|
|
162
|
+
const travelled = (((speed * start) % 1) + 1) % 1;
|
|
163
|
+
return travelled.toFixed(6);
|
|
164
|
+
}
|