@gentbajko/slopify 2.0.2 → 2.1.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 (224) hide show
  1. package/SUBTITLES.md +1 -1
  2. package/dist/adapters/alignment/protocol.js +11 -1
  3. package/dist/adapters/alignment/runner.js +4 -1
  4. package/dist/adapters/alignment/threads.js +11 -0
  5. package/dist/adapters/alignment/worker.js +66 -9
  6. package/dist/adapters/explain.js +93 -0
  7. package/dist/adapters/ffmpeg.js +3 -3
  8. package/dist/adapters/host-cli/index.js +34 -3
  9. package/dist/adapters/host-cli/transport.js +4 -4
  10. package/dist/adapters/image/bytes.js +2 -2
  11. package/dist/adapters/image/codex-output.js +12 -12
  12. package/dist/adapters/image/codex.js +24 -8
  13. package/dist/adapters/image/fal.js +12 -6
  14. package/dist/adapters/image/google.js +18 -8
  15. package/dist/adapters/image/models.js +8 -2
  16. package/dist/adapters/image/openai.js +15 -6
  17. package/dist/adapters/image/replicate.js +16 -7
  18. package/dist/adapters/llm/claude-code-models.js +1 -1
  19. package/dist/adapters/llm/claude-code.js +16 -7
  20. package/dist/adapters/llm/cli-login-error.js +2 -1
  21. package/dist/adapters/llm/codex-models.js +1 -1
  22. package/dist/adapters/llm/codex.js +5 -6
  23. package/dist/adapters/llm/document-workspace.js +2 -2
  24. package/dist/adapters/llm/gemini-models.js +1 -1
  25. package/dist/adapters/llm/gemini-workspace.js +1 -1
  26. package/dist/adapters/llm/gemini.js +15 -11
  27. package/dist/adapters/llm/openrouter.js +21 -9
  28. package/dist/adapters/llm/run-cli.js +14 -11
  29. package/dist/adapters/tts/cartesia.js +10 -3
  30. package/dist/adapters/tts/elevenlabs.js +18 -6
  31. package/dist/adapters/tts/inworld-async.js +47 -16
  32. package/dist/adapters/tts/inworld.js +25 -15
  33. package/dist/adapters/tts/openai.js +18 -6
  34. package/dist/assets/document/SOURCE.txt +7 -0
  35. package/dist/assets/document/background.jpg +0 -0
  36. package/dist/assets/document/fonts/Cinzel-Black.ttf +0 -0
  37. package/dist/assets/document/fonts/Cinzel-Bold.ttf +0 -0
  38. package/dist/assets/document/fonts/Cinzel-Medium.ttf +0 -0
  39. package/dist/assets/document/fonts/Cinzel-Regular.ttf +0 -0
  40. package/dist/assets/document/fonts/OFL.txt +93 -0
  41. package/dist/assets/document/fonts/SOURCE.txt +11 -0
  42. package/dist/catalog/registry.js +13 -10
  43. package/dist/catalog/store.js +1 -1
  44. package/dist/catalog/validate.js +16 -6
  45. package/dist/edge/cli.js +33 -7
  46. package/dist/edge/docker-launch.js +6 -6
  47. package/dist/edge/docker-projects/engine.js +34 -13
  48. package/dist/edge/docker-projects/install.js +8 -8
  49. package/dist/edge/docker-projects/state.js +11 -7
  50. package/dist/edge/docker-projects/tree.js +6 -6
  51. package/dist/edge/docker.js +13 -13
  52. package/dist/edge/host-cli.js +14 -0
  53. package/dist/edge/http/actions.js +27 -25
  54. package/dist/edge/http/app.js +3 -3
  55. package/dist/edge/http/audio-preview.js +1 -1
  56. package/dist/edge/http/checkpoints.js +5 -1
  57. package/dist/edge/http/diagnostics.js +3 -1
  58. package/dist/edge/http/draft-files.js +2 -2
  59. package/dist/edge/http/draft-problem.js +11 -1
  60. package/dist/edge/http/files.js +4 -4
  61. package/dist/edge/http/folder-location-schema.js +5 -8
  62. package/dist/edge/http/folder-location.js +13 -8
  63. package/dist/edge/http/fonts.js +22 -10
  64. package/dist/edge/http/host-cli.js +61 -14
  65. package/dist/edge/http/multipart.js +7 -5
  66. package/dist/edge/http/open-folder.js +2 -2
  67. package/dist/edge/http/planning.js +4 -4
  68. package/dist/edge/http/problem.js +2 -2
  69. package/dist/edge/http/project-templates.js +7 -3
  70. package/dist/edge/http/projects.js +8 -8
  71. package/dist/edge/http/prompts.js +9 -4
  72. package/dist/edge/http/providers.js +15 -7
  73. package/dist/edge/http/revision-files.js +8 -4
  74. package/dist/edge/http/revisions.js +30 -4
  75. package/dist/edge/http/schedules.js +9 -9
  76. package/dist/edge/http/settings.js +15 -9
  77. package/dist/edge/http/staging.js +5 -5
  78. package/dist/edge/http/storage.js +4 -4
  79. package/dist/edge/http/subtitles.js +1 -1
  80. package/dist/edge/http/tutorial.js +2 -2
  81. package/dist/edge/http/update.js +3 -3
  82. package/dist/host-cli/install.js +28 -6
  83. package/dist/host-cli/open-folder.js +95 -0
  84. package/dist/host-cli/paths.js +6 -6
  85. package/dist/host-cli/runtime.js +2 -0
  86. package/dist/host-cli/server.js +1 -1
  87. package/dist/host-cli/service.js +11 -11
  88. package/dist/host-cli/status.js +7 -3
  89. package/dist/kernel/config/index.js +6 -4
  90. package/dist/kernel/db/migrate.js +27 -2
  91. package/dist/kernel/db/migrations/0013-schedule-topic-queue.sql +4 -0
  92. package/dist/kernel/db/migrations/0014-document-stage.sql +44 -0
  93. package/dist/kernel/db/migrations/0015-description-prompts.sql +13 -0
  94. package/dist/kernel/lock.js +4 -3
  95. package/dist/kernel/pipeline.js +11 -1
  96. package/dist/kernel/ports/host-cli.js +14 -0
  97. package/dist/kernel/ports/subtitles.js +15 -1
  98. package/dist/kernel/runner/attempt.js +26 -2
  99. package/dist/kernel/runner/graph.js +6 -1
  100. package/dist/main.js +8 -4
  101. package/dist/slices/admission/model.js +7 -1
  102. package/dist/slices/admission/rules.js +98 -22
  103. package/dist/slices/admission/schema.js +16 -3
  104. package/dist/slices/admission/start.js +3 -2
  105. package/dist/slices/article/continuation.js +4 -2
  106. package/dist/slices/article/plain.js +2 -2
  107. package/dist/slices/article/provided-entries.js +3 -3
  108. package/dist/slices/article/run.js +2 -2
  109. package/dist/slices/article/segments.js +5 -3
  110. package/dist/slices/control/providers.js +20 -7
  111. package/dist/slices/document/blocks.js +129 -0
  112. package/dist/slices/document/flow.js +117 -0
  113. package/dist/slices/document/fonts.js +54 -0
  114. package/dist/slices/document/model.js +11 -0
  115. package/dist/slices/document/pages.js +198 -0
  116. package/dist/slices/document/render.js +242 -0
  117. package/dist/slices/document/sources.js +35 -0
  118. package/dist/slices/document/theme.js +158 -0
  119. package/dist/slices/document/writer.js +66 -0
  120. package/dist/slices/estimate/index.js +14 -2
  121. package/dist/slices/fonts/catalog.js +1 -1
  122. package/dist/slices/fonts/upload.js +1 -1
  123. package/dist/slices/images/run.js +5 -5
  124. package/dist/slices/library/lint.js +13 -7
  125. package/dist/slices/library/model.js +1 -1
  126. package/dist/slices/library/slots.js +6 -3
  127. package/dist/slices/narration/concat.js +1 -1
  128. package/dist/slices/narration/pronunciation-chunks.js +3 -3
  129. package/dist/slices/narration/pronunciation.js +2 -2
  130. package/dist/slices/narration/run.js +7 -7
  131. package/dist/slices/narration/steering.js +3 -3
  132. package/dist/slices/play-drafts/convert.js +35 -5
  133. package/dist/slices/play-drafts/readiness.js +15 -8
  134. package/dist/slices/play-drafts/review-inputs.js +7 -4
  135. package/dist/slices/play-drafts/review.js +3 -3
  136. package/dist/slices/play-drafts/schema.js +22 -5
  137. package/dist/slices/play-drafts/service.js +3 -1
  138. package/dist/slices/play-drafts/uploads.js +1 -1
  139. package/dist/slices/project-templates/from-project.js +10 -0
  140. package/dist/slices/project-templates/setup.js +1 -0
  141. package/dist/slices/rebuild/admission-repo.js +2 -2
  142. package/dist/slices/rebuild/preview-details.js +11 -1
  143. package/dist/slices/rebuild/recipe-audio.js +3 -0
  144. package/dist/slices/rebuild/recipe-build.js +6 -1
  145. package/dist/slices/rebuild/recipe-document.js +36 -0
  146. package/dist/slices/rebuild/recipe-exports.js +14 -2
  147. package/dist/slices/rebuild/recipe-fixture.js +4 -0
  148. package/dist/slices/rebuild/recipe-legacy.js +6 -1
  149. package/dist/slices/rebuild/recipe-model.js +2 -0
  150. package/dist/slices/rebuild/recipe-narration-text.js +1 -1
  151. package/dist/slices/rebuild/recipe-provider-choice.js +3 -0
  152. package/dist/slices/rebuild/recipe-save.js +15 -3
  153. package/dist/slices/rebuild/recipe-validation.js +15 -2
  154. package/dist/slices/rebuild/recipe-visual.js +11 -2
  155. package/dist/slices/rebuild/recipe-work.js +15 -0
  156. package/dist/slices/rebuild/recipe-youtube.js +33 -0
  157. package/dist/slices/rebuild/recovery-selection.js +2 -0
  158. package/dist/slices/rebuild/recovery.js +12 -7
  159. package/dist/slices/rebuild/repo.js +3 -3
  160. package/dist/slices/rebuild/runtime-actions.js +2 -0
  161. package/dist/slices/rebuild/runtime-admission.js +4 -4
  162. package/dist/slices/rebuild/runtime-article.js +5 -5
  163. package/dist/slices/rebuild/runtime-checkpoints.js +4 -4
  164. package/dist/slices/rebuild/runtime-document.js +85 -0
  165. package/dist/slices/rebuild/runtime-export-inputs.js +7 -6
  166. package/dist/slices/rebuild/runtime-export.js +39 -23
  167. package/dist/slices/rebuild/runtime-local.js +6 -6
  168. package/dist/slices/rebuild/runtime-narration-text.js +46 -7
  169. package/dist/slices/rebuild/runtime-piece-label.js +66 -0
  170. package/dist/slices/rebuild/runtime-provider.js +6 -4
  171. package/dist/slices/rebuild/runtime-publication.js +4 -3
  172. package/dist/slices/rebuild/runtime-run.js +22 -4
  173. package/dist/slices/rebuild/runtime-store.js +7 -2
  174. package/dist/slices/rebuild/runtime-subtitles.js +82 -16
  175. package/dist/slices/rebuild/runtime-youtube.js +84 -0
  176. package/dist/slices/rebuild/transition-repo.js +8 -6
  177. package/dist/slices/reruns/cascade.js +6 -4
  178. package/dist/slices/reruns/index.js +1 -1
  179. package/dist/slices/research/run.js +9 -7
  180. package/dist/slices/research/synthesis.js +4 -2
  181. package/dist/slices/revisions/mutation-assets.js +26 -8
  182. package/dist/slices/revisions/mutation-cues.js +4 -2
  183. package/dist/slices/revisions/projection.js +2 -1
  184. package/dist/slices/revisions/rules.js +18 -9
  185. package/dist/slices/revisions/schema.js +1 -1
  186. package/dist/slices/schedules/repo.js +55 -11
  187. package/dist/slices/schedules/scheduler.js +25 -8
  188. package/dist/slices/schedules/schema.js +19 -4
  189. package/dist/slices/schedules/service.js +2 -0
  190. package/dist/slices/settings/cli-paths.js +7 -4
  191. package/dist/slices/settings/models.js +4 -4
  192. package/dist/slices/settings/playback.js +2 -2
  193. package/dist/slices/storage/assets.js +5 -5
  194. package/dist/slices/storage/downloads.js +1 -0
  195. package/dist/slices/storage/layout.js +7 -1
  196. package/dist/slices/storage/model.js +3 -0
  197. package/dist/slices/subtitles/captions.js +1 -1
  198. package/dist/slices/subtitles/prepare.js +7 -6
  199. package/dist/slices/subtitles/transcript.js +1 -1
  200. package/dist/slices/telemetry/model.js +1 -1
  201. package/dist/slices/thumbnail/run.js +8 -8
  202. package/dist/slices/video/audio-export.js +11 -3
  203. package/dist/slices/video/audio-inputs.js +2 -2
  204. package/dist/slices/video/edit-list.js +73 -0
  205. package/dist/slices/video/ffmpeg.js +129 -61
  206. package/dist/slices/video/motion.js +51 -0
  207. package/dist/slices/video/plan.js +62 -28
  208. package/dist/slices/video/reuse-audio.js +2 -0
  209. package/dist/slices/video/run.js +23 -12
  210. package/dist/slices/video/slideshow.js +48 -0
  211. package/dist/slices/video/write-export.js +17 -25
  212. package/dist/slices/youtube/answer.js +199 -0
  213. package/dist/slices/youtube/model.js +27 -0
  214. package/dist/slices/youtube/timestamps.js +24 -0
  215. package/dist/slices/youtube/transcript.js +47 -0
  216. package/dist/updater/plan.js +1 -1
  217. package/dist/updater/service.js +4 -3
  218. package/dist/updater/worker.js +6 -4
  219. package/dist/web/assets/index-B6NbhpjD.css +1 -0
  220. package/dist/web/assets/index-ComF5gwP.js +142 -0
  221. package/dist/web/index.html +2 -2
  222. package/package.json +4 -2
  223. package/dist/web/assets/index-CZNiHm8P.css +0 -1
  224. package/dist/web/assets/index-DPgGLX5m.js +0 -137
