@gentbajko/slopify 2.0.2 → 2.0.3

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 (155) hide show
  1. package/dist/adapters/alignment/protocol.js +11 -1
  2. package/dist/adapters/alignment/runner.js +4 -1
  3. package/dist/adapters/alignment/worker.js +28 -5
  4. package/dist/adapters/explain.js +93 -0
  5. package/dist/adapters/ffmpeg.js +3 -3
  6. package/dist/adapters/host-cli/index.js +9 -2
  7. package/dist/adapters/host-cli/transport.js +4 -4
  8. package/dist/adapters/image/bytes.js +2 -2
  9. package/dist/adapters/image/codex-output.js +12 -12
  10. package/dist/adapters/image/codex.js +24 -8
  11. package/dist/adapters/image/fal.js +12 -6
  12. package/dist/adapters/image/google.js +18 -8
  13. package/dist/adapters/image/models.js +8 -2
  14. package/dist/adapters/image/openai.js +15 -6
  15. package/dist/adapters/image/replicate.js +16 -7
  16. package/dist/adapters/llm/claude-code-models.js +1 -1
  17. package/dist/adapters/llm/claude-code.js +16 -7
  18. package/dist/adapters/llm/cli-login-error.js +2 -1
  19. package/dist/adapters/llm/codex-models.js +1 -1
  20. package/dist/adapters/llm/codex.js +5 -6
  21. package/dist/adapters/llm/document-workspace.js +2 -2
  22. package/dist/adapters/llm/gemini-models.js +1 -1
  23. package/dist/adapters/llm/gemini-workspace.js +1 -1
  24. package/dist/adapters/llm/gemini.js +15 -11
  25. package/dist/adapters/llm/openrouter.js +21 -9
  26. package/dist/adapters/llm/run-cli.js +14 -11
  27. package/dist/adapters/tts/cartesia.js +10 -3
  28. package/dist/adapters/tts/elevenlabs.js +18 -6
  29. package/dist/adapters/tts/inworld-async.js +47 -16
  30. package/dist/adapters/tts/inworld.js +25 -15
  31. package/dist/adapters/tts/openai.js +18 -6
  32. package/dist/catalog/registry.js +13 -10
  33. package/dist/catalog/store.js +1 -1
  34. package/dist/catalog/validate.js +13 -4
  35. package/dist/edge/cli.js +33 -7
  36. package/dist/edge/docker-launch.js +6 -6
  37. package/dist/edge/docker-projects/engine.js +34 -13
  38. package/dist/edge/docker-projects/install.js +8 -8
  39. package/dist/edge/docker-projects/state.js +6 -6
  40. package/dist/edge/docker-projects/tree.js +6 -6
  41. package/dist/edge/docker.js +13 -13
  42. package/dist/edge/http/actions.js +27 -25
  43. package/dist/edge/http/app.js +3 -3
  44. package/dist/edge/http/audio-preview.js +1 -1
  45. package/dist/edge/http/checkpoints.js +5 -1
  46. package/dist/edge/http/diagnostics.js +3 -1
  47. package/dist/edge/http/draft-files.js +2 -2
  48. package/dist/edge/http/draft-problem.js +11 -1
  49. package/dist/edge/http/files.js +4 -4
  50. package/dist/edge/http/folder-location.js +3 -3
  51. package/dist/edge/http/fonts.js +22 -10
  52. package/dist/edge/http/host-cli.js +34 -12
  53. package/dist/edge/http/multipart.js +7 -5
  54. package/dist/edge/http/open-folder.js +2 -2
  55. package/dist/edge/http/planning.js +4 -4
  56. package/dist/edge/http/problem.js +2 -2
  57. package/dist/edge/http/project-templates.js +7 -3
  58. package/dist/edge/http/projects.js +8 -8
  59. package/dist/edge/http/prompts.js +9 -4
  60. package/dist/edge/http/providers.js +15 -7
  61. package/dist/edge/http/revision-files.js +3 -3
  62. package/dist/edge/http/revisions.js +30 -4
  63. package/dist/edge/http/schedules.js +9 -9
  64. package/dist/edge/http/settings.js +15 -9
  65. package/dist/edge/http/staging.js +5 -5
  66. package/dist/edge/http/storage.js +4 -4
  67. package/dist/edge/http/subtitles.js +1 -1
  68. package/dist/edge/http/tutorial.js +2 -2
  69. package/dist/edge/http/update.js +3 -3
  70. package/dist/host-cli/install.js +28 -6
  71. package/dist/host-cli/paths.js +6 -6
  72. package/dist/host-cli/server.js +1 -1
  73. package/dist/host-cli/service.js +11 -11
  74. package/dist/host-cli/status.js +7 -3
  75. package/dist/kernel/config/index.js +6 -4
  76. package/dist/kernel/db/migrate.js +2 -1
  77. package/dist/kernel/db/migrations/0013-schedule-topic-queue.sql +4 -0
  78. package/dist/kernel/lock.js +4 -3
  79. package/dist/kernel/ports/subtitles.js +15 -1
  80. package/dist/kernel/runner/attempt.js +26 -2
  81. package/dist/main.js +2 -2
  82. package/dist/slices/admission/rules.js +32 -20
  83. package/dist/slices/article/continuation.js +4 -2
  84. package/dist/slices/article/plain.js +2 -2
  85. package/dist/slices/article/provided-entries.js +3 -3
  86. package/dist/slices/article/run.js +2 -2
  87. package/dist/slices/article/segments.js +5 -3
  88. package/dist/slices/control/providers.js +20 -7
  89. package/dist/slices/fonts/catalog.js +1 -1
  90. package/dist/slices/fonts/upload.js +1 -1
  91. package/dist/slices/images/run.js +5 -5
  92. package/dist/slices/library/lint.js +13 -7
  93. package/dist/slices/library/slots.js +2 -2
  94. package/dist/slices/narration/concat.js +1 -1
  95. package/dist/slices/narration/pronunciation-chunks.js +3 -3
  96. package/dist/slices/narration/pronunciation.js +2 -2
  97. package/dist/slices/narration/run.js +7 -7
  98. package/dist/slices/narration/steering.js +3 -3
  99. package/dist/slices/play-drafts/convert.js +5 -4
  100. package/dist/slices/play-drafts/readiness.js +12 -6
  101. package/dist/slices/play-drafts/review-inputs.js +7 -4
  102. package/dist/slices/play-drafts/review.js +3 -3
  103. package/dist/slices/play-drafts/service.js +3 -1
  104. package/dist/slices/play-drafts/uploads.js +1 -1
  105. package/dist/slices/rebuild/admission-repo.js +2 -2
  106. package/dist/slices/rebuild/recipe-legacy.js +1 -1
  107. package/dist/slices/rebuild/recipe-narration-text.js +1 -1
  108. package/dist/slices/rebuild/recipe-visual.js +1 -1
  109. package/dist/slices/rebuild/recovery.js +2 -2
  110. package/dist/slices/rebuild/repo.js +3 -3
  111. package/dist/slices/rebuild/runtime-admission.js +4 -4
  112. package/dist/slices/rebuild/runtime-article.js +5 -5
  113. package/dist/slices/rebuild/runtime-checkpoints.js +4 -4
  114. package/dist/slices/rebuild/runtime-export-inputs.js +6 -6
  115. package/dist/slices/rebuild/runtime-export.js +4 -4
  116. package/dist/slices/rebuild/runtime-local.js +6 -6
  117. package/dist/slices/rebuild/runtime-narration-text.js +46 -7
  118. package/dist/slices/rebuild/runtime-piece-label.js +62 -0
  119. package/dist/slices/rebuild/runtime-provider.js +6 -4
  120. package/dist/slices/rebuild/runtime-publication.js +3 -3
  121. package/dist/slices/rebuild/runtime-run.js +11 -1
  122. package/dist/slices/rebuild/runtime-subtitles.js +78 -15
  123. package/dist/slices/rebuild/transition-repo.js +6 -6
  124. package/dist/slices/reruns/index.js +1 -1
  125. package/dist/slices/research/run.js +9 -7
  126. package/dist/slices/research/synthesis.js +4 -2
  127. package/dist/slices/revisions/mutation-assets.js +26 -8
  128. package/dist/slices/revisions/mutation-cues.js +1 -1
  129. package/dist/slices/revisions/rules.js +18 -9
  130. package/dist/slices/revisions/schema.js +1 -1
  131. package/dist/slices/schedules/repo.js +55 -11
  132. package/dist/slices/schedules/scheduler.js +25 -8
  133. package/dist/slices/schedules/schema.js +19 -4
  134. package/dist/slices/schedules/service.js +2 -0
  135. package/dist/slices/settings/cli-paths.js +7 -4
  136. package/dist/slices/settings/models.js +4 -4
  137. package/dist/slices/settings/playback.js +2 -2
  138. package/dist/slices/storage/assets.js +5 -5
  139. package/dist/slices/storage/layout.js +1 -1
  140. package/dist/slices/subtitles/captions.js +1 -1
  141. package/dist/slices/subtitles/prepare.js +5 -5
  142. package/dist/slices/subtitles/transcript.js +1 -1
  143. package/dist/slices/thumbnail/run.js +8 -8
  144. package/dist/slices/video/audio-inputs.js +2 -2
  145. package/dist/slices/video/ffmpeg.js +6 -4
  146. package/dist/slices/video/plan.js +1 -1
  147. package/dist/slices/video/run.js +2 -2
  148. package/dist/updater/plan.js +1 -1
  149. package/dist/updater/service.js +4 -3
  150. package/dist/updater/worker.js +6 -4
  151. package/dist/web/assets/{index-CZNiHm8P.css → index-Bwtmu9ei.css} +1 -1
  152. package/dist/web/assets/index-DjhMJqMN.js +141 -0
  153. package/dist/web/index.html +2 -2
  154. package/package.json +1 -1
  155. package/dist/web/assets/index-DPgGLX5m.js +0 -137
