@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
@@ -1,81 +1,99 @@
1
+ import { z } from "zod";
2
+ import { estimateRequests, groupEstimateRows } from "./requests.js";
3
+ export { estimateRequests } from "./requests.js";
1
4
  export function estimateRun(draft, rendered, expectedWords, catalogue) {
2
- const rows = [];
3
- const model = (family, choice) => choice
4
- ? catalogue?.models(choice.provider, family).find((m) => m.id === choice.model)
5
- : undefined;
6
- const llm = model("llm", draft.llm);
7
- const tts = model("tts", draft.audio);
8
- const image = model("image", draft.images);
5
+ z.number().finite().nonnegative().parse(expectedWords);
6
+ const data = catalogue?.read() ?? {
7
+ schemaVersion: 1,
8
+ updatedAt: "",
9
+ providers: {},
10
+ llm: [],
11
+ tts: [],
12
+ image: [],
13
+ };
14
+ const requests = [];
15
+ const llm = draft.llm ?? { provider: "", model: "" };
16
+ const tts = draft.audio ?? { provider: "", model: "" };
17
+ const image = draft.images ?? { provider: "", model: "" };
18
+ const textNote = data.llm.find((model) => model.provider === llm.provider &&
19
+ model.id === llm.model &&
20
+ model.enabled &&
21
+ !model.deprecated)?.pricing.note ??
22
+ "CLI subscription or model pricing is unavailable; usage may be billed by your account.";
23
+ const imageNote = data.image.find((model) => model.provider === image.provider &&
24
+ model.id === image.model &&
25
+ model.enabled &&
26
+ !model.deprecated)?.pricing.note ?? "Model or account pricing is unknown.";
9
27
  const generatedArticle = draft.sources.article === "generate";
10
28
  const articleChars = generatedArticle ? expectedWords * 6 : (draft.provided.article?.length ?? 0);
11
29
  const promptChars = Object.values(rendered).reduce((sum, value) => sum + value.length, 0);
12
- const row = (stage, amount, detail, variable = false) => {
13
- rows.push({
30
+ const local = (stage, detail) => {
31
+ requests.push({ kind: "local", stage, detail });
32
+ };
33
+ const text = (stage, inputCharacters, outputCharacters, detail) => {
34
+ requests.push({
35
+ kind: "llm",
14
36
  stage,
15
- low: amount === undefined ? null : amount * (variable ? 0.5 : 1),
16
- high: amount === undefined ? null : amount * (variable ? 1.5 : 1),
17
- detail,
37
+ provider: llm.provider,
38
+ model: llm.model,
39
+ inputCharacters,
40
+ outputCharacters,
41
+ detail: detail === undefined ? textNote : `${detail} ${textNote}`,
18
42
  });
19
43
  };
20
- const textCost = (input, output) => {
21
- const p = llm?.pricing;
22
- return p?.inputPerMillionTokens === undefined || p.outputPerMillionTokens === undefined
23
- ? undefined
24
- : ((input / 4) * p.inputPerMillionTokens + (output / 4) * p.outputPerMillionTokens) / 1000000;
25
- };
26
- const textNote = llm?.pricing.note ??
27
- "CLI subscription or model pricing is unavailable; usage may be billed by your account.";
28
44
  if (draft.sources.research === "generate")
29
- row("Research", textCost(promptChars + 6000, 12000), `Assumes about 2,000 words of notes; web search fees are excluded. ${textNote}`, true);
45
+ text("Research", promptChars + 6000, 12000, "Assumes about 2,000 words of notes; web search fees are excluded.");
30
46
  else
31
- row("Research", 0, "Provided or off; no generation charge.");
47
+ local("Research", "Provided or off; no generation charge.");
32
48
  if (generatedArticle)
33
- row("Article", textCost(promptChars + (draft.sources.research === "generate" ? 12000 : 0), articleChars), `${expectedWords.toLocaleString()} expected words. ${textNote}`, true);
49
+ text("Article", promptChars + (draft.sources.research === "generate" ? 12000 : 0), articleChars, `${expectedWords.toLocaleString()} expected words.`);
34
50
  else
35
- row("Article", 0, "Provided article; no generation charge.");
51
+ local("Article", "Provided article; no generation charge.");
36
52
  if (draft.intro?.mode === "llm" || draft.outro?.mode === "llm")
37
- row("Intro / outro text", textCost(promptChars + articleChars, 2400), textNote, true);
53
+ text("Intro / outro text", promptChars + articleChars, 2400);
38
54
  if (draft.sources.audio === "generate") {
39
55
  const extras = ["intro", "outro"].reduce((n, key) => n + (rendered[key]?.length ?? 0), 0);
40
56
  const llmExtras = (draft.intro?.mode === "llm" ? 1200 : 0) + (draft.outro?.mode === "llm" ? 1200 : 0);
41
- const chars = articleChars + extras + llmExtras;
42
- const p = tts?.pricing;
43
- const amount = p?.perMillionCharacters === undefined
44
- ? p?.perMinute === undefined
45
- ? undefined
46
- : (chars / 6 / 150) * p.perMinute
47
- : (chars / 1000000) * p.perMillionCharacters;
48
- row("Narration", amount, `About ${chars.toLocaleString()} characters. ${p?.note ?? "Account pricing is unknown."}`, generatedArticle || llmExtras > 0 || p?.perMinute !== undefined);
57
+ const choice = { stage: "Narration", provider: tts.provider, model: tts.model };
58
+ requests.push(generatedArticle || llmExtras > 0
59
+ ? { ...choice, kind: "tts-estimate", characters: articleChars + extras + llmExtras }
60
+ : {
61
+ ...choice,
62
+ kind: "tts",
63
+ text: (draft.provided.article ?? "") + (rendered.intro ?? "") + (rendered.outro ?? ""),
64
+ });
49
65
  }
50
66
  else
51
- row("Narration", 0, "Provided or off; no generation charge.");
52
- const images = draft.sources.images === "generate" ? draft.imagePrompts.reduce((n, p) => n + p.number, 0) : 0;
53
- row("Images", images === 0
54
- ? 0
55
- : image?.pricing.perImage === undefined
56
- ? undefined
57
- : images * image.pricing.perImage, images === 0
58
- ? "Provided or off."
59
- : `${images} images. ${image?.pricing.note ?? "Model or account pricing is unknown."}`);
60
- const thumbnail = ["from_prompt", "prompt_by_llm"].includes(draft.sources.thumbnail);
61
- row("Thumbnail", thumbnail ? image?.pricing.perImage : 0, thumbnail
62
- ? (image?.pricing.note ?? "Model or account pricing is unknown.")
63
- : "Provided or off.");
67
+ local("Narration", "Provided or off; no generation charge.");
68
+ const images = draft.sources.images === "generate"
69
+ ? draft.imagePrompts.reduce((n, prompt) => n + prompt.number, 0)
70
+ : 0;
71
+ z.number().int().nonnegative().parse(images);
72
+ for (let index = 0; index < images; index++)
73
+ requests.push({
74
+ kind: "image",
75
+ stage: "Images",
76
+ provider: image.provider,
77
+ model: image.model,
78
+ detail: `${images} images. ${imageNote}`,
79
+ });
80
+ if (images === 0)
81
+ local("Images", "Provided or off.");
82
+ if (["from_prompt", "prompt_by_llm"].includes(draft.sources.thumbnail))
83
+ requests.push({
84
+ kind: "image",
85
+ stage: "Thumbnail",
86
+ provider: image.provider,
87
+ model: image.model,
88
+ });
89
+ else
90
+ local("Thumbnail", "Provided or off.");
64
91
  if (draft.sources.thumbnail === "prompt_by_llm")
65
- row("Thumbnail prompt", textCost(promptChars + articleChars, 1200), textNote, true);
66
- row("Export / subtitles", 0, "Local processing; no API fee.");
92
+ text("Thumbnail prompt", promptChars + articleChars, 1200);
93
+ local("Export / subtitles", "Local processing; no API fee.");
67
94
  return {
68
- currency: "USD",
69
- rows,
95
+ ...groupEstimateRows(estimateRequests(requests, data)),
70
96
  expectedWords,
71
- catalogueDate: catalogue?.status().updatedAt ?? null,
72
- low: rows.reduce((n, r) => n + (r.low ?? 0), 0),
73
- high: rows.reduce((n, r) => n + (r.high ?? 0), 0),
74
- unknown: rows.filter((r) => r.low === null).length,
75
- assumptions: [
76
- "Planning estimate, not a spending limit. Generated lengths use a ±50% range; actual output can exceed it.",
77
- "Assumes roughly 6 characters per word, 4 characters per token, and 150 spoken words per minute.",
78
- "Retries, extra reasoning tokens, web tools, taxes, discounts and included credits are excluded. Unknown charges are not counted as zero.",
79
- ],
97
+ catalogueDate: catalogue === undefined ? null : data.updatedAt,
80
98
  };
81
99
  }
@@ -0,0 +1,102 @@
1
+ import { z } from "zod";
2
+ const quantity = z.number().finite().nonnegative();
3
+ const common = z.object({
4
+ stage: z.string(),
5
+ detail: z.string().optional(),
6
+ variable: z.boolean().optional(),
7
+ expectedWords: quantity.optional(),
8
+ });
9
+ const provider = common.extend({ provider: z.string(), model: z.string() });
10
+ const requestSchema = z.discriminatedUnion("kind", [
11
+ provider
12
+ .extend({ kind: z.literal("llm"), inputCharacters: quantity, outputCharacters: quantity })
13
+ .strict(),
14
+ provider.extend({ kind: z.literal("tts"), text: z.string() }).strict(),
15
+ provider.extend({ kind: z.literal("tts-estimate"), characters: quantity }).strict(),
16
+ provider.extend({ kind: z.literal("image") }).strict(),
17
+ common.extend({ kind: z.literal("local") }).strict(),
18
+ ]);
19
+ export function estimateRequests(requests, catalogue) {
20
+ const parsed = z.array(requestSchema).parse(requests);
21
+ const rows = parsed.map((request) => price(request, catalogue));
22
+ return {
23
+ currency: "USD",
24
+ rows,
25
+ low: rows.reduce((total, row) => total + (row.low ?? 0), 0),
26
+ high: rows.reduce((total, row) => total + (row.high ?? 0), 0),
27
+ unknown: rows.filter((row) => row.low === null).length,
28
+ expectedWords: parsed.reduce((total, request) => total + (request.expectedWords ?? 0), 0),
29
+ catalogueDate: catalogue.updatedAt,
30
+ assumptions: [
31
+ "Planning estimate, not a spending limit. Generated lengths use a ±50% range; actual output can exceed it.",
32
+ "Assumes roughly 6 characters per word, 4 characters per token, and 150 spoken words per minute.",
33
+ "Retries, extra reasoning tokens, web tools, taxes, discounts and included credits are excluded. Unknown charges are not counted as zero.",
34
+ ],
35
+ };
36
+ }
37
+ export function groupEstimateRows(estimate) {
38
+ const grouped = new Map();
39
+ for (const row of estimate.rows) {
40
+ const previous = grouped.get(row.stage);
41
+ grouped.set(row.stage, previous === undefined
42
+ ? row
43
+ : {
44
+ ...previous,
45
+ low: previous.low === null || row.low === null ? null : previous.low + row.low,
46
+ high: previous.high === null || row.high === null ? null : previous.high + row.high,
47
+ });
48
+ }
49
+ const rows = [...grouped.values()];
50
+ return { ...estimate, rows, unknown: rows.filter((row) => row.low === null).length };
51
+ }
52
+ function price(request, catalogue) {
53
+ if (request.kind === "local")
54
+ return {
55
+ stage: request.stage,
56
+ low: 0,
57
+ high: 0,
58
+ detail: request.detail ?? "Local processing or retained output; no API fee.",
59
+ };
60
+ const family = request.kind === "tts-estimate" ? "tts" : request.kind;
61
+ const model = catalogue[family].find((entry) => entry.provider === request.provider &&
62
+ entry.id === request.model &&
63
+ entry.enabled &&
64
+ !entry.deprecated);
65
+ const rates = model?.pricing;
66
+ let amount;
67
+ let variable = request.variable === true;
68
+ let detail = rates?.note ?? "Model or account pricing is unknown.";
69
+ switch (request.kind) {
70
+ case "llm":
71
+ if (rates?.inputPerMillionTokens !== undefined && rates.outputPerMillionTokens !== undefined)
72
+ amount =
73
+ ((request.inputCharacters / 4) * rates.inputPerMillionTokens +
74
+ (request.outputCharacters / 4) * rates.outputPerMillionTokens) /
75
+ 1000000;
76
+ variable = true;
77
+ break;
78
+ case "tts":
79
+ case "tts-estimate": {
80
+ const characters = request.kind === "tts" ? request.text.length : request.characters;
81
+ const perMinute = rates?.perMillionCharacters === undefined && rates?.perMinute !== undefined;
82
+ if (rates?.perMillionCharacters !== undefined)
83
+ amount = (characters / 1000000) * rates.perMillionCharacters;
84
+ else if (rates?.perMinute !== undefined)
85
+ amount = (characters / 6 / 150) * rates.perMinute;
86
+ variable ||= request.kind === "tts-estimate" || perMinute;
87
+ detail = `About ${characters.toLocaleString()} characters. ${detail}`;
88
+ break;
89
+ }
90
+ case "image":
91
+ amount = rates?.perImage;
92
+ break;
93
+ }
94
+ if (amount !== undefined)
95
+ quantity.parse(amount);
96
+ return {
97
+ stage: request.stage,
98
+ low: amount === undefined ? null : amount * (variable ? 0.5 : 1),
99
+ high: amount === undefined ? null : amount * (variable ? 1.5 : 1),
100
+ detail: request.detail ?? detail,
101
+ };
102
+ }
@@ -38,7 +38,10 @@ export async function runImages(deps, context, providers) {
38
38
  }
39
39
  // The run's own frame. The adapter turns it into whatever its provider spells the
40
40
  // closest supported size, and the render crops whatever is left over.
41
- const made = await sendAll(deps, context, providers, choice, pieces, project.format);
41
+ const result = await sendAll(deps, context, providers, choice, pieces, project.format);
42
+ if (!result.ok)
43
+ return "held";
44
+ const made = result.value;
42
45
  // Images made means images stored, and only the ones this run stored: a resume does not
43
46
  // count an image the previous run made, and a regenerated image counts again.
44
47
  //
@@ -56,6 +59,7 @@ export async function runImages(deps, context, providers) {
56
59
  stage: "images",
57
60
  detail: `${String(pieces.length)} images from ${String(project.config.imagePrompts.length)} prompts`,
58
61
  });
62
+ return "done";
59
63
  }
60
64
  // For each ticked image prompt, its rendered text is sent Number times as independent
61
65
  // parallel calls. The plan is made once and kept, so a retry finds the rows the first run
@@ -117,12 +121,17 @@ async function sendAll(deps, context, providers, choice, pieces, format) {
117
121
  }
118
122
  setPiece(deps.db, piece.id, "running", piece.payload);
119
123
  try {
120
- const made = await providers.forPiece(piece.id).image({
124
+ const result = await providers.forPiece(piece.id).image({
121
125
  provider: choice.provider,
122
126
  model: choice.model,
123
127
  prompt: asked.prompt,
124
128
  aspect: format,
125
129
  });
130
+ if (!result.ok) {
131
+ setPiece(deps.db, piece.id, "pending", piece.payload);
132
+ return result;
133
+ }
134
+ const made = result.value;
126
135
  const file = keep(deps, projectId, piece.idx, made);
127
136
  // The prompt text, the prompt name, the index within the prompt, the
128
137
  // provider and the model travel with the image.
@@ -146,11 +155,13 @@ async function sendAll(deps, context, providers, choice, pieces, format) {
146
155
  let made = 0;
147
156
  for (const outcome of outcomes) {
148
157
  if (!outcome.ok) {
158
+ if ("reason" in outcome)
159
+ return outcome;
149
160
  throw outcome.error;
150
161
  }
151
162
  made += outcome.made ? 1 : 0;
152
163
  }
153
- return made;
164
+ return { ok: true, value: made };
154
165
  }
155
166
  // An image counts as landed only when its row and its file are both still there: they are
156
167
  // written one after the other, the boot reconcile can remove a file whose row survived,
@@ -4,8 +4,8 @@
4
4
  // is the one that runs.
5
5
  import { normaliseDraft } from "../admission/rules.js";
6
6
  import { collectFields, render } from "../admission/substitute.js";
7
- import { entryByName, promptByName } from "./repo.js";
8
- export function pickTemplates(db, input) {
7
+ import { snapshotEntry, snapshotPrompt } from "./snapshot.js";
8
+ export function pickTemplates(db, input, snapshot) {
9
9
  const draft = normaliseDraft(input);
10
10
  const { sources } = draft;
11
11
  const missing = [];
@@ -14,22 +14,22 @@ export function pickTemplates(db, input) {
14
14
  const text = [];
15
15
  const image = [];
16
16
  if (sources.article === "generate") {
17
- body(db, "article", draft.articlePrompt, "articlePrompt", missing, text, "article");
17
+ body(db, "article", draft.articlePrompt, "articlePrompt", missing, text, "article", snapshot);
18
18
  }
19
- const intro = pickEntry(db, "intro", draft.intro, missing);
20
- const outro = pickEntry(db, "outro", draft.outro, missing);
19
+ const intro = pickEntry(db, "intro", draft.intro, missing, snapshot);
20
+ const outro = pickEntry(db, "outro", draft.outro, missing, snapshot);
21
21
  push(text, "intro", intro);
22
22
  push(text, "outro", outro);
23
23
  if (sources.images === "generate") {
24
24
  for (const [index, picked] of draft.imagePrompts.entries()) {
25
25
  const field = `imagePrompts.${String(index)}.name`;
26
- body(db, "image", picked.name, field, missing, image, `imagePrompts.${String(index)}`);
26
+ body(db, "image", picked.name, field, missing, image, `imagePrompts.${String(index)}`, snapshot);
27
27
  }
28
28
  }
29
29
  // Both Generate modes read the same thumbnail template; Prompt by LLM sends it as the
30
30
  // instruction rather than to the image provider.
31
31
  if (sources.thumbnail === "from_prompt" || sources.thumbnail === "prompt_by_llm") {
32
- body(db, "thumbnail", draft.thumbnailPrompt, "thumbnailPrompt", missing, image, "thumbnailPrompt");
32
+ body(db, "thumbnail", draft.thumbnailPrompt, "thumbnailPrompt", missing, image, "thumbnailPrompt", snapshot);
33
33
  }
34
34
  const fields = collectFields(text.map((picked) => picked.body), image.map((picked) => picked.body));
35
35
  return {
@@ -48,24 +48,24 @@ export function renderPicked(picked, values) {
48
48
  }
49
49
  return rendered;
50
50
  }
51
- function body(db, kind, name, field, missing, into, key) {
51
+ function body(db, kind, name, field, missing, into, key, snapshot) {
52
52
  // An unpicked prompt is admission's rule to state ("Pick an article prompt."), so this
53
53
  // says nothing about it and the form marks the field once.
54
54
  if (name === undefined || name.trim() === "") {
55
55
  return;
56
56
  }
57
- const prompt = promptByName(db, kind, name);
57
+ const prompt = snapshotPrompt(db, snapshot, kind, name);
58
58
  if (prompt === undefined) {
59
59
  missing.push({ field, message: `That ${kind} prompt no longer exists; pick another.` });
60
60
  return;
61
61
  }
62
62
  into.push({ key, body: prompt.body });
63
63
  }
64
- function pickEntry(db, category, choice, missing) {
64
+ function pickEntry(db, category, choice, missing, snapshot) {
65
65
  if (choice === undefined) {
66
66
  return undefined;
67
67
  }
68
- const entry = entryByName(db, category, choice.name);
68
+ const entry = snapshotEntry(db, snapshot, category, choice.name);
69
69
  if (entry === undefined) {
70
70
  missing.push({
71
71
  field: category,
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ import { entryCategories, entryModes, promptKinds } from "./model.js";
3
+ import { entryByName, promptByName } from "./repo.js";
4
+ const base = z.object({
5
+ id: z.string(),
6
+ name: z.string(),
7
+ body: z.string(),
8
+ slots: z.array(z.string()).readonly(),
9
+ updatedAt: z.string(),
10
+ });
11
+ export const librarySnapshotSchema = z
12
+ .object({
13
+ prompts: z
14
+ .array(base
15
+ .extend({ kind: z.enum(promptKinds) })
16
+ .strict()
17
+ .readonly())
18
+ .readonly(),
19
+ entries: z
20
+ .array(base
21
+ .extend({ category: z.enum(entryCategories), mode: z.enum(entryModes) })
22
+ .strict()
23
+ .readonly())
24
+ .readonly(),
25
+ })
26
+ .strict()
27
+ .readonly();
28
+ export function snapshotPrompt(db, snapshot, kind, name) {
29
+ return (snapshot?.prompts.find((row) => row.kind === kind && row.name.toLowerCase() === name.toLowerCase()) ?? promptByName(db, kind, name));
30
+ }
31
+ export function snapshotEntry(db, snapshot, category, name) {
32
+ return (snapshot?.entries.find((row) => row.category === category && row.name.toLowerCase() === name.toLowerCase()) ?? entryByName(db, category, name));
33
+ }
@@ -0,0 +1,58 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { splitText } from "../../kernel/ports/text.js";
4
+ import { insertPiece, piecesOf } from "../../kernel/runner/piece-repo.js";
5
+ const payload = z.object({ text: z.string(), file: z.string().optional() });
6
+ // Old whole-text plans can exceed a newly known account limit. Preserve finished
7
+ // pieces and their paths, and split only unfinished work before any request starts.
8
+ export function planLegacyNarration(deps, stageId, texts, maxCharacters, finished) {
9
+ const old = piecesOf(deps.db, stageId, "chunk");
10
+ const source = old.length
11
+ ? old
12
+ : texts.map((text, idx) => ({
13
+ id: deps.ids.next(),
14
+ stageId,
15
+ kind: "chunk",
16
+ idx: idx + 1,
17
+ state: "pending",
18
+ payload: JSON.stringify({ text }),
19
+ }));
20
+ const planned = [];
21
+ for (const piece of source) {
22
+ const data = payload.parse(JSON.parse(piece.payload ?? "null"));
23
+ if (finished(piece)) {
24
+ planned.push({ ...piece, idx: planned.length + 1 });
25
+ continue;
26
+ }
27
+ for (const [part, text] of splitText(data.text, maxCharacters).entries())
28
+ planned.push({
29
+ ...piece,
30
+ id: part === 0 ? piece.id : deps.ids.next(),
31
+ idx: planned.length + 1,
32
+ state: "pending",
33
+ payload: JSON.stringify({ text }),
34
+ });
35
+ }
36
+ if (old.length &&
37
+ planned.length === old.length &&
38
+ planned.every((p, i) => p.id === old[i]?.id &&
39
+ payload.parse(JSON.parse(p.payload ?? "null")).text ===
40
+ payload.parse(JSON.parse(old[i]?.payload ?? "null")).text))
41
+ return old;
42
+ transact(deps.db, () => {
43
+ // Negative temporary indices avoid colliding with a retained completed piece.
44
+ deps.db
45
+ .prepare("UPDATE stage_pieces SET idx=-idx WHERE stage_id=? AND kind='chunk'")
46
+ .run(stageId);
47
+ const known = new Set(old.map((p) => p.id));
48
+ for (const piece of planned) {
49
+ if (known.has(piece.id))
50
+ deps.db
51
+ .prepare("UPDATE stage_pieces SET idx=?,state=?,payload=? WHERE id=?")
52
+ .run(piece.idx, piece.state, piece.payload, piece.id);
53
+ else
54
+ insertPiece(deps.db, piece);
55
+ }
56
+ });
57
+ return planned;
58
+ }
@@ -1,11 +1,11 @@
1
- export function observeNarration(store, projectId, key, label) {
1
+ export function observeNarration(store, projectId, key, label, origin) {
2
2
  if (store === undefined)
3
3
  return undefined;
4
4
  let sink;
5
5
  return (event) => {
6
6
  switch (event.type) {
7
7
  case "start":
8
- sink = store.begin(projectId, key, label);
8
+ sink = store.begin(projectId, key, label, origin);
9
9
  break;
10
10
  case "chunk":
11
11
  sink?.append(event.bytes);
@@ -1,58 +1,52 @@
1
- import { z } from "zod";
2
- import { transact } from "../../kernel/db/tx.js";
3
1
  import { splitText } from "../../kernel/ports/text.js";
4
- import { insertPiece, piecesOf } from "../../kernel/runner/piece-repo.js";
5
- const payload = z.object({ text: z.string(), file: z.string().optional() });
6
- // Old whole-text plans can exceed a newly known account limit. Preserve finished
7
- // pieces and their paths, and split only unfinished work before any request starts.
8
- export function planNarration(deps, stageId, texts, maxCharacters, finished) {
9
- const old = piecesOf(deps.db, stageId, "chunk");
10
- const source = old.length
11
- ? old
12
- : texts.map((text, idx) => ({
13
- id: deps.ids.next(),
14
- stageId,
15
- kind: "chunk",
16
- idx: idx + 1,
17
- state: "pending",
18
- payload: JSON.stringify({ text }),
19
- }));
20
- const planned = [];
21
- for (const piece of source) {
22
- const data = payload.parse(JSON.parse(piece.payload ?? "null"));
23
- if (finished(piece)) {
24
- planned.push({ ...piece, idx: planned.length + 1 });
25
- continue;
26
- }
27
- for (const [part, text] of splitText(data.text, maxCharacters).entries())
28
- planned.push({
29
- ...piece,
30
- id: part === 0 ? piece.id : deps.ids.next(),
31
- idx: planned.length + 1,
32
- state: "pending",
33
- payload: JSON.stringify({ text }),
2
+ import { fingerprint } from "../../kernel/runner/work.js";
3
+ export function normalizeNarrationText(text) {
4
+ return text.replace(/\r\n?/g, "\n").trim();
5
+ }
6
+ export function narrationRequestFingerprint(input) {
7
+ return fingerprint([
8
+ "narration-request-v1",
9
+ input.provider,
10
+ input.model,
11
+ input.voice,
12
+ input.text,
13
+ null,
14
+ input.segment,
15
+ input.wholeText === null ? null : fingerprint(["whole-v1", input.wholeText]),
16
+ ]);
17
+ }
18
+ export function planNarration(input) {
19
+ return input.groups.flatMap((group) => {
20
+ const text = normalizeNarrationText(group.text);
21
+ return splitText(text, input.maxCharacters).map((part, index) => {
22
+ const requestFingerprint = narrationRequestFingerprint({
23
+ ...input,
24
+ text: part,
25
+ segment: group.segment,
26
+ wholeText: group.wholeRequest ? text : null,
34
27
  });
35
- }
36
- if (old.length &&
37
- planned.length === old.length &&
38
- planned.every((p, i) => p.id === old[i]?.id &&
39
- payload.parse(JSON.parse(p.payload ?? "null")).text ===
40
- payload.parse(JSON.parse(old[i]?.payload ?? "null")).text))
41
- return old;
42
- transact(deps.db, () => {
43
- // Negative temporary indices avoid colliding with a retained completed piece.
44
- deps.db
45
- .prepare("UPDATE stage_pieces SET idx=-idx WHERE stage_id=? AND kind='chunk'")
46
- .run(stageId);
47
- const known = new Set(old.map((p) => p.id));
48
- for (const piece of planned) {
49
- if (known.has(piece.id))
50
- deps.db
51
- .prepare("UPDATE stage_pieces SET idx=?,state=?,payload=? WHERE id=?")
52
- .run(piece.idx, piece.state, piece.payload, piece.id);
53
- else
54
- insertPiece(deps.db, piece);
55
- }
28
+ const workFingerprint = fingerprint([requestFingerprint, group.regenerationToken]);
29
+ const retained = group.regenerationToken === null
30
+ ? input.retained.find((one) => one.available && one.requestFingerprint === requestFingerprint)
31
+ : input.retained.find((one) => one.available && one.fingerprint === workFingerprint);
32
+ return {
33
+ key: `${group.key}:${index + 1}`,
34
+ logicalKey: group.key,
35
+ logicalText: text,
36
+ segment: group.segment,
37
+ text: part,
38
+ requestFingerprint,
39
+ fingerprint: workFingerprint,
40
+ assetId: retained?.assetId ?? null,
41
+ };
42
+ });
56
43
  });
57
- return planned;
44
+ }
45
+ export function narrationRegenerationToken(tokens, logicalKey, segment) {
46
+ return tokens[logicalKey] ?? tokens[`audio:${segment}:future`] ?? null;
47
+ }
48
+ export function narrationRegenerationKey(key) {
49
+ return /^audio:(body:.+|intro|outro):[0-9]+$/.test(key)
50
+ ? key.slice(0, key.lastIndexOf(":"))
51
+ : key;
58
52
  }