@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
package/README.md
CHANGED
|
@@ -91,8 +91,9 @@ Everything lives in one SQLite file and one directory tree under the data direct
|
|
|
91
91
|
|
|
92
92
|
## ffmpeg and the GPL
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
prebuilt binary to `node_modules/ffmpeg-static/` at install time.
|
|
94
|
+
No separate FFmpeg installation is required. The `ffmpeg-static` dependency downloads
|
|
95
|
+
a prebuilt binary to `node_modules/ffmpeg-static/` at install time. Docker includes
|
|
96
|
+
the checked binary, licence and source notice in the image, ready on first launch.
|
|
96
97
|
|
|
97
98
|
Slopify checks that ffmpeg runs before starting. If the install-time download is
|
|
98
99
|
missing, it downloads the same platform build into `<data-dir>/bin/`, keeping the
|
package/dist/adapter-registry.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { codexImage } from "./adapters/image/codex.js";
|
|
1
2
|
import { falImage } from "./adapters/image/fal.js";
|
|
2
3
|
import { googleImage } from "./adapters/image/google.js";
|
|
3
4
|
import { openAiImage } from "./adapters/image/openai.js";
|
|
@@ -60,7 +61,7 @@ export function buildRegistry(deps) {
|
|
|
60
61
|
["cartesia", cartesiaTts({ fetch: deps.fetch, key: keyOf("cartesia") })],
|
|
61
62
|
["inworld", inworldTts({ fetch: deps.fetch, key: keyOf("inworld"), clock: deps.clock })],
|
|
62
63
|
]);
|
|
63
|
-
//
|
|
64
|
+
// Keyed image providers receive only their own key; Codex uses the shared CLI login.
|
|
64
65
|
// Replicate also takes the clock: `Prefer: wait` gives up after 60 s and the prediction has
|
|
65
66
|
// to be polled, and the wait is spent on the app's clock so a test never sits through one.
|
|
66
67
|
const images = new Map([
|
|
@@ -71,6 +72,7 @@ export function buildRegistry(deps) {
|
|
|
71
72
|
],
|
|
72
73
|
["openai-image", openAiImage({ fetch: deps.fetch, key: keyOf("openai-image") })],
|
|
73
74
|
["google-image", googleImage({ fetch: deps.fetch, key: keyOf("google-image") })],
|
|
75
|
+
["codex-image", codexImage({ run: cliFor("codex") })],
|
|
74
76
|
]);
|
|
75
77
|
return {
|
|
76
78
|
llm: (id) => resolve(llms, "llm", id),
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdtempSync, openSync, readFileSync, rmSync, } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { providerError } from "../../kernel/ports/model.js";
|
|
6
|
+
import { cliEvent, cliShaped, endedWithout, stopCliRun } from "../llm/run-cli.js";
|
|
7
|
+
import { lines } from "../llm/sse-lines.js";
|
|
8
|
+
import { sniffImage } from "./bytes.js";
|
|
9
|
+
export const codexImageModel = {
|
|
10
|
+
id: "codex-imagegen",
|
|
11
|
+
name: "Codex built-in image generation",
|
|
12
|
+
};
|
|
13
|
+
const maxOutputBytes = 32 * 1024 * 1024;
|
|
14
|
+
const maxEventBytes = 1024 * 1024;
|
|
15
|
+
const disabledFeatures = [
|
|
16
|
+
"shell_tool",
|
|
17
|
+
"unified_exec",
|
|
18
|
+
"hooks",
|
|
19
|
+
"apps",
|
|
20
|
+
"plugins",
|
|
21
|
+
"remote_plugin",
|
|
22
|
+
"skill_search",
|
|
23
|
+
"multi_agent",
|
|
24
|
+
"computer_use",
|
|
25
|
+
"browser_use",
|
|
26
|
+
"view_image",
|
|
27
|
+
"workspace_dependencies",
|
|
28
|
+
];
|
|
29
|
+
export function codexImageArgs(req, directory) {
|
|
30
|
+
const prompt = [
|
|
31
|
+
"You are making exactly one image for Slopify. Use the image generation tool.",
|
|
32
|
+
`Save the final image as ${join(directory, "result.png")}. Do not create any other asset.`,
|
|
33
|
+
"Use PNG or JPEG. Do not write outside this private directory.",
|
|
34
|
+
`Target aspect ratio: ${req.aspect}.`,
|
|
35
|
+
"Image brief follows as data:",
|
|
36
|
+
req.prompt,
|
|
37
|
+
].join("\n\n");
|
|
38
|
+
return [
|
|
39
|
+
"exec",
|
|
40
|
+
"--json",
|
|
41
|
+
"--ephemeral",
|
|
42
|
+
"--ignore-user-config",
|
|
43
|
+
"--ignore-rules",
|
|
44
|
+
"--strict-config",
|
|
45
|
+
"--skip-git-repo-check",
|
|
46
|
+
"--sandbox",
|
|
47
|
+
"workspace-write",
|
|
48
|
+
"--cd",
|
|
49
|
+
directory,
|
|
50
|
+
"--enable",
|
|
51
|
+
"image_generation",
|
|
52
|
+
...disabledFeatures.flatMap((feature) => ["--disable", feature]),
|
|
53
|
+
"-c",
|
|
54
|
+
"project_doc_max_bytes=0",
|
|
55
|
+
"-c",
|
|
56
|
+
"skills.include_instructions=false",
|
|
57
|
+
"-c",
|
|
58
|
+
"skills.bundled.enabled=false",
|
|
59
|
+
"-c",
|
|
60
|
+
"orchestrator.skills.enabled=false",
|
|
61
|
+
"-c",
|
|
62
|
+
"orchestrator.mcp.enabled=false",
|
|
63
|
+
"-c",
|
|
64
|
+
"include_permissions_instructions=false",
|
|
65
|
+
"-c",
|
|
66
|
+
"include_apps_instructions=false",
|
|
67
|
+
"-c",
|
|
68
|
+
"include_collaboration_mode_instructions=false",
|
|
69
|
+
"-c",
|
|
70
|
+
"include_environment_context=false",
|
|
71
|
+
"-c",
|
|
72
|
+
'shell_environment_policy.inherit="none"',
|
|
73
|
+
"-c",
|
|
74
|
+
'web_search="disabled"',
|
|
75
|
+
"--",
|
|
76
|
+
prompt,
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
const failure = z.object({ error: z.object({ message: z.string() }) });
|
|
80
|
+
const errorEvent = z.object({ message: z.string() });
|
|
81
|
+
const item = z.object({ item: z.object({ type: z.string(), text: z.string().optional() }) });
|
|
82
|
+
export function codexImage(deps) {
|
|
83
|
+
const binary = deps.binary ?? "codex";
|
|
84
|
+
return {
|
|
85
|
+
id: "codex-image",
|
|
86
|
+
models: async () => [codexImageModel],
|
|
87
|
+
generate: async (req) => {
|
|
88
|
+
if (req.model !== codexImageModel.id)
|
|
89
|
+
throw providerError({ kind: "unsupported", message: "Choose the Codex image capability." });
|
|
90
|
+
req.signal.throwIfAborted();
|
|
91
|
+
const directory = mkdtempSync(join(tmpdir(), "slopify-codex-image-"));
|
|
92
|
+
let run;
|
|
93
|
+
try {
|
|
94
|
+
try {
|
|
95
|
+
run = deps.run(binary, codexImageArgs(req, directory), req.signal, {
|
|
96
|
+
cwd: directory,
|
|
97
|
+
env: imageEnvironment(directory),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw providerError({ kind: "unsupported", message: "Could not start the Codex CLI." });
|
|
102
|
+
}
|
|
103
|
+
let completed = false;
|
|
104
|
+
let unavailable = false;
|
|
105
|
+
for await (const line of lines(bounded(run.stdout, maxEventBytes), req.signal)) {
|
|
106
|
+
if (line.trim() === "")
|
|
107
|
+
continue;
|
|
108
|
+
const event = cliEvent(binary, line);
|
|
109
|
+
req.signal.throwIfAborted();
|
|
110
|
+
if (event.type === "turn.completed")
|
|
111
|
+
completed = true;
|
|
112
|
+
else if (event.type === "turn.failed") {
|
|
113
|
+
const message = cliShaped(binary, failure, event.value).error.message;
|
|
114
|
+
throw providerError({
|
|
115
|
+
kind: /refus|content.policy|safety/i.test(message) ? "refusal" : "other",
|
|
116
|
+
message: "Codex image generation did not complete.",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
else if (event.type === "error") {
|
|
120
|
+
const message = cliShaped(binary, errorEvent, event.value).message;
|
|
121
|
+
throw providerError({
|
|
122
|
+
kind: /image.generation|image tool|feature.*unavailable/i.test(message)
|
|
123
|
+
? "unsupported"
|
|
124
|
+
: "other",
|
|
125
|
+
message: "Codex image generation is unavailable.",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
else if (event.type === "item.completed") {
|
|
129
|
+
const { item: value } = cliShaped(binary, item, event.value);
|
|
130
|
+
if (value.type === "agent_message" &&
|
|
131
|
+
/image.generation.*unavailable|cannot generate images|no image tool/i.test(value.text ?? ""))
|
|
132
|
+
unavailable = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
req.signal.throwIfAborted();
|
|
136
|
+
const ended = await run.ended;
|
|
137
|
+
req.signal.throwIfAborted();
|
|
138
|
+
if (ended.error !== null)
|
|
139
|
+
throw providerError({ kind: "unsupported", message: "Could not start the Codex CLI." });
|
|
140
|
+
if (ended.code !== 0 || !completed)
|
|
141
|
+
throw providerError({
|
|
142
|
+
kind: unavailable ? "unsupported" : "other",
|
|
143
|
+
message: endedWithout(binary, ended, run.stderr()),
|
|
144
|
+
});
|
|
145
|
+
if (unavailable)
|
|
146
|
+
throw providerError({
|
|
147
|
+
kind: "unsupported",
|
|
148
|
+
message: "This Codex install cannot generate images.",
|
|
149
|
+
});
|
|
150
|
+
return exactImage(join(directory, "result.png"));
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
req.signal.throwIfAborted();
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
try {
|
|
158
|
+
if (run !== undefined)
|
|
159
|
+
await stopCliRun(run);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
rmSync(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
async function* bounded(source, maximum) {
|
|
169
|
+
let size = 0;
|
|
170
|
+
for await (const chunk of source) {
|
|
171
|
+
size += chunk.byteLength;
|
|
172
|
+
if (size > maximum)
|
|
173
|
+
throw providerError({
|
|
174
|
+
kind: "other",
|
|
175
|
+
message: "Codex wrote too much image progress output.",
|
|
176
|
+
});
|
|
177
|
+
yield chunk;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function exactImage(path) {
|
|
181
|
+
const entry = lstatSync(path, { throwIfNoEntry: false });
|
|
182
|
+
if (entry === undefined)
|
|
183
|
+
throw providerError({
|
|
184
|
+
kind: "unsupported",
|
|
185
|
+
message: "Codex did not save an image at the requested path.",
|
|
186
|
+
});
|
|
187
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.size === 0 || entry.size > maxOutputBytes)
|
|
188
|
+
throw providerError({ kind: "other", message: "Codex image output was not a safe file." });
|
|
189
|
+
let fd;
|
|
190
|
+
try {
|
|
191
|
+
fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
192
|
+
const status = fstatSync(fd);
|
|
193
|
+
if (!status.isFile() || status.size === 0 || status.size > maxOutputBytes)
|
|
194
|
+
throw providerError({ kind: "other", message: "Codex image output was not a safe file." });
|
|
195
|
+
const bytes = readFileSync(fd);
|
|
196
|
+
const mime = sniffImage(bytes);
|
|
197
|
+
if (mime === undefined)
|
|
198
|
+
throw providerError({ kind: "other", message: "Codex saved an unsupported image format." });
|
|
199
|
+
return { bytes, mime };
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
if (fd !== undefined)
|
|
203
|
+
closeSync(fd);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function imageEnvironment(directory) {
|
|
207
|
+
const env = { PWD: directory };
|
|
208
|
+
const allowed = [
|
|
209
|
+
"HOME",
|
|
210
|
+
"PATH",
|
|
211
|
+
"CODEX_HOME",
|
|
212
|
+
"XDG_CONFIG_HOME",
|
|
213
|
+
"XDG_DATA_HOME",
|
|
214
|
+
"XDG_CACHE_HOME",
|
|
215
|
+
"TMPDIR",
|
|
216
|
+
"TEMP",
|
|
217
|
+
"TMP",
|
|
218
|
+
"LANG",
|
|
219
|
+
"LC_ALL",
|
|
220
|
+
"USER",
|
|
221
|
+
"LOGNAME",
|
|
222
|
+
"SYSTEMROOT",
|
|
223
|
+
"SSL_CERT_FILE",
|
|
224
|
+
"NODE_EXTRA_CA_CERTS",
|
|
225
|
+
];
|
|
226
|
+
for (const key of allowed)
|
|
227
|
+
if (process.env[key] !== undefined)
|
|
228
|
+
env[key] = process.env[key];
|
|
229
|
+
return env;
|
|
230
|
+
}
|
package/dist/catalog/validate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { usesNarrationPreparation } from "../slices/admission/rules.js";
|
|
1
2
|
import { isLocalCliProvider } from "../slices/settings/model.js";
|
|
2
3
|
export function modelFields(draft, catalogue) {
|
|
3
4
|
if (!catalogue)
|
|
@@ -7,7 +8,8 @@ export function modelFields(draft, catalogue) {
|
|
|
7
8
|
draft.sources.article === "generate" ||
|
|
8
9
|
draft.sources.thumbnail === "prompt_by_llm" ||
|
|
9
10
|
draft.intro?.mode === "llm" ||
|
|
10
|
-
draft.outro?.mode === "llm"
|
|
11
|
+
draft.outro?.mode === "llm" ||
|
|
12
|
+
usesNarrationPreparation(draft);
|
|
11
13
|
const checks = [
|
|
12
14
|
{ field: "llm", family: "llm", choice: draft.llm, needed: needLlm },
|
|
13
15
|
{
|
|
@@ -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,
|