@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
@@ -14,11 +14,11 @@ export function exportSnapshot(deps, context, piece) {
14
14
  .prepare("SELECT recipe_context FROM revision_work WHERE id=?")
15
15
  .get(context.work.workId);
16
16
  if (view === undefined || row === undefined)
17
- throw new Error("The export revision no longer exists.");
17
+ throw new Error("Slopify hit an internal error (the project version being exported is missing). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
18
18
  const plan = executionPlan(deps, view, savedCatalogue(row.recipe_context));
19
19
  const recipe = plan.recipes.find((one) => one.key === piece.key && one.fingerprint === piece.fingerprint);
20
20
  if (recipe === undefined || recipe.deferred || recipe.unresolved)
21
- throw new Error("The export inputs do not match the admitted recipe.");
21
+ throw new Error("Slopify hit an internal error (the export no longer matches the project's saved inputs). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
22
22
  return { view, plan };
23
23
  }
24
24
  export async function revisionAudio(deps, context, view) {
@@ -34,12 +34,12 @@ export async function revisionAudio(deps, context, view) {
34
34
  (await (deps.measureAudio?.(path, context.signal) ??
35
35
  probeDurationMs(deps.ffmpeg, path, context.signal, deps.log)));
36
36
  if (!Number.isFinite(duration) || duration <= 0)
37
- throw new Error("The narration duration could not be measured.");
37
+ throw new Error("Slopify couldn't read the length of the narration audio, so the file may be damaged or empty. Regenerate the narration (or upload your audio file again in Edit project → Narration), then Retry stage.");
38
38
  return { path, seconds: duration / 1000 };
39
39
  };
40
40
  const body = await input("audio_body");
41
41
  if (body === undefined)
42
- throw new Error("The revision has no complete narration.");
42
+ throw new Error("The narration audio isn't finished yet. Let the Narration stage finish (Resume, or Retry stage on it), then retry this stage.");
43
43
  return audioTimeline({
44
44
  body,
45
45
  intro: config.sources.audio === "generate" && config.intro !== undefined
@@ -72,7 +72,7 @@ export function revisionTranscript(deps, snapshot, kind) {
72
72
  Array.isArray(recipe.input.semantic) &&
73
73
  typeof recipe.input.semantic[0] === "string")
74
74
  return recipe.input.semantic[0];
75
- throw new Error("The saved narration transcript is incomplete.");
75
+ throw new Error("Some narration chunks aren't finished, so the full narration text isn't available. Let the Narration stage finish (Resume, or Retry stage on it), or regenerate the missing chunk in Edit project → Narration.");
76
76
  });
77
77
  if (texts.length > 0)
78
78
  return texts.join("\n");
@@ -84,7 +84,7 @@ export function revisionTranscript(deps, snapshot, kind) {
84
84
  if (view.articleMarkdown !== null)
85
85
  return plainText(splitEndMatter(view.articleMarkdown).body);
86
86
  }
87
- throw new Error(`The saved ${kind} narration transcript is missing.`);
87
+ throw new Error(`The saved text of the ${kind === "body" ? "article" : kind} is missing, so it can't be matched to the narration. Use Re-run section on Article, then Retry stage.`);
88
88
  }
89
89
  export function retainedOutput(deps, view, row) {
90
90
  const asset = z
@@ -20,9 +20,9 @@ export async function executeExportRecipe(deps, context, piece) {
20
20
  const audio = await revisionAudio(deps, context, view);
21
21
  const wav = piece.key === "export:wav";
22
22
  if (!wav && piece.key !== "export:video")
23
- throw new Error("Unknown media export recipe.");
23
+ throw new Error("Slopify hit an internal error (unknown kind of export). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
24
24
  if (wav && audio.length === 0)
25
- throw new Error("Audio export needs complete narration.");
25
+ throw new Error("The audio export has nothing to export because narration is turned off for this project. Turn narration on in Edit project, then Retry stage.");
26
26
  const filename = wav ? "audio.wav" : "video.mp4";
27
27
  const pending = allocateAsset(deps, context.work.projectId, filename);
28
28
  const prepared = [];
@@ -48,7 +48,7 @@ export async function executeExportRecipe(deps, context, piece) {
48
48
  const images = (wav ? [] : view.revision.content.imageOrder).map((key) => {
49
49
  const row = view.outputs.find((one) => one.workKey === `image:${key}` && one.selected && one.available && one.state === "ready");
50
50
  if (row === undefined)
51
- throw new Error("The revision is missing an image selected for its slideshow.");
51
+ throw new Error("The video export can't find one of the slideshow images. Use Re-run section on Images (or regenerate that image in Edit project → Images), then Retry stage.");
52
52
  return outputPath(deps.paths, context.work.projectId, row.output.path);
53
53
  });
54
54
  const plan = wav
@@ -124,7 +124,7 @@ function captionDirectory(deps, context, snapshot) {
124
124
  const ass = find("subtitle_ass");
125
125
  const font = find("subtitle_font");
126
126
  if (ass === undefined || font === undefined)
127
- throw new Error("Burned subtitles need prepared captions and their saved font.");
127
+ throw new Error("Burned-in captions are missing their caption file or font. Use Re-run section on Video (or choose the caption font again in Edit project → Subtitles), then Retry stage.");
128
128
  const directory = mkdtempSync(join(projectDir(deps.paths, context.work.projectId), "render-"));
129
129
  try {
130
130
  mkdirSync(join(directory, "fonts"), { mode: 0o700 });
@@ -19,7 +19,7 @@ export async function executeLocalRecipe(deps, context, piece) {
19
19
  const input = piece.input;
20
20
  if (input.kind === "provided") {
21
21
  if (input.assetId === null)
22
- throw new Error("The provided recipe has no retained asset.");
22
+ throw new Error("A file you uploaded for this project is missing. Upload it again in Edit project, then Retry stage.");
23
23
  const row = z
24
24
  .object({ id: z.string(), project_id: z.string(), path: z.string(), created_at: z.string() })
25
25
  .parse(deps.db
@@ -43,7 +43,7 @@ export async function executeLocalRecipe(deps, context, piece) {
43
43
  return "done";
44
44
  }
45
45
  if (input.kind !== "local")
46
- throw new Error("Expected an exact local recipe.");
46
+ throw new Error("Slopify hit an internal error (a local step was set up wrongly). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
47
47
  if (input.operation === "narration-files-v1") {
48
48
  await publishNarrationText(deps, context, piece);
49
49
  return "done";
@@ -75,19 +75,19 @@ export async function executeLocalRecipe(deps, context, piece) {
75
75
  });
76
76
  return "done";
77
77
  }
78
- throw new Error(`No local executor for ${input.operation}.`);
78
+ throw new Error(`Slopify hit an internal error (no handler for the step "${input.operation}"). Retry stage; if it happens again, use Download diagnostics in Settings and report it.`);
79
79
  }
80
80
  async function concatenate(deps, context, piece) {
81
81
  const view = executionView(deps, context.work.projectId, context.work.revisionId);
82
82
  if (view === undefined)
83
- throw new Error("The pinned revision is missing.");
83
+ throw new Error("Slopify hit an internal error (the project version being built is missing). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
84
84
  const row = deps.db
85
85
  .prepare("SELECT recipe_context FROM revision_work WHERE id=?")
86
86
  .get(context.work.workId);
87
87
  const plan = executionPlan(deps, view, savedCatalogue(row?.recipe_context));
88
88
  const recipe = plan.recipes.find((candidate) => candidate.key === piece.key && candidate.fingerprint === piece.fingerprint);
89
89
  if (recipe === undefined)
90
- throw new Error("The pinned concatenation no longer matches its inputs.");
90
+ throw new Error("Slopify hit an internal error (the narration chunks to join no longer match the project's saved inputs). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
91
91
  const files = recipe.dependsOn.map((key) => {
92
92
  const input = view.pieces.find((candidate) => candidate.key === key &&
93
93
  candidate.selected &&
@@ -95,7 +95,7 @@ async function concatenate(deps, context, piece) {
95
95
  candidate.piece.state === "done" &&
96
96
  candidate.fingerprint === plan.recipes.find((value) => value.key === key)?.fingerprint);
97
97
  if (input?.assetId === null || input === undefined)
98
- throw new Error("A narration part has no retained audio.");
98
+ throw new Error("One narration chunk has no saved audio, so the narration can't be joined together. Regenerate the missing chunk in Edit project → Narration, then Retry stage.");
99
99
  const asset = deps.db
100
100
  .prepare("SELECT path FROM project_assets WHERE id=? AND project_id=?")
101
101
  .get(input.assetId, context.work.projectId);
@@ -9,7 +9,7 @@ export function joinedNarration(parts) {
9
9
  export function narrationTextParts(view, plan, segment) {
10
10
  const concat = plan.recipes.find((row) => row.key === (segment === "body" ? "audio:body:concat" : `audio:${segment}`));
11
11
  if (concat === undefined)
12
- throw new Error(`The saved ${segment} narration is missing.`);
12
+ throw new Error(`Slopify hit an internal error (the ${segment} narration is missing from the project plan). Retry stage; if it happens again, use Download diagnostics in Settings and report it.`);
13
13
  return concat.dependsOn.map((key) => {
14
14
  const recipe = plan.recipes.find((row) => row.key === key);
15
15
  const selected = view.pieces.find((row) => row.key === key &&
@@ -18,7 +18,7 @@ export function narrationTextParts(view, plan, segment) {
18
18
  row.piece.state === "done" &&
19
19
  row.fingerprint === recipe?.fingerprint);
20
20
  if (selected === undefined || recipe === undefined)
21
- throw new Error("The saved narration transcript is incomplete.");
21
+ throw new Error("Some narration chunks aren't finished, so the full narration text isn't available. Let the Narration stage finish (Resume, or Retry stage on it), or regenerate the missing chunk in Edit project → Narration.");
22
22
  const input = recipe.input;
23
23
  if (input.kind === "provided" &&
24
24
  Array.isArray(input.semantic) &&
@@ -29,13 +29,13 @@ export function narrationTextParts(view, plan, segment) {
29
29
  requestText: null,
30
30
  };
31
31
  if (input.kind !== "tts")
32
- throw new Error("The narration source has not resolved.");
32
+ throw new Error("Slopify hit an internal error (a narration chunk's text was never worked out). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
33
33
  const payload = z
34
34
  .object({ text: z.string().optional(), spokenText: z.string().optional() })
35
35
  .parse(JSON.parse(selected.piece.payload ?? "{}"));
36
36
  if (input.spokenText !== undefined &&
37
37
  (payload.spokenText !== input.spokenText || payload.text !== input.text))
38
- throw new Error("Prepared narration is missing its exact clean transcript.");
38
+ throw new Error("A narration chunk's audio no longer matches its prepared text. Regenerate that chunk in Edit project → Narration, then Retry stage.");
39
39
  return {
40
40
  logicalKey: input.logicalKey,
41
41
  spokenText: input.spokenText === undefined ? (payload.text ?? input.text) : (payload.spokenText ?? ""),
@@ -45,7 +45,7 @@ export function narrationTextParts(view, plan, segment) {
45
45
  }
46
46
  export async function publishNarrationText(deps, context, piece) {
47
47
  if (piece.input.kind !== "local")
48
- throw new Error("Expected a narration-file recipe.");
48
+ throw new Error("Slopify hit an internal error (the narration text file step was set up wrongly). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
49
49
  const { segment } = z
50
50
  .object({ segment: z.enum(["body", "intro", "outro"]) })
51
51
  .parse(piece.input.values);
@@ -54,10 +54,10 @@ export async function publishNarrationText(deps, context, piece) {
54
54
  .prepare("SELECT recipe_context FROM revision_work WHERE id=?")
55
55
  .get(context.work.workId);
56
56
  if (view === undefined)
57
- throw new Error("The pinned narration revision is missing.");
57
+ throw new Error("Slopify hit an internal error (the project version being narrated is missing). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
58
58
  const plan = executionPlan(deps, view, savedCatalogue(row?.recipe_context));
59
59
  if (!plan.recipes.some((recipe) => recipe.key === piece.key && recipe.fingerprint === piece.fingerprint))
60
- throw new Error("Narration text no longer matches the admitted inputs.");
60
+ throw new Error("Slopify hit an internal error (the narration text no longer matches the project's saved inputs). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
61
61
  const parts = narrationTextParts(view, plan, segment);
62
62
  const outputs = preparedTexts(deps, context, piece, [
63
63
  ["narration_txt", `${segment}-narration.txt`, joinedNarration(parts)],
@@ -73,3 +73,42 @@ export async function publishNarrationText(deps, context, piece) {
73
73
  }));
74
74
  await publishResult(deps, context, piece, outputs, { segment });
75
75
  }
76
+ // The narration's chunks in the order they are spoken, each with its clean text. A chunk is
77
+ // sent as one or more parts; the Narration editor numbers these chunks, so anything that
78
+ // names "chunk N" to a person counts from this list.
79
+ export function narrationChunks(parts) {
80
+ const chunks = [];
81
+ for (const part of parts) {
82
+ const last = chunks.at(-1);
83
+ if (last?.key === part.logicalKey)
84
+ chunks[chunks.length - 1] = {
85
+ key: last.key,
86
+ spokenText: `${last.spokenText}${part.spokenText}`,
87
+ };
88
+ else
89
+ chunks.push({ key: part.logicalKey, spokenText: part.spokenText });
90
+ }
91
+ return chunks;
92
+ }
93
+ // The current chunk keys of each narration segment, in spoken order, for the Narration
94
+ // editor. Undefined when the revision has no admitted work to read a plan from, or its
95
+ // narration is not complete yet; the editor then keeps its own listing.
96
+ export function narrationChunkOrder(deps, projectId, revisionId) {
97
+ const row = deps.db
98
+ .prepare("SELECT recipe_context FROM revision_work WHERE project_id=? AND revision_id=? AND recipe_context IS NOT NULL LIMIT 1")
99
+ .get(projectId, revisionId);
100
+ const view = executionView(deps, projectId, revisionId);
101
+ if (row === undefined || view === undefined)
102
+ return undefined;
103
+ const plan = executionPlan(deps, view, savedCatalogue(row.recipe_context));
104
+ const order = {};
105
+ for (const segment of ["intro", "body", "outro"]) {
106
+ try {
107
+ order[segment] = narrationChunks(narrationTextParts(view, plan, segment)).map((chunk) => chunk.key);
108
+ }
109
+ catch {
110
+ // A segment that is off, or not narrated yet, has no chunks to order.
111
+ }
112
+ }
113
+ return order;
114
+ }
@@ -0,0 +1,62 @@
1
+ import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
2
+ // What a failed step is called on screen, so its error says which image, chunk or topic to
3
+ // fix. Worked out only after a failure: numbering a narration chunk means building the plan.
4
+ // Undefined for steps that name their own place (captions, export) or have no single one.
5
+ export function pieceLabel(deps, context, piece) {
6
+ const { key } = piece;
7
+ if (key === "thumbnail:image" || key.startsWith("thumbnail:"))
8
+ return "Thumbnail";
9
+ if (key === "article:body")
10
+ return "Article";
11
+ if (key === "entry:intro:text")
12
+ return "Intro text";
13
+ if (key === "entry:outro:text")
14
+ return "Outro text";
15
+ if (key === "research:planner")
16
+ return "Research plan";
17
+ if (key === "research:notes")
18
+ return "Research notes";
19
+ const topic = /^research:chapter:(\d+)$/.exec(key);
20
+ if (topic !== null)
21
+ return `Research topic ${topic[1] ?? ""}`;
22
+ const view = () => executionView(deps, context.work.projectId, context.work.revisionId);
23
+ if (key.startsWith("image:")) {
24
+ const at = view()?.revision.content.imageOrder.indexOf(key.slice("image:".length)) ?? -1;
25
+ return at === -1 ? "An image" : `Image ${String(at + 1)}`;
26
+ }
27
+ const narration = /^(?:narration:prepare:(?:intro|body|outro):)?audio:(intro|body|outro)(?::(.+))?$/.exec(key);
28
+ if (narration === null)
29
+ return undefined;
30
+ const segment = narration[1] ?? "body";
31
+ const prefix = segment === "body" ? "Narration" : `${segment === "intro" ? "Intro" : "Outro"} narration`;
32
+ if (narration[2] === undefined || narration[2] === "concat" || narration[2] === "future")
33
+ return prefix;
34
+ return narrationChunk(deps, context, segment, key, prefix) ?? prefix;
35
+ }
36
+ function narrationChunk(deps, context, segment, key, prefix) {
37
+ const row = deps.db
38
+ .prepare("SELECT recipe_context FROM revision_work WHERE id=?")
39
+ .get(context.work.workId);
40
+ const view = executionView(deps, context.work.projectId, context.work.revisionId);
41
+ if (row?.recipe_context === null || row === undefined || view === undefined)
42
+ return undefined;
43
+ const plan = executionPlan(deps, view, savedCatalogue(row.recipe_context));
44
+ const concat = plan.recipes.find((recipe) => recipe.key === (segment === "body" ? "audio:body:concat" : `audio:${segment}`));
45
+ if (concat === undefined)
46
+ return undefined;
47
+ // Parts are `<chunk>:<n>`; the chunk is everything before the last colon.
48
+ const chunkOf = (part) => part.slice(0, part.lastIndexOf(":"));
49
+ const chunks = [...new Set(concat.dependsOn.map(chunkOf))];
50
+ const preparing = key.startsWith("narration:prepare:");
51
+ const chunk = preparing ? key.slice(key.indexOf("audio:")) : chunkOf(key);
52
+ const at = chunks.indexOf(chunk);
53
+ if (at === -1)
54
+ return undefined;
55
+ const label = `${prefix} chunk ${String(at + 1)} of ${String(chunks.length)}`;
56
+ if (preparing)
57
+ return `${label} (preparing its text)`;
58
+ const parts = concat.dependsOn.filter((part) => chunkOf(part) === chunk);
59
+ return parts.length > 1
60
+ ? `${label}, part ${String(parts.indexOf(key) + 1)} of ${String(parts.length)}`
61
+ : label;
62
+ }
@@ -117,13 +117,15 @@ export async function executeProviderRecipe(deps, context, providers, piece) {
117
117
  });
118
118
  return "done";
119
119
  }
120
- throw new Error("Expected an exact provider recipe.");
120
+ throw new Error("Slopify hit an internal error (this step has no provider request to run). Use Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
121
121
  }
122
122
  function checkAnswer(piece, answer) {
123
123
  if (answer.text.trim() === "")
124
- return "The provider answered with nothing.";
124
+ return "The AI model sent back an empty answer. Use Retry stage; if it keeps happening, choose another model in the Providers section of Edit project.";
125
125
  if (piece.key === "research:planner")
126
- return chaptersFrom(answer.text).length === 0 ? "The planner named no chapters." : undefined;
126
+ return chaptersFrom(answer.text).length === 0
127
+ ? "The AI model's research plan listed no chapters, so research could not go on. Use Retry stage; if it keeps happening, choose another model in the Providers section of Edit project."
128
+ : undefined;
127
129
  if (piece.key === "research:notes" || piece.key.startsWith("research:chapter:"))
128
130
  return sourcedAnswer(piece.key, answer.text);
129
131
  return undefined;
@@ -175,7 +177,7 @@ async function publishText(deps, context, piece, answer) {
175
177
  : [];
176
178
  const title = titles[Number(piece.key.split(":").at(-1)) - 1];
177
179
  if (typeof title !== "string")
178
- throw new Error("Research chapter has no pinned title.");
180
+ throw new Error("Slopify hit an internal error (a research chapter has no title in the research plan). Use Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
179
181
  const asset = writeAsset(deps, context.work.projectId, `research-${piece.key.split(":").at(-1)}.md`, Buffer.from(answer.text));
180
182
  await publishResult(deps, context, piece, [], { title, notes: answer.text }, asset);
181
183
  return;
@@ -29,7 +29,7 @@ export function preparedResult(deps, context, piece, role, asset, durationMs = n
29
29
  export function preparedText(deps, context, piece, role, filename, text) {
30
30
  const output = preparedTexts(deps, context, piece, [[role, filename, text]])[0];
31
31
  if (output === undefined)
32
- throw new Error("The text output was not prepared.");
32
+ throw new Error("Slopify hit an internal error (a text file could not be prepared). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
33
33
  return output;
34
34
  }
35
35
  export function preparedTexts(deps, context, piece, texts) {
@@ -168,10 +168,10 @@ export function retainPartialArticle(deps, context, piece, text) {
168
168
  if (authority === undefined)
169
169
  return;
170
170
  if (authority.key !== piece.key || authority.fingerprint !== piece.fingerprint)
171
- throw new Error("Partial article differs from its durable piece authority.");
171
+ throw new Error("Slopify hit an internal error (a partly written article doesn't match its saved step). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
172
172
  const revision = revisionById(deps.db, context.work.projectId, context.work.revisionId);
173
173
  if (revision === undefined)
174
- throw new Error("Partial article origin disappeared.");
174
+ throw new Error("Slopify hit an internal error (the project version for a partly written article is missing). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
175
175
  insertAsset(deps.db, result.asset);
176
176
  insertManifestOutput(deps.db, revision, {
177
177
  slot: `article:partial:${piece.id}`,
@@ -1,5 +1,6 @@
1
1
  import { executeExportRecipe } from "./runtime-export.js";
2
2
  import { executeLocalRecipe } from "./runtime-local.js";
3
+ import { pieceLabel } from "./runtime-piece-label.js";
3
4
  import { executeProviderRecipe } from "./runtime-provider.js";
4
5
  import { executeSubtitleRecipe } from "./runtime-subtitles.js";
5
6
  import { workPieces } from "./work-records.js";
@@ -25,7 +26,16 @@ export async function runRevisionInvocation(deps, context, providers) {
25
26
  deps.db
26
27
  .prepare("UPDATE revision_work_pieces SET state=? WHERE id=? AND work_id=? AND state!='done'")
27
28
  .run(context.signal.aborted ? "pending" : "failed", piece.id, context.work.workId);
28
- throw error;
29
+ if (context.signal.aborted || !(error instanceof Error))
30
+ throw error;
31
+ let label;
32
+ try {
33
+ label = pieceLabel(deps, context, piece);
34
+ }
35
+ catch {
36
+ // The error itself matters more than its label.
37
+ }
38
+ throw label === undefined ? error : new Error(`${label}: ${error.message}`, { cause: error });
29
39
  }
30
40
  }
31
41
  deps.db
@@ -94,12 +94,12 @@ export function invocationReady(deps, work) {
94
94
  });
95
95
  });
96
96
  }
97
- export function executionStandings(deps, projectId) {
97
+ export function executionStandings(deps, projectId, derived) {
98
98
  const revisionId = currentRevisionId(deps.db, projectId);
99
99
  const view = revisionId === undefined ? undefined : getRevisionView(deps, projectId, revisionId);
100
100
  if (view === undefined)
101
101
  return [];
102
- const work = executionStages(deps, projectId);
102
+ const work = derived ?? executionStages(deps, projectId);
103
103
  return stageKinds.map((kind) => {
104
104
  const source = view.revision.config.sources[kind];
105
105
  const group = work
@@ -136,20 +136,18 @@ export function executionStandings(deps, projectId) {
136
136
  });
137
137
  }
138
138
  export function projectStandings(deps, projectId) {
139
- const standings = executionStandings(deps, projectId);
139
+ // One derivation serves every stage: each builds the whole execution plan.
140
+ const work = executionStages(deps, projectId);
141
+ const standings = executionStandings(deps, projectId, work);
140
142
  for (const stage of standings) {
141
- const invocations = executionStages(deps, projectId).filter((work) => work.kind === stage.kind);
142
- const current = invocations.reduce((total, work) => {
143
- if (work.state === "done")
143
+ const invocations = work.filter((entry) => entry.kind === stage.kind);
144
+ const current = invocations.reduce((total, entry) => {
145
+ if (entry.state === "done")
144
146
  return total + 1;
145
147
  const progress = deps.db
146
148
  .prepare("SELECT progress_current,progress_total FROM revision_work WHERE id=?")
147
- .get(work.work.workId);
148
- if (typeof progress?.progress_current !== "number" ||
149
- typeof progress.progress_total !== "number" ||
150
- progress.progress_total <= 0)
151
- return total;
152
- return total + Math.max(0, Math.min(1, progress.progress_current / progress.progress_total));
149
+ .get(entry.work.workId);
150
+ return total + fraction(progress?.progress_current, progress?.progress_total);
153
151
  }, 0);
154
152
  const failed = invocations.find((work) => work.state === "failed");
155
153
  const failure = failed === undefined
@@ -163,6 +161,11 @@ export function projectStandings(deps, projectId) {
163
161
  }
164
162
  settleScheduleRunsForProject(deps.db, projectId, deps.clock.now().toISOString());
165
163
  }
164
+ // Progress moves one number and nothing else. The stage row already counts this invocation's
165
+ // last fraction, from the claim or the previous event, so it takes the difference instead
166
+ // of re-deriving the project: that derivation rebuilds every stage's plan and ran to seconds
167
+ // on a long article, which froze the process once per downloaded chunk. State changes still
168
+ // go through projectStandings.
166
169
  export function recordWorkProgress(deps, event) {
167
170
  if (event.workId === undefined ||
168
171
  event.revisionId === undefined ||
@@ -170,8 +173,35 @@ export function recordWorkProgress(deps, event) {
170
173
  !Number.isFinite(event.total) ||
171
174
  event.total <= 0)
172
175
  return;
176
+ const before = deps.db
177
+ .prepare("SELECT progress_current,progress_total FROM revision_work WHERE id=? AND revision_id=? AND project_id=? AND kind=? AND state='running'")
178
+ .get(event.workId, event.revisionId, event.projectId, event.stage);
179
+ if (before === undefined)
180
+ return;
181
+ const current = Math.max(0, Math.min(event.current, event.total));
173
182
  deps.db
174
- .prepare("UPDATE revision_work SET progress_current=?,progress_total=? WHERE id=? AND revision_id=? AND project_id=? AND kind=? AND state='running'")
175
- .run(Math.max(0, Math.min(event.current, event.total)), event.total, event.workId, event.revisionId, event.projectId, event.stage);
176
- projectStandings(deps, event.projectId);
183
+ .prepare("UPDATE revision_work SET progress_current=?,progress_total=? WHERE id=?")
184
+ .run(current, event.total, event.workId);
185
+ // Work an edit retired keeps running until it notices, but the stage row belongs to the
186
+ // current revision's work and must not move with it.
187
+ const head = currentRevisionId(deps.db, event.projectId);
188
+ if (head === undefined ||
189
+ deps.db
190
+ .prepare("SELECT 1 FROM revision_work_reservations WHERE work_id=? AND revision_id=? LIMIT 1")
191
+ .get(event.workId, head) === undefined)
192
+ return;
193
+ const delta = current / event.total - fraction(before.progress_current, before.progress_total);
194
+ if (delta === 0)
195
+ return;
196
+ const moved = deps.db
197
+ .prepare("UPDATE stages SET progress_current=MIN(progress_total,MAX(0,progress_current+?)) WHERE project_id=? AND kind=? AND progress_current IS NOT NULL AND progress_total IS NOT NULL")
198
+ .run(delta, event.projectId, event.stage);
199
+ // A row no derivation has counted yet has nothing to adjust: derive it once.
200
+ if (moved.changes === 0)
201
+ projectStandings(deps, event.projectId);
202
+ }
203
+ function fraction(current, total) {
204
+ if (typeof current !== "number" || typeof total !== "number" || total <= 0)
205
+ return 0;
206
+ return Math.max(0, Math.min(1, current / total));
177
207
  }
@@ -1,12 +1,14 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
2
  import { z } from "zod";
3
+ import { subtitleModelDir } from "../../kernel/paths.js";
4
+ import { SubtitleMismatch } from "../../kernel/ports/subtitles.js";
4
5
  import { resolveFont } from "../fonts/index.js";
5
6
  import { validateCues } from "../revisions/rules.js";
6
7
  import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
7
8
  import { outputPath } from "../storage/layout.js";
8
9
  import { captionCues, serializeAss, serializeSrt, serializeVtt } from "../subtitles/captions.js";
9
10
  import { exportSnapshot, retainedOutput, revisionAudio, revisionTranscript, } from "./runtime-export-inputs.js";
11
+ import { narrationChunks, narrationTextParts, } from "./runtime-narration-text.js";
10
12
  import { preparedResult, preparedText, publishResult } from "./runtime-publication.js";
11
13
  const wordsSchema = z.object({
12
14
  key: z.string(),
@@ -39,14 +41,15 @@ export async function executeSubtitleRecipe(deps, context, piece) {
39
41
  return cues(deps, context, piece, snapshot);
40
42
  if (piece.key === "subtitles:files")
41
43
  return files(deps, context, piece, snapshot);
42
- throw new Error("Unknown subtitle recipe.");
44
+ throw new Error("Slopify hit an internal error (unknown kind of caption step). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
43
45
  }
44
46
  async function timing(deps, context, piece, snapshot) {
45
47
  const audio = await revisionAudio(deps, context, snapshot.view);
46
48
  if (audio.length === 0)
47
- throw new Error("Subtitles need narration audio.");
48
- if (deps.alignSubtitles === undefined)
49
- throw new Error("Local subtitle alignment is unavailable in this build.");
49
+ throw new Error("Captions need narration audio, but this project has none. Turn captions off in Edit project → Subtitles, or turn narration on, then Retry stage.");
50
+ const alignSubtitles = deps.alignSubtitles;
51
+ if (alignSubtitles === undefined)
52
+ throw new Error("Slopify hit an internal error (the caption timing tool is missing from this build). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
50
53
  if (!context.maySubmit(piece.id))
51
54
  return "held";
52
55
  const words = [];
@@ -55,11 +58,12 @@ async function timing(deps, context, piece, snapshot) {
55
58
  const total = audio.reduce((sum, segment) => sum + segment.seconds, 0);
56
59
  for (const segment of audio) {
57
60
  context.signal.throwIfAborted();
58
- if (segment.path !== null && segment.kind !== "gap") {
59
- const aligned = await deps.alignSubtitles({
60
- audioPath: segment.path,
61
- text: revisionTranscript(deps, snapshot, segment.kind),
62
- cacheDir: join(deps.paths.dataDir, "models", "english-subtitles"),
61
+ const { path, kind } = segment;
62
+ if (path !== null && kind !== "gap") {
63
+ const aligned = await located(snapshot, kind, () => alignSubtitles({
64
+ audioPath: path,
65
+ text: revisionTranscript(deps, snapshot, kind),
66
+ cacheDir: subtitleModelDir(deps.paths.dataDir),
63
67
  ffmpeg: deps.ffmpeg,
64
68
  signal: context.signal,
65
69
  onOmission: (value) => omissions.push({ ...value, start: value.start + offset }),
@@ -70,10 +74,10 @@ async function timing(deps, context, piece, snapshot) {
70
74
  current: Math.round((100 * (offset + segment.seconds * (maximum > 0 ? current / maximum : 0))) / total),
71
75
  total: 100,
72
76
  }),
73
- });
77
+ }));
74
78
  for (const word of aligned) {
75
79
  if (word.end > segment.seconds + 0.1)
76
- throw new Error("Subtitle timing exceeds the narration duration.");
80
+ throw new Error("Caption timing came out longer than the narration audio. Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
77
81
  words.push({
78
82
  ...word,
79
83
  start: word.start + offset,
@@ -84,7 +88,7 @@ async function timing(deps, context, piece, snapshot) {
84
88
  offset += segment.seconds;
85
89
  }
86
90
  if (captionCues(words).length === 0)
87
- throw new Error("No spoken words could be aligned. Check that the English article matches the audio.");
91
+ throw new Error("None of the narration could be matched to the article text, so captions can't be timed. Captions only work for English narration; if you uploaded your own audio, make sure it reads the article text, then Retry stage.");
88
92
  context.signal.throwIfAborted();
89
93
  const output = preparedText(deps, context, piece, "subtitle_words", "subtitles.json", JSON.stringify({ key: piece.logicalFingerprint ?? piece.fingerprint, words, omissions }));
90
94
  await publishResult(deps, context, piece, [output], { words, omissions }, output.asset);
@@ -96,7 +100,7 @@ async function cues(deps, context, piece, snapshot) {
96
100
  let value;
97
101
  if (manual !== undefined) {
98
102
  if (plan.work.find((one) => one.key === piece.key)?.disposition === "review")
99
- throw new Error("Review the saved captions against the current narration before rebuilding.");
103
+ throw new Error("Your edited captions were written for an older version of the narration. Review them in Edit project → Captions and save, then Retry stage.");
100
104
  const audio = await revisionAudio(deps, context, view);
101
105
  const errors = validateCues(manual.cues, audio.reduce((sum, segment) => sum + segment.seconds, 0));
102
106
  if (errors.length > 0)
@@ -109,7 +113,7 @@ async function cues(deps, context, piece, snapshot) {
109
113
  one.state === "ready" &&
110
114
  one.workKey === "subtitles:timing");
111
115
  if (output === undefined)
112
- throw new Error("The revision has no aligned subtitle timing.");
116
+ throw new Error("Caption timing is missing. Use Re-run section on Video, then Retry stage.");
113
117
  const timed = wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, context.work.projectId, output.output.path), "utf8")));
114
118
  value = { cues: [...captionCues(timed.words)], omissions: timed.omissions };
115
119
  }
@@ -123,10 +127,10 @@ async function files(deps, context, piece, snapshot) {
123
127
  const { view } = snapshot;
124
128
  const config = view.revision.config.subtitles;
125
129
  if (config === undefined || config.mode === "off")
126
- throw new Error("The admitted revision has no subtitles.");
130
+ throw new Error("Slopify hit an internal error (captions are off for this version of the project). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
127
131
  const row = view.pieces.find((one) => one.key === "subtitles:cues" && one.selected && one.available && one.piece.state === "done");
128
132
  if (row?.piece.payload === null || row?.piece.payload === undefined)
129
- throw new Error("The revision has no prepared captions.");
133
+ throw new Error("The captions haven't been prepared yet. Use Re-run section on Video, then Retry stage.");
130
134
  const value = cuesSchema.parse(JSON.parse(row.piece.payload));
131
135
  const previous = view.outputs.find((one) => one.output.role === "subtitle_font" && one.selected && one.available);
132
136
  const previousFiles = view.pieces.find((one) => one.key === "subtitles:files" &&
@@ -200,3 +204,62 @@ async function files(deps, context, piece, snapshot) {
200
204
  discardPreparedAssets(deps, prepared.map((one) => one.asset));
201
205
  }
202
206
  }
207
+ // A mismatch names the place to fix: the time in the narration, the chunk that holds it (as
208
+ // the Narration editor numbers them) and what was expected against what was heard. The
209
+ // usual cause is a TTS request that skipped or reworded a sentence.
210
+ async function located(snapshot, segment, align) {
211
+ try {
212
+ return await align();
213
+ }
214
+ catch (error) {
215
+ if (!(error instanceof SubtitleMismatch))
216
+ throw error;
217
+ throw new Error(mismatchMessage(snapshot, segment, error), { cause: error });
218
+ }
219
+ }
220
+ export function mismatchMessage(snapshot, segment, mismatch) {
221
+ let chunks = [];
222
+ try {
223
+ chunks = narrationChunks(narrationTextParts(snapshot.view, snapshot.plan, segment));
224
+ }
225
+ catch {
226
+ // Without a readable plan the message still gives the time and the words.
227
+ }
228
+ return describeMismatch(chunks, segment, mismatch);
229
+ }
230
+ export function describeMismatch(chunks, segment, mismatch) {
231
+ const probe = comparable(mismatch.expected).split(" ").slice(0, 6).join(" ");
232
+ const at = probe === ""
233
+ ? chunks.length - 1
234
+ : chunks.findIndex((chunk) => comparable(chunk.spokenText).includes(probe));
235
+ const chunk = at === -1 ? undefined : chunks[at];
236
+ const where = chunk === undefined
237
+ ? `${clock(mismatch.at)} into the ${segment} narration`
238
+ : `${clock(mismatch.at)} into the ${segment} narration, in narration chunk ${String(at + 1)} of ${String(chunks.length)} (it starts "${opening(chunk.spokenText)}")`;
239
+ const expected = mismatch.expected === "" ? "the end of the text" : `"${mismatch.expected}…"`;
240
+ const heard = mismatch.heard === "" ? "no more speech" : `"${mismatch.heard.toLowerCase()}…"`;
241
+ const fix = chunk === undefined
242
+ ? "Check that part of the narration, regenerate it in Edit project → Narration, then Resume."
243
+ : `In Edit project → Narration, regenerate narration chunk ${String(at + 1)}, then Resume.`;
244
+ return `Subtitles stopped matching the audio at ${where}. The text expected ${expected} but the audio has ${heard} The recording there probably skips or changes words. ${fix}`;
245
+ }
246
+ function comparable(text) {
247
+ return text
248
+ .toUpperCase()
249
+ .replace(/&/g, " AND ")
250
+ .replace(/[^A-Z0-9' ]+/g, " ")
251
+ .replace(/\s+/g, " ")
252
+ .trim();
253
+ }
254
+ function opening(text) {
255
+ return text.trim().split(/\s+/).slice(0, 6).join(" ");
256
+ }
257
+ function clock(seconds) {
258
+ const whole = Math.floor(seconds);
259
+ const hours = Math.floor(whole / 3600);
260
+ const minutes = Math.floor((whole % 3600) / 60);
261
+ const rest = String(whole % 60).padStart(2, "0");
262
+ return hours > 0
263
+ ? `${String(hours)}:${String(minutes).padStart(2, "0")}:${rest}`
264
+ : `${String(minutes)}:${rest}`;
265
+ }