@gentbajko/slopify 2.5.0 → 3.0.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 (446) hide show
  1. package/LICENSE +201 -21
  2. package/NOTICE +12 -0
  3. package/README.md +21 -40
  4. package/SUBTITLES.md +1 -1
  5. package/dist/adapter-registry.js +3 -0
  6. package/dist/adapters/alignment/cache.js +4 -1
  7. package/dist/adapters/alignment/ctc.js +25 -23
  8. package/dist/adapters/alignment/index.js +37 -6
  9. package/dist/adapters/alignment/multilingual.js +267 -0
  10. package/dist/adapters/alignment/protocol.js +11 -0
  11. package/dist/adapters/alignment/quality.js +4 -3
  12. package/dist/adapters/alignment/sentences.js +95 -0
  13. package/dist/adapters/alignment/spec.js +24 -0
  14. package/dist/adapters/alignment/spell.js +769 -0
  15. package/dist/adapters/alignment/text.js +56 -9
  16. package/dist/adapters/alignment/window.js +34 -30
  17. package/dist/adapters/alignment/worker.js +23 -21
  18. package/dist/adapters/explain.js +11 -11
  19. package/dist/adapters/host-cli/index.js +44 -5
  20. package/dist/adapters/host-cli/transport.js +7 -5
  21. package/dist/adapters/image/bytes.js +2 -2
  22. package/dist/adapters/image/codex-output.js +1 -1
  23. package/dist/adapters/image/codex.js +87 -22
  24. package/dist/adapters/image/fal.js +13 -5
  25. package/dist/adapters/image/google.js +11 -8
  26. package/dist/adapters/image/openai.js +14 -7
  27. package/dist/adapters/image/reference.js +34 -0
  28. package/dist/adapters/image/replicate.js +7 -2
  29. package/dist/adapters/image/video.js +2 -2
  30. package/dist/adapters/key-probes.js +36 -0
  31. package/dist/adapters/llm/claude-code.js +168 -32
  32. package/dist/adapters/llm/cli-login-error.js +1 -1
  33. package/dist/adapters/llm/codex-limits.js +157 -0
  34. package/dist/adapters/llm/codex.js +99 -12
  35. package/dist/adapters/llm/document-workspace.js +2 -2
  36. package/dist/adapters/llm/gemini-workspace.js +1 -1
  37. package/dist/adapters/llm/gemini.js +16 -3
  38. package/dist/adapters/llm/image-workspace.js +58 -0
  39. package/dist/adapters/llm/openrouter.js +11 -3
  40. package/dist/adapters/llm/run-cli.js +12 -7
  41. package/dist/adapters/tts/cartesia.js +10 -0
  42. package/dist/adapters/tts/elevenlabs.js +31 -9
  43. package/dist/adapters/tts/inworld-async.js +9 -5
  44. package/dist/adapters/tts/inworld.js +17 -3
  45. package/dist/adapters/tts/openai.js +3 -0
  46. package/dist/adapters/tts/voice-languages.js +80 -0
  47. package/dist/assets/fonts/noto/NotoSans-Regular.ttf +0 -0
  48. package/dist/assets/fonts/noto/NotoSansArabic-Regular.ttf +0 -0
  49. package/dist/assets/fonts/noto/NotoSansDevanagari-Regular.ttf +0 -0
  50. package/dist/assets/fonts/noto/NotoSansHebrew-Regular.ttf +0 -0
  51. package/dist/assets/fonts/noto/NotoSansThai-Regular.ttf +0 -0
  52. package/dist/assets/fonts/noto/OFL.txt +93 -0
  53. package/dist/assets/fonts/noto/SOURCE.txt +18 -0
  54. package/dist/assets/sample/sample-audiobook.tar +0 -0
  55. package/dist/assets/sample/sample-podcast.tar +0 -0
  56. package/dist/assets/sample/sample-project.tar +0 -0
  57. package/dist/catalog/merge.js +132 -0
  58. package/dist/catalog/registry.js +13 -13
  59. package/dist/catalog/schema.js +2 -0
  60. package/dist/catalog/store.js +147 -8
  61. package/dist/compose.yaml +71 -0
  62. package/dist/edge/autostart/docker-record.js +32 -0
  63. package/dist/edge/autostart/docker.js +50 -0
  64. package/dist/edge/autostart/index.js +78 -0
  65. package/dist/edge/autostart/launcher.js +176 -0
  66. package/dist/edge/autostart/model.js +3 -0
  67. package/dist/edge/autostart/native.js +210 -0
  68. package/dist/edge/autostart/prompt.js +66 -0
  69. package/dist/edge/autostart/service.js +81 -0
  70. package/dist/edge/cli.js +57 -51
  71. package/dist/edge/docker-install/activation.js +56 -0
  72. package/dist/edge/docker-install/apply.js +458 -0
  73. package/dist/edge/docker-install/engine.js +362 -0
  74. package/dist/edge/docker-install/run.js +154 -0
  75. package/dist/edge/docker-install/state.js +146 -0
  76. package/dist/edge/{docker-projects → docker-install}/volume.js +1 -5
  77. package/dist/edge/docker.js +14 -22
  78. package/dist/edge/host-cli.js +1 -1
  79. package/dist/edge/http/actions.js +32 -10
  80. package/dist/edge/http/app.js +58 -2
  81. package/dist/edge/http/auditions.js +70 -0
  82. package/dist/edge/http/autostart.js +37 -0
  83. package/dist/edge/http/channel-memory.js +92 -0
  84. package/dist/edge/http/channels.js +128 -0
  85. package/dist/edge/http/entries.js +22 -3
  86. package/dist/edge/http/files.js +1 -1
  87. package/dist/edge/http/folder-location.js +1 -1
  88. package/dist/edge/http/home.js +39 -0
  89. package/dist/edge/http/host-cli.js +29 -5
  90. package/dist/edge/http/narration-peaks.js +76 -0
  91. package/dist/edge/http/onboarding.js +152 -0
  92. package/dist/edge/http/open-folder.js +1 -1
  93. package/dist/edge/http/patch-notes.js +45 -0
  94. package/dist/edge/http/planning.js +4 -2
  95. package/dist/edge/http/project-create.js +87 -0
  96. package/dist/edge/http/projects.js +31 -99
  97. package/dist/edge/http/prompts.js +40 -3
  98. package/dist/edge/http/pronunciations.js +23 -4
  99. package/dist/edge/http/providers.js +86 -0
  100. package/dist/edge/http/reviews.js +121 -0
  101. package/dist/edge/http/revision-files.js +1 -1
  102. package/dist/edge/http/revisions.js +1 -1
  103. package/dist/edge/http/run-cost.js +25 -0
  104. package/dist/edge/http/schedules.js +111 -6
  105. package/dist/edge/http/settings.js +84 -6
  106. package/dist/edge/http/storage-files.js +50 -0
  107. package/dist/edge/http/storage.js +29 -2
  108. package/dist/edge/http/studio.js +207 -0
  109. package/dist/edge/http/style-preview.js +100 -0
  110. package/dist/edge/http/subtitles.js +1 -1
  111. package/dist/edge/http/trash.js +69 -0
  112. package/dist/edge/http/update.js +24 -3
  113. package/dist/edge/http/whats-new.js +15 -0
  114. package/dist/edge/http/youtube-edits.js +83 -0
  115. package/dist/edge/native-update.js +62 -0
  116. package/dist/host-cli/open-folder.js +10 -10
  117. package/dist/host-cli/runtime.js +9 -1
  118. package/dist/kernel/db/migrations/0025-automatic-reviews.sql +40 -0
  119. package/dist/kernel/db/migrations/0026-schedule-topic-generation.sql +23 -0
  120. package/dist/kernel/db/migrations/0027-run-cost.sql +58 -0
  121. package/dist/kernel/db/migrations/0030-automatic-retries.sql +18 -0
  122. package/dist/kernel/db/migrations/0031-prompt-history-and-description-edits.sql +33 -0
  123. package/dist/kernel/db/migrations/0032-channels.sql +65 -0
  124. package/dist/kernel/db/migrations/0034-script-prompts.sql +23 -0
  125. package/dist/kernel/db/migrations/0035-narration-aliases.sql +13 -0
  126. package/dist/kernel/db/migrations/0037-project-uploads.sql +8 -0
  127. package/dist/kernel/db/migrations/0038-voice-languages.sql +7 -0
  128. package/dist/kernel/db/migrations/0039-channel-essentials.sql +64 -0
  129. package/dist/kernel/db/migrations/0040-standalone-usage.sql +35 -0
  130. package/dist/kernel/paths.js +27 -6
  131. package/dist/kernel/pipeline.js +4 -1
  132. package/dist/kernel/ports/host-cli.js +122 -14
  133. package/dist/kernel/ports/key-probe.js +1 -0
  134. package/dist/kernel/ports/languages.js +157 -0
  135. package/dist/kernel/ports/model.js +5 -2
  136. package/dist/kernel/ports/narration-aliases.js +60 -0
  137. package/dist/kernel/ports/plan-limits.js +36 -0
  138. package/dist/kernel/runner/attempt.js +17 -8
  139. package/dist/kernel/runner/audition.js +36 -0
  140. package/dist/kernel/runner/graph.js +24 -5
  141. package/dist/kernel/runner/index.js +35 -5
  142. package/dist/kernel/runner/meter.js +1 -0
  143. package/dist/kernel/runner/providers.js +142 -7
  144. package/dist/kernel/runner/retry-policy.js +43 -0
  145. package/dist/kernel/runner/standalone.js +134 -0
  146. package/dist/kernel/runner/work-authority.js +28 -2
  147. package/dist/main.js +272 -16
  148. package/dist/patch-notes/0.4.2-to-1.0.md +93 -0
  149. package/dist/patch-notes/1.0-to-2.0.md +74 -0
  150. package/dist/patch-notes/2.0-to-3.0.md +257 -0
  151. package/dist/patch-notes/3.0.0.md +70 -0
  152. package/dist/patch-notes/index.json +26 -0
  153. package/dist/slices/admission/model.js +27 -0
  154. package/dist/slices/admission/repo.js +24 -4
  155. package/dist/slices/admission/rules.js +79 -1
  156. package/dist/slices/admission/schema.js +58 -1
  157. package/dist/slices/admission/short-mode.js +47 -0
  158. package/dist/slices/admission/start.js +41 -5
  159. package/dist/slices/article/continuation.js +2 -2
  160. package/dist/slices/article/provided-entries.js +2 -2
  161. package/dist/slices/article/run.js +6 -2
  162. package/dist/slices/article/segments.js +3 -3
  163. package/dist/slices/backups/folder.js +14 -6
  164. package/dist/slices/batch/index.js +7 -2
  165. package/dist/slices/cancel/index.js +1 -1
  166. package/dist/slices/channels/cast-images.js +110 -0
  167. package/dist/slices/channels/cast-match.js +32 -0
  168. package/dist/slices/channels/images.js +19 -0
  169. package/dist/slices/channels/model.js +5 -0
  170. package/dist/slices/channels/rebrand.js +100 -0
  171. package/dist/slices/channels/repo.js +218 -0
  172. package/dist/slices/channels/runs.js +129 -0
  173. package/dist/slices/channels/schema.js +129 -0
  174. package/dist/slices/channels/service.js +180 -0
  175. package/dist/slices/channels/studio-csv.js +110 -0
  176. package/dist/slices/channels/videos.js +120 -0
  177. package/dist/slices/checkpoints/recovery.js +10 -0
  178. package/dist/slices/control/index.js +2 -1
  179. package/dist/slices/document/fonts.js +1 -1
  180. package/dist/slices/document/render.js +1 -1
  181. package/dist/slices/episodes/related.js +90 -0
  182. package/dist/slices/episodes/repo.js +60 -0
  183. package/dist/slices/episodes/service.js +58 -0
  184. package/dist/slices/episodes/summarize.js +170 -0
  185. package/dist/slices/estimate/index.js +109 -28
  186. package/dist/slices/estimate/requests.js +60 -2
  187. package/dist/slices/fixes/rules.js +73 -0
  188. package/dist/slices/fonts/coverage.js +82 -0
  189. package/dist/slices/fonts/sfnt.js +74 -0
  190. package/dist/slices/images/run.js +8 -5
  191. package/dist/slices/images/scale.js +84 -0
  192. package/dist/slices/library/diff.js +122 -0
  193. package/dist/slices/library/history.js +100 -0
  194. package/dist/slices/library/model.js +3 -0
  195. package/dist/slices/library/photorealistic.js +49 -0
  196. package/dist/slices/library/repo.js +19 -14
  197. package/dist/slices/library/save.js +62 -12
  198. package/dist/slices/library/slots.js +7 -1
  199. package/dist/slices/library/used-by.js +153 -0
  200. package/dist/slices/loudness/level-pieces.js +41 -0
  201. package/dist/slices/loudness/loudnorm.js +164 -0
  202. package/dist/slices/loudness/model.js +153 -0
  203. package/dist/slices/model-upkeep/model.js +10 -0
  204. package/dist/slices/model-upkeep/switch.js +217 -0
  205. package/dist/slices/model-upkeep/usage.js +183 -0
  206. package/dist/slices/narration/aliases-library.js +31 -0
  207. package/dist/slices/narration/aliases-schema.js +53 -0
  208. package/dist/slices/narration/aliases.js +49 -0
  209. package/dist/slices/narration/blocks.js +352 -0
  210. package/dist/slices/narration/chunk.js +19 -7
  211. package/dist/slices/narration/concat.js +2 -2
  212. package/dist/slices/narration/describe.js +45 -0
  213. package/dist/slices/narration/pauses-model.js +63 -0
  214. package/dist/slices/narration/pauses.js +269 -0
  215. package/dist/slices/narration/peaks-model.js +1 -0
  216. package/dist/slices/narration/peaks.js +94 -0
  217. package/dist/slices/narration/preparation.js +21 -3
  218. package/dist/slices/narration/pronunciation-chunks.js +3 -3
  219. package/dist/slices/narration/pronunciation.js +72 -25
  220. package/dist/slices/narration/run.js +6 -6
  221. package/dist/slices/narration/shared-glossary.js +1 -1
  222. package/dist/slices/narration/steering.js +15 -4
  223. package/dist/slices/notifications/notifier.js +21 -3
  224. package/dist/slices/notifications/rules.js +22 -1
  225. package/dist/slices/onboarding/copy.js +143 -0
  226. package/dist/slices/onboarding/first-run.js +46 -0
  227. package/dist/slices/onboarding/install.js +119 -0
  228. package/dist/slices/onboarding/model.js +64 -0
  229. package/dist/slices/onboarding/packs.js +491 -0
  230. package/dist/slices/onboarding/quick-short.js +129 -0
  231. package/dist/slices/onboarding/sample.js +132 -0
  232. package/dist/slices/onboarding/state.js +68 -0
  233. package/dist/slices/onboarding/template.js +73 -0
  234. package/dist/slices/patch-notes/library.js +73 -0
  235. package/dist/slices/patch-notes/seen.js +77 -0
  236. package/dist/slices/play-drafts/convert.js +125 -6
  237. package/dist/slices/play-drafts/readiness.js +3 -0
  238. package/dist/slices/play-drafts/repo.js +2 -0
  239. package/dist/slices/play-drafts/review-inputs.js +20 -3
  240. package/dist/slices/play-drafts/schema.js +89 -0
  241. package/dist/slices/play-drafts/service.js +4 -1
  242. package/dist/slices/play-drafts/start.js +43 -18
  243. package/dist/slices/project-templates/from-project.js +50 -4
  244. package/dist/slices/project-templates/one-off.js +26 -0
  245. package/dist/slices/project-templates/repo.js +2 -1
  246. package/dist/slices/project-templates/service.js +15 -4
  247. package/dist/slices/project-templates/setup.js +18 -2
  248. package/dist/slices/rebuild/admission-repo.js +1 -1
  249. package/dist/slices/rebuild/model.js +6 -0
  250. package/dist/slices/rebuild/preview-details.js +46 -3
  251. package/dist/slices/rebuild/preview-plan.js +13 -6
  252. package/dist/slices/rebuild/recipe-audio-parts.js +40 -7
  253. package/dist/slices/rebuild/recipe-audio.js +66 -12
  254. package/dist/slices/rebuild/recipe-build.js +8 -4
  255. package/dist/slices/rebuild/recipe-cast.js +18 -0
  256. package/dist/slices/rebuild/recipe-checkpoints.js +11 -9
  257. package/dist/slices/rebuild/recipe-describe.js +93 -0
  258. package/dist/slices/rebuild/recipe-edit.js +13 -1
  259. package/dist/slices/rebuild/recipe-exports.js +62 -8
  260. package/dist/slices/rebuild/recipe-input-schema.js +35 -0
  261. package/dist/slices/rebuild/recipe-legacy.js +9 -0
  262. package/dist/slices/rebuild/recipe-loudness.js +45 -0
  263. package/dist/slices/rebuild/recipe-model.js +25 -6
  264. package/dist/slices/rebuild/recipe-pauses.js +7 -0
  265. package/dist/slices/rebuild/recipe-preparation.js +3 -3
  266. package/dist/slices/rebuild/recipe-provider-choice.js +11 -1
  267. package/dist/slices/rebuild/recipe-reference.js +15 -1
  268. package/dist/slices/rebuild/recipe-reviews.js +117 -0
  269. package/dist/slices/rebuild/recipe-save.js +4 -1
  270. package/dist/slices/rebuild/recipe-shorts.js +23 -3
  271. package/dist/slices/rebuild/recipe-text.js +114 -19
  272. package/dist/slices/rebuild/recipe-validation.js +14 -2
  273. package/dist/slices/rebuild/recipe-visual.js +96 -33
  274. package/dist/slices/rebuild/recipe-voices.js +286 -0
  275. package/dist/slices/rebuild/recipe-work.js +77 -6
  276. package/dist/slices/rebuild/recipe-youtube.js +2 -0
  277. package/dist/slices/rebuild/recovery-conflict.js +7 -3
  278. package/dist/slices/rebuild/recovery-model.js +3 -0
  279. package/dist/slices/rebuild/recovery-selection.js +28 -2
  280. package/dist/slices/rebuild/recovery.js +35 -17
  281. package/dist/slices/rebuild/repo.js +3 -3
  282. package/dist/slices/rebuild/retry.js +18 -0
  283. package/dist/slices/rebuild/review-redo.js +110 -0
  284. package/dist/slices/rebuild/runtime-actions.js +5 -0
  285. package/dist/slices/rebuild/runtime-admission.js +4 -1
  286. package/dist/slices/rebuild/runtime-animate.js +4 -4
  287. package/dist/slices/rebuild/runtime-article.js +5 -5
  288. package/dist/slices/rebuild/runtime-document.js +4 -4
  289. package/dist/slices/rebuild/runtime-export-bed.js +43 -0
  290. package/dist/slices/rebuild/runtime-export-edit.js +70 -11
  291. package/dist/slices/rebuild/runtime-export-inputs.js +52 -10
  292. package/dist/slices/rebuild/runtime-export-short.js +85 -0
  293. package/dist/slices/rebuild/runtime-export.js +84 -19
  294. package/dist/slices/rebuild/runtime-figure-card.js +87 -0
  295. package/dist/slices/rebuild/runtime-image.js +21 -2
  296. package/dist/slices/rebuild/runtime-local.js +148 -24
  297. package/dist/slices/rebuild/runtime-materialize.js +8 -0
  298. package/dist/slices/rebuild/runtime-narration-reuse.js +2 -1
  299. package/dist/slices/rebuild/runtime-narration-text.js +7 -7
  300. package/dist/slices/rebuild/runtime-pictures.js +28 -0
  301. package/dist/slices/rebuild/runtime-piece-label.js +27 -0
  302. package/dist/slices/rebuild/runtime-provider.js +126 -18
  303. package/dist/slices/rebuild/runtime-publication.js +21 -5
  304. package/dist/slices/rebuild/runtime-review.js +237 -0
  305. package/dist/slices/rebuild/runtime-run.js +17 -11
  306. package/dist/slices/rebuild/runtime-shorts.js +44 -17
  307. package/dist/slices/rebuild/runtime-store.js +39 -10
  308. package/dist/slices/rebuild/runtime-subtitles.js +72 -19
  309. package/dist/slices/rebuild/runtime-voices.js +103 -0
  310. package/dist/slices/rebuild/runtime-youtube.js +7 -6
  311. package/dist/slices/rebuild/soften.js +49 -0
  312. package/dist/slices/rebuild/transition-repo.js +9 -2
  313. package/dist/slices/research/run.js +4 -4
  314. package/dist/slices/research/synthesis.js +2 -2
  315. package/dist/slices/reviews/model.js +105 -0
  316. package/dist/slices/reviews/outcome.js +22 -0
  317. package/dist/slices/reviews/repo.js +106 -0
  318. package/dist/slices/reviews/rules.js +63 -0
  319. package/dist/slices/reviews/verdict.js +71 -0
  320. package/dist/slices/reviews/view.js +30 -0
  321. package/dist/slices/revisions/adopt-content.js +5 -2
  322. package/dist/slices/revisions/adopt.js +3 -1
  323. package/dist/slices/revisions/downloads.js +7 -1
  324. package/dist/slices/revisions/image-plan.js +183 -0
  325. package/dist/slices/revisions/mutation-assets.js +10 -0
  326. package/dist/slices/revisions/mutation-cues.js +71 -0
  327. package/dist/slices/revisions/mutation-request.js +2 -1
  328. package/dist/slices/revisions/mutations.js +97 -3
  329. package/dist/slices/revisions/schema.js +11 -1
  330. package/dist/slices/run-cost/limits.js +128 -0
  331. package/dist/slices/run-cost/meter.js +47 -0
  332. package/dist/slices/run-cost/panel.js +234 -0
  333. package/dist/slices/run-cost/pricing.js +92 -0
  334. package/dist/slices/run-cost/week.js +108 -0
  335. package/dist/slices/schedules/agenda.js +158 -0
  336. package/dist/slices/schedules/repo.js +58 -18
  337. package/dist/slices/schedules/scheduler.js +67 -16
  338. package/dist/slices/schedules/schema.js +138 -1
  339. package/dist/slices/schedules/service.js +43 -4
  340. package/dist/slices/schedules/similar.js +90 -0
  341. package/dist/slices/schedules/topic-list.js +163 -0
  342. package/dist/slices/schedules/topics.js +422 -0
  343. package/dist/slices/settings/first-run.js +102 -0
  344. package/dist/slices/settings/health.js +190 -0
  345. package/dist/slices/settings/key-guides.js +128 -0
  346. package/dist/slices/settings/key-test.js +85 -0
  347. package/dist/slices/settings/model.js +6 -1
  348. package/dist/slices/settings/playback.js +15 -0
  349. package/dist/slices/settings/repo.js +17 -3
  350. package/dist/slices/settings/voices.js +64 -4
  351. package/dist/slices/settings/whats-new.js +64 -0
  352. package/dist/slices/shorts/captions.js +9 -0
  353. package/dist/slices/shorts/pick.js +3 -3
  354. package/dist/slices/shorts/prompts.js +1 -1
  355. package/dist/slices/shorts/render.js +54 -5
  356. package/dist/slices/storage/asset-name.js +3 -0
  357. package/dist/slices/storage/backup-export.js +48 -9
  358. package/dist/slices/storage/backup-format.js +38 -1
  359. package/dist/slices/storage/backup-import.js +191 -19
  360. package/dist/slices/storage/delete-project.js +7 -4
  361. package/dist/slices/storage/documents.js +79 -0
  362. package/dist/slices/storage/files-location.js +531 -0
  363. package/dist/slices/storage/layout.js +14 -4
  364. package/dist/slices/storage/model.js +11 -0
  365. package/dist/slices/storage/portable.js +92 -12
  366. package/dist/slices/storage/reconcile.js +14 -1
  367. package/dist/slices/storage/repo.js +4 -1
  368. package/dist/slices/storage/schema.js +11 -0
  369. package/dist/slices/storage/trim.js +146 -0
  370. package/dist/slices/studio/disclosure.js +74 -0
  371. package/dist/slices/studio/model.js +28 -0
  372. package/dist/slices/studio/pack.js +209 -0
  373. package/dist/slices/studio/settings.js +133 -0
  374. package/dist/slices/style-preview/images.js +61 -0
  375. package/dist/slices/style-preview/render.js +161 -0
  376. package/dist/slices/style-preview/schema.js +45 -0
  377. package/dist/slices/style-preview/service.js +93 -0
  378. package/dist/slices/style-preview/settings.js +89 -0
  379. package/dist/slices/subtitles/captions.js +42 -9
  380. package/dist/slices/subtitles/model.js +22 -0
  381. package/dist/slices/subtitles/prepare.js +27 -16
  382. package/dist/slices/subtitles/transcript.js +1 -1
  383. package/dist/slices/thumbnail/run.js +7 -7
  384. package/dist/slices/trash/model.js +25 -0
  385. package/dist/slices/trash/service.js +273 -0
  386. package/dist/slices/uploads/repo.js +20 -0
  387. package/dist/slices/video/ambient-bed-schema.js +15 -0
  388. package/dist/slices/video/ambient-bed.js +95 -0
  389. package/dist/slices/video/ambient-mix.js +72 -0
  390. package/dist/slices/video/audio-inputs.js +2 -2
  391. package/dist/slices/video/cards.js +5 -2
  392. package/dist/slices/video/edit-list.js +23 -2
  393. package/dist/slices/video/edit-settings.js +13 -3
  394. package/dist/slices/video/ffmpeg.js +63 -9
  395. package/dist/slices/video/figure-card.js +492 -0
  396. package/dist/slices/video/figure-spans.js +51 -0
  397. package/dist/slices/video/plan.js +125 -13
  398. package/dist/slices/video/run.js +14 -12
  399. package/dist/slices/video/slideshow.js +39 -4
  400. package/dist/slices/voices/attribution.js +69 -0
  401. package/dist/slices/voices/audio-files.js +82 -0
  402. package/dist/slices/voices/audition.js +25 -0
  403. package/dist/slices/voices/cast.js +56 -0
  404. package/dist/slices/voices/grouping.js +49 -0
  405. package/dist/slices/voices/join.js +35 -0
  406. package/dist/slices/voices/languages.js +25 -0
  407. package/dist/slices/voices/model.js +179 -0
  408. package/dist/slices/voices/palette.js +26 -0
  409. package/dist/slices/voices/panel.js +91 -0
  410. package/dist/slices/voices/portraits.js +55 -0
  411. package/dist/slices/voices/script.js +136 -0
  412. package/dist/slices/voices/timing.js +58 -0
  413. package/dist/slices/youtube/answer.js +1 -1
  414. package/dist/slices/youtube/chapters.js +126 -0
  415. package/dist/slices/youtube/edits-repo.js +69 -0
  416. package/dist/slices/youtube/edits.js +89 -0
  417. package/dist/slices/youtube/placeholders.js +98 -0
  418. package/dist/slices/youtube/transcript.js +11 -4
  419. package/dist/updater/service.js +66 -21
  420. package/dist/web/assets/index-CUA2sUxU.css +1 -0
  421. package/dist/web/assets/index-CX590kE-.js +303 -0
  422. package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  423. package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  424. package/dist/web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  425. package/dist/web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  426. package/dist/web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  427. package/dist/web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  428. package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  429. package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  430. package/dist/web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  431. package/dist/web/assets/{pdf-BLF2i8c2.js → pdf-BG8n_uc5.js} +1 -1
  432. package/dist/web/index.html +2 -2
  433. package/package.json +4 -4
  434. package/dist/edge/docker-launch.js +0 -81
  435. package/dist/edge/docker-projects/activation.js +0 -35
  436. package/dist/edge/docker-projects/claims.js +0 -85
  437. package/dist/edge/docker-projects/committed.js +0 -40
  438. package/dist/edge/docker-projects/engine.js +0 -461
  439. package/dist/edge/docker-projects/install.js +0 -254
  440. package/dist/edge/docker-projects/prune.js +0 -66
  441. package/dist/edge/docker-projects/recover.js +0 -100
  442. package/dist/edge/docker-projects/state.js +0 -225
  443. package/dist/web/assets/index-CktpxlJd.js +0 -171
  444. package/dist/web/assets/index-DB-vOnGU.css +0 -1
  445. package/scripts/docker-run.sh +0 -3
  446. /package/dist/edge/{docker-projects → docker-install}/tree.js +0 -0
