@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.
- package/README.md +12 -2
- package/dist/adapter-registry.js +10 -2
- package/dist/adapters/image/codex.js +230 -0
- package/dist/adapters/llm/claude-code-models.js +24 -13
- package/dist/adapters/llm/codex-models.js +12 -5
- package/dist/adapters/llm/codex-server-models.js +150 -0
- package/dist/adapters/llm/codex.js +1 -1
- package/dist/adapters/llm/gemini-models.js +23 -19
- package/dist/catalog/validate.js +3 -1
- package/dist/edge/cli.js +17 -0
- package/dist/kernel/db/migrations/0011-narration-prompts.sql +13 -0
- package/dist/slices/admission/rules.js +17 -1
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/estimate/index.js +32 -1
- package/dist/slices/estimate/requests.js +8 -0
- package/dist/slices/library/model.js +2 -3
- package/dist/slices/library/slots.js +3 -1
- package/dist/slices/narration/preparation.js +75 -0
- package/dist/slices/narration/steering.js +89 -0
- package/dist/slices/play-drafts/convert.js +3 -0
- package/dist/slices/play-drafts/readiness.js +3 -1
- package/dist/slices/play-drafts/schema.js +1 -0
- package/dist/slices/project-templates/from-project.js +2 -0
- package/dist/slices/project-templates/setup.js +1 -0
- package/dist/slices/rebuild/admission-repo.js +2 -2
- package/dist/slices/rebuild/preview-details.js +12 -9
- package/dist/slices/rebuild/preview-plan.js +7 -1
- package/dist/slices/rebuild/recipe-audio-parts.js +89 -0
- package/dist/slices/rebuild/recipe-audio.js +52 -66
- package/dist/slices/rebuild/recipe-input-schema.js +11 -0
- package/dist/slices/rebuild/recipe-legacy.js +5 -0
- package/dist/slices/rebuild/recipe-model.js +3 -0
- package/dist/slices/rebuild/recipe-narration-text.js +33 -0
- package/dist/slices/rebuild/recipe-preparation.js +66 -0
- package/dist/slices/rebuild/recipe-provider-choice.js +7 -5
- package/dist/slices/rebuild/recipe-validation.js +9 -1
- package/dist/slices/rebuild/recipe-work.js +13 -1
- package/dist/slices/rebuild/runtime-admission.js +11 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +4 -0
- package/dist/slices/rebuild/runtime-local.js +5 -0
- package/dist/slices/rebuild/runtime-materialize.js +2 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +1 -0
- package/dist/slices/rebuild/runtime-narration-text.js +75 -0
- package/dist/slices/rebuild/runtime-provider.js +30 -1
- package/dist/slices/rebuild/runtime-publication.js +13 -1
- package/dist/slices/rebuild/runtime-store.js +13 -2
- package/dist/slices/revisions/rules.js +5 -3
- package/dist/slices/settings/cli-paths.js +10 -6
- package/dist/slices/settings/cli-status.js +1 -1
- package/dist/slices/settings/model.js +9 -0
- package/dist/slices/settings/models.js +3 -3
- package/dist/slices/settings/readiness.js +9 -2
- package/dist/slices/storage/asset-name.js +3 -0
- package/dist/slices/storage/layout.js +4 -0
- package/dist/slices/storage/model.js +2 -0
- package/dist/slices/storage/schema.js +1 -0
- package/dist/web/assets/index-DsumGtv6.js +137 -0
- package/dist/web/index.html +1 -1
- package/package.json +2 -1
- package/scripts/docker-run.sh +113 -0
- 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 {
|
|
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
|
-
|
|
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: [
|
|
40
|
-
|
|
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: [
|
|
69
|
-
|
|
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 =
|
|
8
|
-
? "
|
|
9
|
-
: recipe.stage === "
|
|
10
|
-
? "
|
|
11
|
-
: "
|
|
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
|
+
}
|