@gentbajko/slopify 1.2.1 → 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 (53) hide show
  1. package/README.md +3 -2
  2. package/dist/adapter-registry.js +3 -1
  3. package/dist/adapters/image/codex.js +230 -0
  4. package/dist/catalog/validate.js +3 -1
  5. package/dist/kernel/db/migrations/0011-narration-prompts.sql +13 -0
  6. package/dist/slices/admission/rules.js +17 -1
  7. package/dist/slices/admission/schema.js +1 -0
  8. package/dist/slices/estimate/index.js +32 -1
  9. package/dist/slices/estimate/requests.js +8 -0
  10. package/dist/slices/library/model.js +2 -3
  11. package/dist/slices/library/slots.js +3 -1
  12. package/dist/slices/narration/preparation.js +75 -0
  13. package/dist/slices/narration/steering.js +89 -0
  14. package/dist/slices/play-drafts/convert.js +3 -0
  15. package/dist/slices/play-drafts/readiness.js +3 -1
  16. package/dist/slices/play-drafts/schema.js +1 -0
  17. package/dist/slices/project-templates/from-project.js +2 -0
  18. package/dist/slices/project-templates/setup.js +1 -0
  19. package/dist/slices/rebuild/admission-repo.js +2 -2
  20. package/dist/slices/rebuild/preview-details.js +12 -9
  21. package/dist/slices/rebuild/preview-plan.js +7 -1
  22. package/dist/slices/rebuild/recipe-audio-parts.js +89 -0
  23. package/dist/slices/rebuild/recipe-audio.js +52 -66
  24. package/dist/slices/rebuild/recipe-input-schema.js +11 -0
  25. package/dist/slices/rebuild/recipe-legacy.js +5 -0
  26. package/dist/slices/rebuild/recipe-model.js +3 -0
  27. package/dist/slices/rebuild/recipe-narration-text.js +33 -0
  28. package/dist/slices/rebuild/recipe-preparation.js +66 -0
  29. package/dist/slices/rebuild/recipe-provider-choice.js +7 -5
  30. package/dist/slices/rebuild/recipe-validation.js +9 -1
  31. package/dist/slices/rebuild/recipe-work.js +13 -1
  32. package/dist/slices/rebuild/runtime-admission.js +11 -0
  33. package/dist/slices/rebuild/runtime-export-inputs.js +4 -0
  34. package/dist/slices/rebuild/runtime-local.js +5 -0
  35. package/dist/slices/rebuild/runtime-materialize.js +2 -0
  36. package/dist/slices/rebuild/runtime-narration-reuse.js +1 -0
  37. package/dist/slices/rebuild/runtime-narration-text.js +75 -0
  38. package/dist/slices/rebuild/runtime-provider.js +30 -1
  39. package/dist/slices/rebuild/runtime-publication.js +13 -1
  40. package/dist/slices/rebuild/runtime-store.js +13 -2
  41. package/dist/slices/revisions/rules.js +5 -3
  42. package/dist/slices/settings/cli-paths.js +10 -6
  43. package/dist/slices/settings/cli-status.js +1 -1
  44. package/dist/slices/settings/model.js +9 -0
  45. package/dist/slices/settings/readiness.js +9 -2
  46. package/dist/slices/storage/asset-name.js +3 -0
  47. package/dist/slices/storage/layout.js +4 -0
  48. package/dist/slices/storage/model.js +2 -0
  49. package/dist/slices/storage/schema.js +1 -0
  50. package/dist/web/assets/index-DsumGtv6.js +137 -0
  51. package/dist/web/index.html +1 -1
  52. package/package.json +1 -1
  53. package/dist/web/assets/index-Cq2O1JRf.js +0 -137
@@ -73,6 +73,7 @@ function documentFromProject(deps, revision) {
73
73
  });
74
74
  };
75
75
  addPrompt("article", config.articlePrompt, "article");
