@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
package/dist/edge/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
2
4
  import { parseArgs } from "node:util";
3
5
  import { configFrom } from "../kernel/config/index.js";
4
6
  import { readVersion } from "../kernel/version.js";
@@ -12,9 +14,24 @@ const { values } = parseArgs({
12
14
  host: { type: "string" },
13
15
  "data-dir": { type: "string" },
14
16
  "no-open": { type: "boolean" },
17
+ docker: { type: "boolean" },
15
18
  },
16
19
  });
17
20
  try {
21
+ if (values.docker) {
22
+ if (values.host !== undefined || values["data-dir"] !== undefined)
23
+ throw new Error("Docker stores data in the slopify-data volume and binds to localhost. Use --port to change its port.");
24
+ const result = spawnSync("bash", [fileURLToPath(new URL("../../scripts/docker-run.sh", import.meta.url))], {
25
+ stdio: "inherit",
26
+ env: {
27
+ ...process.env,
28
+ ...(values.port === undefined ? {} : { SLOPIFY_DOCKER_HOST_PORT: values.port }),
29
+ },
30
+ });
31
+ if (result.error)
32
+ throw result.error;
33
+ process.exit(result.status ?? 1);
34
+ }
18
35
  const config = configFrom(values, process.env);
19
36
  const forwarded = await forwardManagedUpdate(config.dataDir, readVersion(), process.argv.slice(2));
20
37
  if (forwarded !== undefined)
@@ -0,0 +1,13 @@
1
+ CREATE TABLE prompts_narration_upgrade (
2
+ id TEXT PRIMARY KEY,
3
+ kind TEXT NOT NULL CHECK (kind IN ('article','image','thumbnail','narration')),
4
+ name TEXT NOT NULL,
5
+ body TEXT NOT NULL,
6
+ slots TEXT NOT NULL,
7
+ updated_at TEXT NOT NULL
8
+ );
9
+ INSERT INTO prompts_narration_upgrade (id,kind,name,body,slots,updated_at)
10
+ SELECT id,kind,name,body,slots,updated_at FROM prompts;
11
+ DROP TABLE prompts;
12
+ ALTER TABLE prompts_narration_upgrade RENAME TO prompts;
13
+ CREATE UNIQUE INDEX prompts_name ON prompts(kind,lower(name));
@@ -42,7 +42,8 @@ export function admit(input) {
42
42
  sources.article === "generate" ||
43
43
  sources.thumbnail === "prompt_by_llm" ||
44
44
  draft.intro?.mode === "llm" ||
45
- draft.outro?.mode === "llm";
45
+ draft.outro?.mode === "llm" ||
46
+ usesNarrationPreparation(draft);
46
47
  if (needsLlm && !chosen(draft.llm)) {
47
48
  fields.push({ field: "llm", message: "Pick an LLM provider and model." });
48
49
  }
@@ -50,6 +51,7 @@ export function admit(input) {
50
51
  fields.push({ field: "articlePrompt", message: "Pick an article prompt." });
51
52
  }
52
53
  const voiced = draft.audio;
54
+ fields.push(...narrationPreparationFields(draft));
53
55
  if (sources.audio === "generate") {
54
56
  if (voiced === undefined || !chosen(voiced)) {
55
57
  fields.push({ field: "audio", message: "Pick a narration provider and model." });
@@ -213,6 +215,20 @@ function checkValues(draft, requiredSlots, fields) {
213
215
  function blank(value) {
214
216
  return value === undefined || value.trim() === "";
215
217
  }
218
+ export function usesNarrationPreparation(draft) {
219
+ return draft.sources.audio === "generate" && !blank(draft.narrationPrompt);
220
+ }
221
+ export function narrationPreparationFields(draft) {
222
+ return usesNarrationPreparation(draft) &&
223
+ (draft.audio?.provider !== "inworld" || draft.audio.model !== "inworld-tts-2")
224
+ ? [
225
+ {
226
+ field: "narrationPrompt",
227
+ message: "Narration Preparation requires Inworld TTS-2. Choose that model or turn preparation Off.",
228
+ },
229
+ ]
230
+ : [];
231
+ }
216
232
  function chosen(choice) {
217
233
  return choice !== undefined && choice.provider.trim() !== "" && choice.model.trim() !== "";
218
234
  }
@@ -35,6 +35,7 @@ export const runDraftSchema = z.object({
35
35
  audio: providerChoice.extend({ voice: z.string() }).optional(),
36
36
  images: providerChoice.optional(),
37
37
  articlePrompt: z.string().optional(),
38
+ narrationPrompt: z.string().optional(),
38
39
  imagePrompts: z.array(z.object({ name: z.string(), number: z.number() })),
39
40
  thumbnailPrompt: z.string().optional(),
40
41
  intro: entryChoice.optional(),
@@ -1,4 +1,10 @@
1
1
  import { z } from "zod";
2
+ import { usesNarrationPreparation } from "../admission/rules.js";
3
+ import { plainText } from "../article/plain.js";
4
+ import { splitEndMatter } from "../article/split.js";
5
+ import { chunkNarration, defaultChunking } from "../narration/chunk.js";
6
+ import { normalizeNarrationText } from "../narration/plan.js";
7
+ import { preparationMessages } from "../narration/preparation.js";
2
8
  import { estimateRequests, groupEstimateRows } from "./requests.js";
3
9
  export { estimateRequests } from "./requests.js";
4
10
  export function estimateRun(draft, rendered, expectedWords, catalogue) {
@@ -26,7 +32,7 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
26
32
  !model.deprecated)?.pricing.note ?? "Model or account pricing is unknown.";
27
33
  const generatedArticle = draft.sources.article === "generate";
28
34
  const articleChars = generatedArticle ? expectedWords * 6 : (draft.provided.article?.length ?? 0);
29
- const promptChars = Object.values(rendered).reduce((sum, value) => sum + value.length, 0);
35
+ const promptChars = Object.entries(rendered).reduce((sum, [key, value]) => sum + (key === "narration" ? 0 : value.length), 0);
30
36
  const local = (stage, detail) => {
31
37
  requests.push({ kind: "local", stage, detail });
32
38
  };
@@ -52,6 +58,31 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
52
58
  if (draft.intro?.mode === "llm" || draft.outro?.mode === "llm")
53
59
  text("Intro / outro text", promptChars + articleChars, 2400);
54
60
  if (draft.sources.audio === "generate") {
61
+ if (usesNarrationPreparation(draft)) {
62
+ const known = generatedArticle
63
+ ? []
64
+ : [
65
+ ...chunkNarration(normalizeNarrationText(plainText(splitEndMatter(draft.provided.article ?? "").body)), draft.chunking ?? defaultChunking),
66
+ ];
67
+ const unknown = generatedArticle || draft.intro?.mode === "llm" || draft.outro?.mode === "llm";
68
+ for (const category of ["intro", "outro"])
69
+ if (draft[category]?.mode === "text" && rendered[category]?.trim())
70
+ known.push(rendered[category] ?? "");
71
+ if (unknown)
72
+ requests.push({
73
+ kind: "unknown",
74
+ stage: "Narration Preparation",
75
+ detail: "One LLM call per future logical narration chunk and enabled entry; generated source length is not yet known.",
76
+ });
77
+ else
78
+ for (const source of known)
79
+ text("Narration Preparation", preparationMessages(rendered.narration ?? "", source).reduce((n, message) => n + message.content.length, 0), 2400, `${known.length} LLM calls: one per logical narration chunk or entry.`);
80
+ requests.push({
81
+ kind: "unknown",
82
+ stage: "Delivery cue overhead",
83
+ detail: "TTS character charges include delivery tags; their length is known after preparation.",
84
+ });
85
+ }
55
86
  const extras = ["intro", "outro"].reduce((n, key) => n + (rendered[key]?.length ?? 0), 0);
56
87
  const llmExtras = (draft.intro?.mode === "llm" ? 1200 : 0) + (draft.outro?.mode === "llm" ? 1200 : 0);
57
88
  const choice = { stage: "Narration", provider: tts.provider, model: tts.model };
@@ -15,6 +15,7 @@ const requestSchema = z.discriminatedUnion("kind", [
15
15
  provider.extend({ kind: z.literal("tts-estimate"), characters: quantity }).strict(),
16
16
  provider.extend({ kind: z.literal("image") }).strict(),
17
17
  common.extend({ kind: z.literal("local") }).strict(),
18
+ common.extend({ kind: z.literal("unknown") }).strict(),
18
19
  ]);
19
20
  export function estimateRequests(requests, catalogue) {
20
21
  const parsed = z.array(requestSchema).parse(requests);
@@ -50,6 +51,13 @@ export function groupEstimateRows(estimate) {
50
51
  return { ...estimate, rows, unknown: rows.filter((row) => row.low === null).length };
51
52
  }
52
53
  function price(request, catalogue) {
54
+ if (request.kind === "unknown")
55
+ return {
56
+ stage: request.stage,
57
+ low: null,
58
+ high: null,
59
+ detail: request.detail ?? "Request size is not available yet.",
60
+ };
53
61
  if (request.kind === "local")
54
62
  return {
55
63
  stage: request.stage,
@@ -1,9 +1,8 @@
1
- // The two template libraries: prompts of three kinds, and the intro/outro entries. One rule
1
+ // The two template libraries: prompts and intro/outro entries. One rule
2
2
  // set covers both, which is why the drafts, the lint and the save path below are shared
3
3
  // rather than mirrored.
4
4
  export { entryModes } from "../admission/model.js";
5
- // The CHECK constraint on `prompts.kind` holds the same three.
6
- export const promptKinds = ["article", "image", "thumbnail"];
5
+ export const promptKinds = ["article", "image", "thumbnail", "narration"];
7
6
  export const entryCategories = ["intro", "outro"];
8
7
  // The name is what the Play pickers show, so it shares admission's title bound.
9
8
  export const nameMax = 200;
@@ -2,7 +2,7 @@
2
2
  // the picked templates, the slot names they ask for, and the rendered text stored on the
3
3
  // project. The bodies are read at the click, not at selection, so an edit made in between
4
4
  // is the one that runs.
5
- import { normaliseDraft } from "../admission/rules.js";
5
+ import { normaliseDraft, usesNarrationPreparation } from "../admission/rules.js";
6
6
  import { collectFields, render } from "../admission/substitute.js";
7
7
  import { snapshotEntry, snapshotPrompt } from "./snapshot.js";
8
8
  export function pickTemplates(db, input, snapshot) {
@@ -17,6 +17,8 @@ export function pickTemplates(db, input, snapshot) {
17
17
  body(db, "article", draft.articlePrompt, "articlePrompt", missing, text, "article", snapshot);
18
18
  }
19
19
  const intro = pickEntry(db, "intro", draft.intro, missing, snapshot);
20
+ if (usesNarrationPreparation(draft))
21
+ body(db, "narration", draft.narrationPrompt, "narrationPrompt", missing, text, "narration", snapshot);
20
22
  const outro = pickEntry(db, "outro", draft.outro, missing, snapshot);
21
23
  push(text, "intro", intro);
22
24
  push(text, "outro", outro);
@@ -0,0 +1,75 @@
1
+ import { z } from "zod";
2
+ const sentence = z.number().int().positive();
3
+ const instruction = z
4
+ .string()
5
+ .min(1)
6
+ .max(240)
7
+ .refine((text) => text.trim().length > 0 && !/[[\]<>*_`~#\p{Cc}]/u.test(text));
8
+ const cueSchema = z.discriminatedUnion("kind", [
9
+ z.object({ sentence, kind: z.literal("instruction"), text: instruction }).strict(),
10
+ z.object({ sentence, kind: z.literal("reset") }).strict(),
11
+ z
12
+ .object({
13
+ sentence,
14
+ kind: z.literal("sound"),
15
+ sound: z.enum(["laugh", "breathe", "clear throat", "sigh", "cough", "yawn"]),
16
+ })
17
+ .strict(),
18
+ ]);
19
+ const answerSchema = z.object({ cues: z.array(cueSchema) }).strict();
20
+ export function sourceSentences(source) {
21
+ return Array.from(new Intl.Segmenter("en", { granularity: "sentence" }).segment(source), (part, index) => ({ sentence: index + 1, text: part.segment }));
22
+ }
23
+ export function validatePreparation(answer, source) {
24
+ let raw;
25
+ try {
26
+ raw = JSON.parse(answer);
27
+ }
28
+ catch (error) {
29
+ if (!(error instanceof SyntaxError))
30
+ throw error;
31
+ return { ok: false, reason: "Return a JSON object containing only cues." };
32
+ }
33
+ const parsed = answerSchema.safeParse(raw);
34
+ if (!parsed.success)
35
+ return { ok: false, reason: "Use only the documented cue fields and values." };
36
+ const count = sourceSentences(source).length;
37
+ const persistent = new Set();
38
+ const sounds = new Set();
39
+ let previous = 0;
40
+ for (const cue of parsed.data.cues) {
41
+ if (cue.sentence > count || cue.sentence < previous)
42
+ return { ok: false, reason: "Cue sentence numbers must be in source order and in range." };
43
+ previous = cue.sentence;
44
+ if (cue.kind !== "sound") {
45
+ if (persistent.has(cue.sentence))
46
+ return { ok: false, reason: "Use at most one instruction or reset per sentence." };
47
+ persistent.add(cue.sentence);
48
+ }
49
+ else {
50
+ const key = `${cue.sentence}:${cue.sound}`;
51
+ if (sounds.has(key))
52
+ return { ok: false, reason: "Do not repeat a sound at the same sentence." };
53
+ sounds.add(key);
54
+ }
55
+ }
56
+ return { ok: true, cues: parsed.data.cues };
57
+ }
58
+ const contract = [
59
+ 'Return JSON only: {"cues":[...]}.',
60
+ "Each cue has sentence (a supplied 1-based index) and kind.",
61
+ "instruction adds text: a short English delivery direction without brackets or markup.",
62
+ "reset has no other fields. sound adds sound: laugh, breathe, clear throat, sigh, cough or yawn.",
63
+ "Order cues by sentence. At most one instruction or reset per sentence.",
64
+ "Never return or rewrite narration. Do not translate, correct, abbreviate or add dialogue.",
65
+ "Use cues sparingly; an empty cues array is valid. Combine simultaneous directions.",
66
+ ].join("\n");
67
+ export function preparationMessages(prompt, source) {
68
+ return [
69
+ { role: "system", content: contract },
70
+ {
71
+ role: "user",
72
+ content: JSON.stringify({ direction: prompt, sentences: sourceSentences(source) }),
73
+ },
74
+ ];
75
+ }
@@ -0,0 +1,89 @@
1
+ import { sourceSentences } from "./preparation.js";
2
+ const cannotFit = {
3
+ ok: false,
4
+ reason: "Delivery cues or whitespace leave no room for narration at this model's character limit.",
5
+ };
6
+ export function prepareRequests(source, cues, maxCharacters) {
7
+ if (!Number.isInteger(maxCharacters) || maxCharacters < 2)
8
+ return {
9
+ ok: false,
10
+ reason: "The narration character limit must be a whole number of at least 2.",
11
+ };
12
+ if (source.trim() === "")
13
+ return { ok: true, requests: [] };
14
+ const requests = [];
15
+ const bySentence = new Map();
16
+ for (const cue of cues) {
17
+ const group = bySentence.get(cue.sentence) ?? [];
18
+ group.push(cue);
19
+ bySentence.set(cue.sentence, group);
20
+ }
21
+ let active = null;
22
+ let text = "";
23
+ let spokenText = "";
24
+ const flush = () => {
25
+ if (spokenText.trim() === "")
26
+ return false;
27
+ requests.push({ text, spokenText });
28
+ text = "";
29
+ spokenText = "";
30
+ return true;
31
+ };
32
+ for (const sentence of sourceSentences(source)) {
33
+ const events = bySentence.get(sentence.sentence) ?? [];
34
+ const direction = events.find((cue) => cue.kind !== "sound");
35
+ const tags = events.map(cueTag).join(" ");
36
+ const prefix = tags === "" ? "" : `${tags} `;
37
+ const first = Array.from(sentence.text)[0] ?? "";
38
+ const words = sentence.text.match(/\S+\s*|\s+/gu) ?? [];
39
+ const firstWord = words[0] ?? first;
40
+ const freshPrefix = direction === undefined ? carryTag(active) : "";
41
+ const minimum = firstWord.length + prefix.length + freshPrefix.length <= maxCharacters
42
+ ? firstWord.length
43
+ : first.length;
44
+ if (spokenText.trim() !== "" && text.length + prefix.length + minimum > maxCharacters) {
45
+ if (!flush())
46
+ return cannotFit;
47
+ }
48
+ if (text === "" && direction === undefined)
49
+ text = carryTag(active);
50
+ if (text.length + prefix.length + first.length > maxCharacters)
51
+ return cannotFit;
52
+ text += prefix;
53
+ if (direction !== undefined)
54
+ active = direction.kind === "instruction" ? direction.text : null;
55
+ for (const word of words) {
56
+ if (word.length + carryTag(active).length <= maxCharacters &&
57
+ text.length + word.length > maxCharacters &&
58
+ spokenText.trim() !== "") {
59
+ if (!flush())
60
+ return cannotFit;
61
+ text = carryTag(active);
62
+ }
63
+ for (const point of word) {
64
+ if (text.length + point.length > maxCharacters) {
65
+ if (!flush())
66
+ return cannotFit;
67
+ text = carryTag(active);
68
+ }
69
+ if (text.length + point.length > maxCharacters)
70
+ return cannotFit;
71
+ text += point;
72
+ spokenText += point;
73
+ }
74
+ }
75
+ }
76
+ if (spokenText !== "" && !flush())
77
+ return cannotFit;
78
+ return { ok: true, requests };
79
+ }
80
+ function cueTag(cue) {
81
+ return cue.kind === "instruction"
82
+ ? `[${cue.text}]`
83
+ : cue.kind === "reset"
84
+ ? "[reset]"
85
+ : `[${cue.sound}]`;
86
+ }
87
+ function carryTag(active) {
88
+ return active === null ? "" : `[${active}] `;
89
+ }
@@ -62,6 +62,9 @@ export function toAdmissionDraft(input) {
62
62
  audio: sources.audio === "generate" ? form.audio : undefined,
63
63
  images: form.images,
64
64
  articlePrompt: sources.article === "generate" ? form.articlePrompt : undefined,
65
+ ...(sources.audio === "generate" && form.narrationPrompt?.trim()
66
+ ? { narrationPrompt: form.narrationPrompt }
67
+ : {}),
65
68
  imagePrompts: sources.images === "generate"
66
69
  ? form.imagePrompts.map((prompt, index) => ({
67
70
  name: prompt.name,
@@ -1,6 +1,7 @@
1
1
  import { checkRuntimeModel } from "../../catalog/runtime-models.js";
2
2
  import { modelFields } from "../../catalog/validate.js";
3
3
  import { readinessIsUsable } from "../../kernel/ports/model.js";
4
+ import { usesNarrationPreparation } from "../admission/rules.js";
4
5
  import { cliPathStatus } from "../settings/cli-paths.js";
5
6
  import { hasKey, listVoices } from "../settings/repo.js";
6
7
  export function choices(runs) {
@@ -14,7 +15,8 @@ export function choices(runs) {
14
15
  d.sources.article === "generate" ||
15
16
  d.sources.thumbnail === "prompt_by_llm" ||
16
17
  d.intro?.mode === "llm" ||
17
- d.outro?.mode === "llm",
18
+ d.outro?.mode === "llm" ||
19
+ usesNarrationPreparation(d),
18
20
  },
19
21
  {
20
22
  field: "audio",
@@ -34,6 +34,7 @@ export const playDraftFormSchema = z
34
34
  audio: provider.extend({ voice: text }).readonly(),
35
35
  images: provider.readonly(),
36
36
  articlePrompt: text,
37
+ narrationPrompt: text.optional(),
37
38
  imagePrompts: z.array(z.object({ name: text, number: text }).strict().readonly()).readonly(),
38
39
  thumbnailPrompt: text,
39
40
  intro: text,
@@ -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
+ }