@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,242 @@
1
+ import { jsPDF } from "jspdf";
2
+ import { runsText } from "./blocks.js";
3
+ import { drawLine, wrapRuns } from "./flow.js";
4
+ import { faceWidth, registerFonts, useFace } from "./fonts.js";
5
+ import { contentsPages, endPage, fillContents, headsAndFeet, sourcesPage, titlePage, } from "./pages.js";
6
+ import { documentText } from "./sources.js";
7
+ import { createWriter } from "./writer.js";
8
+ export function renderDocument(input) {
9
+ const { theme } = input;
10
+ const text = documentText(input.articleMarkdown, input.researchNotes);
11
+ const blocks = withoutTitle(text.blocks, input.title);
12
+ if (blocks.length === 0)
13
+ throw new Error("The article has no text to put in the document. Write or regenerate the article (Edit project → Article), then use Retry stage on Document.");
14
+ const words = blocks
15
+ .map((block) => block.kind === "heading" ? block.text : "runs" in block ? runsText(block.runs) : "")
16
+ .join(" ")
17
+ .split(/\s+/)
18
+ .filter((word) => word !== "").length;
19
+ const doc = new jsPDF({
20
+ orientation: "portrait",
21
+ unit: "mm",
22
+ format: theme.page.format,
23
+ compress: true,
24
+ });
25
+ registerFonts(doc, input.assets.cinzel);
26
+ const fill = (template) => template.replaceAll("{title}", input.title).trim();
27
+ doc.setProperties({
28
+ title: input.title,
29
+ author: fill(theme.metadata.author),
30
+ subject: fill(theme.metadata.subject),
31
+ keywords: fill(theme.metadata.keywords),
32
+ creator: fill(theme.metadata.creator),
33
+ });
34
+ const w = createWriter(doc, theme, input.assets);
35
+ const cover = coverImage(input.cover);
36
+ titlePage(w, input.title, words, input.writtenOn, cover === "unsupported" ? null : cover);
37
+ const listed = blocks.filter((block) => block.kind === "heading" && block.level <= theme.contents.depth).length;
38
+ const extra = (theme.sources.enabled && text.sources.length > 0 ? 1 : 0) + (theme.endPage.enabled ? 1 : 0);
39
+ const reserved = contentsPages(w, listed + extra);
40
+ for (let page = 0; page < reserved; page += 1)
41
+ w.addPage();
42
+ w.addPage();
43
+ const contents = [];
44
+ writeBody(w, blocks, contents);
45
+ // The sources and closing pages line up with the article's outermost headings.
46
+ const level = Math.min(3, ...contents.map((entry) => entry.level));
47
+ for (const page of [sourcesPage(w, text.sources), endPage(w, words, input.writtenOn)])
48
+ if (page !== null)
49
+ contents.push({ ...page, level });
50
+ if (reserved > 0)
51
+ fillContents(w, 2, contents);
52
+ headsAndFeet(w, input.title);
53
+ return {
54
+ bytes: new Uint8Array(doc.output("arraybuffer")),
55
+ pages: doc.getNumberOfPages(),
56
+ words,
57
+ contents,
58
+ sources: text.sources.length,
59
+ cover: cover === null ? "none" : cover === "unsupported" ? "unsupported" : "placed",
60
+ };
61
+ }
62
+ // The title page already carries the title, so an article that opens by repeating it
63
+ // starts with its first real section instead.
64
+ function withoutTitle(blocks, title) {
65
+ const at = blocks
66
+ .slice(0, 3)
67
+ .findIndex((block) => block.kind === "heading" && block.text.trim().toLowerCase() === title.trim().toLowerCase());
68
+ return at === -1 ? blocks : blocks.filter((_, index) => index !== at);
69
+ }
70
+ function coverImage(data) {
71
+ if (data === null)
72
+ return null;
73
+ const bytes = (...values) => values.every((value, index) => data[index] === value);
74
+ if (bytes(0x89, 0x50, 0x4e, 0x47))
75
+ return { data, format: "PNG" };
76
+ if (bytes(0xff, 0xd8, 0xff))
77
+ return { data, format: "JPEG" };
78
+ if (bytes(0x52, 0x49, 0x46, 0x46) && String.fromCharCode(...data.subarray(8, 12)) === "WEBP")
79
+ return { data, format: "WEBP" };
80
+ return "unsupported";
81
+ }
82
+ function writeBody(w, blocks, contents) {
83
+ const { theme } = w;
84
+ const line = theme.spacing.bodyLine;
85
+ const style = {
86
+ size: theme.sizes.body,
87
+ body: theme.fonts.body,
88
+ strong: theme.fonts.strong,
89
+ emphasis: theme.fonts.emphasis,
90
+ color: w.colors.text,
91
+ linkColor: w.colors.heading,
92
+ };
93
+ let afterHeading = false;
94
+ for (const [index, block] of blocks.entries()) {
95
+ if (block.kind === "heading") {
96
+ // A heading keeps the start of what follows it on its page: two lines, or all the
97
+ // room a drop-capped paragraph asks for, so the heading is never left at the foot.
98
+ const next = blocks[index + 1];
99
+ const keep = next?.kind === "paragraph" && dropCapLetter(theme, next.runs) !== null
100
+ ? dropCapRoom(w)
101
+ : theme.spacing.bodyLine * 2;
102
+ const entry = writeHeading(w, block, keep);
103
+ if (block.level <= theme.contents.depth)
104
+ contents.push(entry);
105
+ afterHeading = true;
106
+ continue;
107
+ }
108
+ if (block.kind === "rule") {
109
+ w.ensure(line);
110
+ const rgb = w.colors.muted;
111
+ w.doc.setDrawColor(rgb.r, rgb.g, rgb.b);
112
+ w.doc.setLineWidth(0.3);
113
+ const half = theme.spacing.ruleWidth / 2;
114
+ w.doc.line(w.width / 2 - half, w.y - line / 3, w.width / 2 + half, w.y - line / 3);
115
+ w.y += line;
116
+ continue;
117
+ }
118
+ if (block.kind === "paragraph") {
119
+ const cap = afterHeading ? dropCapLetter(theme, block.runs) : null;
120
+ if (cap === null)
121
+ writeLines(w, block.runs, style, 0, w.contentWidth);
122
+ else
123
+ writeDropCap(w, block.runs, style, cap);
124
+ w.y += line * theme.spacing.paragraphGap;
125
+ }
126
+ else if (block.kind === "item") {
127
+ const indent = block.depth * theme.spacing.listIndent;
128
+ w.ensure(line);
129
+ if (block.marker !== "")
130
+ w.write(block.marker, w.left + indent, w.y, theme.fonts.body, theme.sizes.body, "muted");
131
+ const text = indent + theme.spacing.listIndent;
132
+ writeLines(w, block.runs, style, text, w.contentWidth - text);
133
+ w.y += line * theme.spacing.itemGap;
134
+ }
135
+ else {
136
+ const indent = theme.spacing.quoteIndent;
137
+ const quote = { ...style, color: w.colors.muted };
138
+ const start = { page: w.page(), y: w.y };
139
+ writeLines(w, block.runs, quote, indent, w.contentWidth - indent * 2);
140
+ if (w.page() === start.page) {
141
+ const rgb = w.colors.heading;
142
+ w.doc.setDrawColor(rgb.r, rgb.g, rgb.b);
143
+ w.doc.setLineWidth(0.6);
144
+ const x = w.left + indent / 2;
145
+ w.doc.line(x, start.y - line * 0.6, x, w.y - line * 0.8);
146
+ }
147
+ w.y += line * theme.spacing.paragraphGap;
148
+ }
149
+ afterHeading = false;
150
+ }
151
+ }
152
+ function writeLines(w, runs, style, indent, width) {
153
+ const lines = wrapRuns(w.doc, runs, style, () => ({ indent, width }));
154
+ for (const one of lines) {
155
+ w.ensure(w.theme.spacing.bodyLine);
156
+ drawLine(w.doc, one, w.left, w.y, style.size);
157
+ w.y += w.theme.spacing.bodyLine;
158
+ }
159
+ }
160
+ const chapterHeading = /^(Chapter|Section|Part)\s+(\d+):?\s*([\s\S]*)$/i;
161
+ // Level 1 and 2 are centred in the heading face at the section and heading sizes; a
162
+ // "Chapter 3: Title – Subtitle" heading shows its number large with each part of the title
163
+ // under it. Level 3 sits on the left, smaller.
164
+ function writeHeading(w, block, keep) {
165
+ const { theme, doc } = w;
166
+ const { fonts, sizes, spacing } = theme;
167
+ if (w.y > theme.page.contentTop)
168
+ w.y += spacing.bodyLine * spacing.headingGap;
169
+ const sub = block.level === 3;
170
+ const size = block.level === 1 ? sizes.section : sub ? sizes.subheading : sizes.heading;
171
+ const lineHeight = sub ? spacing.subheadingLine : spacing.headingLine;
172
+ const chapter = sub ? null : chapterHeading.exec(block.text);
173
+ const parts = chapter === null
174
+ ? [block.text]
175
+ : (chapter[3] ?? "")
176
+ .split(/\s+[–—]\s+/)
177
+ .map((part) => part.trim())
178
+ .filter((part) => part !== "");
179
+ useFace(doc, fonts.heading, size);
180
+ const lines = parts.flatMap((part) => doc.splitTextToSize(part, w.contentWidth - (chapter === null ? 0 : 20)));
181
+ // The heading and the first lines under it stay on one page.
182
+ const height = lines.length * lineHeight + (chapter === null ? 0 : lineHeight * 1.5);
183
+ w.ensure(height + spacing.bodyLine * spacing.paragraphGap + keep);
184
+ const entry = { title: block.text, level: block.level, page: w.page() };
185
+ if (chapter !== null) {
186
+ w.write(chapter[2] ?? "", w.width / 2, w.y, fonts.dramatic, sizes.section, "heading", "center");
187
+ w.y += lineHeight * 1.5;
188
+ }
189
+ for (const text of lines) {
190
+ if (sub)
191
+ w.write(text, w.left, w.y, fonts.heading, size, "heading");
192
+ else
193
+ w.write(text, w.width / 2, w.y, fonts.heading, size, "heading", "center");
194
+ w.y += lineHeight;
195
+ }
196
+ w.y += spacing.bodyLine * spacing.paragraphGap;
197
+ return entry;
198
+ }
199
+ function dropCapLetter(theme, runs) {
200
+ const first = runs[0];
201
+ if (!theme.dropCap.enabled || first === undefined || first.href !== null)
202
+ return null;
203
+ if (runsText(runs).length < theme.dropCap.minLength)
204
+ return null;
205
+ const letter = [...first.text][0] ?? "";
206
+ return /\p{L}/u.test(letter) ? letter : null;
207
+ }
208
+ // The first letter set large across `lines` body lines, the text beside it narrowed, then
209
+ // full width below it.
210
+ function writeDropCap(w, runs, style, letter) {
211
+ const { theme, doc } = w;
212
+ const { dropCap } = theme;
213
+ const line = theme.spacing.bodyLine;
214
+ w.ensure(dropCapRoom(w));
215
+ const size = line * dropCap.lines * dropCap.scale;
216
+ const capital = letter.toUpperCase();
217
+ const capWidth = faceWidth(doc, theme.fonts.dropCap, size, capital) + dropCap.gap;
218
+ w.write(capital, w.left, w.y + line * (dropCap.lines - 1), theme.fonts.dropCap, size, "heading");
219
+ const [first, ...rest] = runs;
220
+ const remaining = first === undefined ? [] : [{ ...first, text: [...first.text].slice(1).join("") }, ...rest];
221
+ const top = w.y;
222
+ const lines = wrapRuns(doc, remaining, style, (index) => index < dropCap.lines
223
+ ? { indent: capWidth, width: w.contentWidth - capWidth }
224
+ : { indent: 0, width: w.contentWidth });
225
+ for (const [index, one] of lines.entries()) {
226
+ if (index >= dropCap.lines)
227
+ w.ensure(line);
228
+ drawLine(doc, one, w.left, w.y, style.size);
229
+ w.y += line;
230
+ }
231
+ // Text shorter than the initial still leaves its full height free.
232
+ w.y = Math.max(w.y, top + line * dropCap.lines);
233
+ }
234
+ // The room a drop-capped paragraph needs left on the page: the theme's share of it, and at
235
+ // least the initial itself, which reaches about `lines * scale` body lines below its first
236
+ // baseline.
237
+ function dropCapRoom(w) {
238
+ const { theme } = w;
239
+ const { dropCap } = theme;
240
+ const usable = w.height - theme.page.margin - theme.header.top - theme.footer.bottom - theme.footer.reserve;
241
+ return Math.max(usable * dropCap.minRoom, theme.spacing.bodyLine * dropCap.lines * dropCap.scale);
242
+ }
@@ -0,0 +1,35 @@
1
+ import { splitEndMatter } from "../article/split.js";
2
+ import { markdownBlocks, markdownLinks, runsText } from "./blocks.js";
3
+ // The pronunciation glossary is narration's business and is left out; the sources section
4
+ // becomes the Sources page instead of being printed as body text.
5
+ export function documentText(articleMarkdown, researchNotes) {
6
+ const parts = splitEndMatter(articleMarkdown);
7
+ const listed = sourceItems(parts.sources);
8
+ const seen = new Set(listed.flatMap((item) => (item.href === null ? [] : [item.href])));
9
+ const cited = [];
10
+ for (const link of researchNotes === null ? [] : markdownLinks(researchNotes)) {
11
+ if (seen.has(link.href) || !/^https?:\/\//i.test(link.href))
12
+ continue;
13
+ seen.add(link.href);
14
+ cited.push(link.text === link.href ? { text: link.href, href: link.href } : link);
15
+ }
16
+ return { blocks: markdownBlocks(parts.body), sources: [...listed, ...cited] };
17
+ }
18
+ function sourceItems(section) {
19
+ if (section.trim() === "")
20
+ return [];
21
+ // The section's own heading, whether a `#` line or a bold paragraph, is its first block.
22
+ return markdownBlocks(section)
23
+ .slice(1)
24
+ .flatMap((block) => {
25
+ if (block.kind !== "item" && block.kind !== "paragraph" && block.kind !== "quote")
26
+ return [];
27
+ const text = runsText(block.runs).trim();
28
+ if (text === "")
29
+ return [];
30
+ const href = block.runs.find((run) => run.href !== null)?.href ??
31
+ /https?:\/\/[^\s)>\]]+/.exec(text)?.[0] ??
32
+ null;
33
+ return [{ text, href }];
34
+ });
35
+ }
@@ -0,0 +1,158 @@
1
+ import { defaultDocumentTheme } from "./model.js";
2
+ // "Cinzel" is bundled (assets/document/fonts) in the styles below; the other three are the
3
+ // PDF standard fonts every reader has.
4
+ export const fontFamilies = ["Cinzel", "times", "helvetica", "courier"];
5
+ export const fontStyles = ["normal", "medium", "bold", "black", "italic", "bolditalic"];
6
+ const cinzel = (style, letterSpacing) => ({
7
+ family: "Cinzel",
8
+ style,
9
+ letterSpacing,
10
+ });
11
+ const dicemaster = {
12
+ page: { format: "a4", margin: 22, contentTop: 32 },
13
+ background: { image: "parchment", color: "#f5ebd7" },
14
+ colors: { heading: "#8c1e14", text: "#000000", muted: "#50463c", faint: "#786e5f" },
15
+ fonts: {
16
+ body: cinzel("normal", 0.01),
17
+ strong: cinzel("bold", 0.02),
18
+ // Cinzel has no italic, so emphasis borrows the standard serif's.
19
+ emphasis: { family: "times", style: "italic", letterSpacing: 0 },
20
+ heading: cinzel("bold", 0.02),
21
+ dramatic: cinzel("black", 0.03),
22
+ decorative: cinzel("medium", 0.015),
23
+ dropCap: cinzel("black", 0),
24
+ footer: { family: "times", style: "normal", letterSpacing: 0 },
25
+ },
26
+ // lore2script2 scaled its Cinzel sizes and line heights (by 1.1, 1.05 and 1.2) and
27
+ // rounded; these are the results.
28
+ sizes: {
29
+ brand: 31,
30
+ title: 22,
31
+ section: 19,
32
+ heading: 17,
33
+ subheading: 13,
34
+ body: 10.5,
35
+ footer: 9,
36
+ meta: 10,
37
+ },
38
+ spacing: {
39
+ bodyLine: 8,
40
+ headingLine: 11,
41
+ subheadingLine: 9,
42
+ paragraphGap: 0.5,
43
+ headingGap: 1,
44
+ itemGap: 0.2,
45
+ listIndent: 6,
46
+ quoteIndent: 8,
47
+ ruleWidth: 30,
48
+ },
49
+ dropCap: { enabled: true, lines: 3, scale: 3, gap: 4, minLength: 50, minRoom: 0.25 },
50
+ titlePage: {
51
+ brandY: 60,
52
+ taglineOffset: 10,
53
+ titleY: 80,
54
+ titleLine: 10,
55
+ metaOffset: 20,
56
+ metaLine: 10,
57
+ showDate: true,
58
+ showWordCount: true,
59
+ cover: { enabled: true, gap: 6, maxHeight: 110 },
60
+ },
61
+ contents: {
62
+ enabled: true,
63
+ title: "Table of Contents",
64
+ depth: 2,
65
+ titleOffset: 20,
66
+ firstEntryOffset: 40,
67
+ line: 8,
68
+ indent: 5,
69
+ },
70
+ header: { enabled: true, top: 15, maxTitleCharacters: 35 },
71
+ footer: { enabled: true, bottom: 10, reserve: 15, text: "Page {page}" },
72
+ brand: {
73
+ name: "DiceMaster.io",
74
+ url: "https://dicemaster.io/",
75
+ tagline: "Let us Handle the Crunch, You Focus on the Story",
76
+ linkLabel: "Visit DiceMaster.io",
77
+ },
78
+ metadata: {
79
+ author: "Created with DiceMaster.io",
80
+ subject: "{title} - Powered by DiceMaster.io",
81
+ keywords: "D&D, DnD, Dungeons and Dragons, lore, script, tabletop, RPG, TTRPG, DiceMaster.io",
82
+ creator: "DiceMaster.io - The AI-Powered Virtual Tabletop",
83
+ },
84
+ sources: {
85
+ enabled: true,
86
+ title: "Sources Consulted",
87
+ titleOffset: 20,
88
+ bodyOffset: 40,
89
+ line: 8,
90
+ gap: 2,
91
+ },
92
+ endPage: {
93
+ enabled: true,
94
+ title: "About DiceMaster.io",
95
+ titleOffset: 20,
96
+ bodyOffset: 40,
97
+ lines: [
98
+ "About DiceMaster.io:",
99
+ "• Character Engine - Form-driven creation with automatic leveling",
100
+ "• Automated Rules Engine - D&D mechanics and spells run automatically",
101
+ "• AI-Generated NPCs - Instant personalities with full stat blocks",
102
+ "• Granular Inventory Tracking - Live gear & condition management",
103
+ "• Real-Time Rule Guidance - Context-aware clarifications",
104
+ "• Dynamic Storytelling AI - On-the-fly narration & dialogue",
105
+ "• True-Physics Dice - RNG-driven rolls with animated suspense",
106
+ "• Cinematic Combat Module - Positional tracking & narrative summaries",
107
+ "• Voice Commands - Seamless STT/TTS multimodal interaction",
108
+ "• AI Dungeon Master Layer - Rules adjudication & encounter balancing",
109
+ ],
110
+ showDocumentDetails: true,
111
+ link: { text: "Visit DiceMaster.io", url: "https://dicemaster.io/" },
112
+ closing: "Experience the future of tabletop RPGs!",
113
+ },
114
+ };
115
+ const plain = {
116
+ background: { image: null, color: "#fdfaf3" },
117
+ colors: { heading: "#1f3a5f", text: "#1a1a1a", muted: "#555555", faint: "#888888" },
118
+ brand: { name: null, url: null, tagline: null, linkLabel: null },
119
+ metadata: { author: "", subject: "{title}", keywords: "D&D, lore", creator: "" },
120
+ endPage: { enabled: false },
121
+ };
122
+ export function builtInTheme(name) {
123
+ return name === "plain" ? resolveTheme(plain) : resolveTheme();
124
+ }
125
+ export function defaultTheme() {
126
+ return builtInTheme(defaultDocumentTheme);
127
+ }
128
+ // Unknown sections and keys throw, so a typo in a saved theme can't pass silently.
129
+ export function resolveTheme(overrides = {}, base = dicemaster) {
130
+ const theme = structuredClone(base);
131
+ for (const [section, values] of Object.entries(overrides)) {
132
+ const target = theme[section];
133
+ if (target === undefined)
134
+ throw new Error(`Unknown document theme section "${section}" (expected one of: ${Object.keys(theme).join(", ")})`);
135
+ for (const [key, value] of Object.entries(values ?? {})) {
136
+ if (!(key in target))
137
+ throw new Error(`Unknown document theme setting "${section}.${key}" (expected one of: ${Object.keys(target).join(", ")})`);
138
+ target[key] = value;
139
+ }
140
+ }
141
+ const resolved = theme;
142
+ for (const [key, value] of Object.entries(resolved.colors))
143
+ parseHexColor(value, `colors.${key}`);
144
+ parseHexColor(resolved.background.color, "background.color");
145
+ return resolved;
146
+ }
147
+ export function parseHexColor(hex, label = "color") {
148
+ const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
149
+ const digits = match?.[1];
150
+ if (digits === undefined)
151
+ throw new Error(`Invalid document theme ${label} "${hex}": expected a hex color like "#8c1e14"`);
152
+ const full = digits.length === 3 ? [...digits].map((digit) => digit + digit).join("") : digits;
153
+ return {
154
+ r: Number.parseInt(full.slice(0, 2), 16),
155
+ g: Number.parseInt(full.slice(2, 4), 16),
156
+ b: Number.parseInt(full.slice(4, 6), 16),
157
+ };
158
+ }
@@ -0,0 +1,66 @@
1
+ import { useFace } from "./fonts.js";
2
+ import { parseHexColor } from "./theme.js";
3
+ export function createWriter(doc, theme, assets) {
4
+ const width = doc.internal.pageSize.getWidth();
5
+ const height = doc.internal.pageSize.getHeight();
6
+ const colors = {
7
+ heading: parseHexColor(theme.colors.heading),
8
+ text: parseHexColor(theme.colors.text),
9
+ muted: parseHexColor(theme.colors.muted),
10
+ faint: parseHexColor(theme.colors.faint),
11
+ };
12
+ const writer = {
13
+ doc,
14
+ theme,
15
+ colors,
16
+ width,
17
+ height,
18
+ left: theme.page.margin,
19
+ contentWidth: width - theme.page.margin * 2,
20
+ bottom: height - theme.footer.bottom - theme.footer.reserve,
21
+ y: theme.page.contentTop,
22
+ addPage() {
23
+ doc.addPage();
24
+ paintBackground(doc, theme, assets, width, height);
25
+ writer.y = theme.page.contentTop;
26
+ },
27
+ ensure(needed) {
28
+ if (writer.y + needed > writer.bottom)
29
+ writer.addPage();
30
+ },
31
+ page() {
32
+ return doc.getCurrentPageInfo().pageNumber;
33
+ },
34
+ write(text, x, y, face, size, color, align = "left") {
35
+ useFace(doc, face, size);
36
+ const rgb = colors[color];
37
+ doc.setTextColor(rgb.r, rgb.g, rgb.b);
38
+ doc.text(text, x, y, align === "left" ? {} : { align });
39
+ doc.setCharSpace(0);
40
+ },
41
+ };
42
+ paintBackground(doc, theme, assets, width, height);
43
+ return writer;
44
+ }
45
+ // The texture when the theme has one, otherwise the flat colour with slightly darker edges.
46
+ function paintBackground(doc, theme, assets, width, height) {
47
+ if (theme.background.image === "parchment") {
48
+ // The alias embeds the image once and points every page at it.
49
+ doc.addImage(assets.parchment, "JPEG", 0, 0, width, height, "parchment");
50
+ return;
51
+ }
52
+ const base = parseHexColor(theme.background.color);
53
+ doc.setFillColor(base.r, base.g, base.b);
54
+ doc.rect(0, 0, width, height, "F");
55
+ const steps = 4;
56
+ const thickness = 2;
57
+ for (let step = 0; step < steps; step += 1) {
58
+ const inset = step * 2;
59
+ const shade = (channel) => Math.max(0, Math.round(channel - 15 * (1 - step / steps)));
60
+ doc.setFillColor(shade(base.r), shade(base.g), shade(base.b));
61
+ doc.rect(inset, inset, width - 2 * inset, thickness, "F");
62
+ doc.rect(inset, height - inset - thickness, width - 2 * inset, thickness, "F");
63
+ doc.rect(inset, inset, thickness, height - 2 * inset, "F");
64
+ doc.rect(width - inset - thickness, inset, thickness, height - 2 * inset, "F");
65
+ }
66
+ }
@@ -1,10 +1,12 @@
1
1
  import { z } from "zod";
