@gentbajko/slopify 0.8.3 → 1.0.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 (207) hide show
  1. package/README.md +28 -6
  2. package/SUBTITLES.md +2 -0
  3. package/dist/adapters/alignment/window.js +46 -5
  4. package/dist/adapters/alignment/worker.js +2 -2
  5. package/dist/adapters/llm/claude-code.js +6 -3
  6. package/dist/adapters/llm/codex.js +102 -12
  7. package/dist/adapters/llm/gemini.js +11 -8
  8. package/dist/adapters/llm/run-cli.js +61 -6
  9. package/dist/adapters/tts/inworld-async.js +4 -4
  10. package/dist/adapters/tts/inworld.js +73 -68
  11. package/dist/catalog/registry.js +13 -73
  12. package/dist/edge/cli.js +5 -2
  13. package/dist/edge/events/hub.js +7 -2
  14. package/dist/edge/events/preview-cache.js +7 -4
  15. package/dist/edge/events/visibility.js +33 -0
  16. package/dist/edge/http/actions.js +74 -79
  17. package/dist/edge/http/app.js +39 -6
  18. package/dist/edge/http/audio-preview.js +9 -1
  19. package/dist/edge/http/checkpoints.js +95 -0
  20. package/dist/edge/http/diagnostics.js +31 -0
  21. package/dist/edge/http/draft-files.js +69 -0
  22. package/dist/edge/http/draft-problem.js +15 -0
  23. package/dist/edge/http/drafts.js +95 -0
  24. package/dist/edge/http/multipart.js +86 -0
  25. package/dist/edge/http/mutations.js +49 -0
  26. package/dist/edge/http/planning.js +12 -1
  27. package/dist/edge/http/project-templates.js +74 -0
  28. package/dist/edge/http/projects.js +19 -2
  29. package/dist/edge/http/revision-files.js +84 -0
  30. package/dist/edge/http/revisions.js +103 -0
  31. package/dist/edge/http/schedules.js +100 -0
  32. package/dist/edge/http/staging.js +11 -52
  33. package/dist/edge/http/storage.js +110 -0
  34. package/dist/edge/http/subtitles.js +9 -79
  35. package/dist/edge/http/tutorial.js +26 -0
  36. package/dist/edge/signal-shutdown.js +23 -0
  37. package/dist/kernel/audio-preview.js +11 -5
  38. package/dist/kernel/db/migrations/0004-project-revisions.sql +91 -0
  39. package/dist/kernel/db/migrations/0005-revision-work.sql +103 -0
  40. package/dist/kernel/db/migrations/0006-play-drafts.sql +38 -0
  41. package/dist/kernel/db/migrations/0007-review-checkpoints.sql +28 -0
  42. package/dist/kernel/db/migrations/0008-project-templates.sql +21 -0
  43. package/dist/kernel/db/migrations/0009-scheduled-jobs.sql +37 -0
  44. package/dist/kernel/db/migrations/0010-retain-schedule-history.sql +62 -0
  45. package/dist/kernel/ports/model.js +5 -0
  46. package/dist/kernel/runner/attempt-repo.js +15 -0
  47. package/dist/kernel/runner/attempt.js +14 -2
  48. package/dist/kernel/runner/checkpoint-authority.js +23 -0
  49. package/dist/kernel/runner/index.js +35 -11
  50. package/dist/kernel/runner/providers.js +18 -8
  51. package/dist/kernel/runner/work-attempt.js +47 -0
  52. package/dist/kernel/runner/work-authority.js +37 -0
  53. package/dist/kernel/runner/work.js +23 -0
  54. package/dist/main.js +227 -69
  55. package/dist/slices/admission/repo.js +5 -56
  56. package/dist/slices/admission/schema.js +64 -0
  57. package/dist/slices/admission/start.js +13 -4
  58. package/dist/slices/article/continuation.js +9 -3
  59. package/dist/slices/article/provided-entries.js +7 -3
  60. package/dist/slices/article/run.js +20 -7
  61. package/dist/slices/article/segments.js +13 -4
  62. package/dist/slices/batch/index.js +8 -11
  63. package/dist/slices/cancel/index.js +78 -34
  64. package/dist/slices/checkpoints/change.js +250 -0
  65. package/dist/slices/checkpoints/fingerprint.js +33 -0
  66. package/dist/slices/checkpoints/index.js +2 -0
  67. package/dist/slices/checkpoints/model.js +1 -0
  68. package/dist/slices/checkpoints/recovery.js +116 -0
  69. package/dist/slices/checkpoints/repo.js +132 -0
  70. package/dist/slices/checkpoints/rules.js +30 -0
  71. package/dist/slices/checkpoints/schema.js +59 -0
  72. package/dist/slices/control/index.js +88 -22
  73. package/dist/slices/control/providers.js +3 -2
  74. package/dist/slices/control/revision-control-schema.js +7 -0
  75. package/dist/slices/control/revision-control.js +59 -0
  76. package/dist/slices/estimate/index.js +77 -59
  77. package/dist/slices/estimate/requests.js +102 -0
  78. package/dist/slices/images/run.js +14 -3
  79. package/dist/slices/library/slots.js +11 -11
  80. package/dist/slices/library/snapshot.js +33 -0
  81. package/dist/slices/narration/legacy-plan.js +58 -0
  82. package/dist/slices/narration/live.js +2 -2
  83. package/dist/slices/narration/plan.js +48 -54
  84. package/dist/slices/narration/run.js +21 -8
  85. package/dist/slices/play-drafts/convert.js +120 -0
  86. package/dist/slices/play-drafts/model.js +1 -0
  87. package/dist/slices/play-drafts/readiness.js +80 -0
  88. package/dist/slices/play-drafts/repo.js +76 -0
  89. package/dist/slices/play-drafts/review-inputs.js +150 -0
  90. package/dist/slices/play-drafts/review.js +109 -0
  91. package/dist/slices/play-drafts/schema.js +218 -0
  92. package/dist/slices/play-drafts/service.js +242 -0
  93. package/dist/slices/play-drafts/start-repo.js +113 -0
  94. package/dist/slices/play-drafts/start.js +148 -0
  95. package/dist/slices/play-drafts/uploads.js +67 -0
  96. package/dist/slices/project-templates/from-project.js +159 -0
  97. package/dist/slices/project-templates/model.js +1 -0
  98. package/dist/slices/project-templates/repo.js +41 -0
  99. package/dist/slices/project-templates/schema.js +37 -0
  100. package/dist/slices/project-templates/service.js +136 -0
  101. package/dist/slices/project-templates/setup.js +55 -0
  102. package/dist/slices/rebuild/admission-repo.js +120 -0
  103. package/dist/slices/rebuild/dependencies.js +33 -0
  104. package/dist/slices/rebuild/model.js +94 -0
  105. package/dist/slices/rebuild/narration-history.js +43 -0
  106. package/dist/slices/rebuild/narration-reuse.js +20 -0
  107. package/dist/slices/rebuild/preview-details.js +144 -0
  108. package/dist/slices/rebuild/preview-plan.js +223 -0
  109. package/dist/slices/rebuild/preview-retained.js +128 -0
  110. package/dist/slices/rebuild/provided-review.js +32 -0
  111. package/dist/slices/rebuild/recipe-audio.js +172 -0
  112. package/dist/slices/rebuild/recipe-build.js +17 -0
  113. package/dist/slices/rebuild/recipe-checkpoints.js +75 -0
  114. package/dist/slices/rebuild/recipe-exports.js +65 -0
  115. package/dist/slices/rebuild/recipe-fixture.js +150 -0
  116. package/dist/slices/rebuild/recipe-input-schema.js +82 -0
  117. package/dist/slices/rebuild/recipe-legacy.js +135 -0
  118. package/dist/slices/rebuild/recipe-model.js +77 -0
  119. package/dist/slices/rebuild/recipe-provider-choice.js +14 -0
  120. package/dist/slices/rebuild/recipe-save.js +221 -0
  121. package/dist/slices/rebuild/recipe-text.js +170 -0
  122. package/dist/slices/rebuild/recipe-validation.js +86 -0
  123. package/dist/slices/rebuild/recipe-visual.js +111 -0
  124. package/dist/slices/rebuild/recipe-work.js +212 -0
  125. package/dist/slices/rebuild/recipes.js +4 -0
  126. package/dist/slices/rebuild/repo.js +64 -0
  127. package/dist/slices/rebuild/runtime-actions.js +76 -0
  128. package/dist/slices/rebuild/runtime-admission.js +71 -0
  129. package/dist/slices/rebuild/runtime-article.js +117 -0
  130. package/dist/slices/rebuild/runtime-checkpoints.js +45 -0
  131. package/dist/slices/rebuild/runtime-export-inputs.js +109 -0
  132. package/dist/slices/rebuild/runtime-export.js +139 -0
  133. package/dist/slices/rebuild/runtime-instructions.js +51 -0
  134. package/dist/slices/rebuild/runtime-local.js +137 -0
  135. package/dist/slices/rebuild/runtime-materialize.js +105 -0
  136. package/dist/slices/rebuild/runtime-narration-reuse.js +134 -0
  137. package/dist/slices/rebuild/runtime-plan.js +103 -0
  138. package/dist/slices/rebuild/runtime-provider.js +184 -0
  139. package/dist/slices/rebuild/runtime-publication.js +172 -0
  140. package/dist/slices/rebuild/runtime-run.js +35 -0
  141. package/dist/slices/rebuild/runtime-store.js +166 -0
  142. package/dist/slices/rebuild/runtime-subtitles.js +202 -0
  143. package/dist/slices/rebuild/service-readiness.js +110 -0
  144. package/dist/slices/rebuild/service.js +149 -0
  145. package/dist/slices/rebuild/transition-repo.js +108 -0
  146. package/dist/slices/rebuild/work-records.js +44 -0
  147. package/dist/slices/reruns/index.js +5 -0
  148. package/dist/slices/research/run.js +33 -12
  149. package/dist/slices/revisions/adopt-assets.js +31 -0
  150. package/dist/slices/revisions/adopt-content.js +112 -0
  151. package/dist/slices/revisions/adopt.js +100 -0
  152. package/dist/slices/revisions/downloads.js +80 -0
  153. package/dist/slices/revisions/index.js +5 -0
  154. package/dist/slices/revisions/manifest-repo.js +125 -0
  155. package/dist/slices/revisions/model.js +1 -0
  156. package/dist/slices/revisions/mutation-assets.js +143 -0
  157. package/dist/slices/revisions/mutation-cues.js +95 -0
  158. package/dist/slices/revisions/mutation-prepare.js +182 -0
  159. package/dist/slices/revisions/mutation-request.js +58 -0
  160. package/dist/slices/revisions/mutation-work.js +47 -0
  161. package/dist/slices/revisions/mutations.js +194 -0
  162. package/dist/slices/revisions/projection.js +82 -0
  163. package/dist/slices/revisions/publication-model.js +1 -0
  164. package/dist/slices/revisions/publication-rules.js +166 -0
  165. package/dist/slices/revisions/publish.js +115 -0
  166. package/dist/slices/revisions/repo.js +83 -0
  167. package/dist/slices/revisions/restore.js +60 -0
  168. package/dist/slices/revisions/rules.js +99 -0
  169. package/dist/slices/revisions/schema.js +160 -0
  170. package/dist/slices/revisions/view.js +44 -0
  171. package/dist/slices/schedules/calendar.js +47 -0
  172. package/dist/slices/schedules/model.js +1 -0
  173. package/dist/slices/schedules/repo.js +281 -0
  174. package/dist/slices/schedules/scheduler.js +144 -0
  175. package/dist/slices/schedules/schema.js +80 -0
  176. package/dist/slices/schedules/service.js +178 -0
  177. package/dist/slices/settings/cli-paths.js +1 -1
  178. package/dist/slices/settings/cli-status.js +45 -5
  179. package/dist/slices/settings/tutorial-schema.js +39 -0
  180. package/dist/slices/settings/tutorial.js +66 -0
  181. package/dist/slices/storage/assets.js +108 -0
  182. package/dist/slices/storage/delete-project.js +2 -1
  183. package/dist/slices/storage/downloads.js +4 -1
  184. package/dist/slices/storage/portable.js +871 -0
  185. package/dist/slices/storage/prepare.js +67 -0
  186. package/dist/slices/storage/reconcile.js +25 -5
  187. package/dist/slices/storage/repo.js +2 -15
  188. package/dist/slices/storage/schema.js +35 -0
  189. package/dist/slices/storage/staging-refs.js +26 -0
  190. package/dist/slices/storage/staging.js +16 -11
  191. package/dist/slices/thumbnail/run.js +26 -9
  192. package/dist/slices/video/audio-export-args.js +33 -0
  193. package/dist/slices/video/audio-export.js +13 -36
  194. package/dist/slices/video/audio-inputs.js +4 -3
  195. package/dist/slices/video/ffmpeg.js +6 -0
  196. package/dist/slices/video/reuse-audio.js +11 -4
  197. package/dist/updater/install-flow.js +27 -2
  198. package/dist/updater/plan.js +28 -2
  199. package/dist/updater/service.js +27 -2
  200. package/dist/updater/worker.js +2 -1
  201. package/dist/web/assets/index-CO7DnVX9.js +137 -0
  202. package/dist/web/assets/index-DX1_chKs.css +1 -0
  203. package/dist/web/index.html +2 -2
  204. package/package.json +2 -2
  205. package/dist/adapters/tts/inworld-text.js +0 -32
  206. package/dist/web/assets/index-Ce2HApus.css +0 -1
  207. package/dist/web/assets/index-xbD0MZOY.js +0 -130
