@gentbajko/slopify 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +12 -2
  2. package/dist/adapter-registry.js +10 -2
  3. package/dist/adapters/image/codex.js +230 -0
  4. package/dist/adapters/llm/claude-code-models.js +24 -13
  5. package/dist/adapters/llm/codex-models.js +12 -5
  6. package/dist/adapters/llm/codex-server-models.js +150 -0
  7. package/dist/adapters/llm/codex.js +1 -1
  8. package/dist/adapters/llm/gemini-models.js +23 -19
  9. package/dist/catalog/validate.js +3 -1
  10. package/dist/edge/cli.js +17 -0
  11. package/dist/kernel/db/migrations/0011-narration-prompts.sql +13 -0
  12. package/dist/slices/admission/rules.js +17 -1
  13. package/dist/slices/admission/schema.js +1 -0
  14. package/dist/slices/estimate/index.js +32 -1
  15. package/dist/slices/estimate/requests.js +8 -0
  16. package/dist/slices/library/model.js +2 -3
  17. package/dist/slices/library/slots.js +3 -1
  18. package/dist/slices/narration/preparation.js +75 -0
  19. package/dist/slices/narration/steering.js +89 -0
  20. package/dist/slices/play-drafts/convert.js +3 -0
  21. package/dist/slices/play-drafts/readiness.js +3 -1
  22. package/dist/slices/play-drafts/schema.js +1 -0
  23. package/dist/slices/project-templates/from-project.js +2 -0
  24. package/dist/slices/project-templates/setup.js +1 -0
  25. package/dist/slices/rebuild/admission-repo.js +2 -2
  26. package/dist/slices/rebuild/preview-details.js +12 -9
  27. package/dist/slices/rebuild/preview-plan.js +7 -1
  28. package/dist/slices/rebuild/recipe-audio-parts.js +89 -0
  29. package/dist/slices/rebuild/recipe-audio.js +52 -66
  30. package/dist/slices/rebuild/recipe-input-schema.js +11 -0
  31. package/dist/slices/rebuild/recipe-legacy.js +5 -0
  32. package/dist/slices/rebuild/recipe-model.js +3 -0
  33. package/dist/slices/rebuild/recipe-narration-text.js +33 -0
  34. package/dist/slices/rebuild/recipe-preparation.js +66 -0
  35. package/dist/slices/rebuild/recipe-provider-choice.js +7 -5
  36. package/dist/slices/rebuild/recipe-validation.js +9 -1
  37. package/dist/slices/rebuild/recipe-work.js +13 -1
  38. package/dist/slices/rebuild/runtime-admission.js +11 -0
  39. package/dist/slices/rebuild/runtime-export-inputs.js +4 -0
  40. package/dist/slices/rebuild/runtime-local.js +5 -0
  41. package/dist/slices/rebuild/runtime-materialize.js +2 -0
  42. package/dist/slices/rebuild/runtime-narration-reuse.js +1 -0
  43. package/dist/slices/rebuild/runtime-narration-text.js +75 -0
  44. package/dist/slices/rebuild/runtime-provider.js +30 -1
  45. package/dist/slices/rebuild/runtime-publication.js +13 -1
  46. package/dist/slices/rebuild/runtime-store.js +13 -2
  47. package/dist/slices/revisions/rules.js +5 -3
  48. package/dist/slices/settings/cli-paths.js +10 -6
  49. package/dist/slices/settings/cli-status.js +1 -1
  50. package/dist/slices/settings/model.js +9 -0
  51. package/dist/slices/settings/models.js +3 -3
  52. package/dist/slices/settings/readiness.js +9 -2
  53. package/dist/slices/storage/asset-name.js +3 -0
  54. package/dist/slices/storage/layout.js +4 -0
  55. package/dist/slices/storage/model.js +2 -0
  56. package/dist/slices/storage/schema.js +1 -0
  57. package/dist/web/assets/index-DsumGtv6.js +137 -0
  58. package/dist/web/index.html +1 -1
  59. package/package.json +2 -1
  60. package/scripts/docker-run.sh +113 -0
  61. package/dist/web/assets/index-DwM-Y6nw.js +0 -137
@@ -1,8 +1,12 @@
1
1
  import { fingerprint } from "../../kernel/runner/work.js";
2
+ import { usesNarrationPreparation } from "../admission/rules.js";
2
3
  import { chunkNarration, defaultChunking } from "../narration/chunk.js";
3
4
  import { concatArgs } from "../narration/concat.js";