2
- import { usesNarrationPreparation } from "../admission/rules.js";
2
+ import { sourceOf } from "../admission/model.js";
3
+ import { usesNarrationPreparation, usesYoutubeDescription } from "../admission/rules.js";
3
4
  import { plainText } from "../article/plain.js";
4
5
  import { splitEndMatter } from "../article/split.js";
5
6
  import { chunkNarration, defaultChunking } from "../narration/chunk.js";
6
7
  import { normalizeNarrationText } from "../narration/plan.js";
7
8
  import { preparationMessages } from "../narration/preparation.js";
9
+ import { defaultDescriptionPrompt } from "../youtube/model.js";
8
10
  import { estimateRequests, groupEstimateRows } from "./requests.js";
9
11
  export { estimateRequests } from "./requests.js";
10
12
  export function estimateRun(draft, rendered, expectedWords, catalogue) {
@@ -32,7 +34,7 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
32
34
  !model.deprecated)?.pricing.note ?? "Model or account pricing is unknown.";
33
35
  const generatedArticle = draft.sources.article === "generate";
34
36
  const articleChars = generatedArticle ? expectedWords * 6 : (draft.provided.article?.length ?? 0);
35
- const promptChars = Object.entries(rendered).reduce((sum, [key, value]) => sum + (key === "narration" ? 0 : value.length), 0);
37
+ const promptChars = Object.entries(rendered).reduce((sum, [key, value]) => sum + (key === "narration" || key === "description" ? 0 : value.length), 0);
36
38
  const local = (stage, detail) => {
37
39
  requests.push({ kind: "local", stage, detail });
38
40
  };