@@ -10,8 +10,8 @@ import { insertOutput, outputsOf } from "../storage/repo.js";
10
10
  import { probeDurationMs } from "../video/ffmpeg.js";
11
11
  import { chunkNarration, defaultChunking } from "./chunk.js";
12
12
  import { joinNarration } from "./concat.js";
13
+ import { planLegacyNarration } from "./legacy-plan.js";
13
14
  import { observeNarration } from "./live.js";
14
- import { planNarration } from "./plan.js";
15
15
  // An empty narration source fails the stage immediately with no retries. Thrown from the
16
16
  // slice rather than from a provider call, so the attempt wrapper never sees it.
17
17
  export const nothingToNarrate = "nothing to narrate";
@@ -53,15 +53,19 @@ export async function runNarration(deps, context, providers) {
53
53
  if (texts.length === 0) {
54
54
  throw new Error(nothingToNarrate);
55
55
  }
56
- const planned = planNarration(deps, context.stage.id, texts, deps.maxCharacters?.(choice.provider, choice.model) ?? 1000000, (piece) => finished(deps, projectId, piece) !== undefined);
57
- const files = await speakChunks(deps, context, providers, choice, planned);
58
- await storeBody(deps, context, choice, files, outputs);
59
- await speakSegments(deps, context, providers, choice, outputs);
56
+ const planned = planLegacyNarration(deps, context.stage.id, texts, deps.maxCharacters?.(choice.provider, choice.model) ?? 1000000, (piece) => finished(deps, projectId, piece) !== undefined);
57
+ const result = await speakChunks(deps, context, providers, choice, planned);
58
+ if (!result.ok)
59
+ return "held";
60
+ await storeBody(deps, context, choice, result.value, outputs);
61
+ if ((await speakSegments(deps, context, providers, choice, outputs)) === "held")
62
+ return "held";
60
63
  deps.log.write("info", "narration.done", {
61
64
  projectId,
62
65
  stage: "audio",
63
66
  detail: `${String(texts.length)} chunks in ${chunking.mode} mode`,
64
67
  });
68
+ return "done";
65
69
  }
