@gentbajko/slopify 2.3.0 → 2.4.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 (90) hide show
  1. package/dist/adapter-registry.js +1 -1
  2. package/dist/adapters/image/fal.js +77 -6
  3. package/dist/adapters/image/replicate.js +41 -2
  4. package/dist/adapters/image/video.js +28 -0
  5. package/dist/assets/fonts/Barlow-Bold.ttf +0 -0
  6. package/dist/assets/fonts/SOURCE.txt +6 -0
  7. package/dist/assets/models.yaml +82 -1
  8. package/dist/catalog/schema.js +10 -0
  9. package/dist/catalog/store.js +2 -2
  10. package/dist/catalog/validate.js +32 -4
  11. package/dist/edge/docker-launch.js +4 -0
  12. package/dist/edge/docker-projects/engine.js +57 -1
  13. package/dist/edge/docker-projects/install.js +10 -2
  14. package/dist/edge/docker-projects/prune.js +66 -0
  15. package/dist/edge/http/providers.js +8 -0
  16. package/dist/edge/http/timeouts.js +38 -0
  17. package/dist/kernel/db/migrations/0019-shorts-prompts.sql +16 -0
  18. package/dist/kernel/runner/attempt.js +4 -1
  19. package/dist/kernel/runner/providers.js +20 -0
  20. package/dist/main.js +3 -0
  21. package/dist/slices/admission/rules.js +44 -2
  22. package/dist/slices/admission/schema.js +31 -0
  23. package/dist/slices/estimate/index.js +57 -2
  24. package/dist/slices/fonts/catalog.js +23 -0
  25. package/dist/slices/fonts/index.js +1 -1
  26. package/dist/slices/library/model.js +8 -1
  27. package/dist/slices/library/slots.js +6 -1
  28. package/dist/slices/play-drafts/convert.js +37 -1
  29. package/dist/slices/play-drafts/readiness.js +7 -3
  30. package/dist/slices/play-drafts/schema.js +24 -1
  31. package/dist/slices/project-templates/from-project.js +22 -0
  32. package/dist/slices/project-templates/setup.js +2 -0
  33. package/dist/slices/rebuild/preview-details.js +29 -1
  34. package/dist/slices/rebuild/preview-plan.js +2 -2
  35. package/dist/slices/rebuild/recipe-build.js +6 -2
  36. package/dist/slices/rebuild/recipe-edit.js +178 -0
  37. package/dist/slices/rebuild/recipe-exports.js +9 -4
  38. package/dist/slices/rebuild/recipe-input-schema.js +1 -0
  39. package/dist/slices/rebuild/recipe-legacy.js +9 -0
  40. package/dist/slices/rebuild/recipe-model.js +7 -1
  41. package/dist/slices/rebuild/recipe-provider-choice.js +15 -0
  42. package/dist/slices/rebuild/recipe-save.js +25 -1
  43. package/dist/slices/rebuild/recipe-shorts.js +179 -0
  44. package/dist/slices/rebuild/recipe-validation.js +19 -2
  45. package/dist/slices/rebuild/recipe-visual.js +10 -1
  46. package/dist/slices/rebuild/recipe-work.js +83 -1
  47. package/dist/slices/rebuild/runtime-animate.js +110 -0
  48. package/dist/slices/rebuild/runtime-export-edit.js +119 -0
  49. package/dist/slices/rebuild/runtime-export.js +13 -1
  50. package/dist/slices/rebuild/runtime-materialize.js +48 -0
  51. package/dist/slices/rebuild/runtime-piece-label.js +15 -0
  52. package/dist/slices/rebuild/runtime-publication.js +25 -4
  53. package/dist/slices/rebuild/runtime-run.js +11 -5
  54. package/dist/slices/rebuild/runtime-shorts.js +330 -0
  55. package/dist/slices/rebuild/runtime-store.js +7 -3
  56. package/dist/slices/rebuild/transition-repo.js +39 -0
  57. package/dist/slices/revisions/mutation-assets.js +18 -0
  58. package/dist/slices/revisions/mutation-prepare.js +9 -7
  59. package/dist/slices/revisions/mutations.js +2 -1
  60. package/dist/slices/revisions/publish.js +20 -2
  61. package/dist/slices/revisions/schema.js +11 -0
  62. package/dist/slices/shorts/captions.js +272 -0
  63. package/dist/slices/shorts/clips.js +89 -0
  64. package/dist/slices/shorts/model.js +186 -0
  65. package/dist/slices/shorts/pick.js +232 -0
  66. package/dist/slices/shorts/prompts.js +68 -0
  67. package/dist/slices/shorts/render.js +163 -0
  68. package/dist/slices/storage/asset-name.js +9 -2
  69. package/dist/slices/storage/layout.js +8 -0
  70. package/dist/slices/storage/model.js +6 -0
  71. package/dist/slices/storage/schema.js +3 -0
  72. package/dist/slices/telemetry/model.js +3 -0
  73. package/dist/slices/video/cards.js +72 -0
  74. package/dist/slices/video/chapters.js +89 -0
  75. package/dist/slices/video/cuts.js +141 -0
  76. package/dist/slices/video/edit-list.js +36 -6
  77. package/dist/slices/video/edit-settings.js +191 -0
  78. package/dist/slices/video/ffmpeg.js +155 -29
  79. package/dist/slices/video/look.js +164 -0
  80. package/dist/slices/video/plan.js +68 -11
  81. package/dist/slices/video/slideshow.js +22 -6
  82. package/dist/slices/video/transitions.js +51 -0
  83. package/dist/slices/youtube/transcript.js +54 -0
  84. package/dist/web/assets/index-BGgYMm2u.css +1 -0
  85. package/dist/web/assets/index-BVVgtntL.js +171 -0
  86. package/dist/web/assets/{pdf-BkdJFKQ-.js → pdf-CAJNT254.js} +1 -1
  87. package/dist/web/index.html +2 -2
  88. package/package.json +1 -1
  89. package/dist/web/assets/index-Cw1mnRrx.js +0 -168
  90. package/dist/web/assets/index-kAKdJa-U.css +0 -1