4
- import { narrationRegenerationToken, normalizeNarrationText, planNarration, } from "../narration/plan.js";
5
+ import { normalizeNarrationText } from "../narration/plan.js";
6
+ import { narrationParts, voiceValues } from "./recipe-audio-parts.js";
5
7
  import { recipe, resourceIdentity, selectedReference, } from "./recipe-model.js";
8
+ import { narrationFileRecipe } from "./recipe-narration-text.js";
9
+ import { preparationFuture, preparationTemplate } from "./recipe-preparation.js";
6
10
  export function audioRecipes(context, text) {
7
11
  const { config, content } = context;
8
12
  const recipes = [];
@@ -24,21 +28,37 @@ export function audioRecipes(context, text) {
24
28
  : chunkNarration(normalizeNarrationText(text.articleText), config.chunking ?? defaultChunking);
25
29
  const occurrences = new Map();
26
30
  const parts = [];
31
+ let pending = text.articleText === null;
27
32
  for (const logicalText of groups) {
28
33
  const hash = fingerprint(logicalText).slice(0, 20);
29
34
  const occurrence = (occurrences.get(hash) ?? 0) + 1;
30
35
  occurrences.set(hash, occurrence);
31
36
  const logicalKey = `audio:body:${hash}-${occurrence}`;
32
- parts.push(...narrationParts(context, logicalKey, logicalText, "body", [text.article.key]));
37
+ const group = narrationParts(context, logicalKey, logicalText, "body", [text.article.key], recipes);
38
+ if (group.length === 0)
39
+ pending = true;
40
+ parts.push(...group);
33
41
  }
34
- if (text.articleText === null)
42
+ if (text.articleText === null && usesNarrationPreparation(config))
43
+ recipes.push(preparationFuture(context, "body", text.article));
44
+ // Physical ordinals are stable only after every logical group's cue plan is known.
45
+ if (pending && usesNarrationPreparation(config))
46
+ parts.length = 0;
47
+ if (pending)
35
48
  parts.push(recipe(context, "audio:body:future", "audio", {
36
49
  kind: "deferred",
37
50
  version: 1,
38
51
  operation: "body-narration",
39
- template: [text.article.fingerprint, voiceValues(context), chunkingValues(context)],
40
- }, [text.article.key]));
52
+ template: [
53
+ text.article.fingerprint,
54
+ voiceValues(context),
55
+ chunkingValues(context),
56
+ ...(usesNarrationPreparation(config) ? [preparationTemplate(context)] : []),
57
+ ],
58
+ }, [text.article.key, ...preparationKeys(recipes, "body")]));
41
59
  recipes.push(...parts);
60
+ if (usesNarrationPreparation(config))
61
+ recipes.push(narrationFileRecipe(context, "body", parts));
42
62
  body = recipe(context, "audio:body:concat", "audio", {
43
63
  kind: "local",
44
64
  version: 1,
@@ -59,16 +79,33 @@ export function audioRecipes(context, text) {
59
79
  const entry = text.entries[category];
60
80
  if (entry === undefined)
61
81
  continue;
82
+ if (entry.text === null && usesNarrationPreparation(config))
83
+ recipes.push(preparationFuture(context, category, entry.recipe));
62
84
  const parts = entry.text === null
63
85
  ? [
64
86
  recipe(context, `audio:${category}:future`, "audio", {
65
87
  kind: "deferred",
66
88
  version: 1,
67
89
  operation: `${category}-narration`,
68
- template: [entry.recipe.fingerprint, voiceValues(context)],
69
- }, [entry.recipe.key]),
90
+ template: [
91
+ entry.recipe.fingerprint,
92
+ voiceValues(context),
93
+ ...(usesNarrationPreparation(config) ? [preparationTemplate(context)] : []),
94
+ ],
95
+ }, [entry.recipe.key, ...preparationKeys(recipes, category)]),
70
96
  ]
71
- : narrationParts(context, `audio:${category}`, entry.text, category, [entry.recipe.key]);
97
+ : narrationParts(context, `audio:${category}`, entry.text, category, [entry.recipe.key], recipes);
98
+ if (parts.length === 0 && usesNarrationPreparation(config))
99
+ parts.push(recipe(context, `audio:${category}:future`, "audio", {
100
+ kind: "deferred",
101
+ version: 1,
102
+ operation: `${category}-narration`,
103
+ template: [
104
+ entry.recipe.fingerprint,
105
+ voiceValues(context),
106
+ preparationTemplate(context),
107
+ ],
108
+ }, [entry.recipe.key, ...preparationKeys(recipes, category)]));
72
109
  recipes.push(...parts);
73
110
  const audio = recipe(context, `audio:${category}`, "audio", {
74
111
  kind: "local",
@@ -80,6 +117,8 @@ export function audioRecipes(context, text) {
80
117
  ],
81
118
  }, parts.map((part) => part.key));
82
119
  recipes.push(audio);
120
+ if (usesNarrationPreparation(config))
121
+ recipes.push(narrationFileRecipe(context, category, parts));
83
122
  ordered.push({ value: audio, transcript: entry.text ?? entry.recipe.fingerprint });
84
123
  }