@@ -100,8 +100,8 @@ function classify(error, expired, opts, limit) {
100
100
  return providerError({
101
101
  kind: "timeout",
102
102
  message: opts.streaming === true
103
- ? `the provider sent nothing for ${seconds} s`
104
- : `the provider did not answer within ${seconds} s`,
103
+ ? `The ${callee[opts.kind]} sent nothing for ${seconds} seconds, so Slopify stopped waiting. It may be overloaded: wait a few minutes, then use Retry stage.`
104
+ : `The ${callee[opts.kind]} did not answer within ${seconds} seconds. It may be overloaded: wait a few minutes, then use Retry stage, or choose another model in the Providers section of Edit project.`,
105
105
  });
106
106
  }
107
107
  if (isProviderError(error)) {
@@ -113,11 +113,35 @@ function classify(error, expired, opts, limit) {
113
113
  ...(error.fault.retryAfterMs === undefined ? {} : { retryAfterMs: error.fault.retryAfterMs }),
114
114
  });
115
115
  }
116
+ if (unreachable(error)) {
117
+ // `fetch` names only itself; the cause's code (ENOTFOUND, ECONNREFUSED) is the clue.
118
+ const code = causeCode(error);
119
+ return providerError({
120
+ kind: "other",
121
+ message: `Slopify could not reach the ${callee[opts.kind]} over the internet${code === undefined ? "" : ` (${code})`}. Check your internet connection, firewall or VPN, then use Retry stage.`,
122
+ });
123
+ }
116
124
  return providerError({
117
125
  kind: "other",
118
126
  message: redact(error instanceof Error ? error.message : String(error)),
119
127
  });