@@ -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
+ }
@@ -1,5 +1,9 @@
1
+ import { chapterCuts, narrationShotFrames } from "./cuts.js";
1
2
  import { editListVersion, } from "./edit-list.js";
3
+ import { chapterCardSeconds } from "./edit-settings.js";
4
+ import { hasLook } from "./look.js";
2
5
  import { motionFor } from "./motion.js";
6
+ import { withTransitions } from "./transitions.js";
3
7
  // 30 fps, 1920×1080 for 16:9 and 1080×1920 for 9:16.
4
8
  export const fps = 30;
5
9
  const frames = {
@@ -39,6 +43,22 @@ export function planRender(input) {
39
43
  ? input.images.length * input.imageSeconds
40
44
  : audio.reduce((sum, segment) => sum + segment.seconds, 0);
41
45
  const totalFrames = Math.max(1, Math.round(totalSeconds * fps));
46
+ const edit = input.edit;
47
+ const narration = input.body === undefined ? undefined : edit?.narration;
48
+ const lengths = narration === undefined
49
+ ? everyLengths(Math.max(1, Math.round(input.imageSeconds * fps)), totalFrames)
50
+ : narrationShotFrames({
51
+ totalFrames,
52
+ fps,
53
+ imageSeconds: input.imageSeconds,
54
+ cutPoints: narration.cutPoints,
55
+ chapterStarts: narration.chapterStarts,
56
+ });
57
+ const planned = shots(input, lengths);
58
+ const transition = edit?.transition;
59
+ const cards = edit?.cards === undefined
60
+ ? []
61
+ : chapterCards(edit.cards.chapters, narration?.cutPoints ?? [], totalFrames);
42
62
  return {
43
63
  gapSeconds: input.gapSeconds,
44
64
  edgeSeconds: input.edgeSeconds,
@@ -51,13 +71,39 @@ export function planRender(input) {
51
71
  height: frame.height,
52
72
  fps,
53
73
  audio,
54
- shots: shots(input, Math.max(1, Math.round(input.imageSeconds * fps)), totalFrames),
74
+ shots: transition === undefined
75
+ ? planned
76
+ : withTransitions(planned, transition.kind, Math.round(transition.seconds * fps)),
77
+ ...(edit?.look !== undefined && hasLook(edit.look) ? { look: edit.look } : {}),
78
+ ...(cards.length > 0 && edit?.cards !== undefined
79
+ ? { cards, cardFont: edit.cards.font }
80
+ : {}),
55
81
  },
56
82
  totalFrames,
57
83
  totalSeconds,
58
84
  output: input.output,
59
85
  };
60
86
  }
87
+ // A card at each chapter start, on the cut the chapter's shot starts with (a chapter at the
88
+ // very start stays there), for 2.5 s or what is left of the video.
89
+ function chapterCards(chapters, cutPoints, totalFrames) {
90
+ const length = Math.round(chapterCardSeconds * fps);
91
+ const cards = [];
92
+ for (const chapter of chapters) {
93
+ const title = chapter.title.trim();
94
+ const moved = chapter.start < 1 / fps ? [] : chapterCuts([chapter.start], cutPoints, Infinity);
95
+ const startFrame = Math.max(0, Math.round((moved[0] ?? chapter.start) * fps));
96
+ const frames = Math.min(length, totalFrames - startFrame);
97
+ if (title === "" || frames < 1)
98
+ continue;
99
+ // Two chapters inside 2.5 s would stack their cards; the second is left out.
100
+ const previous = cards.at(-1);
101
+ if (previous !== undefined && startFrame < previous.startFrame + previous.frames)
102
+ continue;
103
+ cards.push({ title, startFrame, frames });
104
+ }
105
+ return cards;
106
+ }
61
107
  export function spoken(kind) {
62
108
  return kind !== "gap" && kind !== "edge";
63
109
  }
@@ -92,15 +138,26 @@ export function audioTimeline(input, minimumGap = 1 / fps) {
92
138
  }
93
139
  return segments;
94
140
  }
