@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,86 @@
1
+ import { detectSlots, render } from "../admission/substitute.js";
2
+ export function validateRecipeInputs(config, content) {
3
+ const fields = [];
4
+ const llm = (config.sources.research === "generate" && config.sources.article !== "provide") ||
5
+ (config.sources.article === "generate" && !content.articleEdited) ||
6
+ config.sources.thumbnail === "prompt_by_llm" ||
7
+ (config.sources.audio === "generate" &&
8
+ (config.intro?.mode === "llm" || config.outro?.mode === "llm"));
9
+ if (llm && (!config.llm?.provider.trim() || !config.llm.model.trim()))
10
+ fields.push({ field: "llm", message: "Pick a text provider and model." });
11
+ if (config.sources.audio === "generate") {
12
+ if (!config.audio?.provider.trim() || !config.audio.model.trim())
13
+ fields.push({ field: "audio", message: "Pick a narration provider and model." });
14
+ else if (!config.audio.voice.trim())
15
+ fields.push({ field: "audio.voice", message: "Pick a narration voice." });
16
+ }
17
+ const generatedImages = config.sources.images !== "off" &&
18
+ content.imageOrder.some((key) => content.imageDefinitions[key]?.source === "generate");
19
+ if ((generatedImages ||
20
+ config.sources.thumbnail === "from_prompt" ||
21
+ config.sources.thumbnail === "prompt_by_llm") &&
22
+ (!config.images?.provider.trim() || !config.images.model.trim()))
23
+ fields.push({ field: "images", message: "Pick an image provider and model." });
24
+ if (content.imageOrder.length > 60)
25
+ fields.push({ field: "content.imageOrder", message: "Keep at most 60 images." });
26
+ const chunking = config.chunking;
27
+ const size = chunking?.mode === "words"
28
+ ? chunking.words
29
+ : chunking?.mode === "characters"
30
+ ? chunking.characters
31
+ : undefined;
32
+ if (size !== undefined && (!Number.isInteger(size) || size < 1 || size > 1000000))
33
+ fields.push({
34
+ field: `chunking.${chunking?.mode}`,
35
+ message: "Use a whole number from 1 to 1,000,000.",
36
+ });
37
+ const prompts = [];
38
+ if ((config.sources.article === "generate" && !content.articleEdited) ||
39
+ (config.sources.research === "generate" && config.sources.article !== "provide"))
40
+ prompts.push({
41
+ field: "rendered.article",
42
+ raw: content.promptTemplates.article ?? null,
43
+ literal: config.rendered.article,
44
+ });
45
+ for (const category of ["intro", "outro"])
46
+ if (config.sources.audio === "generate" && config[category] !== undefined)
47
+ prompts.push({
48
+ field: `rendered.${category}`,
49
+ raw: content.promptTemplates[category] ?? null,
50
+ literal: config.rendered[category],
51
+ });
52
+ if (config.sources.thumbnail === "from_prompt" || config.sources.thumbnail === "prompt_by_llm")
53
+ prompts.push({
54
+ field: "rendered.thumbnailPrompt",
55
+ raw: content.promptTemplates.thumbnailPrompt ?? null,
56
+ literal: config.rendered.thumbnailPrompt,
57
+ });
58
+ if (config.sources.images !== "off")
59
+ for (const key of content.imageOrder) {
60
+ const image = content.imageDefinitions[key];
61
+ if (image?.source === "generate")
62
+ prompts.push({
63
+ field: `content.imageDefinitions.${key}.prompt`,
64
+ raw: image.templateKey == null ? null : (content.promptTemplates[image.templateKey] ?? null),
65
+ literal: image.prompt ?? undefined,
66
+ });
67
+ else if (image?.source === "provide" && image.assetId === null)
68
+ fields.push({
69
+ field: `content.imageDefinitions.${key}.assetId`,
70
+ message: "Choose an uploaded image.",
71
+ });
72
+ }
73
+ for (const prompt of prompts) {
74
+ if (!(prompt.raw === null ? prompt.literal : render(prompt.raw, config.values))?.trim())
75
+ fields.push({ field: prompt.field, message: "Enter a prompt or text." });
76
+ if (prompt.raw === null)
77
+ continue;
78
+ const slots = detectSlots(prompt.raw);
79
+ if (slots.errors.length > 0)
80
+ fields.push({ field: prompt.field, message: "Fix the keyword placeholders in this prompt." });
81
+ for (const key of slots.names)
82
+ if (!Object.hasOwn(config.values, key) || !config.values[key]?.trim())
83
+ fields.push({ field: `values.${key}`, message: "Enter a value for this keyword." });
84
+ }
85
+ return fields;
86
+ }
@@ -0,0 +1,111 @@
1
+ import { render } from "../admission/substitute.js";
2
+ import { recipe, resourceIdentity, } from "./recipe-model.js";
3
+ import { matchingText, renderedPrompt } from "./recipe-text.js";
4
+ export function visualRecipes(config, content, audioFingerprint, captionFingerprint) {
5
+ const recipes = [];
6
+ const imageKeys = config.sources.images === "off" ? [] : content.imageOrder;
7
+ for (const key of imageKeys) {
8
+ const image = content.imageDefinitions[key];
9
+ if (image === undefined)
10
+ throw new Error("Saved image definition is missing");
11
+ const raw = image.templateKey === undefined || image.templateKey === null
12
+ ? undefined
13
+ : content.promptTemplates[image.templateKey];
14
+ const prompt = raw === undefined || raw === null ? image.prompt : render(raw, config.values);
15
+ recipes.push(recipe({ content }, `image:${key}`, "images", image.source === "provide"
16
+ ? { kind: "provided", version: 1, assetId: image.assetId, semantic: null }
17
+ : {
18
+ kind: "image",
19
+ version: 1,
20
+ provider: config.images?.provider ?? "",
21
+ model: config.images?.model ?? "",
22
+ aspect: config.format,
23
+ prompt: prompt ?? "",
24
+ }, [], { unresolved: image.source === "provide" ? image.assetId === null : !prompt?.trim() }));
25
+ }
26
+ if (config.sources.video !== "off") {
27
+ const audioKeys = config.sources.audio === "off"
28
+ ? []
29
+ : [
30
+ config.sources.audio === "provide" ? "audio:provided" : "audio:body:concat",
31
+ ...(config.sources.audio === "generate" && config.intro !== undefined
32
+ ? ["audio:intro"]
33
+ : []),
34
+ ...(config.sources.audio === "generate" && config.outro !== undefined
35
+ ? ["audio:outro"]
36
+ : []),
37
+ ];
38
+ recipes.push(recipe({ content }, "export:video", "video", {
39
+ kind: "local",
40
+ version: 1,
41
+ operation: "render-video",
42
+ values: [
43
+ config.format,
44
+ config.silenceGapSeconds,
45
+ recipes.map((value) => value.fingerprint),
46
+ audioFingerprint,
47
+ config.subtitles?.mode === "burn-in" ? captionFingerprint : null,
48
+ "slideshow-zoom-v1",
49
+ ],
50
+ }, [
51
+ ...recipes.map((value) => value.key),
52
+ ...audioKeys,
53
+ ...(config.subtitles?.mode === "burn-in" ? ["subtitles:files"] : []),
54
+ ], { unresolved: imageKeys.length === 0 }));
55
+ }
56
+ return recipes;
57
+ }
58
+ export function thumbnailRecipes(context, textRecipes) {
59
+ const { config, content } = context;
60
+ if (config.sources.thumbnail === "off")
61
+ return [];
62
+ if (config.sources.thumbnail === "provide")
63
+ return [
64
+ recipe(context, "thumbnail:image", "thumbnail", {
65
+ kind: "provided",
66
+ version: 1,
67
+ assetId: content.provided.thumbnail ?? null,
68
+ semantic: [config.title, config.format],
69
+ }, [], { unresolved: content.provided.thumbnail === undefined }),
70
+ ];
71
+ const promptRecipe = textRecipes.find((value) => value.key === "thumbnail:prompt");
72
+ const prompt = config.sources.thumbnail === "prompt_by_llm" && promptRecipe !== undefined
73
+ ? matchingText(context, promptRecipe, "prompt")
74
+ : renderedPrompt(context, "thumbnailPrompt");
75
+ return [
76
+ recipe(context, "thumbnail:image", "thumbnail", prompt === null
77
+ ? {
78
+ kind: "deferred",
79
+ version: 1,
80
+ operation: "thumbnail-image",
81
+ template: [
82
+ promptRecipe?.fingerprint ?? null,
83
+ config.images?.provider ?? null,
84
+ config.images?.model ?? null,
85
+ config.format,
86
+ ],
87
+ }
88
+ : {
89
+ kind: "image",
90
+ version: 1,
91
+ provider: config.images?.provider ?? "",
92
+ model: config.images?.model ?? "",
93
+ aspect: config.format,
94
+ prompt,
95
+ }, promptRecipe === undefined ? [] : [promptRecipe.key]),
96
+ ];
97
+ }
98
+ export function visualAssets(context, recipes) {
99
+ const images = recipes.filter((value) => value.stage === "images");
100
+ return recipes.map((value) => value.key !== "export:video"
101
+ ? value
102
+ : recipe(context, value.key, value.stage, {
103
+ kind: "local",
104
+ version: 1,
105
+ operation: "render-selected-video",
106
+ values: [
107
+ value.requestFingerprint,
108
+ images.map((image) => resourceIdentity(context, image)),
109
+ ],
110
+ }, value.dependsOn, { unresolved: value.unresolved }));
111
+ }
@@ -0,0 +1,212 @@
1
+ import { z } from "zod";
2
+ import { estimateRequests } from "../estimate/index.js";
3
+ import { planDependencies } from "./dependencies.js";
4
+ import { matchingNarrationPiece } from "./narration-reuse.js";
5
+ import { buildRecipes } from "./recipe-build.js";
6
+ import { manualCuesNeedReview } from "./recipe-exports.js";
7
+ import { selectedReference, } from "./recipe-model.js";
8
+ export function planRevisionWork(revision, manifest, catalogue, availableAssetIds, resolved, retainedNarration = []) {
9
+ const selected = {
10
+ outputs: manifest.outputs.filter(selectedReference),
11
+ pieces: manifest.pieces.filter(selectedReference),
12
+ };
13
+ const available = {
14
+ outputs: selected.outputs.filter((row) => availableAssetIds.has(row.assetId)),
15
+ pieces: selected.pieces.filter((row) => row.assetId === null || availableAssetIds.has(row.assetId)),
16
+ };
17
+ const usableArticle = revision.config.sources.article !== "generate" ||
18
+ revision.content.articleEdited === true ||
19
+ selected.outputs.some((row) => row.workKey === "article:body" &&
20
+ row.state === "ready" &&
21
+ availableAssetIds.has(row.assetId));
22
+ const logicalContext = {
23
+ config: revision.config,
24
+ content: revision.content,
25
+ manifest: available,
26
+ resolved: { ...resolved, articleMarkdown: usableArticle ? resolved.articleMarkdown : null },
27
+ };
28
+ const logical = buildRecipes(logicalContext);
29
+ const context = { ...logicalContext, catalogue };
30
+ const recipes = buildRecipes(context).map((row) => ({
31
+ ...row,
32
+ logicalFingerprint: logical.find((one) => one.key === (row.input.kind === "tts" ? `${row.input.logicalKey}:1` : row.key))?.fingerprint ?? row.fingerprint,
33
+ unresolved: row.unresolved ||
34
+ (row.input.kind === "provided" &&
35
+ (row.input.assetId === null || !availableAssetIds.has(row.input.assetId))),
36
+ }));
37
+ const retained = recipes.flatMap((row) => retainedFor(row, logical, row.input.kind === "tts"
38
+ ? { ...selected, pieces: [...selected.pieces, ...retainedNarration] }
39
+ : selected, availableAssetIds));
40
+ const rawWork = planDependencies(recipes.map(comparisonRecipe), retained);
41
+ const manualReview = manualCuesNeedReview(logicalContext, logical);
42
+ const work = rawWork
43
+ .filter((row) => revision.content.subtitleCues === undefined || row.key !== "subtitles:timing")
44
+ .map((row) => manualReview && row.key === "subtitles:cues"
45
+ ? {
46
+ ...row,
47
+ disposition: "review",
48
+ reason: "Narration or transcript changed. Review the saved manual cues before reusing them.",
49
+ }
50
+ : row);
51
+ const changedInputs = work
52
+ .filter((row) => row.disposition !== "reuse")
53
+ .map((row) => ({
54
+ path: row.key,
55
+ before: selected.outputs.find((one) => one.workKey === row.key)?.fingerprint ??
56
+ selected.pieces.find((one) => one.key === row.key)?.fingerprint ??
57
+ null,
58
+ after: row.fingerprint,
59
+ }));
60
+ const priced = work.map((row) => priceRecipe(row, recipes.find((one) => one.key === row.key)));
61
+ return {
62
+ recipes,
63
+ work,
64
+ changedInputs,
65
+ providedReuseRequired: work.filter((row) => row.disposition === "review").map((row) => row.key),
66
+ wholeRequestNotice: revision.config.sources.audio === "generate" &&
67
+ (revision.config.chunking?.mode ?? "whole") === "whole"
68
+ ? "Whole-text narration is one logical request. Editing its text rebuilds every provider part of that request."
69
+ : null,
70
+ costs: estimateRequests(priced, catalogue),
71
+ };
72
+ }
73
+ function comparisonRecipe(row) {
74
+ return {
75
+ key: row.key,
76
+ stage: row.stage,
77
+ kind: row.kind,
78
+ requestFingerprint: row.requestFingerprint,
79
+ fingerprint: row.fingerprint,
80
+ dependsOn: row.dependsOn,
81
+ unresolved: row.unresolved,
82
+ };
83
+ }
84
+ function retainedFor(row, logical, manifest, available) {
85
+ if (row.input.kind === "provided" &&
86
+ row.key.startsWith("audio:") &&
87
+ row.key !== "audio:provided") {
88
+ const assetId = row.input.assetId;
89
+ const previous = manifest.pieces.find((one) => one.key === row.key &&
90
+ one.assetId === assetId &&
91
+ one.piece.state === "done" &&
92
+ one.fingerprint !== row.fingerprint &&
93
+ z.object({ provided: z.literal(true) }).safeParse(JSON.parse(one.piece.payload ?? "null"))
94
+ .success);
95
+ if (previous !== undefined)
96
+ return [
97
+ {
98
+ key: row.key,
99
+ requestFingerprint: "",
100
+ fingerprint: previous.fingerprint,
101
+ available: assetId !== null && available.has(assetId),
102
+ inflight: false,
103
+ pieceIds: [previous.piece.id],
104
+ },
105
+ ];
106
+ }
107
+ const narration = matchingNarrationPiece(row, manifest.pieces, available);
108
+ if (narration !== undefined)
109
+ return [
110
+ {
111
+ key: row.key,
112
+ requestFingerprint: row.requestFingerprint,
113
+ fingerprint: row.fingerprint,
114
+ available: true,
115
+ inflight: false,
116
+ pieceIds: [narration.piece.id],
117
+ },
118
+ ];
119
+ const bundle = manifest.outputs.filter((one) => one.workKey === row.key && one.fingerprint === row.logicalFingerprint);
120
+ const complete = bundle.every((one) => one.state === "ready" && available.has(one.assetId));
121
+ const current = bundle.length > 0 && complete;
122
+ const pieces = manifest.pieces.filter((one) => one.key === row.key);
123
+ const matched = pieces.filter((one) => {
124
+ const request = one.piece.payload === null
125
+ ? undefined
126
+ : z
127
+ .object({ requestFingerprint: z.string().optional() })
128
+ .parse(JSON.parse(one.piece.payload)).requestFingerprint;
129
+ return (one.fingerprint === row.fingerprint &&
130
+ request === row.requestFingerprint &&
131
+ (one.assetId === null || available.has(one.assetId)));
132
+ });
133
+ const concat = row.key.startsWith("audio:body:") && row.key !== "audio:body:concat"
134
+ ? logical.find((one) => one.key === "audio:body:concat")
135
+ : undefined;
136
+ const retainedConcat = concat !== undefined &&
137
+ manifest.outputs.some((one) => one.workKey === concat.key &&
138
+ one.fingerprint === concat.fingerprint &&
139
+ one.state === "ready" &&
140
+ available.has(one.assetId));
141
+ if (current ||
142
+ matched.length > 0 ||
143
+ retainedConcat ||
144
+ (row.input.kind === "provided" &&
145
+ row.input.assetId !== null &&
146
+ available.has(row.input.assetId) &&
147
+ !manifest.outputs.some((one) => one.workKey === row.key)))
148
+ return [
149
+ {
150
+ key: row.key,
151
+ requestFingerprint: row.requestFingerprint,
152
+ fingerprint: row.fingerprint,
153
+ available: current ||
154
+ (complete && matched.some((one) => one.piece.state === "done")) ||
155
+ retainedConcat ||
156
+ row.input.kind === "provided",
157
+ inflight: matched.some((one) => one.piece.state === "running"),
158
+ pieceIds: matched.map((one) => one.piece.id),
159
+ },
160
+ ];
161
+ return manifest.outputs
162
+ .filter((one) => one.workKey === row.key)
163
+ .map((one) => ({
164
+ key: row.key,
165
+ requestFingerprint: "",
166
+ fingerprint: one.fingerprint,
167
+ available: manifest.outputs
168
+ .filter((sibling) => sibling.workKey === one.workKey && sibling.fingerprint === one.fingerprint)
169
+ .every((sibling) => sibling.state === "ready" && available.has(sibling.assetId)),
170
+ inflight: false,
171
+ pieceIds: [],
172
+ }));
173
+ }
174
+ export function priceRecipe(work, value) {
175
+ if (value === undefined || work.disposition !== "generate")
176
+ return {
177
+ kind: "local",
178
+ stage: work.key,
179
+ detail: work.disposition === "reuse"
180
+ ? "Retained work; no generation charge."
181
+ : "Local, provided, or blocked work; no admitted generation charge.",
182
+ };
183
+ const input = value.input;
184
+ if (input.kind === "llm")
185
+ return {
186
+ kind: "llm",
187
+ stage: work.key,
188
+ provider: input.provider,
189
+ model: input.model,
190
+ inputCharacters: input.messages.reduce((sum, message) => sum + message.content.length, 0),
191
+ outputCharacters: work.key === "article:body" ? 9000 : 2400,
192
+ detail: "Actual saved request input; output length is estimated. Retries and tools are excluded.",
193
+ };
194
+ if (input.kind === "tts")
195
+ return {
196
+ kind: "tts",
197
+ stage: work.key,
198
+ provider: input.provider,
199
+ model: input.model,
200
+ text: input.text,
201
+ };
202
+ if (input.kind === "image")
203
+ return { kind: "image", stage: work.key, provider: input.provider, model: input.model };
204
+ return {
205
+ kind: "tts-estimate",
206
+ stage: work.key,
207
+ provider: "",
208
+ model: "",
209
+ characters: 9000,
210
+ detail: "Future generated input is unknown. This group materializes under the same admitted revision; its final request count and charge are unknown.",
211
+ };
212
+ }
@@ -0,0 +1,4 @@
1
+ export { baselineFingerprints, legacyOutputSlot, legacyOutputWorkKey, legacyPieceKey, } from "./recipe-legacy.js";
2
+ export { normalizeArticleIntent, planRevision } from "./recipe-save.js";
3
+ export { visualRecipes } from "./recipe-visual.js";
4
+ export { planRevisionWork } from "./recipe-work.js";
@@ -0,0 +1,64 @@
1
+ import { transact } from "../../kernel/db/tx.js";
2
+ import { workExists } from "../../kernel/runner/work-authority.js";
3
+ import { currentRevisionId, revisionById } from "../revisions/repo.js";
4
+ import { rebuildPreviewSchema } from "./model.js";
5
+ export { claimWork, finishWork, maySubmit } from "../../kernel/runner/work-authority.js";
6
+ export { transitionRevisionWork } from "./transition-repo.js";
7
+ export function publicationTargets(db, publication, workKey, workFingerprint) {
8
+ const { work, pieceId, publicationId } = publication;
9
+ if (publicationId !== (pieceId ?? work.workId))
10
+ throw new Error("Publication identity does not match its durable work.");
11
+ if (!workExists(db, work))
12
+ return [];
13
+ if (pieceId !== null) {
14
+ const piece = db
15
+ .prepare("SELECT work_key,fingerprint FROM revision_work_pieces WHERE id=? AND work_id=?")
16
+ .get(pieceId, work.workId);
17
+ if (piece === undefined)
18
+ throw new Error("Publication piece does not belong to its work.");
19
+ if (piece.work_key !== workKey || piece.fingerprint !== workFingerprint)
20
+ throw new Error("Publication authority does not match its durable piece.");
21
+ }
22
+ const currentId = currentRevisionId(db, work.projectId);
23
+ const owns = (revisionId) => {
24
+ const reservation = db
25
+ .prepare("SELECT logical_key,desired_fingerprint FROM revision_work_reservations WHERE project_id=? AND revision_id=? AND work_key=? AND work_id=? AND fingerprint=? AND piece_id IS ?")
26
+ .get(work.projectId, revisionId, workKey, work.workId, workFingerprint, pieceId);
27
+ if (reservation === undefined)
28
+ return false;
29
+ const logicalKey = typeof reservation.logical_key === "string" ? reservation.logical_key : workKey;
30
+ const desired = typeof reservation.desired_fingerprint === "string"
31
+ ? reservation.desired_fingerprint
32
+ : workFingerprint;
33
+ return revisionById(db, work.projectId, revisionId)?.fingerprints[logicalKey] === desired;
34
+ };
35
+ const targets = [
36
+ {
37
+ revisionId: work.revisionId,
38
+ current: currentId === work.revisionId,
39
+ selected: owns(work.revisionId),
40
+ },
41
+ ];
42
+ if (currentId !== undefined && currentId !== work.revisionId && owns(currentId))
43
+ targets.push({ revisionId: currentId, current: true, selected: true });
44
+ return targets;
45
+ }
46
+ export function storePreview(deps, preview, execution) {
47
+ const parsed = rebuildPreviewSchema.parse(preview);
48
+ deps.db
49
+ .prepare("INSERT INTO rebuild_previews(id,project_id,revision_id,plan_fingerprint,body_json,created_at,execution_json) VALUES (?,?,?,?,?,?,?)")
50
+ .run(parsed.id, parsed.projectId, parsed.baseRevisionId, parsed.planFingerprint, JSON.stringify(parsed), deps.clock.now().toISOString(), execution === undefined ? null : JSON.stringify(execution));
51
+ }
52
+ export function previewById(db, projectId, previewId) {
53
+ const row = db
54
+ .prepare("SELECT body_json FROM rebuild_previews WHERE id=? AND project_id=?")
55
+ .get(previewId, projectId);
56
+ return row === undefined
57
+ ? undefined
58
+ : rebuildPreviewSchema.parse(JSON.parse(String(row.body_json)));
59
+ }
60
+ export function recoverWork(db) {
61
+ transact(db, () => db.exec(`UPDATE revision_work_pieces SET state='held',dispatch_state='held' WHERE state!='done' AND work_id IN (SELECT id FROM revision_work WHERE project_id NOT IN (SELECT project_id FROM project_queue WHERE state='queued'));
62
+ UPDATE revision_work SET state='pending',dispatch_state='held' WHERE state!='done' AND project_id NOT IN (SELECT project_id FROM project_queue WHERE state='queued');`));
63
+ }
64
+ export { admissionReceipt, admitPreview } from "./admission-repo.js";
@@ -0,0 +1,76 @@
1
+ import { saveRevision } from "../revisions/mutations.js";
2
+ import { currentRevisionId } from "../revisions/repo.js";
3
+ import { getRevisionView } from "../revisions/view.js";
4
+ import { projectStandings } from "./runtime-store.js";
5
+ export async function revisionAction(deps, projectId, action) {
6
+ const revisionId = currentRevisionId(deps.db, projectId);
7
+ const view = revisionId === undefined ? undefined : getRevisionView(deps, projectId, revisionId);
8
+ if (view === undefined)
9
+ return { ok: false, reason: "no-project" };
10
+ if (action.kind === "retry")
11
+ return { ok: false, reason: "rebuild-required" };
12
+ const config = view.revision.config;
13
+ const content = view.revision.content;
14
+ const image = action.kind === "delete-image" || action.kind === "regenerate-image"
15
+ ? view.outputs.find((row) => row.selected && row.output.id === action.outputId && row.output.role === "image")
16
+ : undefined;
17
+ if ((action.kind === "delete-image" || action.kind === "regenerate-image") && image === undefined)
18
+ return { ok: false, reason: "unknown-image" };
19
+ const imageKey = image?.workKey.slice(6);
20
+ if (action.kind === "article" && action.markdown.trim() === "")
21
+ return { ok: false, reason: "empty-article" };
22
+ const regenerate = action.kind === "rerun"
23
+ ? Object.keys(view.revision.fingerprints).filter((key) => keyStage(key) === action.stage)
24
+ : action.kind === "regenerate-image" && image !== undefined
25
+ ? [image.workKey]
26
+ : undefined;
27
+ const nextContent = action.kind === "article"
28
+ ? { ...content, articleMarkdown: action.markdown, articleEdited: true }
29
+ : action.kind === "delete-image"
30
+ ? {
31
+ ...content,
32
+ imageOrder: content.imageOrder.filter((key) => key !== imageKey),
33
+ imageDefinitions: Object.fromEntries(Object.entries(content.imageDefinitions).filter(([key]) => key !== imageKey)),
34
+ }
35
+ : content;
36
+ const nextConfig = action.kind === "delete-image" && nextContent.imageOrder.length === 0
37
+ ? {
38
+ ...config,
39
+ sources: { ...config.sources, images: "off", video: "off" },
40
+ ...(config.subtitles?.mode === "burn-in"
41
+ ? { subtitles: { ...config.subtitles, mode: "files" } }
42
+ : {}),
43
+ }
44
+ : config;
45
+ const saved = await saveRevision(deps, {
46
+ projectId,
47
+ baseRevisionId: view.revision.id,
48
+ idempotencyKey: deps.ids.next(),
49
+ edit: {
50
+ config: nextConfig,
51
+ content: nextContent,
52
+ ...(regenerate === undefined ? {} : { regenerate }),
53
+ },
54
+ });
55
+ if (!saved.ok)
56
+ return { ok: false, reason: "not-rerunnable" };
57
+ projectStandings(deps, projectId);
58
+ return {
59
+ ok: true,
60
+ redone: [],
61
+ };
62
+ }
63
+ function keyStage(key) {
64
+ const prefix = key.split(":")[0];
65
+ if (prefix === "entry" || prefix === "article")
66
+ return "article";
67
+ if (prefix === "image")
68
+ return "images";
69
+ if (prefix === "audio")
70
+ return "audio";
71
+ if (prefix === "thumbnail")
72
+ return "thumbnail";
73
+ if (prefix === "research")
74
+ return "research";
75
+ return "video";
76
+ }
@@ -0,0 +1,71 @@
1
+ import { transact } from "../../kernel/db/tx.js";
2
+ import { narrationRegenerationToken } from "../narration/plan.js";
3
+ import { currentRevisionId } from "../revisions/repo.js";
4
+ import { bindNarrationReuse, narrationOrdinal } from "./runtime-narration-reuse.js";
5
+ import { executionCatalogue, executionPlan } from "./runtime-plan.js";
6
+ import { insertWorkPiece } from "./work-records.js";
7
+ export function admitInitialRevision(deps, view, catalogue) {
8
+ transact(deps.db, () => {
9
+ if (currentRevisionId(deps.db, view.revision.projectId) !== view.revision.id)
10
+ throw new Error("Initial admission requires the selected revision.");
11
+ if (deps.db
12
+ .prepare("SELECT 1 FROM revision_work WHERE project_id=? LIMIT 1")
13
+ .get(view.revision.projectId) !== undefined)
14
+ throw new Error("An existing project requires explicit rebuild admission.");
15
+ const snapshot = executionCatalogue(catalogue, view.revision.config);
16
+ const plan = executionPlan(deps, view, snapshot);
17
+ for (const recipe of plan.recipes) {
18
+ if (!plan.work.some((row) => row.key === recipe.key))
19
+ continue;
20
+ const desired = view.revision.fingerprints[recipe.key];
21
+ const logicalKey = recipe.input.kind === "tts" ? `${recipe.input.logicalKey}:1` : recipe.key;
22
+ const anchor = view.revision.fingerprints[logicalKey] ?? desired;
23
+ if (anchor === undefined)
24
+ throw new Error(`Initial work ${recipe.key} has no desired anchor.`);
25
+ const reuse = plan.work.find((row) => row.key === recipe.key)?.disposition === "reuse";
26
+ if (reuse)
27
+ bindNarrationReuse(deps, view, recipe, narrationOrdinal(plan.recipes, recipe.key));
28
+ insertInvocation(deps, view, recipe, snapshot, { key: logicalKey, fingerprint: anchor }, reuse);
29
+ }
30
+ });
31
+ }
32
+ export function insertInvocation(deps, view, recipe, catalogue, anchor, completed, reservationRevisionId = view.revision.id, admissionId = null) {
33
+ const { projectId, id: revisionId } = view.revision;
34
+ const stage = deps.db
35
+ .prepare("SELECT id FROM stages WHERE project_id=? AND kind=?")
36
+ .get(projectId, recipe.stage);
37
+ if (typeof stage?.id !== "string")
38
+ throw new Error("Revision work has no owned stage.");
39
+ const work = {
40
+ projectId,
41
+ revisionId,
42
+ workId: deps.ids.next(),
43
+ stageId: stage.id,
44
+ kind: recipe.stage,
45
+ fingerprint: recipe.fingerprint,
46
+ };
47
+ deps.db
48
+ .prepare(`INSERT INTO revision_work(id,project_id,revision_id,stage_id,kind,fingerprint,state,dispatch_state,recipe_context,created_at,admission_id) VALUES (?,?,?,?,?,?,?,'allowed',?,?,?)`)
49
+ .run(work.workId, projectId, revisionId, stage.id, recipe.stage, recipe.fingerprint, completed ? "done" : "pending", JSON.stringify(catalogue), deps.clock.now().toISOString(), admissionId);
50
+ const pieceId = deps.ids.next();
51
+ insertWorkPiece(deps.db, {
52
+ id: pieceId,
53
+ workId: work.workId,
54
+ key: recipe.key,
55
+ fingerprint: recipe.fingerprint,
56
+ requestFingerprint: recipe.requestFingerprint,
57
+ logicalFingerprint: recipe.logicalFingerprint,
58
+ input: recipe.input,
59
+ continuation: null,
60
+ generationToken: recipe.input.kind === "tts"
61
+ ? narrationRegenerationToken(view.revision.content.regenerationTokens, recipe.input.logicalKey, recipe.input.segment)
62
+ : (view.revision.content.regenerationTokens[recipe.key] ?? null),
63
+ state: completed ? "done" : "pending",
64
+ dispatchState: "allowed",
65
+ submittedAt: null,
66
+ });
67
+ deps.db
68
+ .prepare(`INSERT INTO revision_work_reservations(project_id,revision_id,work_key,work_id,piece_id,fingerprint,logical_key,desired_fingerprint) VALUES (?,?,?,?,?,?,?,?)`)
69
+ .run(projectId, reservationRevisionId, recipe.key, work.workId, pieceId, recipe.fingerprint, anchor.key, anchor.fingerprint);
70
+ return work;
71
+ }