@@ -5,9 +5,9 @@ export async function audioInputs(deps, projectId, outputs, signal) {
5
5
  const dir = projectDir(deps.paths, projectId);
6
6
  const body = await audio(deps, outputs, "audio_body", dir, signal);
7
7
  if (!body)
8
- throw new Error("the project has no narration audio to render against");
8
+ throw new Error("The video has no narration audio to play. Let the Narration stage finish, then Retry stage.");
9
9
  if (body.seconds <= 0)
10
- throw new Error("the narration audio decodes to no sound");
10
+ throw new Error("The narration audio contains no sound; the file may be damaged or empty. Regenerate the narration (or upload a different audio file in Edit project → Narration), then Retry stage.");
11
11
  const intro = await audio(deps, outputs, "audio_intro", dir, signal);
12
12
  const outro = await audio(deps, outputs, "audio_outro", dir, signal);
13
13
  return { body, intro, outro };
@@ -0,0 +1,73 @@
1
+ import { z } from "zod";
2
+ // The edit list is the whole video as data: what plays, for how many frames, how it moves,
3
+ // and the audio under it. Planning (`plan.ts`) writes one and the renderer (`ffmpeg.ts`,
4
+ // `slideshow.ts`) reads nothing else, so a new kind of shot or movement is a new case
5
+ // here, in the planner and in the clip filter, and never a change to how the render runs.
6
+ // It is plain JSON, recorded in render.json, and carries a version so a later Slopify can
7
+ // tell an older list from its own.
8
+ //
9
+ // Extension points, none of them built yet: `Shot.source` gains a `video` kind for clips,
10
+ // `Shot.transition` says how a shot enters from the one before, and effects (a colour
11
+ // grade, a text card) become a list on the shot. Each is a new optional field or union
12
+ // case, so a version-1 list stays readable; a change that alters what an existing field
13
+ // means bumps the version instead.
14
+ export const editListVersion = 1;
15
+ // The same list with every file path passed through `map`: render.json records paths
16
+ // relative to the project folder.
17
+ export function withPaths(edit, map) {
18
+ return {
19
+ ...edit,
20
+ audio: edit.audio.map((segment) => ({
21
+ ...segment,
22
+ path: segment.path === null ? null : map(segment.path),
23
+ })),
24
+ shots: edit.shots.map((shot) => ({
25
+ ...shot,
26
+ source: { ...shot.source, path: map(shot.source.path) },
27
+ })),
28
+ };
29
+ }
30
+ const share = z.number().min(0).max(1);
31
+ const point = z.object({ x: share, y: share }).strict();
32
+ const percent = z.number().min(0);
33
+ const motionSchema = z.discriminatedUnion("kind", [
34
+ z.object({ kind: z.literal("zoom"), direction: z.enum(["in", "out"]), percent }).strict(),
35
+ z.object({ kind: z.literal("pan"), from: point, to: point, percent }).strict(),
36
+ z.object({ kind: z.literal("still") }).strict(),
37
+ ]);
38
+ const count = z.number().int().positive();
39
+ const editListSchema = z
40
+ .object({
41
+ version: z.literal(editListVersion),
42
+ width: count,
43
+ height: count,
44
+ fps: count,
45
+ audio: z.array(z
46
+ .object({
47
+ kind: z.enum(["intro", "body", "outro", "gap", "edge"]),
48
+ path: z.string().nullable(),
49
+ seconds: z.number().nonnegative(),
50
+ })
51
+ .strict()),
52
+ shots: z
53
+ .array(z
54
+ .object({
55
+ source: z.object({ kind: z.literal("image"), path: z.string().min(1) }).strict(),
56
+ frames: count,
57
+ motion: motionSchema,
58
+ })
59
+ .strict())
60
+ .min(1),
61
+ })
62
+ .strict();
63
+ // Reads a recorded edit list back. Strict, so a list written by a newer Slopify, with a
64
+ // field or kind this one does not know, is refused rather than rendered without it.
65
+ export function readEditList(value) {
66
+ const version = typeof value === "object" && value !== null && "version" in value ? value.version : undefined;
67
+ if (version !== editListVersion)
68
+ throw new Error(`This video's edit list is version ${String(version)}, and this Slopify reads version ${editListVersion}. Update Slopify, or use Re-run section on Video to plan it again.`);
69
+ const parsed = editListSchema.safeParse(value);
70
+ if (!parsed.success)
71
+ throw new Error(`This video's edit list is damaged (${parsed.error.issues[0]?.path.join(".") || "the list"}: ${parsed.error.issues[0]?.message ?? "not readable"}). Use Re-run section on Video to plan it again.`);
72
+ return parsed.data;
73
+ }
@@ -1,12 +1,14 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { isAbsolute, resolve as resolvePath } from "node:path";
3
- import { zoomBy, zoomFrom, zoomTo } from "./plan.js";
3
+ import { decimal, zoomRange } from "./plan.js";
4
4
  // Hand-rolled per the standards: the filtergraph is the load-bearing part of this slice
5
5
  // and a wrapper would hide it. Every value goes into an argument array, never a shell
6
6
  // string, so a title or a filename cannot become a command.
7
7
  // zoompan works on a still that has been pre-scaled, because it steps the zoom in
8
8
  // sub-pixel increments and a source at output resolution visibly jitters where the zoom is
9
- // meant to be smooth and linear. Four times the frame is enough at 122.5%.
9
+ // meant to be smooth and linear. A pan steps its crop window in whole source pixels, so
10
+ // the same four times gives it quarter-pixel steps on screen. Four times the frame is
11
+ // enough at 122.5%.
10
12
  const prescale = 4;
11
13
  const sampleRate = 44100;
12
14
  const channelLayout = "stereo";
@@ -24,64 +26,75 @@ export function resolveFfmpeg(env, bundled) {
24
26
  }
25
27
  // Never PATH: a Slopify that quietly rendered with whichever ffmpeg the machine
26
28
  // happens to carry would not be testing the binary it ships with.
27
- throw new Error("No ffmpeg binary is available. Slopify ships one through ffmpeg-static; if this " +
28
- "platform has no build, point SLOPIFY_FFMPEG at an ffmpeg executable.");
29
+ throw new Error("Slopify can't find ffmpeg, the tool it uses to make audio and video: its bundled copy is " +
30
+ "missing for this computer. Reinstall Slopify, or set the SLOPIFY_FFMPEG environment " +
31
+ "variable to the path of an ffmpeg program.");
29
32
  }
