@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
@@ -15,6 +15,8 @@ const rowSchema = z.object({
15
15
  overlap_policy: z.string(),
16
16
  spend_limit_cents: z.number().nullable(),
17
17
  items_json: z.string(),
18
+ topic_keyword: z.string().nullable(),
19
+ values_json: z.string(),
18
20
  status: z.string(),
19
21
  version: z.number(),
20
22
  next_run_at: z.string().nullable(),
@@ -25,7 +27,7 @@ const rowSchema = z.object({
25
27
  export function scheduleById(db, id) {
26
28
  const row = db
27
29
  .prepare(`SELECT id,name,template_id,template_version,cadence_json,timezone,missed_policy,
28
- overlap_policy,spend_limit_cents,items_json,status,version,next_run_at,created_at,updated_at,
30
+ overlap_policy,spend_limit_cents,items_json,topic_keyword,values_json,status,version,next_run_at,created_at,updated_at,
29
31
  deleted_at
30
32
  FROM schedules WHERE id=?`)
31
33
  .get(id);
@@ -34,7 +36,7 @@ export function scheduleById(db, id) {
34
36
  export function scheduleRows(db) {
35
37
  return db
36
38
  .prepare(`SELECT id,name,template_id,template_version,cadence_json,timezone,missed_policy,
37
- overlap_policy,spend_limit_cents,items_json,status,version,next_run_at,created_at,updated_at,
39
+ overlap_policy,spend_limit_cents,items_json,topic_keyword,values_json,status,version,next_run_at,created_at,updated_at,
38
40
  deleted_at
39
41
  FROM schedules ORDER BY created_at DESC,id`)
40
42
  .all()
@@ -43,7 +45,7 @@ export function scheduleRows(db) {
43
45
  export function dueSchedules(db, now) {
44
46
  return db
45
47
  .prepare(`SELECT id,name,template_id,template_version,cadence_json,timezone,missed_policy,
46
- overlap_policy,spend_limit_cents,items_json,status,version,next_run_at,created_at,updated_at,
48
+ overlap_policy,spend_limit_cents,items_json,topic_keyword,values_json,status,version,next_run_at,created_at,updated_at,
47
49
  deleted_at
48
50
  FROM schedules WHERE deleted_at IS NULL AND status='active' AND next_run_at IS NOT NULL
49
51
  AND next_run_at <= ?
@@ -54,17 +56,18 @@ export function dueSchedules(db, now) {
54
56
  export function insertSchedule(db, schedule, creationHash) {
55
57
  db.prepare(`INSERT INTO schedules
56
58
  (id,name,template_id,template_version,cadence_json,timezone,missed_policy,overlap_policy,
57
- spend_limit_cents,items_json,status,version,creation_hash,next_run_at,created_at,updated_at,
58
- deleted_at)
59
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(schedule.id, schedule.name, schedule.templateId, schedule.templateVersion, JSON.stringify(schedule.cadence), schedule.timezone, schedule.missedPolicy, schedule.overlapPolicy, schedule.spendLimitCents, JSON.stringify(schedule.items), schedule.status, schedule.version, creationHash, schedule.nextRunAt, schedule.createdAt, schedule.updatedAt, schedule.deletedAt);
59
+ spend_limit_cents,items_json,topic_keyword,values_json,status,version,creation_hash,
60
+ next_run_at,created_at,updated_at,deleted_at)
61
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(schedule.id, schedule.name, schedule.templateId, schedule.templateVersion, JSON.stringify(schedule.cadence), schedule.timezone, schedule.missedPolicy, schedule.overlapPolicy, schedule.spendLimitCents, JSON.stringify(schedule.items), schedule.topicKeyword, JSON.stringify(schedule.values), schedule.status, schedule.version, creationHash, schedule.nextRunAt, schedule.createdAt, schedule.updatedAt, schedule.deletedAt);
60
62
  }
61
63
  export function updateScheduleRow(db, schedule, mutationId, mutationHash, baseVersion) {
62
64
  const changed = db
63
65
  .prepare(`UPDATE schedules SET name=?,template_id=?,template_version=?,cadence_json=?,timezone=?,
64
- missed_policy=?,overlap_policy=?,spend_limit_cents=?,items_json=?,status=?,version=?,
66
+ missed_policy=?,overlap_policy=?,spend_limit_cents=?,items_json=?,topic_keyword=?,
67
+ values_json=?,status=?,version=?,
65
68
  next_run_at=?,updated_at=?,mutation_id=?,mutation_hash=?
66
69
  WHERE id=? AND version=? AND deleted_at IS NULL`)
67
- .run(schedule.name, schedule.templateId, schedule.templateVersion, JSON.stringify(schedule.cadence), schedule.timezone, schedule.missedPolicy, schedule.overlapPolicy, schedule.spendLimitCents, JSON.stringify(schedule.items), schedule.status, schedule.version, schedule.nextRunAt, schedule.updatedAt, mutationId, mutationHash, schedule.id, baseVersion);
70
+ .run(schedule.name, schedule.templateId, schedule.templateVersion, JSON.stringify(schedule.cadence), schedule.timezone, schedule.missedPolicy, schedule.overlapPolicy, schedule.spendLimitCents, JSON.stringify(schedule.items), schedule.topicKeyword, JSON.stringify(schedule.values), schedule.status, schedule.version, schedule.nextRunAt, schedule.updatedAt, mutationId, mutationHash, schedule.id, baseVersion);
68
71
  return Number(changed.changes) === 1;
69
72
  }
70
73
  export function tombstoneScheduleRow(db, id, version, deletedAt) {
@@ -108,8 +111,14 @@ export function recordRunStartIdentity(db, run) {
108
111
  if (Number(changed.changes) !== 1)
109
112
  throw new Error("The scheduled run could not persist its Start identity.");
110
113
  }
111
- export function recordRunDispatch(db, run, recordedAt) {
114
+ // `used` is the queued topic the run started, removed in the same transaction that records
115
+ // the run's projects. It is matched by value rather than position, so an edit saved while the
116
+ // run was starting cannot make it remove a different topic. The run that empties the queue
117
+ // completes the schedule.
118
+ export function recordRunDispatch(db, run, recordedAt, used) {
112
119
  transact(db, () => {
120
+ if (used !== undefined)
121
+ consumeTopic(db, run.scheduleId, used, recordedAt);
113
122
  const changed = db
114
123
  .prepare(`UPDATE schedule_runs SET request_id=?,project_ids_json=?,estimate_json=?
115
124
  WHERE id=? AND status='running'`)
@@ -120,6 +129,19 @@ export function recordRunDispatch(db, run, recordedAt) {
120
129
  settleScheduleRunsForProject(db, projectId, recordedAt);
121
130
  });
122
131
  }
132
+ function consumeTopic(db, scheduleId, used, updatedAt) {
133
+ const schedule = scheduleById(db, scheduleId);
134
+ if (schedule === undefined || schedule.deletedAt !== null)
135
+ return;
136
+ const key = JSON.stringify(used);
137
+ const at = schedule.items.findIndex((item) => JSON.stringify(item) === key);
138
+ if (at === -1)
139
+ return;
140
+ const items = schedule.items.filter((_item, index) => index !== at);
141
+ const done = items.length === 0 && (schedule.status === "active" || schedule.status === "paused");
142
+ db.prepare(`UPDATE schedules SET items_json=?,status=?,next_run_at=?,version=version+1,updated_at=?
143
+ WHERE id=? AND deleted_at IS NULL`).run(JSON.stringify(items), done ? "completed" : schedule.status, done ? null : schedule.nextRunAt, updatedAt, scheduleId);
144
+ }
123
145
  export function runsForSchedule(db, scheduleId) {
124
146
  return db
125
147
  .prepare(`SELECT id,schedule_id,scheduled_for,status,request_id,project_ids_json,estimate_json,
@@ -200,7 +222,7 @@ function projectIsActive(db, projectId) {
200
222
  const state = derive(stagesOf(db, projectId), projectPaused(db, projectId));
201
223
  return state !== "done" && state !== "failed" && state !== "canceled";
202
224
  }
203
- export function recoverRunningRuns(db, endedAt, error = "The app stopped while this scheduled run was active.") {
225
+ export function recoverRunningRuns(db, endedAt, error = "Slopify was closed while this run was in progress. Check its video in Projects; the next run will go ahead as planned.") {
204
226
  return transact(db, () => {
205
227
  const pending = db
206
228
  .prepare(`SELECT id,request_id FROM schedule_runs
@@ -243,6 +265,8 @@ function parseSchedule(row) {
243
265
  overlapPolicy: value.overlap_policy,
244
266
  spendLimitCents: value.spend_limit_cents,
245
267
  items: JSON.parse(value.items_json),
268
+ topicKeyword: value.topic_keyword,
269
+ values: JSON.parse(value.values_json),
246
270
  status: value.status,
247
271
  version: value.version,
248
272
  nextRunAt: value.next_run_at,
@@ -276,6 +300,26 @@ function parseRun(row) {
276
300
  estimate: value.estimate_json === null ? null : JSON.parse(value.estimate_json),
277
301
  startedAt: value.started_at,
278
302
  endedAt: value.ended_at,
279
- error: value.error,
303
+ error: value.error === null ? null : runErrorText(value.error),
280
304
  });
281
305
  }
306
+ // A failed dispatch records the refusal's reason code; the run history shows the sentence.
307
+ // Mapped on read so runs recorded before these sentences existed read the same way.
308
+ const internalRunError = "Slopify hit an internal error while starting this run. The next run will try again; if it keeps happening, use Download diagnostics in Settings and report it.";
309
+ const runErrors = {
310
+ "missing-template": "The template this schedule uses was deleted or changed. Edit the schedule and choose a template again.",
311
+ "unsupported-media": "This template uses audio, images or a thumbnail you supplied, which scheduled runs cannot use. Pick a template that generates these instead.",
312
+ "spend-limit": "Not started: the estimated cost was above this schedule's spend limit, or some prices were unknown. Raise the spend limit or choose models with known prices.",
313
+ readiness: "Not started: a provider was not ready, for example a missing API key, model or voice. Check Settings → Providers; the next run will try again.",
314
+ "not-found": "The template's settings are no longer valid, for example a prompt or voice it uses was deleted. Fix the template in Library > Templates and save it.",
315
+ "invalid-draft": "The template's settings are no longer valid, for example a prompt or voice it uses was deleted. Fix the template in Library > Templates and save it.",
316
+ "invalid-edit": "The template's settings are no longer valid, for example a prompt or voice it uses was deleted. Fix the template in Library > Templates and save it.",
317
+ conflict: internalRunError,
318
+ "pending-start": internalRunError,
319
+ "already-started": internalRunError,
320
+ "stale-review": internalRunError,
321
+ "The scheduled run failed.": internalRunError,
322
+ };
323
+ function runErrorText(error) {
324
+ return Object.hasOwn(runErrors, error) ? (runErrors[error] ?? error) : error;
325
+ }
@@ -1,4 +1,5 @@
1
1
  import { transact } from "../../kernel/db/tx.js";
2
+ import { render } from "../admission/substitute.js";
2
3
  import { reviewDraft } from "../play-drafts/review.js";
3
4
  import { createDraft } from "../play-drafts/service.js";
4
5
  import { startPlayDraft } from "../play-drafts/start.js";
@@ -59,8 +60,8 @@ function claim(deps, schedule, now) {
59
60
  endedAt: skip ? now.toISOString() : null,
60
61
  error: skip
61
62
  ? overlapping
62
- ? "Skipped because another occurrence is still running."
63
- : "Skipped because the app missed this occurrence."
63
+ ? "Skipped because the previous run of this schedule was still going."
64
+ : "Skipped because Slopify was not running at the scheduled time."
64
65
  : null,
65
66
  };
66
67
  if (!insertRun(deps.db, run))
@@ -81,13 +82,11 @@ async function execute(deps, claimed) {
81
82
  id: claimed.schedule.templateId,
82
83
  version: claimed.schedule.templateVersion,
83
84
  });
85
+ const topic = claimed.schedule.items[0];
84
86
  const fresh = {
85
87
  ...document,
86
- variants: claimed.schedule.items.map((item) => ({
87
- id: deps.uuid(),
88
- title: item.title,
89
- values: item.values,
90
- })),
88
+ form: runForm(document.form, claimed.schedule, topic),
89
+ variants: [],
91
90
  };
92
91
  const draft = createDraft(deps, { id: deps.uuid(), document: fresh });
93
92
  if (!draft.ok)
@@ -118,7 +117,7 @@ async function execute(deps, claimed) {
118
117
  requestId: started.value.requestId,
119
118
  projectIds: started.value.projectIds,
120
119
  };
121
- recordRunDispatch(deps.db, run, deps.clock.now().toISOString());
120
+ recordRunDispatch(deps.db, run, deps.clock.now().toISOString(), topic);
122
121
  run = {
123
122
  ...run,
124
123
  status: "succeeded",
@@ -135,6 +134,24 @@ async function execute(deps, claimed) {
135
134
  }
136
135
  updateRun(deps.db, run);
137
136
  }
137
+ // One project per run. The first queued topic fills the chosen keyword, the schedule's fixed
138
+ // values fill the rest over the template's own, and the template's title is filled from the
139
+ // same keywords, so "D&D Lore: {{Topic}}" names the project after the topic. A topic saved
140
+ // before topics had a keyword brings its own title and values instead.
141
+ function runForm(form, schedule, topic) {
142
+ const values = {
143
+ ...form.values,
144
+ ...schedule.values,
145
+ ...(topic?.values ?? {}),
146
+ ...(topic !== undefined && schedule.topicKeyword !== null
147
+ ? { [schedule.topicKeyword]: topic.title }
148
+ : {}),
149
+ };
150
+ const title = topic !== undefined && schedule.topicKeyword === null
151
+ ? topic.title
152
+ : render(form.title, values);
153
+ return { ...form, title, values };
154
+ }
138
155
  class ScheduleDispatchError extends Error {
139
156
  reason;
140
157
  constructor(reason) {
@@ -5,15 +5,20 @@ const keywordName = z
5
5
  .string()
6
6
  .min(1)
7
7
  .max(200)
8
- .refine((value) => value === value.trim(), "Keyword names cannot start or end with spaces.");
8
+ .refine((value) => value === value.trim(), "Remove the spaces at the start or end of the keyword name.");
9
+ const values = z.record(keywordName, z.string().max(10000)).readonly();
10
+ // A queued topic. Each run takes the first one and removes it once its project starts.
11
+ // `values` is only filled on schedules saved before topics had one keyword.
9
12
  const item = z
10
13
  .object({
11
14
  title: z.string().trim().min(1).max(200),
12
- values: z.record(keywordName, z.string().max(10000)).readonly(),
15
+ values,
13
16
  })
14
17
  .strict()
15
18
  .readonly();
16
19
  const policy = z.enum(["skip", "run-once"]);
20
+ // Enough for a year of daily runs, pasted as one list.
21
+ export const queueMax = 500;
17
22
  export const scheduleCreateSchema = z
18
23
  .object({
19
24
  id,
@@ -21,11 +26,19 @@ export const scheduleCreateSchema = z
21
26
  templateId: id,
22
27
  templateVersion: z.number().int().positive(),
23
28
  cadence: cadenceSchema,
24
- timezone: z.string().min(1).max(100).refine(validTimeZone, "Choose a valid IANA timezone."),
29
+ timezone: z
30
+ .string()
31
+ .min(1)
32
+ .max(100)
33
+ .refine(validTimeZone, "Choose a time zone from the list, such as Europe/London."),
25
34
  missedPolicy: policy.default("skip"),
26
35
  overlapPolicy: z.literal("skip").default("skip"),
27
36
  spendLimitCents: z.number().int().nonnegative().nullable().default(null),
28
- items: z.array(item).max(49).default([]).readonly(),
37
+ items: z.array(item).max(queueMax).default([]).readonly(),
38
+ // The template keyword each queued topic fills, such as "Topic".
39
+ topicKeyword: keywordName.nullable().default(null),
40
+ // Keywords every run uses as they are, such as the word counts.
41
+ values: values.default({}),
29
42
  })
30
43
  .strict()
31
44
  .readonly();
@@ -54,6 +67,8 @@ export const scheduleSummarySchema = z
54
67
  overlapPolicy: z.literal("skip"),
55
68
  spendLimitCents: z.number().int().nonnegative().nullable(),
56
69
  items: z.array(item).readonly(),
70
+ topicKeyword: z.string().nullable().default(null),
71
+ values: values.default({}),
57
72
  status: z.enum(["active", "paused", "completed", "canceled"]),
58
73
  version: z.number().int().positive(),
59
74
  nextRunAt: z.string().datetime({ offset: true }).nullable(),
@@ -168,6 +168,8 @@ function summary(input, nextRunAt, now, status = "active", version = 1) {
168
168
  overlapPolicy: input.overlapPolicy,
169
169
  spendLimitCents: input.spendLimitCents,
170
170
  items: input.items,
171
+ topicKeyword: input.topicKeyword,
172
+ values: input.values,
171
173
  status,
172
174
  version,
173
175
  nextRunAt,
@@ -37,7 +37,7 @@ export async function saveCliPath(deps, id, raw) {
37
37
  if (deps.hostCliStatus)
38
38
  return {
39
39
  ok: false,
40
- message: "CLI paths are managed on the host. Rerun the Docker launcher after changing installations.",
40
+ message: "Slopify runs in Docker, so it finds these tools on your computer (the Docker host) by itself. If you installed or moved one, run the Slopify Docker launcher again.",
41
41
  };
42
42
  // A slow version probe must not overwrite a later Save or Reset from another tab.
43
43
  let queue = saves.get(deps.db);
@@ -66,13 +66,16 @@ async function save(deps, id, raw) {
66
66
  if (!parsed.success)
67
67
  return {
68
68
  ok: false,
69
- message: "Enter an absolute executable file path without quotes or arguments, or leave it blank to use PATH.",
69
+ message: "Enter the full path to the program file, without quotes or extra options, or leave it blank to let Slopify find it.",
70
70
  };
71
71
  const configured = parsed.data === "" ? null : parsed.data;
72
72
  if (configured !== null) {
73
73
  try {
74
74
  if (!(await stat(configured)).isFile())
75
- return { ok: false, message: "Choose an executable file, not a directory." };
75
+ return {
76
+ ok: false,
77
+ message: "This path is a folder. Enter the path to the program file inside it.",
78
+ };
76
79
  await access(configured, process.platform === "win32" ||
77
80
  [".js", ".mjs", ".cjs"].includes(extname(configured).toLowerCase())
78
81
  ? constants.R_OK
@@ -84,7 +87,7 @@ async function save(deps, id, raw) {
84
87
  ["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP"].includes(String(error.code)))
85
88
  return {
86
89
  ok: false,
87
- message: "That executable file cannot be found or run. Check its path and permissions.",
90
+ message: "Slopify cannot find or run a program at this path. Check the path is correct and the file is allowed to run.",
88
91
  };
89
92
  throw error;
90
93
  }
@@ -42,11 +42,11 @@ export function createModelCatalog(deps) {
42
42
  allowsCustom: allowsCustomModel(provider),
43
43
  warning: local
44
44
  ? provider === "codex-image"
45
- ? "Could not check the Codex image capability. Refresh after checking the CLI."
46
- : "Could not discover models from this CLI. Enter an exact model ID manually or refresh."
45
+ ? "Slopify could not check whether Codex CLI can make images. Make sure Codex CLI is installed and signed in, then refresh the list."
46
+ : "Slopify could not get the model list from this command-line tool. Check it is installed and signed in, then refresh the list, or type an exact model ID manually."
47
47
  : saved === undefined
48
- ? "Could not load the model list. Showing bundled choices; refresh to try again."
49
- : "Could not refresh the model list. Showing the last loaded choices.",
48
+ ? "Slopify could not load this provider's model list, so it is showing its built-in choices. Check your internet connection and API key, then refresh the list."
49
+ : "Slopify could not update this provider's model list, so it is showing the last loaded list. Check your internet connection and API key, then refresh again.",
50
50
  };
51
51
  lifetime = retryMs;
52
52
  }
@@ -19,11 +19,11 @@ export function saveSettings(deps, settings) {
19
19
  if (!silenceGapValue.safeParse(settings.silenceGapSeconds).success) {
20
20
  fields.push({
21
21
  field: "silenceGapSeconds",
22
- message: `The silence gap is a whole number of seconds between 0 and ${String(silenceGapSecondsMax)}.`,
22
+ message: `Enter a whole number of seconds between 0 and ${String(silenceGapSecondsMax)}.`,
23
23
  });
24
24
  }
25
25
  if (!appearanceValue.safeParse(settings.appearance).success) {
26
- fields.push({ field: "appearance", message: "Pick System, Light or Dark." });
26
+ fields.push({ field: "appearance", message: "Choose System, Light or Dark." });
27
27
  }
28
28
  if (fields.length > 0) {
29
29
  return { ok: false, fields };
@@ -10,7 +10,7 @@ export function allocateAsset(deps, projectId, filename) {
10
10
  const absolutePath = outputPath(deps.paths, projectId, path);
11
11
  const directory = outputPath(deps.paths, projectId, `assets/${id}`);
12
12
  if (dirname(absolutePath) !== directory)
13
- throw new Error("Asset filenames must be basenames.");
13
+ throw new Error("Slopify hit an internal error (a file name to save is not a plain file name). Try again; if it happens again, use Download diagnostics in Settings and report it.");
14
14
  mkdirSync(outputPath(deps.paths, projectId, "assets"), { recursive: true, mode: 0o700 });
15
15
  mkdirSync(directory, { mode: 0o700 });
16
16
  return { id, projectId, path, absolutePath };
@@ -20,7 +20,7 @@ export function sealAsset(deps, pending) {
20
20
  try {
21
21
  const stats = fstatSync(file);
22
22
  if (!stats.isFile())
23
- throw new Error("An asset must be a regular file.");
23
+ throw new Error("Slopify hit an internal error (a saved file is not a regular file). Try again; if it happens again, use Download diagnostics in Settings and report it.");
24
24
  fchmodSync(file, 0o600);
25
25
  fsyncSync(file);
26
26
  return {
@@ -58,7 +58,7 @@ export function discardPreparedAssets(deps, assets) {
58
58
  const filename = basename(asset.path);
59
59
  requireBasename(filename);
60
60
  if (asset.path !== `assets/${asset.id}/${filename}`) {
61
- throw new Error("A prepared asset must belong to its allocated directory.");
61
+ throw new Error("Slopify hit an internal error (a prepared file is not in its allocated directory). Try again; if it happens again, use Download diagnostics in Settings and report it.");
62
62
  }
63
63
  const directory = outputPath(deps.paths, asset.projectId, `assets/${asset.id}`);
64
64
  const registered = deps.db
@@ -68,7 +68,7 @@ export function discardPreparedAssets(deps, assets) {
68
68
  if (row.id === asset.id)
69
69
  return true;
70
70
  if (typeof row.path !== "string")
71
- throw new Error("A registered asset must name a path.");
71
+ throw new Error("Slopify hit an internal error (a saved file has no path). Try again; if it happens again, use Download diagnostics in Settings and report it.");
72
72
  const inside = relative(directory, outputPath(deps.paths, asset.projectId, row.path));
73
73
  return (inside === "" ||
74
74
  (inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside)));
@@ -92,7 +92,7 @@ function requireBasename(filename) {
92
92
  }) ||
93
93
  /[. ]$/.test(filename) ||
94
94
  /^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(filename))
95
- throw new Error("Asset filenames must be portable basenames.");
95
+ throw new Error("Slopify hit an internal error (a file name to save is not allowed on every system). Try again; if it happens again, use Download diagnostics in Settings and report it.");
96
96
  }
97
97
  function messageOf(error) {
98
98
  return error instanceof Error ? error.message : String(error);
@@ -19,6 +19,7 @@ const contentTypes = {
19
19
  ".ogg": "audio/ogg",
20
20
  ".opus": "audio/opus",
21
21
  ".mp4": "video/mp4",
22
+ ".pdf": "application/pdf",
22
23
  ".srt": "application/x-subrip; charset=utf-8",
23
24
  ".vtt": "text/vtt; charset=utf-8",
24
25
  };
@@ -14,7 +14,7 @@ function contained(root, path) {
14
14
  const target = resolve(root, path);
15
15
  const inside = relative(root, target);
16
16
  if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
17
- throw new Error(`${path} resolves outside ${root}`);
17
+ throw new Error(`Slopify hit an internal error (the saved file path ${path} resolves outside ${root}). Try again; if it happens again, use Download diagnostics in Settings and report it.`);
18
18
  }
19
19
  return target;
20
20
  }
@@ -54,6 +54,12 @@ export function outputFileName(role, index, extension, stageKind) {
54
54
  return `instructions-${stageKind}.txt`;
55
55
  case "video":
56
56
  return "video.mp4";
57
+ case "document_pdf":
58
+ return "document.pdf";
59
+ case "youtube_description":
60
+ return "description.txt";
61
+ case "youtube_tags":
62
+ return "tags.txt";
57
63
  case "audio_body":
58
64
  return `audio-body${extension}`;
59
65
  case "audio_export":
@@ -24,5 +24,8 @@ export const outputRoles = [
24
24
  "subtitle_ass",
25
25
  "subtitle_font",
26
26
  "instructions",
27
+ "document_pdf",
28
+ "youtube_description",
29
+ "youtube_tags",
27
30
  ];
28
31
  export const stagedFileStates = ["copying", "staged"];
@@ -21,7 +21,7 @@ export function captionCues(words) {
21
21
  !Number.isFinite(word.end) ||
22
22
  word.start < previousEnd - 0.001 ||
23
23
  word.end <= word.start) {
24
- throw new Error("The subtitle word timing is invalid; regenerate alignment.");
24
+ throw new Error("The saved caption timing is damaged. Use Re-run section on Video to time them again, then Retry stage.");
25
25
  }
26
26
  const first = pending[0];
27
27
  if (first !== undefined &&
@@ -7,6 +7,7 @@ import { projectById, setStageProgress } from "../admission/repo.js";
7
7
  import { resolveFont } from "../fonts/index.js";
8
8
  import { outputPath, projectDir } from "../storage/layout.js";
9
9
  import { outputsOf } from "../storage/repo.js";
10
+ import { spoken } from "../video/plan.js";
10
11
  import { captionCues, serializeAss, serializeSrt, serializeVtt } from "./captions.js";
11
12
  import { defaultSubtitles } from "./model.js";
12
13
  import { spokenText } from "./transcript.js";
@@ -43,7 +44,7 @@ export async function prepareSubtitles(deps, context, audio, frame) {
43
44
  const { projectId } = context.stage;
44
45
  const project = projectById(deps.db, projectId);
45
46
  if (project === undefined)
46
- throw new Error("The project no longer exists.");
47
+ throw new Error("This project no longer exists; it may have been deleted while it was running.");
47
48
  const config = project.config.subtitles ?? defaultSubtitles;
48
49
  if (config.mode === "off")
49
50
  return undefined;
@@ -51,11 +52,11 @@ export async function prepareSubtitles(deps, context, audio, frame) {
51
52
  setStageProgress(deps.db, context.stage.id, 0, 100);
52
53
  context.emit({ type: "stage.progress", projectId, stage: "video", current: 0, total: 100 });
53
54
  if (audio.length === 0)
54
- throw new Error("Subtitles need narration audio.");
55
+ throw new Error("Captions need narration audio, but this project has none. Turn captions off in Edit project → Subtitles, or turn narration on, then Retry stage.");
55
56
  const outputs = outputsOf(deps.db, projectId);
56
57
  const segments = audio.map((segment) => ({
57
58
  ...segment,
58
- text: segment.kind === "gap" ? "" : spokenText(deps, projectId, segment.kind, outputs),
59
+ text: spoken(segment.kind) ? spokenText(deps, projectId, segment.kind, outputs) : "",
59
60
  }));
60
61
  const key = await timingKey(segments, context.signal);
61
62
  const cache = readCache(deps, projectId, outputs);
@@ -68,7 +69,7 @@ export async function prepareSubtitles(deps, context, audio, frame) {
68
69
  context.signal.throwIfAborted();
69
70
  const cues = captionCues(words);
70
71
  if (cues.length === 0)
71
- throw new Error("No spoken words could be aligned. Check that the English article matches the audio.");
72
+ throw new Error("None of the narration could be matched to the article text, so captions can't be timed. Captions only work for English narration; if you uploaded your own audio, make sure it reads the article text, then Retry stage.");
72
73
  writeFileSync(join(directory, "subtitles.srt"), serializeSrt(cues), { mode: 0o600 });
73
74
  writeFileSync(join(directory, "subtitles.vtt"), serializeVtt(cues), { mode: 0o600 });
74
75
  writeFileSync(join(directory, "subtitles.ass"), serializeAss(cues, {
@@ -118,7 +119,7 @@ async function timingKey(segments, signal) {
118
119
  }
119
120
  async function alignSegments(deps, context, segments, omissions) {
120
121
  if (deps.alignSubtitles === undefined)
121
- throw new Error("Local subtitle alignment is unavailable in this build.");
122
+ throw new Error("Slopify hit an internal error (the caption timing tool is missing from this build). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
122
123
  const words = [];
123
124
  const total = segments.reduce((sum, segment) => sum + segment.seconds, 0);
124
125
  let offset = 0;
@@ -146,7 +147,7 @@ async function alignSegments(deps, context, segments, omissions) {
146
147
  });
147
148
  for (const word of aligned) {
148
149
  if (word.end > segment.seconds + 0.1)
149
- throw new Error("Subtitle timing exceeds the narration duration.");
150
+ throw new Error("Caption timing came out longer than the narration audio. Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
150
151
  words.push({
151
152
  ...word,
152
153
  start: word.start + offset,
@@ -27,7 +27,7 @@ export function spokenText(deps, projectId, kind, outputs) {
27
27
  return segment.text;
28
28
  }
29
29
  }
30
- throw new Error(`The saved ${kind} narration text is missing. Subtitles need the text spoken in the audio.`);
30
+ throw new Error(`The saved text of the ${kind === "body" ? "article" : kind} is missing, and captions need the text spoken in the narration. Use Re-run section on Article, then Retry stage.`);
31
31
  }
32
32
  function payload(raw) {
33
33
  return textPayload.parse(JSON.parse(raw ?? "null"));
@@ -3,7 +3,7 @@ import { stageKinds } from "../../kernel/pipeline.js";
3
3
  // One install event, one per project created, one per stage completing.
4
4
  export const telemetryEventTypes = ["install", "project.created", "stage.completed"];
5
5
  // Counting is finer than a stage: one event per research, article, intro/outro text, audio
6
- // segment (body, intro, outro), images, thumbnail and video. The three that are not whole
6
+ // segment (body, intro, outro), images, thumbnail, video and document. The three that are not whole
7
7
  // stages are named by `segment` beside the stage that produced them - the article stage
8
8
  // writes the intro and outro texts, the audio stage narrates all three - so the event type
9
9
  // stays `stage.completed` and the pair (stage, segment) says which unit it is.
@@ -16,19 +16,19 @@ export async function runThumbnail(deps, context, providers) {
16
16
  const { projectId } = context.stage;
17
17
  const project = projectById(deps.db, projectId);
18
18
  if (project === undefined) {
19
- throw new Error(`project ${projectId} has no row`);
19
+ throw new Error("This project no longer exists; it may have been deleted while it was running.");
20
20
  }
21
21
  const source = project.config.sources.thumbnail;
22
22
  if (source !== "from_prompt" && source !== "prompt_by_llm") {
23
23
  // Off is `skipped` and Provide is `provided` at project creation,
24
24
  // so the runner never starts this stage for either.
25
- throw new Error(`the thumbnail stage cannot run with its source set to ${source}`);
25
+ throw new Error(`Slopify hit an internal error (the thumbnail stage cannot run with its source set to ${source}). Retry stage; if it happens again, use Download diagnostics in Settings and report it.`);
26
26
  }
27
27
  const choice = project.config.images;
28
28
  if (choice === undefined) {
29
29
  // Admission requires the image provider whenever the thumbnail is generated, so reaching
30
30
  // 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 the thumbnail. Choose one in Edit project → Providers, then Retry stage.");
32
32
  }
33
33
  const result = source === "from_prompt" ? undefined : await byLlm(deps, context, providers, project);
34
34
  if (result !== undefined && !result.ok)
@@ -60,7 +60,7 @@ export async function runThumbnail(deps, context, providers) {
60
60
  function fromTemplate(project) {
61
61
  const rendered = project.config.rendered.thumbnailPrompt;
62
62
  if (rendered === undefined) {
63
- throw new Error("the run has no rendered thumbnail prompt");
63
+ throw new Error("Slopify hit an internal error (the thumbnail prompt was never filled in). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
64
64
  }
65
65
  return rendered;
66
66
  }
@@ -83,7 +83,7 @@ async function byLlm(deps, context, providers, project) {
83
83
  }
84
84
  if (llm === undefined) {
85
85
  // Admission requires the LLM row when the thumbnail source is Prompt by LLM.
86
- throw new Error("the run has no LLM provider or model");
86
+ throw new Error("No AI model is set for writing the thumbnail prompt. Choose one in Edit project → Providers, then Retry stage.");
87
87
  }
88
88
  const messages = thumbnailMessages(brief(deps, project));
89
89
  const answer = await providers.llm({
@@ -117,7 +117,7 @@ function named(choice) {
117
117
  function brief(deps, project) {
118
118
  const instruction = project.config.rendered.thumbnailPrompt;
119
119
  if (instruction === undefined) {
120
- throw new Error("the run has no rendered thumbnail prompt");
120
+ throw new Error("Slopify hit an internal error (the thumbnail prompt was never filled in). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
121
121
  }
122
122
  return {
123
123
  instruction,
@@ -133,7 +133,7 @@ function brief(deps, project) {
133
133
  function articleText(deps, projectId) {
134
134
  const article = outputsOf(deps.db, projectId).find((output) => output.role === "article_txt");
135
135
  if (article === undefined) {
136
- throw new Error("the project has no article for the thumbnail prompt to be written from");
136
+ throw new Error("The thumbnail prompt is written from the article, but the article isn't ready. Let the Article stage finish, then Retry stage.");
137
137
  }
138
138
  return readFileSync(outputPath(deps.paths, projectId, article.path), "utf8");
139
139
  }
@@ -212,7 +212,7 @@ function instructionsText(messages) {
212
212
  }
213
213
  function payloadOf(piece) {
214
214
  if (piece.payload === null) {
215
- throw new Error(`the written thumbnail prompt ${piece.id} has no payload`);
215
+ throw new Error("Slopify hit an internal error (the saved thumbnail prompt is empty). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
216
216
  }
217
217
  return writtenPayload.parse(JSON.parse(piece.payload));
218
218
  }
@@ -6,17 +6,18 @@ import { outputsOf } from "../storage/repo.js";
6
6
  import { prepareSubtitles } from "../subtitles/prepare.js";
7
7
  import { audioExportArgs } from "./audio-export-args.js";
8
8
  import { audioInputs } from "./audio-inputs.js";
9
+ import { runFfmpeg } from "./ffmpeg.js";
9
10
  import { audioTimeline } from "./plan.js";
10
11
  import { reusableAudioExport } from "./reuse-audio.js";
11
12
  import { writeExport } from "./write-export.js";
12
13
  import { writeSubtitles } from "./write-subtitles.js";
13
14
  export { audioExportArgs } from "./audio-export-args.js";
14
15
  const rate = 48000;
15
- export async function exportAudioWav(deps, context, gapSeconds) {
16
+ export async function exportAudioWav(deps, context, gapSeconds, edgeSeconds) {
16
17
  const { projectId } = context.stage;
17
18
  const outputs = outputsOf(deps.db, projectId);
18
19
  const input = await audioInputs(deps, projectId, outputs, context.signal);
19
- const audio = audioTimeline({ ...input, gapSeconds }, 1 / rate);
20
+ const audio = audioTimeline({ ...input, gapSeconds, edgeSeconds }, 1 / rate);
20
21
  const totalSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
21
22
  const dir = projectDir(deps.paths, projectId);
22
23
  const sources = outputs.filter((output) => ["audio_body", "audio_intro", "audio_outro"].includes(output.role));
@@ -25,6 +26,7 @@ export async function exportAudioWav(deps, context, gapSeconds) {
25
26
  channels: 2,
26
27
  codec: "pcm_s16le",
27
28
  gapSeconds,
29
+ edgeSeconds,
28
30
  totalSeconds,
29
31
  audio: audio.map((segment) => ({
30
32
  ...segment,
@@ -52,6 +54,12 @@ export async function exportAudioWav(deps, context, gapSeconds) {
52
54
  totalSeconds,
53
55
  record: plan,
54
56
  subtitles,
55
- args: (part) => audioExportArgs(audio, part),
57
+ render: (part, onProgress) => runFfmpeg({
58
+ bin: deps.ffmpeg,
59
+ args: audioExportArgs(audio, part),
60
+ signal: context.signal,
61
+ log: deps.log,
62
+ onProgress,
63
+ }),
56
64
  });
57
65
  }