@gentbajko/slopify 3.0.0 → 3.0.2

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 (198) hide show
  1. package/README.md +6 -4
  2. package/dist/adapter-registry.js +28 -0
  3. package/dist/adapters/alignment/text.js +3 -1
  4. package/dist/adapters/key-probes.js +57 -8
  5. package/dist/adapters/tts/gemini.js +206 -0
  6. package/dist/adapters/tts/pcm-mp3.js +62 -0
  7. package/dist/adapters/tts/system.js +147 -0
  8. package/dist/assets/models.yaml +38 -0
  9. package/dist/assets/style-preview/narration.mp3 +0 -0
  10. package/dist/assets/style-preview/tall-1.jpg +0 -0
  11. package/dist/assets/style-preview/tall-2.jpg +0 -0
  12. package/dist/assets/style-preview/tall-3.jpg +0 -0
  13. package/dist/assets/style-preview/wide-1.jpg +0 -0
  14. package/dist/assets/style-preview/wide-2.jpg +0 -0
  15. package/dist/assets/style-preview/wide-3.jpg +0 -0
  16. package/dist/catalog/registry.js +4 -1
  17. package/dist/catalog/validate.js +2 -2
  18. package/dist/edge/autostart/docker-record.js +2 -2
  19. package/dist/edge/autostart/docker.js +68 -0
  20. package/dist/edge/autostart/prompt.js +7 -1
  21. package/dist/edge/autostart/service.js +18 -9
  22. package/dist/edge/cli-args.js +71 -0
  23. package/dist/edge/cli.js +11 -16
  24. package/dist/edge/docker-install/run.js +7 -1
  25. package/dist/edge/events/hub.js +3 -1
  26. package/dist/edge/http/actions.js +6 -1
  27. package/dist/edge/http/app.js +2 -0
  28. package/dist/edge/http/byte-range.js +42 -0
  29. package/dist/edge/http/diagnostics.js +1 -0
  30. package/dist/edge/http/draft-files.js +2 -4
  31. package/dist/edge/http/files.js +10 -6
  32. package/dist/edge/http/onboarding.js +64 -4
  33. package/dist/edge/http/project-templates.js +19 -2
  34. package/dist/edge/http/projects.js +6 -1
  35. package/dist/edge/http/providers.js +39 -9
  36. package/dist/edge/http/reviews.js +18 -8
  37. package/dist/edge/http/revision-files.js +2 -4
  38. package/dist/edge/http/schedules.js +9 -5
  39. package/dist/edge/http/storage.js +16 -2
  40. package/dist/edge/http/studio.js +111 -31
  41. package/dist/edge/http/style-preview.js +3 -42
  42. package/dist/edge/http/tutorials.js +59 -0
  43. package/dist/edge/http/youtube-edits.js +11 -2
  44. package/dist/extension/slopify-studio-chrome.zip +0 -0
  45. package/dist/extension/slopify-studio-firefox.zip +0 -0
  46. package/dist/host-cli/gemini-login.js +83 -0
  47. package/dist/host-cli/status.js +4 -2
  48. package/dist/kernel/db/migrations/0041-cast-hosts.sql +3 -0
  49. package/dist/kernel/ports/model.js +5 -3
  50. package/dist/kernel/ports/system-speech.js +203 -0
  51. package/dist/kernel/runner/graph.js +17 -1
  52. package/dist/kernel/runner/index.js +4 -4
  53. package/dist/kernel/runner/providers.js +1 -1
  54. package/dist/main.js +5 -0
  55. package/dist/model-catalog.js +2 -0
  56. package/dist/patch-notes/3.0.1.md +105 -0
  57. package/dist/patch-notes/3.0.2.md +20 -0
  58. package/dist/patch-notes/index.json +12 -0
  59. package/dist/slices/admission/rules.js +3 -3
  60. package/dist/slices/admission/start.js +1 -1
  61. package/dist/slices/channels/cast-match.js +1 -1
  62. package/dist/slices/channels/repo.js +3 -0
  63. package/dist/slices/channels/schema.js +23 -0
  64. package/dist/slices/channels/service.js +6 -6
  65. package/dist/slices/control/index.js +3 -1
  66. package/dist/slices/control/providers.js +4 -1
  67. package/dist/slices/episodes/related.js +2 -2
  68. package/dist/slices/estimate/requests.js +9 -1
  69. package/dist/slices/eta/history.js +56 -0
  70. package/dist/slices/eta/model.js +63 -0
  71. package/dist/slices/eta/view.js +25 -0
  72. package/dist/slices/fixes/rules.js +1 -1
  73. package/dist/slices/fonts/coverage.js +1 -1
  74. package/dist/slices/loudness/model.js +1 -1
  75. package/dist/slices/model-upkeep/usage.js +4 -2
  76. package/dist/slices/narration/pauses-model.js +1 -1
  77. package/dist/slices/narration/pronunciation.js +2 -2
  78. package/dist/slices/notifications/notifier.js +28 -2
  79. package/dist/slices/notifications/rules.js +26 -2
  80. package/dist/slices/onboarding/first-run.js +40 -14
  81. package/dist/slices/onboarding/full-video.js +81 -0
  82. package/dist/slices/onboarding/model.js +21 -0
  83. package/dist/slices/onboarding/quick-short.js +25 -10
  84. package/dist/slices/play-drafts/convert.js +3 -3
  85. package/dist/slices/play-drafts/readiness.js +1 -1
  86. package/dist/slices/project-templates/from-project.js +10 -3
  87. package/dist/slices/project-templates/next-chapter.js +94 -0
  88. package/dist/slices/project-templates/one-off.js +1 -1
  89. package/dist/slices/project-templates/schema.js +7 -1
  90. package/dist/slices/rebuild/recipe-exports.js +5 -0
  91. package/dist/slices/rebuild/recipe-save.js +1 -1
  92. package/dist/slices/rebuild/recipe-voices.js +1 -1
  93. package/dist/slices/rebuild/review-redo.js +15 -0
  94. package/dist/slices/rebuild/runtime-export-bed.js +1 -1
  95. package/dist/slices/rebuild/runtime-review.js +35 -5
  96. package/dist/slices/rebuild/runtime-store.js +23 -1
  97. package/dist/slices/rebuild/runtime-voices.js +2 -2
  98. package/dist/slices/rebuild/service-readiness.js +3 -3
  99. package/dist/slices/reviews/repo.js +16 -4
  100. package/dist/slices/run-cost/panel.js +30 -8
  101. package/dist/slices/run-cost/pricing.js +4 -1
  102. package/dist/slices/schedules/agenda.js +66 -2
  103. package/dist/slices/schedules/repo.js +1 -1
  104. package/dist/slices/schedules/scheduler.js +1 -1
  105. package/dist/slices/schedules/schema.js +43 -2
  106. package/dist/slices/schedules/similar.js +41 -3
  107. package/dist/slices/schedules/topic-list.js +2 -2
  108. package/dist/slices/schedules/topics.js +118 -17
  109. package/dist/slices/settings/first-run.js +5 -1
  110. package/dist/slices/settings/health.js +64 -7
  111. package/dist/slices/settings/key-guides.js +17 -7
  112. package/dist/slices/settings/key-test.js +12 -4
  113. package/dist/slices/settings/keys.js +10 -5
  114. package/dist/slices/settings/model-reach.js +52 -0
  115. package/dist/slices/settings/model.js +49 -0
  116. package/dist/slices/settings/models.js +5 -1
  117. package/dist/slices/settings/readiness.js +14 -2
  118. package/dist/slices/settings/tutorial-schema.js +5 -0
  119. package/dist/slices/shorts/render.js +18 -17
  120. package/dist/slices/storage/documents.js +11 -3
  121. package/dist/slices/storage/downloads.js +33 -1
  122. package/dist/slices/storage/portable.js +4 -2
  123. package/dist/slices/studio/model.js +2 -0
  124. package/dist/slices/studio/pack.js +6 -4
  125. package/dist/slices/studio/queue.js +69 -0
  126. package/dist/slices/studio/settings.js +42 -5
  127. package/dist/slices/style-preview/narration.js +33 -0
  128. package/dist/slices/style-preview/render.js +43 -56
  129. package/dist/slices/style-preview/schema.js +17 -3
  130. package/dist/slices/style-preview/service.js +3 -4
  131. package/dist/slices/style-preview/settings.js +75 -25
  132. package/dist/slices/tutorials/anchors.js +57 -0
  133. package/dist/slices/tutorials/library.js +185 -0
  134. package/dist/slices/voices/audio-files.js +7 -2
  135. package/dist/slices/voices/cast.js +26 -8
  136. package/dist/slices/voices/grouping.js +10 -0
  137. package/dist/slices/voices/model.js +49 -10
  138. package/dist/slices/voices/script.js +1 -1
  139. package/dist/slices/youtube/edits-repo.js +19 -4
  140. package/dist/slices/youtube/placeholders.js +2 -2
  141. package/dist/tutorials/AI-CLIs.md +146 -0
  142. package/dist/tutorials/Backups.md +121 -0
  143. package/dist/tutorials/Calendar.md +103 -0
  144. package/dist/tutorials/Cast-Library.md +112 -0
  145. package/dist/tutorials/Channels.md +148 -0
  146. package/dist/tutorials/Costs-and-Run-Cost.md +136 -0
  147. package/dist/tutorials/Docker.md +171 -0
  148. package/dist/tutorials/Document-Themes.md +207 -0
  149. package/dist/tutorials/Editing-a-Project.md +204 -0
  150. package/dist/tutorials/FAQ.md +134 -0
  151. package/dist/tutorials/First-Launch-and-Welcome.md +153 -0
  152. package/dist/tutorials/Home-and-Projects.md +174 -0
  153. package/dist/tutorials/Home.md +119 -0
  154. package/dist/tutorials/Install.md +154 -0
  155. package/dist/tutorials/Intros-and-Outros.md +86 -0
  156. package/dist/tutorials/Keyboard-Shortcuts-and-Command-Palette.md +215 -0
  157. package/dist/tutorials/Library-Overview.md +75 -0
  158. package/dist/tutorials/Models.md +133 -0
  159. package/dist/tutorials/Multiple-Voices.md +161 -0
  160. package/dist/tutorials/Narration-Aliases-and-Glossary.md +129 -0
  161. package/dist/tutorials/Notifications.md +86 -0
  162. package/dist/tutorials/Other-Languages.md +110 -0
  163. package/dist/tutorials/PDF-Documents.md +100 -0
  164. package/dist/tutorials/Play-Images.md +135 -0
  165. package/dist/tutorials/Play-Narration.md +167 -0
  166. package/dist/tutorials/Play-Outputs.md +109 -0
  167. package/dist/tutorials/Play-Overview.md +216 -0
  168. package/dist/tutorials/Play-Title-and-Article.md +135 -0
  169. package/dist/tutorials/Play-Video-and-Style.md +143 -0
  170. package/dist/tutorials/Project-Page.md +228 -0
  171. package/dist/tutorials/Prompts.md +136 -0
  172. package/dist/tutorials/Providers-and-Keys.md +290 -0
  173. package/dist/tutorials/Publishing-to-YouTube.md +135 -0
  174. package/dist/tutorials/Recovery-and-Retries.md +141 -0
  175. package/dist/tutorials/Reviews-and-Checkpoints.md +143 -0
  176. package/dist/tutorials/Schedules.md +154 -0
  177. package/dist/tutorials/Settings-Reference.md +258 -0
  178. package/dist/tutorials/Shorts.md +116 -0
  179. package/dist/tutorials/Start-at-Login.md +126 -0
  180. package/dist/tutorials/Studio-Extension.md +146 -0
  181. package/dist/tutorials/Templates.md +146 -0
  182. package/dist/tutorials/Trash.md +87 -0
  183. package/dist/tutorials/Troubleshooting.md +149 -0
  184. package/dist/tutorials/Uninstalling-and-Moving.md +113 -0
  185. package/dist/tutorials/Updating-and-Patch-Notes.md +120 -0
  186. package/dist/tutorials/Video-Editing.md +135 -0
  187. package/dist/tutorials/Where-Your-Files-Live.md +149 -0
  188. package/dist/tutorials/YouTube-Description.md +132 -0
  189. package/dist/tutorials/Your-First-Short.md +125 -0
  190. package/dist/tutorials/_Footer.md +1 -0
  191. package/dist/tutorials/_Sidebar.md +69 -0
  192. package/dist/web/assets/index-C4McGNke.js +307 -0
  193. package/dist/web/assets/index-DBLSfqA-.css +1 -0
  194. package/dist/web/assets/{pdf-BG8n_uc5.js → pdf-COowcLcz.js} +1 -1
  195. package/dist/web/index.html +2 -2
  196. package/package.json +2 -2
  197. package/dist/web/assets/index-CUA2sUxU.css +0 -1
  198. package/dist/web/assets/index-CX590kE-.js +0 -303