30
- export function renderArgs(plan, burnSubtitles = false) {
31
- const inputs = [];
32
- for (const slot of plan.images) {
33
- inputs.push("-i", slot.path);
34
- }
35
- const audioAt = [];
36
- for (const segment of plan.audio) {
37
- audioAt.push(plan.images.length + audioAt.length);
38
- if (segment.path === null) {
39
- inputs.push("-f", "lavfi", "-t", seconds(segment.seconds), "-i", `anullsrc=r=${sampleRate}:cl=${channelLayout}`);
40
- continue;
33
+ export function slideshowClips(edit) {
34
+ const clips = [];
35
+ 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]);
40
+ let name = named.get(key);
41
+ if (name === undefined) {
42
+ name = `c${clips.length + 1}.mp4`;
43
+ named.set(key, name);
44
+ clips.push({ name, shot });
41
45
  }
42
- inputs.push("-i", segment.path);
43
- }
46
+ return name;
47
+ });
48
+ return { clips, order };
49
+ }
50
+ // Clips that are joined by copying get the final encode's settings. Clips that will be
51
+ // decoded again under burned-in captions are kept closer to the source, so the second
52
+ // encode is not a visible second generation.
53
+ const intermediateCrf = "16";
54
+ 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;
44
60
  return [
45
- "-hide_banner",
46
- "-nostdin",
47
- "-loglevel",
48
- "error",
49
- // Progress on stdout keeps stderr free to carry only what went wrong.
50
- "-progress",
51
- "pipe:1",
52
- "-nostats",
53
- "-y",
54
- ...inputs,
61
+ ...progressArgs,
62
+ "-i",
63
+ shot.source.path,
55
64
  "-filter_complex",
56
- filterGraph(plan, audioAt, burnSubtitles),
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]`,
57
71
  "-map",
58
72
  "[v]",
59
- ...(plan.audio.length > 0 ? ["-map", "[a]"] : []),
60
- "-c:v",
61
- "libx264",
62
- "-pix_fmt",
63
- "yuv420p",
64
- ...(plan.audio.length > 0 ? ["-c:a", "aac"] : ["-an"]),
65
- "-movflags",
66
- "+faststart",
67
- plan.output,
73
+ ...videoCodec,
74
+ ...(intermediate ? ["-crf", intermediateCrf] : []),
75
+ "-an",
76
+ output,
68
77
  ];
69
78
  }
70
- function filterGraph(plan, audioAt, burnSubtitles) {
79
+ // The concat demuxer's own format. Names are resolved beside the list and are this
80
+ // module's `cN.mp4`, so nothing in them needs quoting.
81
+ export function concatList(order) {
82
+ return `ffconcat version 1.0\n${order.map((name) => `file ${name}`).join("\n")}\n`;
83
+ }
84
+ export function joinArgs(edit, output, list, burnSubtitles = false) {
85
+ const inputs = ["-f", "concat", "-i", list];
86
+ const audioAt = [];
87
+ for (const segment of edit.audio) {
88
+ audioAt.push(1 + audioAt.length);
89
+ if (segment.path === null) {
90
+ inputs.push("-f", "lavfi", "-t", seconds(segment.seconds), "-i", `anullsrc=r=${sampleRate}:cl=${channelLayout}`);
91
+ continue;
92
+ }
93
+ inputs.push("-i", segment.path);
94
+ }
71
95
  const chains = [];
72
- const wide = plan.width * prescale;
73
- const tall = plan.height * prescale;
74
- plan.images.forEach((slot, at) => {
75
- chains.push(`[${at}:v]trim=end_frame=1,setpts=PTS-STARTPTS,` +
76
- // Cover the frame and centre-crop, never letterbox.
77
- `scale=${wide}:${tall}:force_original_aspect_ratio=increase,crop=${wide}:${tall},` +
78
- `zoompan=z='${zoomExpression(slot)}':d=${slot.frames}:` +
79
- `x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':` +
80
- `s=${plan.width}x${plan.height}:fps=${plan.fps},setsar=1[v${at}]`);
81
- });
82
- chains.push(`${plan.images.map((_slot, at) => `[v${at}]`).join("")}concat=n=${plan.images.length}:v=1:a=0${burnSubtitles ? "[uncaptioned]" : "[v]"}`);
83
96
  if (burnSubtitles)
