@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
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|