@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
@@ -0,0 +1,36 @@
1
+ import { sourceOf } from "../admission/model.js";
2
+ import { documentThemeOf } from "../document/model.js";
3
+ import { builtInTheme } from "../document/theme.js";
4
+ import { recipe, resourceIdentity, } from "./recipe-model.js";
5
+ // Bumped when the renderer's output changes for the same inputs, so documents made by an
6
+ // older one read as outdated rather than current.
7
+ const renderer = "document-v1";
8
+ // The PDF is made from the saved article (whose end matter feeds the Sources page), the
9
+ // research notes' links, the thumbnail as a cover and the resolved theme. It waits for
10
+ // nothing else, so narration and images run beside it.
11
+ export function documentRecipes(context, text, thumbnail) {
12
+ const { config } = context;
13
+ if (sourceOf(config.sources, "document") !== "generate")
14
+ return [];
15
+ const notes = text.recipes.find((value) => value.key === "research:notes");
16
+ const cover = thumbnail.find((value) => value.key === "thumbnail:image");
17
+ const theme = builtInTheme(documentThemeOf(config.document));
18
+ return [
19
+ recipe(context, "document:pdf", "document", {
20
+ kind: "local",
21
+ version: 1,
22
+ operation: "render-document",
23
+ values: [
24
+ renderer,
25
+ config.title,
26
+ JSON.parse(JSON.stringify(theme)),
27
+ resourceIdentity(context, text.article),
28
+ notes === undefined ? null : resourceIdentity(context, notes),
29
+ cover === undefined ? null : resourceIdentity(context, cover),
30
+ ],
31
+ }, [
32
+ text.article.key,
33
+ ...[notes, cover].flatMap((value) => (value === undefined ? [] : [value.key])),
34
+ ]),
35
+ ];
36
+ }
@@ -1,3 +1,4 @@
1
+ import { usesYoutubeDescription } from "../admission/rules.js";
1
2
  import { audioExportArgs } from "../video/audio-export-args.js";
2
3
  import { recipe, resourceIdentity, } from "./recipe-model.js";
3
4
  export function exportRecipes(context, audio) {
@@ -15,15 +16,26 @@ export function exportRecipes(context, audio) {
15
16
  audioExportArgs([{ kind: "body", path: "$body", seconds: 0 }], "$output"),
16
17
  ],
17
18
  }, audio.keys));
18
- if (config.subtitles === undefined || config.subtitles.mode === "off")
19
+ const captions = config.subtitles !== undefined && config.subtitles.mode !== "off";
20
+ // The YouTube description's chapters use the same word timing, so it runs for them even
21
+ // with captions off; only the caption files below wait for captions.
22
+ if (!captions && !usesYoutubeDescription(config))
19
23
  return recipes;
20
24
  const timing = recipe(context, "subtitles:timing", "video", {
21
25
  kind: "local",
22
26
  version: 1,
23
27
  operation: "wav2vec2-en-a19f851-v2-omissions",
24
- values: [audio.timeline, config.silenceGapSeconds, config.subtitles.language],
28
+ // The lead-in moves every word, so the edge silence is part of the timing.
29
+ values: [
30
+ audio.timeline,
31
+ config.silenceGapSeconds,
32
+ config.subtitles?.language ?? "en",
33
+ config.edgeSilenceSeconds,
34
+ ],
25
35
  }, audio.keys);
26
36
  recipes.push(timing);
37
+ if (config.subtitles === undefined || config.subtitles.mode === "off")
38
+ return recipes;
27
39
  const cues = content.subtitleCues;
28
40
  const cueRecipe = cues === undefined