66
70
  // The narration source: the plain-text article, written by the article stage or pasted in
67
71
  // by a user whose article stage was set to Provide.
@@ -95,7 +99,11 @@ async function speakChunks(deps, context, providers, choice, pieces) {
95
99
  voiceId: choice.voice,
96
100
  text: carried.text,
97
101
  }, observeNarration(deps.audioPreviews, projectId, piece.id, total === 1 ? "Body" : `Body part ${piece.idx} of ${total}`));
98
- write(deps, projectId, file, spoken.bytes);
102
+ if (!spoken.ok) {
103
+ setPiece(deps.db, piece.id, "pending", piece.payload);
104
+ return spoken;
105
+ }
106
+ write(deps, projectId, file, spoken.value.bytes);
99
107
  // The file is on disk before the row says so: a crash between the two leaves a
100
108
  // file the reconcile collects, where the other order would leave a `done` chunk
101
109
  // whose audio never existed and a concat that fails on every retry.
@@ -114,11 +122,13 @@ async function speakChunks(deps, context, providers, choice, pieces) {
114
122
  const files = [];
115
123
  for (const outcome of outcomes) {
116
124
  if (!outcome.ok) {
125
+ if ("reason" in outcome)
126
+ return outcome;
117
127
  throw outcome.error;
118
128
  }
119
129
  files.push(outcome.file);
120
130
  }
121
- return files;
131
+ return { ok: true, value: files };
122
132
  }