120
128
  }
129
+ // What the user calls the other end of each kind of call.
130
+ const callee = {
131
+ llm: "AI model",
132
+ tts: "narration provider",
133
+ image: "image provider",
134
+ };
135
+ // Node's `fetch` rejects a request that never reached the server with this TypeError.
136
+ function unreachable(error) {
137
+ return error instanceof TypeError && error.message === "fetch failed";
138
+ }
139
+ function causeCode(error) {
140
+ const cause = error.cause;
141
+ if (typeof cause !== "object" || cause === null || !("code" in cause))
142
+ return undefined;
143
+ return typeof cause.code === "string" && /^[A-Z_]+$/.test(cause.code) ? cause.code : undefined;
144
+ }
121
145
  // The same milliseconds, measured two ways. `idle` restarts the count on every chunk, so a
122
146
  // stalled stream is cut off and a long answer is not. Cancel still wins.
123
147
  function deadline(clock, parent, ms, idle) {
package/dist/main.js CHANGED
@@ -119,7 +119,7 @@ export async function boot(config, options = {}) {
119
119
  if (dockerState !== undefined &&
120
120
  (process.env.SLOPIFY_CONTAINER !== "1" ||
121
121
  dockerState !== "/opt/slopify-install/activation.json"))
122
- throw new Error("Invalid managed Docker activation configuration.");
122
+ throw new Error("This container was started with Slopify's Docker settings in the wrong place (SLOPIFY_DOCKER_INSTALL_STATE). Start it with the launcher instead: npx @gentbajko/slopify --docker");
123
123
  const candidateToken = process.env.SLOPIFY_UPDATE_TOKEN ?? "";
124
124
  const pendingActivation = isUpdateToken(candidateToken) && process.env.SLOPIFY_UPDATE_PENDING === "1";
125
125
  ensureDirs(paths, { mode: 0o700 });
@@ -243,7 +243,7 @@ export async function boot(config, options = {}) {
243
243
  : !existsSync(oldEntry) || !existsSync(workerEntry)
244
244
  ? "Run Slopify from its installed package to use in-app updates."
245
245
  : npm === undefined
246
- ? "npm is unavailable. Install Node.js with npm to use in-app updates."
246
+ ? "npm was not found, so in-app updates are off. Install Node.js with npm (https://nodejs.org), or update from the terminal: npx @gentbajko/slopify@latest"
247
247
  : undefined,
248
248
  busy: () => updateDb.prepare("SELECT 1 FROM stages WHERE state = 'running' LIMIT 1").get() !==
249
249
  undefined ||
@@ -24,16 +24,16 @@ export function admit(input) {
24
24
  const draft = normaliseDraft(input.draft);
25
25
  const { sources } = draft;
26
26
  if (draft.title === "") {
27
- fields.push({ field: "title", message: "A title is required." });
27
+ fields.push({ field: "title", message: "Enter a title for this video." });
28
28
  }
29
29
  else if (draft.title.length > titleMax) {
30
- fields.push({ field: "title", message: `A title is at most ${titleMax} characters.` });
30
+ fields.push({ field: "title", message: `Keep the title to ${titleMax} characters or fewer.` });
31
31
  }
32
32
  for (const kind of stageKinds) {
33
33
  if (!allowedSources[kind].includes(sources[kind])) {
34
34
  fields.push({
35
35
  field: `sources.${kind}`,
36
- message: `The ${kind} stage cannot be set to ${sources[kind]}.`,
36
+ message: `"${sources[kind]}" is not an option for ${kind}. Reload the page and choose again.`,
37
37
  });
38
38
  }
39
39
  }
@@ -45,7 +45,7 @@ export function admit(input) {
45
45
  draft.outro?.mode === "llm" ||
46
46
  usesNarrationPreparation(draft);
47
47
  if (needsLlm && !chosen(draft.llm)) {
48
- fields.push({ field: "llm", message: "Pick an LLM provider and model." });
48
+ fields.push({ field: "llm", message: "Choose a text (LLM) provider and model." });
49
49
  }
50
50
  if (sources.article === "generate" && blank(draft.articlePrompt)) {
51
51
  fields.push({ field: "articlePrompt", message: "Pick an article prompt." });
@@ -54,10 +54,10 @@ export function admit(input) {
54
54
  fields.push(...narrationPreparationFields(draft));
55
55
  if (sources.audio === "generate") {
56
56
  if (voiced === undefined || !chosen(voiced)) {
57
- fields.push({ field: "audio", message: "Pick a narration provider and model." });
57
+ fields.push({ field: "audio", message: "Choose a narration provider and model." });
58
58
  }
59
59
  else if (voiced.voice.trim() === "") {
60
- fields.push({ field: "audio.voice", message: "Pick a voice." });
60
+ fields.push({ field: "audio.voice", message: "Choose a voice for the narration." });
61
61
  }
62
62
  }
63
63
  if (sources.images === "generate") {
@@ -67,14 +67,17 @@ export function admit(input) {
67
67
  sources.thumbnail === "from_prompt" ||
68
68
  sources.thumbnail === "prompt_by_llm") &&
69
69
  !chosen(draft.images)) {
70
- fields.push({ field: "images", message: "Pick an image provider and model." });
70
+ fields.push({ field: "images", message: "Choose an image provider and model." });
71
71
  }
72
72
  if ((sources.thumbnail === "from_prompt" || sources.thumbnail === "prompt_by_llm") &&
73
73
  blank(draft.thumbnailPrompt)) {
74
74
  fields.push({ field: "thumbnailPrompt", message: "Pick a thumbnail prompt." });
75
75
  }
76
76
  if (sources.audio === "off" && draft.subtitles !== undefined && draft.subtitles.mode !== "off") {
77
- fields.push({ field: "subtitles.mode", message: "Subtitles need narration audio." });
77
+ fields.push({
78
+ field: "subtitles.mode",
79
+ message: "Subtitles need narration. Turn narration on, or turn subtitles off.",
80
+ });
78
81
  }
79
82
  checkProvided(draft, input.staged, fields);
80
83
  checkValues(draft, input.requiredSlots, fields);
@@ -83,7 +86,7 @@ export function admit(input) {
83
86
  draft.silenceGapSeconds > silenceGapSecondsMax) {
84
87
  fields.push({
85
88
  field: "silenceGapSeconds",
86
- message: `The silence gap is between 0 and ${silenceGapSecondsMax} seconds.`,
89
+ message: `Enter a silence gap between 0 and ${silenceGapSecondsMax} seconds.`,
87
90
  });
88
91
  }
89
92
  return fields.length === 0 ? { ok: true, draft } : { ok: false, fields };
@@ -121,7 +124,10 @@ export function normaliseDraft(draft) {
121
124
  function checkImagePrompts(draft, fields) {
122
125
  if (draft.imagePrompts.length === 0) {
123
126
  // A run always has an image source.
124
- fields.push({ field: "imagePrompts", message: "Tick at least one image prompt." });
127
+ fields.push({
128
+ field: "imagePrompts",
129
+ message: "Tick at least one image prompt, or turn images off.",
130
+ });
125
131
  return;
126
132
  }
127
133
  let total = 0;
@@ -135,14 +141,14 @@ function checkImagePrompts(draft, fields) {
135
141
  prompt.number > numberPerPromptMax) {
136
142
  fields.push({
137
143
  field: `imagePrompts.${index}.number`,
138
- message: `Number is between 1 and ${numberPerPromptMax}.`,
144
+ message: `Enter a whole number between 1 and ${numberPerPromptMax}.`,
139
145
  });
140
146
  }
141
147
  }
142
148
  if (total > imagesPerRunMax) {
143
149
  fields.push({
144
150
  field: "imagePrompts",
145
- message: `A run makes at most ${imagesPerRunMax} images; this one asks for ${total}.`,
151
+ message: `A video can have at most ${imagesPerRunMax} images; these prompts ask for ${total}. Lower the numbers.`,
146
152
  });
147
153
  }
148
154
  }
@@ -168,11 +174,14 @@ function checkProvided(draft, staged, fields) {
168
174
  else if (ids.length > imagesPerRunMax) {
169
175
  fields.push({
170
176
  field: "provided.images",
171
- message: `A run holds at most ${imagesPerRunMax} images.`,
177
+ message: `A video can have at most ${imagesPerRunMax} images. Remove some.`,
172
178
  });
173
179
  }
174
180
  if (new Set(ids).size !== ids.length) {
175
- fields.push({ field: "provided.images", message: "The same image was picked twice." });
181
+ fields.push({
182
+ field: "provided.images",
183
+ message: "The same image is picked twice. Remove the duplicate.",
184
+ });
176
185
  }
177
186
  for (const [index, id] of ids.entries()) {
178
187
  checkFile(staged, id, "images", `provided.images.${index}`, "Pick an image.", fields);
@@ -187,28 +196,31 @@ function checkFile(staged, id, kind, field, missing, fields) {
187
196
  }
188
197
  const file = staged.find((candidate) => candidate.id === id);
189
198
  if (file === undefined || file.stageKind !== kind) {
190
- fields.push({ field, message: "That upload is no longer available; pick the file again." });
199
+ fields.push({ field, message: "That upload is no longer available. Choose the file again." });
191
200
  return;
192
201
  }
193
202
  if (file.state !== "staged") {
194
- fields.push({ field, message: "This upload is still copying." });
203
+ fields.push({ field, message: "This file is still uploading. Wait for it to finish." });
195
204
  }
196
205
  }
197
206
  function checkValues(draft, requiredSlots, fields) {
198
207
  for (const name of requiredSlots) {
199
208
  const value = Object.hasOwn(draft.values, name) ? draft.values[name] : undefined;
200
209
  if (value === undefined || value === "") {
201
- fields.push({ field: `values.${name}`, message: "This field is required." });
210
+ fields.push({ field: `values.${name}`, message: "Fill in this field." });
202
211
  continue;
203
212
  }
204
213
  if (value.length > valueMax) {
205
214
  fields.push({
206
215
  field: `values.${name}`,
207
- message: `A value is at most ${valueMax} characters.`,
216
+ message: `Keep this to ${valueMax} characters or fewer.`,
208
217
  });
209
218
  }
210
219
  if (/[\n\r]/.test(value)) {
211
- fields.push({ field: `values.${name}`, message: "A value is a single line." });
220
+ fields.push({
221
+ field: `values.${name}`,
222
+ message: "Keep this to a single line, without line breaks.",
223
+ });
212
224
  }
213
225
  }
214
226
  }
@@ -224,7 +236,7 @@ export function narrationPreparationFields(draft) {
224
236
  ? [
225
237
  {
226
238
  field: "narrationPrompt",
227
- message: "Narration Preparation requires Inworld TTS-2. Choose that model or turn preparation Off.",
239
+ message: "Narration preparation only works with the Inworld TTS-2 model. Choose that model under Narration, or turn preparation Off.",
228
240
  },
229
241
  ]
230
242
  : [];
@@ -81,7 +81,9 @@ function truncated(answer) {
81
81
  }
82
82
  // Empty response → failed attempt.
83
83
  function written(answer) {
84
- return answer.text.trim() === "" ? "the article answered with nothing" : undefined;
84
+ return answer.text.trim() === ""
85
+ ? "The AI model returned an empty article. Retry stage; if it keeps happening, choose a different model in Edit project → Providers."
86
+ : undefined;
85
87
  }
86
88
  function finished(answer) {
87
89
  const empty = written(answer);
@@ -89,6 +91,6 @@ function finished(answer) {
89
91
  return empty;
90
92
  }
91
93
  return truncated(answer)
92
- ? `the article was still unfinished after ${String(continuationLimit)} continuations`
94
+ ? `The article was still unfinished after ${String(continuationLimit)} continuations because the AI model kept stopping at its length limit. Ask for a shorter article in Edit project → Prompts, or choose a model with a larger output limit in Edit project → Providers, then Retry stage.`
93
95
  : undefined;
94
96
  }
@@ -7,11 +7,11 @@ export function plainText(markdown) {
7
7
  const paragraphs = tree.children
8
8
  .map((node) => {
9
9
  if (node.type !== "paragraph")
10
- throw new Error("Expected stripped prose paragraphs.");
10
+ throw new Error("Slopify hit an internal error (the article couldn't be turned into plain text). Try again; if it happens again, use Download diagnostics in Settings and report it.");
11
11
  return node.children
12
12
  .map((child) => {
13
13
  if (child.type !== "text")
14
- throw new Error("Expected stripped prose text.");
14
+ throw new Error("Slopify hit an internal error (the article couldn't be turned into plain text). Try again; if it happens again, use Download diagnostics in Settings and report it.");
15
15
  return child.value;
16
16
  })
17
17
  .join("");
@@ -11,7 +11,7 @@ export async function prepareProvidedArticleSegments(deps, audioContext, provide
11
11
  const { projectId } = audioContext.stage;
12
12
  const project = projectById(deps.db, projectId);
13
13
  if (!project)
14
- throw new Error(`project ${projectId} has no row`);
14
+ throw new Error("This project no longer exists; it may have been deleted while it was running.");
15
15
  const { config } = project;
16
16
  if (config.sources.article !== "provide" || config.sources.audio !== "generate")
17
17
  return "done";
@@ -20,11 +20,11 @@ export async function prepareProvidedArticleSegments(deps, audioContext, provide
20
20
  audioContext.signal.throwIfAborted();
21
21
  const article = stagesOf(deps.db, projectId).find((stage) => stage.kind === "article");
22
22
  if (!article)
23
- throw new Error(`project ${projectId} has no article stage`);
23
+ throw new Error("Slopify hit an internal error (the project has no article stage). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
24
24
  const outputs = outputsOf(deps.db, projectId);
25
25
  const plain = outputs.find((output) => output.role === "article_txt");
26
26
  if (!plain)
27
- throw new Error("the run has no provided article text");
27
+ throw new Error("The article text you provided is missing, so the intro and outro can't be written from it. Paste the article again in Edit project → Article, then Retry stage.");
28
28
  const narration = readFileSync(outputPath(deps.paths, projectId, plain.path), "utf8");
29
29
  const saved = outputs.find((output) => output.stageKind === "article" && output.role === "instructions");
30
30
  let instructions = saved
@@ -13,14 +13,14 @@ export async function runArticle(deps, context, providers) {
13
13
  const { projectId } = context.stage;
14
14
  const project = projectById(deps.db, projectId);
15
15
  if (project === undefined) {
16
- throw new Error(`project ${projectId} has no row`);
16
+ throw new Error("This project no longer exists; it may have been deleted while it was running.");
17
17
  }
18
18
  const choice = project.config.llm;
19
19
  const articlePrompt = project.config.rendered.article;
20
20
  if (choice === undefined || articlePrompt === undefined) {
21
21
  // Admission refuses a run whose article is Generate without both, so
22
22
  // reaching here is a bug in admission rather than something the user did.
23
- throw new Error("the run has no LLM provider or no rendered article prompt");
23
+ throw new Error("No AI model or article prompt is set for writing the article. Choose a model in Edit project → Providers and check Edit project → Prompts, then Retry stage.");
24
24
  }
25
25
  const notes = researchNotes(deps, projectId);
26
26
  const brief = { articlePrompt, ...(notes === undefined ? {} : { notes }) };
@@ -7,12 +7,12 @@ export async function writeSegment(providers, choice, config, category, article,
7
7
  return { ok: true, value: undefined };
8
8
  const body = config.rendered[category];
9
9
  if (body === undefined)
10
- throw new Error(`the run has no rendered ${category} text`);
10
+ throw new Error(`Slopify hit an internal error (the ${category} text was never filled in). Retry stage; if it happens again, use Download diagnostics in Settings and report it.`);
11
11
  const common = { category, name: picked.name, mode: picked.mode };
12
12
  if (picked.mode === "text")
13
13
  return { ok: true, value: { ...common, text: body, tokens: noTokens } };
14
14
  if (choice === undefined)
15
- throw new Error(`the ${category} has no LLM provider or model`);
15
+ throw new Error(`No AI model is set for writing the ${category}. Choose one in Edit project → Providers, then Retry stage.`);
16
16
  const messages = segmentMessages(body, config, article);
17
17
  sent.push({ label: category === "intro" ? "Intro" : "Outro", messages });
18
18
  const answer = await providers.llm({
@@ -20,7 +20,9 @@ export async function writeSegment(providers, choice, config, category, article,
20
20
  model: choice.model,
21
21
  ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
22
22
  messages,
23
- check: (given) => given.text.trim() === "" ? `the ${category} answered with nothing` : undefined,
23
+ check: (given) => given.text.trim() === ""
24
+ ? `The AI model returned an empty ${category}. Retry stage; if it keeps happening, choose a different model in Edit project → Providers.`
25
+ : undefined,
24
26
  });
25
27
  if (!answer.ok)
26
28
  return answer;
@@ -25,7 +25,7 @@ export const providerChangesSchema = z
25
25
  .optional(),
26
26
  })
27
27
  .strict()
28
- .refine((value) => Object.keys(value).length > 0, "Pick at least one provider to update.");
28
+ .refine((value) => Object.keys(value).length > 0, "Change at least one provider before saving.");
29
29
  export function validateLocalProviderChanges(changes, current) {
30
30
  const fields = [];
31
31
  const families = { llm: "llm", audio: "tts", images: "image" };
@@ -35,7 +35,10 @@ export function validateLocalProviderChanges(changes, current) {
35
35
  continue;
36
36
  const provider = current.providers.find((one) => one.id === picked.provider && one.family === families[key]);
37
37
  if (provider === undefined) {
38
- fields.push({ field: key, message: "Choose a provider for this kind of generation." });
38
+ fields.push({
39
+ field: key,
40
+ message: "This provider cannot do this kind of work. Choose another provider.",
41
+ });
39
42
  continue;
40
43
  }
41
44
  const usable = readinessIsUsable(provider.readiness);
@@ -43,14 +46,18 @@ export function validateLocalProviderChanges(changes, current) {
43
46
  fields.push({
44
47
  field: key,
45
48
  message: provider.readiness.kind === "cli"
46
- ? (provider.readiness.issue ?? "Install and sign in to this CLI first.")
47
- : "Save this provider's API key in Settings first.",
49
+ ? (provider.readiness.issue ??
50
+ "Install this command-line tool on your computer and sign in to it, then try again.")
51
+ : "Add this provider's API key in Settings → Providers first.",
48
52
  });
49
53
  }
50
54
  }
51
55
  if (changes.audio !== undefined &&
52
56
  !current.voices.some((voice) => voice.provider === changes.audio?.provider && voice.voiceId === changes.audio.voice)) {
53
- fields.push({ field: "audio.voice", message: "Choose a saved voice for this provider." });
57
+ fields.push({
58
+ field: "audio.voice",
59
+ message: "Choose one of your saved voices for this provider. Add voices in Settings → Voices.",
60
+ });
54
61
  }
55
62
  return fields;
56
63
  }
@@ -67,10 +74,16 @@ export async function validateProviderChanges(changes, current) {
67
74
  continue;
68
75
  const result = await checkRuntimeModel(current.modelsFor, picked.provider, families[key], picked.model, picked.thinking);
69
76
  if (result === "missing") {
70
- fields.push({ field: `${key}.model`, message: "Choose a model supported by this provider." });
77
+ fields.push({
78
+ field: `${key}.model`,
79
+ message: "This provider does not offer that model. Choose another model.",
80
+ });
71
81
  }
72
82
  else if (result === "thinking") {
73
- fields.push({ field: `${key}.thinking`, message: "Choose a supported thinking setting." });
83
+ fields.push({
84
+ field: `${key}.thinking`,
85
+ message: "This model does not support that thinking setting. Choose another.",
86
+ });
74
87
  }
75
88
  }
76
89
  return fields;
@@ -31,7 +31,7 @@ export async function resolveFont(paths, id) {
31
31
  return font;
32
32
  }
33
33
  export function missingFont() {
34
- return new Error("The selected font is no longer available. Choose another font.", {
34
+ return new Error("The chosen caption font is no longer available; it may have been uninstalled or deleted. Choose another font in Edit project → Subtitles, then try again.", {
35
35
  cause: "font-not-found",
36
36
  });
37
37
  }
@@ -27,7 +27,7 @@ export async function uploadFont(paths, input) {
27
27
  const root = join(paths.dataDir, "fonts");
28
28
  await mkdir(root, { recursive: true, mode: 0o700 });
29
29
  if (!(await lstat(root)).isDirectory())
30
- throw new Error("The font storage directory is unavailable.");
30
+ throw new Error(`Slopify can't save uploaded fonts because ${root} is not a folder. Move or rename that file, then upload the font again.`);
31
31
  const temporary = join(root, `.upload-${randomUUID()}`);
32
32
  try {
33
33
  await writeFile(temporary, input.content, { mode: 0o600, flag: "wx" });
@@ -22,19 +22,19 @@ export async function runImages(deps, context, providers) {
22
22
  const { projectId } = context.stage;
23
23
  const project = projectById(deps.db, projectId);
24
24
  if (project === undefined) {
25
- throw new Error(`project ${projectId} has no row`);
25
+ throw new Error("This project no longer exists; it may have been deleted while it was running.");
26
26
  }
27
27
  const choice = project.config.images;
28
28
  if (choice === undefined) {
29
29
  // Admission refuses a run whose images are Generate without a provider and a model, so
30
30
  // reaching here is a bug in admission rather than the user's.
31
- throw new Error("the run has no image provider or model");
31
+ throw new Error("No image model is set for this project. Choose one in Edit project → Providers, then Retry stage.");
32
32
  }
33
33
  const pieces = plan(deps, context, project.config);
34
34
  if (pieces.length === 0) {
35
35
  // Admission makes an image source mandatory and puts the Number at one or more, so an
36
36
  // empty plan is a bug upstream rather than a run with no pictures.
37
- throw new Error("the run ticked no image prompt");
37
+ throw new Error("Slopify hit an internal error (no image prompt was selected). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
38
38
  }
39
39
  // The run's own frame. The adapter turns it into whatever its provider spells the
40
40
  // closest supported size, and the render crops whatever is left over.
@@ -78,7 +78,7 @@ function plan(deps, context, config) {
78
78
  // it, so the run carries the substituted text under this key.
79
79
  const prompt = config.rendered[`imagePrompts.${String(at)}`];
80
80
  if (prompt === undefined) {
81
- throw new Error(`the run has no rendered text for the image prompt ${picked.name}`);
81
+ throw new Error(`Slopify hit an internal error (the image prompt ${picked.name} was never filled in). Retry stage; if it happens again, use Download diagnostics in Settings and report it.`);
82
82
  }
83
83
  for (let send = 1; send <= picked.number; send += 1) {
84
84
  planned.push({
@@ -224,7 +224,7 @@ function report(deps, context, done, total) {
224
224
  }
225
225
  function payloadOf(piece) {
226
226
  if (piece.payload === null) {
227
- throw new Error(`image ${piece.id} has no payload`);
227
+ throw new Error("Slopify hit an internal error (a saved image step is empty). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
228
228
  }
229
229
  return imagePayload.parse(JSON.parse(piece.payload));
230
230
  }
@@ -13,17 +13,23 @@ function lint(name, body) {
13
13
  const fields = [];
14
14
  const trimmed = name.trim();
15
15
  if (trimmed === "") {
16
- fields.push({ field: "name", message: "A name is required." });
16
+ fields.push({ field: "name", message: "Enter a name." });
17
17
  }
18
18
  else if (trimmed.length > nameMax) {
19
- fields.push({ field: "name", message: `A name is at most ${String(nameMax)} characters.` });
19
+ fields.push({
20
+ field: "name",
21
+ message: `Keep the name to ${String(nameMax)} characters or fewer.`,
22
+ });
20
23
  }
21
24
  if (body.trim() === "") {
22
- fields.push({ field: "body", message: "A body is required." });
25
+ fields.push({ field: "body", message: "Enter the text." });
23
26
  return fields;
24
27
  }
25
28
  if (body.length > bodyMax) {
26
- fields.push({ field: "body", message: `A body is at most ${String(bodyMax)} characters.` });
29
+ fields.push({
30
+ field: "body",
31
+ message: `The text is too long. Keep it to ${String(bodyMax)} characters or fewer.`,
32
+ });
27
33
  return fields;
28
34
  }
29
35
  for (const error of detectSlots(body).errors) {
@@ -37,11 +43,11 @@ function describe(error, body) {
37
43
  const where = positionOf(body, error.at);
38
44
  switch (error.kind) {
39
45
  case "unclosed":
40
- return `The \`{{\` at ${where} is never closed.`;
46
+ return `The \`{{\` at ${where} is never closed. Add \`}}\` after the keyword name.`;
41
47
  case "empty":
42
- return `The slot at ${where} has no name.`;
48
+ return `The keyword at ${where} has no name. Write a name between \`{{\` and \`}}\`.`;
43
49
  case "nested":
44
- return `The slot at ${where} holds a brace; slots do not nest.`;
50
+ return `The keyword at ${where} contains a brace. Keywords cannot be placed inside other keywords.`;
45
51
  }
46
52
  }
47
53
  function positionOf(body, at) {
@@ -58,7 +58,7 @@ function body(db, kind, name, field, missing, into, key, snapshot) {
58
58
  }
59
59
  const prompt = snapshotPrompt(db, snapshot, kind, name);
60
60
  if (prompt === undefined) {
61
- missing.push({ field, message: `That ${kind} prompt no longer exists; pick another.` });
61
+ missing.push({ field, message: `That ${kind} prompt was deleted. Choose another.` });
62
62
  return;
63
63
  }
64
64
  into.push({ key, body: prompt.body });
@@ -71,7 +71,7 @@ function pickEntry(db, category, choice, missing, snapshot) {
71
71
  if (entry === undefined) {
72
72
  missing.push({
73
73
  field: category,
74
- message: `That ${category} entry no longer exists; pick another.`,
74
+ message: `That ${category} entry was deleted. Choose another.`,
75
75
  });
76
76
  }
77
77
  return entry;
@@ -46,7 +46,7 @@ export async function joinNarration(deps, input) {
46
46
  const first = input.files[0];
47
47
  if (first === undefined) {
48
48
  // The stage refuses an empty narration before it gets here, so this is a bug.
49
- throw new Error("there are no audio chunks to join");
49
+ throw new Error("Slopify hit an internal error (there is no narration audio to join). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
50
50
  }
51
51
  mkdirSync(dirname(input.output), { recursive: true, mode: 0o700 });
52
52
  if (input.files.length === 1) {
@@ -18,7 +18,7 @@ export function pronunciationChunks(source, chunking, entries, overriddenKeys =
18
18
  const chunks = chunkNarration(source, chunking).map((text) => {
19
19
  const start = source.indexOf(text, offset);
20
20
  if (start < 0)
21
- throw new Error("Narration chunk is missing from its source.");
21
+ throw new Error("Slopify hit an internal error (a narration chunk isn't in the article text). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
22
22
  offset = start + text.length;
23
23
  return { key: nextKey(text), text, start, end: offset };
24
24
  });
@@ -78,7 +78,7 @@ export function pronunciationChunks(source, chunking, entries, overriddenKeys =
78
78
  }
79
79
  const chunk = chunks[index];
80
80
  if (chunk === undefined)
81
- throw new Error("Narration chunk disappeared.");
81
+ throw new Error("Slopify hit an internal error (a narration chunk went missing). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
82
82
  while ((matches[matchIndex]?.end ?? Infinity) <= chunk.end)
83
83
  matchIndex++;
84
84
  const match = matches[matchIndex];
@@ -90,7 +90,7 @@ export function pronunciationChunks(source, chunking, entries, overriddenKeys =
90
90
  continue;
91
91
  const first = chunks[from];
92
92
  if (first === undefined)
93
- throw new Error("Narration group has no first chunk.");
93
+ throw new Error("Slopify hit an internal error (a group of narration chunks is empty). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
94
94
  const text = source.slice(first.start, chunk.end);
95
95
  result.push(from === index
96
96
  ? { key: chunk.key, text }
@@ -105,7 +105,7 @@ export function pronunciationMatches(source, entries) {
105
105
  continue;
106
106
  const entry = ordered.find((_, index) => match[index + 1] !== undefined);
107
107
  if (entry === undefined)
108
- throw new Error("Matched glossary term has no mapping.");
108
+ throw new Error("Slopify hit an internal error (a pronunciation glossary term has no pronunciation). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
109
109
  matches.push({ start: match.index, end, entry });
110
110
  }
111
111
  return matches;
@@ -117,7 +117,7 @@ export function pronunciationSpans(source, entries) {
117
117
  for (const [index, word] of Array.from(words).entries()) {
118
118
  const ipa = match.entry.ipa[index];
119
119
  if (ipa === undefined)
120
- throw new Error("Validated glossary term has no IPA word.");
120
+ throw new Error("Slopify hit an internal error (a pronunciation glossary term is missing a word). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
121
121
  const start = match.start + word.index;
122
122
  spans.push({ start, end: start + word[0].length, text: `/${ipa}/` });
123
123
  }