@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
package/SUBTITLES.md CHANGED
@@ -16,7 +16,7 @@ Fonts are copied into the completed project's caption assets, so an existing exp
16
16
 
17
17
  ## Local model and licenses
18
18
 
19
- Speech inference uses MIT-licensed `onnxruntime-web@1.24.3` in a separate WASM process. The runtime is installed with the package (about 138 MB on disk); model weights are downloaded lazily into `<data-dir>/models/english-subtitles/`.
19
+ Speech inference uses MIT-licensed `onnxruntime-node@1.30.0` (native CPU) in a separate process, with `min(8, CPUs − 1)` threads; set `SLOPIFY_SUBTITLE_THREADS` to override. The npm package bundles CPU builds for Linux x64/arm64, Windows x64/arm64 and Apple silicon macOS (about 220 MB unpacked); Intel Macs have no build, so subtitle timing falls back to the slower single-threaded `onnxruntime-web` there. Its postinstall script only fetches optional CUDA files on Linux x64 and is not needed (the Docker image installs with `--ignore-scripts`); model weights are downloaded lazily into `<data-dir>/models/english-subtitles/`.
20
20
 
21
21
  Model: [Xenova/wav2vec2-base-960h](https://huggingface.co/Xenova/wav2vec2-base-960h/tree/a19f851b3d42865797e410752b4c570c871e4825), an ONNX conversion of [facebook/wav2vec2-base-960h](https://huggingface.co/facebook/wav2vec2-base-960h), licensed Apache-2.0. Pinned revision: `a19f851b3d42865797e410752b4c570c871e4825`. The quantized model is 95,286,046 bytes, SHA256 `cd5040c147381580ed73258143dd8e0c28e800a09e74ee42ee2b3e8cb4d760a3`; every cached/downloaded model is verified before use.
22
22
 
@@ -24,5 +24,15 @@ export const workerMessage = z.discriminatedUnion("type", [
24
24
  })),
25
25
  }),
26
26
  omissionSchema.extend({ type: z.literal("omission") }),
27
- z.object({ type: z.literal("error"), message: z.string() }),
27
+ z.object({
28
+ type: z.literal("error"),
29
+ message: z.string(),
30
+ mismatch: z
31
+ .object({
32
+ at: z.number().finite().nonnegative(),
33
+ expected: z.string().max(2000),
34
+ heard: z.string().max(2000),
35
+ })
36
+ .optional(),
37
+ }),
28
38
  ]);
@@ -1,4 +1,5 @@
1
1
  import { fork } from "node:child_process";
2
+ import { SubtitleMismatch, } from "../../kernel/ports/subtitles.js";
2
3
  import { workerMessage } from "./protocol.js";
3
4
  export async function runAlignmentWorker(input, signal, onProgress, worker = new URL("./worker.js", import.meta.url), onOmission) {
4
5
  signal.throwIfAborted();
@@ -65,7 +66,9 @@ export async function runAlignmentWorker(input, signal, onProgress, worker = new
65
66
  }
66
67
  }
67
68
  else if (message.type === "error")
68
- finish(new Error(message.message));
69
+ finish(message.mismatch === undefined
70
+ ? new Error(message.message)
71
+ : new SubtitleMismatch(message.message, message.mismatch.at, message.mismatch.expected, message.mismatch.heard));
69
72
  else