@@ -1,22 +1,49 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { captionCues } from "../subtitles/captions.js";
3
- import { transitionSecondsMax, transitionSecondsMin, videoEditOf } from "../video/edit-settings.js";
3
+ import { legacyVideoEdit, transitionSecondsMax, transitionSecondsMin, videoEditOf, } from "../video/edit-settings.js";
4
+ import { sampleNarrationText, sampleNarrationWords, sampleShortTitle } from "./narration.js";
4
5
  import { defaultPreviewText, stylePreviewSeconds } from "./schema.js";
5
6
  export function normalizeStylePreview(request,
6
7
  // The sha256 of the picture the server found for request.image, if it found one.
7
8
  image) {
8
9
  const edit = videoEditOf(request);
9
10
  const { subtitles } = request;
10
- const text = (request.previewText ?? "").replace(/\s+/g, " ").trim();
11
+ const typed = (request.previewText ?? "").replace(/\s+/g, " ").trim();
12
+ const text = typed === "" || typed === defaultPreviewText ? sampleNarrationText : typed;
13
+ const shorts = request.shorts;
14
+ if (shorts !== undefined) {
15
+ const title = shorts.title?.replace(/\s+/g, " ").trim() ?? "";
16
+ const none = videoEditOf({ videoEdit: legacyVideoEdit });
17
+ return {
18
+ version: 2,
19
+ format: "9:16",
20
+ captions: null,
21
+ look: {
22
+ vignette: none.vignette,
23
+ grain: none.grain,
24
+ grade: none.grade,
25
+ atmosphere: none.atmosphere,
26
+ },
27
+ transition: null,
28
+ chapterCard: null,
29
+ short: {
30
+ fontId: subtitles.fontId,
31
+ text,
32
+ title: shorts.titleOnScreen ? (title === "" ? sampleShortTitle : title) : null,
33
+ speed: Math.round(Math.min(1.25, Math.max(1, shorts.speed ?? 1)) * 100) / 100,
34
+ },
35
+ ...(image === undefined ? {} : { image }),
36
+ };
37
+ }
11
38
  return {
12
- version: 1,
39
+ version: 2,
13
40
  format: request.format,
14
41
  captions: subtitles.mode === "burn-in"
15
42
  ? {
16
43
  fontId: subtitles.fontId,
17
44
  fontSize: Math.round(subtitles.fontSize),
18
45
  position: subtitles.position,
19
- text: text === "" ? defaultPreviewText : text,
46
+ text,
20
47
  }
21
48
  : null,
22
49
  look: {
@@ -51,38 +78,61 @@ export function stableJson(value) {
51
78
  export function stylePreviewHash(settings) {
52
79
  return createHash("sha256").update(stableJson(settings)).digest("hex");
53
80
  }
54
- // ceiling: three cues read comfortably in six seconds; a short text gets one per word.
55
- const cueCount = 3;
56
- // Longer than the 0.7 s pause `captionCues` starts a new cue at.
57
- const cueGap = 0.8;
58
- const lead = 0.4;
59
- // The sample text as timed words: split into up to three groups spread over the preview with
60
- // a pause between them, the words of each evenly spaced. `captionCues` then groups and wraps
61
- // them exactly as it does real narration.
62
- export function sampleWords(text, seconds = stylePreviewSeconds) {
81
+ // How long the rendered preview runs: a Shorts preview at a higher speed is shorter.
82
+ export function previewSeconds(settings) {
83
+ return settings.short === undefined
84
+ ? stylePreviewSeconds
85
+ : round(stylePreviewSeconds / settings.short.speed);
86
+ }
87
+ // ceiling: a pause this long between two words of the sample narration starts a new phrase,
88
+ // the same pause `captionCues` starts a new cue at.
89
+ const phraseGap = 0.7;
90
+ // The caption text as timed words. The sample narration's own text is its word timing as it
91
+ // was aligned; any other text is spread over the stretches the narration speaks, each phrase
92
+ // taking a share of the words by its length, the words of each evenly spaced. `captionCues`
93
+ // and the Shorts captions then group and wrap them exactly as they do real narration.
94
+ export function previewWords(text, narration = sampleNarrationWords) {
63
95
  const words = text.split(/\s+/).filter((word) => word !== "");
64
96
  if (words.length === 0)
65
97
  return [];
66
- const groups = Math.min(cueCount, words.length);
67
- const per = Math.ceil(words.length / groups);
68
- const chunks = Array.from({ length: groups }, (_value, at) => words.slice(at * per, (at + 1) * per)).filter((chunk) => chunk.length > 0);
69
- const span = (seconds - 2 * lead - cueGap * (chunks.length - 1)) / chunks.length;
98
+ if (words.join(" ") === narration.map((word) => word.text).join(" "))
99
+ return narration;
100
+ const phrases = [];
101
+ for (const word of narration) {
102
+ const last = phrases.at(-1);
103
+ if (last !== undefined && word.start - last.end < phraseGap)
104
+ last.end = word.end;
105
+ else
106
+ phrases.push({ start: word.start, end: word.end });
107
+ }
108
+ if (phrases.length === 0)
109
+ phrases.push({ start: 0.4, end: stylePreviewSeconds - 0.4 });
110
+ const spoken = phrases.reduce((sum, phrase) => sum + (phrase.end - phrase.start), 0);
70
111
  const timed = [];
71
- chunks.forEach((chunk, at) => {
72
- const start = lead + at * (span + cueGap);
73
- const each = span / chunk.length;
112
+ let used = 0;
113
+ let before = 0;
114
+ for (const [at, phrase] of phrases.entries()) {
115
+ before += phrase.end - phrase.start;
116
+ // The words up to the end of this phrase, by its share of the speech; the last takes
117
+ // what is left, and none is left empty while words remain.
118
+ const upTo = at === phrases.length - 1
119
+ ? words.length
120
+ : Math.min(words.length - (phrases.length - 1 - at), Math.max(used + 1, Math.round((words.length * before) / spoken)));
121
+ const chunk = words.slice(used, Math.max(used, upTo));
122
+ used += chunk.length;
123
+ const each = (phrase.end - phrase.start) / Math.max(1, chunk.length);
74
124
  chunk.forEach((word, index) => {
75
125
  timed.push({
76
126
  text: word,
77
- start: round(start + index * each),
78
- end: round(start + (index + 1) * each),
127
+ start: round(phrase.start + index * each),
128
+ end: round(phrase.start + (index + 1) * each),
79
129
  });
80
130
  });
81
- });
131
+ }
82
132
  return timed;
83
133
  }
84
- export function sampleCues(text, seconds = stylePreviewSeconds) {
85
- return captionCues(sampleWords(text, seconds));
134
+ export function previewCues(text) {
135
+ return captionCues(previewWords(text));
86
136
  }
87
137
  function round(value) {
88
138
  return Math.round(value * 1000) / 1000;
@@ -0,0 +1,57 @@
1
+ // The tutorials are the GitHub wiki's pages (docs/wiki), so their links name sections the way
2
+ // GitHub does: `[text](Page-Name#section-anchor)`. These helpers work out those anchors, for
3
+ // the server's search, the web page that scrolls to them and the test that checks every help
4
+ // entry's Learn more link. Pure: the web bundle imports this file.
5
+ export const tutorialPageIdPattern = /^[A-Za-z0-9][A-Za-z0-9-]{0,79}$/;
6
+ // A heading's text as GitHub renders it: link targets, code ticks and emphasis marks dropped.
7
+ export function headingText(markdown) {
8
+ return markdown
9
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
10
+ .replace(/`([^`]*)`/g, "$1")
11
+ .replace(/(\*\*|__)(.+?)\1/g, "$2")
12
+ .replace(/(^|[^\p{L}\p{N}])[*_]([^*_]+)[*_](?![\p{L}\p{N}])/gu, "$1$2")
13
+ .replace(/<[^>]+>/g, "")
14
+ .trim();
15
+ }
16
+ // GitHub's anchor for a heading's text: lower case, punctuation dropped, each space a hyphen.
17
+ export function githubSlug(text) {
18
+ return text
19
+ .toLowerCase()
20
+ .replace(/[^\p{L}\p{N}\p{M} _-]/gu, "")
21
+ .replace(/ /g, "-");
22
+ }
23
+ const headingLine = /^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/;
24
+ // Every heading of a page in order, with GitHub's anchor; a repeated anchor gets `-1`, `-2`.
25
+ // `#` inside fenced code is not a heading.
26
+ export function tutorialHeadings(markdown) {
27
+ const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
28
+ const seen = new Map();
29
+ const headings = [];
30
+ let fenced = false;
31
+ for (const [line, raw] of lines.entries()) {
32
+ if (/^\s*(```|~~~)/.test(raw))
33
+ fenced = !fenced;
34
+ const match = fenced ? null : headingLine.exec(raw);
35
+ if (match === null)
36
+ continue;
37
+ const text = headingText(match[2] ?? "");
38
+ const base = githubSlug(text);
39
+ const count = seen.get(base) ?? 0;
40
+ seen.set(base, count + 1);
41
+ headings.push({
42
+ level: (match[1] ?? "#").length,
43
+ text,
44
+ anchor: count === 0 ? base : `${base}-${String(count)}`,
45
+ line,
46
+ });
47
+ }
48
+ return headings;
49
+ }
50
+ // The anchors a page offers, for checking a link.
51
+ export function tutorialAnchors(markdown) {
52
+ return new Set(tutorialHeadings(markdown).map((heading) => heading.anchor));
53
+ }
54
+ // A page id as a title when the sidebar gives none: "Play-Overview" → "Play Overview".
55
+ export function pageTitleOf(id) {
56
+ return id.replace(/-/g, " ");
57
+ }
@@ -0,0 +1,185 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { pageTitleOf, tutorialHeadings, tutorialPageIdPattern } from "./anchors.js";
6
+ // dist/tutorials when running the build; the repository's docs/wiki when running from source
7
+ // (tests, `tsx`).
8
+ export function bundledTutorialsDir() {
9
+ const candidates = ["../../tutorials/", "../../../../../docs/wiki/"].map((path) => fileURLToPath(new URL(path, import.meta.url)));
10
+ return candidates.find((dir) => existsSync(join(dir, "Home.md"))) ?? candidates[0];
11
+ }
12
+ export class TutorialsMissing extends Error {
13
+ }
14
+ // `[Title](Page-Name)` links of the sidebar, under its `**Group**` lines. The first link
15
+ // before any group is the home page.
16
+ export function parseSidebar(markdown, titleOf = () => undefined) {
17
+ const groups = [];
18
+ let home;
19
+ for (const line of markdown.replace(/\r\n?/g, "\n").split("\n")) {
20
+ const group = /^\s*\*\*([^*[\]]+)\*\*\s*$/.exec(line);
21
+ if (group !== null) {
22
+ groups.push({ title: (group[1] ?? "").trim(), pages: [] });
23
+ continue;
24
+ }
25
+ for (const link of line.matchAll(/\[([^\]]+)\]\(([^)#\s]+)\)/g)) {
26
+ const id = link[2] ?? "";
27
+ if (!tutorialPageIdPattern.test(id))
28
+ continue;
29
+ const page = { id, title: (link[1] ?? titleOf(id) ?? pageTitleOf(id)).trim() };
30
+ const last = groups[groups.length - 1];
31
+ if (last === undefined)
32
+ home ??= page;
33
+ else
34
+ last.pages.push(page);
35
+ }
36
+ }
37
+ return { home, groups: groups.filter((group) => group.pages.length > 0) };
38
+ }
39
+ // Reads every page once; they never change while the app runs.
40
+ export async function loadTutorials(dir) {
41
+ let names;
42
+ try {
43
+ names = await readdir(dir);
44
+ }
45
+ catch (error) {
46
+ if (error.code === "ENOENT")
47
+ throw new TutorialsMissing("This copy of Slopify has no tutorials: its docs/wiki folder is missing. Reinstall Slopify (or run npm run build) and reload the page.");
48
+ throw error;
49
+ }
50
+ const pages = new Map();
51
+ for (const name of names.sort()) {
52
+ const id = name.replace(/\.md$/, "");
53
+ if (!name.endsWith(".md") || !tutorialPageIdPattern.test(id))
54
+ continue;
55
+ pages.set(id, await readFile(join(dir, name), "utf8"));
56
+ }
57
+ if (!pages.has("Home"))
58
+ throw new TutorialsMissing("This copy of Slopify has damaged tutorials: docs/wiki/Home.md is missing. Reinstall Slopify (or run npm run build) and reload the page.");
59
+ const extra = async (name) => readFile(join(dir, name), "utf8").catch((error) => {
60
+ if (error.code === "ENOENT")
61
+ return "";
62
+ throw error;
63
+ });
64
+ const sidebar = parseSidebar(await extra("_Sidebar.md"));
65
+ const groups = sidebar.groups
66
+ .map((group) => ({ ...group, pages: group.pages.filter((page) => pages.has(page.id)) }))
67
+ .filter((group) => group.pages.length > 0);
68
+ const listed = new Map();
69
+ const home = sidebar.home ?? { id: "Home", title: "Home" };
70
+ listed.set(home.id, home);
71
+ for (const group of groups)
72
+ for (const page of group.pages)
73
+ listed.set(page.id, page);
74
+ for (const id of pages.keys())
75
+ if (!listed.has(id))
76
+ listed.set(id, { id, title: pageTitleOf(id) });
77
+ return {
78
+ index: {
79
+ home,
80
+ groups,
81
+ pages: [...listed.values()],
82
+ footer: (await extra("_Footer.md")).trim(),
83
+ },
84
+ pages,
85
+ };
86
+ }
87
+ // Plain words of a Markdown line: links become their text, marks and tables' pipes go.
88
+ function plain(markdown) {
89
+ return markdown
90
+ .replace(/^\s*\|?\s*:?-{3,}[-|: ]*$/gm, "")
91
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
92
+ .replace(/[`*_>|]/g, " ")
93
+ .replace(/^\s*(?:[-+]|\d+\.)\s+/gm, "")
94
+ .replace(/\s+/g, " ")
95
+ .trim();
96
+ }
97
+ function sectionsOf(markdown) {
98
+ const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
99
+ const headings = tutorialHeadings(markdown);
100
+ const sections = [];
101
+ const first = headings[0]?.line ?? lines.length;
102
+ sections.push({ anchor: "", heading: undefined, text: plain(lines.slice(0, first).join("\n")) });
103
+ for (const [at, heading] of headings.entries()) {
104
+ const end = headings[at + 1]?.line ?? lines.length;
105
+ sections.push({
106
+ anchor: heading.anchor,
107
+ heading: heading.text,
108
+ text: plain(lines.slice(heading.line + 1, end).join("\n")),
109
+ });
110
+ }
111
+ return sections.filter((section) => section.heading !== undefined || section.text !== "");
112
+ }
113
+ function snippetOf(text, term) {
114
+ const at = term === "" ? -1 : text.toLowerCase().indexOf(term);
115
+ if (at < 0)
116
+ return text.length > 180 ? `${text.slice(0, 177).trimEnd()}…` : text;
117
+ // Whole words at both ends.
118
+ const from = Math.max(0, at - 60);
119
+ const start = from === 0 ? 0 : text.indexOf(" ", from) + 1 || from;
120
+ const to = Math.min(text.length, at + term.length + 120);
121
+ const end = to === text.length ? to : text.lastIndexOf(" ", to) > at ? text.lastIndexOf(" ", to) : to;
122
+ return `${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`;
123
+ }
124
+ // The sections that hold every word typed, best first: a word in the page's title or the
125
+ // section's heading counts more than one in its text. The first heading (the page's own
126
+ // title) stands for the page.
127
+ export function searchTutorials(book, query, limit = 30) {
128
+ const terms = query
129
+ .toLowerCase()
130
+ .split(/\s+/)
131
+ .map((term) => term.trim())
132
+ .filter((term) => term.length > 0)
133
+ .slice(0, 8);
134
+ if (terms.length === 0)
135
+ return [];
136
+ const titles = new Map(book.index.pages.map((page) => [page.id, page.title]));
137
+ const scored = [];
138
+ let order = 0;
139
+ for (const summary of book.index.pages) {
140
+ const markdown = book.pages.get(summary.id);
141
+ if (markdown === undefined)
142
+ continue;
143
+ const pageTitle = titles.get(summary.id) ?? pageTitleOf(summary.id);
144
+ for (const section of sectionsOf(markdown)) {
145
+ order += 1;
146
+ const heading = (section.heading ?? "").toLowerCase();
147
+ const text = section.text.toLowerCase();
148
+ const title = pageTitle.toLowerCase();
149
+ let score = 0;
150
+ let all = true;
151
+ let own = false;
152
+ for (const term of terms) {
153
+ const inHeading = heading.includes(term);
154
+ const inTitle = title.includes(term);
155
+ const inText = text.includes(term);
156
+ if (!inHeading && !inText && !inTitle) {
157
+ all = false;
158
+ break;
159
+ }
160
+ own ||= inHeading || inText;
161
+ score += (inHeading ? 6 : 0) + (inTitle ? 3 : 0) + (inText ? 1 : 0);
162
+ }
163
+ // A word only in the page's title would list every section of the page.
164
+ if (!all || !own)
165
+ continue;
166
+ if (heading === query.trim().toLowerCase())
167
+ score += 10;
168
+ scored.push({
169
+ hit: {
170
+ page: summary.id,
171
+ pageTitle,
172
+ anchor: section.anchor,
173
+ heading: section.heading,
174
+ snippet: snippetOf(section.text, terms.find((term) => text.includes(term)) ?? ""),
175
+ },
176
+ score,
177
+ order,
178
+ });
179
+ }
180
+ }
181
+ return scored
182
+ .sort((a, b) => b.score - a.score || a.order - b.order)
183
+ .slice(0, limit)
184
+ .map(({ hit }) => hit);
185
+ }
@@ -26,9 +26,14 @@ export function audioChapters(input) {
26
26
  endMs: points[at + 1]?.startMs ?? totalMs,
27
27
  }));
28
28
  }
29
- // FFMETADATA1: `=`, `;`, `#`, `\` and line breaks are escaped with a backslash.
30
- export function ffmetadata(title, chapters) {
29
+ // FFMETADATA1: `=`, `;`, `#`, `\` and line breaks are escaped with a backslash. A chapter of a
30
+ // book also carries the book as its album and the chapter number as its track (ID3 TALB and
31
+ // TRCK in the MP3, the album and track atoms in the M4B), so a player files the chapters together
32
+ // and in order.
33
+ export function ffmetadata(title, chapters, book) {
31
34
  const lines = [";FFMETADATA1", `title=${escapeMeta(title)}`];
35
+ if (book !== undefined)
36
+ lines.push(`album=${escapeMeta(book.title.trim())}`, `track=${String(book.chapter)}`);
32
37
  for (const chapter of chapters)
33
38
  lines.push("", "[CHAPTER]", "TIMEBASE=1/1000", `START=${String(chapter.startMs)}`, `END=${String(chapter.endMs)}`, `title=${escapeMeta(chapter.title)}`);
34
39
  return `${lines.join("\n")}\n`;
@@ -2,18 +2,36 @@
2
2
  // cast in a run. A speaker picked from the cast keeps the member's id, and every run started
3
3
  // later takes the member's voice as it is then, so a character or a host sounds the same in
4
4
  // every episode; editing the cast later changes no project already made.
5
- export function castSpeakers(members) {
6
- return {
7
- speakers: () => members.flatMap((member) => member.voice === undefined ? [] : [speakerFromCast(member, "character")]),
8
- };
5
+ // The channel's hosts as speakers: every member marked as a host who has a voice, in the cast's
6
+ // order. A new podcast or interview starts with them (`defaultVoicesSettings`), so the channel's
7
+ // recurring voices are there without adding them each time.
8
+ export function castHosts(members) {
9
+ return members.flatMap((member) => member.host === true && member.voice !== undefined ? [speakerFromCast(member, "host")] : []);
10
+ }
11
+ // A speaker id no other member can share: the member's whole id (a UUID) rather than its first
12
+ // eight characters, which two members may have in common. An id the speaker schema cannot hold
13
+ // (never one the app made) falls back to a hash of it. A speaker already saved keeps its id.
14
+ export function castSpeakerId(memberId) {
15
+ const lower = memberId.toLowerCase();
16
+ return /^[a-z0-9-]{1,55}$/.test(lower) ? `cast-${lower}` : `cast-h${hashOf(memberId)}`;
17
+ }
18
+ // cyrb53: 53 bits, the same in the browser and on the server.
19
+ function hashOf(text) {
20
+ let h1 = 0xdeadbeef;
21
+ let h2 = 0x41c6ce57;
22
+ for (let i = 0; i < text.length; i += 1) {
23
+ const code = text.charCodeAt(i);
24
+ h1 = Math.imul(h1 ^ code, 2654435761);
25
+ h2 = Math.imul(h2 ^ code, 1597334677);
26
+ }
27
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
28
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
29
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
9
30
  }
10
31
  export function speakerFromCast(member, role) {
11
32
  const voice = member.voice;
12
33
  return {
13
- id: `cast-${member.id
14
- .slice(0, 8)
15
- .toLowerCase()
16
- .replace(/[^a-z0-9-]/g, "")}`,
34
+ id: castSpeakerId(member.id),
17
35
  name: member.name.slice(0, 40),
18
36
  role,
19
37
  voice: voice === undefined
@@ -1,6 +1,16 @@
1
1
  import { speakerPace } from "./model.js";
2
2
  export const dialogueCapabilities = [
3
3
  { provider: "elevenlabs", models: ["eleven_v3"], maxVoices: 10, maxCharacters: 2000 },
4
+ {
5
+ provider: "google-tts",
6
+ models: [
7
+ "gemini-2.5-flash-preview-tts",
8
+ "gemini-2.5-pro-preview-tts",
9
+ "gemini-3.1-flash-tts-preview",
10
+ ],
11
+ maxVoices: 2,
12
+ maxCharacters: 3000,
13
+ },
4
14
  ];
5
15
  export function dialogueCapability(provider, model) {
6
16
  return dialogueCapabilities.find((capability) => capability.provider === provider && capability.models.includes(model));
@@ -47,6 +47,19 @@ export const speakerSchema = z.object({
47
47
  .regex(/^[0-9a-f]{64}$/)
48
48
  .optional(),
49
49
  });
50
+ // A book: a series of audiobook projects, one per chapter, that share their speakers, voices,
51
+ // cast and settings. "Make the next chapter" on a finished chapter starts the next one in Play.
52
+ // The MP3 and M4B carry it as their album and track. Absent is a project on its own, which is
53
+ // every project made before this.
54
+ export const bookTitleMax = 200;
55
+ export const bookChapterMax = 9999;
56
+ export const bookSchema = z
57
+ .object({ title: z.string().max(bookTitleMax), chapter: z.number() })
58
+ .strict()
59
+ .readonly();
60
+ export function bookLabel(book) {
61
+ return `${book.title.trim()} · Chapter ${String(book.chapter)}`;
62
+ }
50
63
  export const voicesSettingsSchema = z.object({
51
64
  format: z.enum(voiceFormats),
52
65
  source: z.enum(scriptSources),
@@ -58,18 +71,33 @@ export const voicesSettingsSchema = z.object({
58
71
  nativeDialogue: z.boolean(),
59
72
  // MP3 and M4B files with chapter markers beside the video.
60
73
  audioFiles: z.boolean(),
74
+ // The book this project is a chapter of. Absent is none.
75
+ book: bookSchema.optional(),
61
76
  });
62
- export function defaultVoicesSettings(format) {
77
+ // `hosts` are the channel's hosts as speakers (`castHosts`): a podcast or an interview starts
78
+ // with them in place of the placeholder hosts, and the other formats ignore them.
79
+ export function defaultVoicesSettings(format, hosts = []) {
63
80
  return {
64
81
  format,
65
82
  source: "script",
66
- speakers: starterSpeakers(format),
83
+ speakers: withHosts(format, starterSpeakers(format), hosts),
67
84
  turnGapSeconds: defaultTurnGapSeconds,
68
85
  nameTags: format === "podcast" || format === "interview",
69
86
  nativeDialogue: true,
70
87
  audioFiles: true,
71
88
  };
72
89
  }
90
+ // A podcast keeps a placeholder host only while it has fewer than two; an interview keeps its
91
+ // guest after the hosts.
92
+ function withHosts(format, starters, hosts) {
93
+ if (hosts.length === 0 || (format !== "podcast" && format !== "interview"))
94
+ return [...starters];
95
+ const cast = hosts.slice(0, speakersMax - 1).map((host) => ({ ...host, role: "host" }));
96
+ const fill = format === "podcast"
97
+ ? starters.slice(0, Math.max(0, 2 - cast.length))
98
+ : starters.filter((one) => one.role !== "host");
99
+ return [...cast, ...fill.filter((one) => !cast.some((host) => host.id === one.id))];
100
+ }
73
101
  function starterSpeakers(format) {
74
102
  const blank = { provider: "", model: "", voice: "" };
75
103
  const speaker = (id, name, role) => ({
@@ -99,9 +127,6 @@ export function usesVoices(draft) {
99
127
  export function usesScriptPrompt(draft) {
100
128
  return usesVoices(draft) && draft.voices?.source === "script";
101
129
  }
102
- export function runSpeakers(settings) {
103
- return { speakers: () => settings.speakers };
104
- }
105
130
  // The rules a multi-voice run must pass before it starts, named for the control to fix.
106
131
  export function voicesProblems(settings) {
107
132
  const problems = [];
@@ -109,23 +134,23 @@ export function voicesProblems(settings) {
109
134
  if (speakers.length === 0)
110
135
  problems.push({
111
136
  field: "voices.speakers",
112
- message: "Add at least one speaker under Speakers (Play → Audio, or Edit project → Providers).",
137
+ message: "Add at least one speaker under Speakers (Play → Narration, or Edit project → Providers).",
113
138
  });
114
139
  if (settings.source === "attribute" && settings.format !== "audiobook")
115
140
  problems.push({
116
141
  field: "voices.source",
117
- message: "Only audiobooks can hand an existing text's dialogue to speakers. Choose Write a script under Speakers (Play → Audio, or Edit project → Providers).",
142
+ message: "Only audiobooks can hand an existing text's dialogue to speakers. Choose Write a script under Speakers (Play → Narration, or Edit project → Providers).",
118
143
  });
119
144
  if ((settings.format === "podcast" || settings.format === "interview") && speakers.length < 2)
120
145
  problems.push({
121
146
  field: "voices.speakers",
122
- message: `A ${voiceFormatLabels[settings.format].toLowerCase()} needs at least two speakers. Add one under Speakers (Play → Audio, or Edit project → Providers).`,
147
+ message: `A ${voiceFormatLabels[settings.format].toLowerCase()} needs at least two speakers. Add one under Speakers (Play → Narration, or Edit project → Providers).`,
123
148
  });
124
149
  if ((settings.format === "audiobook" || settings.format === "drama") &&
125
150
  !speakers.some((speaker) => speaker.role === "narrator"))
126
151
  problems.push({
127
152
  field: "voices.speakers",
128
- message: `An ${settings.format === "drama" ? "radio drama" : "audiobook"} needs a narrator. Set one speaker's role to Narrator under Speakers (Play → Audio, or Edit project → Providers).`,
153
+ message: `An ${settings.format === "drama" ? "radio drama" : "audiobook"} needs a narrator. Set one speaker's role to Narrator under Speakers (Play → Narration, or Edit project → Providers).`,
129
154
  });
130
155
  const names = new Set();
131
156
  const ids = new Set();
@@ -167,10 +192,24 @@ export function voicesProblems(settings) {
167
192
  message: `Pick a pace from the list for ${name || "this speaker"}.`,
168
193
  });
169
194
  }
195
+ if (settings.book !== undefined) {
196
+ if (settings.book.title.trim() === "")
197
+ problems.push({
198
+ field: "voices.book.title",
199
+ message: "Enter the book's title under Speakers (Play → Narration), or remove the book to make a project on its own.",
200
+ });
201
+ if (!Number.isInteger(settings.book.chapter) ||
202
+ settings.book.chapter < 1 ||
203
+ settings.book.chapter > bookChapterMax)
204
+ problems.push({
205
+ field: "voices.book.chapter",
206
+ message: `Enter a chapter number from 1 to ${String(bookChapterMax)} under Speakers (Play → Narration).`,
207
+ });
208
+ }
170
209
  if (!turnGapSteps.includes(settings.turnGapSeconds))
171
210
  problems.push({
172
211
  field: "voices.turnGapSeconds",
173
- message: "Pick a gap between turns from the list under Speakers (Play → Audio, or Edit project → Providers).",
212
+ message: "Pick a gap between turns from the list under Speakers (Play → Narration, or Edit project → Providers).",
174
213
  });
175
214
  return problems;
176
215
  }
@@ -42,7 +42,7 @@ export function parseScript(markdown, speakers, keepMarkdown = false) {
42
42
  ok: false,
43
43
  reason: turn === null
44
44
  ? `Line ${String(number)} of the script is not a turn. Start every turn with a speaker's name and a colon (${names}), one turn per paragraph.`
45
- : `Line ${String(number)} of the script is spoken by "${(turn[1] ?? "").trim()}", who is not one of the speakers (${names}). Use only these names, or add the speaker under Speakers (Play → Audio, or Edit project → Providers).`,
45
+ : `Line ${String(number)} of the script is spoken by "${(turn[1] ?? "").trim()}", who is not one of the speakers (${names}). Use only these names, or add the speaker under Speakers (Play → Narration, or Edit project → Providers).`,
46
46
  };
47
47
  }
48
48
  const spoken = [];
@@ -1,6 +1,9 @@
1
1
  // Where the hand edits and the channel links are kept: the edits per project in
2
- // `youtube_description_edits`, the Settings list of links as one `settings` row.
2
+ // `youtube_description_edits`, each channel's links in its brand kit, and the older Settings list
3
+ // of links (read as the default channel's until its Brand tab saves its own) as one `settings` row.
3
4
  import { z } from "zod";
5
+ import { defaultChannelId } from "../channels/model.js";
6
+ import { channelById, projectChannelId } from "../channels/repo.js";
4
7
  import { readSetting, writeSetting } from "../settings/repo.js";
5
8
  import { chapterNotice, fitChapters } from "./chapters.js";
6
9
  import { composeDescription, descriptionFields, resolveFields, shownFields, splitDescription, } from "./edits.js";
@@ -49,17 +52,29 @@ export function readChannelLinks(db) {
49
52
  export function writeChannelLinks(db, links) {
50
53
  writeSetting(db, channelLinksKey, JSON.stringify(links.map((link) => ({ name: link.name.trim(), url: link.url.trim() }))));
51
54
  }
55
+ // A channel's links: the ones its Brand tab saved. The default channel, until its Brand tab
56
+ // saves a list of its own (even an empty one), reads the older Settings list, so links saved
57
+ // before channels had their own keep filling. A channel that is gone has none.
58
+ export function readChannelLinksFor(db, channelId) {
59
+ const channel = channelById(db, channelId);
60
+ if (channel === undefined)
61
+ return [];
62
+ if (channel.brand.links !== undefined)
63
+ return channel.brand.links;
64
+ return channel.id === defaultChannelId ? readChannelLinks(db) : [];
65
+ }
52
66
  // The description and tags as the project page shows and copies them: the user's edits over
53
67
  // the generated text, the chapters fitted to YouTube's rules (`chapters.ts`; the last one's
54
68
  // length only checked when the video's length is given), placeholders filled from the
55
- // project's and Settings' links (one with no link stays as typed). For anything that hands the
56
- // description on, such as Prepare upload. `chapterNotice` says what the fitting changed.
69
+ // project's and its channel's links (one with no link stays as typed). For anything that hands
70
+ // the description on, such as Prepare upload and the downloads. `chapterNotice` says what the
71
+ // fitting changed.
57
72
  export function effectiveDescription(db, projectId, generated) {
58
73
  const edits = readDescriptionEdits(db, projectId);
59
74
  const shown = shownFields(resolveFields(splitDescription(generated.description, generated.tags), edits.fields));
60
75
  const fitted = fitChapters(shown.chapters, generated.durationSeconds);
61
76
  const notice = chapterNotice(fitted.adjustments);
62
- const links = mergeLinks(readChannelLinks(db), edits.links);
77
+ const links = mergeLinks(readChannelLinksFor(db, projectChannelId(db, projectId)), edits.links);
63
78
  return {
64
79
  description: fillPlaceholders(composeDescription({ ...shown, chapters: fitted.text }), links)
65
80
  .text,
@@ -5,7 +5,7 @@
5
5
  // A placeholder with no link of that name stays in the text as typed and is marked, never
6
6
  // silently dropped. Browser-safe.
7
7
  // The link YouTube descriptions most often point at, and the one that changes per project:
8
- // the project page offers it by name, and a project's own value wins over the Settings list.
8
+ // the project page offers it by name, and a project's own value wins over its channel's list.
9
9
  export const previousVideoLink = "Previous video";
10
10
  export const channelLinkNameMax = 60;
11
11
  export const channelLinkUrlMax = 2000;
@@ -63,7 +63,7 @@ export function fillPlaceholders(text, links) {
63
63
  // Why a list of links cannot be saved, in the words Settings shows; undefined when it can.
64
64
  export function channelLinksProblem(links) {
65
65
  if (links.length > channelLinksMax)
66
- return `Keep at most ${String(channelLinksMax)} channel links. Remove some in Settings → Channel links.`;
66
+ return `Keep at most ${String(channelLinksMax)} links. Remove some.`;
67
67
  const seen = new Set();
68
68
  for (const [index, link] of links.entries()) {
69
69
  const row = `Link ${String(index + 1)}`;