76
+ addPrompt("narration", config.narrationPrompt, "narration");
76
77
  const definitions = revision.content.imageOrder.flatMap((key) => {
77
78
  const definition = revision.content.imageDefinitions[key];
78
79
  return definition === undefined ? [] : [definition];
@@ -131,6 +132,7 @@ function documentFromProject(deps, revision) {
131
132
  audio: config.audio ?? { provider: "", model: "", voice: "" },
132
133
  images: config.images ?? { provider: "", model: "" },
133
134
  articlePrompt: config.articlePrompt ?? "",
135
+ ...(config.narrationPrompt === undefined ? {} : { narrationPrompt: config.narrationPrompt }),
134
136
  imagePrompts,
135
137
  thumbnailPrompt: config.thumbnailPrompt ?? "",
136
138
  intro: config.intro?.name ?? "",
@@ -4,6 +4,7 @@ export function templateSetup(deps, input) {
4
4
  const entries = [];
5
5
  const choices = [
6
6
  { kind: "article", name: input.form.articlePrompt },
7
+ { kind: "narration", name: input.form.narrationPrompt ?? "" },
7
8
  ...input.form.imagePrompts.map((row) => ({ kind: "image", name: row.name })),
8
9
  { kind: "thumbnail", name: input.form.thumbnailPrompt },
9
10
  ];
@@ -6,7 +6,7 @@ import { getRevisionView } from "../revisions/view.js";
6
6
  import { rebuildAdmissionSchema, } from "./model.js";
7
7
  import { executionSnapshotSchema, planPreview, reviewStillCovers } from "./preview-plan.js";
8
8
  import { previewById } from "./repo.js";
9
- import { insertInvocation } from "./runtime-admission.js";
9
+ import { insertInvocation, reservationKey } from "./runtime-admission.js";
10
10
  import { bindNarrationReuse } from "./runtime-narration-reuse.js";
11
11
  import { projectStandings } from "./runtime-store.js";
12
12
  export function admissionReceipt(db, projectId, idempotencyKey, requestHash) {
@@ -93,7 +93,7 @@ export function admitPreview(deps, input) {
93
93
  deps.db
94
94
  .prepare("DELETE FROM revision_work_reservations WHERE revision_id=? AND work_key=?")
95
95
  .run(view.revision.id, recipe.key);
96
- const key = recipe.input.kind === "tts" ? `${recipe.input.logicalKey}:1` : recipe.key;
96
+ const key = reservationKey(recipe, view);
97
97
  const fingerprint = snapshot.anchors[key];
98
98
  if (fingerprint === undefined)
99
99
  throw new Error("Selected work has no reviewed desired anchor.");
@@ -49,15 +49,17 @@ export function previewDetails(deps, view, allRecipes, selected) {
49
49
  }
50
50
  if (input.kind === "llm") {
51
51
  const peers = allRecipes.filter((one) => one.stage === row.stage && one.input.kind === "llm");
52
- const category = row.key.startsWith("entry:intro")
53
- ? "Intro text"
54
- : row.key.startsWith("entry:outro")
55
- ? "Outro text"
56
- : row.stage === "article"
57
- ? "Article"
58
- : row.stage === "research"
59
- ? "Research"
60
- : "Thumbnail prompt";
52
+ const category = input.preparation !== undefined
53
+ ? "Narration Preparation"
54
+ : row.key.startsWith("entry:intro")
55
+ ? "Intro text"
56
+ : row.key.startsWith("entry:outro")
57
+ ? "Outro text"
58
+ : row.stage === "article"
59
+ ? "Article"
60
+ : row.stage === "research"
61
+ ? "Research"
62
+ : "Thumbnail prompt";
61
63
  return [
62
64
  {
63
65
  key: row.key,
@@ -119,6 +121,7 @@ function inputChanges(parent, view) {
119
121
  add(`${name} thinking`, a?.thinking, b?.thinking);
120
122
  }
121
123
  add("Narration voice", before.audio?.voice, after.audio?.voice);
124
+ add("Narration Preparation", before.narrationPrompt, after.narrationPrompt);
122
125
  add("Narration chunking", before.chunking, after.chunking);
123
126
  add("Silence gap (seconds)", before.silenceGapSeconds, after.silenceGapSeconds);
124
127
  add("Intro", before.intro, after.intro);
@@ -9,6 +9,7 @@ import { providedDependencyFingerprint } from "./provided-review.js";
9
9
  import { recipeInputSchema } from "./recipe-input-schema.js";
10
10
  import { recipeProviderChoice } from "./recipe-provider-choice.js";
11
11
  import { priceRecipe } from "./recipe-work.js";
12
+ import { reservationKey } from "./runtime-admission.js";
12
13
  import { narrationOrdinal } from "./runtime-narration-reuse.js";
13
14
  import { executionCatalogue } from "./runtime-plan.js";
14
15
  export const executionSnapshotSchema = z.object({
@@ -143,7 +144,7 @@ export function planPreview(deps, view, catalogue, selection, id) {
143
144
  .map((key) => ({ key, fingerprint: providedDependencyFingerprint(plan, key) })),
144
145
  ordinals: Object.fromEntries(recipes.map((row) => [row.key, narrationOrdinal(plan.recipes, row.key)])),
145
146
  anchors: Object.fromEntries(recipes.map((recipe) => {
146
- const key = recipe.input.kind === "tts" ? `${recipe.input.logicalKey}:1` : recipe.key;
147
+ const key = reservationKey(recipe, view);
147
148
  return [key, view.revision.fingerprints[key] ?? recipe.logicalFingerprint];
148
149
  })),
149
150
  };
@@ -189,6 +190,11 @@ function selectedCatalogue(catalogue, recipes, view) {
189
190
  const choice = recipeProviderChoice(row, view.revision.config);
190
191
  return choice === undefined ? [] : [`${choice.family}:${choice.provider}:${choice.model}`];
191
192
  }));
193
+ const audio = view.revision.config.audio;
194
+ if (audio &&
195
+ recipes.some((row) => (row.input.kind === "llm" && row.input.preparation) ||
196
+ (row.input.kind === "deferred" && row.input.operation === "narration-preparation")))
197
+ uses.add(`tts:${audio.provider}:${audio.model}`);
192
198
  const selected = {
193
199
  llm: catalogue.llm.filter((row) => uses.has(`llm:${row.provider}:${row.id}`)),
194
200
  tts: catalogue.tts.filter((row) => uses.has(`tts:${row.provider}:${row.id}`)),
@@ -0,0 +1,89 @@
1
+ import { usesNarrationPreparation } from "../admission/rules.js";
2
+ import { narrationRegenerationToken, normalizeNarrationText, planNarration, } from "../narration/plan.js";
3
+ import { recipe } from "./recipe-model.js";
4
+ import { preparationForGroup } from "./recipe-preparation.js";
5
+ export function narrationParts(context, logicalKey, originalText, segment, dependsOn, preparations) {
6
+ const override = context.content.narrationOverrides[logicalKey];
7
+ if (override?.kind === "asset")
8
+ return [
9
+ recipe(context, `${logicalKey}:1`, "audio", {
10
+ kind: "provided",
11
+ version: 1,
12
+ assetId: override.assetId,
13
+ semantic: [normalizeNarrationText(originalText), voiceValues(context)],
14
+ }, dependsOn, { tokenKey: logicalKey }),
15
+ ];
16
+ const logicalText = normalizeNarrationText(override?.kind === "text" ? override.text : originalText);
17
+ const wholeRequest = segment !== "body" || (context.config.chunking?.mode ?? "whole") === "whole";
18
+ const choice = context.config.audio;
19
+ const model = context.catalogue?.tts.find((row) => row.provider === choice?.provider &&
20
+ row.id === choice.model &&
21
+ row.enabled &&
22
+ !row.deprecated);
23
+ if (usesNarrationPreparation(context.config)) {
24
+ const prepared = preparationForGroup(context, logicalKey, logicalText, segment, dependsOn, model?.tts.maxCharacters ?? Math.max(2, logicalText.length));
25
+ preparations.push(prepared.preparation);
26
+ if (prepared.refusal !== null)
27
+ return [
28
+ recipe(context, `${logicalKey}:1`, "audio", {
29
+ kind: "deferred",
30
+ version: 1,
31
+ operation: "resolve-revision-recipe",
32
+ template: prepared.refusal,
33
+ }, [prepared.preparation.key], { unresolved: true, refusal: prepared.refusal }),
34
+ ];
35
+ return (prepared.requests ?? []).map(({ text, spokenText }, index) => recipe(context, `${logicalKey}:${index + 1}`, "audio", {
36
+ kind: "tts",
37
+ version: 1,
38
+ provider: choice?.provider ?? "",
39
+ model: choice?.model ?? "",
40
+ voice: choice?.voice ?? "",
41
+ text,
42
+ spokenText,
43
+ logicalKey,
44
+ logicalText,
45
+ segment,
46
+ pronunciation: null,
47
+ wholeRequest,
48
+ }, [prepared.preparation.key], { tokenKey: logicalKey, unresolved: logicalText.length === 0 }));
49
+ }
50
+ const requests = planNarration({
51
+ groups: [
52
+ {
53
+ key: logicalKey,
54
+ text: logicalText,
55
+ segment,
56
+ wholeRequest,
57
+ regenerationToken: narrationRegenerationToken(context.content.regenerationTokens, logicalKey, segment),
58
+ },
59
+ ],
60
+ provider: choice?.provider ?? "",
61
+ model: choice?.model ?? "",
62
+ voice: choice?.voice ?? "",
63
+ maxCharacters: context.catalogue === undefined
64
+ ? Math.max(2, logicalText.length)
65
+ : (model?.tts.maxCharacters ?? Math.max(2, logicalText.length)),
66
+ retained: [],
67
+ });
68
+ return requests.map(({ text, key }) => recipe(context, key, "audio", {
69
+ kind: "tts",
70
+ version: 1,
71
+ provider: choice?.provider ?? "",
72
+ model: choice?.model ?? "",
73
+ voice: choice?.voice ?? "",
74
+ text,
75
+ logicalKey,
76
+ logicalText,
77
+ segment,
78
+ pronunciation: null,
79
+ wholeRequest,
80
+ }, dependsOn, { tokenKey: logicalKey, unresolved: logicalText.length === 0 }));
81
+ }
82
+ export function voiceValues(context) {
83
+ return [
84
+ context.config.audio?.provider ?? null,
85
+ context.config.audio?.model ?? null,
86
+ context.config.audio?.voice ?? null,
87
+ null,
88
+ ];
89
+ }
@@ -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) => {