29
41
  ? recipe(context, "subtitles:cues", "video", {
@@ -17,6 +17,10 @@ export const config = {
17
17
  values: {},
18
18
  provided: { article: "First paragraph.\n\nSecond paragraph." },
19
19
  silenceGapSeconds: 0,
20
+ imageSeconds: 15,
21
+ zoomPercent: 22.5,
22
+ motionStyle: "zoom",
23
+ edgeSilenceSeconds: 0,
20
24
  rendered: { article: "Write an article." },
21
25
  };
22
26
  export const content = {
@@ -54,6 +54,11 @@ export function legacyOutputWorkKey(output, config) {
54
54
  return "export:wav";
55
55
  case "video":
56
56
  return "export:video";
57
+ case "document_pdf":
58
+ return "document:pdf";
59
+ case "youtube_description":
60
+ case "youtube_tags":
61
+ return "youtube:description";
57
62
  case "subtitles_srt":
58
63
  case "subtitles_vtt":
59
64
  case "subtitle_ass":
@@ -117,7 +122,7 @@ export function legacyImageOutput(piece, outputs) {
117
122
  row.meta.promptName === undefined ||
118
123
  row.meta.promptName === promptName)));
119
124
  if (matches.length > 1)
120
- throw new Error(`Legacy image piece ${piece.id} matches multiple outputs.`);
125
+ throw new Error(`Slopify hit an internal error (an image from an older Slopify version, ${piece.id}, matches multiple outputs). Try again; if it happens again, use Download diagnostics in Settings and report it.`);
121
126
  return matches[0];
122
127
  }
123
128
  const legacyImageMetadata = z
@@ -15,6 +15,8 @@ export const localOperations = [
15
15
  "entry-text",
16
16
  "concat-narration",
17
17
  "narration-files-v1",
18
+ "render-document",
19
+ "youtube-description-v1",
18
20
  ];
19
21
  export const deferredOperations = [
20
22
  "narration-preparation",
@@ -26,7 +26,7 @@ export function narrationFileRecipe(context, segment, parts) {
26
26
  ];
27
27
  if (part.input.kind === "provided")
28
28
  return [resourceIdentity(context, part), part.input.semantic, null];
29
- throw new Error("Narration files require exact audio parts.");
29
+ throw new Error("Slopify hit an internal error (a narration chunk has an unexpected source). Try again; if it happens again, use Download diagnostics in Settings and report it.");
30
30
  }),
31
31
  },
32
32
  }, dependencies, { unresolved: parts.length === 0 });
