@gentbajko/slopify 3.19.0 → 3.21.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.20.0.md +10 -0
- package/dist/patch-notes/3.21.0.md +11 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/schema.js +2 -1
- package/dist/slices/narration/breaths-detect.js +51 -21
- package/dist/slices/narration/breaths-model.js +21 -8
- package/dist/slices/narration/breaths.js +15 -13
- package/dist/slices/narration/clean-pieces.js +69 -0
- package/dist/slices/play-drafts/convert.js +3 -2
- package/dist/slices/play-drafts/schema.js +3 -2
- package/dist/slices/project-templates/from-project.js +1 -0
- package/dist/slices/rebuild/preview-details.js +2 -1
- package/dist/slices/rebuild/recipe-breaths.js +21 -11
- package/dist/slices/rebuild/runtime-local.js +13 -12
- package/dist/slices/settings/model.js +2 -0
- package/dist/slices/settings/playback.js +11 -0
- package/dist/tutorials/Editing-a-Project.md +3 -2
- 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-wwRT6LH3.js +326 -0
- package/dist/web/assets/{pdf-Dxrlh3NL.js → pdf-DH2f4NNx.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-OxnO0Bfh.js +0 -326
|
@@ -2,19 +2,20 @@ import { mkdirSync, rmSync } from "node:fs";
|
|
|
2
2
|
import { open, stat } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { runFfmpeg } from "../video/ffmpeg.js";
|
|
5
|
-
import { breathRate,
|
|
6
|
-
//
|
|
5
|
+
import { breathRate, scanBreaths, softenChunk } from "./breaths-detect.js";
|
|
6
|
+
// Soften breaths, made in the join (`breaths-model.ts` says why there). Each narration piece is
|
|
7
7
|
// decoded by ffmpeg to 16-bit mono PCM at 48 kHz in a file beside it, read once to find its
|
|
8
|
-
// breaths (`breaths-detect.ts`) and once more to write it again as a WAV with them
|
|
8
|
+
// breaths (`breaths-detect.ts`) and once more to write it again as a WAV with them turned down. Both
|
|
9
9
|
// reads go in chunks, so a long piece never sits in memory whole, and the detector yields to
|
|
10
10
|
// the event loop as it goes, so the server stays responsive while it works. A piece without a
|
|
11
11
|
// breath is written again all the same: the join takes every piece in the one format.
|
|
12
12
|
// ceiling: one second of audio per read.
|
|
13
13
|
const chunkSamples = breathRate;
|
|
14
|
-
export async function
|
|
14
|
+
export async function softenBreaths(run, files, directory) {
|
|
15
15
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
16
16
|
const written = [];
|
|
17
|
-
let
|
|
17
|
+
let softened = 0;
|
|
18
|
+
let skipped = 0;
|
|
18
19
|
for (const [index, file] of files.entries()) {
|
|
19
20
|
run.signal.throwIfAborted();
|
|
20
21
|
const name = `breaths-${String(index + 1).padStart(4, "0")}`;
|
|
@@ -28,22 +29,23 @@ export async function removeBreaths(run, files, directory) {
|
|
|
28
29
|
log: run.log,
|
|
29
30
|
onProgress: () => { },
|
|
30
31
|
});
|
|
31
|
-
const
|
|
32
|
+
const scan = await scanBreaths(pcmChunks(raw));
|
|
32
33
|
run.signal.throwIfAborted();
|
|
33
|
-
await
|
|
34
|
-
|
|
34
|
+
await writeSoftened(raw, output, scan.stretches);
|
|
35
|
+
softened += scan.stretches.length;
|
|
36
|
+
skipped += scan.skipped.length;
|
|
35
37
|
}
|
|
36
38
|
catch (error) {
|
|
37
39
|
if (run.signal.aborted)
|
|
38
40
|
throw error;
|
|
39
|
-
throw new Error(`
|
|
41
|
+
throw new Error(`Soften breaths could not process narration piece ${String(index + 1)} (${error instanceof Error ? error.message : String(error)}), so the narration can't be joined together. Use Try again; if it fails again, turn off Soften breaths in Edit project → Pauses and volume and use Save changes, then use Download diagnostics in Settings and report it.`, { cause: error });
|
|
40
42
|
}
|
|
41
43
|
finally {
|
|
42
44
|
rmSync(raw, { force: true });
|
|
43
45
|
}
|
|
44
46
|
written.push(output);
|
|
45
47
|
}
|
|
46
|
-
return { files: written,
|
|
48
|
+
return { files: written, softened, skipped };
|
|
47
49
|
}
|
|
48
50
|
export function decodeArgs(input, output) {
|
|
49
51
|
return [
|
|
@@ -94,7 +96,7 @@ async function readChunk(handle) {
|
|
|
94
96
|
out[at] = buffer.readInt16LE(at * 2);
|
|
95
97
|
return out;
|
|
96
98
|
}
|
|
97
|
-
async function
|
|
99
|
+
async function writeSoftened(raw, output, stretches) {
|
|
98
100
|
const size = (await stat(raw)).size;
|
|
99
101
|
const bytes = size - (size % 2);
|
|
100
102
|
const target = await open(output, "w", 0o600);
|
|
@@ -102,9 +104,9 @@ async function writeMuted(raw, output, stretches) {
|
|
|
102
104
|
await target.write(wavHeader(bytes));
|
|
103
105
|
let offset = 0;
|
|
104
106
|
for await (const chunk of pcmChunks(raw)) {
|
|
105
|
-
const
|
|
107
|
+
const soft = softenChunk(chunk, offset, stretches);
|
|
106
108
|
// Typed arrays are little-endian on every platform Slopify ships for, as WAV is.
|
|
107
|
-
await target.write(Buffer.from(
|
|
109
|
+
await target.write(Buffer.from(soft.buffer, soft.byteOffset, soft.byteLength));
|
|
108
110
|
offset += chunk.length;
|
|
109
111
|
}
|
|
110
112
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { runFfmpeg } from "../video/ffmpeg.js";
|
|
4
|
+
import { softenBreaths } from "./breaths.js";
|
|
5
|
+
import { deEsser } from "./breaths-model.js";
|
|
6
|
+
export async function cleanPieces(run, files, directory, cleaning) {
|
|
7
|
+
if (!cleaning.breaths && !cleaning.deEss)
|
|
8
|
+
return undefined;
|
|
9
|
+
const breaths = cleaning.breaths
|
|
10
|
+
? await softenBreaths(run, files, join(directory, "breaths"))
|
|
11
|
+
: undefined;
|
|
12
|
+
const soft = breaths?.files ?? files;
|
|
13
|
+
const cleaned = cleaning.deEss ? await deEssPieces(run, soft, join(directory, "deess")) : soft;
|
|
14
|
+
return {
|
|
15
|
+
files: cleaned,
|
|
16
|
+
...(breaths === undefined
|
|
17
|
+
? {}
|
|
18
|
+
: { breaths: { softened: breaths.softened, skipped: breaths.skipped } }),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export async function deEssPieces(run, files, directory) {
|
|
22
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
23
|
+
const written = [];
|
|
24
|
+
for (const [index, file] of files.entries()) {
|
|
25
|
+
run.signal.throwIfAborted();
|
|
26
|
+
const output = join(directory, `deess-${String(index + 1).padStart(4, "0")}.wav`);
|
|
27
|
+
try {
|
|
28
|
+
await runFfmpeg({
|
|
29
|
+
bin: run.bin,
|
|
30
|
+
args: deEssArgs(file, output),
|
|
31
|
+
signal: run.signal,
|
|
32
|
+
log: run.log,
|
|
33
|
+
onProgress: () => { },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (run.signal.aborted)
|
|
38
|
+
throw error;
|
|
39
|
+
throw new Error(`Soften S sounds could not process narration piece ${String(index + 1)} (${error instanceof Error ? error.message : String(error)}), so the narration can't be joined together. Use Try again; if it fails again, turn off Soften S sounds in Edit project → Pauses and volume and use Save changes, then use Download diagnostics in Settings and report it.`, { cause: error });
|
|
40
|
+
}
|
|
41
|
+
written.push(output);
|
|
42
|
+
}
|
|
43
|
+
return written;
|
|
44
|
+
}
|
|
45
|
+
export function deEssFilter() {
|
|
46
|
+
return `deesser=i=${String(deEsser.intensity)}:m=${String(deEsser.max)}:f=${String(deEsser.frequency)}`;
|
|
47
|
+
}
|
|
48
|
+
// The piece's own rate and channels, written as 16-bit WAV like the other cleaned pieces.
|
|
49
|
+
export function deEssArgs(input, output) {
|
|
50
|
+
return [
|
|
51
|
+
"-hide_banner",
|
|
52
|
+
"-nostdin",
|
|
53
|
+
"-loglevel",
|
|
54
|
+
"error",
|
|
55
|
+
"-nostats",
|
|
56
|
+
"-y",
|
|
57
|
+
"-i",
|
|
58
|
+
input,
|
|
59
|
+
"-map",
|
|
60
|
+
"0:a:0",
|
|
61
|
+
"-af",
|
|
62
|
+
deEssFilter(),
|
|
63
|
+
"-c:a",
|
|
64
|
+
"pcm_s16le",
|
|
65
|
+
"-f",
|
|
66
|
+
"wav",
|
|
67
|
+
output,
|
|
68
|
+
];
|
|
69
|
+
}
|
|
@@ -4,7 +4,7 @@ import { runDraftSchema } from "../admission/schema.js";
|
|
|
4
4
|
import { draftDocumentThemeOf } from "../document/model.js";
|
|
5
5
|
import { defaultExpectedWords, everyMinutesMax, everyMinutesMin, expectedWordsMax, imagesPerHourMax, imagesPerHourMin, perHourFromMinutes, wordCount, } from "../images/scale.js";
|
|
6
6
|
import { defaultLoudness, loudnessOfForm, } from "../loudness/model.js";
|
|
7
|
-
import { breathsOfForm } from "../narration/breaths-model.js";
|
|
7
|
+
import { breathsOfForm, deEssOfForm } from "../narration/breaths-model.js";
|
|
8
8
|
import { pausesOfForm } from "../narration/pauses-model.js";
|
|
9
9
|
import { reviewSettingsFromForm } from "../reviews/model.js";
|
|
10
10
|
import { stageMakesItems } from "../reviews/rules.js";
|
|
@@ -293,8 +293,9 @@ export function toAdmissionDraft(input) {
|
|
|
293
293
|
...pickLoudness(sources.audio, loudnessOfForm(form.loudness, input.loudness ?? defaultLoudness)),
|
|
294
294
|
// Pauses between sentences: the default for a new run, with a narration to pace.
|
|
295
295
|
...(sources.audio === "generate" ? pausesOfForm(form) : {}),
|
|
296
|
-
//
|
|
296
|
+
// Soften breaths and Soften S sounds: only while on, with a narration made here to clean.
|
|
297
297
|
...(sources.audio === "generate" ? breathsOfForm(form) : {}),
|
|
298
|
+
...(sources.audio === "generate" ? deEssOfForm(form) : {}),
|
|
298
299
|
edgeSilenceSeconds: measure("edgeSilenceSeconds", sources.audio !== "off", defaultEdgeSilenceSeconds, edgeSilenceSecondsProblem),
|
|
299
300
|
};
|
|
300
301
|
const parsed = runDraftSchema.safeParse(draft);
|
|
@@ -86,9 +86,10 @@ export const playDraftFormSchema = z
|
|
|
86
86
|
// paragraph minimum (`narration/pauses-model.ts`).
|
|
87
87
|
sentencePause: text.optional(),
|
|
88
88
|
paragraphPause: text.optional(),
|
|
89
|
-
//
|
|
90
|
-
// before
|
|
89
|
+
// Soften breaths and Soften S sounds (`narration/breaths-model.ts`). Absent on drafts and
|
|
90
|
+
// templates saved before them: off.
|
|
91
91
|
removeBreaths: z.boolean().optional(),
|
|
92
|
+
deEss: z.boolean().optional(),
|
|
92
93
|
// Absent on drafts and templates saved before the YouTube description: off, built-in prompt.
|
|
93
94
|
youtubeDescription: z.boolean().optional(),
|
|
94
95
|
showFigures: z.boolean().optional(),
|
|
@@ -237,6 +237,7 @@ export function documentFromProject(deps, revision) {
|
|
|
237
237
|
? {}
|
|
238
238
|
: { paragraphPause: String(config.paragraphPauseSeconds) }),
|
|
239
239
|
...(config.removeBreaths === true ? { removeBreaths: true } : {}),
|
|
240
|
+
...(config.deEss === true ? { deEss: true } : {}),
|
|
240
241
|
imagePrompts,
|
|
241
242
|
...(config.imageScale === undefined ? {} : { imageScale: imageScaleForm(config.imageScale) }),
|
|
242
243
|
...(config.imageScenes === true ? { imageScenes: true } : {}),
|
|
@@ -159,7 +159,8 @@ function inputChanges(parent, view) {
|
|
|
159
159
|
add("Show tables and figures on screen", before.showFigures === true ? "On" : "Off", after.showFigures === true ? "On" : "Off");
|
|
160
160
|
add("Pause between sentences (seconds)", before.sentencePauseSeconds ?? 0, after.sentencePauseSeconds ?? 0);
|
|
161
161
|
add("Pause between paragraphs (seconds)", before.paragraphPauseSeconds ?? 0, after.paragraphPauseSeconds ?? 0);
|
|
162
|
-
add("
|
|
162
|
+
add("Soften breaths", before.removeBreaths === true ? "On" : "Off", after.removeBreaths === true ? "On" : "Off");
|
|
163
|
+
add("Soften S sounds", before.deEss === true ? "On" : "Off", after.deEss === true ? "On" : "Off");
|
|
163
164
|
add("Level the volume", before.loudness === undefined ? "Off" : loudnessSummary(before.loudness), after.loudness === undefined ? "Off" : loudnessSummary(after.loudness));
|
|
164
165
|
add("YouTube description", before.youtubeDescription === true ? "On" : "Off", after.youtubeDescription === true ? "On" : "Off");
|
|
165
166
|
add("Description prompt", before.descriptionPrompt, after.descriptionPrompt);
|
|
@@ -1,16 +1,26 @@
|
|
|
1
|
-
import { breathsOn } from "../narration/breaths-model.js";
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// every join made before
|
|
5
|
-
//
|
|
6
|
-
|
|
1
|
+
import { breathsOn, deEsser, deEssOn } from "../narration/breaths-model.js";
|
|
2
|
+
// Soften breaths and Soften S sounds (`narration/breaths-model.ts`) are made in the narration
|
|
3
|
+
// joins, so a join carries them in its values, after the pauses when they are set. Nothing at
|
|
4
|
+
// all while they are off, so every join made before them keeps its fingerprint. The levelled
|
|
5
|
+
// join copies these values, so it softens the same.
|
|
6
|
+
// v2: 3.19.0's v1 muted breaths to silence and could clip word edges; its joins are remade.
|
|
7
|
+
export const breathsValue = "breaths-v2";
|
|
8
|
+
export const deEssValue = "deess-v1";
|
|
7
9
|
export function breathValues(config) {
|
|
8
|
-
return
|
|
10
|
+
return [
|
|
11
|
+
...(breathsOn(config) ? [[breathsValue]] : []),
|
|
12
|
+
...(deEssOn(config) ? [[deEssValue, deEsser.intensity, deEsser.max, deEsser.frequency]] : []),
|
|
13
|
+
];
|
|
9
14
|
}
|
|
10
15
|
// Whether a join's values ask for it: any value after the join's own two.
|
|
11
16
|
export function breathsAsked(values) {
|
|
12
|
-
return (
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
return laterValues(values).some((value) => value.length === 1 && value[0] === breathsValue);
|
|
18
|
+
}
|
|
19
|
+
export function deEssAsked(values) {
|
|
20
|
+
return laterValues(values).some((value) => value[0] === deEssValue);
|
|
21
|
+
}
|
|
22
|
+
function laterValues(values) {
|
|
23
|
+
return Array.isArray(values)
|
|
24
|
+
? values.slice(2).filter((value) => Array.isArray(value))
|
|
25
|
+
: [];
|
|
16
26
|
}
|
|
@@ -5,14 +5,14 @@ import { referenceKey, thumbnailVariant } from "../admission/model.js";
|
|
|
5
5
|
import { plainText } from "../article/plain.js";
|
|
6
6
|
import { splitEndMatter } from "../article/split.js";
|
|
7
7
|
import { levelPieces } from "../loudness/level-pieces.js";
|
|
8
|
-
import {
|
|
8
|
+
import { cleanPieces } from "../narration/clean-pieces.js";
|
|
9
9
|
import { joinNarration } from "../narration/concat.js";
|
|
10
10
|
import { applyPauses, planPieces } from "../narration/pauses.js";
|
|
11
11
|
import { allocateAsset, discardPreparedAssets, sealAsset } from "../storage/assets.js";
|
|
12
12
|
import { outputPath } from "../storage/layout.js";
|
|
13
13
|
import { probeDurationMs, runFfmpeg } from "../video/ffmpeg.js";
|
|
14
14
|
import { turnJoinArgs } from "../voices/join.js";
|
|
15
|
-
import { breathsAsked } from "./recipe-breaths.js";
|
|
15
|
+
import { breathsAsked, deEssAsked } from "./recipe-breaths.js";
|
|
16
16
|
import { levelOperation } from "./recipe-loudness.js";
|
|
17
17
|
import { executeFigureCard } from "./runtime-figure-card.js";
|
|
18
18
|
import { publishNarrationText } from "./runtime-narration-text.js";
|
|
@@ -136,27 +136,28 @@ async function concatenate(deps, context, piece) {
|
|
|
136
136
|
try {
|
|
137
137
|
const run = { bin: deps.ffmpeg, log: deps.log, signal: context.signal };
|
|
138
138
|
const levelled = level ? await levelPieces(run, files, piecesDirectory) : undefined;
|
|
139
|
-
//
|
|
140
|
-
// are still planned from the pieces as spoken.
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
:
|
|
144
|
-
|
|
139
|
+
// Soften breaths, then Soften S sounds: made in what is joined, before the pauses; nothing
|
|
140
|
+
// moves, so the pauses are still planned from the pieces as spoken.
|
|
141
|
+
const cleaned = await cleanPieces(run, levelled?.files ?? files, piecesDirectory, {
|
|
142
|
+
breaths: breathsAsked(values),
|
|
143
|
+
deEss: deEssAsked(values),
|
|
144
|
+
});
|
|
145
|
+
if (cleaned?.breaths !== undefined)
|
|
145
146
|
deps.log.write("info", "narration.breaths", {
|
|
146
147
|
projectId: context.work.projectId,
|
|
147
148
|
stage: "audio",
|
|
148
|
-
detail: `${String(
|
|
149
|
+
detail: `${String(cleaned.breaths.softened)} breaths softened, ${String(cleaned.breaths.skipped)} left alone (a word sound in them) in ${String(files.length)} pieces`,
|
|
149
150
|
});
|
|
150
151
|
// Pauses between sentences: planned from the pieces as spoken, made in what is joined.
|
|
151
152
|
const pauses = pausesIn(values);
|
|
152
153
|
const paced = pauses === undefined
|
|
153
154
|
? undefined
|
|
154
|
-
: await applyPauses(run,
|
|
155
|
+
: await applyPauses(run, cleaned?.files ?? levelled?.files ?? files, paceScaled(await planPieces(run, files.map((file, at) => ({
|
|
155
156
|
file,
|
|
156
157
|
text: pieceText(plan.recipes.find((one) => one.key === recipe.dependsOn[at])),
|
|
157
158
|
next: pauses.next[at] ?? "end",
|
|
158
159
|
})), pauses.settings), operation === "concat-turns-v1" ? values : undefined), join(piecesDirectory, "paced"));
|
|
159
|
-
const joined = paced?.files ??
|
|
160
|
+
const joined = paced?.files ?? cleaned?.files ?? levelled?.files ?? files;
|
|
160
161
|
const durationMs = operation === "concat-turns-v1"
|
|
161
162
|
? await joinTurns(run, joined, values, pending.absolutePath)
|
|
162
163
|
: await joinNarration({ bin: deps.ffmpeg, log: deps.log }, {
|
|
@@ -164,7 +165,7 @@ async function concatenate(deps, context, piece) {
|
|
|
164
165
|
output: pending.absolutePath,
|
|
165
166
|
listPath: join(dirname(pending.absolutePath), "parts.txt"),
|
|
166
167
|
signal: context.signal,
|
|
167
|
-
reencode: level || paced !== undefined ||
|
|
168
|
+
reencode: level || paced !== undefined || cleaned !== undefined,
|
|
168
169
|
});
|
|
169
170
|
rmSync(piecesDirectory, { recursive: true, force: true });
|
|
170
171
|
context.signal.throwIfAborted();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// The provider catalogue and the settings domain types.
|
|
2
|
+
import { defaultEnglishSubtitleModel, } from "../../kernel/ports/subtitles.js";
|
|
2
3
|
import { defaultLoudness } from "../loudness/model.js";
|
|
3
4
|
// The three families are the three ports, so the set is named beside them.
|
|
4
5
|
export { providerFamilies } from "../../kernel/ports/model.js";
|
|
@@ -135,4 +136,5 @@ export const defaultSettings = {
|
|
|
135
136
|
silenceGapSeconds: 3,
|
|
136
137
|
appearance: "system",
|
|
137
138
|
loudness: defaultLoudness,
|
|
139
|
+
subtitleModel: defaultEnglishSubtitleModel,
|
|
138
140
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { englishSubtitleModels } from "../../kernel/ports/subtitles.js";
|
|
2
3
|
import { silenceGapSecondsMax } from "../admission/rules.js";
|
|
3
4
|
import { loudnessFields } from "../loudness/model.js";
|
|
4
5
|
import { appearances, defaultSettings } from "./model.js";
|
|
@@ -8,8 +9,10 @@ import { readSetting, writeSetting } from "./repo.js";
|
|
|
8
9
|
const silenceGapKey = "silenceGapSeconds";
|
|
9
10
|
const appearanceKey = "appearance";
|
|
10
11
|
const loudnessKey = "loudness";
|
|
12
|
+
const subtitleModelKey = "subtitleModel";
|
|
11
13
|
const silenceGapValue = z.number().int().min(0).max(silenceGapSecondsMax);
|
|
12
14
|
const appearanceValue = z.enum(appearances);
|
|
15
|
+
const subtitleModelValue = z.enum(englishSubtitleModels);
|
|
13
16
|
const loudnessValue = z.object({
|
|
14
17
|
enabled: z.boolean(),
|
|
15
18
|
videoLufs: z.number(),
|
|
@@ -20,6 +23,7 @@ export function readSettings(deps) {
|
|
|
20
23
|
silenceGapSeconds: parsed(deps, silenceGapKey, silenceGapValue, defaultSettings.silenceGapSeconds),
|
|
21
24
|
appearance: parsed(deps, appearanceKey, appearanceValue, defaultSettings.appearance),
|
|
22
25
|
loudness: parsed(deps, loudnessKey, loudnessValue, defaultSettings.loudness),
|
|
26
|
+
subtitleModel: parsed(deps, subtitleModelKey, subtitleModelValue, defaultSettings.subtitleModel),
|
|
23
27
|
};
|
|
24
28
|
}
|
|
25
29
|
export function saveSettings(deps, settings) {
|
|
@@ -39,12 +43,19 @@ export function saveSettings(deps, settings) {
|
|
|
39
43
|
else {
|
|
40
44
|
fields.push(...loudnessFields(settings.loudness));
|
|
41
45
|
}
|
|
46
|
+
if (!subtitleModelValue.safeParse(settings.subtitleModel).success) {
|
|
47
|
+
fields.push({
|
|
48
|
+
field: "subtitleModel",
|
|
49
|
+
message: "Choose Most accurate, Balanced or Smallest in Settings → Production defaults → Subtitle timing model.",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
42
52
|
if (fields.length > 0) {
|
|
43
53
|
return { ok: false, fields };
|
|
44
54
|
}
|
|
45
55
|
writeSetting(deps.db, silenceGapKey, JSON.stringify(settings.silenceGapSeconds));
|
|
46
56
|
writeSetting(deps.db, appearanceKey, JSON.stringify(settings.appearance));
|
|
47
57
|
writeSetting(deps.db, loudnessKey, JSON.stringify(settings.loudness));
|
|
58
|
+
writeSetting(deps.db, subtitleModelKey, JSON.stringify(settings.subtitleModel));
|
|
48
59
|
return { ok: true, settings };
|
|
49
60
|
}
|
|
50
61
|
// A stored value is JSON, and a hand-edited database is the only way it can be anything
|
|
@@ -134,7 +134,8 @@ See [Narration Aliases and Glossary](Narration-Aliases-and-Glossary).
|
|
|
134
134
|
| --- | --- | --- |
|
|
135
135
|
| **Pause between sentences (seconds)** | The least quiet after each sentence. Adds silence where the pause is shorter; never shortens one or cuts a word. About 0.25 s is brisk, 0.35 to 0.45 natural, 0.5 to 0.8 an audiobook's pace, 0.8 and up sleep content. 0 keeps the voice's own. | 0.4; 0 to 2 in steps of 0.05 |
|
|
136
136
|
| **Pause between paragraphs (seconds)** | The least quiet at the end of a paragraph, when you want more than between sentences. At 0 paragraphs get the sentence pause. Try 0.8 to 1.2 for a slower read. A speaker's turn gap is set under Speakers instead. | 0; 0 to 2 |
|
|
137
|
-
| **
|
|
137
|
+
| **Soften breaths** | Turns the audible in-breaths a voice takes between sentences and after commas down by 12 dB, with soft fades (Inworld's TTS-2 takes a loud one almost every time). A breath with any word sound in it is left alone; words, pauses and captions stay where they were. | Off |
|
|
138
|
+
| **Soften S sounds** | Takes the sharp edge off s and sh sounds with a very light de-esser; the voice keeps its brightness. Works with or without Soften breaths. | Off |
|
|
138
139
|
| **Level the volume** | Brings every narration piece to one loudness before they are joined, then sets the video, shorts and audio files to a finished loudness. | On for new runs (Settings → General → **Level the volume for new runs**) |
|
|
139
140
|
| **Video volume** | How loud the video and shorts are, in dB from the recommended level (0 dB is -14 LUFS, what YouTube and Spotify play at). -6 dB is about 50%, +4 dB about 158%. Peaks stay under -1.5 dBTP. | 0 dB; -10 to +4 dB in 0.5 dB steps |
|
|
140
141
|
| **Audio files volume** | How loud the WAV, MP3 and M4B are, in dB from -18 LUFS (what audiobook shops such as ACX ask for). Peaks stay under -3 dBTP. | 0 dB; -10 to +4 dB |
|
|
@@ -143,7 +144,7 @@ You can type a volume in dB or as a percentage of the recommended level.
|
|
|
143
144
|
|
|
144
145
|
What changing them remakes:
|
|
145
146
|
|
|
146
|
-
- **Pauses or
|
|
147
|
+
- **Pauses, Soften breaths or Soften S sounds**: the narration is joined again from the pieces it already has and timed again, which remakes what is timed from it: captions, the video and, when on, the YouTube description and the shorts' pick (those two are text calls). No speech is made again.
|
|
147
148
|
- **Level the volume on or off**: the levelled narration is joined again and the exports rendered again. No speech is made again, and word timing, description, shorts' pick and reviews are not redone.
|
|
148
149
|
- **A volume only**: the exports are mastered again, nothing else.
|
|
149
150
|
|
package/dist/tutorials/FAQ.md
CHANGED
|
@@ -48,7 +48,7 @@ What you write is sent only to the AI providers you pick, because they make the
|
|
|
48
48
|
|
|
49
49
|
### Does it work offline?
|
|
50
50
|
|
|
51
|
-
Partly. The AI steps (article, research, narration, images, thumbnails, descriptions) call providers over the internet, or CLIs that do. Rendering the video, levelling the volume, captions, the style preview and the System voice run on your computer. Captions need a speech model of about
|
|
51
|
+
Partly. The AI steps (article, research, narration, images, thumbnails, descriptions) call providers over the internet, or CLIs that do. Rendering the video, levelling the volume, captions, the style preview and the System voice run on your computer. Captions need a speech model of about 378 MB (smaller ones can be chosen in Settings → Production defaults) that is downloaded once, on first use. Patch notes ship with the app and open offline.
|
|
52
52
|
|
|
53
53
|
## Making and editing videos
|
|
54
54
|
|
|
@@ -11,7 +11,7 @@ Slopify is a local web app you start from a terminal. It runs on your own comput
|
|
|
11
11
|
| Node.js | Version 26 or newer, with npm (it comes with Node). Get it from nodejs.org. |
|
|
12
12
|
| Operating system | Windows, macOS or Linux for the normal install. The managed Docker install is Linux only (see [Docker](Docker)). |
|
|
13
13
|
| ffmpeg | Nothing to install: Slopify brings its own copy. See [ffmpeg](#ffmpeg) below. |
|
|
14
|
-
| Disk space | Enough for your videos. Every project keeps its video, images, narration and working files. The caption model is a 95 MB
|
|
14
|
+
| Disk space | Enough for your videos. Every project keeps its video, images, narration and working files. The caption model is a 378 MB download (95 MB if you pick Smallest in Settings → Production defaults). |
|
|
15
15
|
| Keys or CLIs | Text and images can come from Claude Code, Codex or Gemini CLI, or from API keys. Narration can use a voice provider key, or your computer's own speech for free (the System voice; on Linux, install `espeak-ng`). See [Providers and Keys](Providers-and-Keys) and [AI CLIs](AI-CLIs). |
|
|
16
16
|
|
|
17
17
|
## Choose how to run it
|
|
@@ -71,7 +71,7 @@ A flag beats the environment variable, and the variable beats the default.
|
|
|
71
71
|
| `--no-open` | `SLOPIFY_NO_OPEN` | opens a browser | Doesn't open a browser tab. For the variable, any value except empty, `0` or `false` counts as on. |
|
|
72
72
|
| `--autostart` / `--no-autostart` | | asks once | Turns [Start at Login](Start-at-Login) on or off without asking. Useful in scripts. |
|
|
73
73
|
| | `SLOPIFY_FFMPEG` | the bundled ffmpeg | The full path of another ffmpeg to use. |
|
|
74
|
-
| | `SLOPIFY_NO_MODEL_PREFETCH` | downloads at start | Set to `1` to skip fetching the
|
|
74
|
+
| | `SLOPIFY_NO_MODEL_PREFETCH` | downloads at start | Set to `1` to skip fetching the caption model (the one chosen in Settings → Production defaults, 378 MB by default) when Slopify starts. It is then fetched the first time a video needs captions. |
|
|
75
75
|
|
|
76
76
|
Options that only work with `--docker`: `--projects-dir <folder>`, `--host-cli=off` and `--accept-host-cli`. See [Docker](Docker). With `--docker`, `--host` and `--data-dir` are refused, because the Docker version always listens on localhost and keeps its data in a Docker volume.
|
|
77
77
|
|
|
@@ -112,7 +112,7 @@ The bundled ffmpeg is a separate program under the GPL-3.0-or-later licence. Slo
|
|
|
112
112
|
|
|
113
113
|
## The caption model
|
|
114
114
|
|
|
115
|
-
Captions are timed against the narration by a speech model that runs on your computer, at no cost. The English model
|
|
115
|
+
Captions are timed against the narration by a speech model that runs on your computer, at no cost. The English model is downloaded when Slopify starts, in the background, so a run never waits for it later: the one chosen in Settings → Production defaults → **Subtitle timing model**, Most accurate (378 MB) unless changed. The Docker image already carries it. It is kept in `<data folder>/models/`. Set `SLOPIFY_NO_MODEL_PREFETCH=1` to skip the download at start. Other languages use a separate model that is downloaded the first time a project in that language needs it.
|
|
116
116
|
|
|
117
117
|
## Anonymous usage stats
|
|
118
118
|
|
|
@@ -32,7 +32,7 @@ This applies with the brand kit off too, because language is not styling. Projec
|
|
|
32
32
|
| | English | Spanish, German, French, Italian, Portuguese, Dutch, Catalan, Polish, Czech | All other languages |
|
|
33
33
|
|---|---|---|---|
|
|
34
34
|
| Article, narration preparation, YouTube description, chapters, tags, shorts titles and hashtags, multi-voice scripts | English | Written in the language | Written in the language |
|
|
35
|
-
| Caption timing | Word by word, English model (95 MB) | Word by word, multilingual model (248 MB, downloaded once, on first use) | Sentence by sentence, no model |
|
|
35
|
+
| Caption timing | Word by word, English model (378 MB; 189 MB or 95 MB in Settings → Production defaults) | Word by word, multilingual model (248 MB, downloaded once, on first use) | Sentence by sentence, no model |
|
|
36
36
|
| Word-by-word Shorts captions | On | On | Off: shorts show whole caption groups |
|
|
37
37
|
| Cuts that follow the narration | Available | Available | Off: cuts fall every N seconds |
|
|
38
38
|
| Pronunciation Glossary | Standard-English IPA | Full IPA (ç, ʁ, ø, y, ɲ, x and so on) | Full IPA |
|
|
@@ -84,7 +84,7 @@ Below the ambient sound are **Pause between sentences**, **Pause between paragra
|
|
|
84
84
|
|
|
85
85
|
## Captions
|
|
86
86
|
|
|
87
|
-
Captions are timed from your narration on this computer, with no paid API. The first use downloads a speech model of about
|
|
87
|
+
Captions are timed from your narration on this computer, with no paid API. The first use downloads a speech model of about 378 MB (Settings → Production defaults → **Subtitle timing model** offers smaller ones). They need narration: with narration Off, the section says "Turn Audio on to add subtitles."
|
|
88
88
|
|
|
89
89
|
| Option | What it does | Default / range |
|
|
90
90
|
| --- | --- | --- |
|
|
@@ -116,7 +116,7 @@ There is nothing to turn on: the address works whenever Slopify runs. See [Agent
|
|
|
116
116
|
|
|
117
117
|
## Production defaults
|
|
118
118
|
|
|
119
|
-
"How new runs pace the narration and level the volume."
|
|
119
|
+
"How new runs pace the narration and level the volume, and which model times English captions."
|
|
120
120
|
|
|
121
121
|
### Silence between segments
|
|
122
122
|
|
|
@@ -136,6 +136,14 @@ Videos already started keep their own gap. A value that is not a whole number fr
|
|
|
136
136
|
|
|
137
137
|
The volume fields show only while the switch is on. A project keeps what it started with; change it there in **Edit project**. Levelling runs on this computer and makes no API calls. Changes save as you make them.
|
|
138
138
|
|
|
139
|
+
### Subtitle timing model
|
|
140
|
+
|
|
141
|
+
| Control | What it does | Default / range |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| **Subtitle timing model** | The speech model that times English captions on this computer. The same model in three sizes: **Most accurate (378 MB, recommended)**, **Balanced (189 MB)** and **Smallest (95 MB, less accurate)**. Bigger hears the narration more accurately and, on most computers, also runs faster. | Most accurate |
|
|
144
|
+
|
|
145
|
+
The chosen model downloads once, the first time captions are timed (or when Slopify starts, unless `SLOPIFY_NO_MODEL_PREFETCH` is set), and is kept in `<data folder>/models/english-subtitles/`. A model already downloaded stays there when you switch, so switching back costs nothing. Smallest can mishear a line and stop a run with "Subtitles stopped matching the audio" where the narration is fine. Changing the model never marks finished subtitles outdated, and a timing that is already running finishes with the model it started with; the next one uses the new choice. It is a setting of this computer, not of a project. Other languages use their own model and are not affected. Changes save as you make them.
|
|
146
|
+
|
|
139
147
|
## Notifications
|
|
140
148
|
|
|
141
149
|
"When a run finishes, fails, or waits for you."
|
|
@@ -67,7 +67,7 @@ Slopify ships its own ffmpeg; you do not need to install one. In Docker it is in
|
|
|
67
67
|
| "The audio/video export failed (ffmpeg exited with code ...)" | Open **Error details**: it quotes what ffmpeg could not do, for example an input file it could not open. Replace that file (an uploaded image or clip in **Edit project → Images**), or try again. |
|
|
68
68
|
| "Slopify couldn't measure the loudness of the narration" | Try again. If it happens again, turn off **Level the volume** in **Edit project → Inputs → Pauses and volume**, then download diagnostics and report it. |
|
|
69
69
|
| "The style preview couldn't render ... Press Render again." | Press **Render again**. If it fails again, download diagnostics and report it. |
|
|
70
|
-
| "The free subtitle model could not be downloaded" / "download was interrupted" / "failed download verification" | The
|
|
70
|
+
| "The free subtitle model could not be downloaded" / "download was interrupted" / "failed download verification" | The caption model (378 MB by default, the size chosen in Settings → Production defaults → **Subtitle timing model**) downloads on first use. Check your connection, then **Continue** the run, or pick a smaller model there. `SLOPIFY_NO_MODEL_PREFETCH` only skips the download at start; captions still need it. |
|
|
71
71
|
| "Captions can't be edited until the current narration has finished and its subtitle timing is ready." | Let the narration and subtitle timing finish, then edit captions. |
|
|
72
72
|
| "The saved subtitle timing doesn't match the current narration." | Do what the message says: make the subtitle timing again (More → make it again), then edit the captions. |
|
|
73
73
|
| "Subtitles recovered after missing narration (N)" under the video | Some transcript passages could not be matched to the audio and were left out of the captions. The audio is fine. Review those passages before sharing. |
|
|
@@ -90,7 +90,7 @@ With **Level the volume** on, the bed is mixed and ducked first, then the whole
|
|
|
90
90
|
| **Subtitle font size** | How big burned-in captions are. Larger is easier to read on phones but covers more of the picture. | 48; 16 to 120 |
|
|
91
91
|
| **Subtitle position** | Where burned-in captions sit: **Top**, **Upper-middle**, **Center**, **Lower-middle** or **Bottom**. Move them up when pictures have detail near the bottom. | Bottom |
|
|
92
92
|
|
|
93
|
-
Captions are timed from your narration on your computer, with no paid API. The first use downloads a speech model of about
|
|
93
|
+
Captions are timed from your narration on your computer, with no paid API. The first use downloads a speech model of about 378 MB (Settings → Production defaults → **Subtitle timing model** offers smaller ones). Subtitle files carry no font, size or position, because players style them. Changing the font, size or position remakes only the video.
|
|
94
94
|
|
|
95
95
|
Subtitles need narration: with Audio off the section says "Turn Audio on to add subtitles." With Video off (an audio export), only subtitle files are offered.
|
|
96
96
|
|