@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
@@ -0,0 +1,125 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { z } from "zod";
3
+ import { transact } from "../../kernel/db/tx.js";
4
+ import { stageKinds } from "../../kernel/pipeline.js";
5
+ import { outputSchema } from "../storage/schema.js";
6
+ import { stagePieceSchema } from "./schema.js";
7
+ const selected = z.union([z.literal(0), z.literal(1)]).transform((value) => value === 1);
8
+ const commonRow = z.object({
9
+ id: z.string(),
10
+ asset_id: z.string().nullable(),
11
+ fingerprint: z.string(),
12
+ descriptor: z.string(),
13
+ publication_id: z.string().nullable(),
14
+ selected,
15
+ });
16
+ const outputRow = commonRow.extend({
17
+ slot: z.string(),
18
+ work_key: z.string(),
19
+ asset_id: z.string(),
20
+ state: z.enum(["ready", "outdated", "review"]),
21
+ });
22
+ const pieceRow = commonRow.extend({ piece_key: z.string(), stage_kind: z.enum(stageKinds) });
23
+ export function insertManifestOutput(db, revision, output, recordId, publicationId = null) {
24
+ if (publicationId !== null) {
25
+ const row = db
26
+ .prepare("SELECT * FROM revision_outputs WHERE project_id=? AND revision_id=? AND publication_id=? AND slot=?")
27
+ .get(revision.projectId, revision.id, publicationId, output.slot);
28
+ if (row !== undefined) {
29
+ const previous = toOutput(row);
30
+ if (!isDeepStrictEqual(previous, {
31
+ ...output,
32
+ output: outputSchema.parse(JSON.parse(JSON.stringify(output.output))),
33
+ recordId: previous.recordId,
34
+ publicationId,
35
+ selected: previous.selected,
36
+ }))
37
+ throw new Error("Output publication conflicts with its retained result.");
38
+ return;
39
+ }
40
+ }
41
+ db.prepare(`INSERT INTO revision_outputs
42
+ (id,project_id,revision_id,slot,work_key,asset_id,fingerprint,state,descriptor,publication_id,selected,created_at)
43
+ VALUES (?,?,?,?,?,?,?,?,?,?,0,?)`).run(recordId, revision.projectId, revision.id, output.slot, output.workKey, output.assetId, output.fingerprint, output.state, JSON.stringify(output.output), publicationId, revision.createdAt);
44
+ }
45
+ export function insertManifestPiece(db, revision, piece, recordId, publicationId = null) {
46
+ if (publicationId !== null) {
47
+ const row = db
48
+ .prepare("SELECT * FROM revision_pieces WHERE project_id=? AND revision_id=? AND publication_id=? AND piece_key=?")
49
+ .get(revision.projectId, revision.id, publicationId, piece.key);
50
+ if (row !== undefined) {
51
+ const previous = toPiece(row);
52
+ if (!isDeepStrictEqual(previous, {
53
+ ...piece,
54
+ piece: stagePieceSchema.parse(JSON.parse(JSON.stringify(piece.piece))),
55
+ recordId: previous.recordId,
56
+ publicationId,
57
+ selected: previous.selected,
58
+ }))
59
+ throw new Error("Piece publication conflicts with its retained result.");
60
+ return;
61
+ }
62
+ }
63
+ db.prepare(`INSERT INTO revision_pieces
64
+ (id,project_id,revision_id,piece_key,stage_kind,asset_id,fingerprint,descriptor,publication_id,selected,created_at)
65
+ VALUES (?,?,?,?,?,?,?,?,?,0,?)`).run(recordId, revision.projectId, revision.id, piece.key, piece.stageKind, piece.assetId, piece.fingerprint, JSON.stringify(piece.piece), publicationId, revision.createdAt);
66
+ }
67
+ export function outputsForRevision(db, projectId, revisionId) {
68
+ return db
69
+ .prepare("SELECT * FROM revision_outputs WHERE project_id=? AND revision_id=? ORDER BY created_at,id")
70
+ .all(projectId, revisionId)
71
+ .map(toOutput);
72
+ }
73
+ export function piecesForRevision(db, projectId, revisionId) {
74
+ return db
75
+ .prepare("SELECT * FROM revision_pieces WHERE project_id=? AND revision_id=? ORDER BY created_at,id")
76
+ .all(projectId, revisionId)
77
+ .map(toPiece);
78
+ }
79
+ export function selectOutputRecord(db, revisionId, slot, recordId) {
80
+ transact(db, () => {
81
+ db.prepare("UPDATE revision_outputs SET selected=0 WHERE revision_id=? AND slot=? AND selected=1").run(revisionId, slot);
82
+ const result = db
83
+ .prepare("UPDATE revision_outputs SET selected=1 WHERE id=? AND revision_id=? AND slot=?")
84
+ .run(recordId, revisionId, slot);
85
+ if (Number(result.changes) !== 1)
86
+ throw new Error("Published output record was not found.");
87
+ });
88
+ }
89
+ export function selectPieceRecord(db, revisionId, key, recordId) {
90
+ transact(db, () => {
91
+ db.prepare("UPDATE revision_pieces SET selected=0 WHERE revision_id=? AND piece_key=? AND selected=1").run(revisionId, key);
92
+ const result = db
93
+ .prepare("UPDATE revision_pieces SET selected=1 WHERE id=? AND revision_id=? AND piece_key=?")
94
+ .run(recordId, revisionId, key);
95
+ if (Number(result.changes) !== 1)
96
+ throw new Error("Published piece record was not found.");
97
+ });
98
+ }
99
+ function toOutput(value) {
100
+ const row = outputRow.parse(value);
101
+ return {
102
+ recordId: row.id,
103
+ publicationId: row.publication_id,
104
+ selected: row.selected,
105
+ slot: row.slot,
106
+ workKey: row.work_key,
107
+ assetId: row.asset_id,
108
+ output: outputSchema.parse(JSON.parse(row.descriptor)),
109
+ fingerprint: row.fingerprint,
110
+ state: row.state,
111
+ };
112
+ }
113
+ function toPiece(value) {
114
+ const row = pieceRow.parse(value);
115
+ return {
116
+ recordId: row.id,
117
+ publicationId: row.publication_id,
118
+ selected: row.selected,
119
+ key: row.piece_key,
120
+ stageKind: row.stage_kind,
121
+ piece: stagePieceSchema.parse(JSON.parse(row.descriptor)),
122
+ assetId: row.asset_id,
123
+ fingerprint: row.fingerprint,
124
+ };
125
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,143 @@
1
+ import { statSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import { outputPath, stagingPath } from "../storage/layout.js";
4
+ import { stagedFileById } from "../storage/repo.js";
5
+ export function bindUpload(content, upload, assetId) {
6
+ const to = upload.destination;
7
+ if (to.kind === "provided")
8
+ return { ...content, provided: { ...content.provided, [to.stage]: assetId } };
9
+ if (to.kind === "narration")
10
+ return {
11
+ ...content,
12
+ narrationOverrides: { ...content.narrationOverrides, [to.key]: { kind: "asset", assetId } },
13
+ };
14
+ const image = content.imageDefinitions[to.imageKey];
15
+ if (image === undefined)
16
+ throw new Error("Validated image destination disappeared.");
17
+ return {
18
+ ...content,
19
+ imageDefinitions: {
20
+ ...content.imageDefinitions,
21
+ [to.imageKey]: { ...image, source: "provide", assetId, prompt: null, templateKey: null },
22
+ },
23
+ };
24
+ }
25
+ export function validateUploads(deps, edit) {
26
+ const fields = [];
27
+ const staged = new Set();
28
+ const destinations = new Set();
29
+ for (const [index, upload] of (edit.uploads ?? []).entries()) {
30
+ const field = `uploads.${index}`;
31
+ const to = upload.destination;
32
+ if ((to.kind === "provided" && edit.config.sources[to.stage] !== "provide") ||
33
+ (to.kind === "narration" && edit.config.sources.audio !== "generate") ||
34
+ (to.kind === "image" && edit.config.sources.images === "off"))
35
+ fields.push({
36
+ field,
37
+ message: "Enable the corresponding provided or generated content before importing this file.",
38
+ });
39
+ const destination = JSON.stringify(to);
40
+ if (staged.has(upload.stagedFileId) || destinations.has(destination))
41
+ fields.push({ field, message: "Each upload and destination must be unique." });
42
+ staged.add(upload.stagedFileId);
43
+ destinations.add(destination);
44
+ const file = stagedFileById(deps.db, upload.stagedFileId);
45
+ const kind = to.kind === "provided" ? to.stage : to.kind === "image" ? "images" : "audio";
46
+ if (file === undefined ||
47
+ file.state !== "staged" ||
48
+ file.stageKind !== kind ||
49
+ !statSync(stagingPath(deps.paths, file.path), { throwIfNoEntry: false })?.isFile())
50
+ fields.push({ field, message: "Choose a completed upload for this stage." });
51
+ if (to.kind === "image" && edit.content.imageDefinitions[to.imageKey] === undefined)
52
+ fields.push({ field, message: "Choose an image in this revision." });
53
+ if (to.kind === "narration" && !to.key.startsWith("audio:"))
54
+ fields.push({ field, message: "Choose a narration segment." });
55
+ }
56
+ return fields;
57
+ }
58
+ export function validateAssetReferences(deps, projectId, content, prepared) {
59
+ const fields = [];
60
+ const refs = [];
61
+ for (const key of ["research", "article", "audio", "thumbnail"]) {
62
+ const id = content.provided[key];
63
+ if (id !== undefined)
64
+ refs.push([`content.provided.${key}`, id, key, false]);
65
+ }
66
+ for (const [key, row] of Object.entries(content.imageDefinitions))
67
+ if (row.assetId !== null)
68
+ refs.push([`content.imageDefinitions.${key}.assetId`, row.assetId, "images", true]);
69
+ for (const [key, row] of Object.entries(content.narrationOverrides))
70
+ if (row.kind === "asset")
71
+ refs.push([`content.narrationOverrides.${key}.assetId`, row.assetId, "audio", true]);
72
+ const roles = {
73
+ research: ["notes"],
74
+ article: ["article_md", "article_txt"],
75
+ audio: ["audio_body", "audio_intro", "audio_outro", "audio_export"],
76
+ images: ["image"],
77
+ thumbnail: ["thumbnail"],
78
+ };
79
+ for (const [field, id, kind, allowPiece] of refs) {
80
+ if (!prepared.some((row) => row.id === id && row.projectId === projectId) &&
81
+ deps.db
82
+ .prepare("SELECT 1 FROM project_assets WHERE project_id=? AND id=?")
83
+ .get(projectId, id) === undefined)
84
+ fields.push({ field, message: "Choose an asset belonging to this project." });
85
+ if (!prepared.some((row) => row.id === id) &&
86
+ deps.db
87
+ .prepare("SELECT 1 FROM revision_outputs WHERE project_id=? AND asset_id=? AND json_extract(descriptor,'$.stageKind')=? AND json_extract(descriptor,'$.role') IN (SELECT value FROM json_each(?))")
88
+ .get(projectId, id, kind, JSON.stringify(roles[kind])) === undefined &&
89
+ (!allowPiece ||
90
+ deps.db
91
+ .prepare("SELECT 1 FROM revision_pieces WHERE project_id=? AND asset_id=? AND stage_kind=? AND json_extract(descriptor,'$.kind') IN (SELECT value FROM json_each(?))")
92
+ .get(projectId, id, kind, JSON.stringify(kind === "audio" ? ["chunk", "segment"] : ["image"])) === undefined))
93
+ fields.push({ field, message: "Choose an asset for this content stage." });
94
+ }
95
+ return fields;
96
+ }
97
+ export async function measureAudio(deps, projectId, path) {
98
+ if (deps.measureAudio === undefined)
99
+ throw new Error("Audio inspection is unavailable.");
100
+ const duration = await deps.measureAudio(outputPath(deps.paths, projectId, path));
101
+ if (!Number.isFinite(duration) || duration < 0)
102
+ throw new Error("Audio inspection returned an invalid duration.");
103
+ return duration;
104
+ }
105
+ export function assetPath(deps, projectId, id) {
106
+ return z
107
+ .object({ path: z.string() })
108
+ .parse(deps.db
109
+ .prepare("SELECT path FROM project_assets WHERE project_id=? AND id=?")
110
+ .get(projectId, id)).path;
111
+ }
112
+ export function providedAssetSelected(base, kind, assetId) {
113
+ return (base.revision.config.sources[kind] === "provide" &&
114
+ base.outputs.some((row) => row.selected &&
115
+ row.assetId === assetId &&
116
+ row.workKey === (kind === "audio" ? "audio:provided" : "thumbnail:image") &&
117
+ row.output.role === (kind === "audio" ? "audio_body" : "thumbnail")));
118
+ }
119
+ export function validateReplacementAvailability(deps, base, edit) {
120
+ const previous = new Set([
121
+ ...Object.values(base.revision.content.provided),
122
+ ...Object.values(base.revision.content.imageDefinitions).map((row) => row.assetId),
123
+ ...Object.values(base.revision.content.narrationOverrides).flatMap((row) => row.kind === "asset" ? [row.assetId] : []),
124
+ ]);
125
+ for (const kind of ["audio", "thumbnail"]) {
126
+ const assetId = edit.content.provided[kind];
127
+ if (assetId !== undefined &&
128
+ edit.config.sources[kind] === "provide" &&
129
+ !providedAssetSelected(base, kind, assetId) &&
130
+ !edit.uploads?.some((row) => row.destination.kind === "provided" && row.destination.stage === kind))
131
+ previous.delete(assetId);
132
+ }
133
+ const next = [
134
+ ...Object.values(edit.content.provided),
135
+ ...Object.values(edit.content.imageDefinitions).map((row) => row.assetId),
136
+ ...Object.values(edit.content.narrationOverrides).flatMap((row) => row.kind === "asset" ? [row.assetId] : []),
137
+ ];
138
+ return next.flatMap((id) => id == null ||
139
+ previous.has(id) ||
140
+ statSync(outputPath(deps.paths, base.revision.projectId, assetPath(deps, base.revision.projectId, id)), { throwIfNoEntry: false })?.isFile()
141
+ ? []
142
+ : [{ field: "content", message: "The replacement asset is missing. Upload it again." }]);
143
+ }
@@ -0,0 +1,95 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { assetPath, measureAudio } from "./mutation-assets.js";
3
+ import { validateCues } from "./rules.js";
4
+ export function cuesChanged(base, edit) {
5
+ return (edit.content.subtitleCues !== undefined &&
6
+ !isDeepStrictEqual(base.revision.content.subtitleCues, edit.content.subtitleCues));
7
+ }
8
+ export async function inspectCueAudio(deps, base, edit, prepared) {
9
+ const durations = new Map();
10
+ if (!cuesChanged(base, edit))
11
+ return durations;
12
+ for (const row of [
13
+ ...base.outputs
14
+ .filter((row) => row.selected && row.available)
15
+ .map((row) => ({ assetId: row.assetId, output: row.output })),
16
+ ...prepared.map((row) => ({ assetId: row.asset.id, output: row.output })),
17
+ ]) {
18
+ if (!row.output.role.startsWith("audio_") || row.output.role === "audio_export")
19
+ continue;
20
+ durations.set(row.assetId, row.output.durationMs ?? (await measureAudio(deps, base.revision.projectId, row.output.path)));
21
+ }
22
+ for (const row of base.pieces.filter((row) => row.selected && row.available && row.stageKind === "audio" && row.assetId !== null))
23
+ if (row.assetId !== null && !durations.has(row.assetId))
24
+ durations.set(row.assetId, await measureAudio(deps, base.revision.projectId, assetPath(deps, base.revision.projectId, row.assetId)));
25
+ for (const row of Object.values(edit.content.narrationOverrides))
26
+ if (row.kind === "asset" && !durations.has(row.assetId))
27
+ durations.set(row.assetId, await measureAudio(deps, base.revision.projectId, assetPath(deps, base.revision.projectId, row.assetId)));
28
+ return durations;
29
+ }
30
+ export function validateProposedCues(base, edit, plan, prepared, durations) {
31
+ const cues = edit.content.subtitleCues;
32
+ if (cues === undefined || !cuesChanged(base, edit))
33
+ return [];
34
+ const fail = () => [
35
+ {
36
+ field: "content.subtitleCues",
37
+ message: "Save captions against the current, complete narration timeline.",
38
+ },
39
+ ];
40
+ const candidates = [
41
+ ...plan.manifest.outputs
42
+ .filter((row) => row.state === "ready")
43
+ .map((row) => ({ key: row.workKey, assetId: row.assetId, duration: row.output.durationMs })),
44
+ ...prepared
45
+ .filter((row) => row.upload?.destination.kind !== "narration")
46
+ .map((row) => ({ key: row.workKey, assetId: row.asset.id, duration: row.output.durationMs })),
47
+ ];
48
+ const durationFor = (key) => {
49
+ const selected = candidates.findLast((row) => row.key === key);
50
+ if (selected !== undefined)
51
+ return selected.duration ?? durations.get(selected.assetId);
52
+ const recipe = plan.recipes.find((row) => row.key === key);
53
+ if (recipe === undefined)
54
+ return undefined;
55
+ if (recipe.input.kind === "provided" && recipe.input.assetId !== null)
56
+ return durations.get(recipe.input.assetId);
57
+ if (recipe.input.kind === "local" && recipe.input.operation === "concat-narration") {
58
+ const parts = recipe.dependsOn.map(durationFor);
59
+ return parts.some((one) => one === undefined)
60
+ ? undefined
61
+ : parts.reduce((sum, one) => sum + (one ?? 0), 0);
62
+ }
63
+ if (plan.baseFingerprints[key] !== plan.fingerprints[key])
64
+ return undefined;
65
+ const prefix = key.replace(/:1$/, ":");
66
+ const parts = plan.manifest.pieces.filter((row) => row.stageKind === "audio" &&
67
+ row.piece.state === "done" &&
68
+ (row.key === key || row.key.startsWith(prefix)));
69
+ if (parts.length === 0)
70
+ return undefined;
71
+ const values = parts.map((row) => row.assetId === null ? undefined : durations.get(row.assetId));
72
+ return values.some((one) => one === undefined)
73
+ ? undefined
74
+ : values.reduce((sum, one) => sum + (one ?? 0), 0);
75
+ };
76
+ const keys = plan.config.sources.audio === "provide"
77
+ ? ["audio:provided"]
78
+ : ["audio:intro", "audio:body:concat", "audio:outro"].filter((key) => plan.fingerprints[key] !== undefined);
79
+ if (keys.length === 0)
80
+ return fail();
81
+ const parts = keys.map(durationFor);
82
+ if (parts.some((one) => one === undefined))
83
+ return fail();
84
+ const seconds = parts.reduce((sum, one) => sum + (one ?? 0), 0) / 1000 +
85
+ Math.max(0, keys.length - 1) * plan.config.silenceGapSeconds;
86
+ return validateCues(cues.cues, seconds);
87
+ }
88
+ export function measuredOutputs(deps, base, durations) {
89
+ return base.outputs.flatMap((row) => {
90
+ const durationMs = durations.get(row.assetId);
91
+ return row.selected && row.output.durationMs === null && durationMs !== undefined
92
+ ? [{ ...row, output: { ...row.output, id: deps.ids.next(), durationMs } }]
93
+ : [];
94
+ });
95
+ }
@@ -0,0 +1,182 @@
1
+ import { z } from "zod";
2
+ import { plainText } from "../article/plain.js";
3
+ import { splitEndMatter } from "../article/split.js";
4
+ import { normalizeArticleIntent } from "../rebuild/recipe-save.js";
5
+ import { discardPreparedAssets } from "../storage/assets.js";
6
+ import { prepareStagedFile, prepareText } from "../storage/prepare.js";
7
+ import { outputSchema } from "../storage/schema.js";
8
+ import { bindUpload, measureAudio, providedAssetSelected, } from "./mutation-assets.js";
9
+ export async function prepareEditAssets(deps, base, edit) {
10
+ const prepared = [];
11
+ const allocated = [];
12
+ try {
13
+ const projectId = base.revision.projectId;
14
+ let content = normalizeArticleIntent(base, edit);
15
+ for (const upload of edit.uploads ?? []) {
16
+ const to = upload.destination;
17
+ const role = to.kind === "image"
18
+ ? "image"
19
+ : to.kind === "provided" && to.stage === "thumbnail"
20
+ ? "thumbnail"
21
+ : "audio_body";
22
+ const result = prepareStagedFile(deps, {
23
+ projectId,
24
+ stagedFileId: upload.stagedFileId,
25
+ role,
26
+ index: to.kind === "image" ? content.imageOrder.indexOf(to.imageKey) + 1 : 1,
27
+ });
28
+ if (!result.ok)
29
+ throw new Error("A validated upload became unavailable.");
30
+ const workKey = to.kind === "image"
31
+ ? `image:${to.imageKey}`
32
+ : to.kind === "narration"
33
+ ? to.key
34
+ : to.stage === "audio"
35
+ ? "audio:provided"
36
+ : "thumbnail:image";
37
+ const item = {
38
+ ...result,
39
+ upload,
40
+ workKey,
41
+ slot: to.kind === "image"
42
+ ? workKey
43
+ : to.kind === "narration"
44
+ ? workKey
45
+ : `${result.output.stageKind}:${role}`,
46
+ };
47
+ allocated.push(result.asset);
48
+ const durationMs = result.output.stageKind === "audio"
49
+ ? await measureAudio(deps, projectId, result.asset.path)
50
+ : item.output.durationMs;
51
+ prepared.push({ ...item, output: { ...item.output, durationMs } });
52
+ content = bindUpload(content, upload, result.asset.id);
53
+ }
54
+ for (const [index, key] of content.imageOrder.entries()) {
55
+ const definition = content.imageDefinitions[key];
56
+ const assetId = definition?.assetId;
57
+ const workKey = `image:${key}`;
58
+ if (definition?.source !== "provide" ||
59
+ assetId == null ||
60
+ prepared.some((row) => row.workKey === workKey) ||
61
+ base.outputs.some((row) => row.selected &&
62
+ row.workKey === workKey &&
63
+ row.assetId === assetId &&
64
+ row.output.role === "image"))
65
+ continue;
66
+ const asset = retainedAsset(deps, projectId, assetId);
67
+ const existing = deps.db
68
+ .prepare("SELECT descriptor FROM revision_outputs WHERE project_id=? AND asset_id=? AND json_extract(descriptor,'$.stageKind')='images' AND json_extract(descriptor,'$.role')='image' LIMIT 1")
69
+ .get(projectId, assetId);
70
+ const descriptor = existing === undefined
71
+ ? {
72
+ projectId,
73
+ stageKind: "images",
74
+ role: "image",
75
+ path: asset.path,
76
+ originalFilename: null,
77
+ bytes: asset.bytes,
78
+ durationMs: null,
79
+ meta: {},
80
+ createdAt: asset.createdAt,
81
+ }
82
+ : outputSchema.parse(JSON.parse(z.string().parse(existing.descriptor)));
83
+ prepared.push({
84
+ asset,
85
+ output: {
86
+ ...descriptor,
87
+ id: deps.ids.next(),
88
+ meta: { ...descriptor.meta, index: index + 1 },
89
+ },
90
+ workKey,
91
+ slot: workKey,
92
+ });
93
+ }
94
+ for (const kind of ["audio", "thumbnail"]) {
95
+ const assetId = content.provided[kind];
96
+ if (edit.config.sources[kind] !== "provide" ||
97
+ assetId === undefined ||
98
+ providedAssetSelected(base, kind, assetId) ||
99
+ prepared.some((row) => row.asset.id === assetId))
100
+ continue;
101
+ const existing = deps.db
102
+ .prepare("SELECT descriptor FROM revision_outputs WHERE project_id=? AND asset_id=? AND json_extract(descriptor,'$.stageKind')=? LIMIT 1")
103
+ .get(projectId, assetId, kind);
104
+ if (existing === undefined)
105
+ throw new Error("Validated replacement asset has no descriptor.");
106
+ const descriptor = outputSchema.parse(JSON.parse(z.string().parse(existing.descriptor)));
107
+ const asset = retainedAsset(deps, projectId, assetId);
108
+ const role = kind === "audio" ? "audio_body" : "thumbnail";
109
+ const item = {
110
+ asset,
111
+ output: {
112
+ ...descriptor,
113
+ id: deps.ids.next(),
114
+ role,
115
+ durationMs: kind === "audio"
116
+ ? await measureAudio(deps, projectId, asset.path)
117
+ : descriptor.durationMs,
118
+ },
119
+ workKey: kind === "audio" ? "audio:provided" : "thumbnail:image",
120
+ slot: `${kind}:${role}`,
121
+ };
122
+ prepared.push(item);
123
+ }
124
+ const article = edit.config.sources.article === "provide" || content.articleEdited === true
125
+ ? (content.articleMarkdown ?? edit.config.provided.article)
126
+ : undefined;
127
+ if (article !== undefined &&
128
+ (article !== base.articleMarkdown ||
129
+ !base.outputs.some((row) => row.selected && row.available && row.output.role === "article_md"))) {
130
+ const end = splitEndMatter(article);
131
+ const texts = [
132
+ ["article_md", article],
133
+ ["article_txt", plainText(end.body)],
134
+ ["sources", end.sources],
135
+ ["glossary", end.glossary],
136
+ ];
137
+ for (const [role, text] of texts)
138
+ if (text.trim() !== "" || role === "article_md" || role === "article_txt") {
139
+ const row = prepareText(deps, { projectId, stageKind: "article", role, text });
140
+ allocated.push(row.asset);
141
+ prepared.push({ ...row, workKey: "article:body", slot: `article:${role}` });
142
+ }
143
+ }
144
+ if (edit.config.sources.research === "provide" &&
145
+ (edit.config.provided.research !== base.revision.config.provided.research ||
146
+ !base.outputs.some((row) => row.selected && row.output.role === "notes"))) {
147
+ const row = prepareText(deps, {
148
+ projectId,
149
+ stageKind: "research",
150
+ role: "notes",
151
+ text: edit.config.provided.research ?? "",
152
+ });
153
+ allocated.push(row.asset);
154
+ prepared.push({ ...row, workKey: "research:notes", slot: "research:notes" });
155
+ }
156
+ return { edit: { ...edit, content }, assets: prepared };
157
+ }
158
+ catch (error) {
159
+ discardPreparedAssets(deps, allocated);
160
+ throw error;
161
+ }
162
+ }
163
+ function retainedAsset(deps, projectId, assetId) {
164
+ const row = z
165
+ .object({
166
+ id: z.string(),
167
+ project_id: z.string(),
168
+ path: z.string(),
169
+ bytes: z.number(),
170
+ created_at: z.string(),
171
+ })
172
+ .parse(deps.db
173
+ .prepare("SELECT * FROM project_assets WHERE project_id=? AND id=?")
174
+ .get(projectId, assetId));
175
+ return {
176
+ id: row.id,
177
+ projectId: row.project_id,
178
+ path: row.path,
179
+ bytes: row.bytes,
180
+ createdAt: row.created_at,
181
+ };
182
+ }
@@ -0,0 +1,58 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { currentRevisionId } from "./repo.js";
4
+ import { getRevisionView } from "./view.js";
5
+ export function requestHash(operation, baseRevisionId, input) {
6
+ return createHash("sha256")
7
+ .update(JSON.stringify([operation, baseRevisionId, input], (_key, value) => {
8
+ if (typeof value !== "object" || value === null || Array.isArray(value))
9
+ return value;
10
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
11
+ }))
12
+ .digest("hex");
13
+ }
14
+ export function refusal(deps, projectId, reason) {
15
+ return { ok: false, reason, currentRevisionId: currentRevisionId(deps.db, projectId) ?? null };
16
+ }
17
+ export function checkMutation(deps, input) {
18
+ const { db } = deps;
19
+ const row = db
20
+ .prepare("SELECT operation,request_hash,result_revision_id FROM revision_mutations WHERE project_id=? AND idempotency_key=?")
21
+ .get(input.projectId, input.idempotencyKey);
22
+ if (row !== undefined) {
23
+ const receipt = z
24
+ .object({ operation: z.string(), request_hash: z.string(), result_revision_id: z.string() })
25
+ .parse(row);
26
+ if (receipt.operation !== input.operation || receipt.request_hash !== input.hash)
27
+ return refusal(deps, input.projectId, "idempotency-conflict");
28
+ return {
29
+ ok: true,
30
+ view: requiredView(deps, input.projectId, receipt.result_revision_id),
31
+ duplicate: true,
32
+ };
33
+ }
34
+ if (db
35
+ .prepare("SELECT 1 FROM rebuild_admissions WHERE project_id=? AND idempotency_key=?")
36
+ .get(input.projectId, input.idempotencyKey) !== undefined)
37
+ return refusal(deps, input.projectId, "idempotency-conflict");
38
+ if (db
39
+ .prepare("SELECT 1 FROM project_control_receipts WHERE project_id=? AND idempotency_key=?")
40
+ .get(input.projectId, input.idempotencyKey) !== undefined)
41
+ return refusal(deps, input.projectId, "idempotency-conflict");
42
+ if (db.prepare("SELECT 1 FROM projects WHERE id=?").get(input.projectId) === undefined)
43
+ return refusal(deps, input.projectId, "no-project");
44
+ if (currentRevisionId(db, input.projectId) !== input.baseRevisionId)
45
+ return refusal(deps, input.projectId, "conflict");
46
+ return undefined;
47
+ }
48
+ export function requiredView(deps, projectId, revisionId) {
49
+ const view = getRevisionView(deps, projectId, revisionId);
50
+ if (view === undefined)
51
+ throw new Error("The retained revision was not found.");
52
+ return view;
53
+ }
54
+ export function insertReceipt(deps, input, revisionId) {
55
+ deps.db
56
+ .prepare("INSERT INTO revision_mutations(project_id,idempotency_key,operation,request_hash,base_revision_id,result_revision_id,created_at) VALUES (?,?,?,?,?,?,?)")
57
+ .run(input.projectId, input.idempotencyKey, input.operation, input.hash, input.baseRevisionId, revisionId, deps.clock.now().toISOString());
58
+ }
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+ import { buildRecipes } from "../rebuild/recipe-build.js";
3
+ import { recipeInputSchema } from "../rebuild/recipe-input-schema.js";
4
+ export function preserveDeferredIntent(deps, base, plan) {
5
+ const rows = deps.db
6
+ .prepare("SELECT p.input_json,r.work_key,r.fingerprint,r.logical_key,r.desired_fingerprint FROM revision_work_reservations r JOIN revision_work_pieces p ON p.id=r.piece_id JOIN revision_work w ON w.id=r.work_id WHERE r.project_id=? AND r.revision_id=? AND (w.dispatch_state='allowed' OR p.dispatch_state='allowed')")
7
+ .all(base.revision.projectId, base.revision.id);
8
+ const before = buildRecipes({
9
+ config: base.revision.config,
10
+ content: base.revision.content,
11
+ manifest: {
12
+ outputs: base.outputs.filter((row) => row.selected),
13
+ pieces: base.pieces.filter((row) => row.selected),
14
+ },
15
+ resolved: {
16
+ articleMarkdown: null,
17
+ researchNotes: base.revision.config.provided.research ?? null,
18
+ },
19
+ });
20
+ const after = buildRecipes({
21
+ config: plan.config,
22
+ content: plan.content,
23
+ manifest: plan.manifest,
24
+ resolved: { articleMarkdown: null, researchNotes: plan.config.provided.research ?? null },
25
+ });
26
+ const fingerprints = { ...plan.fingerprints };
27
+ const baseFingerprints = { ...plan.baseFingerprints };
28
+ const recipes = [...plan.recipes];
29
+ for (const row of rows) {
30
+ const input = recipeInputSchema.parse(JSON.parse(z.string().parse(row.input_json)));
31
+ const key = z.string().parse(row.logical_key ?? row.work_key);
32
+ if (input.kind !== "deferred" || fingerprints[key] !== undefined)
33
+ continue;
34
+ const old = before.find((one) => one.key === key);
35
+ const next = after.find((one) => one.key === key);
36
+ const desired = z.string().parse(row.desired_fingerprint ?? row.fingerprint);
37
+ if (old !== undefined &&
38
+ next !== undefined &&
39
+ old.fingerprint === next.fingerprint &&
40
+ old.fingerprint === desired) {
41
+ fingerprints[key] = desired;
42
+ baseFingerprints[key] = desired;
43
+ recipes.push(next);
44
+ }
45
+ }
46
+ return { ...plan, fingerprints, baseFingerprints, recipes };
47
+ }