@gentbajko/slopify 3.20.0 → 3.22.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/SUBTITLES.md +10 -2
- package/dist/adapters/alignment/cache.js +36 -9
- package/dist/adapters/alignment/index.js +16 -4
- package/dist/adapters/alignment/prefetch.js +11 -6
- package/dist/edge/http/settings.js +6 -1
- package/dist/edge/mcp/tools-setup.js +2 -2
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/ports/subtitles.js +6 -0
- package/dist/main.js +6 -2
- package/dist/patch-notes/3.21.0.md +11 -0
- package/dist/patch-notes/3.22.0.md +12 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/admission/start.js +2 -0
- package/dist/slices/narration/pronunciation-chunks.js +2 -2
- package/dist/slices/narration/pronunciation.js +95 -25
- package/dist/slices/rebuild/recipe-audio-parts.js +3 -3
- package/dist/slices/rebuild/recipe-audio.js +2 -1
- package/dist/slices/rebuild/recipe-text.js +2 -2
- package/dist/slices/rebuild/recipe-voices.js +3 -3
- package/dist/slices/rebuild/recipe-work.js +4 -4
- package/dist/slices/settings/model.js +2 -0
- package/dist/slices/settings/playback.js +11 -0
- package/dist/tutorials/FAQ.md +1 -1
- package/dist/tutorials/Install.md +3 -3
- package/dist/tutorials/Other-Languages.md +1 -1
- package/dist/tutorials/Play-Video-and-Style.md +1 -1
- package/dist/tutorials/Settings-Reference.md +9 -1
- package/dist/tutorials/Troubleshooting.md +1 -1
- package/dist/tutorials/Video-Editing.md +1 -1
- package/dist/web/assets/{index-BKbZIxAq.css → index-BadsoORk.css} +1 -1
- package/dist/web/assets/index-X6JyTe2r.js +326 -0
- package/dist/web/assets/{pdf-tyJWWiKA.js → pdf-B00spAHg.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DNPct8pl.js +0 -326
package/SUBTITLES.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
1. In Play, enable narration or provide matching English audio and article text.
|
|
4
4
|
2. In Subtitles, choose **Subtitle files (.srt + .vtt)** or **Burn into video + files**.
|
|
5
5
|
3. For burned captions, select a bundled or system font, or upload a `.ttf` or `.otf` file (up to 32 MiB). Choose a size from 16 to 120 and a position: top, upper-middle, center, lower-middle or bottom. The side preview shows the selected font, size and position in a 16:9 or 9:16 frame.
|
|
6
|
-
4. Start the run. Slopify times the article against the actual narration on your computer. The first use downloads
|
|
6
|
+
4. Start the run. Slopify times the article against the actual narration on your computer. The first use downloads the English speech model chosen in Settings → Production defaults → **Subtitle timing model** (378 MB unless changed); later runs work offline with the cached model. Allow roughly 1.2 GB of available memory during alignment (0.7 GB with Smallest). No extra API key, Python, or compiler is needed.
|
|
7
7
|
5. Download SRT/VTT beside the final export. Files mode adds an optional native caption track to the in-app video preview. Burned captions remain visible in the downloaded MP4.
|
|
8
8
|
|
|
9
9
|
For a completed project, open its final Video or Audio export section, set subtitles, and click **Save subtitles**. This rebuilds only the local export from saved narration and images. Changing font, size or position reuses word timing when the audio and spoken text are unchanged. A paused run saves these choices until Resume; pause an active run before editing. Failed alignment or rendering keeps the previous finished export.
|
|
@@ -18,6 +18,14 @@ Fonts are copied into the completed project's caption assets, so an existing exp
|
|
|
18
18
|
|
|
19
19
|
Speech inference uses MIT-licensed `onnxruntime-node@1.30.0` (native CPU) in a separate process, with `min(8, CPUs − 1)` threads; set `SLOPIFY_SUBTITLE_THREADS` to override. The npm package bundles CPU builds for Linux x64/arm64, Windows x64/arm64 and Apple silicon macOS (about 220 MB unpacked); Intel Macs have no build, so subtitle timing falls back to the slower single-threaded `onnxruntime-web` there. Its postinstall script only fetches optional CUDA files on Linux x64 and is not needed (the Docker image installs with `--ignore-scripts`); model weights are downloaded lazily into `<data-dir>/models/english-subtitles/`.
|
|
20
20
|
|
|
21
|
-
Model: [Xenova/wav2vec2-base-960h](https://huggingface.co/Xenova/wav2vec2-base-960h/tree/a19f851b3d42865797e410752b4c570c871e4825), an ONNX conversion of [facebook/wav2vec2-base-960h](https://huggingface.co/facebook/wav2vec2-base-960h), licensed Apache-2.0. Pinned revision: `a19f851b3d42865797e410752b4c570c871e4825`.
|
|
21
|
+
Model: [Xenova/wav2vec2-base-960h](https://huggingface.co/Xenova/wav2vec2-base-960h/tree/a19f851b3d42865797e410752b4c570c871e4825), an ONNX conversion of [facebook/wav2vec2-base-960h](https://huggingface.co/facebook/wav2vec2-base-960h), licensed Apache-2.0. Pinned revision: `a19f851b3d42865797e410752b4c570c871e4825`. Three precisions of that revision can be chosen per computer, each verified before use and kept under its own file name so they can sit side by side:
|
|
22
|
+
|
|
23
|
+
| Setting | File | Bytes | SHA256 |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| Most accurate (default) | `onnx/model.onnx` (fp32) | 377,887,594 | `e46614273f03ff4b87923a965e417fa72004825522cb007c9c25633b8475490d` |
|
|
26
|
+
| Balanced | `onnx/model_fp16.onnx` | 189,192,204 | `378348ee38b739cc77e77e3fb8502f0f40ed53da0dbf7401b24c25c8fafe03de` |
|
|
27
|
+
| Smallest | `onnx/model_quantized.onnx` (int8) | 95,286,046 | `cd5040c147381580ed73258143dd8e0c28e800a09e74ee42ee2b3e8cb4d760a3` |
|
|
28
|
+
|
|
29
|
+
On a 2 h 28 min narration fp32 aligned in 2 min 40 s (1.1 GB peak) and fp16 in 3 min 16 s (1.2 GB), both hearing every line; int8 took 5 min 35 s (0.7 GB) and misheard two lines, stopping the run with a false "Subtitles stopped matching the audio".
|
|
22
30
|
|
|
23
31
|
The bundled Barlow font uses the SIL Open Font License; its license and source record ship in `dist/assets/fonts/`. Uploaded/system font licensing remains with its author.
|
|
@@ -5,13 +5,36 @@ import { join } from "node:path";
|
|
|
5
5
|
import { Readable } from "node:stream";
|
|
6
6
|
import { pipeline } from "node:stream/promises";
|
|
7
7
|
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
import { defaultEnglishSubtitleModel, } from "../../kernel/ports/subtitles.js";
|
|
9
|
+
// Xenova/wav2vec2-base-960h at one pinned revision, which carries the same model at three
|
|
10
|
+
// precisions. Each keeps its own file name so the cache can hold any of them at once; int8
|
|
11
|
+
// keeps the name every install used before the choice existed.
|
|
8
12
|
const revision = "a19f851b3d42865797e410752b4c570c871e4825";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
const repo = `https://huggingface.co/Xenova/wav2vec2-base-960h/resolve/${revision}/onnx`;
|
|
14
|
+
export const englishModels = {
|
|
15
|
+
fp32: {
|
|
16
|
+
filename: "wav2vec2-base-960h-fp32-e4661427.onnx",
|
|
17
|
+
url: `${repo}/model.onnx`,
|
|
18
|
+
bytes: 377_887_594,
|
|
19
|
+
sha256: "e46614273f03ff4b87923a965e417fa72004825522cb007c9c25633b8475490d",
|
|
20
|
+
timeoutMs: 1_800_000,
|
|
21
|
+
},
|
|
22
|
+
fp16: {
|
|
23
|
+
filename: "wav2vec2-base-960h-fp16-378348ee.onnx",
|
|
24
|
+
url: `${repo}/model_fp16.onnx`,
|
|
25
|
+
bytes: 189_192_204,
|
|
26
|
+
sha256: "378348ee38b739cc77e77e3fb8502f0f40ed53da0dbf7401b24c25c8fafe03de",
|
|
27
|
+
timeoutMs: 1_200_000,
|
|
28
|
+
},
|
|
29
|
+
int8: {
|
|
30
|
+
filename: "wav2vec2-base-960h-cd5040c1.onnx",
|
|
31
|
+
url: `${repo}/model_quantized.onnx`,
|
|
32
|
+
bytes: 95_286_046,
|
|
33
|
+
sha256: "cd5040c147381580ed73258143dd8e0c28e800a09e74ee42ee2b3e8cb4d760a3",
|
|
34
|
+
},
|
|
14
35
|
};
|
|
36
|
+
// The default English model, and the one the Docker image ships as its seed.
|
|
37
|
+
export const alignmentModel = englishModels[defaultEnglishSubtitleModel];
|
|
15
38
|
export async function prepareModel(input, deps = { model: alignmentModel, fetch: globalThis.fetch }) {
|
|
16
39
|
input.signal.throwIfAborted();
|
|
17
40
|
await mkdir(input.cacheDir, { recursive: true, mode: 0o700 });
|
|
@@ -38,7 +61,7 @@ export async function prepareModel(input, deps = { model: alignmentModel, fetch:
|
|
|
38
61
|
}
|
|
39
62
|
}
|
|
40
63
|
}
|
|
41
|
-
throw new Error(
|
|
64
|
+
throw new Error(`The free subtitle model download (${megabytes(deps.model.bytes)}) was interrupted after 3 attempts, so the captions could not be timed. Check your internet connection, then Continue the run.${deps.model === alignmentModel || deps.model === englishModels.fp16 ? smaller : ""}`, { cause: lastFailure });
|
|
42
65
|
}
|
|
43
66
|
async function downloadModel(input, deps, target) {
|
|
44
67
|
input.signal.throwIfAborted();
|
|
@@ -58,7 +81,7 @@ async function downloadModel(input, deps, target) {
|
|
|
58
81
|
throw interrupted(error);
|
|
59
82
|
}
|
|
60
83
|
if (!response.ok || response.body === null) {
|
|
61
|
-
const error = new Error(`The free subtitle model could not be downloaded (HTTP ${response.status}). Check your connection
|
|
84
|
+
const error = new Error(`The free subtitle model could not be downloaded (HTTP ${response.status}) from Hugging Face, so the captions could not be timed. Check your internet connection, then Continue the run.`);
|
|
62
85
|
let failure = error;
|
|
63
86
|
try {
|
|
64
87
|
if (response.body !== null)
|
|
@@ -91,7 +114,7 @@ async function downloadModel(input, deps, target) {
|
|
|
91
114
|
break;
|
|
92
115
|
current += result.value.length;
|
|
93
116
|
if (current > deps.model.bytes)
|
|
94
|
-
throw new Error("The subtitle model failed download size verification.");
|
|
117
|
+
throw new Error("The subtitle model failed download size verification: more arrived than the file Slopify expects, so it was thrown away. Continue the run to download it again.");
|
|
95
118
|
hash.update(result.value);
|
|
96
119
|
input.onProgress?.(current, deps.model.bytes);
|
|
97
120
|
yield result.value;
|
|
@@ -112,7 +135,7 @@ async function downloadModel(input, deps, target) {
|
|
|
112
135
|
});
|
|
113
136
|
input.signal.throwIfAborted();
|
|
114
137
|
if (current !== deps.model.bytes || hash.digest("hex") !== deps.model.sha256)
|
|
115
|
-
throw new Error("The subtitle model failed download verification.
|
|
138
|
+
throw new Error("The subtitle model failed download verification: the file that arrived is not the one Slopify expects, so it was thrown away. Continue the run to download it again.");
|
|
116
139
|
// Each caller writes in its own directory. On Windows a completed concurrent download
|
|
117
140
|
// may already own the final filename; reuse it only after the same verification.
|
|
118
141
|
try {
|
|
@@ -146,6 +169,10 @@ async function seedModel(input, seed, target) {
|
|
|
146
169
|
await rm(staging, { recursive: true, force: true });
|
|
147
170
|
}
|
|
148
171
|
}
|
|
172
|
+
const smaller = " To download less, pick a smaller model in Settings → Production defaults → Subtitle timing model.";
|
|
173
|
+
function megabytes(bytes) {
|
|
174
|
+
return `${String(Math.round(bytes / 1_000_000))} MB`;
|
|
175
|
+
}
|
|
149
176
|
function interrupted(cause) {
|
|
150
177
|
const error = new Error("The subtitle model download was interrupted.", { cause });
|
|
151
178
|
error.name = "SubtitleModelDownloadInterrupted";
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { languageInfo } from "../../kernel/ports/languages.js";
|
|
4
|
+
import { defaultEnglishSubtitleModel, } from "../../kernel/ports/subtitles.js";
|
|
4
5
|
import { decodeAudio } from "./audio.js";
|
|
5
|
-
import {
|
|
6
|
+
import { englishModels, prepareModel } from "./cache.js";
|
|
6
7
|
import { claimWorker } from "./lock.js";
|
|
7
8
|
import { alignmentSpecFor, multilingualModel } from "./multilingual.js";
|
|
8
9
|
import { runAlignmentWorker } from "./runner.js";
|
|
9
10
|
import { sentenceTiming, speechShape } from "./sentences.js";
|
|
10
|
-
|
|
11
|
+
// The model file that times a language: English with the model chosen in Settings, every other
|
|
12
|
+
// word-timed language with the multilingual one.
|
|
13
|
+
export function modelFor(language, english) {
|
|
14
|
+
return language === undefined || language === "en" ? englishModels[english] : multilingualModel;
|
|
15
|
+
}
|
|
16
|
+
// `chosen` is asked once per alignment, when it starts: an alignment already running keeps the
|
|
17
|
+
// model it opened, and the next one uses whatever Settings says then.
|
|
18
|
+
export function alignSubtitlesWith(chosen) {
|
|
19
|
+
return (request) => align(request, chosen);
|
|
20
|
+
}
|
|
21
|
+
export const alignSubtitles = alignSubtitlesWith(() => defaultEnglishSubtitleModel);
|
|
22
|
+
async function align(request, chosen) {
|
|
11
23
|
request.signal.throwIfAborted();
|
|
12
24
|
if (languageInfo(request.language).timing === "sentences")
|
|
13
25
|
return timeSentences(request);
|
|
@@ -20,7 +32,7 @@ export const alignSubtitles = async (request) => {
|
|
|
20
32
|
cacheDir: request.cacheDir,
|
|
21
33
|
signal: request.signal,
|
|
22
34
|
onProgress: (current, total) => request.onProgress?.(Math.round((current / total) * 20), 100),
|
|
23
|
-
}, { model:
|
|
35
|
+
}, { model: modelFor(request.language, chosen()), fetch: globalThis.fetch });
|
|
24
36
|
request.onProgress?.(20, 100);
|
|
25
37
|
const working = await mkdtemp(join(request.cacheDir, ".alignment-audio-"));
|
|
26
38
|
try {
|
|
@@ -42,7 +54,7 @@ export const alignSubtitles = async (request) => {
|
|
|
42
54
|
finally {
|
|
43
55
|
await release();
|
|
44
56
|
}
|
|
45
|
-
}
|
|
57
|
+
}
|
|
46
58
|
// No model and no download: the narration's loudness says where it pauses, and the sentences
|
|
47
59
|
// are laid over it (`sentences.ts`).
|
|
48
60
|
async function timeSentences(request) {
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { defaultEnglishSubtitleModel, } from "../../kernel/ports/subtitles.js";
|
|
4
|
+
import { englishModels, prepareModel } from "./cache.js";
|
|
4
5
|
import { claimWorker } from "./lock.js";
|
|
5
|
-
// Readies the subtitle model when the app starts, so the first captioned render
|
|
6
|
-
// on a
|
|
7
|
-
// starts meanwhile waits for this copy rather than fetching a second one.
|
|
8
|
-
export async function prefetchModel(input, deps) {
|
|
6
|
+
// Readies the chosen English subtitle model when the app starts, so the first captioned render
|
|
7
|
+
// never waits on a download of up to 378 MB in the middle of a run. It takes the worker lock,
|
|
8
|
+
// so a render that starts meanwhile waits for this copy rather than fetching a second one.
|
|
9
|
+
export async function prefetchModel(input, deps = {}) {
|
|
9
10
|
await mkdir(input.cacheDir, { recursive: true, mode: 0o700 });
|
|
10
11
|
await clearOwnLock(input.cacheDir);
|
|
11
12
|
const release = await claimWorker(input.cacheDir, input.signal);
|
|
12
13
|
try {
|
|
13
|
-
return await prepareModel({ cacheDir: input.cacheDir, seed: input.seed, signal: input.signal },
|
|
14
|
+
return await prepareModel({ cacheDir: input.cacheDir, seed: input.seed, signal: input.signal }, {
|
|
15
|
+
...deps,
|
|
16
|
+
model: deps.model ?? englishModels[input.choice ?? defaultEnglishSubtitleModel],
|
|
17
|
+
fetch: deps.fetch ?? globalThis.fetch,
|
|
18
|
+
});
|
|
14
19
|
}
|
|
15
20
|
finally {
|
|
16
21
|
await release();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { zValidator } from "@hono/zod-validator";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { englishSubtitleModels } from "../../kernel/ports/subtitles.js";
|
|
4
5
|
import { lastAgentConnection } from "../../slices/agents/connection.js";
|
|
5
6
|
import { recentAgentActions } from "../../slices/agents/repo.js";
|
|
6
7
|
import { notificationUrlMax, notificationUrlProblem, testNotice, webhookBody, } from "../../slices/notifications/address.js";
|
|
@@ -42,6 +43,8 @@ const playbackBody = z.object({
|
|
|
42
43
|
loudness: z
|
|
43
44
|
.object({ enabled: z.boolean(), videoLufs: z.number(), audioFilesLufs: z.number() })
|
|
44
45
|
.optional(),
|
|
46
|
+
// Absent keeps what is saved, as with loudness.
|
|
47
|
+
subtitleModel: z.enum(englishSubtitleModels).optional(),
|
|
45
48
|
});
|
|
46
49
|
// The return type is inferred so Hono keeps the route types the SPA's client is
|
|
47
50
|
// generated from; see stagingRoutes.
|
|
@@ -53,9 +56,11 @@ export function settingsRoutes(deps) {
|
|
|
53
56
|
.get("/", (c) => c.json(readSettings(playback)))
|
|
54
57
|
.put("/", zValidator("json", playbackBody, onInvalid), (c) => {
|
|
55
58
|
const body = c.req.valid("json");
|
|
59
|
+
const saved = readSettings(playback);
|
|
56
60
|
const result = saveSettings(playback, {
|
|
57
61
|
...body,
|
|
58
|
-
loudness: body.loudness ??
|
|
62
|
+
loudness: body.loudness ?? saved.loudness,
|
|
63
|
+
subtitleModel: body.subtitleModel ?? saved.subtitleModel,
|
|
59
64
|
});
|
|
60
65
|
if (!result.ok) {
|
|
61
66
|
return problem(c, {
|
|
@@ -52,13 +52,13 @@ const settings = [
|
|
|
52
52
|
name: "get_settings",
|
|
53
53
|
route: "GET /api/settings",
|
|
54
54
|
title: "Read production defaults",
|
|
55
|
-
description: "Settings → Production defaults: silence gap, appearance and
|
|
55
|
+
description: "Settings → Production defaults: silence gap, appearance, loudness and the English subtitle timing model.",
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
name: "update_settings",
|
|
59
59
|
route: "PUT /api/settings",
|
|
60
60
|
title: "Save production defaults",
|
|
61
|
-
description: "Body: {silenceGapSeconds, appearance: system | light | dark, loudness?: {enabled, videoLufs, audioFilesLufs}}.",
|
|
61
|
+
description: "Body: {silenceGapSeconds, appearance: system | light | dark, loudness?: {enabled, videoLufs, audioFilesLufs}, subtitleModel?: fp32 | fp16 | int8}. subtitleModel is this computer's English caption-timing model (fp32 378 MB most accurate, the default; fp16 189 MB; int8 95 MB, less accurate); it never marks subtitles outdated.",
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
name: "get_default_channel_links",
|
|
Binary file
|
|
Binary file
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// The English timing model, chosen per computer in Settings → Production defaults: the same
|
|
2
|
+
// pinned wav2vec2 conversion at three precisions. Bigger hears more accurately and, on most
|
|
3
|
+
// computers, also runs faster. The choice never enters a fingerprint: every precision times the
|
|
4
|
+
// same words, so switching must not mark finished subtitles outdated.
|
|
5
|
+
export const englishSubtitleModels = ["fp32", "fp16", "int8"];
|
|
6
|
+
export const defaultEnglishSubtitleModel = "fp32";
|
|
1
7
|
// Where the audio stopped matching its transcript: seconds into the aligned file, the
|
|
2
8
|
// transcript words it expected there and what the model heard instead (either may be empty).
|
|
3
9
|
// The aligner throws it so the caller can say which part of the narration to redo.
|
package/dist/main.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { serve } from "@hono/node-server";
|
|
6
6
|
import ffmpegStatic from "ffmpeg-static";
|
|
7
7
|
import { buildRegistry } from "./adapter-registry.js";
|
|
8
|
-
import {
|
|
8
|
+
import { alignSubtitlesWith } from "./adapters/alignment/index.js";
|
|
9
9
|
import { prefetchModel } from "./adapters/alignment/prefetch.js";
|
|
10
10
|
import { prepareFfmpeg } from "./adapters/ffmpeg.js";
|
|
11
11
|
import { createHostCliClient } from "./adapters/host-cli/index.js";
|
|
@@ -72,6 +72,7 @@ import { settleTerminalScheduleRuns } from "./slices/schedules/repo.js";
|
|
|
72
72
|
import { createScheduleRunner } from "./slices/schedules/scheduler.js";
|
|
73
73
|
import { nodeCliProbe } from "./slices/settings/cli-status.js";
|
|
74
74
|
import { isLocalCliProvider, localCliConcurrency } from "./slices/settings/model.js";
|
|
75
|
+
import { readSettings } from "./slices/settings/playback.js";
|
|
75
76
|
import { providerStatuses } from "./slices/settings/readiness.js";
|
|
76
77
|
import { readSetting } from "./slices/settings/repo.js";
|
|
77
78
|
import { arrangeChanged } from "./slices/storage/arrange.js";
|
|
@@ -692,6 +693,7 @@ export async function boot(config, options = {}) {
|
|
|
692
693
|
? prefetchModel({
|
|
693
694
|
cacheDir: subtitleModelDir(paths.dataDir),
|
|
694
695
|
seed: options.subtitleModelSeed,
|
|
696
|
+
choice: readSettings({ db, log }).subtitleModel,
|
|
695
697
|
signal: modelPrefetch.signal,
|
|
696
698
|
}).then(() => log.write("info", "subtitle-model.ready"), (error) => {
|
|
697
699
|
// Not fatal: the first captioned render tries again and says why on its row.
|
|
@@ -762,7 +764,9 @@ export async function boot(config, options = {}) {
|
|
|
762
764
|
throw error;
|
|
763
765
|
}
|
|
764
766
|
}
|
|
765
|
-
export function wireRunner({ onFinished, audioPreviews, db, paths, clock, ids, log, hub, telemetry, flusher, registry, catalogue, ffmpeg,
|
|
767
|
+
export function wireRunner({ onFinished, audioPreviews, db, paths, clock, ids, log, hub, telemetry, flusher, registry, catalogue, ffmpeg,
|
|
768
|
+
// Settings → Production defaults → Subtitle timing model, read as each alignment starts.
|
|
769
|
+
alignSubtitles: aligner = alignSubtitlesWith(() => readSettings({ db, log }).subtitleModel), }) {
|
|
766
770
|
// A stage counts what it did and the queue is flushed after each new event. `record`
|
|
767
771
|
// swallows its own failures, so this can neither fail a stage nor widen what leaves the
|
|
768
772
|
// machine - the payload allow-list is checked inside it.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Slopify 3.21.0
|
|
2
|
+
|
|
3
|
+
Released 13 October 2026.
|
|
4
|
+
|
|
5
|
+
- **Captions are timed with a more accurate speech model by default.** The old 95 MB model sometimes misheard a line of narration, stopped the run with "Subtitles stopped matching the audio" and recorded a good narration chunk again at a cost. The full-precision version of the same model hears those lines correctly. On a 2½-hour narration it was also faster: 2 min 40 s instead of 5 min 35 s.
|
|
6
|
+
- **Choose the subtitle timing model** in Settings → Production defaults: Most accurate (378 MB, the default), Balanced (189 MB) or Smallest (95 MB, less accurate). The choice belongs to this computer and downloads once, the first time captions are timed.
|
|
7
|
+
|
|
8
|
+
## Upgrading from 3.20.0
|
|
9
|
+
|
|
10
|
+
- The first video you make downloads the 378 MB model once. To keep the smaller model, choose Smallest in Settings → Production defaults.
|
|
11
|
+
- Nothing becomes outdated: the model choice doesn't change any word timings already made.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Slopify 3.22.0
|
|
2
|
+
|
|
3
|
+
Released 13 October 2026.
|
|
4
|
+
|
|
5
|
+
- **Names keep their pronunciation in possessives.** "Gygax's", "Lolth's" and every other possessive of a name in the Pronunciation Glossary were read without the glossary's pronunciation. They now get it, with the right ending sound added.
|
|
6
|
+
- **Hyphenated names work in the glossary.** Names like "Arach-Tinilith" or "Catti-brie" with one pronunciation per part were refused; they are now used.
|
|
7
|
+
- **Blank glossary entries are no longer reported as errors.** When the article leaves a name's pronunciation blank ("—"), the Pronunciation tab now says how many names are read as written, instead of listing them as broken entries.
|
|
8
|
+
- **A project's page no longer scrolls into an empty screen.** On wide screens, scrolling outside the columns of a project (most of all on Video) could slide the whole page up past its content.
|
|
9
|
+
|
|
10
|
+
## Upgrading from 3.21.0
|
|
11
|
+
|
|
12
|
+
- The new glossary rules apply to projects made from now on. Projects already made keep the old rules, so nothing already voiced becomes outdated.
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "3.22.0",
|
|
4
|
+
"title": "Slopify 3.22.0",
|
|
5
|
+
"version": "3.22.0",
|
|
6
|
+
"date": "2026-10-13"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "3.21.0",
|
|
10
|
+
"title": "Slopify 3.21.0",
|
|
11
|
+
"version": "3.21.0",
|
|
12
|
+
"date": "2026-10-12"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"id": "3.20.0",
|
|
4
16
|
"title": "Slopify 3.20.0",
|
|
@@ -70,6 +70,7 @@ export const runDraftSchema = z.object({
|
|
|
70
70
|
})
|
|
71
71
|
.optional(),
|
|
72
72
|
narrationAliases: narrationAliasesSchema.optional(),
|
|
73
|
+
pronunciationRules: z.union([z.literal(1), z.literal(2)]).optional(),
|
|
73
74
|
sharedGlossary: z
|
|
74
75
|
.array(z.object({
|
|
75
76
|
term: z.string().min(1).max(200),
|
|
@@ -5,6 +5,7 @@ import { resolveChannelId, setProjectChannel } from "../channels/repo.js";
|
|
|
5
5
|
import { defaultDocumentTheme } from "../document/model.js";
|
|
6
6
|
import { earlierEpisodesFor } from "../episodes/related.js";
|
|
7
7
|
import { listNarrationAliases } from "../narration/aliases-library.js";
|
|
8
|
+
import { currentPronunciationRules } from "../narration/pronunciation.js";
|
|
8
9
|
import { collectSharedGlossary } from "../narration/shared-glossary.js";
|
|
9
10
|
import { admitInitialRevision } from "../rebuild/runtime-admission.js";
|
|
10
11
|
import { adoptBaseline } from "../revisions/adopt.js";
|
|
@@ -60,6 +61,7 @@ export function startRun(deps, draft, rendered, retainStaged = false, templates)
|
|
|
60
61
|
rendered,
|
|
61
62
|
...(shared.length === 0 ? {} : { sharedGlossary: shared }),
|
|
62
63
|
...(aliases.length === 0 ? {} : { narrationAliases: aliases }),
|
|
64
|
+
pronunciationRules: currentPronunciationRules,
|
|
63
65
|
...(earlier === undefined ? {} : { earlierEpisodes: earlier }),
|
|
64
66
|
};
|
|
65
67
|
const project = {
|
|
@@ -15,7 +15,7 @@ export function chunkingFingerprintOf(chunking) {
|
|
|
15
15
|
export function narrationChunkTexts(source, chunking, anchors) {
|
|
16
16
|
return anchoredChunks(source, chunking, anchors?.chunkingFingerprint === chunkingFingerprintOf(chunking) ? anchors.texts : undefined);
|
|
17
17
|
}
|
|
18
|
-
export function pronunciationChunks(source, chunking, entries, overriddenKeys = new Set(), sources = {}, anchors) {
|
|
18
|
+
export function pronunciationChunks(source, chunking, entries, overriddenKeys = new Set(), sources = {}, anchors, possessives = false) {
|
|
19
19
|
const occurrences = new Map();
|
|
20
20
|
const usedKeys = new Set();
|
|
21
21
|
const nextKey = (text) => {
|
|
@@ -67,7 +67,7 @@ export function pronunciationChunks(source, chunking, entries, overriddenKeys =
|
|
|
67
67
|
})),
|
|
68
68
|
].sort((a, b) => a.start - b.start);
|
|
69
69
|
let blockedIndex = 0;
|
|
70
|
-
const matches = pronunciationMatches(source, entries).filter((match) => {
|
|
70
|
+
const matches = pronunciationMatches(source, entries, possessives).filter((match) => {
|
|
71
71
|
while ((blocked[blockedIndex]?.end ?? Infinity) <= match.start)
|
|
72
72
|
blockedIndex++;
|
|
73
73
|
return (blocked[blockedIndex]?.start ?? Infinity) >= match.end;
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { remark } from "remark";
|
|
2
2
|
import remarkGfm from "remark-gfm";
|
|
3
3
|
import { remembered } from "../../kernel/remembered.js";
|
|
4
|
+
export const currentPronunciationRules = 2;
|
|
5
|
+
export function pronunciationRulesOf(config) {
|
|
6
|
+
return config.pronunciationRules ?? 1;
|
|
7
|
+
}
|
|
8
|
+
// "'s" after a name is English; another language's glossary has no such ending to add.
|
|
9
|
+
export function readsPossessives(config) {
|
|
10
|
+
return (pronunciationRulesOf(config) === 2 &&
|
|
11
|
+
(config.language === undefined || config.language === "en"));
|
|
12
|
+
}
|
|
4
13
|
// Inworld requires English IPA, not every symbol in Unicode's IPA Extensions block.
|
|
5
14
|
// https://docs.inworld.ai/tts/capabilities/custom-pronunciation
|
|
6
15
|
const ipaAtom = "[ˈˌ]?[abdefghijklmnoprstuvwxzæðŋθɑɒɔəɚɛɜɝɡɪɹʃʊʌʒʔɫɾ][\\u0303\\u031a\\u0325\\u0329\\u032a\\u032c\\u032f\\u035c\\u0361ʰʲʷ]*[ːˑ]?";
|
|
@@ -50,8 +59,29 @@ function termIdentity(term) {
|
|
|
50
59
|
}
|
|
51
60
|
// `language` is the project's (`kernel/ports/languages.ts`): absent or English accepts the
|
|
52
61
|
// standard-English IPA Inworld asks for, as it always did; any other accepts full IPA.
|
|
53
|
-
export const parsePronunciationGlossary = remembered(8, (markdown, language) => `${language ?? ""}\u0000${markdown}`, parseGlossary);
|
|
54
|
-
|
|
62
|
+
export const parsePronunciationGlossary = remembered(8, (markdown, language, rules) => `${rules ?? currentPronunciationRules}\u0000${language ?? ""}\u0000${markdown}`, parseGlossary);
|
|
63
|
+
// A cell the writer left out on purpose: empty, a dash, or "n/a", with or without slashes.
|
|
64
|
+
const blankIpa = /^(?:\/\s*(?:[—–-]+|n\/a)\s*\/|[—–-]+|n\/a)?$/iu;
|
|
65
|
+
const hyphens = /[-\u2010\u2011]/u;
|
|
66
|
+
// One IPA word per written word. A hyphenated name is one written word, so its parts' IPA words
|
|
67
|
+
// are joined with "." (a syllable break) into the one word Inworld wants between a pair of slashes.
|
|
68
|
+
function pairWords(term, ipa) {
|
|
69
|
+
const words = term.split(" ");
|
|
70
|
+
if (words.length === ipa.length)
|
|
71
|
+
return ipa;
|
|
72
|
+
const parts = words.map((word) => word.split(hyphens).filter((part) => part !== "").length || 1);
|
|
73
|
+
if (parts.reduce((sum, count) => sum + count, 0) !== ipa.length)
|
|
74
|
+
return null;
|
|
75
|
+
let next = 0;
|
|
76
|
+
return parts.map((count) => {
|
|
77
|
+
const joined = ipa.slice(next, next + count).join(".");
|
|
78
|
+
next += count;
|
|
79
|
+
return joined;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function parseGlossary(markdown, language, rules = currentPronunciationRules) {
|
|
83
|
+
const modern = rules === 2;
|
|
84
|
+
let unpronounced = 0;
|
|
55
85
|
const english = language === undefined || language === "en";
|
|
56
86
|
const symbols = english ? ipaSymbols : worldSymbols;
|
|
57
87
|
const kind = english ? "standard-English IPA" : "IPA";
|
|
@@ -65,7 +95,7 @@ function parseGlossary(markdown, language) {
|
|
|
65
95
|
const trimmed = row.trim();
|
|
66
96
|
if (trimmed === "" || /^pronunciation glossary:?\s*$/iu.test(trimmed))
|
|
67
97
|
continue;
|
|
68
|
-
const pair = /^([^:]+):\s*(.+)$/u.exec(trimmed);
|
|
98
|
+
const pair = (modern ? /^([^:]+):\s*(.*)$/u : /^([^:]+):\s*(.+)$/u).exec(trimmed);
|
|
69
99
|
const term = pair?.[1]?.trim().replace(/\s+/gu, " ");
|
|
70
100
|
const pronunciation = pair?.[2];
|
|
71
101
|
if (term === undefined ||
|
|
@@ -75,6 +105,10 @@ function parseGlossary(markdown, language) {
|
|
|
75
105
|
skip("use Term: /IPA/ or a Term | IPA table");
|
|
76
106
|
continue;
|
|
77
107
|
}
|
|
108
|
+
if (modern && blankIpa.test(pronunciation.trim())) {
|
|
109
|
+
unpronounced++;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
78
112
|
const notation = /^(\/[^/]+\/(?:\s+\/[^/]+\/)*)(?:\s+[^/]+)?$/u.exec(pronunciation);
|
|
79
113
|
if (notation?.[1] === undefined) {
|
|
80
114
|
skip(`use slash-delimited ${kind}`);
|
|
@@ -87,37 +121,54 @@ function parseGlossary(markdown, language) {
|
|
|
87
121
|
: "use IPA, not ARPAbet or delivery tags");
|
|
88
122
|
continue;
|
|
89
123
|
}
|
|
90
|
-
|
|
91
|
-
|
|
124
|
+
const paired = modern
|
|
125
|
+
? pairWords(term, ipa)
|
|
126
|
+
: term.split(" ").length === ipa.length
|
|
127
|
+
? ipa
|
|
128
|
+
: null;
|
|
129
|
+
if (paired === null) {
|
|
130
|
+
skip(modern
|
|
131
|
+
? "supply one IPA word for each written word, or for each hyphen-separated part of it"
|
|
132
|
+
: "supply one IPA word for each written word");
|
|
92
133
|
continue;
|
|
93
134
|
}
|
|
94
135
|
const key = termIdentity(term);
|
|
95
136
|
const previous = entries.get(key);
|
|
96
|
-
if (previous !== undefined && previous.ipa.join(" ") !==
|
|
137
|
+
if (previous !== undefined && previous.ipa.join(" ") !== paired.join(" ")) {
|
|
97
138
|
skip("an earlier entry already gives this term a different pronunciation");
|
|
98
139
|
continue;
|
|
99
140
|
}
|
|
100
141
|
if (previous === undefined)
|
|
101
|
-
entries.set(key, { term, ipa });
|
|
142
|
+
entries.set(key, { term, ipa: paired });
|
|
102
143
|
}
|
|
103
|
-
return
|
|
104
|
-
|
|
105
|
-
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
entries: [...entries.values()],
|
|
147
|
+
...(unpronounced === 0 ? {} : { unpronounced }),
|
|
148
|
+
...(skipped.length === 0 ? {} : { skipped }),
|
|
149
|
+
};
|
|
106
150
|
}
|
|
107
|
-
// What a person reads about the skipped rows: where they are and why, never their text.
|
|
108
|
-
|
|
109
|
-
|
|
151
|
+
// What a person reads about the skipped rows: where they are and why, never their text. Names
|
|
152
|
+
// left without a pronunciation on purpose are not errors, so they get one calm line of their own.
|
|
153
|
+
export function skippedGlossaryNotice(skipped, unpronounced = 0) {
|
|
154
|
+
if (skipped.length === 0 && unpronounced === 0)
|
|
110
155
|
return null;
|
|
156
|
+
const where = "in the Pronunciation Glossary at the end of the article in Edit project → Article";
|
|
157
|
+
const blank = unpronounced === 0
|
|
158
|
+
? ""
|
|
159
|
+
: `${unpronounced === 1 ? "1 name has" : `${unpronounced} names have`} no pronunciation given and ${unpronounced === 1 ? "is" : "are"} read as written. To give ${unpronounced === 1 ? "it one" : "them one"}, fill in its IPA ${where}.`;
|
|
160
|
+
if (skipped.length === 0)
|
|
161
|
+
return `Pronunciation Glossary: ${blank}`;
|
|
111
162
|
const rows = skipped.map((one) => `entry ${one.row}: ${one.reason}`).join("; ");
|
|
112
163
|
return (`Pronunciation Glossary: ${skipped.length === 1 ? "1 entry is" : `${skipped.length} entries are`} ` +
|
|
113
164
|
`skipped and read as ordinary text (${rows}). The rest of the glossary is used. ` +
|
|
114
|
-
|
|
165
|
+
`To use them, fix those entries ${where}.${blank === "" ? "" : ` ${blank}`}`);
|
|
115
166
|
}
|
|
116
|
-
export function skippedSpeakerPronunciations(speakers, language) {
|
|
167
|
+
export function skippedSpeakerPronunciations(speakers, language, rules) {
|
|
117
168
|
return speakers.flatMap((speaker) => {
|
|
118
169
|
if (!speaker.pronunciations?.trim())
|
|
119
170
|
return [];
|
|
120
|
-
const parsed = parsePronunciationGlossary(speaker.pronunciations, language);
|
|
171
|
+
const parsed = parsePronunciationGlossary(speaker.pronunciations, language, rules);
|
|
121
172
|
const skipped = parsed.ok ? (parsed.skipped ?? []) : [];
|
|
122
173
|
return skipped.length === 0 ? [] : [{ speaker: speaker.name.trim() || "A speaker", skipped }];
|
|
123
174
|
});
|
|
@@ -130,8 +181,8 @@ export function skippedSpeakerPronunciationsNotice(rows) {
|
|
|
130
181
|
"The rest are used. To use them, fix those entries under Speakers → Pronunciations for that speaker (Play → Narration, or Edit project → Providers).");
|
|
131
182
|
}
|
|
132
183
|
// Remembered (`kernel/remembered.ts`): every plan rebuild matches the whole narration again.
|
|
133
|
-
export const pronunciationMatches = remembered(1024, (source, entries) => `${source}\u0000${JSON.stringify(entries)}`, matchPronunciations);
|
|
134
|
-
function matchPronunciations(source, entries) {
|
|
184
|
+
export const pronunciationMatches = remembered(1024, (source, entries, possessives) => `${possessives === true ? "p" : "-"}\u0000${source}\u0000${JSON.stringify(entries)}`, matchPronunciations);
|
|
185
|
+
function matchPronunciations(source, entries, possessives = false) {
|
|
135
186
|
if (entries.length === 0)
|
|
136
187
|
return [];
|
|
137
188
|
const ordered = [...entries].sort((a, b) => b.term.length - a.term.length);
|
|
@@ -140,11 +191,15 @@ function matchPronunciations(source, entries) {
|
|
|
140
191
|
.map((word) => word.replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&"))
|
|
141
192
|
.join("\\s+"));
|
|
142
193
|
const boundary = "[\\p{L}\\p{M}\\p{N}_\\-\u00ad\u2010\u2011\ufe63\uff0d]";
|
|
194
|
+
// A bare trailing apostrophe ("Iuz' henchmen") is never a possessive here: it can't be told
|
|
195
|
+
// from a closing quote, and a wrong "z" is worse than a name read as written.
|
|
143
196
|
const expression = new RegExp("(?<!" +
|
|
144
197
|
boundary +
|
|
145
198
|
"['’]?)(?:" +
|
|
146
199
|
patterns.map((pattern) => `(${pattern})`).join("|") +
|
|
147
|
-
")
|
|
200
|
+
")" +
|
|
201
|
+
(possessives ? "(['’]s)?" : "") +
|
|
202
|
+
"(?!['’]?" +
|
|
148
203
|
boundary +
|
|
149
204
|
")", "giu");
|
|
150
205
|
const matches = [];
|
|
@@ -157,20 +212,35 @@ function matchPronunciations(source, entries) {
|
|
|
157
212
|
const entry = ordered.find((_, index) => match[index + 1] !== undefined);
|
|
158
213
|
if (entry === undefined)
|
|
159
214
|
throw new Error("Slopify hit an internal error (a pronunciation glossary term has no pronunciation). Try again; if it happens again, use Download diagnostics in Settings and report it.");
|
|
160
|
-
|
|
215
|
+
const possessive = possessives ? match[ordered.length + 1] : undefined;
|
|
216
|
+
matches.push({
|
|
217
|
+
start: match.index,
|
|
218
|
+
end,
|
|
219
|
+
entry,
|
|
220
|
+
...(possessive === undefined ? {} : { possessive }),
|
|
221
|
+
});
|
|
161
222
|
}
|
|
162
223
|
return matches;
|
|
163
224
|
}
|
|
164
|
-
|
|
225
|
+
// The sound a possessive adds to the last sound of the name: "iz" after s z sh zh ch j,
|
|
226
|
+
// "s" after the other voiceless sounds, "z" after everything else.
|
|
227
|
+
function withPossessive(ipa) {
|
|
228
|
+
const last = ipa.replace(/[\u0300-\u036fʰʲʷːˑ]+$/u, "").slice(-1);
|
|
229
|
+
if ("sz\u0283\u0292".includes(last))
|
|
230
|
+
return `${ipa}\u026az`;
|
|
231
|
+
return `${ipa}${"ptkfθ".includes(last) ? "s" : "z"}`;
|
|
232
|
+
}
|
|
233
|
+
export function pronunciationSpans(source, entries, possessives = false) {
|
|
165
234
|
const spans = [];
|
|
166
|
-
for (const match of pronunciationMatches(source, entries)) {
|
|
167
|
-
const words = source.slice(match.start, match.end).matchAll(/\S+/gu);
|
|
168
|
-
for (const [index, word] of
|
|
235
|
+
for (const match of pronunciationMatches(source, entries, possessives)) {
|
|
236
|
+
const words = Array.from(source.slice(match.start, match.end).matchAll(/\S+/gu));
|
|
237
|
+
for (const [index, word] of words.entries()) {
|
|
169
238
|
const ipa = match.entry.ipa[index];
|
|
170
239
|
if (ipa === undefined)
|
|
171
240
|
throw new Error("Slopify hit an internal error (a pronunciation glossary term is missing a word). Try again; if it happens again, use Download diagnostics in Settings and report it.");
|
|
172
241
|
const start = match.start + word.index;
|
|
173
|
-
|
|
242
|
+
const spoken = match.possessive !== undefined && index === words.length - 1 ? withPossessive(ipa) : ipa;
|
|
243
|
+
spans.push({ start, end: start + word[0].length, text: `/${spoken}/` });
|
|
174
244
|
}
|
|
175
245
|
}
|
|
176
246
|
return spans;
|
|
@@ -2,7 +2,7 @@ import { aliasMatches } from "../../kernel/ports/narration-aliases.js";
|
|
|
2
2
|
import { narrationAliasesOf, usesNarrationPreparation, usesPronunciationGlossary, } from "../admission/rules.js";
|
|
3
3
|
import { aliasSpans, withAliasSpans } from "../narration/aliases.js";
|
|
4
4
|
import { narrationRegenerationToken, normalizeNarrationText, planNarration, } from "../narration/plan.js";
|
|
5
|
-
import { pronunciationSpans } from "../narration/pronunciation.js";
|
|
5
|
+
import { pronunciationSpans, readsPossessives, } from "../narration/pronunciation.js";
|
|
6
6
|
import { prepareRequests } from "../narration/steering.js";
|
|
7
7
|
import { recipe } from "./recipe-model.js";
|
|
8
8
|
import { preparationForGroup } from "./recipe-preparation.js";
|
|
@@ -25,7 +25,7 @@ export function narrationParts(context, logicalKey, originalText, segment, depen
|
|
|
25
25
|
// Aliases first: where one applies, the glossary does not.
|
|
26
26
|
const aliases = narrationAliasesOf(context.config);
|
|
27
27
|
const matches = aliasMatches(logicalText, aliases);
|
|
28
|
-
const spans = withAliasSpans(pronunciationSpans(logicalText, glossary.entries), aliasSpans(logicalText, aliases));
|
|
28
|
+
const spans = withAliasSpans(pronunciationSpans(logicalText, glossary.entries, readsPossessives(context.config)), aliasSpans(logicalText, aliases));
|
|
29
29
|
const wholeRequest = segment !== "body" || (context.config.chunking?.mode ?? "whole") === "whole";
|
|
30
30
|
const choice = context.config.audio;
|
|
31
31
|
// A retired model's character limit is still its limit: the automatic catalogue check marks
|
|
@@ -128,7 +128,7 @@ export function pronunciationFutureValues(context, glossary, article, logicalKey
|
|
|
128
128
|
],
|
|
129
129
|
];
|
|
130
130
|
const clean = normalizeNarrationText(override?.kind === "text" ? override.text : source);
|
|
131
|
-
const spans = pronunciationSpans(clean, glossary.entries);
|
|
131
|
+
const spans = pronunciationSpans(clean, glossary.entries, readsPossessives(context.config));
|
|
132
132
|
return spans.length === 0
|
|
133
133
|
? []
|
|
134
134
|
: [
|