@@ -122,6 +124,16 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
122
124
  if (draft.sources.thumbnail === "prompt_by_llm")
123
125
  text("Thumbnail prompt", promptChars + articleChars, 1200);
124
126
  local("Export / subtitles", "Local processing; no API fee.");
127
+ // The timed transcript is the narration with a time before every passage of about 20 s,
128
+ // plus the fixed rules of the answer.
129
+ if (usesYoutubeDescription(draft))
130
+ text("YouTube description", (rendered.description?.trim()
131
+ ? rendered.description.length
132
+ : defaultDescriptionPrompt.length) +
133
+ Math.round(articleChars * 1.05) +
134
+ 1500, 2400, "One LLM call on the timed transcript.");
135
+ if (sourceOf(draft.sources, "document") === "generate")
136
+ local("Document", "Laid out locally from the article; no API fee.");
125
137
  return {
126
138
  ...groupEstimateRows(estimateRequests(requests, data)),
127
139
  expectedWords,
@@ -31,7 +31,7 @@ export async function resolveFont(paths, id) {
31
31
  return font;
32
32
  }
33
33
  export function missingFont() {
34
- return new Error("The selected font is no longer available. Choose another font.", {
34
+ return new Error("The chosen caption font is no longer available; it may have been uninstalled or deleted. Choose another font in Edit project → Subtitles, then try again.", {
35
35
  cause: "font-not-found",
36
36
  });
37
37
  }
@@ -27,7 +27,7 @@ export async function uploadFont(paths, input) {
27
27
  const root = join(paths.dataDir, "fonts");
28
28
  await mkdir(root, { recursive: true, mode: 0o700 });
29
29
  if (!(await lstat(root)).isDirectory())
30
- throw new Error("The font storage directory is unavailable.");
30
+ throw new Error(`Slopify can't save uploaded fonts because ${root} is not a folder. Move or rename that file, then upload the font again.`);
31
31
  const temporary = join(root, `.upload-${randomUUID()}`);
32
32
  try {
33
33
  await writeFile(temporary, input.content, { mode: 0o600, flag: "wx" });
@@ -22,19 +22,19 @@ export async function runImages(deps, context, providers) {
22
22
  const { projectId } = context.stage;
23
23
  const project = projectById(deps.db, projectId);
24
24
  if (project === undefined) {
25
- throw new Error(`project ${projectId} has no row`);
25
+ throw new Error("This project no longer exists; it may have been deleted while it was running.");
26
26
  }
27
27
  const choice = project.config.images;
28
28
  if (choice === undefined) {
29
29
  // Admission refuses a run whose images are Generate without a provider and a model, so
30
30
  // reaching here is a bug in admission rather than the user's.
31
- throw new Error("the run has no image provider or model");
31
+ throw new Error("No image model is set for this project. Choose one in Edit project → Providers, then Retry stage.");
32
32
  }
33
33
  const pieces = plan(deps, context, project.config);
34
34
  if (pieces.length === 0) {
35
35
  // Admission makes an image source mandatory and puts the Number at one or more, so an
36
36
  // empty plan is a bug upstream rather than a run with no pictures.
37
- throw new Error("the run ticked no image prompt");
37
+ throw new Error("Slopify hit an internal error (no image prompt was selected). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
38
38
  }
39
39
  // The run's own frame. The adapter turns it into whatever its provider spells the
40
40
  // closest supported size, and the render crops whatever is left over.
@@ -78,7 +78,7 @@ function plan(deps, context, config) {
78
78
  // it, so the run carries the substituted text under this key.
79
79
  const prompt = config.rendered[`imagePrompts.${String(at)}`];
80
80
  if (prompt === undefined) {
81
- throw new Error(`the run has no rendered text for the image prompt ${picked.name}`);
81
+ throw new Error(`Slopify hit an internal error (the image prompt ${picked.name} was never filled in). Retry stage; if it happens again, use Download diagnostics in Settings and report it.`);
82
82
  }
83
83
  for (let send = 1; send <= picked.number; send += 1) {
84
84
  planned.push({
@@ -224,7 +224,7 @@ function report(deps, context, done, total) {
224
224
  }
225
225
  function payloadOf(piece) {
226
226
  if (piece.payload === null) {
227
- throw new Error(`image ${piece.id} has no payload`);
227
+ throw new Error("Slopify hit an internal error (a saved image step is empty). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
228
228
  }
229
229
  return imagePayload.parse(JSON.parse(piece.payload));
230
230
  }
@@ -13,17 +13,23 @@ function lint(name, body) {
13
13
  const fields = [];
14
14
  const trimmed = name.trim();
15
15
  if (trimmed === "") {
16
- fields.push({ field: "name", message: "A name is required." });
16
+ fields.push({ field: "name", message: "Enter a name." });
17
17
  }
18
18
  else if (trimmed.length > nameMax) {
19
- fields.push({ field: "name", message: `A name is at most ${String(nameMax)} characters.` });
19
+ fields.push({
20
+ field: "name",
21
+ message: `Keep the name to ${String(nameMax)} characters or fewer.`,
22
+ });
20
23
  }
21
24
  if (body.trim() === "") {
22
- fields.push({ field: "body", message: "A body is required." });
25
+ fields.push({ field: "body", message: "Enter the text." });
23
26
  return fields;
24
27
  }
25
28
  if (body.length > bodyMax) {
26
- fields.push({ field: "body", message: `A body is at most ${String(bodyMax)} characters.` });
29
+ fields.push({
30
+ field: "body",
31
+ message: `The text is too long. Keep it to ${String(bodyMax)} characters or fewer.`,
32
+ });
27
33
  return fields;
28
34
  }
29
35
  for (const error of detectSlots(body).errors) {
@@ -37,11 +43,11 @@ function describe(error, body) {
37
43
  const where = positionOf(body, error.at);
38
44
  switch (error.kind) {
39
45
  case "unclosed":
40
- return `The \`{{\` at ${where} is never closed.`;
46
+ return `The \`{{\` at ${where} is never closed. Add \`}}\` after the keyword name.`;
41
47
  case "empty":
42
- return `The slot at ${where} has no name.`;
48
+ return `The keyword at ${where} has no name. Write a name between \`{{\` and \`}}\`.`;
43
49
  case "nested":
44
- return `The slot at ${where} holds a brace; slots do not nest.`;
50
+ return `The keyword at ${where} contains a brace. Keywords cannot be placed inside other keywords.`;
45
51
  }
46
52
  }
47
53
  function positionOf(body, at) {
@@ -2,7 +2,7 @@
2
2
  // set covers both, which is why the drafts, the lint and the save path below are shared
3
3
  // rather than mirrored.
4
4
  export { entryModes } from "../admission/model.js";
5
- export const promptKinds = ["article", "image", "thumbnail", "narration"];
5
+ export const promptKinds = ["article", "image", "thumbnail", "narration", "description"];
6
6
  export const entryCategories = ["intro", "outro"];
7
7
  // The name is what the Play pickers show, so it shares admission's title bound.
8
8
  export const nameMax = 200;