@@ -2,6 +2,9 @@ export function recipeProviderChoice(recipe, config) {
2
2
  const input = recipe.input;
3
3
  if (input.kind === "llm" || input.kind === "tts" || input.kind === "image")
4
4
  return { ...input, family: input.kind };
5
+ // The YouTube description's request is built when it runs, from the project's LLM row.
6
+ if (input.kind === "local" && input.operation === "youtube-description-v1")
7
+ return config.llm === undefined ? undefined : { ...config.llm, family: "llm" };
5
8
  if (input.kind !== "deferred")
6
9
  return undefined;
7
10
  const family = input.operation === "narration-preparation"
@@ -1,5 +1,6 @@
1
1
  import { stageKinds } from "../../kernel/pipeline.js";
2
- import { allowedSources, normaliseDraft, silenceGapSecondsMax, } from "../admission/rules.js";
2
+ import { sourceOf } from "../admission/model.js";
3
+ import { allowedSources, edgeSilenceSecondsProblem, imageSecondsProblem, normaliseDraft, silenceGapSecondsMax, zoomPercentProblem, } from "../admission/rules.js";
3
4
  import { render } from "../admission/substitute.js";
4
5
  import { buildRecipes } from "./recipe-build.js";
5
6
  import { selectedReference } from "./recipe-model.js";
@@ -153,10 +154,10 @@ function normalizeImages(content) {
153
154
  export function validateRevisionEdit(config, content) {
154
155
  const fields = [];
155
156
  for (const kind of stageKinds)
156
- if (!allowedSources[kind].includes(config.sources[kind]))
157
+ if (!allowedSources[kind].includes(sourceOf(config.sources, kind)))
157
158
  fields.push({
158
159
  field: `sources.${kind}`,
159
- message: `The ${kind} stage cannot be set to ${config.sources[kind]}.`,
160
+ message: `The ${kind} stage cannot be set to ${sourceOf(config.sources, kind)}.`,
160
161
  });
161
162
  if (config.sources.images === "provide" &&
162
163
  content.imageOrder.some((key) => content.imageDefinitions[key]?.source === "generate"))
@@ -187,6 +188,17 @@ export function validateRevisionEdit(config, content) {
187
188
  field: "silenceGapSeconds",
188
189
  message: `Use a gap between 0 and ${silenceGapSecondsMax} seconds.`,
189
190
  });
191
+ const imageProblem = config.sources.video === "off" ? undefined : imageSecondsProblem(config.imageSeconds);
192
+ if (imageProblem !== undefined)
193
+ fields.push({ field: "imageSeconds", message: imageProblem });
194
+ const zoomProblem = config.sources.video === "off" ? undefined : zoomPercentProblem(config.zoomPercent);
195
+ if (zoomProblem !== undefined)
196
+ fields.push({ field: "zoomPercent", message: zoomProblem });
197
+ const edgeProblem = config.sources.audio === "off"
198
+ ? undefined
199
+ : edgeSilenceSecondsProblem(config.edgeSilenceSeconds);
200
+ if (edgeProblem !== undefined)
201
+ fields.push({ field: "edgeSilenceSeconds", message: edgeProblem });
190
202
  if (config.sources.article === "generate" &&
191
203
  content.articleEdited === true &&
192
204
  !content.articleMarkdown?.trim())
@@ -1,11 +1,15 @@
1
- import { narrationPreparationFields, usesNarrationPreparation, } from "../admission/rules.js";
1
+ import { narrationPreparationFields, usesNarrationPreparation, usesYoutubeDescription, youtubeDescriptionFields, } from "../admission/rules.js";
2
2
  import { detectSlots, render } from "../admission/substitute.js";
3
3
  export function validateRecipeInputs(config, content) {
4
- const fields = [...narrationPreparationFields(config)];
4
+ const fields = [
5
+ ...narrationPreparationFields(config),
6
+ ...youtubeDescriptionFields(config),
7
+ ];
5
8
  const llm = (config.sources.research === "generate" && config.sources.article !== "provide") ||
6
9
  (config.sources.article === "generate" && !content.articleEdited) ||
7
10
  config.sources.thumbnail === "prompt_by_llm" ||
8
11
  usesNarrationPreparation(config) ||
12
+ usesYoutubeDescription(config) ||
9
13
  (config.sources.audio === "generate" &&
10
14
  (config.intro?.mode === "llm" || config.outro?.mode === "llm"));
11
15
  if (llm && (!config.llm?.provider.trim() || !config.llm.model.trim()))
@@ -43,6 +47,15 @@ export function validateRecipeInputs(config, content) {
43
47
  raw: content.promptTemplates.narration ?? null,
44
48
  literal: config.rendered.narration,
45
49
  });
50
+ // Only a picked Description prompt is checked: none picked uses the built-in one.
51
+ if (usesYoutubeDescription(config) &&
52
+ config.descriptionPrompt?.trim() &&
53
+ content.promptTemplates.description != null)
54
+ prompts.push({
55
+ field: "rendered.description",
56
+ raw: content.promptTemplates.description,
57
+ literal: config.rendered.description,
58
+ });
46
59
  if ((config.sources.article === "generate" && !content.articleEdited) ||
47
60
  (config.sources.research === "generate" && config.sources.article !== "provide"))
48
61
  prompts.push({
@@ -7,7 +7,7 @@ export function visualRecipes(config, content, audioFingerprint, captionFingerpr
7
7
  for (const key of imageKeys) {
8
8
  const image = content.imageDefinitions[key];
9
9
  if (image === undefined)
10
- throw new Error("Saved image definition is missing");
10
+ throw new Error("Slopify hit an internal error (one of the project's images has no saved settings). Try again; if it happens again, use Download diagnostics in Settings and report it.");
11
11
  const raw = image.templateKey === undefined || image.templateKey === null
12
12
  ? undefined
13
13
  : content.promptTemplates[image.templateKey];
@@ -45,7 +45,16 @@ export function visualRecipes(config, content, audioFingerprint, captionFingerpr
45
45
  recipes.map((value) => value.fingerprint),
46
46
  audioFingerprint,
47
47
  config.subtitles?.mode === "burn-in" ? captionFingerprint : null,
48
- "slideshow-zoom-v1",
48
+ // Only here: how long a still is held and how it moves change the render,
49
+ // never an image.
50
+ config.imageSeconds,
51
+ config.zoomPercent,
52
+ // Left out for "zoom", which is what every video did before the setting
53
+ // existed, so a project that keeps it keeps its render; the other styles
54
+ // each render differently.
55
+ ...(config.motionStyle === "zoom" ? [] : [config.motionStyle]),
56
+ // v1 split the timeline evenly across the images; v2 cycles them.
57
+ "slideshow-zoom-v2",
49
58
  ],
50
59
  }, [
51
60
  ...recipes.map((value) => value.key),
@@ -177,6 +177,9 @@ function retainedFor(row, logical, manifest, available) {
177
177
  pieceIds: [],
178
178
  }));
179
179
  }
180
+ // ceiling: the timed transcript of a 20-minute narration, about 3,000 words. The real one
181
+ // is only built once the word timing lands.
182
+ const youtubeTranscriptEstimate = 20000;
180
183
  export function priceRecipe(work, value) {
181
184
  if (value === undefined || work.disposition !== "generate")
182
185
  return {
@@ -214,6 +217,18 @@ export function priceRecipe(work, value) {
214
217
  };
215
218
  if (input.kind === "image")
216
219
  return { kind: "image", stage: work.key, provider: input.provider, model: input.model };
220
+ if (input.kind === "local" && input.operation === "youtube-description-v1") {
221
+ const [, provider, model, , prompt] = Array.isArray(input.values) ? input.values : [];
222
+ return {
223
+ kind: "llm",
224
+ stage: work.key,
225
+ provider: typeof provider === "string" ? provider : "",
226
+ model: typeof model === "string" ? model : "",
227
+ inputCharacters: (typeof prompt === "string" ? prompt.length : 0) + youtubeTranscriptEstimate,
228
+ outputCharacters: 2400,
229
+ detail: "The timed transcript is built when the step runs; its length is estimated. Retries are excluded.",
230
+ };
231
+ }
217
232
  return {
218
233
  kind: "tts-estimate",
219
234
  stage: work.key,
@@ -0,0 +1,33 @@
1
+ import { usesYoutubeDescription } from "../admission/rules.js";
2
+ import { recipe, resourceIdentity, } from "./recipe-model.js";
3
+ import { renderedPrompt } from "./recipe-text.js";
4
+ // The YouTube description is written from the word timing, so it waits for
5
+ // `subtitles:timing` and nothing else: it runs beside the render, and a change to the
6
+ // motion, zoom, images or captions' look leaves it current. The transcript is only known once
7
+ // the timing lands, so the request is built when the step runs (`runtime-youtube.ts`); what
8
+ // identifies it here is the timing it reads, the prompt, the model and the title.
9
+ export function youtubeRecipes(context, exports) {
10
+ const { config } = context;
11
+ const timing = exports.find((value) => value.key === "subtitles:timing");
12
+ if (!usesYoutubeDescription(config) || timing === undefined)
13
+ return [];
14
+ return [
15
+ recipe(context, "youtube:description", "video", {
16
+ kind: "local",
17
+ version: 1,
18
+ operation: "youtube-description-v1",
19
+ values: [
20
+ resourceIdentity(context, timing),
21
+ config.llm?.provider ?? "",
22
+ config.llm?.model ?? "",
23
+ config.llm?.thinking ?? null,
24
+ // Blank means the built-in prompt; the step fills it in, so a new built-in wording
25
+ // ships as a new operation version rather than a silent change here.
26
+ config.descriptionPrompt?.trim() ? renderedPrompt(context, "description") : "",
27
+ config.title,
28
+ ],
29
+ }, [timing.key],
30
+ // A paid LLM call: readiness, the charge warning and the estimate treat it like one.
31
+ { kind: "provider" }),
32
+ ];
33
+ }
@@ -16,6 +16,8 @@ export function sectionRoots(view, plan, stage) {
16
16
  ["body-narration", "intro-narration", "outro-narration"].includes(row.input.operation)));
17
17
  if (stage === "article")
18
18
  return row.key === "article:body" && row.kind === "provider";
19
+ if (stage === "document")
20
+ return row.key === "document:pdf";
19
21
  return row.kind === "provider";
20
22
  })
21
23
  .map((row) => row.key);
@@ -78,7 +78,7 @@ export async function recoverProject(deps, projectId, raw) {
78
78
  const prior = retainedPreviewPlan(deps, base, deps.catalogue.read());
79
79
  const action = input.action;
80
80
  if (action.kind !== "rerun")
81
- throw new Error("Recovery edit has no rerun action");
81
+ throw new Error("Slopify hit an internal error (a recovery edit has nothing to re-run). Try again; if it happens again, use Download diagnostics in Settings and report it.");
82
82
  const affected = dependentClosure(prior.recipes, sectionRoots(base, prior, action.stage));
83
83
  const savedIntent = deps.db
84
84
  .prepare("SELECT result_revision_id FROM revision_mutations WHERE project_id=? AND idempotency_key=?")
@@ -158,16 +158,21 @@ export async function recoverProject(deps, projectId, raw) {
158
158
  fields: blocked.map((row) => ({ field: row.key, message: row.reason })),
159
159
  });
160
160
  if (input.action.kind === "rerun" &&
161
- input.action.stage === "video" &&
161
+ (input.action.stage === "video" || input.action.stage === "document") &&
162
162
  preview.value.preview.work.some((row) => row.kind === "provider" && row.disposition !== "reuse"))
163
163
  return finish({
164
164
  ok: false,
165
165
  reason: "readiness",
166
166
  fields: [
167
- {
168
- field: "video",
169
- message: "Source media is incomplete. Use Resume to recover it before rerunning the export.",
170
- },
167
+ input.action.stage === "video"
168
+ ? {
169
+ field: "video",
170
+ message: "Source media is incomplete. Use Resume to recover it before rerunning the export.",
171
+ }
172
+ : {
173
+ field: "document",
174
+ message: "The article or thumbnail the document is made from isn't finished. Use Resume to finish it, then Re-run section on Document.",
175
+ },
171
176
  ],
172
177
  });
173
178
  const conflict = rerunKeys.length > 0 ? activeConflict(deps, projectId, rerunKeys) : undefined;
@@ -176,7 +181,7 @@ export async function recoverProject(deps, projectId, raw) {
176
181
  storePreview(deps, preview.value.preview, preview.value.execution);
177
182
  const stored = previewById(deps.db, projectId, preview.value.preview.id);
178
183
  if (stored === undefined)
179
- throw new Error("Stored recovery preview is missing.");
184
+ throw new Error("Slopify hit an internal error (the saved recovery preview is missing). Try again; if it happens again, use Download diagnostics in Settings and report it.");
180
185
  return {
181
186
  plan: { preview: stored, execution: preview.value.execution },
182
187
  view,
@@ -7,7 +7,7 @@ export { transitionRevisionWork } from "./transition-repo.js";
7
7
  export function publicationTargets(db, publication, workKey, workFingerprint) {
8
8
  const { work, pieceId, publicationId } = publication;
9
9
  if (publicationId !== (pieceId ?? work.workId))
10
- throw new Error("Publication identity does not match its durable work.");
10
+ throw new Error("Slopify hit an internal error (a finished result is filed under the wrong step). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
11
11
  if (!workExists(db, work))
12
12
  return [];
13
13
  if (pieceId !== null) {
@@ -15,9 +15,9 @@ export function publicationTargets(db, publication, workKey, workFingerprint) {
15
15
  .prepare("SELECT work_key,fingerprint FROM revision_work_pieces WHERE id=? AND work_id=?")
16
16
  .get(pieceId, work.workId);
17
17
  if (piece === undefined)
18
- throw new Error("Publication piece does not belong to its work.");
18
+ throw new Error("Slopify hit an internal error (a finished result belongs to a different step). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
19
19
  if (piece.work_key !== workKey || piece.fingerprint !== workFingerprint)
20
- throw new Error("Publication authority does not match its durable piece.");
20
+ throw new Error("Slopify hit an internal error (a finished result doesn't match its saved step). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
21
21
  }
22
22
  const currentId = currentRevisionId(db, work.projectId);
23
23
  const owns = (revisionId) => {
@@ -72,5 +72,7 @@ function keyStage(key) {
72
72
  return "thumbnail";
73
73
  if (prefix === "research")
74
74
  return "research";
75
+ if (prefix === "document")
76
+ return "document";
75
77
  return "video";
76
78
  }
@@ -18,11 +18,11 @@ export function reservationKey(recipe, view) {
18
18
  export function admitInitialRevision(deps, view, catalogue) {
19
19
  transact(deps.db, () => {
20
20
  if (currentRevisionId(deps.db, view.revision.projectId) !== view.revision.id)
21
- throw new Error("Initial admission requires the selected revision.");
21
+ throw new Error("The project was changed while this run was starting. Try again.");
22
22
  if (deps.db
23
23
  .prepare("SELECT 1 FROM revision_work WHERE project_id=? LIMIT 1")
24
24
  .get(view.revision.projectId) !== undefined)
25
- throw new Error("An existing project requires explicit rebuild admission.");
25
+ throw new Error("Slopify hit an internal error (this project was started twice). Try again; if it happens again, use Download diagnostics in Settings and report it.");
26
26
  const snapshot = executionCatalogue(catalogue, view.revision.config);
27
27
  const plan = executionPlan(deps, view, snapshot);
28
28
  for (const recipe of plan.recipes) {
@@ -32,7 +32,7 @@ export function admitInitialRevision(deps, view, catalogue) {
32
32
  const logicalKey = recipe.input.kind === "tts" ? `${recipe.input.logicalKey}:1` : recipe.key;
33
33
  const anchor = view.revision.fingerprints[logicalKey] ?? desired;
34
34
  if (anchor === undefined)
35
- throw new Error(`Initial work ${recipe.key} has no desired anchor.`);
35
+ throw new Error(`Slopify hit an internal error (step ${recipe.key} has no saved starting point). Try again; if it happens again, use Download diagnostics in Settings and report it.`);
36
36
  const reuse = plan.work.find((row) => row.key === recipe.key)?.disposition === "reuse";
37
37
  if (reuse)
38
38
  bindNarrationReuse(deps, view, recipe, narrationOrdinal(plan.recipes, recipe.key));
@@ -46,7 +46,7 @@ export function insertInvocation(deps, view, recipe, catalogue, anchor, complete
46
46
  .prepare("SELECT id FROM stages WHERE project_id=? AND kind=?")
47
47
  .get(projectId, recipe.stage);
48
48
  if (typeof stage?.id !== "string")
49
- throw new Error("Revision work has no owned stage.");
49
+ throw new Error("Slopify hit an internal error (a step has no matching stage). Try again; if it happens again, use Download diagnostics in Settings and report it.");
50
50
  const work = {
51
51
  projectId,
52
52
  revisionId,
@@ -12,14 +12,14 @@ const answerSchema = z.object({
12
12
  });
13
13
  export async function executeArticleRequests(deps, context, providers, initial) {
14
14
  if (initial.input.kind !== "llm")
15
- throw new Error("Article generation needs a pinned LLM request.");
15
+ throw new Error("Slopify hit an internal error (the article step has no AI request). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
16
16
  let piece = initial;
17
17
  let text = "";
18
18
  let inputTokens = 0;
19
19
  let outputTokens = 0;
20
20
  for (let part = 0; part <= continuationLimit; part += 1) {
21
21
  if (piece.input.kind !== "llm")
22
- throw new Error("An article continuation must contain an exact LLM request.");
22
+ throw new Error("Slopify hit an internal error (an article continuation has no AI request). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
23
23
  const saved = deps.db
24
24
  .prepare("SELECT result_json FROM revision_work_pieces WHERE id=? AND work_id=?")
25
25
  .get(piece.id, context.work.workId);
@@ -38,9 +38,9 @@ export async function executeArticleRequests(deps, context, providers, initial)
38
38
  webSearch: input.webSearch,
39
39
  previewLabel: part === 0 ? "Article" : `Continuation ${part}`,
40
40
  check: (value) => value.text.trim() === ""
41
- ? "The article answered with nothing."
41
+ ? "The AI model returned an empty article. Retry stage; if it keeps happening, choose a different model in Edit project → Providers."
42
42
  : part === continuationLimit && value.finishReason === "length"
43
- ? `The article was still unfinished after ${continuationLimit} continuations.`
43
+ ? `The article was still unfinished after ${continuationLimit} continuations because the AI model kept stopping at its length limit. Ask for a shorter article in Edit project → Prompts, or choose a model with a larger output limit in Edit project → Providers, then Retry stage.`
44
44
  : undefined,
45
45
  }, (event) => {
46
46
  if (event.type === "delta")
@@ -72,7 +72,7 @@ export async function executeArticleRequests(deps, context, providers, initial)
72
72
  return { ok: false, reason: "held" };
73
73
  piece = next;
74
74
  }
75
- throw new Error("The article exceeded its continuation limit.");
75
+ throw new Error(`The article was still unfinished after ${continuationLimit} continuations because the AI model kept stopping at its length limit. Ask for a shorter article in Edit project → Prompts, or choose a model with a larger output limit in Edit project → Providers, then Retry stage.`);
76
76
  }
77
77
  function continuationPiece(deps, context, initial, part, text) {
78
78
  return transact(deps.db, () => {
@@ -11,14 +11,14 @@ export function admitReviewedCheckpoints(deps, projectIds, reviewed, catalogue)
11
11
  const revisionId = currentRevisionId(deps.db, projectId);
12
12
  const view = revisionId === undefined ? undefined : getRevisionView(deps, projectId, revisionId);
13
13
  if (!view)
14
- throw new Error("Checkpoint admission has no retained revision");
14
+ throw new Error("Slopify hit an internal error (the project version to review is missing). Try again; if it happens again, use Download diagnostics in Settings and report it.");
15
15
  const plan = executionPlan(deps, view, executionCatalogue(catalogue, view.revision.config));
16
16
  const checkpoints = gates.map((gate) => {
17
17
  const work = deps.db
18
18
  .prepare("SELECT id FROM revision_work WHERE project_id=? AND revision_id=? AND kind=? ORDER BY id LIMIT 1")
19
19
  .get(projectId, view.revision.id, gate.stage);
20
20
  if (typeof work?.id !== "string")
21
- throw new Error("Checkpoint admission has no stage work");
21
+ throw new Error("Slopify hit an internal error (a review checkpoint has no matching stage). Try again; if it happens again, use Download diagnostics in Settings and report it.");
22
22
  return {
23
23
  checkpointId: gate.checkpointId,
24
24
  stage: gate.stage,
@@ -34,11 +34,11 @@ export function admitReviewedCheckpoints(deps, projectIds, reviewed, catalogue)
34
34
  createdAt: deps.clock.now().toISOString(),
35
35
  });
36
36
  if (!saved.ok)
37
- throw new Error("Checkpoint admission could not persist reviewed gates");
37
+ throw new Error("Slopify hit an internal error (the review checkpoints could not be saved). Try again; if it happens again, use Download diagnostics in Settings and report it.");
38
38
  return saved.value.map((row) => {
39
39
  const gate = gates.find((gate) => gate.checkpointId === row.checkpointId);
40
40
  if (!gate)
41
- throw new Error("Checkpoint admission lost its reviewed identity");
41
+ throw new Error("Slopify hit an internal error (a saved review checkpoint could not be matched). Try again; if it happens again, use Download diagnostics in Settings and report it.");
42
42
  return { ...row, reviewedFingerprint: gate.fingerprint };
43
43
  });
44
44
  });
@@ -0,0 +1,85 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { readDocumentAssets } from "../document/fonts.js";
3
+ import { documentThemeOf } from "../document/model.js";
4
+ import { renderDocument } from "../document/render.js";
5
+ import { builtInTheme } from "../document/theme.js";
6
+ import { writeAsset } from "../storage/assets.js";
7
+ import { outputPath } from "../storage/layout.js";
8
+ import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
9
+ import { preparedResult, publishResult } from "./runtime-publication.js";
10
+ // Lays the saved article out as a PDF. Local and quick: no provider, no charge.
11
+ export async function executeDocumentRecipe(deps, context, piece) {
12
+ if (deps.db
13
+ .prepare("SELECT state FROM revision_work_pieces WHERE id=? AND work_id=?")
14
+ .get(piece.id, context.work.workId)?.state === "done")
15
+ return "done";
16
+ if (!context.maySubmit(piece.id))
17
+ return "held";
18
+ context.signal.throwIfAborted();
19
+ const view = documentView(deps, context, piece);
20
+ const { projectId } = context.work;
21
+ const text = (role, workKey) => {
22
+ const row = ready(view, role, workKey);
23
+ return row === undefined
24
+ ? null
25
+ : readFileSync(outputPath(deps.paths, projectId, row.output.path), "utf8");
26
+ };
27
+ const config = view.revision.config;
28
+ const article = view.revision.content.articleEdited === true || config.sources.article === "provide"
29
+ ? view.articleMarkdown
30
+ : text("article_md", "article:body");
31
+ if (article === null)
32
+ throw new Error("The article isn't finished yet, so there's nothing to make the document from. Let the Article stage finish (Resume, or Retry stage on Article), then use Retry stage on Document.");
33
+ const thumbnail = ready(view, "thumbnail", "thumbnail:image");
34
+ let cover = null;
35
+ if (config.sources.thumbnail !== "off" && thumbnail !== undefined) {
36
+ try {
37
+ cover = new Uint8Array(readFileSync(outputPath(deps.paths, projectId, thumbnail.output.path)));
38
+ }
39
+ catch (error) {
40
+ throw new Error(`The document's cover is the project's thumbnail, and its file can't be read (${error instanceof Error ? error.message : String(error)}). Regenerate or upload the thumbnail again (Re-run section on Thumbnail, or Edit project → Thumbnail), then use Retry stage on Document.`, { cause: error });
41
+ }
42
+ }
43
+ const rendered = renderDocument({
44
+ title: config.title,
45
+ articleMarkdown: article,
46
+ researchNotes: config.sources.research === "off" ? null : text("notes", "research:notes"),
47
+ cover,
48
+ theme: builtInTheme(documentThemeOf(config.document)),
49
+ writtenOn: deps.clock.now(),
50
+ assets: readDocumentAssets(),
51
+ });
52
+ if (rendered.cover === "unsupported")
53
+ deps.log.write("warn", "document.cover", {
54
+ projectId,
55
+ stage: "document",
56
+ detail: "The thumbnail is not a PNG, JPEG or WebP image, so the document has no cover.",
57
+ });
58
+ context.signal.throwIfAborted();
59
+ if (!context.maySubmit(piece.id))
60
+ return "held";
61
+ const asset = writeAsset(deps, projectId, "document.pdf", rendered.bytes);
62
+ // publishResult discards the file itself when the publication doesn't commit.
63
+ await publishResult(deps, context, piece, [preparedResult(deps, context, piece, "document_pdf", asset)], { pages: rendered.pages, words: rendered.words, cover: rendered.cover });
64
+ deps.count?.("stage.completed", { stage: "document" });
65
+ return "done";
66
+ }
67
+ function documentView(deps, context, piece) {
68
+ const view = executionView(deps, context.work.projectId, context.work.revisionId);
69
+ const row = deps.db
70
+ .prepare("SELECT recipe_context FROM revision_work WHERE id=?")
71
+ .get(context.work.workId);
72
+ if (view === undefined || row === undefined)
73
+ throw new Error("Slopify hit an internal error (the project version the document belongs to is missing). Use Retry stage on Document; if it happens again, use Download diagnostics in Settings and report it.");
74
+ const recipe = executionPlan(deps, view, savedCatalogue(row.recipe_context)).recipes.find((one) => one.key === piece.key && one.fingerprint === piece.fingerprint);
75
+ if (recipe === undefined || recipe.unresolved)
76
+ throw new Error("The project changed after this document was queued, so it no longer matches the saved article. Use Rebuild affected outputs (or Retry stage on Document) to make it from the current version.");
77
+ return view;
78
+ }
79
+ function ready(view, role, workKey) {
80
+ return view.outputs.find((row) => row.selected &&
81
+ row.available &&
82
+ row.state === "ready" &&
83
+ row.workKey === workKey &&
84
+ row.output.role === role);
85
+ }