95
- // The images take turns in slideshow order, each for `each` frames, starting over after the
96
- // last until the timeline is full; the last shot is cut to what is left. A timeline shorter
97
- // than one shot is a single shot. The motion goes by the shot's place, not the image
98
- // (`motion.ts`), so an image that comes round again may move another way.
99
- function shots(input, each, totalFrames) {
141
+ // Every N seconds: each shot `each` frames until the timeline is full; the last shot is cut
142
+ // to what is left. A timeline shorter than one shot is a single shot.
143
+ function everyLengths(each, totalFrames) {
100
144
  const count = Math.ceil(totalFrames / each);
101
- return Array.from({ length: count }, (_value, at) => ({
102
- source: { kind: "image", path: input.images[at % input.images.length] ?? "" },
103
- frames: Math.min(each, totalFrames - each * at),
104
- motion: motionFor(input.motionStyle, at, input.zoomPercent),
105
- }));
145
+ return Array.from({ length: count }, (_value, at) => Math.min(each, totalFrames - each * at));
146
+ }
147
+ // The images take turns in slideshow order, one per shot, starting over after the last until
148
+ // the timeline is full. The motion goes by the shot's place, not the image (`motion.ts`), so
149
+ // an image that comes round again may move another way. A moving clip in an image's place
150
+ // plays as it is, with no motion of its own.
151
+ function shots(input, lengths) {
152
+ return lengths.map((frames, at) => {
153
+ const index = at % input.images.length;
154
+ const clip = input.edit?.clips?.[index];
155
+ return clip === undefined
156
+ ? {
157
+ source: { kind: "image", path: input.images[index] ?? "" },
158
+ frames,
159
+ motion: motionFor(input.motionStyle, at, input.zoomPercent),
160
+ }
161
+ : { source: clip, frames, motion: { kind: "still" } };
162
+ });
106
163
  }
@@ -1,6 +1,8 @@
1
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { clipArgs, concatList, joinArgs, runFfmpeg, slideshowClips } from "./ffmpeg.js";
1
+ import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { extname, join } from "node:path";
3
+ import { cardsAss } from "./cards.js";
4
+ import { concatList, joinArgs, runFfmpeg, segmentArgs, slideshowClips } from "./ffmpeg.js";
5
+ import { cardsIn } from "./look.js";
4
6
  // estimate: a zoompan frame from a 4x still costs several plain encoded frames, and a
5
7
  // copying join is nearly free, so progress is weighted by that rather than by clock time.
6
8
  const clipWeight = 4;
@@ -12,24 +14,38 @@ export async function renderSlideshow(run) {
12
14
  const joinMs = msOf(edit.shots.reduce((sum, shot) => sum + shot.frames, 0));
13
15
  // Progress is reported against the video's length, the longer of picture and sound.
14
16
  const totalMs = Math.max(joinMs, edit.audio.reduce((sum, segment) => sum + segment.seconds * 1000, 0));
15
- const clipMs = clips.reduce((sum, clip) => sum + msOf(clip.shot.frames), 0);
17
+ const clipMs = clips.reduce((sum, clip) => sum + msOf(clip.frames), 0);
16
18
  const work = clipMs * clipWeight + joinMs * (run.burnSubtitles ? 1 : copyWeight);
17
19
  const report = (done) => {
18
20
  run.onProgress(work > 0 ? Math.round((Math.min(done, work) / work) * totalMs) : 0);
19
21
  };
20
22
  const workspace = mkdtempSync(join(run.scratch, "render-"));
21
23
  try {
24
+ // Chapter cards are drawn by the clips from a script and a font beside them.
25
+ const cards = edit.cards ?? [];
26
+ const font = edit.cardFont;
27
+ if (cards.length > 0) {
28
+ if (font === undefined)
29
+ throw new Error("Slopify hit an internal error (the chapter cards have no font). Use Re-run section on Video; if it happens again, use Download diagnostics in Settings and report it.");
30
+ mkdirSync(join(workspace, "fonts"), { mode: 0o700 });
31
+ copyFileSync(font.path, join(workspace, "fonts", `card${extname(font.path)}`));
32
+ }
22
33
  let rendered = 0;
23
34
  for (const clip of clips) {
24
35
  const before = rendered;
36
+ const script = clip.name.replace(/\.mp4$/, ".ass");
37
+ const shown = cardsIn(cards, clip.segment.start, clip.segment.count);
38
+ if (shown.length > 0 && font !== undefined)
39
+ writeFileSync(join(workspace, script), cardsAss(edit, shown, font.name, clip.segment.start), { mode: 0o600 });
25
40
  await runFfmpeg({
26
41
  bin: run.bin,
27
- args: clipArgs(edit, clip.shot, join(workspace, clip.name), run.burnSubtitles),
42
+ ...(cards.length > 0 ? { cwd: workspace } : {}),
43
+ args: segmentArgs(edit, clip.segment, join(workspace, clip.name), run.burnSubtitles, script),
28
44
  signal: run.signal,
29
45
  log: run.log,
30
46
  onProgress: (elapsedMs) => report(before + elapsedMs * clipWeight),
31
47
  });
32
- rendered += msOf(clip.shot.frames) * clipWeight;
48
+ rendered += msOf(clip.frames) * clipWeight;
33
49
  }
34
50
  const list = join(workspace, "slides.ffconcat");
35
51
  writeFileSync(list, concatList(order), { mode: 0o600 });
@@ -0,0 +1,51 @@
1
+ // Transitions without a longer render. The slideshow is still encoded clip by clip and
2
+ // joined by the concat demuxer (`ffmpeg.ts`); a transition is one more short clip between
3
+ // two shots. The shot before gives up its last floor(T / 2) frames and the shot after its
4
+ // first ceil(T / 2), and the T-frame transition clip plays the first shot's picture from
5
+ // where it was cut, running on past its end, blended into the second's from half a
6
+ // transition before its start. Every frame of the timeline is still played exactly once, so
7
+ // the video stays as long as its sound.
8
+ // The frames a transition of `frames` takes from the shot before and the shot after.
9
+ export function transitionHalves(frames) {
10
+ const tail = Math.floor(frames / 2);
11
+ return { tail, head: frames - tail };
12
+ }
13
+ // Gives every shot after the first a transition of `frames` frames, shortened where a shot is
14
+ // too short to give up its share and still show at least one frame of its own, and left a
15
+ // hard cut where under two frames would remain.
16
+ export function withTransitions(shots, kind, frames) {
17
+ return shots.map((shot, index) => {
18
+ const before = shots[index - 1];
19
+ if (before === undefined)
20
+ return shot;
21
+ const room = Math.floor((Math.min(before.frames, shot.frames) - 1) / 2);
22
+ const length = Math.min(Math.round(frames), room);
23
+ return length < 2 ? shot : { ...shot, transition: { kind, frames: length } };
24
+ });
25
+ }
26
+ // The clips in timeline order, adding up to the shots' frames exactly.
27
+ export function segments(shots) {
28
+ const out = [];
29
+ let start = 0;
30
+ for (const [index, shot] of shots.entries()) {
31
+ const entering = shot.transition;
32
+ const leaving = shots[index + 1]?.transition;
33
+ const head = entering === undefined ? 0 : transitionHalves(entering.frames).head;
34
+ const tail = leaving === undefined ? 0 : transitionHalves(leaving.frames).tail;
35
+ if (entering !== undefined) {
36
+ const before = transitionHalves(entering.frames).tail;
37
+ out.push({
38
+ kind: "transition",
39
+ from: index - 1,
40
+ to: index,
41
+ count: entering.frames,
42
+ start: start - before,
43
+ });
44
+ start += head;
45
+ }
46
+ const count = shot.frames - head - tail;
47
+ out.push({ kind: "shot", shot: index, from: head, count, start });
48
+ start += count + tail;
49
+ }
50
+ return out;
51
+ }