123
133
  // One body file, in chunk order, with its measured duration on the row.
124
134
  async function storeBody(deps, context, choice, files, outputs) {
@@ -166,8 +176,10 @@ async function speakSegments(deps, context, providers, choice, outputs) {
166
176
  voiceId: choice.voice,
167
177
  text,
168
178
  }, observeNarration(deps.audioPreviews, projectId, piece.id, segment.category === "intro" ? "Intro" : "Outro"));
179
+ if (!spoken.ok)
180
+ return "held";
169
181
  const name = outputFileName(role, 1, ".mp3", "audio");
170
- write(deps, projectId, name, spoken.bytes);
182
+ write(deps, projectId, name, spoken.value.bytes);
171
183
  // Measured the same way the body is, because the render adds all three and the gaps to
172
184
  // get the length of the video.
173
185
  const durationMs = await probeDurationMs(deps.ffmpeg, outputPath(deps.paths, projectId, name), context.signal, deps.log);
@@ -175,6 +187,7 @@ async function speakSegments(deps, context, providers, choice, outputs) {
175
187
  store(deps, projectId, role, name, durationMs, choice);
176
188
  counted(deps, segment.category, durationMs, choice);
177
189
  }
190
+ return "done";
178
191
  }
179
192
  // The segment pieces belong to the *article* stage, which is where they are written, so
180
193
  // they are read under its id and never under this stage's own. They stay there because the
