@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
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { createReadStream, createWriteStream } from "node:fs";
3
- import { mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
3
+ import { chmod, copyFile, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { Readable } from "node:stream";
6
6
  import { pipeline } from "node:stream/promises";
@@ -18,6 +18,8 @@ export async function prepareModel(input, deps = { model: alignmentModel, fetch:
18
18
  const target = join(input.cacheDir, deps.model.filename);
19
19
  if (await verified(target, deps.model, input.signal))
20
20
  return target;
21
+ if (input.seed !== undefined && (await verified(input.seed, deps.model, input.signal)))
22
+ return seedModel(input, input.seed, target);
21
23
  let lastFailure;
22
24
  for (let attempt = 1; attempt <= 3; attempt += 1) {
23
25
  try {
@@ -127,6 +129,20 @@ async function downloadModel(input, deps, target) {
127
129
  await rm(staging, { recursive: true, force: true });
128
130
  }
129
131
  }
132
+ async function seedModel(input, seed, target) {
133
+ const staging = await mkdtemp(join(input.cacheDir, ".alignment-seed-"));
134
+ try {
135
+ const part = join(staging, "model.part");
136
+ await copyFile(seed, part);
137
+ await chmod(part, 0o600);
138
+ input.signal.throwIfAborted();
139
+ await rename(part, target);
140
+ return target;
141
+ }
142
+ finally {
143
+ await rm(staging, { recursive: true, force: true });
144
+ }
145
+ }
130
146
  function interrupted(cause) {
131
147
  const error = new Error("The subtitle model download was interrupted.", { cause });
132
148
  error.name = "SubtitleModelDownloadInterrupted";
@@ -0,0 +1,35 @@
1
+ import { mkdir, readFile, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { prepareModel } from "./cache.js";
4
+ import { claimWorker } from "./lock.js";
5
+ // Readies the subtitle model when the app starts, so the first captioned render never waits
6
+ // on a 95 MB download in the middle of a run. It takes the worker lock, so a render that
7
+ // starts meanwhile waits for this copy rather than fetching a second one.
8
+ export async function prefetchModel(input, deps) {
9
+ await mkdir(input.cacheDir, { recursive: true, mode: 0o700 });
10
+ await clearOwnLock(input.cacheDir);
11
+ const release = await claimWorker(input.cacheDir, input.signal);
12
+ try {
13
+ return await prepareModel({ cacheDir: input.cacheDir, seed: input.seed, signal: input.signal }, deps);
14
+ }
15
+ finally {
16
+ await release();
17
+ }
18
+ }
19
+ // The lock names its holder by pid. A lock that names this process when the app has only
20
+ // just started was left by an earlier run: in a container the app is always pid 1, so the
21
+ // liveness check in claimWorker would take it for a live holder and wait forever.
22
+ async function clearOwnLock(cacheDir) {
23
+ const path = join(cacheDir, "alignment-worker.lock");
24
+ let owner;
25
+ try {
26
+ owner = await readFile(path, "utf8");
27
+ }
28
+ catch (error) {
29
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
30
+ return;
31
+ throw error;
32
+ }
33
+ if (Number(owner) === process.pid)
34
+ await rm(path, { force: true });
35
+ }
@@ -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,
@@ -1,5 +1,6 @@
1
1
  import { open, readFile, stat } from "node:fs/promises";
2
2
  import * as ort from "onnxruntime-web/wasm";
3
+ import { SubtitleMismatch } from "../../kernel/ports/subtitles.js";
3
4
  import { mismatch } from "./ctc.js";
4
5
  import { workerInput } from "./protocol.js";
5
6
  import { speechWords } from "./text.js";
@@ -13,7 +14,13 @@ 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);
@@ -64,11 +71,20 @@ async function run(input) {
64
71
  const finalWindow = sampleAt + count >= totalSamples;
65
72
  const complete = finalWindow && cursor + candidate.length === source.length;
66
73
  const cutoff = finalWindow ? count / sampleRate : count / sampleRate - overlapSeconds;
67
- const recovered = alignSpeechWindow(logits.data, frames, candidate, complete, cutoff, cursor === 0 ? 0 : omissionBudget - omitted);
74
+ const stuck = () => new SubtitleMismatch(mismatch, sampleAt / sampleRate, snippet(candidate.map((word) => word.text)), snippet(observed.split(/\s+/)));
75
+ let recovered;
76
+ try {
77
+ recovered = alignSpeechWindow(logits.data, frames, candidate, complete, cutoff, cursor === 0 ? 0 : omissionBudget - omitted);
78
+ }
79
+ catch (error) {
80
+ if (error instanceof Error && error.message === mismatch)
81
+ throw stuck();
82
+ throw error;
83
+ }
68
84
  const accepted = recovered.words;
69
85
  const last = accepted.at(-1);
70
86
  if (last === undefined)
71
- throw new Error(mismatch);
87
+ throw stuck();
72
88
  if (recovered.skipped > 0) {
73
89
  omitted += recovered.skipped;
74
90
  send({
@@ -95,7 +111,7 @@ async function run(input) {
95
111
  tensor.dispose();
96
112
  }
97
113
  if (cursor !== source.length || output.length === 0)
98
- throw new Error(mismatch);
114
+ throw new SubtitleMismatch(mismatch, sampleAt / sampleRate, snippet(source.slice(cursor).map((word) => word.text)), "");
99
115
  // A substantial spoken tail absent from the transcript is a mismatch too.
100
116
  while (sampleAt + sampleRate < totalSamples) {
101
117
  const count = Math.min(windowSeconds * sampleRate, totalSamples - sampleAt);
@@ -110,7 +126,7 @@ async function run(input) {
110
126
  if (logits !== undefined &&
111
127
  logits.data instanceof Float32Array &&
112
128
  greedy(logits.data, logits.dims[1] ?? 0).replace(/[^A-Z]/g, "").length > 8)
113
- throw new Error(mismatch);
129
+ throw new SubtitleMismatch(mismatch, sampleAt / sampleRate, "", snippet(greedy(logits.data, logits.dims[1] ?? 0).split(/\s+/)));
114
130
  for (const tensor of Object.values(result))
115
131
  tensor.dispose();
116
132
  }
@@ -140,6 +156,13 @@ function candidates(source, cursor, observed) {
140
156
  }
141
157
  return selected;
142
158
  }
159
+ // A dozen words: enough to find the place in the text, short enough for an error row.
160
+ function snippet(words) {
161
+ return words
162
+ .filter((word) => word !== "")
163
+ .slice(0, 12)
164
+ .join(" ");
165
+ }
143
166
  function normalize(audio) {
144
167
  let mean = 0;
145
168
  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) {
@@ -3,6 +3,7 @@ import { bridgeLimits, hostFaultSchema, hostFrameSchema, hostHealthSchema, hostI
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)
@@ -88,7 +89,10 @@ export function createHostCliClient(options) {
88
89
  ...(req.webSearch === undefined ? {} : { webSearch: req.webSearch }),
89
90
  });
90
91
  if (!parsed.success)
91
- throw providerError({ kind: "unsupported", message: "Invalid host LLM request." });
92
+ throw providerError({
93
+ kind: "unsupported",
94
+ message: tooBigForHost,
95
+ });
92
96
  await ready(req.signal);
93
97
  const response = await request({
94
98
  path: `/v1/llm/${id}`,
@@ -122,7 +126,10 @@ export function createHostCliClient(options) {
122
126
  aspect: req.aspect,
123
127
  });
124
128
  if (!parsed.success)
125
- throw providerError({ kind: "unsupported", message: "Invalid host image request." });
129
+ throw providerError({
130
+ kind: "unsupported",
131
+ message: tooBigForHost,
132
+ });
126
133
  await ready(req.signal);
127
134
  const response = await request({
128
135
  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 = [
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { redact } from "../../kernel/log.js";
3
3
  import { providerError } from "../../kernel/ports/model.js";
4
+ import { httpFailure, missingKey, noImage, unreadable } from "../explain.js";
4
5
  import { retryAfter } from "../retry-after.js";
5
6
  import { downloadImage } from "./bytes.js";
6
7
  // The HTTP gateway adapter for fal.ai: `fetch` and the downloader beside this file, no SDK.
@@ -88,12 +89,12 @@ export function falImage(deps) {
88
89
  refused(answer, req.prompt);
89
90
  const first = answer.images[0];
90
91
  if (first === undefined) {
91
- throw providerError({ kind: "other", message: "fal answered with no image" });
92
+ throw providerError({ kind: "other", message: noImage("fal.ai") });
92
93
  }
93
94
  // The download rides inside the attempt: a link that 404s is a failed attempt.
94
95
  return await downloadImage({
95
96
  fetch: deps.fetch,
96
- provider: "fal",
97
+ provider: "fal.ai",
97
98
  url: first.url,
98
99
  signal: req.signal,
99
100
  });
@@ -104,7 +105,7 @@ function keyOf(deps) {
104
105
  // `missing_key`, not `auth`, because that rule makes it terminal.
105
106
  const key = deps.key();
106
107
  if (key === undefined || key === "") {
107
- throw providerError({ kind: "missing_key", message: "no fal key is stored" });
108
+ throw providerError({ kind: "missing_key", message: missingKey("fal.ai") });
108
109
  }
109
110
  return key;
110
111
  }
@@ -116,7 +117,7 @@ function refused(answer, prompt) {
116
117
  if (flags.length > 0 && flags.every((flagged) => flagged)) {
117
118
  throw providerError({
118
119
  kind: "refusal",
119
- message: `fal's safety checker rejected every image for this prompt: ${redact(prompt)}`,
120
+ message: `fal.ai's safety checker blocked the image for this prompt: "${redact(prompt)}". Reword the image prompt in the Images section of Edit project, or choose another image provider in its Providers section.`,
120
121
  });
121
122
  }
122
123
  }
@@ -140,7 +141,12 @@ async function failure(response) {
140
141
  const retryAfterMs = retryAfter(response.headers.get("retry-after"));
141
142
  return providerError({
142
143
  kind: kindOf(response.status),
143
- message: `fal answered ${String(response.status)}: ${message}`,
144
+ message: httpFailure({
145
+ provider: "fal.ai",
146
+ status: response.status,
147
+ detail: message,
148
+ subject: "image request",
149
+ }),
144
150
  ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
145
151
  });
146
152
  }
@@ -160,7 +166,7 @@ function parse(text) {
160
166
  if (!parsed.success) {
161
167
  throw providerError({
162
168
  kind: "other",
163
- message: "fal's answer was not in the shape this app can read",
169
+ message: unreadable("fal.ai"),
164
170
  });
165
171
  }
166
172
  return parsed.data;