@gentbajko/slopify 1.5.1 → 2.0.0
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 +34 -8
- package/dist/adapters/alignment/cache.js +67 -7
- package/dist/edge/cli.js +10 -2
- package/dist/edge/docker-launch.js +77 -0
- package/dist/edge/docker-projects/activation.js +35 -0
- package/dist/edge/docker-projects/claims.js +85 -0
- package/dist/edge/docker-projects/committed.js +40 -0
- package/dist/edge/docker-projects/engine.js +384 -0
- package/dist/edge/docker-projects/install.js +246 -0
- package/dist/edge/docker-projects/recover.js +100 -0
- package/dist/edge/docker-projects/state.js +221 -0
- package/dist/edge/docker-projects/tree.js +170 -0
- package/dist/edge/docker-projects/volume.js +95 -0
- package/dist/edge/docker.js +6 -0
- package/dist/edge/http/actions.js +64 -15
- package/dist/edge/http/app.js +6 -0
- package/dist/edge/http/folder-location-schema.js +11 -0
- package/dist/edge/http/folder-location.js +65 -0
- package/dist/edge/http/open-folder.js +2 -14
- package/dist/edge/http/projects.js +2 -0
- package/dist/edge/http/revision-files.js +2 -18
- package/dist/kernel/db/migrations/0012-project-recovery.sql +17 -0
- package/dist/kernel/lock.js +20 -6
- package/dist/main.js +11 -1
- package/dist/slices/admission/rules.js +6 -0
- package/dist/slices/admission/schema.js +3 -1
- package/dist/slices/article/plain.js +16 -9
- package/dist/slices/control/revision-control.js +2 -2
- package/dist/slices/narration/pronunciation-chunks.js +105 -0
- package/dist/slices/narration/pronunciation.js +126 -0
- package/dist/slices/narration/steering.js +82 -24
- package/dist/slices/play-drafts/convert.js +3 -1
- package/dist/slices/play-drafts/schema.js +3 -1
- package/dist/slices/rebuild/admission-repo.js +7 -4
- package/dist/slices/rebuild/recipe-audio-parts.js +70 -14
- package/dist/slices/rebuild/recipe-audio.js +61 -40
- package/dist/slices/rebuild/recipe-model.js +5 -3
- package/dist/slices/rebuild/recipe-preparation.js +6 -4
- package/dist/slices/rebuild/recipe-text.js +11 -3
- package/dist/slices/rebuild/recovery-conflict.js +30 -0
- package/dist/slices/rebuild/recovery-model.js +44 -0
- package/dist/slices/rebuild/recovery-repo.js +84 -0
- package/dist/slices/rebuild/recovery-selection.js +68 -0
- package/dist/slices/rebuild/recovery.js +257 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +3 -2
- package/dist/slices/rebuild/runtime-narration-publication.js +30 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +20 -2
- package/dist/slices/rebuild/runtime-plan.js +8 -2
- package/dist/slices/rebuild/runtime-publication.js +9 -4
- package/dist/slices/rebuild/service.js +44 -22
- package/dist/slices/rebuild/transition-repo.js +1 -1
- package/dist/slices/revisions/mutation-assets.js +6 -1
- package/dist/slices/revisions/mutation-request.js +4 -0
- package/dist/slices/revisions/mutations.js +10 -5
- package/dist/slices/revisions/rules.js +43 -0
- package/dist/slices/revisions/schema.js +12 -0
- package/dist/slices/settings/repo.js +4 -2
- package/dist/web/assets/index-CZNiHm8P.css +1 -0
- package/dist/web/assets/index-DPgGLX5m.js +137 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/scripts/docker-run.sh +1 -74
- package/dist/web/assets/index-CbGJtCio.js +0 -137
- package/dist/web/assets/index-DX1_chKs.css +0 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { remark } from "remark";
|
|
2
|
+
import remarkGfm from "remark-gfm";
|
|
3
|
+
// Inworld requires English IPA, not every symbol in Unicode's IPA Extensions block.
|
|
4
|
+
// https://docs.inworld.ai/tts/capabilities/custom-pronunciation
|
|
5
|
+
const ipaAtom = "[ˈˌ]?[abdefghijklmnoprstuvwxzæðŋθɑɒɔəɚɛɜɝɡɪɹʃʊʌʒʔɫɾ][\\u0303\\u031a\\u0325\\u0329\\u032a\\u032c\\u032f\\u035c\\u0361ʰʲʷ]*[ːˑ]?";
|
|
6
|
+
const ipaSymbols = new RegExp(`^(?:${ipaAtom})+(?:\\.(?:${ipaAtom})+)*$`, "u");
|
|
7
|
+
function textOf(node) {
|
|
8
|
+
if (node.type === "break")
|
|
9
|
+
return "\n";
|
|
10
|
+
return node.value ?? node.children?.map(textOf).join("") ?? "";
|
|
11
|
+
}
|
|
12
|
+
function rowsOf(node) {
|
|
13
|
+
if (node.type === "heading") {
|
|
14
|
+
const text = textOf(node).trim();
|
|
15
|
+
return /^pronunciation glossary:?\s*$/iu.test(text) ? [] : [text];
|
|
16
|
+
}
|
|
17
|
+
if (node.type === "table") {
|
|
18
|
+
const rows = node.children ?? [];
|
|
19
|
+
return rows.slice(1).map((row) => {
|
|
20
|
+
const cells = row.children ?? [];
|
|
21
|
+
return cells.length >= 2 ? cells.slice(0, 2).map(textOf).join(": ") : textOf(row);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (node.type === "paragraph")
|
|
25
|
+
return textOf(node).split(/\r?\n/u);
|
|
26
|
+
if (node.type === "root" || node.type === "list" || node.type === "listItem")
|
|
27
|
+
return (node.children ?? []).flatMap(rowsOf);
|
|
28
|
+
return ["(unsupported glossary block)"];
|
|
29
|
+
}
|
|
30
|
+
function refused(row, reason) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
reason: "Pronunciation Glossary entry " +
|
|
34
|
+
row +
|
|
35
|
+
": " +
|
|
36
|
+
reason +
|
|
37
|
+
". Edit the glossary or turn off Use Pronunciation Glossary.",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function termIdentity(term) {
|
|
41
|
+
return Array.from(term, (character) => {
|
|
42
|
+
const lower = character.toLowerCase();
|
|
43
|
+
const folded = lower.toUpperCase().toLowerCase();
|
|
44
|
+
if (folded === character)
|
|
45
|
+
return character;
|
|
46
|
+
// Full case conversion can expand letters; only /iu-equivalent forms may share a key.
|
|
47
|
+
const match = new RegExp(`^${character.replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&")}$`, "iu");
|
|
48
|
+
return match.test(folded) ? folded : match.test(lower) ? lower : character;
|
|
49
|
+
}).join("");
|
|
50
|
+
}
|
|
51
|
+
export function parsePronunciationGlossary(markdown) {
|
|
52
|
+
const rows = rowsOf(remark().use(remarkGfm).parse(markdown));
|
|
53
|
+
const entries = new Map();
|
|
54
|
+
for (const [index, row] of rows.entries()) {
|
|
55
|
+
const trimmed = row.trim();
|
|
56
|
+
if (trimmed === "" || /^pronunciation glossary:?\s*$/iu.test(trimmed))
|
|
57
|
+
continue;
|
|
58
|
+
const pair = /^([^:]+):\s*(.+)$/u.exec(trimmed);
|
|
59
|
+
const term = pair?.[1]?.trim().replace(/\s+/gu, " ");
|
|
60
|
+
const pronunciation = pair?.[2];
|
|
61
|
+
if (term === undefined ||
|
|
62
|
+
pronunciation === undefined ||
|
|
63
|
+
!/[\p{L}\p{N}]/u.test(term) ||
|
|
64
|
+
/[/[\]<>\p{Cc}]/u.test(term))
|
|
65
|
+
return refused(index + 1, "use Term: /IPA/ or a Term | IPA table");
|
|
66
|
+
const notation = /^(\/[^/]+\/(?:\s+\/[^/]+\/)*)(?:\s+[^/]+)?$/u.exec(pronunciation);
|
|
67
|
+
if (notation?.[1] === undefined)
|
|
68
|
+
return refused(index + 1, "use slash-delimited standard-English IPA");
|
|
69
|
+
const ipa = Array.from(notation[1].matchAll(/\/([^/]+)\//gu)).flatMap((match) => (match[1] ?? "").trim().split(/\s+/u));
|
|
70
|
+
if (ipa.length === 0 || ipa.some((word) => !ipaSymbols.test(word)))
|
|
71
|
+
return refused(index + 1, "use standard-English IPA, not ARPAbet or delivery tags");
|
|
72
|
+
if (term.split(" ").length !== ipa.length)
|
|
73
|
+
return refused(index + 1, "supply one IPA word for each written word");
|
|
74
|
+
const key = termIdentity(term);
|
|
75
|
+
const previous = entries.get(key);
|
|
76
|
+
if (previous !== undefined && previous.ipa.join(" ") !== ipa.join(" "))
|
|
77
|
+
return refused(index + 1, "conflicting pronunciations were supplied for the same term");
|
|
78
|
+
if (previous === undefined)
|
|
79
|
+
entries.set(key, { term, ipa });
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, entries: [...entries.values()] };
|
|
82
|
+
}
|
|
83
|
+
export function pronunciationMatches(source, entries) {
|
|
84
|
+
if (entries.length === 0)
|
|
85
|
+
return [];
|
|
86
|
+
const ordered = [...entries].sort((a, b) => b.term.length - a.term.length);
|
|
87
|
+
const patterns = ordered.map((entry) => entry.term
|
|
88
|
+
.split(" ")
|
|
89
|
+
.map((word) => word.replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&"))
|
|
90
|
+
.join("\\s+"));
|
|
91
|
+
const boundary = "[\\p{L}\\p{M}\\p{N}_\\-\u00ad\u2010\u2011\ufe63\uff0d]";
|
|
92
|
+
const expression = new RegExp("(?<!" +
|
|
93
|
+
boundary +
|
|
94
|
+
"['’]?)(?:" +
|
|
95
|
+
patterns.map((pattern) => `(${pattern})`).join("|") +
|
|
96
|
+
")(?!['’]?" +
|
|
97
|
+
boundary +
|
|
98
|
+
")", "giu");
|
|
99
|
+
const matches = [];
|
|
100
|
+
for (const match of source.matchAll(expression)) {
|
|
101
|
+
const end = match.index + match[0].length;
|
|
102
|
+
const before = source[match.index - 1];
|
|
103
|
+
const after = source[end];
|
|
104
|
+
if ((after === "'" && before !== "'") || (after === "’" && before !== "‘"))
|
|
105
|
+
continue;
|
|
106
|
+
const entry = ordered.find((_, index) => match[index + 1] !== undefined);
|
|
107
|
+
if (entry === undefined)
|
|
108
|
+
throw new Error("Matched glossary term has no mapping.");
|
|
109
|
+
matches.push({ start: match.index, end, entry });
|
|
110
|
+
}
|
|
111
|
+
return matches;
|
|
112
|
+
}
|
|
113
|
+
export function pronunciationSpans(source, entries) {
|
|
114
|
+
const spans = [];
|
|
115
|
+
for (const match of pronunciationMatches(source, entries)) {
|
|
116
|
+
const words = source.slice(match.start, match.end).matchAll(/\S+/gu);
|
|
117
|
+
for (const [index, word] of Array.from(words).entries()) {
|
|
118
|
+
const ipa = match.entry.ipa[index];
|
|
119
|
+
if (ipa === undefined)
|
|
120
|
+
throw new Error("Validated glossary term has no IPA word.");
|
|
121
|
+
const start = match.start + word.index;
|
|
122
|
+
spans.push({ start, end: start + word[0].length, text: `/${ipa}/` });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return spans;
|
|
126
|
+
}
|
|
@@ -1,9 +1,68 @@
|
|
|
1
1
|
import { sourceSentences } from "./preparation.js";
|
|
2
2
|
const cannotFit = {
|
|
3
3
|
ok: false,
|
|
4
|
-
reason: "Delivery cues or whitespace leave no room for narration at this model's character limit.",
|
|
4
|
+
reason: "Delivery cues, an indivisible IPA token or whitespace leave no room for narration at this model's character limit.",
|
|
5
5
|
};
|
|
6
|
-
|
|
6
|
+
function atoms(source, from, to, spans) {
|
|
7
|
+
const result = [];
|
|
8
|
+
for (let at = from; at < to;) {
|
|
9
|
+
const span = spans.get(at);
|
|
10
|
+
if (span !== undefined) {
|
|
11
|
+
if (span.end > to || span.end <= at)
|
|
12
|
+
throw new Error("Pronunciation span crosses a word.");
|
|
13
|
+
result.push({ text: span.text, spokenText: source.slice(at, span.end) });
|
|
14
|
+
at = span.end;
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
const point = source.codePointAt(at);
|
|
18
|
+
if (point === undefined)
|
|
19
|
+
throw new Error("Narration offset is outside the source.");
|
|
20
|
+
const text = String.fromCodePoint(point);
|
|
21
|
+
result.push({ text, spokenText: text });
|
|
22
|
+
at += text.length;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
function checkedSpans(source, pronunciation) {
|
|
28
|
+
const spans = new Map();
|
|
29
|
+
let end = 0;
|
|
30
|
+
for (const span of [...pronunciation].sort((a, b) => a.start - b.start)) {
|
|
31
|
+
if (!Number.isInteger(span.start) ||
|
|
32
|
+
!Number.isInteger(span.end) ||
|
|
33
|
+
span.start < end ||
|
|
34
|
+
span.end <= span.start ||
|
|
35
|
+
span.end > source.length ||
|
|
36
|
+
!/^[^\s\uD800-\uDFFF]+$/u.test(source.slice(span.start, span.end)))
|
|
37
|
+
throw new Error("Pronunciation spans must cover disjoint whole source characters in a word.");
|
|
38
|
+
spans.set(span.start, span);
|
|
39
|
+
end = span.end;
|
|
40
|
+
}
|
|
41
|
+
return spans;
|
|
42
|
+
}
|
|
43
|
+
function anchoredParts(source, cues, spans) {
|
|
44
|
+
const bySentence = new Map();
|
|
45
|
+
for (const cue of cues) {
|
|
46
|
+
const group = bySentence.get(cue.sentence) ?? [];
|
|
47
|
+
group.push(cue);
|
|
48
|
+
bySentence.set(cue.sentence, group);
|
|
49
|
+
}
|
|
50
|
+
const anchors = new Map();
|
|
51
|
+
let offset = 0;
|
|
52
|
+
for (const sentence of sourceSentences(source)) {
|
|
53
|
+
const start = spans.find((span) => span.start < offset && offset < span.end)?.start ?? offset;
|
|
54
|
+
const events = anchors.get(start) ?? [];
|
|
55
|
+
events.push(...(bySentence.get(sentence.sentence) ?? []));
|
|
56
|
+
anchors.set(start, events);
|
|
57
|
+
offset += sentence.text.length;
|
|
58
|
+
}
|
|
59
|
+
return [...anchors].map(([start, events], index, boundaries) => ({
|
|
60
|
+
start,
|
|
61
|
+
text: source.slice(start, boundaries[index + 1]?.[0] ?? source.length),
|
|
62
|
+
events,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
export function prepareRequests(source, cues, maxCharacters, pronunciation = []) {
|
|
7
66
|
if (!Number.isInteger(maxCharacters) || maxCharacters < 2)
|
|
8
67
|
return {
|
|
9
68
|
ok: false,
|
|
@@ -11,13 +70,8 @@ export function prepareRequests(source, cues, maxCharacters) {
|
|
|
11
70
|
};
|
|
12
71
|
if (source.trim() === "")
|
|
13
72
|
return { ok: true, requests: [] };
|
|
73
|
+
const spans = checkedSpans(source, pronunciation);
|
|
14
74
|
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
75
|
let active = null;
|
|
22
76
|
let text = "";
|
|
23
77
|
let spokenText = "";
|
|
@@ -29,17 +83,19 @@ export function prepareRequests(source, cues, maxCharacters) {
|
|
|
29
83
|
spokenText = "";
|
|
30
84
|
return true;
|
|
31
85
|
};
|
|
32
|
-
for (const
|
|
33
|
-
const events =
|
|
34
|
-
const direction =
|
|
86
|
+
for (const part of anchoredParts(source, cues, pronunciation)) {
|
|
87
|
+
const events = part.events;
|
|
88
|
+
const direction = pronunciation.length === 0
|
|
89
|
+
? events.find((cue) => cue.kind !== "sound")
|
|
90
|
+
: events.findLast((cue) => cue.kind !== "sound");
|
|
35
91
|
const tags = events.map(cueTag).join(" ");
|
|
36
92
|
const prefix = tags === "" ? "" : `${tags} `;
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
93
|
+
const words = Array.from(part.text.matchAll(/\S+\s*|\s+/gu)).map((match) => atoms(source, part.start + match.index, part.start + match.index + match[0].length, spans));
|
|
94
|
+
const first = words[0]?.[0]?.text ?? "";
|
|
95
|
+
const firstWordLength = words[0]?.reduce((length, atom) => length + atom.text.length, 0) ?? 0;
|
|
40
96
|
const freshPrefix = direction === undefined ? carryTag(active) : "";
|
|
41
|
-
const minimum =
|
|
42
|
-
?
|
|
97
|
+
const minimum = firstWordLength + prefix.length + freshPrefix.length <= maxCharacters
|
|
98
|
+
? firstWordLength
|
|
43
99
|
: first.length;
|
|
44
100
|
if (spokenText.trim() !== "" && text.length + prefix.length + minimum > maxCharacters) {
|
|
45
101
|
if (!flush())
|
|
@@ -52,24 +108,26 @@ export function prepareRequests(source, cues, maxCharacters) {
|
|
|
52
108
|
text += prefix;
|
|
53
109
|
if (direction !== undefined)
|
|
54
110
|
active = direction.kind === "instruction" ? direction.text : null;
|
|
55
|
-
for (const word of words) {
|
|
56
|
-
|
|
57
|
-
|
|
111
|
+
for (const [index, word] of words.entries()) {
|
|
112
|
+
const length = word.reduce((sum, atom) => sum + atom.text.length, 0);
|
|
113
|
+
if ((index > 0 || prefix === "" || spans.size === 0) &&
|
|
114
|
+
length + carryTag(active).length <= maxCharacters &&
|
|
115
|
+
text.length + length > maxCharacters &&
|
|
58
116
|
spokenText.trim() !== "") {
|
|
59
117
|
if (!flush())
|
|
60
118
|
return cannotFit;
|
|
61
119
|
text = carryTag(active);
|
|
62
120
|
}
|
|
63
|
-
for (const
|
|
64
|
-
if (text.length +
|
|
121
|
+
for (const atom of word) {
|
|
122
|
+
if (text.length + atom.text.length > maxCharacters) {
|
|
65
123
|
if (!flush())
|
|
66
124
|
return cannotFit;
|
|
67
125
|
text = carryTag(active);
|
|
68
126
|
}
|
|
69
|
-
if (text.length +
|
|
127
|
+
if (text.length + atom.text.length > maxCharacters)
|
|
70
128
|
return cannotFit;
|
|
71
|
-
text +=
|
|
72
|
-
spokenText +=
|
|
129
|
+
text += atom.text;
|
|
130
|
+
spokenText += atom.spokenText;
|
|
73
131
|
}
|
|
74
132
|
}
|
|
75
133
|
}
|
|
@@ -59,7 +59,9 @@ export function toAdmissionDraft(input) {
|
|
|
59
59
|
format: form.format,
|
|
60
60
|
sources,
|
|
61
61
|
llm: form.llm,
|
|
62
|
-
audio: sources.audio === "generate"
|
|
62
|
+
audio: sources.audio === "generate" || form.audio.usePronunciationGlossary !== undefined
|
|
63
|
+
? form.audio
|
|
64
|
+
: undefined,
|
|
63
65
|
images: form.images,
|
|
64
66
|
articlePrompt: sources.article === "generate" ? form.articlePrompt : undefined,
|
|
65
67
|
...(sources.audio === "generate" && form.narrationPrompt?.trim()
|
|
@@ -31,7 +31,9 @@ export const playDraftFormSchema = z
|
|
|
31
31
|
.strict()
|
|
32
32
|
.readonly(),
|
|
33
33
|
llm: provider.readonly(),
|
|
34
|
-
audio: provider
|
|
34
|
+
audio: provider
|
|
35
|
+
.extend({ voice: text, usePronunciationGlossary: z.boolean().optional() })
|
|
36
|
+
.readonly(),
|
|
35
37
|
images: provider.readonly(),
|
|
36
38
|
articlePrompt: text,
|
|
37
39
|
narrationPrompt: text.optional(),
|
|
@@ -8,6 +8,7 @@ import { executionSnapshotSchema, planPreview, reviewStillCovers } from "./previ
|
|
|
8
8
|
import { previewById } from "./repo.js";
|
|
9
9
|
import { insertInvocation, reservationKey } from "./runtime-admission.js";
|
|
10
10
|
import { bindNarrationReuse } from "./runtime-narration-reuse.js";
|
|
11
|
+
import { executionCatalogue } from "./runtime-plan.js";
|
|
11
12
|
import { projectStandings } from "./runtime-store.js";
|
|
12
13
|
export function admissionReceipt(db, projectId, idempotencyKey, requestHash) {
|
|
13
14
|
const row = db
|
|
@@ -24,8 +25,9 @@ export function admissionReceipt(db, projectId, idempotencyKey, requestHash) {
|
|
|
24
25
|
},
|
|
25
26
|
};
|
|
26
27
|
if (db
|
|
27
|
-
.prepare("SELECT 1 FROM revision_mutations WHERE project_id=? AND idempotency_key=? UNION ALL SELECT 1 FROM project_control_receipts WHERE project_id=? AND idempotency_key=?")
|
|
28
|
-
.get(projectId, idempotencyKey, projectId, idempotencyKey) !==
|
|
28
|
+
.prepare("SELECT 1 FROM revision_mutations WHERE project_id=? AND idempotency_key=? UNION ALL SELECT 1 FROM project_control_receipts WHERE project_id=? AND idempotency_key=? UNION ALL SELECT 1 FROM project_recovery_requests WHERE project_id=? AND idempotency_key=?")
|
|
29
|
+
.get(projectId, idempotencyKey, projectId, idempotencyKey, projectId, idempotencyKey) !==
|
|
30
|
+
undefined)
|
|
29
31
|
return { ok: false, reason: "conflict" };
|
|
30
32
|
return undefined;
|
|
31
33
|
}
|
|
@@ -49,9 +51,10 @@ export function admitPreview(deps, input) {
|
|
|
49
51
|
const view = getRevisionView(deps, preview.projectId, preview.baseRevisionId);
|
|
50
52
|
if (view === undefined)
|
|
51
53
|
return { ok: false, reason: "no-project" };
|
|
52
|
-
const fresh = planPreview(deps, view,
|
|
54
|
+
const fresh = planPreview(deps, view, input.planningCatalogue, preview.selection, preview.id);
|
|
53
55
|
if (!fresh.ok || !reviewStillCovers(preview, snapshot, fresh.value))
|
|
54
56
|
return { ok: false, reason: "stale-preview" };
|
|
57
|
+
const catalogue = executionCatalogue(input.planningCatalogue, view.revision.config);
|
|
55
58
|
const admissionId = deps.ids.next();
|
|
56
59
|
const workIds = [];
|
|
57
60
|
for (const recipe of snapshot.recipes) {
|
|
@@ -97,7 +100,7 @@ export function admitPreview(deps, input) {
|
|
|
97
100
|
const fingerprint = snapshot.anchors[key];
|
|
98
101
|
if (fingerprint === undefined)
|
|
99
102
|
throw new Error("Selected work has no reviewed desired anchor.");
|
|
100
|
-
const work = insertInvocation(deps, view, recipe,
|
|
103
|
+
const work = insertInvocation(deps, view, recipe, catalogue, { key, fingerprint }, false, view.revision.id, admissionId);
|
|
101
104
|
workIds.push(work.workId);
|
|
102
105
|
}
|
|
103
106
|
const receipt = {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { usesNarrationPreparation } from "../admission/rules.js";
|
|
1
|
+
import { usesNarrationPreparation, usesPronunciationGlossary } from "../admission/rules.js";
|
|
2
2
|
import { narrationRegenerationToken, normalizeNarrationText, planNarration, } from "../narration/plan.js";
|
|
3
|
+
import { pronunciationSpans } from "../narration/pronunciation.js";
|
|
4
|
+
import { prepareRequests } from "../narration/steering.js";
|
|
3
5
|
import { recipe } from "./recipe-model.js";
|
|
4
6
|
import { preparationForGroup } from "./recipe-preparation.js";
|
|
5
|
-
export function narrationParts(context, logicalKey, originalText, segment, dependsOn, preparations) {
|
|
7
|
+
export function narrationParts(context, logicalKey, originalText, segment, dependsOn, preparations, glossary) {
|
|
6
8
|
const override = context.content.narrationOverrides[logicalKey];
|
|
7
9
|
if (override?.kind === "asset")
|
|
8
10
|
return [
|
|
@@ -13,25 +15,26 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
|
|
|
13
15
|
semantic: [normalizeNarrationText(originalText), voiceValues(context)],
|
|
14
16
|
}, dependsOn, { tokenKey: logicalKey }),
|
|
15
17
|
];
|
|
18
|
+
if (glossary === null)
|
|
19
|
+
return [];
|
|
20
|
+
if (!glossary.ok)
|
|
21
|
+
return [refusedPart(context, logicalKey, dependsOn, glossary.reason)];
|
|
16
22
|
const logicalText = normalizeNarrationText(override?.kind === "text" ? override.text : originalText);
|
|
23
|
+
const spans = pronunciationSpans(logicalText, glossary.entries);
|
|
17
24
|
const wholeRequest = segment !== "body" || (context.config.chunking?.mode ?? "whole") === "whole";
|
|
18
25
|
const choice = context.config.audio;
|
|
19
26
|
const model = context.catalogue?.tts.find((row) => row.provider === choice?.provider &&
|
|
20
27
|
row.id === choice.model &&
|
|
21
28
|
row.enabled &&
|
|
22
29
|
!row.deprecated);
|
|
30
|
+
const logicalLimit = Math.max(2, logicalText.length +
|
|
31
|
+
spans.reduce((sum, span) => sum + Math.max(0, span.text.length - (span.end - span.start)), 0));
|
|
32
|
+
const maxCharacters = model?.tts.maxCharacters ?? logicalLimit;
|
|
23
33
|
if (usesNarrationPreparation(context.config)) {
|
|
24
|
-
const prepared = preparationForGroup(context, logicalKey, logicalText, segment, dependsOn,
|
|
34
|
+
const prepared = preparationForGroup(context, logicalKey, logicalText, segment, dependsOn, maxCharacters, spans);
|
|
25
35
|
preparations.push(prepared.preparation);
|
|
26
36
|
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
|
-
];
|
|
37
|
+
return [refusedPart(context, logicalKey, [prepared.preparation.key], prepared.refusal)];
|
|
35
38
|
return (prepared.requests ?? []).map(({ text, spokenText }, index) => recipe(context, `${logicalKey}:${index + 1}`, "audio", {
|
|
36
39
|
kind: "tts",
|
|
37
40
|
version: 1,
|
|
@@ -47,6 +50,26 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
|
|
|
47
50
|
wholeRequest,
|
|
48
51
|
}, [prepared.preparation.key], { tokenKey: logicalKey, unresolved: logicalText.length === 0 }));
|
|
49
52
|
}
|
|
53
|
+
if (spans.length > 0) {
|
|
54
|
+
const prepared = prepareRequests(logicalText, [], maxCharacters, spans);
|
|
55
|
+
if (!prepared.ok)
|
|
56
|
+
return [refusedPart(context, logicalKey, dependsOn, prepared.reason)];
|
|
57
|
+
return prepared.requests.map(({ text, spokenText }, index) => recipe(context, `${logicalKey}:${index + 1}`, "audio", {
|
|
58
|
+
kind: "tts",
|
|
59
|
+
version: 1,
|
|
60
|
+
provider: choice?.provider ?? "",
|
|
61
|
+
model: choice?.model ?? "",
|
|
62
|
+
voice: choice?.voice ?? "",
|
|
63
|
+
text,
|
|
64
|
+
spokenText,
|
|
65
|
+
logicalKey,
|
|
66
|
+
logicalText,
|
|
67
|
+
segment,
|
|
68
|
+
pronunciation: null,
|
|
69
|
+
wholeRequest,
|
|
70
|
+
}, dependsOn, { tokenKey: logicalKey, unresolved: logicalText.length === 0 }));
|
|
71
|
+
}
|
|
72
|
+
// Preserve the pre-feature splitter, shape and identities when no term matches.
|
|
50
73
|
const requests = planNarration({
|
|
51
74
|
groups: [
|
|
52
75
|
{
|
|
@@ -60,9 +83,7 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
|
|
|
60
83
|
provider: choice?.provider ?? "",
|
|
61
84
|
model: choice?.model ?? "",
|
|
62
85
|
voice: choice?.voice ?? "",
|
|
63
|
-
maxCharacters
|
|
64
|
-
? Math.max(2, logicalText.length)
|
|
65
|
-
: (model?.tts.maxCharacters ?? Math.max(2, logicalText.length)),
|
|
86
|
+
maxCharacters,
|
|
66
87
|
retained: [],
|
|
67
88
|
});
|
|
68
89
|
return requests.map(({ text, key }) => recipe(context, key, "audio", {
|
|
@@ -79,6 +100,41 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
|
|
|
79
100
|
wholeRequest,
|
|
80
101
|
}, dependsOn, { tokenKey: logicalKey, unresolved: logicalText.length === 0 }));
|
|
81
102
|
}
|
|
103
|
+
function refusedPart(context, logicalKey, dependsOn, reason) {
|
|
104
|
+
return recipe(context, `${logicalKey}:1`, "audio", { kind: "deferred", version: 1, operation: "resolve-revision-recipe", template: reason }, dependsOn, { unresolved: true, refusal: reason });
|
|
105
|
+
}
|
|
106
|
+
export function pronunciationFutureValues(context, glossary, article, logicalKey, source) {
|
|
107
|
+
if (!usesPronunciationGlossary(context.config))
|
|
108
|
+
return [];
|
|
109
|
+
const override = context.content.narrationOverrides[logicalKey];
|
|
110
|
+
if (override?.kind === "asset")
|
|
111
|
+
return [];
|
|
112
|
+
if (glossary === null)
|
|
113
|
+
return [["pronunciation-glossary-v1", "pending-article", article.fingerprint]];
|
|
114
|
+
if (!glossary.ok)
|
|
115
|
+
return [["pronunciation-glossary-v1", "invalid", glossary.reason]];
|
|
116
|
+
// Unknown generated entry text may match any supplied term.
|
|
117
|
+
if (source === null)
|
|
118
|
+
return glossary.entries.length === 0
|
|
119
|
+
? []
|
|
120
|
+
: [
|
|
121
|
+
[
|
|
122
|
+
"pronunciation-glossary-v1",
|
|
123
|
+
glossary.entries.map((entry) => [entry.term, [...entry.ipa]]),
|
|
124
|
+
],
|
|
125
|
+
];
|
|
126
|
+
const clean = normalizeNarrationText(override?.kind === "text" ? override.text : source);
|
|
127
|
+
const spans = pronunciationSpans(clean, glossary.entries);
|
|
128
|
+
return spans.length === 0
|
|
129
|
+
? []
|
|
130
|
+
: [
|
|
131
|
+
[
|
|
132
|
+
"pronunciation-glossary-v1",
|
|
133
|
+
logicalKey,
|
|
134
|
+
spans.map((span) => [span.start, span.end, span.text]),
|
|
135
|
+
],
|
|
136
|
+
];
|
|
137
|
+
}
|
|
82
138
|
export function voiceValues(context) {
|
|
83
139
|
return [
|
|
84
140
|
context.config.audio?.provider ?? null,
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
import { fingerprint } from "../../kernel/runner/work.js";
|
|
2
|
-
import { usesNarrationPreparation } from "../admission/rules.js";
|
|
3
|
-
import {
|
|
2
|
+
import { usesNarrationPreparation, usesPronunciationGlossary } from "../admission/rules.js";
|
|
3
|
+
import { defaultChunking } from "../narration/chunk.js";
|
|
4
4
|
import { concatArgs } from "../narration/concat.js";
|
|
5
5
|
import { normalizeNarrationText } from "../narration/plan.js";
|
|
6
|
-
import {
|
|
6
|
+
import { pronunciationChunks } from "../narration/pronunciation-chunks.js";
|
|
7
|
+
import { narrationParts, pronunciationFutureValues, voiceValues } from "./recipe-audio-parts.js";
|
|
7
8
|
import { recipe, resourceIdentity, selectedReference, } from "./recipe-model.js";
|
|
8
9
|
import { narrationFileRecipe } from "./recipe-narration-text.js";
|
|
9
10
|
import { preparationFuture, preparationTemplate } from "./recipe-preparation.js";
|
|
11
|
+
export function bodyNarrationGroups(context, text) {
|
|
12
|
+
return text.articleText === null
|
|
13
|
+
? []
|
|
14
|
+
: pronunciationChunks(normalizeNarrationText(text.articleText), context.config.chunking ?? defaultChunking, usesPronunciationGlossary(context.config) && text.glossary?.ok ? text.glossary.entries : [], new Set(Object.keys(context.content.narrationOverrides)), context.content.narrationSources);
|
|
15
|
+
}
|
|
10
16
|
export function audioRecipes(context, text) {
|
|
11
17
|
const { config, content } = context;
|
|
12
18
|
const recipes = [];
|
|
13
19
|
if (config.sources.audio === "off")
|
|
14
20
|
return { recipes, mediaFingerprint: null, timeline: [], keys: [] };
|
|
21
|
+
const prepare = usesNarrationPreparation(config);
|
|
22
|
+
const pronounce = usesPronunciationGlossary(config);
|
|
23
|
+
const narrationFiles = prepare || pronounce;
|
|
15
24
|
let body;
|
|
25
|
+
let bodyTranscript = text.articleText ?? text.article.fingerprint;
|
|
16
26
|
if (config.sources.audio === "provide") {
|
|
17
27
|
body = recipe(context, "audio:provided", "audio", {
|
|
18
28
|
kind: "provided",
|
|
@@ -23,26 +33,30 @@ export function audioRecipes(context, text) {
|
|
|
23
33
|
recipes.push(body);
|
|
24
34
|
}
|
|
25
35
|
else {
|
|
26
|
-
const groups = text
|
|
27
|
-
? []
|
|
28
|
-
: chunkNarration(normalizeNarrationText(text.articleText), config.chunking ?? defaultChunking);
|
|
29
|
-
const occurrences = new Map();
|
|
36
|
+
const groups = bodyNarrationGroups(context, text);
|
|
30
37
|
const parts = [];
|
|
38
|
+
const transcripts = [];
|
|
39
|
+
const futurePronunciation = [];
|
|
31
40
|
let pending = text.articleText === null;
|
|
32
|
-
for (const logicalText of groups) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
for (const { key: logicalKey, text: logicalText } of groups) {
|
|
42
|
+
transcripts.push({
|
|
43
|
+
original: logicalText,
|
|
44
|
+
effective: effectiveText(context, logicalKey, logicalText),
|
|
45
|
+
});
|
|
46
|
+
futurePronunciation.push(...pronunciationFutureValues(context, text.glossary, text.article, logicalKey, logicalText));
|
|
47
|
+
const group = narrationParts(context, logicalKey, logicalText, "body", [text.article.key], recipes, text.glossary);
|
|
38
48
|
if (group.length === 0)
|
|
39
49
|
pending = true;
|
|
40
50
|
parts.push(...group);
|
|
41
51
|
}
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
if (transcripts.some((row) => row.effective !== row.original))
|
|
53
|
+
bodyTranscript = ["narration-transcript-v1", transcripts.map((row) => row.effective)];
|
|
54
|
+
if (text.articleText === null) {
|
|
55
|
+
futurePronunciation.push(...pronunciationFutureValues(context, text.glossary, text.article, "audio:body:future", null));
|
|
56
|
+
if (prepare)
|
|
57
|
+
recipes.push(preparationFuture(context, "body", text.article));
|
|
58
|
+
}
|
|
59
|
+
if (pending && prepare)
|
|
46
60
|
parts.length = 0;
|
|
47
61
|
if (pending)
|
|
48
62
|
parts.push(recipe(context, "audio:body:future", "audio", {
|
|
@@ -53,11 +67,12 @@ export function audioRecipes(context, text) {
|
|
|
53
67
|
text.article.fingerprint,
|
|
54
68
|
voiceValues(context),
|
|
55
69
|
chunkingValues(context),
|
|
56
|
-
...(
|
|
70
|
+
...(prepare ? [preparationTemplate(context)] : []),
|
|
71
|
+
...futurePronunciation,
|
|
57
72
|
],
|
|
58
73
|
}, [text.article.key, ...preparationKeys(recipes, "body")]));
|
|
59
74
|
recipes.push(...parts);
|
|
60
|
-
if (
|
|
75
|
+
if (narrationFiles)
|
|
61
76
|
recipes.push(narrationFileRecipe(context, "body", parts));
|
|
62
77
|
body = recipe(context, "audio:body:concat", "audio", {
|
|
63
78
|
kind: "local",
|
|
@@ -73,29 +88,25 @@ export function audioRecipes(context, text) {
|
|
|
73
88
|
const ordered = [];
|
|
74
89
|
for (const category of ["intro", "body", "outro"]) {
|
|
75
90
|
if (category === "body") {
|
|
76
|
-
ordered.push({ value: body, transcript:
|
|
91
|
+
ordered.push({ value: body, transcript: bodyTranscript });
|
|
77
92
|
continue;
|
|
78
93
|
}
|
|
79
94
|
const entry = text.entries[category];
|
|
80
95
|
if (entry === undefined)
|
|
81
96
|
continue;
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
const logicalKey = `audio:${category}`;
|
|
98
|
+
const supplied = content.narrationOverrides[logicalKey]?.kind === "asset";
|
|
99
|
+
const dependencies = [entry.recipe.key, ...(pronounce && !supplied ? [text.article.key] : [])];
|
|
100
|
+
const invalidGlossary = !supplied && text.glossary !== null && !text.glossary.ok ? text.glossary.reason : undefined;
|
|
101
|
+
if (prepare &&
|
|
102
|
+
!supplied &&
|
|
103
|
+
invalidGlossary === undefined &&
|
|
104
|
+
(entry.text === null || text.glossary === null))
|
|
105
|
+
recipes.push(preparationFuture(context, category, entry.recipe, pronounce ? [text.article] : []));
|
|
84
106
|
const parts = entry.text === null
|
|
85
|
-
? [
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
version: 1,
|
|
89
|
-
operation: `${category}-narration`,
|
|
90
|
-
template: [
|
|
91
|
-
entry.recipe.fingerprint,
|
|
92
|
-
voiceValues(context),
|
|
93
|
-
...(usesNarrationPreparation(config) ? [preparationTemplate(context)] : []),
|
|
94
|
-
],
|
|
95
|
-
}, [entry.recipe.key, ...preparationKeys(recipes, category)]),
|
|
96
|
-
]
|
|
97
|
-
: narrationParts(context, `audio:${category}`, entry.text, category, [entry.recipe.key], recipes);
|
|
98
|
-
if (parts.length === 0 && usesNarrationPreparation(config))
|
|
107
|
+
? []
|
|
108
|
+
: narrationParts(context, logicalKey, entry.text, category, dependencies, recipes, text.glossary);
|
|
109
|
+
if (parts.length === 0)
|
|
99
110
|
parts.push(recipe(context, `audio:${category}:future`, "audio", {
|
|
100
111
|
kind: "deferred",
|
|
101
112
|
version: 1,
|
|
@@ -103,9 +114,10 @@ export function audioRecipes(context, text) {
|
|
|
103
114
|
template: [
|
|
104
115
|
entry.recipe.fingerprint,
|
|
105
116
|
voiceValues(context),
|
|
106
|
-
preparationTemplate(context),
|
|
117
|
+
...(prepare ? [preparationTemplate(context)] : []),
|
|
118
|
+
...pronunciationFutureValues(context, text.glossary, text.article, logicalKey, entry.text),
|
|
107
119
|
],
|
|
108
|
-
}, [
|
|
120
|
+
}, [...dependencies, ...preparationKeys(recipes, category)], invalidGlossary === undefined ? {} : { unresolved: true, refusal: invalidGlossary }));
|
|
109
121
|
recipes.push(...parts);
|
|
110
122
|
const audio = recipe(context, `audio:${category}`, "audio", {
|
|
111
123
|
kind: "local",
|
|
@@ -117,9 +129,14 @@ export function audioRecipes(context, text) {
|
|
|
117
129
|
],
|
|
118
130
|
}, parts.map((part) => part.key));
|
|
119
131
|
recipes.push(audio);
|
|
120
|
-
if (
|
|
132
|
+
if (narrationFiles)
|
|
121
133
|
recipes.push(narrationFileRecipe(context, category, parts));
|
|
122
|
-
ordered.push({
|
|
134
|
+
ordered.push({
|
|
135
|
+
value: audio,
|
|
136
|
+
transcript: entry.text === null
|
|
137
|
+
? entry.recipe.fingerprint
|
|
138
|
+
: effectiveText(context, logicalKey, entry.text),
|
|
139
|
+
});
|
|
123
140
|
}
|
|
124
141
|
const timeline = ordered.map(({ value, transcript }) => {
|
|
125
142
|
const retained = context.manifest.outputs.find((row) => row.workKey === value.key && row.state === "ready" && selectedReference(row));
|
|
@@ -140,6 +157,10 @@ export function audioRecipes(context, text) {
|
|
|
140
157
|
keys: ordered.map(({ value }) => value.key),
|
|
141
158
|
};
|
|
142
159
|
}
|
|
160
|
+
function effectiveText(context, key, original) {
|
|
161
|
+
const override = context.content.narrationOverrides[key];
|
|
162
|
+
return override?.kind === "text" ? normalizeNarrationText(override.text) : original;
|
|
163
|
+
}
|
|
143
164
|
function chunkingValues(context) {
|
|
144
165
|
const chunking = context.config.chunking ?? defaultChunking;
|
|
145
166
|
return [
|