@gentbajko/slopify 2.0.1 → 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 (162) hide show
  1. package/dist/adapters/alignment/cache.js +17 -1
  2. package/dist/adapters/alignment/prefetch.js +35 -0
  3. package/dist/adapters/alignment/protocol.js +11 -1
  4. package/dist/adapters/alignment/runner.js +4 -1
  5. package/dist/adapters/alignment/worker.js +28 -5
  6. package/dist/adapters/explain.js +93 -0
  7. package/dist/adapters/ffmpeg.js +3 -3
  8. package/dist/adapters/host-cli/index.js +9 -2
  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/catalog/registry.js +13 -10
  35. package/dist/catalog/store.js +1 -1
  36. package/dist/catalog/validate.js +13 -4
  37. package/dist/edge/cli.js +37 -8
  38. package/dist/edge/docker-launch.js +6 -6
  39. package/dist/edge/docker-projects/engine.js +34 -13
  40. package/dist/edge/docker-projects/install.js +8 -8
  41. package/dist/edge/docker-projects/state.js +6 -6
  42. package/dist/edge/docker-projects/tree.js +6 -6
  43. package/dist/edge/docker.js +13 -13
  44. package/dist/edge/http/actions.js +27 -25
  45. package/dist/edge/http/app.js +3 -3
  46. package/dist/edge/http/audio-preview.js +1 -1
  47. package/dist/edge/http/checkpoints.js +5 -1
  48. package/dist/edge/http/diagnostics.js +3 -1
  49. package/dist/edge/http/draft-files.js +2 -2
  50. package/dist/edge/http/draft-problem.js +11 -1
  51. package/dist/edge/http/files.js +4 -4
  52. package/dist/edge/http/folder-location.js +3 -3
  53. package/dist/edge/http/fonts.js +22 -10
  54. package/dist/edge/http/host-cli.js +34 -12
  55. package/dist/edge/http/multipart.js +7 -5
  56. package/dist/edge/http/open-folder.js +2 -2
  57. package/dist/edge/http/planning.js +4 -4
  58. package/dist/edge/http/problem.js +2 -2
  59. package/dist/edge/http/project-templates.js +7 -3
  60. package/dist/edge/http/projects.js +8 -8
  61. package/dist/edge/http/prompts.js +9 -4
  62. package/dist/edge/http/providers.js +15 -7
  63. package/dist/edge/http/revision-files.js +3 -3
  64. package/dist/edge/http/revisions.js +30 -4
  65. package/dist/edge/http/schedules.js +9 -9
  66. package/dist/edge/http/settings.js +15 -9
  67. package/dist/edge/http/staging.js +5 -5
  68. package/dist/edge/http/storage.js +4 -4
  69. package/dist/edge/http/subtitles.js +1 -1
  70. package/dist/edge/http/tutorial.js +2 -2
  71. package/dist/edge/http/update.js +3 -3
  72. package/dist/host-cli/install.js +28 -6
  73. package/dist/host-cli/paths.js +6 -6
  74. package/dist/host-cli/server.js +1 -1
  75. package/dist/host-cli/service.js +11 -11
  76. package/dist/host-cli/status.js +7 -3
  77. package/dist/kernel/config/index.js +6 -4
  78. package/dist/kernel/db/migrate.js +2 -1
  79. package/dist/kernel/db/migrations/0013-schedule-topic-queue.sql +4 -0
  80. package/dist/kernel/errors.js +12 -0
  81. package/dist/kernel/lock.js +4 -3
  82. package/dist/kernel/paths.js +5 -0
  83. package/dist/kernel/ports/subtitles.js +15 -1
  84. package/dist/kernel/runner/attempt.js +26 -2
  85. package/dist/kernel/runner/index.js +16 -6
  86. package/dist/kernel/runner/progress.js +56 -0
  87. package/dist/main.js +20 -4
  88. package/dist/slices/admission/rules.js +32 -20
  89. package/dist/slices/article/continuation.js +4 -2
  90. package/dist/slices/article/plain.js +2 -2
  91. package/dist/slices/article/provided-entries.js +3 -3
  92. package/dist/slices/article/run.js +2 -2
  93. package/dist/slices/article/segments.js +5 -3
  94. package/dist/slices/control/providers.js +20 -7
  95. package/dist/slices/fonts/catalog.js +1 -1
  96. package/dist/slices/fonts/upload.js +1 -1
  97. package/dist/slices/images/run.js +5 -5
  98. package/dist/slices/library/lint.js +13 -7
  99. package/dist/slices/library/slots.js +2 -2
  100. package/dist/slices/narration/concat.js +1 -1
  101. package/dist/slices/narration/pronunciation-chunks.js +3 -3
  102. package/dist/slices/narration/pronunciation.js +2 -2
  103. package/dist/slices/narration/run.js +7 -7
  104. package/dist/slices/narration/steering.js +3 -3
  105. package/dist/slices/play-drafts/convert.js +5 -4
  106. package/dist/slices/play-drafts/readiness.js +12 -6
  107. package/dist/slices/play-drafts/review-inputs.js +7 -4
  108. package/dist/slices/play-drafts/review.js +3 -3
  109. package/dist/slices/play-drafts/service.js +3 -1
  110. package/dist/slices/play-drafts/uploads.js +1 -1
  111. package/dist/slices/rebuild/admission-repo.js +2 -2
  112. package/dist/slices/rebuild/recipe-legacy.js +1 -1
  113. package/dist/slices/rebuild/recipe-narration-text.js +1 -1
  114. package/dist/slices/rebuild/recipe-visual.js +1 -1
  115. package/dist/slices/rebuild/recovery.js +2 -2
  116. package/dist/slices/rebuild/repo.js +3 -3
  117. package/dist/slices/rebuild/runtime-admission.js +4 -4
  118. package/dist/slices/rebuild/runtime-article.js +5 -5
  119. package/dist/slices/rebuild/runtime-checkpoints.js +4 -4
  120. package/dist/slices/rebuild/runtime-export-inputs.js +6 -6
  121. package/dist/slices/rebuild/runtime-export.js +4 -4
  122. package/dist/slices/rebuild/runtime-local.js +6 -6
  123. package/dist/slices/rebuild/runtime-narration-text.js +46 -7
  124. package/dist/slices/rebuild/runtime-piece-label.js +62 -0
  125. package/dist/slices/rebuild/runtime-provider.js +6 -4
  126. package/dist/slices/rebuild/runtime-publication.js +3 -3
  127. package/dist/slices/rebuild/runtime-run.js +11 -1
  128. package/dist/slices/rebuild/runtime-store.js +45 -15
  129. package/dist/slices/rebuild/runtime-subtitles.js +80 -17
  130. package/dist/slices/rebuild/transition-repo.js +6 -6
  131. package/dist/slices/reruns/index.js +1 -1
  132. package/dist/slices/research/run.js +9 -7
  133. package/dist/slices/research/synthesis.js +4 -2
  134. package/dist/slices/revisions/mutation-assets.js +26 -8
  135. package/dist/slices/revisions/mutation-cues.js +1 -1
  136. package/dist/slices/revisions/rules.js +18 -9
  137. package/dist/slices/revisions/schema.js +1 -1
  138. package/dist/slices/schedules/repo.js +55 -11
  139. package/dist/slices/schedules/scheduler.js +25 -8
  140. package/dist/slices/schedules/schema.js +19 -4
  141. package/dist/slices/schedules/service.js +2 -0
  142. package/dist/slices/settings/cli-paths.js +7 -4
  143. package/dist/slices/settings/models.js +4 -4
  144. package/dist/slices/settings/playback.js +2 -2
  145. package/dist/slices/storage/assets.js +5 -5
  146. package/dist/slices/storage/layout.js +1 -1
  147. package/dist/slices/subtitles/captions.js +1 -1
  148. package/dist/slices/subtitles/prepare.js +7 -6
  149. package/dist/slices/subtitles/transcript.js +1 -1
  150. package/dist/slices/thumbnail/run.js +8 -8
  151. package/dist/slices/video/audio-inputs.js +2 -2
  152. package/dist/slices/video/ffmpeg.js +6 -4
  153. package/dist/slices/video/plan.js +1 -1
  154. package/dist/slices/video/run.js +2 -2
  155. package/dist/updater/plan.js +1 -1
  156. package/dist/updater/service.js +4 -3
  157. package/dist/updater/worker.js +6 -4
  158. package/dist/web/assets/{index-CZNiHm8P.css → index-Bwtmu9ei.css} +1 -1
  159. package/dist/web/assets/index-DjhMJqMN.js +141 -0
  160. package/dist/web/index.html +2 -2
  161. package/package.json +1 -1
  162. package/dist/web/assets/index-DPgGLX5m.js +0 -137
