@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
@@ -2,7 +2,7 @@
2
2
  // the picked templates, the slot names they ask for, and the rendered text stored on the
3
3
  // project. The bodies are read at the click, not at selection, so an edit made in between
4
4
  // is the one that runs.
5
- import { normaliseDraft, usesNarrationPreparation } from "../admission/rules.js";
5
+ import { normaliseDraft, usesNarrationPreparation, usesYoutubeDescription, } from "../admission/rules.js";
6
6
  import { collectFields, render } from "../admission/substitute.js";
7
7
  import { snapshotEntry, snapshotPrompt } from "./snapshot.js";
8
8
  export function pickTemplates(db, input, snapshot) {
@@ -22,6 +22,9 @@ export function pickTemplates(db, input, snapshot) {
22
22
  const outro = pickEntry(db, "outro", draft.outro, missing, snapshot);
23
23
  push(text, "intro", intro);
24
24
  push(text, "outro", outro);
25
+ // None picked is the built-in prompt, which asks for no keyword.
26
+ if (usesYoutubeDescription(draft))
27
+ body(db, "description", draft.descriptionPrompt, "descriptionPrompt", missing, text, "description", snapshot);
25
28
  if (sources.images === "generate") {
26
29
  for (const [index, picked] of draft.imagePrompts.entries()) {
27
30
  const field = `imagePrompts.${String(index)}.name`;
@@ -58,7 +61,7 @@ function body(db, kind, name, field, missing, into, key, snapshot) {
58
61
  }
59
62
  const prompt = snapshotPrompt(db, snapshot, kind, name);
60
63
  if (prompt === undefined) {
61
- missing.push({ field, message: `That ${kind} prompt no longer exists; pick another.` });
64
+ missing.push({ field, message: `That ${kind} prompt was deleted. Choose another.` });
62
65
  return;
63
66
  }
64
67
  into.push({ key, body: prompt.body });
@@ -71,7 +74,7 @@ function pickEntry(db, category, choice, missing, snapshot) {
71
74
  if (entry === undefined) {
72
75
  missing.push({
73
76
  field: category,
74
- message: `That ${category} entry no longer exists; pick another.`,
77
+ message: `That ${category} entry was deleted. Choose another.`,
75
78
  });
76
79
  }
77
80
  return entry;
@@ -46,7 +46,7 @@ export async function joinNarration(deps, input) {
46
46
  const first = input.files[0];
47
47
  if (first === undefined) {
48
48
  // The stage refuses an empty narration before it gets here, so this is a bug.
49
- throw new Error("there are no audio chunks to join");
49
+ throw new Error("Slopify hit an internal error (there is no narration audio to join). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
50
50
  }
51
51
  mkdirSync(dirname(input.output), { recursive: true, mode: 0o700 });
52
52
  if (input.files.length === 1) {
@@ -18,7 +18,7 @@ export function pronunciationChunks(source, chunking, entries, overriddenKeys =
18
18
  const chunks = chunkNarration(source, chunking).map((text) => {
19
19
  const start = source.indexOf(text, offset);
20
20
  if (start < 0)
21
- throw new Error("Narration chunk is missing from its source.");
21
+ throw new Error("Slopify hit an internal error (a narration chunk isn't in the article text). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
22
22
  offset = start + text.length;
23
23
  return { key: nextKey(text), text, start, end: offset };
24
24
  });
@@ -78,7 +78,7 @@ export function pronunciationChunks(source, chunking, entries, overriddenKeys =
78
78
  }
79
79
  const chunk = chunks[index];
80
80
  if (chunk === undefined)
81
- throw new Error("Narration chunk disappeared.");
81
+ throw new Error("Slopify hit an internal error (a narration chunk went missing). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
82
82
  while ((matches[matchIndex]?.end ?? Infinity) <= chunk.end)
83
83
  matchIndex++;
84
84
  const match = matches[matchIndex];
@@ -90,7 +90,7 @@ export function pronunciationChunks(source, chunking, entries, overriddenKeys =
90
90
  continue;
91
91
  const first = chunks[from];
92
92
  if (first === undefined)
93
- throw new Error("Narration group has no first chunk.");
93
+ throw new Error("Slopify hit an internal error (a group of narration chunks is empty). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
94
94
  const text = source.slice(first.start, chunk.end);
95
95
  result.push(from === index
96
96
  ? { key: chunk.key, text }
@@ -105,7 +105,7 @@ export function pronunciationMatches(source, entries) {
105
105
  continue;
106
106
  const entry = ordered.find((_, index) => match[index + 1] !== undefined);
107
107
  if (entry === undefined)
108
- throw new Error("Matched glossary term has no mapping.");
108
+ throw new Error("Slopify hit an internal error (a pronunciation glossary term has no pronunciation). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
109
109
  matches.push({ start: match.index, end, entry });
110
110
  }
111
111
  return matches;
@@ -117,7 +117,7 @@ export function pronunciationSpans(source, entries) {
117
117
  for (const [index, word] of Array.from(words).entries()) {
118
118
  const ipa = match.entry.ipa[index];
119
119
  if (ipa === undefined)
120
- throw new Error("Validated glossary term has no IPA word.");
120
+ throw new Error("Slopify hit an internal error (a pronunciation glossary term is missing a word). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
121
121
  const start = match.start + word.index;
122
122
  spans.push({ start, end: start + word[0].length, text: `/${ipa}/` });
123
123
  }
@@ -14,7 +14,7 @@ import { planLegacyNarration } from "./legacy-plan.js";
14
14
  import { observeNarration } from "./live.js";
15
15
  // An empty narration source fails the stage immediately with no retries. Thrown from the
16
16
  // slice rather than from a provider call, so the attempt wrapper never sees it.
17
- export const nothingToNarrate = "nothing to narrate";
17
+ export const nothingToNarrate = "There is nothing to narrate because the article is empty. Add article text in Edit project → Article, then Retry stage.";
18
18
  // What a chunk piece carries between runs: the text that was sent, and the file it came
19
19
  // back as once the provider answered. That is the whole of the resume.
20
20
  const chunkPayload = z.object({ text: z.string(), file: z.string().optional() });
@@ -35,13 +35,13 @@ export async function runNarration(deps, context, providers) {
35
35
  deps.audioPreviews?.clear(projectId);
36
36
  const project = projectById(deps.db, projectId);
37
37
  if (project === undefined) {
38
- throw new Error(`project ${projectId} has no row`);
38
+ throw new Error("This project no longer exists; it may have been deleted while it was running.");
39
39
  }
40
40
  const choice = project.config.audio;
41
41
  if (choice === undefined) {
42
42
  // Admission refuses a run whose audio is Generate without a provider and a voice, so
43
43
  // reaching here is a bug in admission rather than something the user did.
44
- throw new Error("the run has no TTS provider or voice");
44
+ throw new Error("No voice is set for the narration. Choose a voice in Edit project → Providers, then Retry stage.");
45
45
  }
46
46
  // Read once, before anything is written: the segment steps below ask it what a previous
47
47
  // run already stored, and the body step is the only thing that adds to it.
@@ -168,7 +168,7 @@ async function speakSegments(deps, context, providers, choice, outputs) {
168
168
  if (text === "") {
169
169
  // The same rule, applied to a segment: there is nothing to say, and silently
170
170
  // dropping an intro the user picked would lose it without telling anyone.
171
- throw new Error(`the ${segment.category} segment has ${nothingToNarrate}`);
171
+ throw new Error(`The ${segment.category} is empty, so there is nothing to narrate for it. Write the ${segment.category} or remove it in Edit project, then Retry stage.`);
172
172
  }
173
173
  const spoken = await providers.forPiece(piece.id).tts({
174
174
  provider: choice.provider,
@@ -200,7 +200,7 @@ function segmentPieces(deps, projectId) {
200
200
  if (article === undefined) {
201
201
  // Admission writes all six stage rows with the project, so a project without an article
202
202
  // stage is a bug rather than a run the user configured.
203
- throw new Error(`project ${projectId} has no article stage`);
203
+ throw new Error("Slopify hit an internal error (the project has no article stage). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
204
204
  }
205
205
  return piecesOf(deps.db, article.id, "segment");
206
206
  }
@@ -257,13 +257,13 @@ function store(deps, projectId, role, path, durationMs, choice) {
257
257
  }
258
258
  function chunkOf(piece) {
259
259
  if (piece.payload === null) {
260
- throw new Error(`chunk ${piece.id} has no payload`);
260
+ throw new Error("Slopify hit an internal error (a saved narration chunk is empty). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
261
261
  }
262
262
  return chunkPayload.parse(JSON.parse(piece.payload));
263
263
  }
264
264
  function segmentOf(piece) {
265
265
  if (piece.payload === null) {
266
- throw new Error(`segment ${piece.id} has no payload`);
266
+ throw new Error("Slopify hit an internal error (a saved intro or outro is empty). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
267
267
  }
268
268
  return segmentPayload.parse(JSON.parse(piece.payload));
269
269
  }
@@ -9,14 +9,14 @@ function atoms(source, from, to, spans) {
9
9
  const span = spans.get(at);
10
10
  if (span !== undefined) {
11
11
  if (span.end > to || span.end <= at)
12
- throw new Error("Pronunciation span crosses a word.");
12
+ throw new Error("Slopify hit an internal error (a pronunciation fix spans more than one word). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
13
13
  result.push({ text: span.text, spokenText: source.slice(at, span.end) });
14
14
  at = span.end;
15
15
  }
16
16
  else {
17
17
  const point = source.codePointAt(at);
18
18
  if (point === undefined)
19
- throw new Error("Narration offset is outside the source.");
19
+ throw new Error("Slopify hit an internal error (a narration position is outside the article text). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
20
20
  const text = String.fromCodePoint(point);
21
21
  result.push({ text, spokenText: text });
22
22
  at += text.length;
@@ -34,7 +34,7 @@ function checkedSpans(source, pronunciation) {
34
34
  span.end <= span.start ||
35
35
  span.end > source.length ||
36
36
  !/^[^\s\uD800-\uDFFF]+$/u.test(source.slice(span.start, span.end)))
37
- throw new Error("Pronunciation spans must cover disjoint whole source characters in a word.");
37
+ throw new Error("Slopify hit an internal error (pronunciation fixes overlap or split a word). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
38
38
  spans.set(span.start, span);
39
39
  end = span.end;
40
40
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
- import { numberPerPromptMax } from "../admission/rules.js";
2
+ import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultZoomPercent, edgeSilenceSecondsProblem, imageSecondsProblem, numberPerPromptMax, zoomPercentProblem, } from "../admission/rules.js";
3
3
  import { runDraftSchema } from "../admission/schema.js";
4
+ import { documentThemeOf } from "../document/model.js";
4
5
  import { defaultSubtitles } from "../subtitles/model.js";
5
6
  export function toAdmissionDraft(input) {
6
7
  const { form } = input.document;
@@ -22,6 +23,18 @@ export function toAdmissionDraft(input) {
22
23
  fields.push({ field, message: `Enter a whole number between ${min} and ${max}.` });
23
24
  return min;
24
25
  };
26
+ // A value the run uses is refused in the rule's words; one it ignores (the control is
27
+ // hidden) falls back to the default rather than holding up the run.
28
+ const measure = (field, used, fallback, problem) => {
29
+ const raw = form[field].trim();
30
+ const value = raw === "" ? Number.NaN : Number(raw);
31
+ const refused = problem(value);
32
+ if (refused === undefined)
33
+ return value;
34
+ if (used)
35
+ fields.push({ field, message: refused });
36
+ return fallback;
37
+ };
25
38
  const entry = (category) => {
26
39
  const name = form[category];
27
40
  if (sources.audio !== "generate" || name === "")
@@ -31,7 +44,7 @@ export function toAdmissionDraft(input) {
31
44
  return { name: saved.name, mode: saved.mode };
32
45
  fields.push({
33
46
  field: category,
34
- message: `That ${category} entry no longer exists; pick another.`,
47
+ message: `That ${category} entry was deleted. Choose another.`,
35
48
  });
36
49
  return undefined;
37
50
  };
@@ -42,8 +55,8 @@ export function toAdmissionDraft(input) {
42
55
  fields.push({
43
56
  field,
44
57
  message: attachment?.state === "copying" || attachment?.state === "pending"
45
- ? "This upload is still copying."
46
- : "That upload is no longer available; pick the file again.",
58
+ ? "This file is still uploading. Wait for it to finish."
59
+ : "That upload is no longer available. Choose the file again.",
47
60
  });
48
61
  return undefined;
49
62
  };
@@ -58,6 +71,9 @@ export function toAdmissionDraft(input) {
58
71
  title: form.title,
59
72
  format: form.format,
60
73
  sources,
74
+ ...(sources.document === "generate"
75
+ ? { document: { theme: documentThemeOf(form.document) } }
76
+ : {}),
61
77
  llm: form.llm,
62
78
  audio: sources.audio === "generate" || form.audio.usePronunciationGlossary !== undefined
63
79
  ? form.audio
@@ -67,6 +83,13 @@ export function toAdmissionDraft(input) {
67
83
  ...(sources.audio === "generate" && form.narrationPrompt?.trim()
68
84
  ? { narrationPrompt: form.narrationPrompt }
69
85
  : {}),
86
+ // Timed from the narration, so a switch left on with narration Off asks for nothing.
87
+ ...(sources.audio !== "off" && form.youtubeDescription === true
88
+ ? {
89
+ youtubeDescription: true,
90
+ ...(form.descriptionPrompt?.trim() ? { descriptionPrompt: form.descriptionPrompt } : {}),
91
+ }
92
+ : {}),
70
93
  imagePrompts: sources.images === "generate"
71
94
  ? form.imagePrompts.map((prompt, index) => ({
72
95
  name: prompt.name,
@@ -112,12 +135,19 @@ export function toAdmissionDraft(input) {
112
135
  fontSize: number(form.subtitles.fontSize, "subtitles.fontSize", 120, 16),
113
136
  },
114
137
  silenceGapSeconds: input.silenceGapSeconds,
138
+ imageSeconds: measure("imageSeconds", sources.video === "generate", defaultImageSeconds, imageSecondsProblem),
139
+ zoomPercent: measure("zoomPercent", sources.video === "generate", defaultZoomPercent, zoomPercentProblem),
140
+ // A choice from a list, so there is nothing to refuse; a run without a video keeps it
141
+ // for when the video is turned back on.
142
+ motionStyle: form.motionStyle,
143
+ edgeSilenceSeconds: measure("edgeSilenceSeconds", sources.audio !== "off", defaultEdgeSilenceSeconds, edgeSilenceSecondsProblem),
115
144
  };
116
145
  const parsed = runDraftSchema.safeParse(draft);
117
146
  if (!parsed.success)
118
147
  fields.push(...parsed.error.issues.map((issue) => ({
119
148
  field: issue.path.join("."),
120
- message: issue.message,
149
+ // The schema carries no sentences of its own; its defaults read like code.
150
+ message: "This setting is not valid. Reload the page and choose it again.",
121
151
  })));
122
152
  return fields.length || !parsed.success
123
153
  ? { ok: false, fields }
@@ -1,7 +1,7 @@
1
1
  import { checkRuntimeModel } from "../../catalog/runtime-models.js";
2
2
  import { modelFields } from "../../catalog/validate.js";
3
3
  import { readinessIsUsable } from "../../kernel/ports/model.js";
4
- import { usesNarrationPreparation } from "../admission/rules.js";
4
+ import { usesNarrationPreparation, usesYoutubeDescription, } from "../admission/rules.js";
5
5
  import { cliPathChanged } from "../settings/cli-paths.js";
6
6
  import { hasKey, listVoices } from "../settings/repo.js";
7
7
  export function choices(runs) {
@@ -16,7 +16,8 @@ export function choices(runs) {
16
16
  d.sources.thumbnail === "prompt_by_llm" ||
17
17
  d.intro?.mode === "llm" ||
18
18
  d.outro?.mode === "llm" ||
19
- usesNarrationPreparation(d),
19
+ usesNarrationPreparation(d) ||
20
+ usesYoutubeDescription(d),
20
21
  },
21
22
  {
22
23
  field: "audio",
@@ -48,7 +49,7 @@ export async function checkDraftReadiness(deps, runs) {
48
49
  field: c.field,
49
50
  message: provider?.readiness.kind === "cli" && provider.readiness.issue
50
51
  ? provider.readiness.issue
51
- : "Configure this provider before starting.",
52
+ : "Set up this provider in Settings → Providers before starting.",
52
53
  });
53
54
  continue;
54
55
  }
@@ -56,12 +57,12 @@ export async function checkDraftReadiness(deps, runs) {
56
57
  if (result === "missing")
57
58
  fields.push({
58
59
  field: `${c.field}.model`,
59
- message: "Choose an available model before starting.",
60
+ message: "This model is no longer available. Choose another model.",
60
61
  });
61
62
  else if (result === "thinking")
62
63
  fields.push({
63
64
  field: `${c.field}.thinking`,
64
- message: "Choose a supported thinking setting.",
65
+ message: "This model does not support that thinking setting. Choose another.",
65
66
  });
66
67
  }
67
68
  return { fields: [...fields, ...localDraftReadiness(deps, runs, providers)], providers };
@@ -73,15 +74,21 @@ export function localDraftReadiness(deps, runs, providers) {
73
74
  if (cliPathChanged(deps.db, provider))
74
75
  fields.push({
75
76
  field: `${c.field}.cliPath`,
76
- message: "The configured CLI command changed. Check readiness again.",
77
+ message: "This provider's command-line tool changed in Settings → Providers. Choose Review and start again.",
77
78
  });
78
79
  if (provider?.readiness.kind === "keyed" && !hasKey(deps.db, provider.id))
79
- fields.push({ field: c.field, message: "Save the provider key before starting." });
80
+ fields.push({
81
+ field: c.field,
82
+ message: "Add this provider's API key in Settings → Providers before starting.",
83
+ });
80
84
  }
81
85
  for (const { draft } of runs)
82
86
  if (draft.sources.audio === "generate" &&
83
87
  draft.audio &&
84
88
  !listVoices(deps.db).some((v) => v.provider === draft.audio?.provider && v.voiceId === draft.audio.voice))
85
- fields.push({ field: "audio.voice", message: "Choose a saved voice before starting." });
89
+ fields.push({
90
+ field: "audio.voice",
91
+ message: "Choose one of your saved voices. Add voices in Settings → Voices.",
92
+ });
86
93
  return fields;
87
94
  }
@@ -47,7 +47,10 @@ export function resolveReviewInputs(deps, view, font) {
47
47
  if (!words.success)
48
48
  fields.push({ field: "expectedWords", message: "Enter a whole number between 1 and 100000." });
49
49
  if (document.variants.length > 49)
50
- fields.push({ field: "variants", message: "Review at most 50 runs including the base run." });
50
+ fields.push({
51
+ field: "variants",
52
+ message: "You can review at most 50 videos at once, counting the first one. Remove some variations.",
53
+ });
51
54
  const converted = toAdmissionDraft({
52
55
  document,
53
56
  attachments: fresh.value.attachments,
@@ -96,7 +99,7 @@ export function resolveReviewInputs(deps, view, font) {
96
99
  field: index > 0 && (issue.field === "title" || issue.field.startsWith("values."))
97
100
  ? `variants.${index - 1}.${issue.field}`
98
101
  : issue.field,
99
- message: `${index > 0 ? `Run ${index + 1}: ` : ""}${issue.message}`,
102
+ message: `${index > 0 ? `Video ${index + 1}: ` : ""}${issue.message}`,
100
103
  })));
101
104
  runs.push({
102
105
  draft: accepted,
@@ -118,7 +121,7 @@ export function resolveReviewInputs(deps, view, font) {
118
121
  if (!stat?.isFile() || font.id !== converted.draft.subtitles?.fontId)
119
122
  fields.push({
120
123
  field: "subtitles.fontId",
121
- message: "The selected font is no longer available. Choose another font.",
124
+ message: "This font was deleted. Choose another font.",
122
125
  });
123
126
  else
124
127
  fontHash = createHash("sha256").update(readFileSync(font.path)).digest("hex");
@@ -133,7 +136,7 @@ export function resolveReviewInputs(deps, view, font) {
133
136
  : run.draft.sources[stage] !== "generate")
134
137
  fields.push({
135
138
  field: `checkpoints.${stage}`,
136
- message: "Choose a checkpoint only before an enabled generated stage or export.",
139
+ message: "Checkpoints can only go before a step Slopify generates, or before export. Remove this checkpoint or turn that step on.",
137
140
  });
138
141
  if (fields.length)
139
142
  return reviewRefusal(view, fields);
@@ -27,7 +27,7 @@ export async function resolvePlayReview(deps, view) {
27
27
  return reviewRefusal(view, [
28
28
  {
29
29
  field: "subtitles.fontId",
30
- message: "The selected font is no longer available. Choose another font.",
30
+ message: "This font was deleted. Choose another font.",
31
31
  },
32
32
  ]);
33
33
  }
@@ -41,12 +41,12 @@ export async function resolvePlayReview(deps, view) {
41
41
  if (result === "missing")
42
42
  fields.push({
43
43
  field: `${choice.field}.model`,
44
- message: "Choose an available model before reviewing.",
44
+ message: "This model is no longer available. Choose another model.",
45
45
  });
46
46
  else if (result === "thinking")
47
47
  fields.push({
48
48
  field: `${choice.field}.thinking`,
49
- message: "Choose a supported thinking setting.",
49
+ message: "This model does not support that thinking setting. Choose another.",
50
50
  });
51
51
  }
52
52
  if (fields.length)
@@ -1,9 +1,11 @@
1
1
  import { z } from "zod";
2
- import { formats } from "../../kernel/pipeline.js";
2
+ import { formats, stageKinds } from "../../kernel/pipeline.js";
3
3
  import { thinkingModes } from "../../kernel/ports/llm.js";
4
- import { stageSources } from "../admission/model.js";
4
+ import { motionStyles, stageSources } from "../admission/model.js";
5
+ import { defaultEdgeSilenceSeconds, defaultImageSeconds, defaultMotionStyle, defaultZoomPercent, } from "../admission/rules.js";
5
6
  import { runDraftSchema } from "../admission/schema.js";
6
7
  import { checkpointRowSchema, checkpointStageSchema } from "../checkpoints/schema.js";
8
+ import { documentThemes } from "../document/model.js";
7
9
  import { librarySnapshotSchema } from "../library/snapshot.js";
8
10
  import { chunkModes } from "../narration/chunk.js";
9
11
  import { subtitleModes, subtitlePositions } from "../subtitles/model.js";
@@ -27,9 +29,17 @@ export const playDraftFormSchema = z
27
29
  images: z.enum(stageSources),
28
30
  thumbnail: z.enum(stageSources),
29
31
  video: z.enum(stageSources),
32
+ // Absent on drafts and templates saved before the Document stage: Off.
33
+ document: z.enum(stageSources).optional(),
30
34
  })
31
35
  .strict()
32
36
  .readonly(),
37
+ // Absent until the theme is first chosen: the default theme.
38
+ document: z
39
+ .object({ theme: z.enum(documentThemes) })
40
+ .strict()
41
+ .readonly()
42
+ .optional(),
33
43
  llm: provider.readonly(),
34
44
  audio: provider
35
45
  .extend({ voice: text, usePronunciationGlossary: z.boolean().optional() })
@@ -37,6 +47,9 @@ export const playDraftFormSchema = z
37
47
  images: provider.readonly(),
38
48
  articlePrompt: text,
39
49
  narrationPrompt: text.optional(),
50
+ // Absent on drafts and templates saved before the YouTube description: off, built-in prompt.
51
+ youtubeDescription: z.boolean().optional(),
52
+ descriptionPrompt: text.optional(),
40
53
  imagePrompts: z.array(z.object({ name: text, number: text }).strict().readonly()).readonly(),
41
54
  thumbnailPrompt: text,
42
55
  intro: text,
@@ -55,6 +68,12 @@ export const playDraftFormSchema = z
55
68
  })
56
69
  .strict()
57
70
  .readonly(),
71
+ // Raw text, like every other number on Play, so what was typed survives a reload.
72
+ // Defaulted for drafts and templates saved before these controls existed.
73
+ imageSeconds: text.default(String(defaultImageSeconds)),
74
+ edgeSilenceSeconds: text.default(String(defaultEdgeSilenceSeconds)),
75
+ zoomPercent: text.default(String(defaultZoomPercent)),
76
+ motionStyle: z.enum(motionStyles).default(defaultMotionStyle),
58
77
  values,
59
78
  provided: z
60
79
  .object({
@@ -183,9 +202,7 @@ export const playReviewSchema = z
183
202
  stage: checkpointStageSchema,
184
203
  fingerprint: text,
185
204
  workKeys: z.array(text).readonly(),
186
- dependents: z
187
- .array(z.enum(["research", "article", "audio", "images", "thumbnail", "video"]))
188
- .readonly(),
205
+ dependents: z.array(z.enum(stageKinds)).readonly(),
189
206
  })
190
207
  .strict()
191
208
  .readonly())
@@ -68,7 +68,9 @@ function attachmentsOf(deps, id) {
68
68
  state,
69
69
  stagedFileId: invalid ? null : row.staged_file_id,
70
70
  bytes: invalid ? 0 : (staged?.bytes ?? 0),
71
- error: invalid ? "Upload is missing or incomplete. Reattach the file." : row.error,
71
+ error: invalid
72
+ ? "This file is missing or did not finish uploading. Attach it again."
73
+ : row.error,
72
74
  };
73
75
  });
74
76
  }
@@ -43,7 +43,7 @@ export async function uploadDraftAttachment(deps, input) {
43
43
  if (!result.ok) {
44
44
  deps.db
45
45
  .prepare("UPDATE play_draft_attachments SET status='reattach',error=? WHERE id=? AND draft_id=? AND staged_file_id IS NULL")
46
- .run("Upload failed. Reattach the file.", input.attachmentId, input.draftId);
46
+ .run("The upload failed. Attach the file again.", input.attachmentId, input.draftId);
47
47
  return refuse("invalid-edit");
48
48
  }
49
49
  const updated = deps.db
@@ -74,6 +74,7 @@ function documentFromProject(deps, revision) {
74
74
  };
75
75
  addPrompt("article", config.articlePrompt, "article");
76
76
  addPrompt("narration", config.narrationPrompt, "narration");
77
+ addPrompt("description", config.descriptionPrompt, "description");
77
78
  const definitions = revision.content.imageOrder.flatMap((key) => {
78
79
  const definition = revision.content.imageDefinitions[key];
79
80
  return definition === undefined ? [] : [definition];
@@ -127,12 +128,17 @@ function documentFromProject(deps, revision) {
127
128
  title: config.title,
128
129
  format: config.format,
129
130
  sources: config.sources,
131
+ ...(config.document === undefined ? {} : { document: config.document }),
130
132
  checkpoints: listCheckpoints(deps.db, revision.projectId, revision.id).map((gate) => gate.stage),
131
133
  llm: config.llm ?? { provider: "", model: "" },
132
134
  audio: config.audio ?? { provider: "", model: "", voice: "" },
133
135
  images: config.images ?? { provider: "", model: "" },
134
136
  articlePrompt: config.articlePrompt ?? "",
135
137
  ...(config.narrationPrompt === undefined ? {} : { narrationPrompt: config.narrationPrompt }),
138
+ ...(config.youtubeDescription === true ? { youtubeDescription: true } : {}),
139
+ ...(config.descriptionPrompt === undefined
140
+ ? {}
141
+ : { descriptionPrompt: config.descriptionPrompt }),
136
142
  imagePrompts,
137
143
  thumbnailPrompt: config.thumbnailPrompt ?? "",
138
144
  intro: config.intro?.name ?? "",
@@ -149,6 +155,10 @@ function documentFromProject(deps, revision) {
149
155
  fontSize: config.subtitles?.fontSize === undefined ? "48" : String(config.subtitles.fontSize),
150
156
  position: config.subtitles?.position ?? "bottom",
151
157
  },
158
+ imageSeconds: String(config.imageSeconds),
159
+ edgeSilenceSeconds: String(config.edgeSilenceSeconds),
160
+ zoomPercent: String(config.zoomPercent),
161
+ motionStyle: config.motionStyle,
152
162
  values: config.values,
153
163
  provided,
154
164
  },
@@ -5,6 +5,7 @@ export function templateSetup(deps, input) {
5
5
  const choices = [
6
6
  { kind: "article", name: input.form.articlePrompt },
7
7
  { kind: "narration", name: input.form.narrationPrompt ?? "" },
8
+ { kind: "description", name: input.form.descriptionPrompt ?? "" },
8
9
  ...input.form.imagePrompts.map((row) => ({ kind: "image", name: row.name })),
9
10
  { kind: "thumbnail", name: input.form.thumbnailPrompt },
10
11
  ];
@@ -92,14 +92,14 @@ export function admitPreview(deps, input) {
92
92
  continue;
93
93
  }
94
94
  if (existing?.state === "running")
95
- throw new Error("Selected work has a mismatched running reservation.");
95
+ throw new Error("Slopify hit an internal error (a step is already running for a different version of the project). Try again; if it happens again, use Download diagnostics in Settings and report it.");
96
96
  deps.db
97
97
  .prepare("DELETE FROM revision_work_reservations WHERE revision_id=? AND work_key=?")
98
98
  .run(view.revision.id, recipe.key);
99
99
  const key = reservationKey(recipe, view);
100
100
  const fingerprint = snapshot.anchors[key];
101
101
  if (fingerprint === undefined)
102
- throw new Error("Selected work has no reviewed desired anchor.");
102
+ throw new Error("Slopify hit an internal error (a step has no saved starting point). Try again; if it happens again, use Download diagnostics in Settings and report it.");
103
103
  const work = insertInvocation(deps, view, recipe, catalogue, { key, fingerprint }, false, view.revision.id, admissionId);
104
104
  workIds.push(work.workId);
105
105
  }
@@ -1,4 +1,7 @@
1
1
  import { stageKinds } from "../../kernel/pipeline.js";
2
+ import { sourceOf } from "../admission/model.js";
3
+ import { motionStyleLabels } from "../admission/rules.js";
4
+ import { documentThemeLabels, documentThemeOf } from "../document/model.js";
2
5
  import { getRevisionView } from "../revisions/view.js";
3
6
  export function previewDetails(deps, view, allRecipes, selected) {
4
7
  const parent = view.revision.parentId === null
@@ -111,7 +114,7 @@ function inputChanges(parent, view) {
111
114
  add("Project title", before.title, after.title);
112
115
  add("Aspect ratio", before.format, after.format);
113
116
  for (const kind of stageKinds)
114
- add(`${kind[0]?.toUpperCase()}${kind.slice(1)} source`, before.sources[kind], after.sources[kind]);
117
+ add(`${kind[0]?.toUpperCase()}${kind.slice(1)} source`, sourceOf(before.sources, kind), sourceOf(after.sources, kind));
115
118
  for (const [name, a, b] of [
116
119
  ["Text", before.llm, after.llm],
117
120
  ["Narration", before.audio, after.audio],
@@ -124,10 +127,17 @@ function inputChanges(parent, view) {
124
127
  add("Narration voice", before.audio?.voice, after.audio?.voice);
125
128
  add("Narration Preparation", before.narrationPrompt, after.narrationPrompt);
126
129
  add("Narration chunking", before.chunking, after.chunking);
130
+ add("YouTube description", before.youtubeDescription === true ? "On" : "Off", after.youtubeDescription === true ? "On" : "Off");
131
+ add("Description prompt", before.descriptionPrompt, after.descriptionPrompt);
127
132
  add("Silence gap (seconds)", before.silenceGapSeconds, after.silenceGapSeconds);
133
+ add("Silence at start and end (seconds)", before.edgeSilenceSeconds, after.edgeSilenceSeconds);
134
+ add("Seconds per image", before.imageSeconds, after.imageSeconds);
135
+ add("Zoom (%)", before.zoomPercent, after.zoomPercent);
136
+ add("Motion", motionStyleLabels[before.motionStyle], motionStyleLabels[after.motionStyle]);
128
137
  add("Intro", before.intro, after.intro);
129
138
  add("Outro", before.outro, after.outro);
130
139
  add("Subtitle settings", before.subtitles, after.subtitles);
140
+ add("Document theme", documentThemeLabels[documentThemeOf(before.document)], documentThemeLabels[documentThemeOf(after.document)]);
131
141
  add("Article text", parent.articleMarkdown, view.articleMarkdown);
132
142
  add("Provided research", before.provided.research, after.provided.research);
133
143
  for (const key of new Set([...Object.keys(before.values), ...Object.keys(after.values)]))
@@ -152,6 +152,9 @@ export function audioRecipes(context, text) {
152
152
  "audio-media-v1",
153
153
  ordered.map(({ value }) => resourceIdentity(context, value)),
154
154
  config.silenceGapSeconds,
155
+ // The exported timeline, not a narration piece: the silence only reaches the
156
+ // exports that read this fingerprint.
157
+ config.edgeSilenceSeconds,
155
158
  ]),
156
159
  timeline,
157
160
  keys: ordered.map(({ value }) => value.key),
@@ -1,17 +1,22 @@
1
1
  import { audioRecipes } from "./recipe-audio.js";
2
+ import { documentRecipes } from "./recipe-document.js";
2
3
  import { exportRecipes } from "./recipe-exports.js";
3
4
  import { textRecipes } from "./recipe-text.js";
4
5
  import { thumbnailRecipes, visualAssets, visualRecipes } from "./recipe-visual.js";
6
+ import { youtubeRecipes } from "./recipe-youtube.js";
5
7
  export function buildRecipes(context) {
6
8
  const text = textRecipes(context);
7
9
  const audio = audioRecipes(context, text);
8
10
  const exports = exportRecipes(context, audio);
9
11
  const captions = exports.find((value) => value.key === "subtitles:files");
12
+ const thumbnail = thumbnailRecipes(context, text.recipes);
10
13
  return [
11
14
  ...text.recipes,
12
15
  ...audio.recipes,
13
16
  ...exports,
14
- ...thumbnailRecipes(context, text.recipes),
17
+ ...youtubeRecipes(context, exports),
18
+ ...thumbnail,
19
+ ...documentRecipes(context, text, thumbnail),
15
20
  ...visualAssets(context, visualRecipes(context.config, context.content, audio.mediaFingerprint, captions?.fingerprint ?? null)),
16
21
  ];
17
22
  }