@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
@@ -2,7 +2,8 @@
2
2
  // the project page builds the same URL for every image, player and download link it
3
3
  // draws, and `downloads.ts` reads the disk: importing it into the SPA would drag
4
4
  // `node:fs` and the zip encoder into the browser bundle. Nothing here touches IO.
5
- // An output's role is its asset name; images add their place in the slideshow, and the
5
+ // An output's role is its asset name; images (and animated images) add their place in the
6
+ // slideshow, and the
6
7
  // instructions add their stage, those being the two roles a project holds more than one of -
7
8
  // research and the article each store what they sent.
8
9
  export function assetOf(output) {
@@ -10,10 +11,16 @@ export function assetOf(output) {
10
11
  if ((output.role === "narration_txt" || output.role === "tts_script") &&
11
12
  output.meta.segment !== undefined)
12
13
  return `${asset}-${output.meta.segment}`;
14
+ // A project holds one video per short and several images per short.
15
+ if (output.role === "short_video" && output.meta.short !== undefined)
16
+ return `${asset}-${String(output.meta.short)}`;
17
+ if (output.role === "short_image" && output.meta.short !== undefined)
18
+ return `${asset}-${String(output.meta.short)}-${String(output.meta.index ?? 1)}`;
13
19
  if (output.role === "instructions") {
14
20
  return `${output.stageKind}-${asset}`;
15
21
  }
16
- return output.role === "image" && output.meta.index !== undefined
22
+ return (output.role === "image" || output.role === "animated_image") &&
23
+ output.meta.index !== undefined
17
24
  ? `${asset}-${String(output.meta.index)}`
18
25
  : asset;
19
26
  }
@@ -5,6 +5,14 @@ export function projectDir(paths, projectId) {
5
5
  export function outputPath(paths, projectId, relativePath) {
6
6
  return contained(projectDir(paths, projectId), relativePath);
7
7
  }
8
+ // Scheduled backups (slices/backups) land here unless the user picks another folder. It sits
9
+ // inside the projects root because that is the one folder a Docker install shares with the
10
+ // host, so the archives end up on the user's disk and not only in the container's volume.
11
+ // Project folders are ULIDs, so the name never collides with one; reconcile leaves it alone.
12
+ export const backupsFolderName = "Backups";
13
+ export function defaultBackupsDir(paths) {
14
+ return contained(paths.projects, backupsFolderName);
15
+ }
8
16
  export function stagingPath(paths, stagedFileId) {
9
17
  return contained(paths.staging, stagedFileId);
10
18
  }
@@ -60,6 +68,14 @@ export function outputFileName(role, index, extension, stageKind) {
60
68
  return "description.txt";
61
69
  case "youtube_tags":
62
70
  return "tags.txt";
71
+ case "shorts":
72
+ return "shorts.json";
73
+ case "short_image":
74
+ return `shorts/image-${String(index).padStart(3, "0")}${extension}`;
75
+ case "short_video":
76
+ return `shorts/short-${String(index).padStart(2, "0")}${extension}`;
77
+ case "animated_image":
78
+ return `animated/image-${String(index).padStart(3, "0")}${extension}`;
63
79
  case "audio_body":
64
80
  return `audio-body${extension}`;
65
81
  case "audio_export":
@@ -70,6 +86,8 @@ export function outputFileName(role, index, extension, stageKind) {
70
86
  return `audio-outro${extension}`;
71
87
  case "thumbnail":
72
88
  return `thumbnail${extension}`;
89
+ case "reference":
90
+ return `reference${extension}`;
73
91
  case "image":
74
92
  return `images/${String(index).padStart(3, "0")}${extension}`;
75
93
  }
@@ -27,5 +27,14 @@ export const outputRoles = [
27
27
  "document_pdf",
28
28
  "youtube_description",
29
29
  "youtube_tags",
30
+ // The Shorts step: the picked clips (JSON), each clip's vertical images, and each clip.
31
+ "shorts",
32
+ "short_image",
33
+ "short_video",
34
+ // An image of the slideshow brought to life by an image-to-video model (Animate images).
35
+ "animated_image",
36
+ // The Images stage's establishing image: made or uploaded first, drawn from by the other
37
+ // images, never shown in the video.
38
+ "reference",
30
39
  ];
31
40
  export const stagedFileStates = ["copying", "staged"];
@@ -12,12 +12,13 @@ import { readFontMetadata } from "../fonts/sfnt.js";
12
12
  import { lintEntry, lintPrompt } from "../library/lint.js";
13
13
  import { entryCategories, entryModes, promptKinds } from "../library/model.js";
14
14
  import { listEntries, listPrompts } from "../library/repo.js";
15
+ import { notificationUrlKey } from "../notifications/settings.js";
15
16
  import { projectTemplateSchema } from "../project-templates/schema.js";
16
17
  import { cliPathMaxLength } from "../settings/cli-paths.js";
17
18
  import { appearances, providerById, providerIds } from "../settings/model.js";
18
19
  import { listVoices } from "../settings/repo.js";
19
20
  import { voiceIdMax, voiceNameMax } from "../settings/voices.js";
20
- import { projectDir, stagingPath } from "./layout.js";
21
+ import { defaultBackupsDir, projectDir, stagingPath } from "./layout.js";
21
22
  import { stageKinds } from "./model.js";
22
23
  import { insertStagedFile, stagedFiles } from "./repo.js";
23
24
  const record = z.record(z.string(), z.string());
@@ -326,7 +327,8 @@ const storedAppearance = z.enum(appearances);
326
327
  function portableSettings(settings) {
327
328
  const portable = {};
328
329
  for (const [key, value] of Object.entries(settings)) {
329
- if (key === "tutorial.session")
330
+ // The Notification URL stays on this machine: an ntfy topic in it is as good as a password.
331
+ if (key === "tutorial.session" || key === notificationUrlKey)
330
332
  continue;
331
333
  const parsed = storedJson(value);
332
334
  if (key === "silenceGapSeconds")
@@ -862,7 +864,8 @@ function planStagedImport(deps, archive, files) {
862
864
  });
863
865
  }
864
866
  export function storageBytes(paths) {
865
- const projects = directoryBytes(paths.projects);
867
+ // Scheduled backups default to a folder inside the projects root; they are not project files.
868
+ const projects = directoryBytes(paths.projects) - directoryBytes(defaultBackupsDir(paths));
866
869
  const staging = directoryBytes(paths.staging);
867
870
  return { data: directoryBytes(paths.dataDir), projects, staging };
868
871
  }
@@ -1,6 +1,6 @@
1
1
  import { readdirSync, rmSync, statSync, unlinkSync } from "node:fs";
2
2
  import { join, relative, sep } from "node:path";
3
- import { outputPath, stagingPath } from "./layout.js";
3
+ import { backupsFolderName, outputPath, stagingPath } from "./layout.js";
4
4
  import { stagedFiles as stagedRows } from "./repo.js";
5
5
  import { stagedFileReferenced } from "./staging-refs.js";
6
6
  export function reconcileStorage(db, paths) {
@@ -33,6 +33,10 @@ export function reconcileStorage(db, paths) {
33
33
  let orphanFiles = 0;
34
34
  for (const entry of readdirSync(paths.projects, { withFileTypes: true })) {
35
35
  const path = join(paths.projects, entry.name);
36
+ // The scheduled backups' default folder: its archives belong to slices/backups, which
37
+ // prunes them by its own rules.
38
+ if (entry.name === backupsFolderName && entry.isDirectory())
39
+ continue;
36
40
  if (!entry.isDirectory()) {
37
41
  unlinkSync(path);
38
42
  orphanFiles += 1;
@@ -9,6 +9,9 @@ export const metaSchema = z.object({
9
9
  promptName: z.string().optional(),
10
10
  prompt: z.string().optional(),
11
11
  index: z.number().optional(),
12
+ short: z.number().optional(),
13
+ sentences: z.tuple([z.number(), z.number()]).optional(),
14
+ warnings: z.array(z.string()).optional(),
12
15
  provider: z.string().optional(),
13
16
  model: z.string().optional(),
14
17
  voice: z.string().optional(),
@@ -37,4 +37,7 @@ export const payloadSchema = z.strictObject({
37
37
  // A written YouTube description. Its event names no stage: it is made inside the Video
38
38
  // stage, and a collector counting stage "video" as a finished video must not count it.
39
39
  descriptions: z.number().int().nonnegative().optional(),
40
+ // A rendered short. Like a description it names no stage: the Shorts step runs inside
41
+ // the Video stage, and a short is not a finished video.
42
+ shorts: z.number().int().nonnegative().optional(),
40
43
  });
@@ -0,0 +1,72 @@
1
+ // Chapter cards as an ASS script the clips draw with libass, the renderer the captions use,
2
+ // so a card is set in the subtitle font without a font-rendering filter of its own (this
3
+ // ffmpeg has none). Each card is centred, well above where captions sit by default, and fades
4
+ // in and out. Every clip a card shows in gets its own script with the times counted from the
5
+ // clip's first frame (`offset`), since a clip's frames count from zero.
6
+ // ceiling: the owner's eye. The fades take 0.4 s each of the card's 2.5 s.
7
+ const fadeMs = 400;
8
+ export function cardsAss(edit, cards, fontName, offset = 0) {
9
+ const { width, height, fps } = edit;
10
+ // The title is sized from the frame's short side, so a vertical video's card fits too. At
11
+ // 8% it read as a caption rather than a chapter title; 11% and bold gives it presence.
12
+ const size = Math.round(Math.min(width, height) * 0.11);
13
+ // A wide, blurred, half-clear dark outline: a soft shadow all round that keeps the title
14
+ // readable on a bright picture without a box behind it.
15
+ const outline = Math.max(2, Math.round(size / 10));
16
+ const header = [
17
+ "[Script Info]",
18
+ "ScriptType: v4.00+",
19
+ `PlayResX: ${String(width)}`,
20
+ `PlayResY: ${String(height)}`,
21
+ "WrapStyle: 0",
22
+ "ScaledBorderAndShadow: yes",
23
+ "",
24
+ "[V4+ Styles]",
25
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
26
+ // White bold text, the soft outline, no offset shadow, centred (alignment 5), letter-spaced.
27
+ `Style: Card,${fontName.replace(/,/g, " ")},${String(size)},&H00FFFFFF,&H00FFFFFF,&H60000000,&H00000000,-1,0,0,0,100,100,${String(Math.round(size / 12))},0,1,${String(outline)},0,5,${String(Math.round(width * 0.1))},${String(Math.round(width * 0.1))},0,1`,
28
+ "",
29
+ "[Events]",
30
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
31
+ ];
32
+ const events = cards.flatMap((card) => {
33
+ const from = card.startFrame - offset;
34
+ const to = from + card.frames;
35
+ if (to <= 0)
36
+ return [];
37
+ // A card that began before this clip starts with the clip, part way through its fade in
38
+ // (or past it), so it runs on from the clip before without a second fade.
39
+ const before = Math.max(0, (-from / fps) * 1000);
40
+ const length = Math.round((to / fps) * 1000);
41
+ const fade = before === 0
42
+ ? `\\fad(${String(fadeMs)},${String(fadeMs)})`
43
+ : `\\fade(${String(Math.round(255 * Math.max(0, 1 - before / fadeMs)))},0,255,0,${String(Math.max(0, Math.round(fadeMs - before)))},${String(length - fadeMs)},${String(length)})`;
44
+ return [
45
+ `Dialogue: 0,${assTime(Math.max(0, from) / fps)},${assTime(to / fps)},Card,,0,0,0,,{\\blur${String(outline)}${fade}${settle(before)}}${assText(card.title)}`,
46
+ ];
47
+ });
48
+ return `${[...header, ...events].join("\n")}\n`;
49
+ }
50
+ // The title settles from 96% to full size while it fades in, a small movement that reads as
51
+ // a title arriving rather than text appearing. A card carried on from the clip before is
52
+ // already settled.
53
+ function settle(before) {
54
+ if (before >= fadeMs)
55
+ return "";
56
+ const from = Math.round(96 + (4 * before) / fadeMs);
57
+ return `\\fscx${String(from)}\\fscy${String(from)}\\t(0,${String(Math.round(fadeMs - before))},\\fscx100\\fscy100)`;
58
+ }
59
+ // h:mm:ss.cc, the centiseconds ASS counts in.
60
+ function assTime(seconds) {
61
+ const centis = Math.max(0, Math.round(seconds * 100));
62
+ const h = Math.floor(centis / 360000);
63
+ const m = Math.floor((centis % 360000) / 6000);
64
+ const s = Math.floor((centis % 6000) / 100);
65
+ const c = centis % 100;
66
+ return `${String(h)}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(c).padStart(2, "0")}`;
67
+ }
68
+ // A title is plain text: braces would start an override block and a backslash an escape, so
69
+ // both are replaced, and a line break is a space.
70
+ function assText(text) {
71
+ return text.replace(/[{}]/g, "").replace(/\\/g, "/").replace(/\s+/g, " ").trim();
72
+ }
@@ -0,0 +1,89 @@
1
+ import { splitEndMatter } from "../article/split.js";
2
+ // ceiling: a heading is looked for within 200 words either side of where its share of the
3
+ // article puts it; past that, a rewritten narration has drifted too far to trust a match.
4
+ const searchWords = 200;
5
+ // The heading's first words that have to match, in order.
6
+ const matchWords = 4;
7
+ export function headingChapters(markdown, words) {
8
+ if (words.length === 0)
9
+ return [];
10
+ const headings = topHeadings(splitEndMatter(markdown).body);
11
+ const articleWords = tokens(splitEndMatter(markdown).body).length;
12
+ if (headings.length === 0 || articleWords === 0)
13
+ return [];
14
+ const spoken = words.map((word) => normalise(word.text));
15
+ const chapters = [];
16
+ let after = -1;
17
+ for (const heading of headings) {
18
+ const estimate = Math.round((heading.at / articleWords) * words.length);
19
+ const wanted = tokens(heading.title).slice(0, matchWords);
20
+ const found = findNear(spoken, wanted, estimate, after + 1);
21
+ const index = found ?? Math.max(after + 1, Math.min(estimate, words.length - 1));
22
+ const word = words[index];
23
+ if (word === undefined || index <= after)
24
+ continue;
25
+ chapters.push({ title: heading.title, start: word.start });
26
+ after = index;
27
+ }
28
+ return chapters;
29
+ }
30
+ // The article's chapter headings: the highest level used, except a lone title heading at the
31
+ // very top, which names the article rather than a part of it.
32
+ export function topHeadings(markdown) {
33
+ const found = [];
34
+ let fenced = false;
35
+ let before = 0;
36
+ for (const line of markdown.split(/\r?\n/)) {
37
+ if (/^\s*(```|~~~)/.test(line))
38
+ fenced = !fenced;
39
+ const matched = fenced ? null : /^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
40
+ if (matched?.[1] !== undefined && matched[2] !== undefined) {
41
+ const title = plainHeading(matched[2]);
42
+ if (title !== "")
43
+ found.push({ level: matched[1].length, title, at: before });
44
+ }
45
+ before += tokens(line).length;
46
+ }
47
+ if (found.length === 0)
48
+ return [];
49
+ const top = Math.min(...found.map((heading) => heading.level));
50
+ const atTop = found.filter((heading) => heading.level === top);
51
+ const first = found[0];
52
+ if (atTop.length === 1 && first !== undefined && first.level === top && found.length > 1) {
53
+ const rest = found.slice(1);
54
+ const next = Math.min(...rest.map((heading) => heading.level));
55
+ return rest.filter((heading) => heading.level === next);
56
+ }
57
+ return atTop;
58
+ }
59
+ function plainHeading(text) {
60
+ return text
61
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
62
+ .replace(/[*_`~]/g, "")
63
+ .replace(/\s+/g, " ")
64
+ .trim();
65
+ }
66
+ function tokens(text) {
67
+ return text
68
+ .split(/\s+/)
69
+ .map(normalise)
70
+ .filter((token) => token !== "");
71
+ }
72
+ function normalise(word) {
73
+ return word.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
74
+ }
75
+ // The start of the run of `wanted` in `spoken` nearest to `estimate`, at or after `from`.
76
+ function findNear(spoken, wanted, estimate, from) {
77
+ if (wanted.length === 0)
78
+ return undefined;
79
+ const low = Math.max(from, estimate - searchWords);
80
+ const high = Math.min(spoken.length - wanted.length, estimate + searchWords);
81
+ let best;
82
+ for (let at = low; at <= high; at += 1) {
83
+ if (!wanted.every((token, offset) => spoken[at + offset] === token))
84
+ continue;
85
+ if (best === undefined || Math.abs(at - estimate) < Math.abs(best - estimate))
86
+ best = at;
87
+ }
88
+ return best;
89
+ }
@@ -0,0 +1,141 @@
1
+ import { transcriptSentences } from "../youtube/transcript.js";
2
+ import { cutFloorShare } from "./edit-settings.js";
3
+ // "Follow the narration": every cut lands in the pause after a sentence, as near to Seconds
4
+ // per image as the sentences allow, and a chapter always starts on a new shot. Pure, so the
5
+ // same timing plans the same cuts every time and a re-render is identical.
6
+ //
7
+ // The images carry no link to a passage of the article: each one is rendered from an image
8
+ // prompt template filled with the run's keyword values, never from a paragraph. So they are
9
+ // spread over the narration in slideshow order, taking turns as they do with Every N
10
+ // seconds; only where the cuts fall changes.
11
+ // A cut sits in the pause between two sentences: halfway through it, but never more than this
12
+ // long after the last word, so a long breath does not hold the old picture over the new
13
+ // sentence's first word.
14
+ const pauseCutSeconds = 0.25;
15
+ // A chapter start is moved onto the nearest sentence pause within this many seconds; a
16
+ // chapter the model or a heading places mid-sentence would otherwise cut mid-word.
17
+ const chapterSnapSeconds = 3;
18
+ // Where the narration pauses between sentences, in seconds into the video. The word times
19
+ // already count the silence at the start, the intro and the gaps (`runtime-subtitles.ts`).
20
+ export function sentenceCutPoints(words) {
21
+ const sentences = transcriptSentences(words);
22
+ const points = [];
23
+ for (const [index, sentence] of sentences.entries()) {
24
+ const next = sentences[index + 1];
25
+ if (next === undefined)
26
+ break;
27
+ const pause = Math.max(0, next.start - sentence.end);
28
+ points.push(sentence.end + Math.min(pause / 2, pauseCutSeconds));
29
+ }
30
+ return points;
31
+ }
32
+ // The frames of each shot, in timeline order, adding up to `totalFrames`. Between two
33
+ // chapter starts the shots are laid greedily: each next cut is the sentence pause nearest to
34
+ // Seconds per image after the last one, no nearer than the floor (40% of it) and no further
35
+ // than twice it; with no pause in that window the cut falls at Seconds per image, as it does
36
+ // with Every N seconds. What is left before a chapter start that could not hold a full shot
37
+ // and a floor-length one after it becomes the last shot, so no shot runs past 1.4 times
38
+ // Seconds per image unless the sentences leave no pause.
39
+ export function narrationShotFrames(input) {
40
+ const total = input.totalFrames / input.fps;
41
+ if (!(total > 0) || !(input.imageSeconds > 0))
42
+ return [Math.max(1, input.totalFrames)];
43
+ const floor = input.imageSeconds * (input.floorShare ?? cutFloorShare);
44
+ const points = [...input.cutPoints]
45
+ .filter((point) => Number.isFinite(point) && point > 0 && point < total)
46
+ .toSorted((left, right) => left - right);
47
+ // A chapter that opens with the narration's first words would cut the silence before them
48
+ // into a shot of its own; the first shot opens that chapter anyway.
49
+ const bounds = chapterCuts(input.chapterStarts, points, total).filter((cut) => cut >= floor);
50
+ const cuts = [];
51
+ let start = 0;
52
+ for (const end of [...bounds, total]) {
53
+ let position = start;
54
+ while (end - position > input.imageSeconds + floor) {
55
+ const target = position + input.imageSeconds;
56
+ const low = position + floor;
57
+ const high = Math.min(position + 2 * input.imageSeconds, end - floor);
58
+ const cut = nearest(points.filter((point) => point >= low && point <= high), target);
59
+ position = cut ?? target;
60
+ cuts.push(position);
61
+ }
62
+ if (end < total)
63
+ cuts.push(end);
64
+ start = end;
65
+ }
66
+ return framesBetween(cuts, input.totalFrames, input.fps);
67
+ }
68
+ // The chapter starts as cuts: inside the video, each moved to the nearest pause within 3 s,
69
+ // and at least one frame from the start, the end and each other.
70
+ export function chapterCuts(starts, points, total) {
71
+ const snapped = starts
72
+ .filter((start) => Number.isFinite(start))
73
+ .map((start) => {
74
+ const near = nearest(points.filter((point) => Math.abs(point - start) <= chapterSnapSeconds), start);
75
+ return near ?? start;
76
+ })
77
+ .filter((start) => start > 0 && start < total)
78
+ .toSorted((left, right) => left - right);
79
+ return snapped.filter((start, index) => index === 0 || start > (snapped[index - 1] ?? 0));
80
+ }
81
+ function nearest(values, target) {
82
+ let best;
83
+ for (const value of values)
84
+ if (best === undefined || Math.abs(value - target) < Math.abs(best - target))
85
+ best = value;
86
+ return best;
87
+ }
88
+ // Cut times to whole frames. Two cuts that round to the same frame are one cut, so every shot
89
+ // is at least one frame, and the last shot runs to the end.
90
+ function framesBetween(cuts, totalFrames, fps) {
91
+ const marks = [];
92
+ for (const cut of cuts) {
93
+ const frame = Math.round(cut * fps);
94
+ if (frame > (marks.at(-1) ?? 0) && frame < totalFrames)
95
+ marks.push(frame);
96
+ }
97
+ const shots = [];
98
+ let from = 0;
99
+ for (const mark of [...marks, totalFrames]) {
100
+ shots.push(mark - from);
101
+ from = mark;
102
+ }
103
+ return shots.filter((frames) => frames > 0);
104
+ }
105
+ // The shots (0-based) the video and each chapter open on, as the render will cut them: what
106
+ // "Chapter openers" animates. The cuts before a chapter do not depend on how long the video
107
+ // runs after it, so the timeline is taken to end just past the last chapter.
108
+ export function chapterOpeningShots(input) {
109
+ const { fps, imageSeconds } = input;
110
+ const last = Math.max(0, ...input.chapterStarts.filter((start) => Number.isFinite(start)));
111
+ const totalFrames = Math.round((last + 1) * fps);
112
+ if (!input.narration) {
113
+ // Every N seconds does not move a chapter: the image on screen when it starts opens it.
114
+ const each = Math.max(1, Math.round(imageSeconds * fps));
115
+ return [
116
+ ...new Set([
117
+ 0,
118
+ ...input.chapterStarts
119
+ .filter((start) => Number.isFinite(start) && start > 0)
120
+ .map((start) => Math.floor(Math.round(start * fps) / each)),
121
+ ]),
122
+ ];
123
+ }
124
+ const starts = chapterStartFrames(input.chapterStarts, input.cutPoints, totalFrames, fps);
125
+ const shots = narrationShotFrames({ ...input, totalFrames });
126
+ const opening = [0];
127
+ let frame = 0;
128
+ for (const [index, frames] of shots.entries()) {
129
+ if (starts.includes(frame) && index > 0)
130
+ opening.push(index);
131
+ frame += frames;
132
+ }
133
+ return opening;
134
+ }
135
+ // The frame each chapter's shot starts on, for the chapter cards and the chapter openers:
136
+ // the same snapping the cuts use, so a card appears with its shot.
137
+ export function chapterStartFrames(starts, points, totalFrames, fps) {
138
+ return chapterCuts(starts, points, totalFrames / fps)
139
+ .map((start) => Math.round(start * fps))
140
+ .filter((frame) => frame > 0 && frame < totalFrames);
141
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { atmospheres, colorGrades, lookLevels, } from "./edit-settings.js";
2
3
  // The edit list is the whole video as data: what plays, for how many frames, how it moves,
3
4
  // and the audio under it. Planning (`plan.ts`) writes one and the renderer (`ffmpeg.ts`,
4
5
  // `slideshow.ts`) reads nothing else, so a new kind of shot or movement is a new case
@@ -6,11 +7,10 @@ import { z } from "zod";
6
7
  // It is plain JSON, recorded in render.json, and carries a version so a later Slopify can
7
8
  // tell an older list from its own.
8
9
  //
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.
10
+ // Each extension is a new optional field or union case, so a version-1 list stays readable:
11
+ // `Shot.source` has a `video` kind for clips, `Shot.transition` says how a shot enters from
12
+ // the one before, and the list's `look` and `cards` are the effects every shot is rendered
13
+ // with. A change that alters what an existing field means bumps the version instead.
14
14
  export const editListVersion = 1;
15
15
  // The same list with every file path passed through `map`: render.json records paths
16
16
  // relative to the project folder.
@@ -25,6 +25,9 @@ export function withPaths(edit, map) {
25
25
  ...shot,
26
26
  source: { ...shot.source, path: map(shot.source.path) },
27
27
  })),
28
+ ...(edit.cardFont === undefined
29
+ ? {}
30
+ : { cardFont: { ...edit.cardFont, path: map(edit.cardFont.path) } }),
28
31
  };
29
32
  }
30
33
  const share = z.number().min(0).max(1);
@@ -36,6 +39,26 @@ const motionSchema = z.discriminatedUnion("kind", [
36
39
  z.object({ kind: z.literal("still") }).strict(),
37
40
  ]);
38
41
  const count = z.number().int().positive();
42
+ const sourceSchema = z.discriminatedUnion("kind", [
43
+ z.object({ kind: z.literal("image"), path: z.string().min(1) }).strict(),
44
+ z
45
+ .object({ kind: z.literal("video"), path: z.string().min(1), seconds: z.number().positive() })
46
+ .strict(),
47
+ ]);
48
+ const transitionSchema = z
49
+ .object({ kind: z.enum(["crossfade", "fadeblack", "slide", "wipe"]), frames: count })
50
+ .strict();
51
+ const lookSchema = z
52
+ .object({
53
+ vignette: z.enum(lookLevels),
54
+ grain: z.enum(lookLevels),
55
+ grade: z.enum(colorGrades),
56
+ atmosphere: z.enum(atmospheres),
57
+ })
58
+ .strict();
59
+ const cardSchema = z
60
+ .object({ title: z.string().min(1), startFrame: z.number().int().nonnegative(), frames: count })
61
+ .strict();
39
62
  const editListSchema = z
40
63
  .object({
41
64
  version: z.literal(editListVersion),
@@ -52,12 +75,19 @@ const editListSchema = z
52
75
  shots: z
53
76
  .array(z
54
77
  .object({
55
- source: z.object({ kind: z.literal("image"), path: z.string().min(1) }).strict(),
78
+ source: sourceSchema,
56
79
  frames: count,
57
80
  motion: motionSchema,
81
+ transition: transitionSchema.optional(),
58
82
  })
59
83
  .strict())
60
84
  .min(1),
85
+ look: lookSchema.optional(),
86
+ cards: z.array(cardSchema).optional(),
87
+ cardFont: z
88
+ .object({ path: z.string().min(1), name: z.string().min(1) })
89
+ .strict()
90
+ .optional(),
61
91
  })
62
92
  .strict();
63
93
  // Reads a recorded edit list back. Strict, so a list written by a newer Slopify, with a