@@ -19,3 +19,8 @@ export function ensureDirs(paths, options) {
19
19
  chmodSync(dir, options.mode);
20
20
  }
21
21
  }
22
+ // The subtitle-timing model's cache. The app readies it at start and every captioned render
23
+ // reads it, so both name it through here.
24
+ export function subtitleModelDir(dataDir) {
25
+ return join(dataDir, "models", "english-subtitles");
26
+ }
@@ -1 +1,15 @@
1
- export {};
1
+ // Where the audio stopped matching its transcript: seconds into the aligned file, the
2
+ // transcript words it expected there and what the model heard instead (either may be empty).
3
+ // The aligner throws it so the caller can say which part of the narration to redo.
4
+ export class SubtitleMismatch extends Error {
5
+ at;
6
+ expected;
7
+ heard;
8
+ constructor(message, at, expected, heard) {
9
+ super(message);
10
+ this.at = at;
11
+ this.expected = expected;
12
+ this.heard = heard;
13
+ this.name = "SubtitleMismatch";
14
+ }
15
+ }
@@ -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) {
@@ -1,4 +1,6 @@
1
+ import { causedBy } from "../errors.js";
1
2
  import { derive, deps as graph, satisfied } from "./graph.js";
3
+ import { progressGate } from "./progress.js";
2
4
  const pauseReason = new Error("paused by user");
3
5
  export function createRunner(deps) {
4
6
  const inflight = new Map();
@@ -60,6 +62,12 @@ export function createRunner(deps) {
60
62
  emitStage(stage, state, reason);
61
63
  };
62
64
  async function execute(stage, controller) {
65
+ const tag = (event) => ({
66
+ ...event,
67
+ revisionId: stage.work.revisionId,
68
+ workId: stage.work.workId,
69
+ });
70
+ const progress = progressGate((event) => deps.emit(stage.projectId, tag(event)));
63
71
  try {
64
72
  const run = deps.runs[stage.kind];
65
73
  if (run === undefined) {
@@ -76,16 +84,18 @@ export function createRunner(deps) {
76
84
  deps.stages.maySubmit(stage.work, pieceId),
77
85
  signal: controller.signal,
78
86
  emit: (event) => {
79
- deps.emit(stage.projectId, {
80
- ...event,
81
- revisionId: stage.work.revisionId,
82
- workId: stage.work.workId,
83
- });
87
+ if (event.type === "stage.progress")
88
+ progress.offer(event);
89
+ else
90
+ deps.emit(stage.projectId, tag(event));
84
91
  },
85
92
  });
93
+ progress.flush();
94
+ progress.close();
86
95
  conclude(stage, result === "held" ? "pending" : "done", null);
87
96
  }
88
97
  catch (error) {
98
+ progress.close();
89
99
  if (controller.signal.aborted) {
90
100
  // A late rejection from the aborted call is how a stage learns it was canceled, so
91
101
  // it is not logged as a fault.
@@ -96,7 +106,7 @@ export function createRunner(deps) {
96
106
  deps.log.write("error", "stage.failed", {
97
107
  projectId: stage.projectId,
98
108
  stage: stage.kind,
99
- detail: reasonOf(error),
109
+ detail: causedBy(error),
100
110
  });
101
111
  conclude(stage, "failed", reasonOf(error));
102
112
  }
@@ -0,0 +1,56 @@
1
+ // The most progress a single stage invocation reports per second. A stage may call emit on
2
+ // every chunk it reads (a 95 MB download is ~6,000 of them) and each report writes the
3
+ // database and reaches every open page; nobody can read a meter faster than this.
4
+ export const progressIntervalMs = 500;
5
+ // Repeats of the last value are dropped. The first report of a window goes out at once and
6
+ // the newest one offered inside it goes out when the window ends, so a meter never sits on
7
+ // a stale value longer than one interval.
8
+ export function progressGate(send, intervalMs = progressIntervalMs) {
9
+ let last;
10
+ let held;
11
+ let timer;
12
+ let closed = false;
13
+ const same = (a, b) => a !== undefined && a.current === b.current && a.total === b.total;
14
+ const deliver = (event) => {
15
+ last = event;
16
+ send(event);
17
+ timer = setTimeout(() => {
18
+ timer = undefined;
19
+ const next = held;
20
+ held = undefined;
21
+ if (next !== undefined && !closed && !same(last, next))
22
+ deliver(next);
23
+ }, intervalMs);
24
+ timer.unref?.();
25
+ };
26
+ return {
27
+ offer: (event) => {
28
+ if (closed)
29
+ return;
30
+ if (timer !== undefined) {
31
+ held = event;
32
+ return;
33
+ }
34
+ if (!same(last, event))
35
+ deliver(event);
36
+ },
37
+ flush: () => {
38
+ if (timer !== undefined)
39
+ clearTimeout(timer);
40
+ timer = undefined;
41
+ const next = held;
42
+ held = undefined;
43
+ if (next !== undefined && !closed && !same(last, next)) {
44
+ last = next;
45
+ send(next);
46
+ }
47
+ },
48
+ close: () => {
49
+ closed = true;
50
+ held = undefined;
51
+ if (timer !== undefined)
52
+ clearTimeout(timer);
53
+ timer = undefined;
54
+ },
55
+ };
56
+ }
package/dist/main.js CHANGED
@@ -5,6 +5,7 @@ import { serve } from "@hono/node-server";
5
5
  import ffmpegStatic from "ffmpeg-static";
6
6
  import { buildRegistry } from "./adapter-registry.js";
7
7
  import { alignSubtitles } from "./adapters/alignment/index.js";
8
+ import { prefetchModel } from "./adapters/alignment/prefetch.js";
8
9
  import { prepareFfmpeg } from "./adapters/ffmpeg.js";
9
10
  import { createHostCliClient } from "./adapters/host-cli/index.js";
10
11
  import { nodeRunCli } from "./adapters/llm/run-cli.js";
@@ -21,10 +22,11 @@ import { systemClock } from "./kernel/clock.js";
21
22
  import { openDb } from "./kernel/db/index.js";
22
23
  import { migrate } from "./kernel/db/migrate.js";
23
24
  import { transact } from "./kernel/db/tx.js";
25
+ import { causedBy } from "./kernel/errors.js";
24
26
  import { ulidIds } from "./kernel/ids.js";
25
27
  import { acquireInstanceLock } from "./kernel/lock.js";
26
28
  import { openLog } from "./kernel/log.js";
27
- import { ensureDirs, layout } from "./kernel/paths.js";
29
+ import { ensureDirs, layout, subtitleModelDir } from "./kernel/paths.js";
28
30
  import { sqliteAttempts } from "./kernel/runner/attempt-repo.js";
29
31
  import { createCheckpointAuthority, } from "./kernel/runner/checkpoint-authority.js";
30
32
  import { createRunner } from "./kernel/runner/index.js";
@@ -109,7 +111,7 @@ export function createScheduleTickLifecycle(deps) {
109
111
  },
110
112
  };
111
113
  }
112
- export async function boot(config) {
114
+ export async function boot(config, options = {}) {
113
115
  const clock = systemClock;
114
116
  const ids = ulidIds;
115
117
  const paths = layout(config.dataDir);
@@ -117,7 +119,7 @@ export async function boot(config) {
117
119
  if (dockerState !== undefined &&
118
120
  (process.env.SLOPIFY_CONTAINER !== "1" ||
119
121
  dockerState !== "/opt/slopify-install/activation.json"))
120
- 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");
121
123
  const candidateToken = process.env.SLOPIFY_UPDATE_TOKEN ?? "";
122
124
  const pendingActivation = isUpdateToken(candidateToken) && process.env.SLOPIFY_UPDATE_PENDING === "1";
123
125
  ensureDirs(paths, { mode: 0o700 });
@@ -241,7 +243,7 @@ export async function boot(config) {
241
243
  : !existsSync(oldEntry) || !existsSync(workerEntry)
242
244
  ? "Run Slopify from its installed package to use in-app updates."
243
245
  : npm === undefined
244
- ? "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"
245
247
  : undefined,
246
248
  busy: () => updateDb.prepare("SELECT 1 FROM stages WHERE state = 'running' LIMIT 1").get() !==
247
249
  undefined ||
@@ -364,6 +366,18 @@ export async function boot(config) {
364
366
  // Whatever last run left queued goes out at start. Nothing waits for
365
367
  // it, and an unreachable collector costs one refused socket.
366
368
  flusher.soon();
369
+ const modelPrefetch = new AbortController();
370
+ const prefetching = options.prefetchSubtitleModel
371
+ ? prefetchModel({
372
+ cacheDir: subtitleModelDir(paths.dataDir),
373
+ seed: options.subtitleModelSeed,
374
+ signal: modelPrefetch.signal,
375
+ }).then(() => log.write("info", "subtitle-model.ready"), (error) => {
376
+ // Not fatal: the first captioned render tries again and says why on its row.
377
+ if (!modelPrefetch.signal.aborted)
378
+ log.write("warn", "subtitle-model.prefetch", { detail: causedBy(error) });
379
+ })
380
+ : Promise.resolve();
367
381
  const open = db;
368
382
  let stopping;
369
383
  let stopActivation = () => { };
@@ -376,6 +390,7 @@ export async function boot(config) {
376
390
  const serverClose = beginServerClose(server);
377
391
  stopActivation();
378
392
  audioPreviews.close();
393
+ modelPrefetch.abort();
379
394
  try {
380
395
  // Admission closes before the listener. Requests already admitted keep the
381
396
  // database until their response settles; anything racing shutdown receives 503.
@@ -394,6 +409,7 @@ export async function boot(config) {
394
409
  // closes and fail to mark its batch delivered, which costs one re-send that
395
410
  // the collector deduplicates by event id.
396
411
  flusher.stop();
412
+ await prefetching;
397
413
  log.write("info", "shutdown");
398
414
  open.close();
399
415
  lock.release();
@@ -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" });