@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.
- package/README.md +3 -2
- package/dist/adapter-registry.js +3 -1
- package/dist/adapters/image/codex.js +230 -0
- package/dist/catalog/validate.js +3 -1
- 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/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 +1 -1
- 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
|
|
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 =
|
|
53
|
-
? "
|
|
54
|
-
: row.key.startsWith("entry:
|
|
55
|
-
? "
|
|
56
|
-
: row.
|
|
57
|
-
? "
|
|
58
|
-
: row.stage === "
|
|
59
|
-
? "
|
|
60
|
-
:
|
|
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
|
|
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 {
|
|
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) => {
|