@gentbajko/slopify 0.8.4 → 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 (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-CO7DnVX9.js +137 -0
  199. package/dist/web/assets/index-DX1_chKs.css +1 -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,172 @@
1
+ import { transact } from "../../kernel/db/tx.js";
2
+ import { fingerprint } from "../../kernel/runner/work.js";
3
+ import { publicationAuthority } from "../revisions/publication-rules.js";
4
+ import { commitRevisionOutputs } from "../revisions/publish.js";
5
+ import { insertAsset, insertManifestOutput, revisionById } from "../revisions/repo.js";
6
+ import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
7
+ import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
8
+ export function preparedResult(deps, context, piece, role, asset, durationMs = null, meta = {}) {
9
+ return {
10
+ slot: role === "image" ? piece.key : `${context.work.kind}:${role}`,
11
+ workKey: piece.key,
12
+ fingerprint: piece.logicalFingerprint ?? piece.fingerprint,
13
+ asset,
14
+ output: {
15
+ id: deps.ids.next(),
16
+ projectId: context.work.projectId,
17
+ stageKind: context.work.kind,
18
+ role,
19
+ path: asset.path,
20
+ originalFilename: null,
21
+ bytes: asset.bytes,
22
+ durationMs,
23
+ meta,
24
+ createdAt: asset.createdAt,
25
+ },
26
+ };
27
+ }
28
+ export function preparedText(deps, context, piece, role, filename, text) {
29
+ const output = preparedTexts(deps, context, piece, [[role, filename, text]])[0];
30
+ if (output === undefined)
31
+ throw new Error("The text output was not prepared.");
32
+ return output;
33
+ }
34
+ export function preparedTexts(deps, context, piece, texts) {
35
+ const assets = [];
36
+ try {
37
+ return texts.map(([role, filename, text]) => {
38
+ const asset = writeAsset(deps, context.work.projectId, filename, Buffer.from(text));
39
+ assets.push(asset);
40
+ return preparedResult(deps, context, piece, role, asset);
41
+ });
42
+ }
43
+ catch (error) {
44
+ discardPreparedAssets(deps, assets);
45
+ throw error;
46
+ }
47
+ }
48
+ export async function publishResult(deps, context, piece, outputs, payload, asset = null) {
49
+ try {
50
+ const record = {
51
+ key: piece.key,
52
+ stageKind: context.work.kind,
53
+ asset,
54
+ fingerprint: piece.fingerprint,
55
+ piece: {
56
+ id: piece.id,
57
+ stageId: context.work.stageId,
58
+ kind: pieceKind(piece),
59
+ idx: pieceIndex(deps, context, piece),
60
+ state: "done",
61
+ payload: JSON.stringify({
62
+ ...payload,
63
+ requestFingerprint: piece.requestFingerprint,
64
+ ...(asset === null ? {} : { file: asset.path }),
65
+ }),
66
+ },
67
+ };
68
+ const result = await commitRevisionOutputs(deps, { work: context.work, pieceId: piece.id, publicationId: piece.id }, outputs, [record]);
69
+ deps.db
70
+ .prepare("UPDATE revision_work_pieces SET state='done' WHERE id=? AND work_id=?")
71
+ .run(piece.id, context.work.workId);
72
+ if (result.currentAttached)
73
+ context.emit({ type: "project.updated", projectId: context.work.projectId });
74
+ }
75
+ finally {
76
+ discardPreparedAssets(deps, [
77
+ ...outputs.map((output) => output.asset),
78
+ ...(asset === null ? [] : [asset]),
79
+ ]);
80
+ }
81
+ }
82
+ function pieceKind(piece) {
83
+ if (piece.input.kind === "tts")
84
+ return piece.input.segment === "body" ? "chunk" : "segment";
85
+ if (piece.input.kind === "provided" && /^audio:body:.+:\d+$/.test(piece.key))
86
+ return "chunk";
87
+ if (piece.input.kind === "provided" && /^audio:(intro|outro):\d+$/.test(piece.key))
88
+ return "segment";
89
+ if (piece.key.startsWith("image:"))
90
+ return "image";
91
+ if (piece.key.startsWith("research:chapter:"))
92
+ return "chapter";
93
+ if (piece.key.startsWith("entry:"))
94
+ return "segment";
95
+ if (piece.key === "research:planner" || piece.key === "thumbnail:prompt")
96
+ return "prompt_written";
97
+ return "article_written";
98
+ }
99
+ function pieceIndex(deps, context, piece) {
100
+ // Intro and outro can resolve independently. Reserve alternating ordinals so a
101
+ // late intro part cannot collide with an outro that was already published.
102
+ const segment = /^audio:(intro|outro):(\d+)$/.exec(piece.key);
103
+ if (segment !== null)
104
+ return Number(segment[2]) * 2 - (segment[1] === "intro" ? 1 : 0);
105
+ if (piece.key === "entry:intro:text")
106
+ return 1;
107
+ if (piece.key === "entry:outro:text")
108
+ return 2;
109
+ const localOrder = [
110
+ "audio:body:concat",
111
+ "audio:intro",
112
+ "audio:outro",
113
+ "audio:provided",
114
+ "subtitles:timing",
115
+ "subtitles:cues",
116
+ "subtitles:files",
117
+ "export:wav",
118
+ "export:video",
119
+ ];
120
+ const localIndex = localOrder.indexOf(piece.key);
121
+ if (localIndex >= 0)
122
+ return localIndex + 1;
123
+ const view = executionView(deps, context.work.projectId, context.work.revisionId);
124
+ const row = deps.db
125
+ .prepare("SELECT recipe_context FROM revision_work WHERE id=?")
126
+ .get(context.work.workId);
127
+ if (view !== undefined && row?.recipe_context !== null && row?.recipe_context !== undefined) {
128
+ const keys = executionPlan(deps, view, savedCatalogue(row.recipe_context)).recipes.filter((recipe) => recipe.stage === context.work.kind &&
129
+ pieceKind({ ...piece, key: recipe.key, input: recipe.input }) === pieceKind(piece));
130
+ const index = keys.findIndex((recipe) => recipe.key === piece.key);
131
+ if (index >= 0)
132
+ return index + 1;
133
+ }
134
+ const last = piece.key.split(":").at(-1);
135
+ return last !== undefined && /^\d+$/.test(last) ? Number(last) : 1;
136
+ }
137
+ export function retainPartialArticle(deps, context, piece, text) {
138
+ const publicationId = fingerprint(["partial-article", piece.id]);
139
+ if (deps.db
140
+ .prepare("SELECT 1 FROM revision_outputs WHERE revision_id=? AND publication_id=?")
141
+ .get(context.work.revisionId, publicationId) !== undefined)
142
+ return;
143
+ const result = preparedText(deps, context, piece, "article_md", "partial-article.md", text);
144
+ try {
145
+ transact(deps.db, () => {
146
+ const authority = publicationAuthority(deps.db, {
147
+ work: context.work,
148
+ pieceId: piece.id,
149
+ publicationId: piece.id,
150
+ });
151
+ if (authority === undefined)
152
+ return;
153
+ if (authority.key !== piece.key || authority.fingerprint !== piece.fingerprint)
154
+ throw new Error("Partial article differs from its durable piece authority.");
155
+ const revision = revisionById(deps.db, context.work.projectId, context.work.revisionId);
156
+ if (revision === undefined)
157
+ throw new Error("Partial article origin disappeared.");
158
+ insertAsset(deps.db, result.asset);
159
+ insertManifestOutput(deps.db, revision, {
160
+ slot: `article:partial:${piece.id}`,
161
+ workKey: `article:partial:${piece.id}`,
162
+ assetId: result.asset.id,
163
+ output: { ...result.output, originalFilename: "partial-article.md" },
164
+ fingerprint: piece.fingerprint,
165
+ state: "outdated",
166
+ }, deps.ids.next(), publicationId);
167
+ });
168
+ }
169
+ finally {
170
+ discardPreparedAssets(deps, [result.asset]);
171
+ }
172
+ }
@@ -0,0 +1,35 @@
1
+ import { executeExportRecipe } from "./runtime-export.js";
2
+ import { executeLocalRecipe } from "./runtime-local.js";
3
+ import { executeProviderRecipe } from "./runtime-provider.js";
4
+ import { executeSubtitleRecipe } from "./runtime-subtitles.js";
5
+ import { workPieces } from "./work-records.js";
6
+ export async function runRevisionInvocation(deps, context, providers) {
7
+ const pieces = workPieces(deps.db, context.work.workId).filter((piece) => !piece.key.startsWith("article:continuation:"));
8
+ for (const piece of pieces) {
9
+ if (piece.state === "done")
10
+ continue;
11
+ if (piece.input.kind === "deferred")
12
+ return "held";
13
+ try {
14
+ const outcome = piece.key.startsWith("subtitles:")
15
+ ? await executeSubtitleRecipe(deps, context, piece)
16
+ : piece.key.startsWith("export:")
17
+ ? await executeExportRecipe(deps, context, piece)
18
+ : piece.input.kind === "llm" || piece.input.kind === "tts" || piece.input.kind === "image"
19
+ ? await executeProviderRecipe(deps, context, providers, piece)
20
+ : await executeLocalRecipe(deps, context, piece);
21
+ if (outcome === "held")
22
+ return outcome;
23
+ }
24
+ catch (error) {
25
+ deps.db
26
+ .prepare("UPDATE revision_work_pieces SET state=? WHERE id=? AND work_id=? AND state!='done'")
27
+ .run(context.signal.aborted ? "pending" : "failed", piece.id, context.work.workId);
28
+ throw error;
29
+ }
30
+ }
31
+ deps.db
32
+ .prepare("UPDATE revision_work_pieces SET state='done' WHERE work_id=? AND result_json IS NOT NULL")
33
+ .run(context.work.workId);
34
+ return "done";
35
+ }
@@ -0,0 +1,166 @@
1
+ import { z } from "zod";
2
+ import { stageKinds, stageStates } from "../../kernel/pipeline.js";
3
+ import { currentRevisionId } from "../revisions/repo.js";
4
+ import { getRevisionView } from "../revisions/view.js";
5
+ import { settleScheduleRunsForProject } from "../schedules/repo.js";
6
+ import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
7
+ import { workPieces } from "./work-records.js";
8
+ const workRow = z.object({
9
+ id: z.string(),
10
+ project_id: z.string(),
11
+ revision_id: z.string(),
12
+ stage_id: z.string(),
13
+ kind: z.enum(stageKinds),
14
+ state: z.enum(stageStates),
15
+ fingerprint: z.string(),
16
+ });
17
+ export function executionStages(deps, projectId) {
18
+ const head = currentRevisionId(deps.db, projectId);
19
+ if (head === undefined)
20
+ return [];
21
+ const view = getRevisionView(deps, projectId, head);
22
+ const rows = deps.db
23
+ .prepare(`SELECT DISTINCT w.* FROM revision_work w JOIN revision_work_reservations r ON r.work_id=w.id WHERE r.project_id=? AND r.revision_id=?`)
24
+ .all(projectId, head);
25
+ const keys = new Set(deps.db
26
+ .prepare("SELECT work_key FROM revision_work_reservations WHERE revision_id=?")
27
+ .all(head)
28
+ .map((row) => String(row.work_key)));
29
+ return rows
30
+ .filter((row) => workPieces(deps.db, String(row.id)).some((piece) => {
31
+ if (!keys.has(piece.key))
32
+ return false;
33
+ if (piece.key === "subtitles:timing" && view?.revision.content.subtitleCues !== undefined)
34
+ return false;
35
+ if (piece.input.kind === "deferred" && piece.key.endsWith(":future")) {
36
+ const prefix = piece.key.slice(0, -"future".length);
37
+ return !rows.some((candidate) => workPieces(deps.db, String(candidate.id)).some((part) => part.input.kind === "tts" && part.key.startsWith(prefix)));
38
+ }
39
+ return true;
40
+ }))
41
+ .map((value) => {
42
+ const row = workRow.parse(value);
43
+ return {
44
+ id: row.stage_id,
45
+ projectId,
46
+ kind: row.kind,
47
+ state: row.state,
48
+ work: {
49
+ projectId,
50
+ revisionId: row.revision_id,
51
+ workId: row.id,
52
+ stageId: row.stage_id,
53
+ kind: row.kind,
54
+ fingerprint: row.fingerprint,
55
+ },
56
+ };
57
+ });
58
+ }
59
+ export function invocationReady(deps, work) {
60
+ const row = deps.db
61
+ .prepare("SELECT recipe_context FROM revision_work WHERE id=? AND project_id=? AND revision_id=?")
62
+ .get(work.workId, work.projectId, work.revisionId);
63
+ if (row?.recipe_context === null || row === undefined)
64
+ return false;
65
+ const view = executionView(deps, work.projectId, work.revisionId);
66
+ if (view === undefined)
67
+ return false;
68
+ const plan = executionPlan(deps, view, savedCatalogue(row.recipe_context));
69
+ const pieces = workPieces(deps.db, work.workId);
70
+ return pieces.some((piece) => {
71
+ if (piece.state === "done" ||
72
+ piece.dispatchState !== "allowed" ||
73
+ piece.input.kind === "deferred")
74
+ return false;
75
+ if (piece.continuation !== null)
76
+ return true;
77
+ const recipe = plan.recipes.find((value) => value.key === piece.key && value.fingerprint === piece.fingerprint);
78
+ if (recipe === undefined || recipe.unresolved || recipe.deferred)
79
+ return false;
80
+ return recipe.dependsOn.every((key) => {
81
+ const dependency = plan.work.find((value) => value.key === key);
82
+ return dependency?.disposition === "reuse" && !dependency.inflight;
83
+ });
84
+ });
85
+ }
86
+ export function executionStandings(deps, projectId) {
87
+ const revisionId = currentRevisionId(deps.db, projectId);
88
+ const view = revisionId === undefined ? undefined : getRevisionView(deps, projectId, revisionId);
89
+ if (view === undefined)
90
+ return [];
91
+ const work = executionStages(deps, projectId);
92
+ return stageKinds.map((kind) => {
93
+ const source = view.revision.config.sources[kind];
94
+ const group = work
95
+ .filter((entry) => entry.kind === kind)
96
+ .map((entry) => {
97
+ const pieces = workPieces(deps.db, entry.work.workId);
98
+ const retained = pieces.length > 0 &&
99
+ pieces.every((piece) => view.outputs.some((output) => output.workKey === piece.key &&
100
+ output.selected &&
101
+ output.available &&
102
+ output.state === "ready") ||
103
+ view.pieces.some((output) => output.key === piece.key &&
104
+ output.selected &&
105
+ output.available &&
106
+ output.piece.state === "done" &&
107
+ output.fingerprint === piece.fingerprint));
108
+ // Save can bind an existing asset without granting its placeholder invocation.
109
+ return retained ? { ...entry, state: "done" } : entry;
110
+ });
111
+ let state = source === "off" ? "skipped" : source === "provide" ? "provided" : "pending";
112
+ if (group.some((entry) => entry.state === "running"))
113
+ state = "running";
114
+ else if (group.some((entry) => entry.state === "failed"))
115
+ state = "failed";
116
+ else if (group.some((entry) => entry.state === "canceled"))
117
+ state = "canceled";
118
+ else if (group.length > 0)
119
+ state = group.every((entry) => entry.state === "done")
120
+ ? source === "provide"
121
+ ? "provided"
122
+ : "done"
123
+ : "pending";
124
+ return { kind, state };
125
+ });
126
+ }
127
+ export function projectStandings(deps, projectId) {
128
+ const standings = executionStandings(deps, projectId);
129
+ for (const stage of standings) {
130
+ const invocations = executionStages(deps, projectId).filter((work) => work.kind === stage.kind);
131
+ const current = invocations.reduce((total, work) => {
132
+ if (work.state === "done")
133
+ return total + 1;
134
+ const progress = deps.db
135
+ .prepare("SELECT progress_current,progress_total FROM revision_work WHERE id=?")
136
+ .get(work.work.workId);
137
+ if (typeof progress?.progress_current !== "number" ||
138
+ typeof progress.progress_total !== "number" ||
139
+ progress.progress_total <= 0)
140
+ return total;
141
+ return total + Math.max(0, Math.min(1, progress.progress_current / progress.progress_total));
142
+ }, 0);
143
+ const failed = invocations.find((work) => work.state === "failed");
144
+ const failure = failed === undefined
145
+ ? null
146
+ : (deps.db
147
+ .prepare("SELECT failure_reason FROM revision_work WHERE id=?")
148
+ .get(failed.work.workId)?.failure_reason ?? null);
149
+ deps.db
150
+ .prepare("UPDATE stages SET state=?,failure_reason=?,progress_current=?,progress_total=?,finished_at=CASE WHEN ?='done' THEN ? ELSE NULL END WHERE project_id=? AND kind=?")
151
+ .run(stage.state, failure, invocations.length === 0 ? null : current, invocations.length === 0 ? null : invocations.length, stage.state, deps.clock.now().toISOString(), projectId, stage.kind);
152
+ }
153
+ settleScheduleRunsForProject(deps.db, projectId, deps.clock.now().toISOString());
154
+ }
155
+ export function recordWorkProgress(deps, event) {
156
+ if (event.workId === undefined ||
157
+ event.revisionId === undefined ||
158
+ !Number.isFinite(event.current) ||
159
+ !Number.isFinite(event.total) ||
160
+ event.total <= 0)
161
+ return;
162
+ deps.db
163
+ .prepare("UPDATE revision_work SET progress_current=?,progress_total=? WHERE id=? AND revision_id=? AND project_id=? AND kind=? AND state='running'")
164
+ .run(Math.max(0, Math.min(event.current, event.total)), event.total, event.workId, event.revisionId, event.projectId, event.stage);
165
+ projectStandings(deps, event.projectId);
166
+ }
@@ -0,0 +1,202 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { resolveFont } from "../fonts/index.js";
5
+ import { validateCues } from "../revisions/rules.js";
6
+ import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
7
+ import { outputPath } from "../storage/layout.js";
8
+ import { captionCues, serializeAss, serializeSrt, serializeVtt } from "../subtitles/captions.js";
9
+ import { exportSnapshot, retainedOutput, revisionAudio, revisionTranscript, } from "./runtime-export-inputs.js";
10
+ import { preparedResult, preparedText, publishResult } from "./runtime-publication.js";
11
+ const wordsSchema = z.object({
12
+ key: z.string(),
13
+ words: z.array(z.object({
14
+ text: z.string(),
15
+ start: z.number().finite().nonnegative(),
16
+ end: z.number().finite().positive(),
17
+ confidence: z.number().optional(),
18
+ })),
19
+ omissions: z
20
+ .array(z.object({ start: z.number().finite().nonnegative(), text: z.string() }))
21
+ .default([]),
22
+ });
23
+ const cuesSchema = z.object({
24
+ cues: z.array(z.object({
25
+ text: z.string(),
26
+ start: z.number().finite().nonnegative(),
27
+ end: z.number().finite().positive(),
28
+ })),
29
+ omissions: wordsSchema.shape.omissions,
30
+ });
31
+ export async function executeSubtitleRecipe(deps, context, piece) {
32
+ if (!context.maySubmit(piece.id))
33
+ return "held";
34
+ context.signal.throwIfAborted();
35
+ const snapshot = exportSnapshot(deps, context, piece);
36
+ if (piece.key === "subtitles:timing")
37
+ return timing(deps, context, piece, snapshot);
38
+ if (piece.key === "subtitles:cues")
39
+ return cues(deps, context, piece, snapshot);
40
+ if (piece.key === "subtitles:files")
41
+ return files(deps, context, piece, snapshot);
42
+ throw new Error("Unknown subtitle recipe.");
43
+ }
44
+ async function timing(deps, context, piece, snapshot) {
45
+ const audio = await revisionAudio(deps, context, snapshot.view);
46
+ 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.");
50
+ if (!context.maySubmit(piece.id))
51
+ return "held";
52
+ const words = [];
53
+ const omissions = [];
54
+ let offset = 0;
55
+ const total = audio.reduce((sum, segment) => sum + segment.seconds, 0);
56
+ for (const segment of audio) {
57
+ 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"),
63
+ ffmpeg: deps.ffmpeg,
64
+ signal: context.signal,
65
+ onOmission: (value) => omissions.push({ ...value, start: value.start + offset }),
66
+ onProgress: (current, maximum) => context.emit({
67
+ type: "stage.progress",
68
+ projectId: context.work.projectId,
69
+ stage: "video",
70
+ current: Math.round((100 * (offset + segment.seconds * (maximum > 0 ? current / maximum : 0))) / total),
71
+ total: 100,
72
+ }),
73
+ });
74
+ for (const word of aligned) {
75
+ if (word.end > segment.seconds + 0.1)
76
+ throw new Error("Subtitle timing exceeds the narration duration.");
77
+ words.push({
78
+ ...word,
79
+ start: word.start + offset,
80
+ end: Math.min(word.end, segment.seconds) + offset,
81
+ });
82
+ }
83
+ }
84
+ offset += segment.seconds;
85
+ }
86
+ if (captionCues(words).length === 0)
87
+ throw new Error("No spoken words could be aligned. Check that the English article matches the audio.");
88
+ context.signal.throwIfAborted();
89
+ const output = preparedText(deps, context, piece, "subtitle_words", "subtitles.json", JSON.stringify({ key: piece.logicalFingerprint ?? piece.fingerprint, words, omissions }));
90
+ await publishResult(deps, context, piece, [output], { words, omissions }, output.asset);
91
+ return "done";
92
+ }
93
+ async function cues(deps, context, piece, snapshot) {
94
+ const { view, plan } = snapshot;
95
+ const manual = view.revision.content.subtitleCues;
96
+ let value;
97
+ if (manual !== undefined) {
98
+ 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.");
100
+ const audio = await revisionAudio(deps, context, view);
101
+ const errors = validateCues(manual.cues, audio.reduce((sum, segment) => sum + segment.seconds, 0));
102
+ if (errors.length > 0)
103
+ throw new Error(errors.map((error) => error.message).join(" "));
104
+ value = { cues: [...manual.cues], omissions: [] };
105
+ }
106
+ else {
107
+ const output = view.outputs.find((one) => one.selected &&
108
+ one.available &&
109
+ one.state === "ready" &&
110
+ one.workKey === "subtitles:timing");
111
+ if (output === undefined)
112
+ throw new Error("The revision has no aligned subtitle timing.");
113
+ const timed = wordsSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, context.work.projectId, output.output.path), "utf8")));
114
+ value = { cues: [...captionCues(timed.words)], omissions: timed.omissions };
115
+ }
116
+ if (!context.maySubmit(piece.id))
117
+ return "held";
118
+ const asset = writeAsset(deps, context.work.projectId, "cues.json", Buffer.from(JSON.stringify(value)));
119
+ await publishResult(deps, context, piece, [], value, asset);
120
+ return "done";
121
+ }
122
+ async function files(deps, context, piece, snapshot) {
123
+ const { view } = snapshot;
124
+ const config = view.revision.config.subtitles;
125
+ if (config === undefined || config.mode === "off")
126
+ throw new Error("The admitted revision has no subtitles.");
127
+ const row = view.pieces.find((one) => one.key === "subtitles:cues" && one.selected && one.available && one.piece.state === "done");
128
+ if (row?.piece.payload === null || row?.piece.payload === undefined)
129
+ throw new Error("The revision has no prepared captions.");
130
+ const value = cuesSchema.parse(JSON.parse(row.piece.payload));
131
+ const previous = view.outputs.find((one) => one.output.role === "subtitle_font" && one.selected && one.available);
132
+ const previousFiles = view.pieces.find((one) => one.key === "subtitles:files" &&
133
+ one.piece.state === "done" &&
134
+ (one.selected ||
135
+ (one.publicationId !== null && one.publicationId === previous?.publicationId)));
136
+ const savedFont = z
137
+ .object({
138
+ font: z
139
+ .object({
140
+ id: z.string(),
141
+ name: z.string(),
142
+ assName: z.string(),
143
+ extension: z.enum([".ttf", ".otf", ".ttc"]),
144
+ })
145
+ .optional(),
146
+ })
147
+ .parse(JSON.parse(previousFiles?.piece.payload ?? "{}")).font;
148
+ const font = savedFont?.id === config.fontId && previous !== undefined
149
+ ? { ...savedFont, path: outputPath(deps.paths, context.work.projectId, previous.output.path) }
150
+ : await resolveFont(deps.paths, config.fontId);
151
+ if (!context.maySubmit(piece.id))
152
+ return "held";
153
+ const prepared = [];
154
+ try {
155
+ const frame = view.revision.config.format === "16:9"
156
+ ? { width: 1920, height: 1080 }
157
+ : { width: 1080, height: 1920 };
158
+ for (const [role, filename, text] of [
159
+ ["subtitles_srt", "subtitles.srt", serializeSrt(value.cues)],
160
+ ["subtitles_vtt", "subtitles.vtt", serializeVtt(value.cues)],
161
+ [
162
+ "subtitle_ass",
163
+ "subtitles.ass",
164
+ serializeAss(value.cues, {
165
+ ...frame,
166
+ fontName: font.assName,
167
+ fontSize: config.fontSize,
168
+ position: config.position,
169
+ }),
170
+ ],
171
+ ])
172
+ prepared.push(preparedText(deps, context, piece, role, filename, text));
173
+ prepared.push(preparedResult(deps, context, piece, "subtitle_font", writeAsset(deps, context.work.projectId, `selected${font.extension}`, readFileSync(font.path))));
174
+ const media = view.outputs.find((one) => one.selected &&
175
+ one.available &&
176
+ one.state === "ready" &&
177
+ (one.output.role === "audio_export" ||
178
+ (one.output.role === "video" && config.mode === "files")));
179
+ if (media !== undefined) {
180
+ const retained = retainedOutput(deps, view, media);
181
+ prepared.push({
182
+ ...retained,
183
+ output: {
184
+ ...retained.output,
185
+ meta: {
186
+ ...retained.output.meta,
187
+ subtitlesMode: "files",
188
+ subtitleOmissions: value.omissions,
189
+ },
190
+ },
191
+ });
192
+ }
193
+ await publishResult(deps, context, piece, prepared, {
194
+ ...value,
195
+ font: { id: font.id, name: font.name, assName: font.assName, extension: font.extension },
196
+ });
197
+ return "done";
198
+ }
199
+ finally {
200
+ discardPreparedAssets(deps, prepared.map((one) => one.asset));
201
+ }
202
+ }