84
- chains.push("[uncaptioned]ass=filename=subtitles.ass:fontsdir=fonts[v]");
97
+ chains.push("[0:v]ass=filename=subtitles.ass:fontsdir=fonts[v]");
85
98
  audioAt.forEach((input, at) => {
86
99
  // The segments come from different files and the silence from lavfi, so they are
87
100
  // brought to one format before concat, which refuses to join mismatched streams.
@@ -90,17 +103,71 @@ function filterGraph(plan, audioAt, burnSubtitles) {
90
103
  if (audioAt.length > 0) {
91
104
  chains.push(`${audioAt.map((_input, at) => `[a${at}]`).join("")}concat=n=${audioAt.length}:v=0:a=1[a]`);
92
105
  }
93
- return chains.join(";");
106
+ return [
107
+ ...progressArgs,
108
+ ...inputs,
109
+ ...(chains.length > 0 ? ["-filter_complex", chains.join(";")] : []),
110
+ "-map",
111
+ burnSubtitles ? "[v]" : "0:v",
112
+ ...(edit.audio.length > 0 ? ["-map", "[a]"] : []),
113
+ ...(burnSubtitles ? videoCodec : ["-c:v", "copy"]),
114
+ ...(edit.audio.length > 0 ? ["-c:a", "aac"] : ["-an"]),
115
+ "-movflags",
116
+ "+faststart",
117
+ output,
118
+ ];
94
119
  }
95
- // Odd images 100% → 122.5%, even images 122.5% → 100%, linear over the slot. `on` is
96
- // zoompan's output frame counter, 0 to d-1, and a one-frame slot has no span to divide
97
- // by, so it holds the zoom it starts at.
98
- function zoomExpression(slot) {
99
- const span = slot.frames - 1;
100
- if (slot.zoom === "in") {
101
- return span < 1 ? String(zoomFrom) : `${zoomFrom}+${zoomBy}*on/${span}`;
120
+ const progressArgs = [
121
+ "-hide_banner",
122
+ "-nostdin",
123
+ "-loglevel",
124
+ "error",
125
+ // Progress on stdout keeps stderr free to carry only what went wrong.
126
+ "-progress",
127
+ "pipe:1",
128
+ "-nostats",
129
+ "-y",
130
+ ];
131
+ const videoCodec = ["-c:v", "libx264", "-pix_fmt", "yuv420p"];
132
+ const centredX = "iw/2-(iw/zoom/2)";
133
+ const centredY = "ih/2-(ih/zoom/2)";
134
+ // 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) {
139
+ if (motion.kind === "still")
140
+ return undefined;
141
+ const range = zoomRange(motion.percent);
142
+ if (range === undefined)
143
+ return undefined;
144
+ const span = frames - 1;
145
+ if (motion.kind === "zoom") {
146
+ // Zoom in rises from 100%, zoom out falls back to it, linear over the shot.
147
+ const z = motion.direction === "in"
148
+ ? span < 1
149
+ ? range.from
150
+ : `${range.from}+${range.by}*on/${span}`
151
+ : span < 1
152
+ ? range.to
153
+ : `${range.to}-${range.by}*on/${span}`;
154
+ return { z, x: centredX, y: centredY };
102
155
  }
103
- return span < 1 ? String(zoomTo) : `${zoomTo}-${zoomBy}*on/${span}`;
156
+ return {
157
+ 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)})`,
160
+ };
161
+ }
162
+ // A share of the room from `from` to `to`, linear over the shot. Built in whole
163
+ // thousandths as decimal text, like the zoom, so 1 - 0.5 reads 0.5 and never
164
+ // 0.49999999999999994.
165
+ function travel(from, to, span) {
166
+ const start = Math.round(from * 1000);
167
+ const by = Math.round(to * 1000) - start;
168
+ if (span < 1 || by === 0)
169
+ return decimal(start);
170
+ return `${decimal(start)}${by > 0 ? "+" : "-"}${decimal(Math.abs(by))}*on/${span}`;
104
171
  }
105
172
  function seconds(value) {
106
173
  return value.toFixed(3);
@@ -180,7 +247,7 @@ export function runFfmpeg(run) {
180
247
  run.signal.addEventListener("abort", stop, { once: true });
181
248
  child.on("error", (error) => {
182
249
  run.signal.removeEventListener("abort", stop);
183
- reject(new Error(`ffmpeg could not be started: ${error.message}`));
250
+ reject(new Error(`Slopify could not start ffmpeg, the tool it uses to make audio and video (${error.message}). Reinstall Slopify, or if you set SLOPIFY_FFMPEG, check that it points to a working ffmpeg program.`));
184
251
  });
185
252
  child.on("close", (code, signal) => {
186
253
  run.signal.removeEventListener("abort", stop);
@@ -240,7 +307,8 @@ function messageOf(error) {
240
307
  }
241
308
  function failure(code, signal, errors) {
242
309
  const ended = code === null ? `killed by ${signal ?? "a signal"}` : `exited with code ${code}`;
243
- return errors.length === 0
310
+ const said = errors.length === 0
244
311
  ? `ffmpeg ${ended} and wrote nothing to its error stream`
245
312
  : `ffmpeg ${ended}: ${errors.join(" / ")}`;
313
+ return `The audio/video export failed (${said}). Retry stage; if it fails again, make sure your disk has free space (see Settings → Backup & storage), then use Download diagnostics in Settings and report it.`;
246
314
  }
@@ -0,0 +1,51 @@
1
+ // floor: a pan needs room to travel, and a crop of 100% leaves none. Below 10% the
2
+ // travel is a few percent of the frame over the whole shot, which reads as a wobble
3
+ // rather than a pan, so a pan uses the project's zoom or 10%, whichever is more; with
4
+ // Zoom (%) at 0 the pans still move and zoom motions keep the still still.
5
+ export const panMinimumPercent = 10;
6
+ const left = { x: 0, y: 0.5 };
7
+ const right = { x: 1, y: 0.5 };
8
+ const top = { x: 0.5, y: 0 };
9
+ const bottom = { x: 0.5, y: 1 };
10
+ // The order the pans take turns in: across one way, back, down, back up.
11
+ const pans = [
12
+ [left, right],
13
+ [right, left],
14
+ [top, bottom],
15
+ [bottom, top],
16
+ ];
17
+ // The motion for the slot at `at` (0-based) of the timeline. Everything turns on the
18
+ // slot's place and nothing else, so the same project plans the same list every time and
19
+ // a re-render is identical. Zoom: in, out, in, ... Pan: the four directions above in
20
+ // turn. Mixed: zoom and pan take turns, each keeping its own alternation, so the slots
21
+ // run zoom in, pan left to right, zoom out, pan right to left, zoom in, pan top to
22
+ // bottom, ...
23
+ export function motionFor(style, at, zoomPercent) {
24
+ switch (style) {
25
+ case "still":
26
+ return { kind: "still" };
27
+ case "zoom":
28
+ return zoom(at, zoomPercent);
29
+ case "pan":
30
+ return pan(at, zoomPercent);
31
+ case "mixed":
32
+ return at % 2 === 0 ? zoom(at / 2, zoomPercent) : pan((at - 1) / 2, zoomPercent);
33
+ }
34
+ }
35
+ function zoom(turn, percent) {
36
+ // Rounded to the half steps admission allows, which is what the renderer can write
37
+ // exactly; 0% is no zoom at all.
38
+ const halves = Math.round(percent * 2);
39
+ if (halves <= 0)
40
+ return { kind: "still" };
41
+ return { kind: "zoom", direction: turn % 2 === 0 ? "in" : "out", percent: halves / 2 };
42
+ }
43
+ function pan(turn, percent) {
44
+ const [from, to] = pans[turn % pans.length] ?? [left, right];
45
+ return {
46
+ kind: "pan",
47
+ from,
48
+ to,
49
+ percent: Math.max(Math.round(percent * 2) / 2, panMinimumPercent),
50
+ };
51
+ }
@@ -1,46 +1,79 @@
1
+ import { editListVersion, } from "./edit-list.js";
2
+ import { motionFor } from "./motion.js";
1
3
  // 30 fps, 1920×1080 for 16:9 and 1080×1920 for 9:16.
2
4
  export const fps = 30;
3
5
  const frames = {
4
6
  "16:9": { width: 1920, height: 1080 },
5
7
  "9:16": { width: 1080, height: 1920 },
6
8
  };
7
- // 100% → 122.5%, linear, centred, alternating per image. The three are written out
8
- // rather than derived from one another, because they are formatted into an ffmpeg
9
- // expression and 1.225 - 1 is not 0.225 in binary floating point.
10
- export const zoomFrom = 1;
11
- export const zoomTo = 1.225;
12
- export const zoomBy = 0.225;
9
+ // A zoom shot zooms linearly and centred between 100% and 100% + the project's zoom
10
+ // percent; a pan holds 100% + its percent while it travels. The ends are formatted into an ffmpeg
11
+ // expression, so they are built from whole thousandths as decimal text rather than by
12
+ // float arithmetic: 1 + 0.225 is exact, but 1.225 - 1 is not 0.225 in binary floating
13
+ // point, and a percent in half steps is always a whole number of thousandths.
14
+ export const zoomFrom = "1";
15
+ // undefined for 0%: the still does not move.
16
+ export function zoomRange(percent) {
17
+ const thousandths = Math.round(percent * 10);
18
+ if (thousandths <= 0)
19
+ return undefined;
20
+ return { from: zoomFrom, to: decimal(1000 + thousandths), by: decimal(thousandths) };
21
+ }
22
+ // Whole thousandths as decimal text, for anything that goes into an ffmpeg expression.
23
+ export function decimal(thousandths) {
24
+ const fraction = String(thousandths % 1000)
25
+ .padStart(3, "0")
26
+ .replace(/0+$/, "");
27
+ const whole = String(Math.floor(thousandths / 1000));
28
+ return fraction === "" ? whole : `${whole}.${fraction}`;
29
+ }
13
30
  export function planRender(input) {
14
31
  if (input.images.length === 0) {
15
32
  // Admission permits Video only with an image source, so an empty set is a bug upstream.
16
- throw new Error("a render needs at least one image");
33
+ throw new Error("The video needs at least one image, but there are none. Check the Images section in Edit project, then Retry stage.");
17
34
  }
18
35
  const frame = frames[input.format];
19
36
  const audio = input.body === undefined ? [] : audioTimeline({ ...input, body: input.body });
37
+ // A silent video shows every image once, which is the one length it has to go on.
20
38
  const totalSeconds = input.body === undefined
21
- ? input.images.length * 5
39
+ ? input.images.length * input.imageSeconds
22
40
  : audio.reduce((sum, segment) => sum + segment.seconds, 0);
23
- const totalFrames = Math.max(input.images.length, Math.round(totalSeconds * fps));
41
+ const totalFrames = Math.max(1, Math.round(totalSeconds * fps));
24
42
  return {
25
- width: frame.width,
26
- height: frame.height,
27
- fps,
28
43
  gapSeconds: input.gapSeconds,
29
- audio,
30
- images: slots(input.images, totalFrames),
44
+ edgeSeconds: input.edgeSeconds,
45
+ imageSeconds: input.imageSeconds,
46
+ zoomPercent: input.zoomPercent,
47
+ motionStyle: input.motionStyle,
48
+ editList: {
49
+ version: editListVersion,
50
+ width: frame.width,
51
+ height: frame.height,
52
+ fps,
53
+ audio,
54
+ shots: shots(input, Math.max(1, Math.round(input.imageSeconds * fps)), totalFrames),
55
+ },
31
56
  totalFrames,
32
57
  totalSeconds,
33
58
  output: input.output,
34
59
  };
35
60
  }
36
- // Intro, gap, body, gap, outro, with a gap only where the segment on
37
- // the other side of it exists.
61
+ export function spoken(kind) {
62
+ return kind !== "gap" && kind !== "edge";
63
+ }
64
+ // Edge, intro, gap, body, gap, outro, edge, with a gap only where the segment on the other
65
+ // side of it exists. Captions walk these same segments, so the lead-in shifts every cue.
38
66
  export function audioTimeline(input, minimumGap = 1 / fps) {
39
67
  const segments = [];
40
68
  const gap = { kind: "gap", path: null, seconds: input.gapSeconds };
69
+ const edge = { kind: "edge", path: null, seconds: input.edgeSeconds };
41
70
  // Video keeps its existing minimum of one frame. WAV passes one sample instead,
42
71
  // because its gap duration is independent of the slideshow's frame rate.
43
72
  const audible = input.gapSeconds >= minimumGap;
73
+ const edged = input.edgeSeconds >= minimumGap;
74
+ if (edged) {
75
+ segments.push(edge);
76
+ }
44
77
  if (input.intro !== undefined) {
45
78
  segments.push({ kind: "intro", path: input.intro.path, seconds: input.intro.seconds });
46
79
  if (audible) {
@@ -54,19 +87,20 @@ export function audioTimeline(input, minimumGap = 1 / fps) {
54
87
  }
55
88
  segments.push({ kind: "outro", path: input.outro.path, seconds: input.outro.seconds });
56
89
  }
90
+ if (edged) {
91
+ segments.push(edge);
92
+ }
57
93
  return segments;
58
94
  }
59
- // The slot is the total divided by the image count, and the last image absorbs the frame
60
- // rounding. One image fills the whole length. ceiling: every slot holds at least one frame, so
61
- // a run with more images than the timeline has frames renders slightly longer than its audio
62
- // rather than failing.
63
- function slots(paths, totalFrames) {
64
- const each = Math.max(1, Math.floor(totalFrames / paths.length));
65
- return paths.map((path, at) => ({
66
- path,
67
- index: at + 1,
68
- frames: at === paths.length - 1 ? Math.max(1, totalFrames - each * at) : each,
69
- // Odd images zoom in, even images zoom out.
70
- zoom: at % 2 === 0 ? "in" : "out",
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) {
100
+ 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),
71
105
  }));
72
106
  }
@@ -6,6 +6,8 @@ const planSchema = z.object({
6
6
  channels: z.number(),
7
7
  codec: z.string(),
8
8
  gapSeconds: z.number(),
9
+ // Absent from records written before the edge silence existed, which then never match.
10
+ edgeSeconds: z.number().optional(),
9
11
  totalSeconds: z.number(),
10
12
  audio: z.array(z.object({ kind: z.string(), path: z.string().nullable(), seconds: z.number() })),
11
13
  output: z.string(),
@@ -5,18 +5,19 @@ import { outputsOf } from "../storage/repo.js";
5
5
  import { prepareSubtitles } from "../subtitles/prepare.js";
6
6
  import { exportAudioWav } from "./audio-export.js";
7
7
  import { audioInputs } from "./audio-inputs.js";
8
- import { renderArgs } from "./ffmpeg.js";
8
+ import { withPaths } from "./edit-list.js";
9
9
  import { planRender } from "./plan.js";
10
+ import { renderSlideshow } from "./slideshow.js";
10
11
  import { writeExport } from "./write-export.js";
11
12
  export async function renderVideo(deps, context) {
12
13
  const { projectId } = context.stage;
13
14
  const project = projectById(deps.db, projectId);
14
15
  if (!project)
15
- throw new Error(`project ${projectId} has no row`);
16
+ throw new Error("This project no longer exists; it may have been deleted while it was running.");
16
17
  if (project.config.sources.video === "off") {
17
18
  if (project.config.sources.audio === "off")
18
- throw new Error("the project has no media export enabled");
19
- await exportAudioWav(deps, context, project.config.silenceGapSeconds);
19
+ throw new Error("There is nothing to export because both video and narration are turned off for this project. Turn one on in Edit project, then Retry stage.");
20
+ await exportAudioWav(deps, context, project.config.silenceGapSeconds, project.config.edgeSilenceSeconds);
20
21
  return;
21
22
  }
22
23
  const outputs = outputsOf(deps.db, projectId);
@@ -27,11 +28,15 @@ export async function renderVideo(deps, context) {
27
28
  const plan = planRender({
28
29
  format: project.format,
29
30
  gapSeconds: project.config.silenceGapSeconds,
31
+ edgeSeconds: project.config.edgeSilenceSeconds,
32
+ imageSeconds: project.config.imageSeconds,
33
+ zoomPercent: project.config.zoomPercent,
34
+ motionStyle: project.config.motionStyle,
30
35
  ...narration,
31
36
  images: slideshow(outputs).map((output) => outputPath(deps.paths, projectId, output.path)),
32
37
  output: outputPath(deps.paths, projectId, "video.mp4"),
33
38
  });
34
- const subtitles = await prepareSubtitles(deps, context, plan.audio, plan);
39
+ const subtitles = await prepareSubtitles(deps, context, plan.editList.audio, plan.editList);
35
40
  await writeExport(deps, context, {
36
41
  role: "video",
37
42
  filename: "video.mp4",
@@ -39,7 +44,17 @@ export async function renderVideo(deps, context) {
39
44
  totalSeconds: plan.totalSeconds,
40
45
  record: { ...recorded(plan, dir), subtitles: project.config.subtitles ?? null },
41
46
  subtitles,
42
- args: (part) => renderArgs({ ...plan, output: part }, subtitles?.burnIn),
47
+ render: (part, onProgress) => renderSlideshow({
48
+ bin: deps.ffmpeg,
49
+ edit: plan.editList,
50
+ output: part,
51
+ burnSubtitles: subtitles?.burnIn ?? false,
52
+ cwd: subtitles?.directory,
53
+ scratch: dir,
54
+ signal: context.signal,
55
+ log: deps.log,
56
+ onProgress,
57
+ }),
43
58
  });
44
59
  deps.count("stage.completed", { stage: "video" });
45
60
  }
@@ -48,15 +63,11 @@ export async function renderVideo(deps, context) {
48
63
  function recorded(plan, dir) {
49
64
  return {
50
65
  ...plan,
51
- audio: plan.audio.map((segment) => ({
52
- ...segment,
53
- path: segment.path === null ? null : relative(dir, segment.path),
54
- })),
55
- images: plan.images.map((slot) => ({ ...slot, path: relative(dir, slot.path) })),
66
+ editList: withPaths(plan.editList, (path) => relative(dir, path)),
56
67
  output: relative(dir, plan.output),
57
68
  };
58
69
  }
59
- // Every slideshow image appears exactly once, in slideshow order.
70
+ // Every slideshow image, once each, in slideshow order; the plan cycles through them.
60
71
  // The thumbnail is a different role, so it is never in this list.
61
72
  function slideshow(outputs) {
62
73
  return outputs
@@ -0,0 +1,48 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { clipArgs, concatList, joinArgs, runFfmpeg, slideshowClips } from "./ffmpeg.js";
4
+ // estimate: a zoompan frame from a 4x still costs several plain encoded frames, and a
5
+ // copying join is nearly free, so progress is weighted by that rather than by clock time.
6
+ const clipWeight = 4;
7
+ const copyWeight = 0.05;
8
+ export async function renderSlideshow(run) {
9
+ const { edit } = run;
10
+ const { clips, order } = slideshowClips(edit);
11
+ const msOf = (frames) => (frames / edit.fps) * 1000;
12
+ const joinMs = msOf(edit.shots.reduce((sum, shot) => sum + shot.frames, 0));
13
+ // Progress is reported against the video's length, the longer of picture and sound.
14
+ 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);
16
+ const work = clipMs * clipWeight + joinMs * (run.burnSubtitles ? 1 : copyWeight);
17
+ const report = (done) => {
18
+ run.onProgress(work > 0 ? Math.round((Math.min(done, work) / work) * totalMs) : 0);
19
+ };
20
+ const workspace = mkdtempSync(join(run.scratch, "render-"));
21
+ try {
22
+ let rendered = 0;
23
+ for (const clip of clips) {
24
+ const before = rendered;
25
+ await runFfmpeg({
26
+ bin: run.bin,
27
+ args: clipArgs(edit, clip.shot, join(workspace, clip.name), run.burnSubtitles),
28
+ signal: run.signal,
29
+ log: run.log,
30
+ onProgress: (elapsedMs) => report(before + elapsedMs * clipWeight),
31
+ });
32
+ rendered += msOf(clip.shot.frames) * clipWeight;
33
+ }
34
+ const list = join(workspace, "slides.ffconcat");
35
+ writeFileSync(list, concatList(order), { mode: 0o600 });
36
+ await runFfmpeg({
37
+ bin: run.bin,
38
+ cwd: run.cwd,
39
+ args: joinArgs(edit, run.output, list, run.burnSubtitles),
40
+ signal: run.signal,
41
+ log: run.log,
42
+ onProgress: (elapsedMs) => report(rendered + elapsedMs * (run.burnSubtitles ? 1 : copyWeight)),
43
+ });
44
+ }
45
+ finally {
46
+ rmSync(workspace, { recursive: true, force: true });
47
+ }
48
+ }