@@ -0,0 +1,163 @@
1
+ import { parse, stringify } from "yaml";
2
+ import { detectSlots, render } from "../admission/substitute.js";
3
+ // The longest value one topic may give a keyword. Word counts, names and short notes fit;
4
+ // anything longer belongs in the template.
5
+ export const topicValueMax = 2000;
6
+ export const topicTitleMax = 200;
7
+ // A template's keywords: those its project title names plus those it stores a value for (Play
8
+ // stores one for every keyword its prompts use).
9
+ export function templateKeywords(form) {
10
+ return [...new Set([...detectSlots(form.title).names, ...Object.keys(form.values)])];
11
+ }
12
+ // The project title a run gets, exactly as the scheduler builds it: the template's values, the
13
+ // schedule's every-run values, the topic's own values, then the topic in its keyword. Without
14
+ // a topic keyword the topic is the whole title.
15
+ export function scheduledValues(form, schedule, topic) {
16
+ return {
17
+ ...form.values,
18
+ ...schedule.values,
19
+ ...(topic?.values ?? {}),
20
+ ...(topic !== undefined && schedule.topicKeyword !== null
21
+ ? { [schedule.topicKeyword]: topic.title }
22
+ : {}),
23
+ };
24
+ }
25
+ export function renderedTitle(form, schedule, topic) {
26
+ return topic !== undefined && schedule.topicKeyword === null
27
+ ? topic.title
28
+ : render(form.title, scheduledValues(form, schedule, topic));
29
+ }
30
+ const quote = (name) => `“${name}”`;
31
+ const listed = (names) => names.length === 0 ? "none" : names.map(quote).join(", ");
32
+ function rowName(index, title) {
33
+ return title === "" ? `Topic ${String(index + 1)}` : `Topic ${String(index + 1)} (${title})`;
34
+ }
35
+ // What is wrong with a queue, one plain sentence per problem naming the row and the key.
36
+ // `keywords` are the template's; `required` must be set on every row (keywords with no
37
+ // every-run value); an item equal to one in `kept` (the saved queue) is not checked again, so
38
+ // a topic saved before these rules keeps working until it is edited.
39
+ export function topicRowProblems(rows, options) {
40
+ const problems = [];
41
+ const kept = new Set((options.kept ?? []).map((row) => key(row)));
42
+ rows.forEach((row, index) => {
43
+ if (kept.has(key(row)))
44
+ return;
45
+ const name = rowName(index, row.title.trim());
46
+ if (row.title.trim() === "")
47
+ problems.push(options.topicKeyword === null
48
+ ? `${name} has no title. Write one, or remove the row.`
49
+ : `${name} has no ${quote(options.topicKeyword)}, the keyword each topic fills. Write one, or remove the row.`);
50
+ else if (row.title.trim().length > topicTitleMax)
51
+ problems.push(`${name} is ${String(row.title.trim().length)} characters; a topic can be at most ${String(topicTitleMax)}. Shorten it.`);
52
+ for (const [keyword, value] of Object.entries(row.values)) {
53
+ if (!options.keywords.includes(keyword))
54
+ problems.push(`${name}: ${quote(keyword)} is not a keyword of this template (its keywords are ${listed(options.keywords)}). Rename it to one of them or remove it.`);
55
+ else if (value.length > topicValueMax)
56
+ problems.push(`${name}: ${quote(keyword)} is ${String(value.length)} characters; a topic's value can be at most ${String(topicValueMax)}. Shorten it, or put the long text in the template.`);
57
+ }
58
+ for (const keyword of options.required ?? []) {
59
+ if (keyword === options.topicKeyword)
60
+ continue;
61
+ if ((row.values[keyword] ?? "").trim() === "")
62
+ problems.push(`${name} has no ${quote(keyword)}, and there is no every-run value to fall back on. Give this topic one, or fill ${quote(keyword)} for every run.`);
63
+ }
64
+ });
65
+ return problems;
66
+ }
67
+ function key(row) {
68
+ return JSON.stringify([
69
+ row.title,
70
+ Object.entries(row.values).sort(([a], [b]) => a.localeCompare(b)),
71
+ ]);
72
+ }
73
+ // One topic per line. A line whose text matches a row already in the queue keeps that row's
74
+ // values (first unused match), so switching to lines and back loses nothing.
75
+ export function rowsFromLines(text, previous) {
76
+ const unused = [...previous];
77
+ return text
78
+ .split("\n")
79
+ .map((line) => line.trim())
80
+ .filter((line) => line !== "")
81
+ .map((title) => {
82
+ const at = unused.findIndex((row) => row.title === title);
83
+ if (at === -1)
84
+ return { title, values: {} };
85
+ const [row] = unused.splice(at, 1);
86
+ return row ?? { title, values: {} };
87
+ });
88
+ }
89
+ export function linesFromRows(rows) {
90
+ return rows.map((row) => row.title).join("\n");
91
+ }
92
+ // The YAML / JSON list: each item is a map of keyword to value, or a plain string (the topic).
93
+ // JSON is YAML, so a JSON array reads the same way. Numbers and yes/no are read as the text
94
+ // they were written as; lists, maps and multi-document files are refused.
95
+ export function parseTopicList(text, options) {
96
+ if (text.trim() === "")
97
+ return { ok: true, rows: [] };
98
+ let data;
99
+ try {
100
+ // Every scalar as the string it was written as: "12000", "yes", "0012".
101
+ data = parse(text, { schema: "failsafe", uniqueKeys: true });
102
+ }
103
+ catch (error) {
104
+ const message = error instanceof Error ? error.message.split("\n")[0] : "";
105
+ return {
106
+ ok: false,
107
+ problems: [
108
+ `The list couldn't be read as YAML or JSON: ${message ?? ""}. Write one item per topic, such as "- Topic: Tiamat".`,
109
+ ],
110
+ };
111
+ }
112
+ if (!Array.isArray(data))
113
+ return {
114
+ ok: false,
115
+ problems: [
116
+ 'The list must be a list of topics: start each one with "- " in YAML, or wrap them in [ ] in JSON.',
117
+ ],
118
+ };
119
+ const problems = [];
120
+ const rows = [];
121
+ data.forEach((item, index) => {
122
+ const name = `Topic ${String(index + 1)}`;
123
+ if (typeof item === "string") {
124
+ rows.push({ title: item.trim(), values: {} });
125
+ return;
126
+ }
127
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
128
+ problems.push(`${name} is not a topic or a set of keywords. Write it as "- Tiamat" or "- Topic: Tiamat".`);
129
+ return;
130
+ }
131
+ let title = "";
132
+ const values = {};
133
+ for (const [written, value] of Object.entries(item)) {
134
+ const keyword = written.trim();
135
+ // "Topic:" with nothing after it reads as no value.
136
+ const raw = value ?? "";
137
+ if (typeof raw !== "string") {
138
+ problems.push(`${name}: ${quote(keyword)} holds a list or a map. Give it one value, such as "${keyword}: 12000".`);
139
+ continue;
140
+ }
141
+ if (options.topicKeyword !== null && keyword === options.topicKeyword)
142
+ title = raw.trim();
143
+ else if (!options.keywords.includes(keyword))
144
+ problems.push(`${name}: ${quote(keyword)} is not a keyword of this template (its keywords are ${listed(options.keywords)}). Rename it to one of them or remove it.`);
145
+ else
146
+ values[keyword] = raw.trim();
147
+ }
148
+ rows.push({ title, values });
149
+ });
150
+ // Unknown keys were left out of the rows above, so these name the other problems once.
151
+ const all = [...problems, ...topicRowProblems(rows, options)];
152
+ return all.length > 0 ? { ok: false, problems: all } : { ok: true, rows };
153
+ }
154
+ // The queue as YAML: a plain "- title" when a topic sets nothing else, else a map with the
155
+ // topic keyword first. Reading it back with parseTopicList gives the same rows.
156
+ export function formatTopicList(rows, topicKeyword) {
157
+ if (rows.length === 0)
158
+ return "";
159
+ const items = rows.map((row) => Object.keys(row.values).length === 0 || topicKeyword === null
160
+ ? row.title
161
+ : { [topicKeyword]: row.title, ...row.values });
162
+ return stringify(items, { lineWidth: 0 });
163
+ }
@@ -0,0 +1,422 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { liveProject } from "../admission/repo.js";
4
+ import { scheduleChannel } from "../channels/repo.js";
5
+ import { channelVideoTitles } from "../channels/videos.js";
6
+ import { scheduleById } from "./repo.js";
7
+ import { heldTopicSchema, queueMax, topicMoveSchema, topicTitleSchema, topicTransferSchema, } from "./schema.js";
8
+ import { newTopics } from "./similar.js";
9
+ // A schedule that finds its own topics asks an LLM whenever its queue holds fewer than
10
+ // `keepAtLeast`. Only one generation per schedule runs at a time: a lease on the schedule row
11
+ // (`topics_generating_at`) is taken before the LLM is asked and cleared after, so a tick and a
12
+ // "Generate topics now" press cannot both ask. A lease older than `leaseMs` belongs to a
13
+ // generation that died with the process and is taken over.
14
+ export const generationTimeoutMs = 10 * 60_000;
15
+ const leaseMs = generationTimeoutMs + 5 * 60_000;
16
+ // A failure is recorded on the schedule and the next tick after this long tries again, so a
17
+ // signed-out CLI or a bad answer is not asked again every 15 seconds.
18
+ export const generationRetryMs = 5 * 60_000;
19
+ // Asked for on top of what is missing, so the duplicates dropped still leave enough.
20
+ const spare = 5;
21
+ const askMax = 50;
22
+ // The prompt lists every title; this only bounds a pathological library.
23
+ const knownMax = 5000;
24
+ export function generationWanted(schedule) {
25
+ const generation = schedule.topicGeneration;
26
+ if (generation.mode === "off")
27
+ return 0;
28
+ const waiting = schedule.items.length + (generation.mode === "hold" ? schedule.topics.held : 0);
29
+ return Math.max(0, Math.min(generation.keepAtLeast, queueMax) - waiting);
30
+ }
31
+ // Whether a tick should start a generation for this schedule now.
32
+ export function generationDue(schedule, now) {
33
+ if (schedule.deletedAt !== null || schedule.status !== "active")
34
+ return false;
35
+ if (generationWanted(schedule) === 0)
36
+ return false;
37
+ if (schedule.topics.generatingSince !== null && !stale(schedule.topics.generatingSince, now))
38
+ return false;
39
+ return (schedule.topics.failedAt === null ||
40
+ now.valueOf() - new Date(schedule.topics.failedAt).valueOf() >= generationRetryMs);
41
+ }
42
+ function stale(since, now) {
43
+ return now.valueOf() - new Date(since).valueOf() >= leaseMs;
44
+ }
45
+ // `force` is the "Generate topics now" button: it ignores the retry wait and asks for a few
46
+ // even when the queue is full.
47
+ export async function generateTopics(deps, scheduleId, options = {}) {
48
+ const now = deps.clock.now();
49
+ const claimed = transact(deps.db, () => {
50
+ const schedule = scheduleById(deps.db, scheduleId);
51
+ if (schedule === undefined || schedule.deletedAt !== null)
52
+ return { ok: false, reason: "not-found", message: "This schedule no longer exists." };
53
+ if (schedule.topicGeneration.mode === "off")
54
+ return {
55
+ ok: false,
56
+ reason: "off",
57
+ message: "Topic generation is off for this schedule. Turn it on under Edit → Topic generation.",
58
+ };
59
+ if (schedule.status === "canceled" || schedule.status === "completed")
60
+ return {
61
+ ok: false,
62
+ reason: "off",
63
+ message: `This schedule is ${schedule.status}, so it needs no more topics.`,
64
+ };
65
+ if (!options.force && generationWanted(schedule) === 0)
66
+ return { ok: false, reason: "full", message: "The queue already has enough topics." };
67
+ const taken = deps.db
68
+ .prepare(`UPDATE schedules SET topics_generating_at=?
69
+ WHERE id=? AND (topics_generating_at IS NULL OR topics_generating_at<=?)`)
70
+ .run(now.toISOString(), scheduleId, new Date(now.valueOf() - leaseMs).toISOString());
71
+ if (Number(taken.changes) !== 1)
72
+ return {
73
+ ok: false,
74
+ reason: "busy",
75
+ message: "Slopify is already generating topics for this schedule. Wait for it to finish.",
76
+ };
77
+ return schedule;
78
+ });
79
+ if ("ok" in claimed)
80
+ return claimed;
81
+ const schedule = claimed;
82
+ try {
83
+ return await ask(deps, schedule, options);
84
+ }
85
+ finally {
86
+ deps.db
87
+ .prepare("UPDATE schedules SET topics_generating_at=NULL WHERE id=? AND topics_generating_at=?")
88
+ .run(scheduleId, now.toISOString());
89
+ }
90
+ }
91
+ async function ask(deps, schedule, options) {
92
+ const fail = (message) => {
93
+ const failedAt = deps.clock.now();
94
+ const retry = "Slopify tries again in 5 minutes, or press Generate topics now on the Schedules screen.";
95
+ const text = `Couldn't generate topics: ${message} ${retry}`;
96
+ deps.db
97
+ .prepare("UPDATE schedules SET topics_failed_at=?,topics_error=? WHERE id=?")
98
+ .run(failedAt.toISOString(), text, schedule.id);
99
+ return { ok: false, reason: "failed", message: text };
100
+ };
101
+ const template = deps.template(schedule.templateId, schedule.templateVersion);
102
+ const llm = schedule.topicGeneration.llm ?? template?.document.form.llm;
103
+ if (llm === undefined)
104
+ return fail("the template this schedule uses was deleted, so there is no LLM to ask. Edit the schedule and choose a template, or pick an LLM under Topic generation.");
105
+ if (deps.topicLlm === undefined)
106
+ return fail("no LLM is available in this Slopify. Restart Slopify and try again.");
107
+ const wanted = Math.max(generationWanted(schedule), options.force ? spare : 0);
108
+ const known = knownTitles(deps, schedule.id);
109
+ const signal = AbortSignal.any([
110
+ AbortSignal.timeout(generationTimeoutMs),
111
+ ...(options.signal === undefined ? [] : [options.signal]),
112
+ ]);
113
+ let answer;
114
+ try {
115
+ const answered = await deps.topicLlm({
116
+ owner: { kind: "schedule", id: schedule.id },
117
+ purpose: "topics",
118
+ provider: llm.provider,
119
+ model: llm.model,
120
+ ...(llm.thinking === undefined ? {} : { thinking: llm.thinking }),
121
+ messages: topicMessages(withChannelBrief(deps, schedule), template?.document.form.title, known, Math.min(wanted + spare, askMax)),
122
+ signal,
123
+ });
124
+ answer = answered.text;
125
+ }
126
+ catch (error) {
127
+ if (options.signal?.aborted === true)
128
+ return { ok: false, reason: "failed", message: "Slopify was closing." };
129
+ const detail = error instanceof Error ? error.message : String(error);
130
+ return fail(`${llm.provider} (${llm.model}) did not answer: ${detail.trim().slice(0, 300)}. Check it in Settings → Providers, or pick another LLM under Edit → Topic generation.`);
131
+ }
132
+ const suggested = parseTopics(answer);
133
+ if (suggested.length === 0)
134
+ return fail(`${llm.provider} (${llm.model}) answered without a list of topics. Try again, or pick another LLM under Edit → Topic generation.`);
135
+ return transact(deps.db, () => {
136
+ const current = scheduleById(deps.db, schedule.id);
137
+ if (current === undefined || current.deletedAt !== null)
138
+ return { ok: false, reason: "not-found", message: "This schedule no longer exists." };
139
+ // Checked again against what is there now: a topic added while the LLM was thinking counts.
140
+ const fresh = newTopics(suggested, knownTitles(deps, schedule.id)).slice(0, Math.max(0, Math.min(wanted, queueMax - current.items.length)));
141
+ if (fresh.length === 0)
142
+ return fail("every topic the LLM suggested was already made, queued or turned down for this schedule. Add more detail to the series brief under Edit, so it has new ground to cover.");
143
+ const at = deps.clock.now().toISOString();
144
+ const mode = current.topicGeneration.mode === "hold" ? "hold" : "queue";
145
+ if (mode === "queue") {
146
+ deps.db
147
+ .prepare("UPDATE schedules SET items_json=?,version=version+1,updated_at=? WHERE id=? AND deleted_at IS NULL")
148
+ .run(JSON.stringify([...current.items, ...fresh.map((title) => ({ title, values: {} }))]), at, schedule.id);
149
+ }
150
+ else {
151
+ const start = nextRank(deps, schedule.id);
152
+ fresh.forEach((title, index) => {
153
+ deps.db
154
+ .prepare(`INSERT INTO schedule_topics (id,schedule_id,title,state,rank,created_at)
155
+ VALUES (?,?,?,'held',?,?)`)
156
+ .run(deps.uuid(), schedule.id, title, start + index, at);
157
+ });
158
+ }
159
+ deps.db
160
+ .prepare("UPDATE schedules SET topics_generated_at=?,topics_failed_at=NULL,topics_error=NULL WHERE id=?")
161
+ .run(at, schedule.id);
162
+ if (mode === "hold") {
163
+ const waiting = heldTopics(deps, schedule.id).length;
164
+ try {
165
+ deps.topicsWaiting?.({
166
+ type: "schedule.topics",
167
+ scheduleId: schedule.id,
168
+ scheduleName: current.name,
169
+ added: fresh.length,
170
+ waiting,
171
+ });
172
+ }
173
+ catch {
174
+ // A notification is never a reason to lose the topics.
175
+ }
176
+ }
177
+ return { ok: true, added: fresh.length, mode };
178
+ });
179
+ }
180
+ function nextRank(deps, scheduleId) {
181
+ const row = deps.db
182
+ .prepare("SELECT max(rank) AS rank FROM schedule_topics WHERE schedule_id=? AND state='held'")
183
+ .get(scheduleId);
184
+ const rank = z.object({ rank: z.number().nullable() }).parse(row).rank;
185
+ return rank === null ? 0 : rank + 1;
186
+ }
187
+ // Everything this schedule has queued, held, turned down or used, every project's title, and
188
+ // the titles of the videos its channel made before Slopify (Channel page → Existing videos).
189
+ export function knownTitles(deps, scheduleId) {
190
+ const schedule = scheduleById(deps.db, scheduleId);
191
+ const logged = deps.db
192
+ .prepare("SELECT title FROM schedule_topics WHERE schedule_id=? ORDER BY created_at DESC")
193
+ .all(scheduleId)
194
+ .map((row) => z.object({ title: z.string() }).parse(row).title);
195
+ const projects = deps.db
196
+ .prepare(`SELECT title FROM projects WHERE ${liveProject()} ORDER BY created_at DESC`)
197
+ .all()
198
+ .map((row) => z.object({ title: z.string() }).parse(row).title);
199
+ const channel = scheduleChannel(deps.db, scheduleId);
200
+ const existing = channel === undefined ? [] : channelVideoTitles(deps.db, channel.id);
201
+ return {
202
+ topics: [...new Set([...(schedule?.items.map((item) => item.title) ?? []), ...logged])],
203
+ projects: [...new Set([...projects, ...existing])],
204
+ };
205
+ }
206
+ // A schedule without a brief of its own reads its channel's series brief (the channel of the
207
+ // template it runs); one it sets itself wins.
208
+ export function withChannelBrief(deps, schedule) {
209
+ if (schedule.brief?.trim())
210
+ return schedule;
211
+ const brief = scheduleChannel(deps.db, schedule.id)?.seriesBrief.trim() ?? "";
212
+ return brief === "" ? schedule : { ...schedule, brief };
213
+ }
214
+ export function topicMessages(schedule, templateTitle, known, count) {
215
+ const titles = [...new Set([...known.topics, ...known.projects])].slice(0, knownMax);
216
+ const keyword = schedule.topicKeyword;
217
+ const fills = keyword !== null && templateTitle?.includes(`{{${keyword}}}`) === true
218
+ ? `Each topic fills {{${keyword}}} in the video title "${templateTitle}", so write only the topic itself, not a whole title.`
219
+ : "Each topic becomes the video's subject; write it as a short title.";
220
+ const brief = schedule.brief?.trim();
221
+ return [
222
+ {
223
+ role: "system",
224
+ content: "You plan the next videos for a YouTube channel. Answer with a JSON array of strings and nothing else.",
225
+ },
226
+ {
227
+ role: "user",
228
+ content: [
229
+ `Suggest ${String(count)} new topics for the series "${schedule.name}".`,
230
+ "",
231
+ "Series brief:",
232
+ brief === undefined || brief === ""
233
+ ? "(none given; work out what the channel covers from the titles below)"
234
+ : brief,
235
+ "",
236
+ `${fills} Keep each under 120 characters.`,
237
+ "Rank them most view-worthy first: the topics most likely to be watched, as the brief defines it.",
238
+ "",
239
+ titles.length === 0
240
+ ? "Nothing has been made for this channel yet."
241
+ : "Never repeat or rephrase any of these, which were already made, queued or turned down:",
242
+ ...titles.map((title) => `- ${title}`),
243
+ ].join("\n"),
244
+ },
245
+ ];
246
+ }
247
+ // A JSON array of strings, or failing that one topic per line with bullets and numbers dropped.
248
+ export function parseTopics(answer) {
249
+ const start = answer.indexOf("[");
250
+ const end = answer.lastIndexOf("]");
251
+ let lines;
252
+ if (start !== -1 && end > start) {
253
+ try {
254
+ const parsed = JSON.parse(answer.slice(start, end + 1));
255
+ if (Array.isArray(parsed))
256
+ lines = parsed;
257
+ }
258
+ catch {
259
+ lines = undefined;
260
+ }
261
+ }
262
+ lines ??= answer.split(/\r?\n/);
263
+ return lines
264
+ .map((line) => typeof line === "string"
265
+ ? line
266
+ : typeof line === "object" && line !== null && "title" in line
267
+ ? String(line.title)
268
+ : "")
269
+ .map((line) => line
270
+ .replace(/^\s*(?:[-*•]|\d+[.)])\s*/, "")
271
+ .replace(/^["'“”]+|["'“”,]+$/g, "")
272
+ .replace(/\s+/g, " ")
273
+ .trim())
274
+ .filter((line) => line !== "" && line !== "[" && line !== "]" && line.length <= 200);
275
+ }
276
+ export function heldTopics(deps, scheduleId) {
277
+ return deps.db
278
+ .prepare(`SELECT id,title,rank,created_at FROM schedule_topics
279
+ WHERE schedule_id=? AND state='held' ORDER BY rank,created_at,id`)
280
+ .all(scheduleId)
281
+ .map((row) => {
282
+ const value = z
283
+ .object({ id: z.string(), title: z.string(), rank: z.number(), created_at: z.string() })
284
+ .parse(row);
285
+ return heldTopicSchema.parse({
286
+ id: value.id,
287
+ title: value.title,
288
+ rank: value.rank,
289
+ createdAt: value.created_at,
290
+ });
291
+ });
292
+ }
293
+ function openSchedule(deps, scheduleId) {
294
+ const schedule = scheduleById(deps.db, scheduleId);
295
+ if (schedule === undefined || schedule.deletedAt !== null)
296
+ return { ok: false, reason: "not-found" };
297
+ if (schedule.status === "canceled" || schedule.status === "completed")
298
+ return { ok: false, reason: "conflict" };
299
+ return { ok: true, value: schedule };
300
+ }
301
+ // Approved topics join the end of the queue in their ranked order. `edits` renames a topic
302
+ // as it is approved.
303
+ export function approveHeldTopics(deps, scheduleId, ids, edits = {}) {
304
+ return transact(deps.db, () => {
305
+ const open = openSchedule(deps, scheduleId);
306
+ if (!open.ok)
307
+ return open;
308
+ const held = heldTopics(deps, scheduleId);
309
+ const chosen = ids === "all" ? held : held.filter((topic) => ids.includes(topic.id));
310
+ if (ids !== "all" && chosen.length !== ids.length)
311
+ return { ok: false, reason: "topic-not-found" };
312
+ const titles = [];
313
+ for (const topic of chosen) {
314
+ const title = topicTitleSchema.safeParse(edits[topic.id] ?? topic.title);
315
+ if (!title.success)
316
+ return { ok: false, reason: "invalid-input" };
317
+ titles.push(title.data);
318
+ }
319
+ if (open.value.items.length + titles.length > queueMax)
320
+ return { ok: false, reason: "queue-full" };
321
+ const at = deps.clock.now().toISOString();
322
+ for (const topic of chosen)
323
+ deps.db.prepare("DELETE FROM schedule_topics WHERE id=?").run(topic.id);
324
+ deps.db
325
+ .prepare("UPDATE schedules SET items_json=?,version=version+1,updated_at=? WHERE id=?")
326
+ .run(JSON.stringify([...open.value.items, ...titles.map((title) => ({ title, values: {} }))]), at, scheduleId);
327
+ return { ok: true, value: scheduleById(deps.db, scheduleId) };
328
+ });
329
+ }
330
+ // Turned down topics stay recorded so no later generation suggests them again.
331
+ export function rejectHeldTopic(deps, scheduleId, topicId) {
332
+ const changed = deps.db
333
+ .prepare(`UPDATE schedule_topics SET state='rejected',decided_at=?
334
+ WHERE id=? AND schedule_id=? AND state='held'`)
335
+ .run(deps.clock.now().toISOString(), topicId, scheduleId);
336
+ if (Number(changed.changes) !== 1)
337
+ return { ok: false, reason: "topic-not-found" };
338
+ return { ok: true, value: scheduleById(deps.db, scheduleId) };
339
+ }
340
+ export function editHeldTopic(deps, scheduleId, topicId, title) {
341
+ const parsed = topicTitleSchema.safeParse(title);
342
+ if (!parsed.success)
343
+ return { ok: false, reason: "invalid-input" };
344
+ const changed = deps.db
345
+ .prepare("UPDATE schedule_topics SET title=? WHERE id=? AND schedule_id=? AND state='held'")
346
+ .run(parsed.data, topicId, scheduleId);
347
+ if (Number(changed.changes) !== 1)
348
+ return { ok: false, reason: "topic-not-found" };
349
+ const topic = heldTopics(deps, scheduleId).find((row) => row.id === topicId);
350
+ return topic === undefined
351
+ ? { ok: false, reason: "topic-not-found" }
352
+ : { ok: true, value: topic };
353
+ }
354
+ // Moves one queued topic to another position. Each run takes the first topic, so this is
355
+ // also what reorders the calendar.
356
+ export function moveTopic(deps, scheduleId, input) {
357
+ const parsed = topicMoveSchema.safeParse(input);
358
+ if (!parsed.success)
359
+ return { ok: false, reason: "invalid-input" };
360
+ return transact(deps.db, () => {
361
+ const open = openSchedule(deps, scheduleId);
362
+ if (!open.ok)
363
+ return open;
364
+ const schedule = open.value;
365
+ if (schedule.version !== parsed.data.baseVersion)
366
+ return { ok: false, reason: "conflict" };
367
+ const { from } = parsed.data;
368
+ if (from >= schedule.items.length)
369
+ return { ok: false, reason: "invalid-input" };
370
+ const items = [...schedule.items];
371
+ const [topic] = items.splice(from, 1);
372
+ if (topic === undefined)
373
+ return { ok: false, reason: "invalid-input" };
374
+ items.splice(Math.min(parsed.data.to, items.length), 0, topic);
375
+ writeItems(deps, scheduleId, schedule.version, items);
376
+ return { ok: true, value: scheduleById(deps.db, scheduleId) };
377
+ });
378
+ }
379
+ // Takes one queued topic off this schedule and puts it on another's queue.
380
+ export function transferTopic(deps, scheduleId, input) {
381
+ const parsed = topicTransferSchema.safeParse(input);
382
+ if (!parsed.success)
383
+ return { ok: false, reason: "invalid-input" };
384
+ if (parsed.data.targetId === scheduleId)
385
+ return { ok: false, reason: "invalid-input" };
386
+ return transact(deps.db, () => {
387
+ const source = openSchedule(deps, scheduleId);
388
+ if (!source.ok)
389
+ return source;
390
+ const target = openSchedule(deps, parsed.data.targetId);
391
+ if (!target.ok)
392
+ return target;
393
+ if (source.value.version !== parsed.data.baseVersion)
394
+ return { ok: false, reason: "conflict" };
395
+ const items = [...source.value.items];
396
+ const [topic] = items.splice(parsed.data.index, 1);
397
+ if (topic === undefined)
398
+ return { ok: false, reason: "invalid-input" };
399
+ if (target.value.items.length + 1 > queueMax)
400
+ return { ok: false, reason: "queue-full" };
401
+ const landing = [...target.value.items];
402
+ landing.splice(Math.min(parsed.data.position ?? landing.length, landing.length), 0, topic);
403
+ writeItems(deps, scheduleId, source.value.version, items);
404
+ writeItems(deps, target.value.id, target.value.version, landing);
405
+ return {
406
+ ok: true,
407
+ value: {
408
+ source: scheduleById(deps.db, scheduleId),
409
+ target: scheduleById(deps.db, target.value.id),
410
+ },
411
+ };
412
+ });
413
+ }
414
+ function writeItems(deps, scheduleId, version, items) {
415
+ deps.db
416
+ .prepare("UPDATE schedules SET items_json=?,version=version+1,updated_at=? WHERE id=? AND version=?")
417
+ .run(JSON.stringify(items), deps.clock.now().toISOString(), scheduleId, version);
418
+ }
419
+ // Boot: a generation cannot outlive the process that asked.
420
+ export function releaseTopicLeases(deps) {
421
+ deps.db.prepare("UPDATE schedules SET topics_generating_at=NULL").run();
422
+ }