@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.
Files changed (146) hide show
  1. package/dist/adapter-registry.js +9 -2
  2. package/dist/adapters/host-cli/index.js +11 -0
  3. package/dist/adapters/host-cli/transport.js +6 -1
  4. package/dist/adapters/image/codex-output.js +59 -17
  5. package/dist/adapters/image/codex.js +92 -16
  6. package/dist/adapters/image/fal.js +99 -8
  7. package/dist/adapters/image/google.js +13 -1
  8. package/dist/adapters/image/openai.js +37 -15
  9. package/dist/adapters/image/reference.js +7 -0
  10. package/dist/adapters/image/replicate.js +48 -2
  11. package/dist/adapters/image/video.js +28 -0
  12. package/dist/adapters/llm/codex-models.js +2 -1
  13. package/dist/adapters/llm/codex-server-models.js +2 -1
  14. package/dist/adapters/llm/run-cli.js +14 -2
  15. package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
  16. package/dist/assets/fonts/SOURCE.txt +6 -0
  17. package/dist/assets/models.yaml +89 -1
  18. package/dist/catalog/registry.js +24 -3
  19. package/dist/catalog/runtime-models.js +1 -1
  20. package/dist/catalog/schema.js +20 -1
  21. package/dist/catalog/store.js +2 -2
  22. package/dist/catalog/validate.js +47 -4
  23. package/dist/edge/docker-launch.js +4 -0
  24. package/dist/edge/docker-projects/engine.js +57 -1
  25. package/dist/edge/docker-projects/install.js +10 -2
  26. package/dist/edge/docker-projects/prune.js +66 -0
  27. package/dist/edge/events/hub.js +11 -0
  28. package/dist/edge/http/app.js +2 -0
  29. package/dist/edge/http/backups.js +40 -0
  30. package/dist/edge/http/host-cli.js +17 -2
  31. package/dist/edge/http/problem.js +1 -0
  32. package/dist/edge/http/providers.js +8 -0
  33. package/dist/edge/http/schedules.js +1 -1
  34. package/dist/edge/http/settings.js +44 -0
  35. package/dist/edge/http/timeouts.js +38 -0
  36. package/dist/host-cli/runtime.js +3 -2
  37. package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
  38. package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
  39. package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
  40. package/dist/kernel/ports/host-cli.js +17 -2
  41. package/dist/kernel/ports/image.js +3 -1
  42. package/dist/kernel/ports/llm.js +3 -1
  43. package/dist/kernel/runner/attempt.js +5 -2
  44. package/dist/kernel/runner/providers.js +42 -2
  45. package/dist/main.js +55 -7
  46. package/dist/slices/admission/model.js +8 -0
  47. package/dist/slices/admission/rules.js +91 -2
  48. package/dist/slices/admission/schema.js +42 -1
  49. package/dist/slices/admission/start.js +53 -15
  50. package/dist/slices/backups/files.js +135 -0
  51. package/dist/slices/backups/folder.js +35 -0
  52. package/dist/slices/backups/model.js +65 -0
  53. package/dist/slices/backups/repo.js +28 -0
  54. package/dist/slices/backups/schedule.js +60 -0
  55. package/dist/slices/backups/service.js +311 -0
  56. package/dist/slices/batch/index.js +2 -0
  57. package/dist/slices/estimate/index.js +65 -2
  58. package/dist/slices/fonts/catalog.js +23 -0
  59. package/dist/slices/fonts/index.js +1 -1
  60. package/dist/slices/library/model.js +8 -1
  61. package/dist/slices/library/slots.js +13 -2
  62. package/dist/slices/library/snapshot.js +8 -2
  63. package/dist/slices/notifications/notifier.js +76 -0
  64. package/dist/slices/notifications/rules.js +81 -0
  65. package/dist/slices/notifications/send.js +40 -0
  66. package/dist/slices/notifications/settings.js +34 -0
  67. package/dist/slices/play-drafts/convert.js +72 -4
  68. package/dist/slices/play-drafts/readiness.js +7 -3
  69. package/dist/slices/play-drafts/repo.js +7 -1
  70. package/dist/slices/play-drafts/review-inputs.js +2 -0
  71. package/dist/slices/play-drafts/schema.js +44 -3
  72. package/dist/slices/play-drafts/service.js +6 -0
  73. package/dist/slices/play-drafts/uploads.js +2 -1
  74. package/dist/slices/project-templates/from-project.js +42 -0
  75. package/dist/slices/project-templates/setup.js +13 -0
  76. package/dist/slices/rebuild/preview-details.js +53 -3
  77. package/dist/slices/rebuild/preview-plan.js +2 -2
  78. package/dist/slices/rebuild/recipe-build.js +13 -3
  79. package/dist/slices/rebuild/recipe-edit.js +178 -0
  80. package/dist/slices/rebuild/recipe-exports.js +9 -4
  81. package/dist/slices/rebuild/recipe-input-schema.js +9 -1
  82. package/dist/slices/rebuild/recipe-legacy.js +12 -0
  83. package/dist/slices/rebuild/recipe-model.js +7 -1
  84. package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
  85. package/dist/slices/rebuild/recipe-reference.js +44 -0
  86. package/dist/slices/rebuild/recipe-save.js +25 -1
  87. package/dist/slices/rebuild/recipe-shorts.js +186 -0
  88. package/dist/slices/rebuild/recipe-validation.js +33 -2
  89. package/dist/slices/rebuild/recipe-visual.js +29 -8
  90. package/dist/slices/rebuild/recipe-work.js +83 -1
  91. package/dist/slices/rebuild/runtime-actions.js +9 -2
  92. package/dist/slices/rebuild/runtime-animate.js +110 -0
  93. package/dist/slices/rebuild/runtime-export-edit.js +119 -0
  94. package/dist/slices/rebuild/runtime-export.js +13 -1
  95. package/dist/slices/rebuild/runtime-image.js +50 -0
  96. package/dist/slices/rebuild/runtime-local.js +6 -3
  97. package/dist/slices/rebuild/runtime-materialize.js +48 -0
  98. package/dist/slices/rebuild/runtime-piece-label.js +17 -0
  99. package/dist/slices/rebuild/runtime-provider.js +17 -10
  100. package/dist/slices/rebuild/runtime-publication.js +25 -4
  101. package/dist/slices/rebuild/runtime-run.js +11 -5
  102. package/dist/slices/rebuild/runtime-shorts.js +328 -0
  103. package/dist/slices/rebuild/runtime-store.js +7 -3
  104. package/dist/slices/rebuild/service-readiness.js +15 -1
  105. package/dist/slices/rebuild/transition-repo.js +40 -1
  106. package/dist/slices/revisions/adopt-content.js +7 -0
  107. package/dist/slices/revisions/adopt.js +7 -2
  108. package/dist/slices/revisions/mutation-assets.js +51 -9
  109. package/dist/slices/revisions/mutation-prepare.js +14 -14
  110. package/dist/slices/revisions/mutations.js +2 -1
  111. package/dist/slices/revisions/publish.js +20 -2
  112. package/dist/slices/revisions/schema.js +16 -1
  113. package/dist/slices/schedules/repo.js +1 -1
  114. package/dist/slices/schedules/service.js +6 -1
  115. package/dist/slices/settings/model.js +5 -0
  116. package/dist/slices/shorts/captions.js +272 -0
  117. package/dist/slices/shorts/clips.js +89 -0
  118. package/dist/slices/shorts/model.js +186 -0
  119. package/dist/slices/shorts/pick.js +232 -0
  120. package/dist/slices/shorts/prompts.js +68 -0
  121. package/dist/slices/shorts/render.js +163 -0
  122. package/dist/slices/storage/asset-name.js +9 -2
  123. package/dist/slices/storage/layout.js +18 -0
  124. package/dist/slices/storage/model.js +9 -0
  125. package/dist/slices/storage/portable.js +6 -3
  126. package/dist/slices/storage/reconcile.js +5 -1
  127. package/dist/slices/storage/schema.js +3 -0
  128. package/dist/slices/telemetry/model.js +3 -0
  129. package/dist/slices/video/cards.js +72 -0
  130. package/dist/slices/video/chapters.js +89 -0
  131. package/dist/slices/video/cuts.js +141 -0
  132. package/dist/slices/video/edit-list.js +36 -6
  133. package/dist/slices/video/edit-settings.js +191 -0
  134. package/dist/slices/video/ffmpeg.js +155 -29
  135. package/dist/slices/video/look.js +164 -0
  136. package/dist/slices/video/plan.js +68 -11
  137. package/dist/slices/video/slideshow.js +22 -6
  138. package/dist/slices/video/transitions.js +51 -0
  139. package/dist/slices/youtube/transcript.js +54 -0
  140. package/dist/web/assets/index-CktpxlJd.js +171 -0
  141. package/dist/web/assets/index-DB-vOnGU.css +1 -0
  142. package/dist/web/assets/{pdf-DR97Byps.js → pdf-BLF2i8c2.js} +1 -1
  143. package/dist/web/index.html +2 -2
  144. package/package.json +1 -1
  145. package/dist/web/assets/index-DfXmBzCk.js +0 -168
  146. 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((shot) => {
37
- // The motion is plain data built in a fixed key order, so equal motions stringify
38
- // alike.
39
- const key = JSON.stringify([shot.source, shot.motion, shot.frames]);
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
- clips.push({ name, shot });
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
- const move = zoompan(shot.motion, shot.frames);
56
- // A still that does not move has no sub-pixel steps to smooth, so it is not prescaled.
57
- const factor = move === undefined ? 1 : prescale;
58
- const wide = frame.width * factor;
59
- const tall = frame.height * factor;
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
- "-i",
63
- shot.source.path,
118
+ ...inputs,
64
119
  "-filter_complex",
65
- `[0:v]trim=end_frame=1,setpts=PTS-STARTPTS,` +
66
- // Cover the frame and centre-crop, never letterbox.
67
- `scale=${wide}:${tall}:force_original_aspect_ratio=increase,crop=${wide}:${tall},` +
68
- `zoompan=z='${move?.z ?? "1"}':d=${shot.frames}:` +
69
- `x='${move?.x ?? centredX}':y='${move?.y ?? centredY}':` +
70
- `s=${frame.width}x${frame.height}:fps=${frame.fps},setsar=1[v]`,
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, and a one-frame shot has no span to divide
136
- // by, so it holds where it starts. `zoom` in x and y is the current zoom, so iw-iw/zoom
137
- // is the room the crop window has to travel in.
138
- function zoompan(motion, frames) {
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}*on/${span}`
276
+ : `${range.from}+${range.by}*${at}/${span}`
151
277
  : span < 1
152
278
  ? range.to
153
- : `${range.to}-${range.by}*on/${span}`;
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))}*on/${span}`;
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
+ }