85
124
  const timeline = ordered.map(({ value, transcript }) => {
@@ -101,64 +140,6 @@ export function audioRecipes(context, text) {
101
140
  keys: ordered.map(({ value }) => value.key),
102
141
  };
103
142
  }
104
- function narrationParts(context, logicalKey, originalText, segment, dependsOn) {
105
- const override = context.content.narrationOverrides[logicalKey];
106
- if (override?.kind === "asset")
107
- return [
108
- recipe(context, `${logicalKey}:1`, "audio", {
109
- kind: "provided",
110
- version: 1,
111
- assetId: override.assetId,
112
- semantic: [normalizeNarrationText(originalText), voiceValues(context)],
113
- }, dependsOn, { tokenKey: logicalKey }),
114
- ];
115
- const logicalText = normalizeNarrationText(override?.kind === "text" ? override.text : originalText);
116
- const wholeRequest = segment !== "body" || (context.config.chunking?.mode ?? "whole") === "whole";
117
- const choice = context.config.audio;
118
- const model = context.catalogue?.tts.find((row) => row.provider === choice?.provider &&
119
- row.id === choice.model &&
120
- row.enabled &&
121
- !row.deprecated);
122
- const requests = planNarration({
123
- groups: [
124
- {
125
- key: logicalKey,
126
- text: logicalText,
127
- segment,
128
- wholeRequest,
129
- regenerationToken: narrationRegenerationToken(context.content.regenerationTokens, logicalKey, segment),
130
- },
131
- ],
132
- provider: choice?.provider ?? "",
133
- model: choice?.model ?? "",
134
- voice: choice?.voice ?? "",
135
- maxCharacters: context.catalogue === undefined
136
- ? Math.max(2, logicalText.length)
137
- : (model?.tts.maxCharacters ?? Math.max(2, logicalText.length)),
138
- retained: [],
139
- });
140
- return requests.map(({ text, key }) => recipe(context, key, "audio", {
141
- kind: "tts",
142
- version: 1,
143
- provider: choice?.provider ?? "",
144
- model: choice?.model ?? "",
145
- voice: choice?.voice ?? "",
146
- text,
147
- logicalKey,
148
- logicalText,
149
- segment,
150
- pronunciation: null,
151
- wholeRequest,
152
- }, dependsOn, { tokenKey: logicalKey, unresolved: logicalText.length === 0 }));
153
- }
154
- function voiceValues(context) {
155
- return [
156
- context.config.audio?.provider ?? null,
157
- context.config.audio?.model ?? null,
158
- context.config.audio?.voice ?? null,
159
- null,
160
- ];
161
- }
162
143
  function chunkingValues(context) {
163
144
  const chunking = context.config.chunking ?? defaultChunking;
164
145
  return [
@@ -170,3 +151,8 @@ function chunkingValues(context) {
170
151
  : null,
171
152
  ];
172
153
  }
154
+ function preparationKeys(recipes, segment) {
155
+ return recipes
156
+ .filter((row) => row.key.startsWith(`narration:prepare:${segment}:`))
157
+ .map((row) => row.key);
158
+ }
@@ -28,6 +28,16 @@ export const recipeInputSchema = z.discriminatedUnion("kind", [
28
28
  .nullable(),
29
29
  messages: z.array(z.object({ role: z.enum(messageRoles), content: z.string() }).strict()),
30
30
  webSearch: z.boolean(),
31
+ preparation: z
32
+ .object({
33
+ format: z.literal("inworld-tts-2"),
34
+ version,
35
+ source: z.string(),
36
+ logicalKey: z.string(),
37
+ segment: z.enum(["body", "intro", "outro"]),
38
+ })
39
+ .strict()
40
+ .optional(),
31
41
  })
32
42
  .strict(),
33
43
  z
@@ -38,6 +48,7 @@ export const recipeInputSchema = z.discriminatedUnion("kind", [
38
48
  model: z.string(),
39
49
  voice: z.string(),
40
50
  text: z.string(),
51
+ spokenText: z.string().optional(),
41
52
  logicalKey: z.string(),
42
53
  logicalText: z.string(),
43
54
  segment: z.enum(["body", "intro", "outro"]),
@@ -24,6 +24,8 @@ export function baselineFingerprints(project, outputs, pieces, content, stages =
24
24
  return fingerprints;
25
25
  }
26
26
  export function legacyOutputSlot(output) {
27
+ if (output.role === "narration_txt" || output.role === "tts_script")
28
+ return `narration:files:${output.meta.segment ?? "body"}:${output.role}`;
27
29
  return output.role === "image" ? `image:${output.id}` : `${output.stageKind}:${output.role}`;
28
30
  }
29
31
  export function legacyOutputWorkKey(output, config) {
@@ -39,6 +41,9 @@ export function legacyOutputWorkKey(output, config) {
39
41
  case "sources":
40
42
  case "glossary":
41
43
  return "article:body";
44
+ case "narration_txt":
45
+ case "tts_script":
46
+ return `narration:files:${output.meta.segment ?? "body"}`;
42
47
  case "audio_body":
43
48
  return config?.sources.audio === "provide" ? "audio:provided" : "audio:body:concat";
44
49
  case "audio_intro":
@@ -14,8 +14,10 @@ export const localOperations = [
14
14
  "manual-article",
15
15
  "entry-text",
16
16
  "concat-narration",
17
+ "narration-files-v1",
17
18
  ];
18
19
  export const deferredOperations = [
20
+ "narration-preparation",
19
21
  "research-synthesis",
20
22
  "article",
21
23
  "entry:intro:text",
@@ -57,6 +59,7 @@ export function recipe(context, key, stage, input, dependsOn = [], options = {})
57
59
  dependsOn,
58
60
  unresolved: options.unresolved ?? false,
59
61
  deferred: input.kind === "deferred",
62
+ ...(options.refusal === undefined ? {} : { refusal: options.refusal }),
60
63
  };
61
64
  }
62
65
  export function selectedAsset(context, key, desired) {
@@ -0,0 +1,33 @@
1
+ import { recipe, resourceIdentity, } from "./recipe-model.js";
2
+ export function narrationFileRecipe(context, segment, parts) {
3
+ const key = `narration:files:${segment}`;
4
+ const identities = parts.map((part) => resourceIdentity(context, part));
5
+ const dependencies = parts.map((part) => part.key);
6
+ if (parts.some((part) => part.deferred || part.unresolved))
7
+ return recipe(context, key, "audio", {
8
+ kind: "deferred",
9
+ version: 1,
10
+ operation: "resolve-revision-recipe",
11
+ template: [segment, identities],
12
+ }, dependencies);
13
+ return recipe(context, key, "audio", {
14
+ kind: "local",
15
+ version: 1,
16
+ operation: "narration-files-v1",
17
+ values: {
18
+ segment,
19
+ version: 1,
20
+ parts: parts.map((part) => {
21
+ if (part.input.kind === "tts")
22
+ return [
23
+ resourceIdentity(context, part),
24
+ part.input.spokenText ?? part.input.text,
25
+ part.input.text,
26
+ ];
27
+ if (part.input.kind === "provided")
28
+ return [resourceIdentity(context, part), part.input.semantic, null];
29
+ throw new Error("Narration files require exact audio parts.");
30
+ }),
31
+ },
32
+ }, dependencies, { unresolved: parts.length === 0 });
33
+ }
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import { preparationMessages, validatePreparation, } from "../narration/preparation.js";
3
+ import { prepareRequests } from "../narration/steering.js";
4
+ import { recipe, selectedReference, } from "./recipe-model.js";
5
+ import { llmInput, renderedPrompt } from "./recipe-text.js";
6
+ export function preparationTemplate(context) {
7
+ const input = llmInput(context, preparationMessages(renderedPrompt(context, "narration"), ""));
8
+ return [
9
+ "inworld-tts-2",
10
+ 1,
11
+ JSON.parse(JSON.stringify({ ...input, thinkingConfig: null })),
12
+ JSON.parse(JSON.stringify(context.content.narrationOverrides)),
13
+ ];
14
+ }
15
+ export function preparationFuture(context, segment, dependency) {
16
+ return recipe(context, `narration:prepare:${segment}:future`, "audio", {
17
+ kind: "deferred",
18
+ version: 1,
19
+ operation: "narration-preparation",
20
+ template: [
21
+ dependency.fingerprint,
22
+ preparationTemplate(context),
23
+ JSON.parse(JSON.stringify(context.config.chunking ?? null)),
24
+ ],
25
+ }, [dependency.key]);
26
+ }
27
+ export function preparationForGroup(context, logicalKey, source, segment, dependsOn, maxCharacters) {
28
+ const preparation = recipe(context, `narration:prepare:${segment}:${logicalKey}`, "audio", {
29
+ ...llmInput(context, preparationMessages(renderedPrompt(context, "narration"), source)),
30
+ preparation: { format: "inworld-tts-2", version: 1, source, logicalKey, segment },
31
+ }, dependsOn, { unresolved: source.trim() === "" });
32
+ if (source.trim() === "")
33
+ return {
34
+ preparation,
35
+ requests: null,
36
+ refusal: "Narration Preparation needs non-empty narration text.",
37
+ };
38
+ const logical = recipe(context, preparation.key, "audio", preparation.input.kind === "llm"
39
+ ? { ...preparation.input, thinkingConfig: null }
40
+ : preparation.input, dependsOn).fingerprint;
41
+ const saved = context.manifest.pieces.find((row) => row.key === preparation.key &&
42
+ row.stageKind === "audio" &&
43
+ selectedReference(row) &&
44
+ (!("available" in row) || row.available) &&
45
+ row.piece.state === "done" &&
46
+ (row.fingerprint === preparation.fingerprint ||
47
+ (context.catalogue === undefined &&
48
+ z
49
+ .object({ logicalFingerprint: z.literal(logical) })
50
+ .safeParse(JSON.parse(row.piece.payload ?? "null")).success)));
51
+ if (saved?.piece.payload === undefined || saved.piece.payload === null)
52
+ return { preparation, requests: null, refusal: null };
53
+ const payload = z.object({ text: z.string() }).safeParse(JSON.parse(saved.piece.payload));
54
+ if (!payload.success)
55
+ return { preparation, requests: null, refusal: "Saved narration preparation is incomplete." };
56
+ const validated = validatePreparation(payload.data.text, source);
57
+ if (!validated.ok)
58
+ return { preparation, requests: null, refusal: validated.reason };
59
+ const prepared = prepareRequests(source, validated.cues, context.catalogue === undefined
60
+ ? Math.max(2, source.length +
61
+ validated.cues.reduce((n, cue) => n + (cue.kind === "instruction" ? cue.text.length : 20) + 3, 0))
62
+ : maxCharacters);
63
+ return prepared.ok
64
+ ? { preparation, requests: prepared.requests, refusal: null }
65
+ : { preparation, requests: null, refusal: prepared.reason };
66
+ }
@@ -4,11 +4,13 @@ export function recipeProviderChoice(recipe, config) {
4
4
  return { ...input, family: input.kind };
5
5
  if (input.kind !== "deferred")
6
6
  return undefined;
7
- const family = recipe.stage === "audio"
8
- ? "tts"
9
- : recipe.stage === "images" || input.operation === "thumbnail-image"
10
- ? "image"
11
- : "llm";
7
+ const family = input.operation === "narration-preparation"
8
+ ? "llm"
9
+ : recipe.stage === "audio"
10
+ ? "tts"
11
+ : recipe.stage === "images" || input.operation === "thumbnail-image"
12
+ ? "image"
13
+ : "llm";
12
14
  const choice = family === "tts" ? config.audio : family === "image" ? config.images : config.llm;
13
15
  return choice === undefined ? undefined : { ...choice, family };
14
16
  }
@@ -1,9 +1,11 @@
1
+ import { narrationPreparationFields, usesNarrationPreparation, } from "../admission/rules.js";
1
2
  import { detectSlots, render } from "../admission/substitute.js";
2
3
  export function validateRecipeInputs(config, content) {
3
- const fields = [];
4
+ const fields = [...narrationPreparationFields(config)];
4
5
  const llm = (config.sources.research === "generate" && config.sources.article !== "provide") ||
5
6
  (config.sources.article === "generate" && !content.articleEdited) ||
6
7
  config.sources.thumbnail === "prompt_by_llm" ||
8
+ usesNarrationPreparation(config) ||
7
9
  (config.sources.audio === "generate" &&
8
10
  (config.intro?.mode === "llm" || config.outro?.mode === "llm"));
9
11
  if (llm && (!config.llm?.provider.trim() || !config.llm.model.trim()))
@@ -35,6 +37,12 @@ export function validateRecipeInputs(config, content) {
35
37
  message: "Use a whole number from 1 to 1,000,000.",
36
38
  });
37
39
  const prompts = [];
40
+ if (usesNarrationPreparation(config))
41
+ prompts.push({
42
+ field: "rendered.narration",
43
+ raw: content.promptTemplates.narration ?? null,
44
+ literal: config.rendered.narration,
45
+ });
38
46
  if ((config.sources.article === "generate" && !content.articleEdited) ||
39
47
  (config.sources.research === "generate" && config.sources.article !== "provide"))
40
48
  prompts.push({
@@ -47,7 +47,13 @@ export function planRevisionWork(revision, manifest, catalogue, availableAssetId
47
47
  disposition: "review",
48
48
  reason: "Narration or transcript changed. Review the saved manual cues before reusing them.",
49
49
  }
50
- : row);
50
+ : recipes.find((recipe) => recipe.key === row.key)?.refusal !== undefined
51
+ ? {
52
+ ...row,
53
+ disposition: "blocked",
54
+ reason: recipes.find((recipe) => recipe.key === row.key)?.refusal ?? row.reason,
55
+ }
56
+ : row);
51
57
  const changedInputs = work
52
58
  .filter((row) => row.disposition !== "reuse")
53
59
  .map((row) => ({
@@ -181,6 +187,12 @@ export function priceRecipe(work, value) {
181
187
  : "Local, provided, or blocked work; no admitted generation charge.",
182
188
  };
183
189
  const input = value.input;
190
+ if (input.kind === "deferred" && input.operation === "narration-preparation")
191
+ return {
192
+ kind: "unknown",
193
+ stage: work.key,
194
+ detail: "Narration Preparation: one LLM call per future logical chunk or entry; source length is not yet known.",
195
+ };
184
196
  if (input.kind === "llm")
185
197
  return {
186
198
  kind: "llm",
@@ -4,6 +4,17 @@ import { currentRevisionId } from "../revisions/repo.js";
4
4
  import { bindNarrationReuse, narrationOrdinal } from "./runtime-narration-reuse.js";
5
5
  import { executionCatalogue, executionPlan } from "./runtime-plan.js";
6
6
  import { insertWorkPiece } from "./work-records.js";
7
+ export function reservationKey(recipe, view) {
8
+ const key = recipe.input.kind === "tts" ? `${recipe.input.logicalKey}:1` : recipe.key;
9
+ if (view.revision.fingerprints[key] !== undefined)
10
+ return key;
11
+ const future = recipe.input.kind === "tts"
12
+ ? `audio:${recipe.input.segment}:future`
13
+ : recipe.input.kind === "llm" && recipe.input.preparation
14
+ ? `narration:prepare:${recipe.input.preparation.segment}:future`
15
+ : key;
16
+ return view.revision.fingerprints[future] === undefined ? key : future;
17
+ }
7
18
  export function admitInitialRevision(deps, view, catalogue) {
8
19
  transact(deps.db, () => {
9
20
  if (currentRevisionId(deps.db, view.revision.projectId) !== view.revision.id)
@@ -1,10 +1,12 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { z } from "zod";
3
+ import { usesNarrationPreparation } from "../admission/rules.js";
3
4
  import { plainText } from "../article/plain.js";
4
5
  import { splitEndMatter } from "../article/split.js";
5
6
  import { outputPath } from "../storage/layout.js";
6
7
  import { probeDurationMs } from "../video/ffmpeg.js";
7
8
  import { audioTimeline } from "../video/plan.js";
9
+ import { joinedNarration, narrationTextParts } from "./runtime-narration-text.js";
8
10
  import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
9
11
  export function exportSnapshot(deps, context, piece) {
10
12
  const view = executionView(deps, context.work.projectId, context.work.revisionId);
@@ -51,6 +53,8 @@ export async function revisionAudio(deps, context, view) {
51
53
  }
52
54
  export function revisionTranscript(deps, snapshot, kind) {
53
55
  const { view, plan } = snapshot;
56
+ if (usesNarrationPreparation(view.revision.config))
57
+ return joinedNarration(narrationTextParts(view, plan, kind));
54
58
  const concat = plan.recipes.find((one) => one.key === (kind === "body" ? "audio:body:concat" : `audio:${kind}`));
55
59
  if (concat !== undefined) {
56
60
  const texts = concat.dependsOn.map((key) => {
@@ -6,6 +6,7 @@ import { splitEndMatter } from "../article/split.js";
6
6
  import { joinNarration } from "../narration/concat.js";
7
7
  import { allocateAsset, discardPreparedAssets, sealAsset } from "../storage/assets.js";
8
8
  import { outputPath } from "../storage/layout.js";
9
+ import { publishNarrationText } from "./runtime-narration-text.js";
9
10
  import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
10
11
  import { preparedResult, preparedText, preparedTexts, publishResult, } from "./runtime-publication.js";
11
12
  export async function executeLocalRecipe(deps, context, piece) {
@@ -43,6 +44,10 @@ export async function executeLocalRecipe(deps, context, piece) {
43
44
  }
44
45
  if (input.kind !== "local")
45
46
  throw new Error("Expected an exact local recipe.");
47
+ if (input.operation === "narration-files-v1") {
48
+ await publishNarrationText(deps, context, piece);
49
+ return "done";
50
+ }
46
51
  if (input.operation === "concat-narration") {
47
52
  await concatenate(deps, context, piece);
48
53
  return "done";
@@ -95,6 +95,8 @@ function desiredAnchor(deps, projectId, head, row) {
95
95
  (row.desired_fingerprint ?? row.fingerprint));
96
96
  }
97
97
  function futureKey(recipe) {
98
+ if (recipe.input.kind === "llm" && recipe.input.preparation !== undefined)
99
+ return `narration:prepare:${recipe.input.preparation.segment}:future`;
98
100
  if (recipe.input.kind === "tts")
99
101
  return `audio:${recipe.input.segment}:future`;
100
102
  if (recipe.key.startsWith("research:chapter:"))
@@ -112,6 +112,7 @@ export function bindNarrationReuse(deps, view, recipe, ordinal) {
112
112
  payload: JSON.stringify({
113
113
  ...(typeof payload === "object" && payload !== null ? payload : {}),
114
114
  text: input.text,
115
+ spokenText: input.spokenText,
115
116
  logicalKey: input.logicalKey,
116
117
  logicalText: input.logicalText,
117
118
  segment: input.segment,
@@ -0,0 +1,75 @@
1
+ import { z } from "zod";
2
+ import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
3
+ import { preparedTexts, publishResult } from "./runtime-publication.js";
4
+ export function joinedNarration(parts) {
5
+ return parts
6
+ .map((part, index) => `${index > 0 && parts[index - 1]?.logicalKey !== part.logicalKey ? "\n" : ""}${part.spokenText}`)
7
+ .join("");
8
+ }
9
+ export function narrationTextParts(view, plan, segment) {
10
+ const concat = plan.recipes.find((row) => row.key === (segment === "body" ? "audio:body:concat" : `audio:${segment}`));
11
+ if (concat === undefined)
12
+ throw new Error(`The saved ${segment} narration is missing.`);
13
+ return concat.dependsOn.map((key) => {
14
+ const recipe = plan.recipes.find((row) => row.key === key);
15
+ const selected = view.pieces.find((row) => row.key === key &&
16
+ row.selected &&
17
+ row.available &&
18
+ row.piece.state === "done" &&
19
+ row.fingerprint === recipe?.fingerprint);
20
+ if (selected === undefined || recipe === undefined)
21
+ throw new Error("The saved narration transcript is incomplete.");
22
+ const input = recipe.input;
23
+ if (input.kind === "provided" &&
24
+ Array.isArray(input.semantic) &&
25
+ typeof input.semantic[0] === "string")
26
+ return {
27
+ logicalKey: key.slice(0, key.lastIndexOf(":")),
28
+ spokenText: input.semantic[0],
29
+ requestText: null,
30
+ };
31
+ if (input.kind !== "tts")
32
+ throw new Error("The narration source has not resolved.");
33
+ const payload = z
34
+ .object({ text: z.string().optional(), spokenText: z.string().optional() })
35
+ .parse(JSON.parse(selected.piece.payload ?? "{}"));
36
+ if (input.spokenText !== undefined &&
37
+ (payload.spokenText !== input.spokenText || payload.text !== input.text))
38
+ throw new Error("Prepared narration is missing its exact clean transcript.");
39
+ return {
40
+ logicalKey: input.logicalKey,
41
+ spokenText: input.spokenText === undefined ? (payload.text ?? input.text) : (payload.spokenText ?? ""),
42
+ requestText: input.text,
43
+ };
44
+ });
45
+ }
46
+ export async function publishNarrationText(deps, context, piece) {
47
+ if (piece.input.kind !== "local")
48
+ throw new Error("Expected a narration-file recipe.");
49
+ const { segment } = z
50
+ .object({ segment: z.enum(["body", "intro", "outro"]) })
51
+ .parse(piece.input.values);
52
+ const view = executionView(deps, context.work.projectId, context.work.revisionId);
53
+ const row = deps.db
54
+ .prepare("SELECT recipe_context FROM revision_work WHERE id=?")
55
+ .get(context.work.workId);
56
+ if (view === undefined)
57
+ throw new Error("The pinned narration revision is missing.");
58
+ const plan = executionPlan(deps, view, savedCatalogue(row?.recipe_context));
59
+ if (!plan.recipes.some((recipe) => recipe.key === piece.key && recipe.fingerprint === piece.fingerprint))
60
+ throw new Error("Narration text no longer matches the admitted inputs.");
61
+ const parts = narrationTextParts(view, plan, segment);
62
+ const outputs = preparedTexts(deps, context, piece, [
63
+ ["narration_txt", `${segment}-narration.txt`, joinedNarration(parts)],
64
+ [
65
+ "tts_script",
66
+ `${segment}-tts-script.txt`,
67
+ parts.flatMap((part) => (part.requestText === null ? [] : [part.requestText])).join("\n\n"),
68
+ ],
69
+ ]).map((output) => ({
70
+ ...output,
71
+ slot: `${piece.key}:${output.output.role}`,
72
+ output: { ...output.output, meta: { ...output.output.meta, segment } },
73
+ }));
74
+ await publishResult(deps, context, piece, outputs, { segment });
75
+ }
@@ -1,6 +1,8 @@
1
1
  import { plainText } from "../article/plain.js";
2
2
  import { splitEndMatter } from "../article/split.js";
3
3
  import { observeNarration } from "../narration/live.js";
4
+ import { validatePreparation } from "../narration/preparation.js";
5
+ import { prepareRequests } from "../narration/steering.js";
4
6
  import { chaptersFrom } from "../research/planner.js";
5
7
  import { sourcedAnswer } from "../research/synthesis.js";
6
8
  import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
@@ -28,7 +30,7 @@ export async function executeProviderRecipe(deps, context, providers, piece) {
28
30
  messages: input.messages,
29
31
  webSearch: input.webSearch,
30
32
  previewLabel: piece.key,
31
- check: (value) => checkAnswer(piece, value),
33
+ check: (value) => checkAnswer(piece, value) ?? checkPreparation(deps, context, piece, value),
32
34
  });
33
35
  if (!answer.ok)
34
36
  return "held";
@@ -63,6 +65,7 @@ export async function executeProviderRecipe(deps, context, providers, piece) {
63
65
  : await probeDurationMs(deps.ffmpeg, outputPath(deps.paths, context.work.projectId, asset.path), context.signal, deps.log);
64
66
  await publishResult(deps, context, piece, [], {
65
67
  text: input.text,
68
+ ...(input.spokenText === undefined ? {} : { spokenText: input.spokenText }),
66
69
  logicalKey: input.logicalKey,
67
70
  logicalText: input.logicalText,
68
71
  segment: input.segment,
@@ -125,6 +128,18 @@ function checkAnswer(piece, answer) {
125
128
  return undefined;
126
129
  }
127
130
  async function publishText(deps, context, piece, answer) {
131
+ if (piece.input.kind === "llm" && piece.input.preparation !== undefined) {
132
+ const checked = validatePreparation(answer.text, piece.input.preparation.source);
133
+ if (!checked.ok)
134
+ throw new Error(checked.reason);
135
+ await publishResult(deps, context, piece, [], {
136
+ text: answer.text,
137
+ preparation: piece.input.preparation,
138
+ cues: checked.cues,
139
+ logicalFingerprint: piece.logicalFingerprint ?? piece.fingerprint,
140
+ });
141
+ return;
142
+ }
128
143
  if (piece.key === "article:body") {
129
144
  const parts = splitEndMatter(answer.text);
130
145
  await publishResult(deps, context, piece, preparedTexts(deps, context, piece, [
@@ -182,3 +197,17 @@ function narrationLabel(deps, context, piece) {
182
197
  const index = parts.findIndex((recipe) => recipe.key === piece.key);
183
198
  return parts.length > 1 && index >= 0 ? `${name} part ${index + 1} of ${parts.length}` : name;
184
199
  }
200
+ function checkPreparation(deps, context, piece, answer) {
201
+ if (piece.input.kind !== "llm" || piece.input.preparation === undefined)
202
+ return undefined;
203
+ const source = piece.input.preparation.source;
204
+ const checked = validatePreparation(answer.text, source);
205
+ if (!checked.ok)
206
+ return checked.reason;
207
+ const row = deps.db
208
+ .prepare("SELECT recipe_context FROM revision_work WHERE id=?")
209
+ .get(context.work.workId);
210
+ const model = savedCatalogue(row?.recipe_context).tts.find((model) => model.provider === "inworld" && model.id === "inworld-tts-2");
211
+ const prepared = prepareRequests(source, checked.cues, model?.tts.maxCharacters ?? 4000);
212
+ return prepared.ok ? undefined : prepared.reason;
213
+ }