@gentbajko/slopify 0.8.4 → 1.0.1

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 (204) hide show
  1. package/README.md +28 -6
  2. package/dist/adapters/llm/claude-code.js +6 -3
  3. package/dist/adapters/llm/codex.js +102 -12
  4. package/dist/adapters/llm/gemini.js +11 -8
  5. package/dist/adapters/llm/run-cli.js +61 -6
  6. package/dist/adapters/tts/inworld-async.js +4 -4
  7. package/dist/adapters/tts/inworld.js +73 -68
  8. package/dist/catalog/registry.js +13 -73
  9. package/dist/edge/cli.js +5 -2
  10. package/dist/edge/events/hub.js +7 -2
  11. package/dist/edge/events/preview-cache.js +7 -4
  12. package/dist/edge/events/visibility.js +33 -0
  13. package/dist/edge/http/actions.js +74 -79
  14. package/dist/edge/http/app.js +39 -6
  15. package/dist/edge/http/audio-preview.js +9 -1
  16. package/dist/edge/http/checkpoints.js +95 -0
  17. package/dist/edge/http/diagnostics.js +31 -0
  18. package/dist/edge/http/draft-files.js +69 -0
  19. package/dist/edge/http/draft-problem.js +15 -0
  20. package/dist/edge/http/drafts.js +95 -0
  21. package/dist/edge/http/multipart.js +86 -0
  22. package/dist/edge/http/mutations.js +49 -0
  23. package/dist/edge/http/planning.js +12 -1
  24. package/dist/edge/http/project-templates.js +74 -0
  25. package/dist/edge/http/projects.js +19 -2
  26. package/dist/edge/http/revision-files.js +84 -0
  27. package/dist/edge/http/revisions.js +103 -0
  28. package/dist/edge/http/schedules.js +100 -0
  29. package/dist/edge/http/staging.js +11 -52
  30. package/dist/edge/http/storage.js +110 -0
  31. package/dist/edge/http/subtitles.js +9 -79
  32. package/dist/edge/http/tutorial.js +26 -0
  33. package/dist/edge/signal-shutdown.js +23 -0
  34. package/dist/kernel/audio-preview.js +11 -5
  35. package/dist/kernel/db/migrations/0004-project-revisions.sql +91 -0
  36. package/dist/kernel/db/migrations/0005-revision-work.sql +103 -0
  37. package/dist/kernel/db/migrations/0006-play-drafts.sql +38 -0
  38. package/dist/kernel/db/migrations/0007-review-checkpoints.sql +28 -0
  39. package/dist/kernel/db/migrations/0008-project-templates.sql +21 -0
  40. package/dist/kernel/db/migrations/0009-scheduled-jobs.sql +37 -0
  41. package/dist/kernel/db/migrations/0010-retain-schedule-history.sql +62 -0
  42. package/dist/kernel/ports/model.js +5 -0
  43. package/dist/kernel/runner/attempt-repo.js +15 -0
  44. package/dist/kernel/runner/attempt.js +14 -2
  45. package/dist/kernel/runner/checkpoint-authority.js +23 -0
  46. package/dist/kernel/runner/index.js +35 -11
  47. package/dist/kernel/runner/providers.js +18 -8
  48. package/dist/kernel/runner/work-attempt.js +47 -0
  49. package/dist/kernel/runner/work-authority.js +37 -0
  50. package/dist/kernel/runner/work.js +23 -0
  51. package/dist/main.js +227 -69
  52. package/dist/slices/admission/repo.js +5 -56
  53. package/dist/slices/admission/schema.js +64 -0
  54. package/dist/slices/admission/start.js +13 -4
  55. package/dist/slices/article/continuation.js +9 -3
  56. package/dist/slices/article/provided-entries.js +7 -3
  57. package/dist/slices/article/run.js +20 -7
  58. package/dist/slices/article/segments.js +13 -4
  59. package/dist/slices/batch/index.js +8 -11
  60. package/dist/slices/cancel/index.js +78 -34
  61. package/dist/slices/checkpoints/change.js +250 -0
  62. package/dist/slices/checkpoints/fingerprint.js +33 -0
  63. package/dist/slices/checkpoints/index.js +2 -0
  64. package/dist/slices/checkpoints/model.js +1 -0
  65. package/dist/slices/checkpoints/recovery.js +116 -0
  66. package/dist/slices/checkpoints/repo.js +132 -0
  67. package/dist/slices/checkpoints/rules.js +30 -0
  68. package/dist/slices/checkpoints/schema.js +59 -0
  69. package/dist/slices/control/index.js +88 -22
  70. package/dist/slices/control/providers.js +3 -2
  71. package/dist/slices/control/revision-control-schema.js +7 -0
  72. package/dist/slices/control/revision-control.js +59 -0
  73. package/dist/slices/estimate/index.js +77 -59
  74. package/dist/slices/estimate/requests.js +102 -0
  75. package/dist/slices/images/run.js +14 -3
  76. package/dist/slices/library/slots.js +11 -11
  77. package/dist/slices/library/snapshot.js +33 -0
  78. package/dist/slices/narration/legacy-plan.js +58 -0
  79. package/dist/slices/narration/live.js +2 -2
  80. package/dist/slices/narration/plan.js +48 -54
  81. package/dist/slices/narration/run.js +21 -8
  82. package/dist/slices/play-drafts/convert.js +120 -0
  83. package/dist/slices/play-drafts/model.js +1 -0
  84. package/dist/slices/play-drafts/readiness.js +80 -0
  85. package/dist/slices/play-drafts/repo.js +76 -0
  86. package/dist/slices/play-drafts/review-inputs.js +150 -0
  87. package/dist/slices/play-drafts/review.js +109 -0
  88. package/dist/slices/play-drafts/schema.js +218 -0
  89. package/dist/slices/play-drafts/service.js +242 -0
  90. package/dist/slices/play-drafts/start-repo.js +113 -0
  91. package/dist/slices/play-drafts/start.js +148 -0
  92. package/dist/slices/play-drafts/uploads.js +67 -0
  93. package/dist/slices/project-templates/from-project.js +159 -0
  94. package/dist/slices/project-templates/model.js +1 -0
  95. package/dist/slices/project-templates/repo.js +41 -0
  96. package/dist/slices/project-templates/schema.js +37 -0
  97. package/dist/slices/project-templates/service.js +136 -0
  98. package/dist/slices/project-templates/setup.js +55 -0
  99. package/dist/slices/rebuild/admission-repo.js +120 -0
  100. package/dist/slices/rebuild/dependencies.js +33 -0
  101. package/dist/slices/rebuild/model.js +94 -0
  102. package/dist/slices/rebuild/narration-history.js +43 -0
  103. package/dist/slices/rebuild/narration-reuse.js +20 -0
  104. package/dist/slices/rebuild/preview-details.js +144 -0
  105. package/dist/slices/rebuild/preview-plan.js +223 -0
  106. package/dist/slices/rebuild/preview-retained.js +128 -0
  107. package/dist/slices/rebuild/provided-review.js +32 -0
  108. package/dist/slices/rebuild/recipe-audio.js +172 -0
  109. package/dist/slices/rebuild/recipe-build.js +17 -0
  110. package/dist/slices/rebuild/recipe-checkpoints.js +75 -0
  111. package/dist/slices/rebuild/recipe-exports.js +65 -0
  112. package/dist/slices/rebuild/recipe-fixture.js +150 -0
  113. package/dist/slices/rebuild/recipe-input-schema.js +82 -0
  114. package/dist/slices/rebuild/recipe-legacy.js +135 -0
  115. package/dist/slices/rebuild/recipe-model.js +77 -0
  116. package/dist/slices/rebuild/recipe-provider-choice.js +14 -0
  117. package/dist/slices/rebuild/recipe-save.js +221 -0
  118. package/dist/slices/rebuild/recipe-text.js +170 -0
  119. package/dist/slices/rebuild/recipe-validation.js +86 -0
  120. package/dist/slices/rebuild/recipe-visual.js +111 -0
  121. package/dist/slices/rebuild/recipe-work.js +212 -0
  122. package/dist/slices/rebuild/recipes.js +4 -0
  123. package/dist/slices/rebuild/repo.js +64 -0
  124. package/dist/slices/rebuild/runtime-actions.js +76 -0
  125. package/dist/slices/rebuild/runtime-admission.js +71 -0
  126. package/dist/slices/rebuild/runtime-article.js +117 -0
  127. package/dist/slices/rebuild/runtime-checkpoints.js +45 -0
  128. package/dist/slices/rebuild/runtime-export-inputs.js +109 -0
  129. package/dist/slices/rebuild/runtime-export.js +139 -0
  130. package/dist/slices/rebuild/runtime-instructions.js +51 -0
  131. package/dist/slices/rebuild/runtime-local.js +137 -0
  132. package/dist/slices/rebuild/runtime-materialize.js +105 -0
  133. package/dist/slices/rebuild/runtime-narration-reuse.js +134 -0
  134. package/dist/slices/rebuild/runtime-plan.js +103 -0
  135. package/dist/slices/rebuild/runtime-provider.js +184 -0
  136. package/dist/slices/rebuild/runtime-publication.js +172 -0
  137. package/dist/slices/rebuild/runtime-run.js +35 -0
  138. package/dist/slices/rebuild/runtime-store.js +166 -0
  139. package/dist/slices/rebuild/runtime-subtitles.js +202 -0
  140. package/dist/slices/rebuild/service-readiness.js +110 -0
  141. package/dist/slices/rebuild/service.js +149 -0
  142. package/dist/slices/rebuild/transition-repo.js +108 -0
  143. package/dist/slices/rebuild/work-records.js +44 -0
  144. package/dist/slices/reruns/index.js +5 -0
  145. package/dist/slices/research/run.js +33 -12
  146. package/dist/slices/revisions/adopt-assets.js +31 -0
  147. package/dist/slices/revisions/adopt-content.js +112 -0
  148. package/dist/slices/revisions/adopt.js +100 -0
  149. package/dist/slices/revisions/downloads.js +80 -0
  150. package/dist/slices/revisions/index.js +5 -0
  151. package/dist/slices/revisions/manifest-repo.js +125 -0
  152. package/dist/slices/revisions/model.js +1 -0
  153. package/dist/slices/revisions/mutation-assets.js +143 -0
  154. package/dist/slices/revisions/mutation-cues.js +95 -0
  155. package/dist/slices/revisions/mutation-prepare.js +182 -0
  156. package/dist/slices/revisions/mutation-request.js +58 -0
  157. package/dist/slices/revisions/mutation-work.js +47 -0
  158. package/dist/slices/revisions/mutations.js +194 -0
  159. package/dist/slices/revisions/projection.js +82 -0
  160. package/dist/slices/revisions/publication-model.js +1 -0
  161. package/dist/slices/revisions/publication-rules.js +166 -0
  162. package/dist/slices/revisions/publish.js +115 -0
  163. package/dist/slices/revisions/repo.js +83 -0
  164. package/dist/slices/revisions/restore.js +60 -0
  165. package/dist/slices/revisions/rules.js +99 -0
  166. package/dist/slices/revisions/schema.js +160 -0
  167. package/dist/slices/revisions/view.js +44 -0
  168. package/dist/slices/schedules/calendar.js +47 -0
  169. package/dist/slices/schedules/model.js +1 -0
  170. package/dist/slices/schedules/repo.js +281 -0
  171. package/dist/slices/schedules/scheduler.js +144 -0
  172. package/dist/slices/schedules/schema.js +80 -0
  173. package/dist/slices/schedules/service.js +178 -0
  174. package/dist/slices/settings/cli-paths.js +1 -1
  175. package/dist/slices/settings/cli-status.js +45 -5
  176. package/dist/slices/settings/tutorial-schema.js +39 -0
  177. package/dist/slices/settings/tutorial.js +66 -0
  178. package/dist/slices/storage/assets.js +108 -0
  179. package/dist/slices/storage/delete-project.js +2 -1
  180. package/dist/slices/storage/downloads.js +4 -1
  181. package/dist/slices/storage/portable.js +871 -0
  182. package/dist/slices/storage/prepare.js +67 -0
  183. package/dist/slices/storage/reconcile.js +25 -5
  184. package/dist/slices/storage/repo.js +2 -15
  185. package/dist/slices/storage/schema.js +35 -0
  186. package/dist/slices/storage/staging-refs.js +26 -0
  187. package/dist/slices/storage/staging.js +16 -11
  188. package/dist/slices/thumbnail/run.js +26 -9
  189. package/dist/slices/video/audio-export-args.js +33 -0
  190. package/dist/slices/video/audio-export.js +13 -36
  191. package/dist/slices/video/audio-inputs.js +4 -3
  192. package/dist/slices/video/ffmpeg.js +6 -0
  193. package/dist/slices/video/reuse-audio.js +11 -4
  194. package/dist/updater/install-flow.js +27 -2
  195. package/dist/updater/plan.js +28 -2
  196. package/dist/updater/service.js +27 -2
  197. package/dist/updater/worker.js +2 -1
  198. package/dist/web/assets/index-DX1_chKs.css +1 -0
  199. package/dist/web/assets/index-DwM-Y6nw.js +137 -0
  200. package/dist/web/index.html +2 -2
  201. package/package.json +2 -2
  202. package/dist/adapters/tts/inworld-text.js +0 -32
  203. package/dist/web/assets/index-Ce2HApus.css +0 -1
  204. package/dist/web/assets/index-xbD0MZOY.js +0 -130