70
73
  finish(undefined, message.words.map(({ confidence, ...word }) => ({
71
74
  ...word,
@@ -0,0 +1,11 @@
1
+ import { availableParallelism } from "node:os";
2
+ // Measured on a 32-thread machine, 5 min of narration in 12 s windows: one native CPU session with
3
+ // 8 intra-op threads ran 37× realtime; 16 threads 31×, 32 threads 20×, 1 thread 20×, and the old
4
+ // single-threaded WASM runtime 4.8×. More threads in one session gets slower, so 8 is the ceiling.
5
+ const maxThreads = 8;
6
+ export function subtitleThreads(env = process.env, cpus = availableParallelism()) {
7
+ const override = env.SLOPIFY_SUBTITLE_THREADS?.trim();
8
+ if (override !== undefined && /^[1-9]\d*$/.test(override))
9
+ return Number(override);
10
+ return Math.min(maxThreads, Math.max(1, cpus - 1));
11
+ }
@@ -1,8 +1,9 @@
1
1
  import { open, readFile, stat } from "node:fs/promises";
2
- import * as ort from "onnxruntime-web/wasm";
2
+ import { SubtitleMismatch } from "../../kernel/ports/subtitles.js";
3
3
  import { mismatch } from "./ctc.js";
4
4
  import { workerInput } from "./protocol.js";
5
5
  import { speechWords } from "./text.js";
6
+ import { subtitleThreads } from "./threads.js";
6
7
  import { alignSpeechWindow, greedy } from "./window.js";
7
8
  const sampleRate = 16000;
8
9
  const windowSeconds = 12;
@@ -13,15 +14,19 @@ process.once("message", (raw) => {
13
14
  send({ type: "error", message: "The subtitle worker received an invalid request." });
14
15
  return;
15
16
  }
16
- run(input.data).then((words) => send({ type: "done", words }), (error) => send({ type: "error", message: error instanceof Error ? error.message : String(error) }));
17
+ run(input.data).then((words) => send({ type: "done", words }), (error) => send({
18
+ type: "error",
19
+ message: error instanceof Error ? error.message : String(error),
20
+ ...(error instanceof SubtitleMismatch
21
+ ? { mismatch: { at: error.at, expected: error.expected, heard: error.heard } }
22
+ : {}),
23
+ }));
17
24
  });
18
25
  function send(message) {
19
26
  process.send?.(message);
20
27
  }
21
28
  async function run(input) {
22
- ort.env.wasm.numThreads = 1;
23
- const model = new Uint8Array(await readFile(input.modelPath));
24
- const session = await ort.InferenceSession.create(model, { executionProviders: ["wasm"] });
29
+ const { ort, session } = await openSession(input.modelPath);
25
30
  const file = await open(input.pcmPath, "r");
26
31
  try {
27
32
  const totalSamples = (await stat(input.pcmPath)).size / 4;
@@ -64,11 +69,20 @@ async function run(input) {
64
69
  const finalWindow = sampleAt + count >= totalSamples;
65
70
  const complete = finalWindow && cursor + candidate.length === source.length;
66
71
  const cutoff = finalWindow ? count / sampleRate : count / sampleRate - overlapSeconds;
67
- const recovered = alignSpeechWindow(logits.data, frames, candidate, complete, cutoff, cursor === 0 ? 0 : omissionBudget - omitted);
72
+ const stuck = () => new SubtitleMismatch(mismatch, sampleAt / sampleRate, snippet(candidate.map((word) => word.text)), snippet(observed.split(/\s+/)));
73
+ let recovered;
74
+ try {
75
+ recovered = alignSpeechWindow(logits.data, frames, candidate, complete, cutoff, cursor === 0 ? 0 : omissionBudget - omitted);
76
+ }
77
+ catch (error) {
78
+ if (error instanceof Error && error.message === mismatch)
79
+ throw stuck();
80
+ throw error;
81
+ }
68
82
  const accepted = recovered.words;
69
83
  const last = accepted.at(-1);
70
84
  if (last === undefined)
71
- throw new Error(mismatch);
85
+ throw stuck();
72
86
  if (recovered.skipped > 0) {
73
87
  omitted += recovered.skipped;
74
88
  send({
@@ -95,7 +109,7 @@ async function run(input) {
95
109
  tensor.dispose();
96
110
  }
97
111
  if (cursor !== source.length || output.length === 0)
98
- throw new Error(mismatch);
112
+ throw new SubtitleMismatch(mismatch, sampleAt / sampleRate, snippet(source.slice(cursor).map((word) => word.text)), "");
99
113
  // A substantial spoken tail absent from the transcript is a mismatch too.
100
114
  while (sampleAt + sampleRate < totalSamples) {
101
115
  const count = Math.min(windowSeconds * sampleRate, totalSamples - sampleAt);
@@ -110,7 +124,7 @@ async function run(input) {
110
124
  if (logits !== undefined &&
111
125
  logits.data instanceof Float32Array &&
112
126
  greedy(logits.data, logits.dims[1] ?? 0).replace(/[^A-Z]/g, "").length > 8)
113
- throw new Error(mismatch);
127
+ throw new SubtitleMismatch(mismatch, sampleAt / sampleRate, "", snippet(greedy(logits.data, logits.dims[1] ?? 0).split(/\s+/)));
114
128
  for (const tensor of Object.values(result))
115
129
  tensor.dispose();
116
130
  }
@@ -123,6 +137,42 @@ async function run(input) {
123
137
  await session.release();
124
138
  }
125
139
  }
140
+ // One native CPU session per job, as benchmarked; threads.ts records the measured speeds. The
141
+ // native runtime loads its binary on import, so a missing or foreign binary fails there; that
142
+ // system (an Intel Mac has no 1.24 build) falls back to the single-threaded WebAssembly runtime,
143
+ // about 7× slower but the same model, rather than losing captions.
144
+ async function openSession(modelPath) {
145
+ let native;
146
+ try {
147
+ native = await import("onnxruntime-node");
148
+ }
149
+ catch (nativeError) {
150
+ try {
151
+ const web = await import("onnxruntime-web/wasm");
152
+ web.env.wasm.numThreads = 1;
153
+ const session = await web.InferenceSession.create(new Uint8Array(await readFile(modelPath)), {
154
+ executionProviders: ["wasm"],
155
+ });
156
+ return {
157
+ ort: web,
158
+ session: session,
159
+ };
160
+ }
161
+ catch {
162
+ const system = `${process.platform} ${process.arch}`;
163
+ const reason = nativeError instanceof Error
164
+ ? (nativeError.message.split("\n")[0] ?? "")
165
+ : String(nativeError);
166
+ throw new Error(`Subtitle timing could not start: neither of its speech engines loaded on this computer (${system}): ${reason}. Reinstall Slopify so npm installs the engine for this system, then try again; if it keeps failing, turn subtitles off for this project.`);
167
+ }
168
+ }
169
+ const session = await native.InferenceSession.create(modelPath, {
170
+ executionProviders: ["cpu"],
171
+ intraOpNumThreads: subtitleThreads(),
172
+ interOpNumThreads: 1,
173
+ });
174
+ return { ort: native, session };
175
+ }
126
176
  function candidates(source, cursor, observed) {
127
177
  const selected = [];
128
178
  let length = 0;
@@ -140,6 +190,13 @@ function candidates(source, cursor, observed) {
140
190
  }
141
191
  return selected;
142
192
  }
193
+ // A dozen words: enough to find the place in the text, short enough for an error row.
194
+ function snippet(words) {
195
+ return words
196
+ .filter((word) => word !== "")
197
+ .slice(0, 12)
198
+ .join(" ");
199
+ }
143
200
  function normalize(audio) {
144
201
  let mean = 0;
145
202
  for (const value of audio)
@@ -0,0 +1,93 @@
1
+ // The sentences a failed provider call shows. Whatever an adapter throws becomes the stage's
2
+ // failure reason on the project page, read by someone who only wants the video, so each one
3
+ // says what failed, the likely cause and the next step, using the names on screen. The
4
+ // provider's own words stay in, quoted after the plain sentence, because they are often the
5
+ // only clue to a problem Slopify cannot see. Keys never reach here: callers redact first.
6
+ // A provider's error body can be a whole HTML page; the first few hundred characters say why.
7
+ const detailMax = 300;
8
+ export function missingKey(provider) {
9
+ return `No ${provider} API key is saved. Add one in Settings → Providers, then use Retry stage.`;
10
+ }
11
+ export const refreshList = "refresh the list";
12
+ export const checkChoice = "Check the provider and model chosen in the Providers section of Edit project, then use Retry stage.";
13
+ export function httpFailure(failure) {
14
+ const { provider, status } = failure;
15
+ const said = quoted(failure.label ?? `error ${String(status)}`, failure.detail);
16
+ const next = failure.next ?? "use Retry stage";
17
+ if (status === 401) {
18
+ return `${provider} did not accept the API key${said}. The key may be wrong, expired or revoked: paste a current key in Settings → Providers, then ${next}.`;
19
+ }
20
+ if (status === 403) {
21
+ return `${provider} refused access${said}. The API key may not be allowed to use this model, or the account needs attention: check your ${provider} account and the key in Settings → Providers, then ${next}.`;
22
+ }
23
+ if (status === 402) {
24
+ return `${provider} says the account is out of credits${said}. Add credits on the ${provider} website, then ${next}.`;
25
+ }
26
+ if (status === 429) {
27
+ return `${provider} is limiting requests${said}. Too many requests were sent at once or the plan's quota is used up: wait a few minutes or check your ${provider} usage and billing, then ${next}.`;
28
+ }
29
+ if (status >= 400 && status < 500) {
30
+ return `${provider} rejected the ${failure.subject ?? "request"}${said}. ${failure.fix ?? (failure.next === undefined ? checkChoice : `Check the key in Settings → Providers, then ${next}.`)}`;
31
+ }
32
+ if (status >= 500) {
33
+ return `${provider} had a problem on its side${said}. This is usually temporary: wait a few minutes, then ${next}.`;
34
+ }
35
+ return `${provider} answered with an unexpected error${said}. ${failure.next === undefined ? "Use Retry stage" : "Try again"}; if it keeps failing, use Download diagnostics in Settings and report it.`;
36
+ }
37
+ // A failure the provider reported inside an otherwise successful answer, with no status.
38
+ export function providerSaid(provider, what, detail, next) {
39
+ return `${provider} ${what}${quoted("", detail)}. ${next}`;
40
+ }
41
+ export function unreadable(provider) {
42
+ return `${provider} sent back an answer Slopify could not read. This is usually temporary: use Retry stage; if it keeps happening, use Download diagnostics in Settings and report it.`;
43
+ }
44
+ export function droppedStream(provider) {
45
+ return `The connection to ${provider} dropped before the answer was complete. Check your internet connection, then use Retry stage.`;
46
+ }
47
+ export function noAudio(provider) {
48
+ return `${provider} finished without sending any audio. Use Retry stage; if it keeps happening, check the voice in Settings → Voices.`;
49
+ }
50
+ export function noImage(provider) {
51
+ return `${provider} finished without sending an image. Use Retry stage; if it keeps happening, reword the image prompt in the Images section of Edit project or choose another image model in its Providers section.`;
52
+ }
53
+ export function refusedImage(provider, detail) {
54
+ return `${provider} refused to make this image under its content rules${quoted("", detail)}. Reword the image prompt in the Images section of Edit project, or choose another image provider in its Providers section.`;
55
+ }
56
+ // A narration request the provider turned down is most often a voice that no longer exists
57
+ // or a part longer than the model takes.
58
+ export function voiceFix(voiceId) {
59
+ return `Check that voice "${voiceId}" still exists in Settings → Voices and that each narration part fits the model's length limit (Chunking, in the Providers section of Edit project), then use Retry stage.`;
60
+ }
61
+ export function internalError(what) {
62
+ return `Slopify hit an internal error (${what}). Use Retry stage; if it happens again, use Download diagnostics in Settings and report it.`;
63
+ }
64
+ const cliNames = {
65
+ claude: "Claude Code CLI",
66
+ "claude-code": "Claude Code CLI",
67
+ codex: "Codex CLI",
68
+ gemini: "Gemini CLI",
69
+ };
70
+ // A configured path (`/opt/bin/claude`, `C:\\tools\\codex.cmd`) still names the CLI the user chose.
71
+ export function cliName(binary) {
72
+ const base = commandOf(binary);
73
+ return cliNames[base] ?? `"${base}" CLI`;
74
+ }
75
+ // What to type in a terminal to run it.
76
+ export function commandOf(binary) {
77
+ return (binary.split(/[\\/]/).at(-1) ?? binary).replace(/\.(?:cmd|exe|bat|js|mjs)$/i, "");
78
+ }
79
+ // An error the CLI itself reported, in its own words, with the step that most often fixes it.
80
+ export function cliReported(binary, detail, next) {
81
+ return `The ${cliName(binary)} reported an error${quoted("", detail)}. ${next}`;
82
+ }
83
+ export function cliCheck(binary) {
84
+ return `Use Retry stage; if it keeps failing, run ${commandOf(binary)} in a terminal to check it works and is signed in, or choose another model in the Providers section of Edit project.`;
85
+ }
86
+ // ` (error 401: "Unauthorized")`, ` (error 401)`, ` ("Unauthorized")`, or nothing.
87
+ export function quoted(label, detail) {
88
+ const text = detail.trim();
89
+ const clipped = text.length > detailMax ? `${text.slice(0, detailMax)}…` : text;
90
+ if (clipped === "")
91
+ return label === "" ? "" : ` (${label})`;
92
+ return label === "" ? ` ("${clipped}")` : ` (${label}: "${clipped}")`;
93
+ }
@@ -15,7 +15,7 @@ export async function prepareFfmpeg(deps) {
15
15
  return override;
16
16
  }
17
17
  if (typeof deps.bundled !== "string" || deps.bundled === "") {
18
- throw new Error(`No ffmpeg build is available for this platform. ${remedy}`);
18
+ throw new Error(`Slopify has no built-in copy of ffmpeg (the tool it uses to make video) for this kind of computer. Install ffmpeg, then restart Slopify. ${remedy}`);
19
19
  }
20
20
  if (existsSync(deps.bundled)) {
21
21
  await verify(deps.bundled);
@@ -58,7 +58,7 @@ export async function prepareFfmpeg(deps) {
58
58
  rmSync(staging, { recursive: true, force: true });
59
59
  }
60
60
  }
61
- const remedy = "You can also set SLOPIFY_FFMPEG (or FFMPEG_BIN) to a working ffmpeg executable.";
61
+ const remedy = "You can also set the SLOPIFY_FFMPEG (or FFMPEG_BIN) environment variable to the full path of a working ffmpeg.";
62
62
  async function verify(bin) {
63
63
  try {
64
64
  const { stdout } = await promisify(execFile)(bin, ["-version"], {
@@ -70,7 +70,7 @@ async function verify(bin) {
70
70
  throw new Error("the executable did not identify itself as ffmpeg");
71
71
  }
72
72
  catch (error) {
73
- throw new Error(`ffmpeg at ${bin} could not be started. ${remedy} ${detail(error)}`);
73
+ throw new Error(`ffmpeg at ${bin} could not be started. Reinstall ffmpeg or check your antivirus has not quarantined it, then restart Slopify. ${remedy} ${detail(error)}`);
74
74
  }
75
75
  }
76
76
  function detail(error) {
@@ -1,8 +1,9 @@
1
1
  import { redact } from "../../kernel/log.js";
2
- import { bridgeLimits, hostFaultSchema, hostFrameSchema, hostHealthSchema, hostImageSchema, hostLlmSchema, hostModelsSchema, hostStatusSchema, } from "../../kernel/ports/host-cli.js";
2
+ import { bridgeLimits, hostFaultSchema, hostFrameSchema, hostHealthSchema, hostImageSchema, hostLlmSchema, hostModelsSchema, hostOpenedSchema, hostOpenFolderSchema, hostStatusSchema, } from "../../kernel/ports/host-cli.js";
3
3
  import { isProviderError, providerError } from "../../kernel/ports/model.js";
4
4
  import { sniffImage } from "../image/bytes.js";
5
5
  import { hostRequest, hostUnavailable, readHostBytes, } from "./transport.js";
6
+ const tooBigForHost = "Slopify could not send this request to the host helper because it is larger than, or shaped differently from, what the helper accepts. Make the inputs shorter in Edit project, then use Retry stage; if it happens again, use Download diagnostics in Settings and report it.";
6
7
  export function createHostCliClient(options) {
7
8
  async function request(input) {
8
9
  if (!options.directory)
@@ -49,6 +50,30 @@ export function createHostCliClient(options) {
49
50
  ...(model.thinkingModes === undefined ? {} : { thinkingModes: model.thinkingModes }),
50
51
  }));
51
52
  return {
53
+ openFolder: async (path, signal) => {
54
+ const parsed = hostOpenFolderSchema.safeParse({ path });
55
+ if (!parsed.success || !options.directory)
56
+ return false;
57
+ // An older helper answers 404 and a refused folder 403; either way the caller shows the path.
58
+ try {
59
+ const response = await hostRequest({
60
+ directory: options.directory,
61
+ path: "/v1/open-folder",
62
+ method: "POST",
63
+ kind: "metadata",
64
+ body: Buffer.from(JSON.stringify(parsed.data)),
65
+ signal,
66
+ });
67
+ if (response.statusCode !== 200) {
68
+ response.destroy();
69
+ return false;
70
+ }
71
+ return hostOpenedSchema.safeParse(JSON.parse((await readHostBytes(response, bridgeLimits.status)).toString("utf8"))).success;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ },
52
77
  status: async (id) => {
53
78
  try {
54
79
  const status = await metadata(`/v1/status/${id}`, hostStatusSchema);
@@ -88,7 +113,10 @@ export function createHostCliClient(options) {
88
113
  ...(req.webSearch === undefined ? {} : { webSearch: req.webSearch }),
89
114
  });
90
115
  if (!parsed.success)
91
- throw providerError({ kind: "unsupported", message: "Invalid host LLM request." });
116
+ throw providerError({
117
+ kind: "unsupported",
118
+ message: tooBigForHost,
119
+ });
92
120
  await ready(req.signal);
93
121
  const response = await request({
94
122
  path: `/v1/llm/${id}`,
@@ -122,7 +150,10 @@ export function createHostCliClient(options) {
122
150
  aspect: req.aspect,
123
151
  });
124
152
  if (!parsed.success)
125
- throw providerError({ kind: "unsupported", message: "Invalid host image request." });
153
+ throw providerError({
154
+ kind: "unsupported",
155
+ message: tooBigForHost,
156
+ });
126
157
  await ready(req.signal);
127
158
  const response = await request({
128
159
  path: "/v1/image",
@@ -8,8 +8,8 @@ export function hostUnavailable(submitted = false) {
8
8
  return providerError({
9
9
  kind: "unavailable",
10
10
  message: submitted
11
- ? "The host CLI connection ended without a reliable result; the result may be uncertain. Review the affected rebuild before retrying."
12
- : "Host helper unavailable. Run npx @gentbajko/slopify@latest --docker on the host to set it up or check its service.",
11
+ ? "The connection to the host helper dropped before the CLI's answer arrived, so Slopify cannot tell whether it finished (it may already have used your quota). Check the host helper is still running, then use Retry stage."
12
+ : "Slopify cannot reach its host helper, which runs the Claude Code, Codex and Gemini CLIs outside Docker. On the host computer, run npx @gentbajko/slopify@latest --docker to set it up or restart it, then use Retry stage.",
13
13
  });
14
14
  }
15
15
  async function tokenAt(directory) {
@@ -33,7 +33,7 @@ export async function hostRequest(options) {
33
33
  if (options.body && options.body.byteLength > bridgeLimits.request)
34
34
  throw providerError({
35
35
  kind: "unsupported",
36
- message: "The host provider request exceeds 16 MiB.",
36
+ message: "This request is larger than the host helper accepts (16 MB), so nothing was sent. Make the inputs shorter in Edit project, or choose an API provider such as OpenRouter in its Providers section, then use Retry stage.",
37
37
  });
38
38
  let token;
39
39
  try {
@@ -121,7 +121,7 @@ export async function readHostBytes(response, maximum) {
121
121
  if (length > maximum)
122
122
  throw providerError({
123
123
  kind: "unavailable",
124
- message: "Host response exceeded its size limit.",
124
+ message: "The host helper sent back more data than Slopify accepts, so the answer was dropped. Use Retry stage; if it happens again, use Download diagnostics in Settings and report it.",
125
125
  });
126
126
  chunks.push(value);
127
127
  }
@@ -40,7 +40,7 @@ export async function downloadImage(download) {
40
40
  // quoted back - a fal or Replicate delivery link carries its own signature.
41
41
  throw providerError({
42
42
  kind: "other",
43
- message: `${download.provider} answered ${String(response.status)} for the image it said it had made`,
43
+ message: `${download.provider} made the image, but Slopify could not download it (error ${String(response.status)}). Check your internet connection, then use Retry stage.`,
44
44
  });
45
45
  }
46
46
  const bytes = new Uint8Array(await response.arrayBuffer());
@@ -48,7 +48,7 @@ export async function downloadImage(download) {
48
48
  if (mime === undefined) {
49
49
  throw providerError({
50
50
  kind: "other",
51
- message: `${download.provider}'s image link answered with ${describeBytes(bytes)} rather than a PNG or a JPEG`,
51
+ message: `${download.provider} sent back something that is not a PNG or JPEG image (${describeBytes(bytes)}). Use Retry stage; if it keeps happening, choose another image model in the Providers section of Edit project.`,
52
52
  });
53
53
  }
54
54
  return { bytes, mime };
@@ -8,23 +8,23 @@ const maxOutputBytes = 32 * 1024 * 1024;
8
8
  function unavailable(detail) {
9
9
  return providerError({
10
10
  kind: "unavailable",
11
- message: `Codex finished, but ${detail}. An image may already exist in Codex; review before retrying.`,
11
+ message: `The Codex CLI finished, but Slopify could not collect the image: ${detail}. Use Retry stage to make it again (this uses your Codex quota again).`,
12
12
  });
13
13
  }
14
14
  export function codexGeneratedImage(env, threadId, startedAt) {
15
15
  if (!z.uuid().safeParse(threadId).success || threadId === undefined)
16
- throw unavailable("its image session could not be identified");
16
+ throw unavailable("the image job could not be identified");
17
17
  const root = join(resolve(env.CODEX_HOME || join(env.HOME || homedir(), ".codex")), "generated_images");
18
18
  const directory = join(root, threadId);
19
19
  try {
20
20
  for (const path of [root, directory]) {
21
21
  const entry = lstatSync(path);
22
22
  if (!entry.isDirectory() || entry.isSymbolicLink())
23
- throw unavailable("its image directory is unsafe");
23
+ throw unavailable("Codex's image folder is not a plain folder");
24
24
  }
25
25
  const canonical = realpathSync(directory);
26
26
  if (canonical !== join(realpathSync(root), threadId))
27
- throw unavailable("its image directory changed");
27
+ throw unavailable("Codex's image folder moved while it was being read");
28
28
  // Codex 0.155.1 omits image items from exec JSONL. Its artifact contract is
29
29
  // generated_images/<thread.started ID>/<sanitized tool call ID>.png.
30
30
  const dir = opendirSync(directory);
@@ -32,11 +32,11 @@ export function codexGeneratedImage(env, threadId, startedAt) {
32
32
  try {
33
33
  const entry = dir.readSync();
34
34
  if (entry === null)
35
- throw unavailable("no image was saved for this session");
35
+ throw unavailable("no image was saved");
36
36
  if (dir.readSync() !== null)
37
- throw unavailable("more than one output was saved for this session");
37
+ throw unavailable("more than one file was saved");
38
38
  if (!entry.isFile() || !/^[a-zA-Z0-9_-]+\.png$/.test(entry.name))
39
- throw unavailable("its image output is unsafe");
39
+ throw unavailable("the saved file is not a plain image file");
40
40
  name = entry.name;
41
41
  }
42
42
  finally {
@@ -45,7 +45,7 @@ export function codexGeneratedImage(env, threadId, startedAt) {
45
45
  const path = join(directory, name);
46
46
  const before = lstatSync(path);
47
47
  if (!before.isFile() || before.isSymbolicLink() || realpathSync(path) !== join(canonical, name))
48
- throw unavailable("its image output is unsafe");
48
+ throw unavailable("the saved file is not a plain image file");
49
49
  const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
50
50
  try {
51
51
  const stat = fstatSync(fd);
@@ -56,7 +56,7 @@ export function codexGeneratedImage(env, threadId, startedAt) {
56
56
  stat.size === 0 ||
57
57
  stat.size > maxOutputBytes ||
58
58
  stat.mtimeMs < Math.floor(startedAt / 1000) * 1000)
59
- throw unavailable("its image output is stale or unsafe");
59
+ throw unavailable("the saved file is empty, too large, older than this job, or not a plain file");
60
60
  const bytes = Buffer.alloc(stat.size + 1);
61
61
  let length = 0;
62
62
  while (length < bytes.length) {
@@ -71,11 +71,11 @@ export function codexGeneratedImage(env, threadId, startedAt) {
71
71
  after.mtimeMs !== stat.mtimeMs ||
72
72
  after.ctimeMs !== stat.ctimeMs ||
73
73
  realpathSync(directory) !== canonical)
74
- throw unavailable("its image changed while being collected");
74
+ throw unavailable("the file changed while it was being read");
75
75
  const image = bytes.subarray(0, length);
76
76
  const mime = sniffImage(image);
77
77
  if (mime === undefined)
78
- throw unavailable("its output is not a PNG or JPEG image");
78
+ throw unavailable("the saved file is not a PNG or JPEG image");
79
79
  return { bytes: image, mime };
80
80
  }
81
81
  finally {
@@ -85,6 +85,6 @@ export function codexGeneratedImage(env, threadId, startedAt) {
85
85
  catch (error) {
86
86
  if (isProviderError(error))
87
87
  throw error;
88
- throw unavailable("its saved image could not be read");
88
+ throw unavailable("the saved file could not be read");
89
89
  }
90
90
  }
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { redact } from "../../kernel/log.js";
6
6
  import { providerError } from "../../kernel/ports/model.js";
7
+ import { cliReported, quoted, refusedImage } from "../explain.js";
7
8
  import { cliLoginError } from "../llm/cli-login-error.js";
8
9
  import { cliEvent, cliShaped, endedWithout, stopCliRun } from "../llm/run-cli.js";
9
10
  import { lines } from "../llm/sse-lines.js";
@@ -87,7 +88,10 @@ export function codexImage(deps) {
87
88
  models: async () => [codexImageModel],
88
89
  generate: async (req) => {
89
90
  if (req.model !== codexImageModel.id)
90
- throw providerError({ kind: "unsupported", message: "Choose the Codex image capability." });
91
+ throw providerError({
92
+ kind: "unsupported",
93
+ message: "The Codex CLI makes images only with its own image model. Choose that model for images in the Providers section of Edit project, then use Retry stage.",
94
+ });
91
95
  req.signal.throwIfAborted();
92
96
  const directory = mkdtempSync(join(tmpdir(), "slopify-codex-image-"));
93
97
  const env = imageEnvironment(directory, deps.env ?? process.env);
@@ -102,7 +106,10 @@ export function codexImage(deps) {
102
106
  });
103
107
  }
104
108
  catch {
105
- throw providerError({ kind: "unsupported", message: "Could not start the Codex CLI." });
109
+ throw providerError({
110
+ kind: "unsupported",
111
+ message: "The Codex CLI could not be started. Check it is installed and set up in Settings → Providers, then use Retry stage.",
112
+ });
106
113
  }
107
114
  let completed = false;
108
115
  let unavailable = false;
@@ -115,7 +122,7 @@ export function codexImage(deps) {
115
122
  if (threadId !== undefined)
116
123
  throw providerError({
117
124
  kind: "unavailable",
118
- message: "Codex reported more than one image session. Review before retrying.",
125
+ message: "The Codex CLI started more than one image job for one image, so Slopify cannot tell which result is right (both may have used your quota). Use Retry stage to make the image again.",
119
126
  });
120
127
  threadId = cliShaped(binary, z.object({ thread_id: z.string() }), event.value).thread_id;
121
128
  }
@@ -128,7 +135,9 @@ export function codexImage(deps) {
128
135
  throw login;
129
136
  throw providerError({
130
137
  kind: /refus|content.policy|safety/i.test(message) ? "refusal" : "other",
131
- message: redact(message),
138
+ message: /refus|content.policy|safety/i.test(message)
139
+ ? refusedImage("The Codex CLI", redact(message))
140
+ : cliReported(binary, redact(message), imageNext),
132
141
  });
133
142
  }
134
143
  else if (event.type === "error") {
@@ -140,7 +149,9 @@ export function codexImage(deps) {
140
149
  kind: /image.generation|image tool|feature.*unavailable/i.test(message)
141
150
  ? "unsupported"
142
151
  : "other",
143
- message: redact(message),
152
+ message: /image.generation|image tool|feature.*unavailable/i.test(message)
153
+ ? `The Codex CLI cannot make images${quoted("", redact(message))}. ${cannotDraw}`
154
+ : cliReported(binary, redact(message), imageNext),
144
155
  });
145
156
  }
146
157
  else if (event.type === "item.completed") {
@@ -154,7 +165,10 @@ export function codexImage(deps) {
154
165
  const ended = await run.ended;
155
166
  req.signal.throwIfAborted();
156
167
  if (ended.error !== null)
157
- throw providerError({ kind: "unsupported", message: "Could not start the Codex CLI." });
168
+ throw providerError({
169
+ kind: "unsupported",
170
+ message: "The Codex CLI could not be started. Check it is installed and set up in Settings → Providers, then use Retry stage.",
171
+ });
158
172
  if (ended.code !== 0 || !completed)
159
173
  throw (cliLoginError("codex", run.stderr()) ??
160
174
  providerError({
@@ -164,7 +178,7 @@ export function codexImage(deps) {
164
178
  if (unavailable)
165
179
  throw providerError({
166
180
  kind: "unsupported",
167
- message: "This Codex install cannot generate images.",
181
+ message: `The Codex CLI cannot make images. ${cannotDraw}`,
168
182
  });
169
183
  return codexGeneratedImage(env, threadId, startedAt);
170
184
  }
@@ -191,11 +205,13 @@ async function* bounded(source, maximum) {
191
205
  if (size > maximum)
192
206
  throw providerError({
193
207
  kind: "other",
194
- message: "Codex wrote too much image progress output.",
208
+ message: "The Codex CLI sent far more output than an image job should, so Slopify stopped it. Use Retry stage; if it keeps happening, update the Codex CLI to the latest version.",
195
209
  });
196
210
  yield chunk;
197
211
  }
198
212
  }
213
+ const imageNext = "Use Retry stage; if it keeps failing, run codex in a terminal to check it works and is signed in, or choose another image provider in the Providers section of Edit project.";
214
+ const cannotDraw = "Update the Codex CLI and check your ChatGPT plan includes image generation, or choose another image provider in the Providers section of Edit project.";
199
215
  function imageEnvironment(directory, source) {
200
216
  const env = { PWD: directory };
201
217
  const allowed = [