@@ -0,0 +1,120 @@
1
+ import { z } from "zod";
2
+ import { numberPerPromptMax } from "../admission/rules.js";
3
+ import { runDraftSchema } from "../admission/schema.js";
4
+ import { defaultSubtitles } from "../subtitles/model.js";
5
+ export function toAdmissionDraft(input) {
6
+ const { form } = input.document;
7
+ const fields = [];
8
+ const sources = {
9
+ ...form.sources,
10
+ ...(form.sources.article === "provide" ? { research: "off" } : {}),
11
+ ...(form.sources.images === "off" ? { video: "off" } : {}),
12
+ };
13
+ const number = (raw, field, max, min = 1) => {
14
+ const parsed = z
15
+ .number()
16
+ .int()
17
+ .min(min)
18
+ .max(max)
19
+ .safeParse(raw.trim() === "" ? Number.NaN : Number(raw));
20
+ if (parsed.success)
21
+ return parsed.data;
22
+ fields.push({ field, message: `Enter a whole number between ${min} and ${max}.` });
23
+ return min;
24
+ };
25
+ const entry = (category) => {
26
+ const name = form[category];
27
+ if (sources.audio !== "generate" || name === "")
28
+ return undefined;
29
+ const saved = input.entries.find((candidate) => candidate.category === category && candidate.name.toLowerCase() === name.toLowerCase());
30
+ if (saved)
31
+ return { name: saved.name, mode: saved.mode };
32
+ fields.push({
33
+ field: category,
34
+ message: `That ${category} entry no longer exists; pick another.`,
35
+ });
36
+ return undefined;
37
+ };
38
+ const file = (ref, kind, field) => {
39
+ const attachment = input.attachments.find((item) => item.id === ref?.attachmentId && item.kind === kind && item.name === ref.name);
40
+ if (attachment?.state === "ready" && attachment.stagedFileId !== null)
41
+ return attachment.stagedFileId;
42
+ fields.push({
43
+ field,
44
+ message: attachment?.state === "copying" || attachment?.state === "pending"
45
+ ? "This upload is still copying."
46
+ : "That upload is no longer available; pick the file again.",
47
+ });
48
+ return undefined;
49
+ };
50
+ const mode = sources.audio === "off"
51
+ ? "off"
52
+ : sources.video === "off" && form.subtitles.mode === "burn-in"
53
+ ? "files"
54
+ : form.subtitles.mode;
55
+ const chunkMode = sources.audio === "generate" ? form.chunking.mode : "whole";
56
+ const draft = {
57
+ ...(form.checkpoints === undefined ? {} : { checkpoints: form.checkpoints }),
58
+ title: form.title,
59
+ format: form.format,
60
+ sources,
61
+ llm: form.llm,
62
+ audio: sources.audio === "generate" ? form.audio : undefined,
63
+ images: form.images,
64
+ articlePrompt: sources.article === "generate" ? form.articlePrompt : undefined,
65
+ imagePrompts: sources.images === "generate"
66
+ ? form.imagePrompts.map((prompt, index) => ({
67
+ name: prompt.name,
68
+ number: number(prompt.number, `imagePrompts.${index}.number`, numberPerPromptMax),
69
+ }))
70
+ : [],
71
+ thumbnailPrompt: ["from_prompt", "prompt_by_llm"].includes(sources.thumbnail)
72
+ ? form.thumbnailPrompt
73
+ : undefined,
74
+ intro: entry("intro"),
75
+ outro: entry("outro"),
76
+ values: form.values,
77
+ provided: {
78
+ research: sources.research === "provide" ? form.provided.research : undefined,
79
+ article: sources.article === "provide" ? form.provided.article : undefined,
80
+ audio: sources.audio === "provide"
81
+ ? file(form.provided.audio, "audio", "provided.audio")
82
+ : undefined,
83
+ thumbnail: sources.thumbnail === "provide"
84
+ ? file(form.provided.thumbnail, "thumbnail", "provided.thumbnail")
85
+ : undefined,
86
+ images: sources.images === "provide"
87
+ ? form.provided.images.flatMap((ref, index) => {
88
+ const id = file(ref, "images", `provided.images.${index}`);
89
+ return id === undefined ? [] : [id];
90
+ })
91
+ : [],
92
+ },
93
+ chunking: {
94
+ mode: chunkMode,
95
+ ...(chunkMode === "words"
96
+ ? { words: number(form.chunking.words, "chunking.words", 10000) }
97
+ : {}),
98
+ ...(chunkMode === "characters"
99
+ ? { characters: number(form.chunking.characters, "chunking.characters", 1000000) }
100
+ : {}),
101
+ },
102
+ subtitles: mode === "off"
103
+ ? defaultSubtitles
104
+ : {
105
+ ...form.subtitles,
106
+ mode,
107
+ fontSize: number(form.subtitles.fontSize, "subtitles.fontSize", 120, 16),
108
+ },
109
+ silenceGapSeconds: input.silenceGapSeconds,
110
+ };
111
+ const parsed = runDraftSchema.safeParse(draft);
112
+ if (!parsed.success)
113
+ fields.push(...parsed.error.issues.map((issue) => ({
114
+ field: issue.path.join("."),
115
+ message: issue.message,
116
+ })));
117
+ return fields.length || !parsed.success
118
+ ? { ok: false, fields }
119
+ : { ok: true, draft: parsed.data };
120
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,80 @@
1
+ import { modelFields } from "../../catalog/validate.js";
2
+ import { readinessIsUsable } from "../../kernel/ports/model.js";
3
+ import { cliPathStatus } from "../settings/cli-paths.js";
4
+ import { hasKey, listVoices } from "../settings/repo.js";
5
+ function choices(runs) {
6
+ const selected = runs
7
+ .flatMap(({ draft: d }) => [
8
+ {
9
+ field: "llm",
10
+ family: "llm",
11
+ choice: d.llm,
12
+ needed: d.sources.research === "generate" ||
13
+ d.sources.article === "generate" ||
14
+ d.sources.thumbnail === "prompt_by_llm" ||
15
+ d.intro?.mode === "llm" ||
16
+ d.outro?.mode === "llm",
17
+ },
18
+ {
19
+ field: "audio",
20
+ family: "tts",
21
+ choice: d.audio,
22
+ needed: d.sources.audio === "generate",
23
+ },
24
+ {
25
+ field: "images",
26
+ family: "image",
27
+ choice: d.images,
28
+ needed: d.sources.images === "generate" ||
29
+ ["from_prompt", "prompt_by_llm"].includes(d.sources.thumbnail),
30
+ },
31
+ ])
32
+ .flatMap((c) => (c.needed && c.choice ? [{ ...c, ...c.choice }] : []));
33
+ return [...new Map(selected.map((c) => [`${c.family}:${c.provider}:${c.model}`, c])).values()];
34
+ }
35
+ export async function checkDraftReadiness(deps, runs) {
36
+ const selected = choices(runs);
37
+ if (selected.length === 0)
38
+ return { fields: [], providers: [] };
39
+ const providers = await deps.providers();
40
+ const fields = [];
41
+ for (const c of selected) {
42
+ const provider = providers.find((p) => p.id === c.provider && p.family === c.family);
43
+ if (!provider || !readinessIsUsable(provider.readiness)) {
44
+ fields.push({
45
+ field: c.field,
46
+ message: provider?.readiness.kind === "cli" && provider.readiness.issue
47
+ ? provider.readiness.issue
48
+ : "Configure this provider before starting.",
49
+ });
50
+ continue;
51
+ }
52
+ const models = await deps.modelsFor(c.provider, c.family);
53
+ if (!models.some((m) => m.id === c.model))
54
+ fields.push({
55
+ field: `${c.field}.model`,
56
+ message: "Choose an available model before starting.",
57
+ });
58
+ }
59
+ return { fields: [...fields, ...localDraftReadiness(deps, runs, providers)], providers };
60
+ }
61
+ export function localDraftReadiness(deps, runs, providers) {
62
+ const fields = runs.flatMap((run) => modelFields(run.draft, deps.catalogue));
63
+ for (const c of choices(runs)) {
64
+ const provider = providers.find((p) => p.id === c.provider && p.family === c.family);
65
+ if (provider?.cliPath !== undefined &&
66
+ cliPathStatus(deps.db, provider.id).command !== provider.cliPath.command)
67
+ fields.push({
68
+ field: `${c.field}.cliPath`,
69
+ message: "The configured CLI command changed. Check readiness again.",
70
+ });
71
+ if (provider?.readiness.kind === "keyed" && !hasKey(deps.db, provider.id))
72
+ fields.push({ field: c.field, message: "Save the provider key before starting." });
73
+ }
74
+ for (const { draft } of runs)
75
+ if (draft.sources.audio === "generate" &&
76
+ draft.audio &&
77
+ !listVoices(deps.db).some((v) => v.provider === draft.audio?.provider && v.voiceId === draft.audio.voice))
78
+ fields.push({ field: "audio.voice", message: "Choose a saved voice before starting." });
79
+ return fields;
80
+ }
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ import { fingerprint } from "../../kernel/runner/work.js";
3
+ const rowSchema = z.object({
4
+ id: z.string(),
5
+ schema_version: z.number(),
6
+ version: z.number(),
7
+ title: z.string(),
8
+ document_json: z.string(),
9
+ creation_hash: z.string(),
10
+ save_mutation_id: z.string().nullable(),
11
+ save_request_hash: z.string().nullable(),
12
+ created_at: z.string(),
13
+ updated_at: z.string(),
14
+ state: z.enum(["active", "starting", "started"]),
15
+ review_id: z.string().nullable(),
16
+ review_json: z.string().nullable(),
17
+ review_fingerprint: z.string().nullable(),
18
+ start_id: z.string().nullable(),
19
+ });
20
+ const attachmentSchema = z.object({
21
+ id: z.string(),
22
+ draft_id: z.string(),
23
+ staged_file_id: z.string().nullable(),
24
+ kind: z.enum(["audio", "images", "thumbnail"]),
25
+ original_filename: z.string(),
26
+ status: z.enum(["pending", "ready", "reattach"]),
27
+ error: z.string().nullable(),
28
+ });
29
+ export function draftRow(db, id) {
30
+ const row = db.prepare("SELECT * FROM play_drafts WHERE id=?").get(id);
31
+ return row === undefined ? undefined : rowSchema.parse(row);
32
+ }
33
+ export function draftRows(db) {
34
+ return db
35
+ .prepare("SELECT * FROM play_drafts WHERE state IN ('active','starting') ORDER BY updated_at DESC,id")
36
+ .all()
37
+ .map((row) => rowSchema.parse(row));
38
+ }
39
+ export function attachmentRow(db, id) {
40
+ const row = db.prepare("SELECT * FROM play_draft_attachments WHERE id=?").get(id);
41
+ return row === undefined ? undefined : attachmentSchema.parse(row);
42
+ }
43
+ export function attachmentRows(db, id) {
44
+ return db
45
+ .prepare("SELECT * FROM play_draft_attachments WHERE draft_id=? ORDER BY rowid")
46
+ .all(id)
47
+ .map((row) => attachmentSchema.parse(row));
48
+ }
49
+ export function attachmentRefs(document) {
50
+ const p = document.form.provided;
51
+ return [
52
+ ...(p.audio === null ? [] : [{ ...p.audio, kind: "audio" }]),
53
+ ...p.images.map((file) => ({ ...file, kind: "images" })),
54
+ ...(p.thumbnail === null ? [] : [{ ...p.thumbnail, kind: "thumbnail" }]),
55
+ ];
56
+ }
57
+ export function requestHash(value) {
58
+ return fingerprint(z.json().parse(JSON.parse(JSON.stringify(value))));
59
+ }
60
+ export function insertDraft(db, id, document, hash, at) {
61
+ db.prepare("INSERT INTO play_drafts (id,schema_version,version,title,document_json,creation_hash,created_at,updated_at,state) VALUES (?,1,1,?,?,?,?,?,'active')").run(id, document.form.title, JSON.stringify(document), hash, at, at);
62
+ }
63
+ export function insertAttachment(db, id, ref, status, stagedId = null) {
64
+ db.prepare("INSERT INTO play_draft_attachments (id,draft_id,kind,original_filename,status,staged_file_id) VALUES (?,?,?,?,?,?)").run(ref.attachmentId, id, ref.kind, ref.name, status, stagedId);
65
+ }
66
+ export function syncAttachments(db, id, document) {
67
+ const refs = attachmentRefs(document);
68
+ const retained = new Set(refs.map((ref) => ref.attachmentId));
69
+ for (const old of attachmentRows(db, id))
70
+ if (!retained.has(old.id))
71
+ db.prepare("DELETE FROM play_draft_attachments WHERE id=?").run(old.id);
72
+ // Removed staged bytes remain until reference-aware storage cleanup runs.
73
+ for (const ref of refs)
74
+ if (attachmentRow(db, ref.attachmentId) === undefined)
75
+ insertAttachment(db, id, ref, "pending");
76
+ }
@@ -0,0 +1,150 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import { z } from "zod";
4
+ import { modelFields } from "../../catalog/validate.js";
5
+ import { admit } from "../admission/rules.js";
6
+ import { estimateRun } from "../estimate/index.js";
7
+ import { listEntries } from "../library/repo.js";
8
+ import { pickTemplates, renderPicked } from "../library/slots.js";
9
+ import { reviewCheckpointSet } from "../rebuild/recipe-checkpoints.js";
10
+ import { readSettings } from "../settings/playback.js";
11
+ import { stagedFiles } from "../storage/repo.js";
12
+ import { toAdmissionDraft } from "./convert.js";
13
+ import { requestHash } from "./repo.js";
14
+ import { readDraft } from "./service.js";
15
+ export function reviewRefusal(view, fields, reason = "readiness") {
16
+ return { ok: false, reason, currentVersion: view.draft.version, fields };
17
+ }
18
+ export function reviewBinding(value) {
19
+ return requestHash({
20
+ ...(value.checkpointSet === undefined ? {} : { checkpointSet: value.checkpointSet }),
21
+ runs: value.runs,
22
+ estimates: value.estimates,
23
+ catalogue: value.catalogue,
24
+ attachmentIdentity: value.attachmentIdentity,
25
+ });
26
+ }
27
+ export function resolveReviewInputs(deps, view, font) {
28
+ const fresh = readDraft(deps, view.draft.id);
29
+ if (!fresh.ok)
30
+ return fresh;
31
+ if (fresh.value.draft.version !== view.draft.version ||
32
+ requestHash(fresh.value.draft.document) !== requestHash(view.draft.document))
33
+ return reviewRefusal(fresh.value, [], "stale-review");
34
+ const { document } = view.draft;
35
+ const fields = [];
36
+ if (document.fontUpload !== null)
37
+ fields.push({
38
+ field: "fontUpload",
39
+ message: "Wait for the font upload to finish or choose another font.",
40
+ });
41
+ const words = z
42
+ .number()
43
+ .int()
44
+ .min(1)
45
+ .max(100000)
46
+ .safeParse(document.expectedWords.trim() === "" ? Number.NaN : Number(document.expectedWords));
47
+ if (!words.success)
48
+ fields.push({ field: "expectedWords", message: "Enter a whole number between 1 and 100000." });
49
+ if (document.variants.length > 49)
50
+ fields.push({ field: "variants", message: "Review at most 50 runs including the base run." });
51
+ const converted = toAdmissionDraft({
52
+ document,
53
+ attachments: fresh.value.attachments,
54
+ entries: [...(document.librarySnapshot?.entries ?? []), ...listEntries(deps.db)],
55
+ silenceGapSeconds: readSettings(deps).silenceGapSeconds,
56
+ });
57
+ if (!converted.ok)
58
+ fields.push(...converted.fields);
59
+ if (!converted.ok || !words.success || fields.length)
60
+ return reviewRefusal(view, fields);
61
+ const catalogue = deps.catalogue.read();
62
+ const captured = {
63
+ ...deps.catalogue,
64
+ read: () => catalogue,
65
+ models: (provider, family) => catalogue[family].filter((model) => model.provider === provider && model.enabled && !model.deprecated),
66
+ };
67
+ const staged = stagedFiles(deps.db);
68
+ const basePicked = pickTemplates(deps.db, converted.draft, document.librarySnapshot);
69
+ const drafts = [
70
+ basePicked.draft,
71
+ ...document.variants.map((variant) => ({
72
+ ...basePicked.draft,
73
+ title: variant.title,
74
+ values: { ...basePicked.draft.values, ...variant.values },
75
+ })),
76
+ ];
77
+ const runs = [];
78
+ for (const [index, draft] of drafts.entries()) {
79
+ const values = Object.fromEntries(basePicked.requiredSlots.map((name) => [
80
+ name,
81
+ Object.hasOwn(draft.values, name) ? (draft.values[name] ?? "") : "",
82
+ ]));
83
+ const admitted = admit({
84
+ draft: { ...draft, values },
85
+ staged,
86
+ requiredSlots: basePicked.requiredSlots,
87
+ });
88
+ const accepted = admitted.ok ? admitted.draft : draft;
89
+ const issues = [
90
+ ...basePicked.missing,
91
+ ...(admitted.ok ? [] : admitted.fields),
92
+ ...modelFields(accepted, captured),
93
+ ];
94
+ fields.push(...issues.map((issue) => ({
95
+ ...issue,
96
+ field: index > 0 && (issue.field === "title" || issue.field.startsWith("values."))
97
+ ? `variants.${index - 1}.${issue.field}`
98
+ : issue.field,
99
+ message: `${index > 0 ? `Run ${index + 1}: ` : ""}${issue.message}`,
100
+ })));
101
+ runs.push({
102
+ draft: accepted,
103
+ rendered: renderPicked(basePicked, accepted.values),
104
+ templates: Object.fromEntries(basePicked.bodies.map(({ key, body }) => [key, body])),
105
+ });
106
+ }
107
+ const activeIds = new Set(runs.flatMap((run) => [
108
+ run.draft.provided.audio,
109
+ run.draft.provided.thumbnail,
110
+ ...(run.draft.provided.images ?? []),
111
+ ]));
112
+ const attachmentIdentity = fresh.value.attachments.flatMap((file) => file.stagedFileId !== null && activeIds.has(file.stagedFileId)
113
+ ? [{ id: file.id, stagedFileId: file.stagedFileId, bytes: file.bytes }]
114
+ : []);
115
+ let fontHash = null;
116
+ if (font !== null) {
117
+ const stat = statSync(font.path, { throwIfNoEntry: false });
118
+ if (!stat?.isFile() || font.id !== converted.draft.subtitles?.fontId)
119
+ fields.push({
120
+ field: "subtitles.fontId",
121
+ message: "The selected font is no longer available. Choose another font.",
122
+ });
123
+ else
124
+ fontHash = createHash("sha256").update(readFileSync(font.path)).digest("hex");
125
+ }
126
+ if (fields.length)
127
+ return reviewRefusal(view, fields);
128
+ const estimates = runs.map((run) => estimateRun(run.draft, run.rendered, words.data, captured));
129
+ for (const run of runs)
130
+ for (const stage of run.draft.checkpoints ?? [])
131
+ if (stage === "video"
132
+ ? run.draft.sources.video === "off" && run.draft.sources.audio === "off"
133
+ : run.draft.sources[stage] !== "generate")
134
+ fields.push({
135
+ field: `checkpoints.${stage}`,
136
+ message: "Choose a checkpoint only before an enabled generated stage or export.",
137
+ });
138
+ if (fields.length)
139
+ return reviewRefusal(view, fields);
140
+ const checkpointSet = reviewCheckpointSet(runs, catalogue, attachmentIdentity, fontHash);
141
+ const resolved = {
142
+ runs,
143
+ estimates,
144
+ catalogue,
145
+ attachmentIdentity,
146
+ font,
147
+ ...(checkpointSet.length ? { checkpointSet } : {}),
148
+ };
149
+ return { ok: true, value: { ...resolved, fingerprint: requestHash({ ...resolved, fontHash }) } };
150
+ }
@@ -0,0 +1,109 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { isMissingFont } from "../fonts/index.js";
4
+ import { draftRow } from "./repo.js";
5
+ import { resolveReviewInputs, reviewBinding, reviewRefusal } from "./review-inputs.js";
6
+ import { draftViewSchema, playReviewSchema } from "./schema.js";
7
+ import { readDraft } from "./service.js";
8
+ export { resolveReviewInputs } from "./review-inputs.js";
9
+ export async function resolvePlayReview(deps, view) {
10
+ const parsed = draftViewSchema.safeParse(view);
11
+ if (!parsed.success)
12
+ return { ok: false, reason: "invalid-edit", currentVersion: null, fields: [] };
13
+ const before = resolveReviewInputs(deps, parsed.data, null);
14
+ if (!before.ok)
15
+ return before;
16
+ let font = null;
17
+ const subtitles = before.value.runs[0]?.draft.subtitles;
18
+ if (subtitles !== undefined && subtitles.mode !== "off") {
19
+ try {
20
+ font = await deps.resolveFont(subtitles.fontId);
21
+ }
22
+ catch (error) {
23
+ if (!isMissingFont(error))
24
+ throw error;
25
+ return reviewRefusal(view, [
26
+ {
27
+ field: "subtitles.fontId",
28
+ message: "The selected font is no longer available. Choose another font.",
29
+ },
30
+ ]);
31
+ }
32
+ }
33
+ const after = resolveReviewInputs(deps, parsed.data, font);
34
+ if (!after.ok)
35
+ return after;
36
+ return reviewBinding(before.value) === reviewBinding(after.value)
37
+ ? after
38
+ : reviewRefusal(view, [], "stale-review");
39
+ }
40
+ const inputSchema = z.object({ id: z.uuid(), baseVersion: z.number().int().positive() }).strict();
41
+ export async function reviewDraft(deps, input) {
42
+ const parsed = inputSchema.safeParse(input);
43
+ if (!parsed.success)
44
+ return { ok: false, reason: "invalid-edit", currentVersion: null, fields: [] };
45
+ const view = readDraft(deps, parsed.data.id);
46
+ if (!view.ok)
47
+ return view;
48
+ const gate = reviewGate(view.value, parsed.data.baseVersion);
49
+ if (gate !== null)
50
+ return gate;
51
+ const resolved = await resolvePlayReview(deps, view.value);
52
+ if (!resolved.ok)
53
+ return resolved;
54
+ return transact(deps.db, () => {
55
+ const current = readDraft(deps, parsed.data.id);
56
+ if (!current.ok)
57
+ return current;
58
+ const denied = reviewGate(current.value, parsed.data.baseVersion);
59
+ if (denied !== null)
60
+ return denied;
61
+ const final = resolveReviewInputs(deps, current.value, resolved.value.font);
62
+ if (!final.ok)
63
+ return final;
64
+ if (final.value.fingerprint !== resolved.value.fingerprint)
65
+ return reviewRefusal(current.value, [], "stale-review");
66
+ const old = current.value.review;
67
+ if (old?.draftVersion === current.value.draft.version &&
68
+ old.fingerprint === final.value.fingerprint)
69
+ return { ok: true, value: old };
70
+ const review = playReviewSchema.parse({
71
+ ...(final.value.checkpointSet === undefined
72
+ ? {}
73
+ : { checkpointSet: final.value.checkpointSet }),
74
+ id: deps.uuid(),
75
+ draftId: parsed.data.id,
76
+ draftVersion: parsed.data.baseVersion,
77
+ fingerprint: final.value.fingerprint,
78
+ runs: final.value.runs,
79
+ estimates: final.value.estimates,
80
+ });
81
+ const { catalogue, attachmentIdentity, font } = final.value;
82
+ const changed = deps.db
83
+ .prepare("UPDATE play_drafts SET review_id=?,review_json=?,review_fingerprint=? WHERE id=? AND version=? AND state='active'")
84
+ .run(review.id, JSON.stringify({ review, execution: { catalogue, attachmentIdentity, font } }), review.fingerprint, parsed.data.id, parsed.data.baseVersion);
85
+ if (Number(changed.changes) !== 1)
86
+ return {
87
+ ok: false,
88
+ reason: "conflict",
89
+ currentVersion: draftRow(deps.db, parsed.data.id)?.version ?? null,
90
+ fields: [],
91
+ };
92
+ return { ok: true, value: review };
93
+ });
94
+ }
95
+ function reviewGate(view, baseVersion) {
96
+ if (view.pendingStart !== null)
97
+ return {
98
+ ok: false,
99
+ reason: "pending-start",
100
+ currentVersion: view.draft.version,
101
+ reviewId: view.pendingStart.reviewId,
102
+ fields: [],
103
+ };
104
+ if (view.start !== null)
105
+ return { ok: false, reason: "already-started", currentVersion: view.draft.version, fields: [] };
106
+ if (view.draft.version !== baseVersion)
107
+ return { ok: false, reason: "conflict", currentVersion: view.draft.version, fields: [] };
108
+ return null;
109
+ }