@@ -0,0 +1,58 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { splitText } from "../../kernel/ports/text.js";
4
+ import { insertPiece, piecesOf } from "../../kernel/runner/piece-repo.js";
5
+ const payload = z.object({ text: z.string(), file: z.string().optional() });
6
+ // Old whole-text plans can exceed a newly known account limit. Preserve finished
7
+ // pieces and their paths, and split only unfinished work before any request starts.
8
+ export function planLegacyNarration(deps, stageId, texts, maxCharacters, finished) {
9
+ const old = piecesOf(deps.db, stageId, "chunk");
10
+ const source = old.length
11
+ ? old
12
+ : texts.map((text, idx) => ({
13
+ id: deps.ids.next(),
14
+ stageId,
15
+ kind: "chunk",
16
+ idx: idx + 1,
17
+ state: "pending",
18
+ payload: JSON.stringify({ text }),
19
+ }));
20
+ const planned = [];
21
+ for (const piece of source) {
22
+ const data = payload.parse(JSON.parse(piece.payload ?? "null"));
23
+ if (finished(piece)) {
24
+ planned.push({ ...piece, idx: planned.length + 1 });
25
+ continue;
26
+ }
27
+ for (const [part, text] of splitText(data.text, maxCharacters).entries())
28
+ planned.push({
29
+ ...piece,
30
+ id: part === 0 ? piece.id : deps.ids.next(),
31
+ idx: planned.length + 1,
32
+ state: "pending",
33
+ payload: JSON.stringify({ text }),
34
+ });
35
+ }
36
+ if (old.length &&
37
+ planned.length === old.length &&
38
+ planned.every((p, i) => p.id === old[i]?.id &&
39
+ payload.parse(JSON.parse(p.payload ?? "null")).text ===
40
+ payload.parse(JSON.parse(old[i]?.payload ?? "null")).text))
41
+ return old;
42
+ transact(deps.db, () => {
43
+ // Negative temporary indices avoid colliding with a retained completed piece.
44
+ deps.db
45
+ .prepare("UPDATE stage_pieces SET idx=-idx WHERE stage_id=? AND kind='chunk'")
46
+ .run(stageId);
47
+ const known = new Set(old.map((p) => p.id));
48
+ for (const piece of planned) {
49
+ if (known.has(piece.id))
50
+ deps.db
51
+ .prepare("UPDATE stage_pieces SET idx=?,state=?,payload=? WHERE id=?")
52
+ .run(piece.idx, piece.state, piece.payload, piece.id);
53
+ else
54
+ insertPiece(deps.db, piece);
55
+ }
56
+ });
57
+ return planned;
58
+ }
@@ -1,11 +1,11 @@
1
- export function observeNarration(store, projectId, key, label) {
1
+ export function observeNarration(store, projectId, key, label, origin) {
2
2
  if (store === undefined)
3
3
  return undefined;
4
4
  let sink;
5
5
  return (event) => {
6
6
  switch (event.type) {
7
7
  case "start":
8
- sink = store.begin(projectId, key, label);
8
+ sink = store.begin(projectId, key, label, origin);
9
9
  break;
10
10
  case "chunk":
11
11
  sink?.append(event.bytes);
@@ -1,58 +1,52 @@
1
- import { z } from "zod";
2
- import { transact } from "../../kernel/db/tx.js";
3
1
  import { splitText } from "../../kernel/ports/text.js";
4
- import { insertPiece, piecesOf } from "../../kernel/runner/piece-repo.js";
5
- const payload = z.object({ text: z.string(), file: z.string().optional() });
6
- // Old whole-text plans can exceed a newly known account limit. Preserve finished
7
- // pieces and their paths, and split only unfinished work before any request starts.
8
- export function planNarration(deps, stageId, texts, maxCharacters, finished) {
9
- const old = piecesOf(deps.db, stageId, "chunk");
10
- const source = old.length
11
- ? old
12
- : texts.map((text, idx) => ({
13
- id: deps.ids.next(),
14
- stageId,
15
- kind: "chunk",
16
- idx: idx + 1,
17
- state: "pending",
18
- payload: JSON.stringify({ text }),
19
- }));
20
- const planned = [];
21
- for (const piece of source) {
22
- const data = payload.parse(JSON.parse(piece.payload ?? "null"));
23
- if (finished(piece)) {
24
- planned.push({ ...piece, idx: planned.length + 1 });
25
- continue;
26
- }
27
- for (const [part, text] of splitText(data.text, maxCharacters).entries())
28
- planned.push({
29
- ...piece,
30
- id: part === 0 ? piece.id : deps.ids.next(),
31
- idx: planned.length + 1,
32
- state: "pending",
33
- payload: JSON.stringify({ text }),
2
+ import { fingerprint } from "../../kernel/runner/work.js";
3
+ export function normalizeNarrationText(text) {
4
+ return text.replace(/\r\n?/g, "\n").trim();
5
+ }
6
+ export function narrationRequestFingerprint(input) {
7
+ return fingerprint([
8
+ "narration-request-v1",
9
+ input.provider,
10
+ input.model,
11
+ input.voice,
12
+ input.text,
13
+ null,
14
+ input.segment,
15
+ input.wholeText === null ? null : fingerprint(["whole-v1", input.wholeText]),
16
+ ]);
17
+ }
18
+ export function planNarration(input) {
19
+ return input.groups.flatMap((group) => {
20
+ const text = normalizeNarrationText(group.text);
21
+ return splitText(text, input.maxCharacters).map((part, index) => {
22
+ const requestFingerprint = narrationRequestFingerprint({
23
+ ...input,
24
+ text: part,
25
+ segment: group.segment,
26
+ wholeText: group.wholeRequest ? text : null,
34
27
  });
35
- }
36
- if (old.length &&
37
- planned.length === old.length &&
38
- planned.every((p, i) => p.id === old[i]?.id &&
39
- payload.parse(JSON.parse(p.payload ?? "null")).text ===
40
- payload.parse(JSON.parse(old[i]?.payload ?? "null")).text))
41
- return old;
42
- transact(deps.db, () => {
43
- // Negative temporary indices avoid colliding with a retained completed piece.
44
- deps.db
45
- .prepare("UPDATE stage_pieces SET idx=-idx WHERE stage_id=? AND kind='chunk'")
46
- .run(stageId);
47
- const known = new Set(old.map((p) => p.id));
48
- for (const piece of planned) {
49
- if (known.has(piece.id))
50
- deps.db
51
- .prepare("UPDATE stage_pieces SET idx=?,state=?,payload=? WHERE id=?")
52
- .run(piece.idx, piece.state, piece.payload, piece.id);
53
- else
54
- insertPiece(deps.db, piece);
55
- }
28
+ const workFingerprint = fingerprint([requestFingerprint, group.regenerationToken]);
29
+ const retained = group.regenerationToken === null
30
+ ? input.retained.find((one) => one.available && one.requestFingerprint === requestFingerprint)
31
+ : input.retained.find((one) => one.available && one.fingerprint === workFingerprint);
32
+ return {
33
+ key: `${group.key}:${index + 1}`,
34
+ logicalKey: group.key,
35
+ logicalText: text,
36
+ segment: group.segment,
37
+ text: part,
38
+ requestFingerprint,
39
+ fingerprint: workFingerprint,
40
+ assetId: retained?.assetId ?? null,
41
+ };
42
+ });
56
43
  });
57
- return planned;
44
+ }
45
+ export function narrationRegenerationToken(tokens, logicalKey, segment) {
46
+ return tokens[logicalKey] ?? tokens[`audio:${segment}:future`] ?? null;
47
+ }
48
+ export function narrationRegenerationKey(key) {
49
+ return /^audio:(body:.+|intro|outro):[0-9]+$/.test(key)
50
+ ? key.slice(0, key.lastIndexOf(":"))
51
+ : key;
58
52
  }
@@ -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
+ }