@gentbajko/slopify 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SUBTITLES.md +21 -0
- package/dist/adapter-registry.js +8 -3
- package/dist/adapters/alignment/audio.js +45 -0
- package/dist/adapters/alignment/cache.js +91 -0
- package/dist/adapters/alignment/ctc.js +128 -0
- package/dist/adapters/alignment/index.js +34 -0
- package/dist/adapters/alignment/lock.js +58 -0
- package/dist/adapters/alignment/numbers.js +91 -0
- package/dist/adapters/alignment/protocol.js +23 -0
- package/dist/adapters/alignment/quality.js +17 -0
- package/dist/adapters/alignment/runner.js +76 -0
- package/dist/adapters/alignment/text.js +61 -0
- package/dist/adapters/alignment/vocabulary.js +69 -0
- package/dist/adapters/alignment/worker.js +163 -0
- package/dist/adapters/llm/claude-code.js +6 -3
- package/dist/adapters/llm/codex.js +3 -2
- package/dist/adapters/llm/gemini-workspace.js +70 -0
- package/dist/adapters/llm/gemini.js +141 -0
- package/dist/adapters/llm/run-cli.js +6 -9
- package/dist/assets/fonts/Barlow-Regular.ttf +0 -0
- package/dist/assets/fonts/OFL.txt +93 -0
- package/dist/assets/fonts/SOURCE.txt +7 -0
- package/dist/edge/events/hub.js +4 -0
- package/dist/edge/http/actions.js +68 -14
- package/dist/edge/http/app.js +4 -0
- package/dist/edge/http/fonts.js +147 -0
- package/dist/edge/http/projects.js +40 -11
- package/dist/edge/http/providers.js +13 -0
- package/dist/edge/http/subtitles.js +92 -0
- package/dist/kernel/cli-command.js +45 -0
- package/dist/kernel/db/migrations/0002-project-controls.sql +4 -0
- package/dist/kernel/pipeline.js +10 -5
- package/dist/kernel/ports/subtitles.js +1 -0
- package/dist/kernel/runner/graph.js +18 -18
- package/dist/kernel/runner/index.js +10 -6
- package/dist/kernel/runner/piece-repo.js +8 -1
- package/dist/kernel/runner/providers.js +3 -2
- package/dist/main.js +32 -4
- package/dist/slices/admission/model.js +2 -2
- package/dist/slices/admission/repo.js +22 -4
- package/dist/slices/admission/rules.js +32 -12
- package/dist/slices/admission/start.js +3 -1
- package/dist/slices/article/provided-entries.js +62 -0
- package/dist/slices/article/run.js +63 -101
- package/dist/slices/article/segments.js +73 -0
- package/dist/slices/cancel/index.js +27 -4
- package/dist/slices/control/index.js +155 -0
- package/dist/slices/control/lock.js +19 -0
- package/dist/slices/control/providers.js +59 -0
- package/dist/slices/fonts/catalog.js +95 -0
- package/dist/slices/fonts/discovery.js +81 -0
- package/dist/slices/fonts/files.js +37 -0
- package/dist/slices/fonts/index.js +5 -0
- package/dist/slices/fonts/model.js +4 -0
- package/dist/slices/fonts/preview.js +14 -0
- package/dist/slices/fonts/sfnt.js +262 -0
- package/dist/slices/fonts/upload.js +40 -0
- package/dist/slices/library/slots.js +3 -1
- package/dist/slices/reruns/cascade.js +11 -7
- package/dist/slices/reruns/index.js +23 -1
- package/dist/slices/settings/cli-paths.js +98 -0
- package/dist/slices/settings/cli-status.js +21 -12
- package/dist/slices/settings/model.js +9 -0
- package/dist/slices/settings/readiness.js +13 -9
- package/dist/slices/storage/downloads.js +2 -0
- package/dist/slices/storage/layout.js +12 -0
- package/dist/slices/storage/model.js +6 -0
- package/dist/slices/storage/repo.js +1 -0
- package/dist/slices/subtitles/captions.js +88 -0
- package/dist/slices/subtitles/model.js +18 -0
- package/dist/slices/subtitles/prepare.js +162 -0
- package/dist/slices/subtitles/transcript.js +34 -0
- package/dist/slices/video/audio-export.js +69 -0
- package/dist/slices/video/audio-inputs.js +32 -0
- package/dist/slices/video/ffmpeg.js +19 -12
- package/dist/slices/video/plan.js +13 -12
- package/dist/slices/video/run.js +28 -141
- package/dist/slices/video/write-export.js +163 -0
- package/dist/web/assets/index-CNYs0noB.js +130 -0
- package/dist/web/assets/index-Mk0bvBg-.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-BZvZKvIv.js +0 -81
- package/dist/web/assets/index-cpjfvs5E.css +0 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, stat } from "node:fs/promises";
|
|
3
|
+
import { extname, isAbsolute } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { cliProbeTimeoutMs, readinessFromProbe } from "./cli-status.js";
|
|
6
|
+
import { providerById } from "./model.js";
|
|
7
|
+
import { readSetting, writeSetting } from "./repo.js";
|
|
8
|
+
export const cliPathMaxLength = 4096;
|
|
9
|
+
const pathSchema = z
|
|
10
|
+
.string()
|
|
11
|
+
.trim()
|
|
12
|
+
.max(cliPathMaxLength)
|
|
13
|
+
.refine((value) => value === "" || (isAbsolute(value) && !/[\p{Cc}]/u.test(value)));
|
|
14
|
+
const storedPathSchema = z.string().max(cliPathMaxLength).nullable();
|
|
15
|
+
const saves = new WeakMap();
|
|
16
|
+
export function cliPathStatus(db, id) {
|
|
17
|
+
const provider = providerById(id);
|
|
18
|
+
if (provider.auth !== "cli")
|
|
19
|
+
throw new Error(`${id} is not a CLI provider`);
|
|
20
|
+
const stored = readSetting(db, `cli.path.${id}`);
|
|
21
|
+
const configured = stored === undefined ? null : storedPathSchema.parse(JSON.parse(stored));
|
|
22
|
+
return { configured, command: configured ?? provider.binary };
|
|
23
|
+
}
|
|
24
|
+
// Read on each attempt, so a saved path reaches the next invocation without a restart.
|
|
25
|
+
export function cliBinary(db, id) {
|
|
26
|
+
return cliPathStatus(db, id).command;
|
|
27
|
+
}
|
|
28
|
+
export async function saveCliPath(deps, id, raw) {
|
|
29
|
+
// A slow version probe must not overwrite a later Save or Reset from another tab.
|
|
30
|
+
let queue = saves.get(deps.db);
|
|
31
|
+
if (queue === undefined) {
|
|
32
|
+
queue = new Map();
|
|
33
|
+
saves.set(deps.db, queue);
|
|
34
|
+
}
|
|
35
|
+
const previous = queue.get(id) ?? Promise.resolve();
|
|
36
|
+
const next = previous.then(() => save(deps, id, raw));
|
|
37
|
+
const settled = next.then(() => { }, () => { });
|
|
38
|
+
queue.set(id, settled);
|
|
39
|
+
try {
|
|
40
|
+
return await next;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
if (queue.get(id) === settled)
|
|
44
|
+
queue.delete(id);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function save(deps, id, raw) {
|
|
48
|
+
const provider = providerById(id);
|
|
49
|
+
if (provider.auth !== "cli")
|
|
50
|
+
return { ok: false, message: `${provider.displayName} uses an API key, not a CLI executable.` };
|
|
51
|
+
const parsed = pathSchema.safeParse(raw);
|
|
52
|
+
if (!parsed.success)
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
message: "Enter an absolute executable file path without quotes or arguments, or leave it blank to use PATH.",
|
|
56
|
+
};
|
|
57
|
+
const configured = parsed.data === "" ? null : parsed.data;
|
|
58
|
+
if (configured !== null) {
|
|
59
|
+
try {
|
|
60
|
+
if (!(await stat(configured)).isFile())
|
|
61
|
+
return { ok: false, message: "Choose an executable file, not a directory." };
|
|
62
|
+
await access(configured, process.platform === "win32" ||
|
|
63
|
+
[".js", ".mjs", ".cjs"].includes(extname(configured).toLowerCase())
|
|
64
|
+
? constants.R_OK
|
|
65
|
+
: constants.X_OK);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof Error &&
|
|
69
|
+
"code" in error &&
|
|
70
|
+
["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP"].includes(String(error.code)))
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
message: "That executable file cannot be found or run. Check its path and permissions.",
|
|
74
|
+
};
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const command = configured ?? provider.binary;
|
|
79
|
+
const probed = await deps.probe(command, provider.versionArgs, cliProbeTimeoutMs);
|
|
80
|
+
const readiness = readinessFromProbe(probed);
|
|
81
|
+
if (configured !== null && readiness.kind === "cli" && !readiness.installed)
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
message: probed.error ??
|
|
85
|
+
`${provider.displayName} did not answer --version successfully within ${cliProbeTimeoutMs / 1000} seconds. Choose its executable file without arguments.`,
|
|
86
|
+
};
|
|
87
|
+
writeSetting(deps.db, `cli.path.${id}`, JSON.stringify(configured));
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
status: {
|
|
91
|
+
id,
|
|
92
|
+
family: provider.family,
|
|
93
|
+
displayName: provider.displayName,
|
|
94
|
+
readiness,
|
|
95
|
+
cliPath: { configured, command },
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
export const cliProbeTimeoutMs = 2000;
|
|
2
|
+
import { cliCommand } from "../../kernel/cli-command.js";
|
|
3
|
+
// Gemini's Node startup can take several seconds on Windows. Providers are probed in
|
|
4
|
+
// parallel; a hung process is bounded without treating ordinary startup as missing.
|
|
5
|
+
export const cliProbeTimeoutMs = 15_000;
|
|
7
6
|
const probeOutputMax = 64 * 1024;
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// answer, not a failure. ceiling: POSIX only. A Windows install puts `claude` on PATH as a
|
|
11
|
-
// `.cmd` shim, which execFile cannot run without a shell; a shim-aware lookup is the upgrade
|
|
12
|
-
// when Windows is supported.
|
|
7
|
+
// Known Windows package-manager shims are resolved to an executable plus fixed args;
|
|
8
|
+
// user input never becomes a shell command. Missing/unsupported commands mean not ready.
|
|
13
9
|
export function nodeCliProbe(binary, args, timeoutMs) {
|
|
10
|
+
let command;
|
|
11
|
+
try {
|
|
12
|
+
command = cliCommand(binary);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
return Promise.resolve({
|
|
16
|
+
ran: false,
|
|
17
|
+
stdout: "",
|
|
18
|
+
error: error instanceof Error ? error.message : "The CLI launcher could not be resolved.",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
14
21
|
return new Promise((resolve) => {
|
|
15
|
-
execFile(
|
|
22
|
+
execFile(command.file, [...command.args, ...args], { timeout: timeoutMs, maxBuffer: probeOutputMax, windowsHide: true }, (error, stdout) => {
|
|
16
23
|
resolve(error === null ? { ran: true, stdout } : { ran: false, stdout: "" });
|
|
17
24
|
});
|
|
18
25
|
});
|
|
19
26
|
}
|
|
20
27
|
export async function cliReadiness(probe, provider) {
|
|
21
|
-
|
|
28
|
+
return readinessFromProbe(await probe(provider.binary, provider.versionArgs, cliProbeTimeoutMs));
|
|
29
|
+
}
|
|
30
|
+
export function readinessFromProbe(result) {
|
|
22
31
|
if (!result.ran) {
|
|
23
32
|
return { kind: "cli", installed: false };
|
|
24
33
|
}
|
|
@@ -8,6 +8,7 @@ export const providerIds = [
|
|
|
8
8
|
"openrouter",
|
|
9
9
|
"claude-code",
|
|
10
10
|
"codex",
|
|
11
|
+
"gemini",
|
|
11
12
|
"elevenlabs",
|
|
12
13
|
"openai-tts",
|
|
13
14
|
"cartesia",
|
|
@@ -34,6 +35,14 @@ export const providers = [
|
|
|
34
35
|
binary: "codex",
|
|
35
36
|
versionArgs: ["--version"],
|
|
36
37
|
},
|
|
38
|
+
{
|
|
39
|
+
id: "gemini",
|
|
40
|
+
family: "llm",
|
|
41
|
+
displayName: "Gemini CLI",
|
|
42
|
+
auth: "cli",
|
|
43
|
+
binary: "gemini",
|
|
44
|
+
versionArgs: ["--version"],
|
|
45
|
+
},
|
|
37
46
|
{ id: "elevenlabs", family: "tts", displayName: "ElevenLabs", auth: "key" },
|
|
38
47
|
{ id: "openai-tts", family: "tts", displayName: "OpenAI", auth: "key" },
|
|
39
48
|
{ id: "cartesia", family: "tts", displayName: "Cartesia", auth: "key" },
|
|
@@ -1,19 +1,23 @@
|
|
|
1
|
+
import { cliPathStatus } from "./cli-paths.js";
|
|
1
2
|
import { cliReadiness } from "./cli-status.js";
|
|
2
3
|
import { providers } from "./model.js";
|
|
3
4
|
import { keyedProviders } from "./repo.js";
|
|
4
5
|
// Every supported provider is listed, keyed or not, found or not, so Play can grey one out with
|
|
5
6
|
// a reason instead of hiding it. A keyed provider is ready when a key is stored; a CLI provider
|
|
6
|
-
// when its binary answers. Readiness is computed per request
|
|
7
|
+
// when its binary answers. Readiness is computed per request using the latest saved executable override.
|
|
7
8
|
export async function providerStatuses(deps) {
|
|
8
9
|
const keyed = keyedProviders(deps.db);
|
|
9
|
-
return await Promise.all(providers.map(async (provider) =>
|
|
10
|
-
id: provider.id,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
return await Promise.all(providers.map(async (provider) => {
|
|
11
|
+
const base = { id: provider.id, family: provider.family, displayName: provider.displayName };
|
|
12
|
+
if (provider.auth !== "cli")
|
|
13
|
+
return { ...base, readiness: keyedReadiness(keyed, provider.id) };
|
|
14
|
+
const cliPath = cliPathStatus(deps.db, provider.id);
|
|
15
|
+
return {
|
|
16
|
+
...base,
|
|
17
|
+
cliPath,
|
|
18
|
+
readiness: await cliReadiness(deps.probe, { ...provider, binary: cliPath.command }),
|
|
19
|
+
};
|
|
20
|
+
}));
|
|
17
21
|
}
|
|
18
22
|
function keyedReadiness(keyed, id) {
|
|
19
23
|
return { kind: "keyed", hasKey: keyed.has(id) };
|
|
@@ -19,6 +19,8 @@ const contentTypes = {
|
|
|
19
19
|
".ogg": "audio/ogg",
|
|
20
20
|
".opus": "audio/opus",
|
|
21
21
|
".mp4": "video/mp4",
|
|
22
|
+
".srt": "application/x-subrip; charset=utf-8",
|
|
23
|
+
".vtt": "text/vtt; charset=utf-8",
|
|
22
24
|
};
|
|
23
25
|
// Leaves room for "-<asset>.<ext>" inside a 255-byte filename, and keeps a download
|
|
24
26
|
// folder readable when a title runs to the 200 characters the schema allows.
|
|
@@ -34,6 +34,16 @@ export function outputFileName(role, index, extension, stageKind) {
|
|
|
34
34
|
return "sources.txt";
|
|
35
35
|
case "glossary":
|
|
36
36
|
return "glossary.txt";
|
|
37
|
+
case "subtitles_srt":
|
|
38
|
+
return "subtitles.srt";
|
|
39
|
+
case "subtitles_vtt":
|
|
40
|
+
return "subtitles.vtt";
|
|
41
|
+
case "subtitle_words":
|
|
42
|
+
return "subtitles.json";
|
|
43
|
+
case "subtitle_ass":
|
|
44
|
+
return "subtitles.ass";
|
|
45
|
+
case "subtitle_font":
|
|
46
|
+
return `subtitle-font${extension}`;
|
|
37
47
|
case "render_params":
|
|
38
48
|
return "render.json";
|
|
39
49
|
case "instructions":
|
|
@@ -42,6 +52,8 @@ export function outputFileName(role, index, extension, stageKind) {
|
|
|
42
52
|
return "video.mp4";
|
|
43
53
|
case "audio_body":
|
|
44
54
|
return `audio-body${extension}`;
|
|
55
|
+
case "audio_export":
|
|
56
|
+
return "audio.wav";
|
|
45
57
|
case "audio_intro":
|
|
46
58
|
return `audio-intro${extension}`;
|
|
47
59
|
case "audio_outro":
|
|
@@ -11,10 +11,16 @@ export const outputRoles = [
|
|
|
11
11
|
"audio_body",
|
|
12
12
|
"audio_intro",
|
|
13
13
|
"audio_outro",
|
|
14
|
+
"audio_export",
|
|
14
15
|
"image",
|
|
15
16
|
"thumbnail",
|
|
16
17
|
"video",
|
|
17
18
|
"render_params",
|
|
19
|
+
"subtitles_srt",
|
|
20
|
+
"subtitles_vtt",
|
|
21
|
+
"subtitle_words",
|
|
22
|
+
"subtitle_ass",
|
|
23
|
+
"subtitle_font",
|
|
18
24
|
"instructions",
|
|
19
25
|
];
|
|
20
26
|
export const stagedFileStates = ["copying", "staged"];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { outputRoles, stagedFileStates, stageKinds } from "./model.js";
|
|
3
3
|
const metaSchema = z.object({
|
|
4
|
+
subtitlesMode: z.enum(["off", "files", "burn-in"]).optional(),
|
|
4
5
|
promptName: z.string().optional(),
|
|
5
6
|
prompt: z.string().optional(),
|
|
6
7
|
index: z.number().optional(),
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Keep spoken spelling and punctuation. Word times come only from acoustic alignment.
|
|
2
|
+
export function captionCues(words) {
|
|
3
|
+
const cues = [];
|
|
4
|
+
let pending = [];
|
|
5
|
+
let previousEnd = 0;
|
|
6
|
+
const flush = () => {
|
|
7
|
+
const first = pending[0];
|
|
8
|
+
const last = pending.at(-1);
|
|
9
|
+
if (first !== undefined && last !== undefined) {
|
|
10
|
+
cues.push({
|
|
11
|
+
start: first.start,
|
|
12
|
+
end: last.end,
|
|
13
|
+
text: wrap(pending.map((word) => word.text)),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
pending = [];
|
|
17
|
+
};
|
|
18
|
+
for (const word of words) {
|
|
19
|
+
if (!Number.isFinite(word.start) ||
|
|
20
|
+
!Number.isFinite(word.end) ||
|
|
21
|
+
word.start < previousEnd - 0.001 ||
|
|
22
|
+
word.end <= word.start) {
|
|
23
|
+
throw new Error("The subtitle word timing is invalid; regenerate alignment.");
|
|
24
|
+
}
|
|
25
|
+
const first = pending[0];
|
|
26
|
+
if (first !== undefined &&
|
|
27
|
+
(word.start - previousEnd > 0.7 ||
|
|
28
|
+
word.end - first.start > 5 ||
|
|
29
|
+
wrap([...pending.map((one) => one.text), word.text]).split("\n").length > 2)) {
|
|
30
|
+
flush();
|
|
31
|
+
}
|
|
32
|
+
pending.push({ ...word, text: word.text.replace(/\s+/g, " ").trim() });
|
|
33
|
+
previousEnd = word.end;
|
|
34
|
+
if (/[.!?]["'’”)]*$/.test(word.text))
|
|
35
|
+
flush();
|
|
36
|
+
}
|
|
37
|
+
flush();
|
|
38
|
+
return cues;
|
|
39
|
+
}
|
|
40
|
+
function wrap(words) {
|
|
41
|
+
const lines = [];
|
|
42
|
+
let line = "";
|
|
43
|
+
for (const word of words) {
|
|
44
|
+
if (line !== "" && line.length + word.length + 1 > 42) {
|
|
45
|
+
lines.push(line);
|
|
46
|
+
line = "";
|
|
47
|
+
}
|
|
48
|
+
line = line === "" ? word : `${line} ${word}`;
|
|
49
|
+
}
|
|
50
|
+
if (line !== "")
|
|
51
|
+
lines.push(line);
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
export function serializeSrt(cues) {
|
|
55
|
+
return cues
|
|
56
|
+
.map((cue, index) => `${index + 1}\n${stamp(cue.start, ",")} --> ${stamp(cue.end, ",")}\n${markup(cue.text)}\n`)
|
|
57
|
+
.join("\n");
|
|
58
|
+
}
|
|
59
|
+
export function serializeVtt(cues) {
|
|
60
|
+
return `WEBVTT\n\n${cues.map((cue) => `${stamp(cue.start, ".")} --> ${stamp(cue.end, ".")}\n${markup(cue.text)}\n`).join("\n")}`;
|
|
61
|
+
}
|
|
62
|
+
export function serializeAss(cues, style) {
|
|
63
|
+
const name = style.fontName.replace(/[\p{Cc},]/gu, " ").trim();
|
|
64
|
+
const header = `[Script Info]\nScriptType: v4.00+\nPlayResX: ${style.width}\nPlayResY: ${style.height}\nWrapStyle: 0\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,${name},${style.fontSize},&H00FFFFFF,&H00FFFFFF,&H00101010,&H80000000,0,0,0,0,100,100,0,0,1,2.5,1,2,60,60,60,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n`;
|
|
65
|
+
return (header +
|
|
66
|
+
cues
|
|
67
|
+
.map((cue) => `Dialogue: 0,${assStamp(cue.start)},${assStamp(cue.end)},Default,,0,0,0,,${assText(cue.text)}\n`)
|
|
68
|
+
.join(""));
|
|
69
|
+
}
|
|
70
|
+
function markup(text) {
|
|
71
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
72
|
+
}
|
|
73
|
+
function assText(text) {
|
|
74
|
+
// Full-width alternatives stay visible and cannot open an ASS override or escape.
|
|
75
|
+
return text
|
|
76
|
+
.replaceAll("\\", "\")
|
|
77
|
+
.replaceAll("{", "{")
|
|
78
|
+
.replaceAll("}", "}")
|
|
79
|
+
.replaceAll("\n", "\\N");
|
|
80
|
+
}
|
|
81
|
+
function stamp(seconds, separator) {
|
|
82
|
+
const ms = Math.max(0, Math.round(seconds * 1000));
|
|
83
|
+
return `${String(Math.floor(ms / 3_600_000)).padStart(2, "0")}:${String(Math.floor(ms / 60_000) % 60).padStart(2, "0")}:${String(Math.floor(ms / 1000) % 60).padStart(2, "0")}${separator}${String(ms % 1000).padStart(3, "0")}`;
|
|
84
|
+
}
|
|
85
|
+
function assStamp(seconds) {
|
|
86
|
+
const cs = Math.max(0, Math.round(seconds * 100));
|
|
87
|
+
return `${Math.floor(cs / 360_000)}:${String(Math.floor(cs / 6000) % 60).padStart(2, "0")}:${String(Math.floor(cs / 100) % 60).padStart(2, "0")}.${String(cs % 100).padStart(2, "0")}`;
|
|
88
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const subtitleModes = ["off", "files", "burn-in"];
|
|
3
|
+
export const subtitleConfigSchema = z.object({
|
|
4
|
+
mode: z.enum(subtitleModes),
|
|
5
|
+
language: z.literal("en").default("en"),
|
|
6
|
+
fontId: z
|
|
7
|
+
.string()
|
|
8
|
+
.max(160)
|
|
9
|
+
.regex(/^[A-Za-z0-9_-]+$/)
|
|
10
|
+
.default("default"),
|
|
11
|
+
fontSize: z.number().int().min(16).max(120).default(48),
|
|
12
|
+
});
|
|
13
|
+
export const defaultSubtitles = {
|
|
14
|
+
mode: "off",
|
|
15
|
+
language: "en",
|
|
16
|
+
fontId: "default",
|
|
17
|
+
fontSize: 48,
|
|
18
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { copyFileSync, createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { join, relative, sep } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { projectById, setStageProgress } from "../admission/repo.js";
|
|
6
|
+
import { resolveFont } from "../fonts/index.js";
|
|
7
|
+
import { outputPath, projectDir } from "../storage/layout.js";
|
|
8
|
+
import { outputsOf } from "../storage/repo.js";
|
|
9
|
+
import { captionCues, serializeAss, serializeSrt, serializeVtt } from "./captions.js";
|
|
10
|
+
import { defaultSubtitles } from "./model.js";
|
|
11
|
+
import { spokenText } from "./transcript.js";
|
|
12
|
+
export const subtitleRoles = [
|
|
13
|
+
"subtitles_srt",
|
|
14
|
+
"subtitles_vtt",
|
|
15
|
+
"subtitle_words",
|
|
16
|
+
"subtitle_ass",
|
|
17
|
+
"subtitle_font",
|
|
18
|
+
];
|
|
19
|
+
const wordSchema = z.object({
|
|
20
|
+
text: z.string(),
|
|
21
|
+
start: z.number().nonnegative(),
|
|
22
|
+
end: z.number().positive(),
|
|
23
|
+
confidence: z.number().optional(),
|
|
24
|
+
});
|
|
25
|
+
const fontSchema = z.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
name: z.string(),
|
|
28
|
+
assName: z.string(),
|
|
29
|
+
extension: z.enum([".ttf", ".otf", ".ttc"]),
|
|
30
|
+
});
|
|
31
|
+
const cacheSchema = z.object({ key: z.string(), words: z.array(wordSchema), font: fontSchema });
|
|
32
|
+
// Preparation writes into a new directory; only writeExport commits its output rows.
|
|
33
|
+
// The old caption files/font/timing stay usable if alignment or rendering fails.
|
|
34
|
+
export async function prepareSubtitles(deps, context, audio, frame) {
|
|
35
|
+
const { projectId } = context.stage;
|
|
36
|
+
const project = projectById(deps.db, projectId);
|
|
37
|
+
if (project === undefined)
|
|
38
|
+
throw new Error("The project no longer exists.");
|
|
39
|
+
const config = project.config.subtitles ?? defaultSubtitles;
|
|
40
|
+
if (config.mode === "off")
|
|
41
|
+
return undefined;
|
|
42
|
+
if (audio.length === 0)
|
|
43
|
+
throw new Error("Subtitles need narration audio.");
|
|
44
|
+
const outputs = outputsOf(deps.db, projectId);
|
|
45
|
+
const segments = audio.map((segment) => ({
|
|
46
|
+
...segment,
|
|
47
|
+
text: segment.kind === "gap" ? "" : spokenText(deps, projectId, segment.kind, outputs),
|
|
48
|
+
}));
|
|
49
|
+
const key = await timingKey(segments, context.signal);
|
|
50
|
+
const cache = readCache(deps, projectId, outputs);
|
|
51
|
+
const dir = projectDir(deps.paths, projectId);
|
|
52
|
+
const directory = mkdtempSync(join(dir, "captions-"));
|
|
53
|
+
try {
|
|
54
|
+
const font = await snapshotFont(deps, projectId, outputs, config.fontId, cache, directory);
|
|
55
|
+
const words = cache?.key === key ? cache.words : await alignSegments(deps, context, segments);
|
|
56
|
+
context.signal.throwIfAborted();
|
|
57
|
+
const cues = captionCues(words);
|
|
58
|
+
if (cues.length === 0)
|
|
59
|
+
throw new Error("No spoken words could be aligned. Check that the English article matches the audio.");
|
|
60
|
+
writeFileSync(join(directory, "subtitles.srt"), serializeSrt(cues), { mode: 0o600 });
|
|
61
|
+
writeFileSync(join(directory, "subtitles.vtt"), serializeVtt(cues), { mode: 0o600 });
|
|
62
|
+
writeFileSync(join(directory, "subtitles.ass"), serializeAss(cues, { ...frame, fontSize: config.fontSize, fontName: font.assName }), { mode: 0o600 });
|
|
63
|
+
writeFileSync(join(directory, "subtitles.json"), JSON.stringify({ key, words, font }), {
|
|
64
|
+
mode: 0o600,
|
|
65
|
+
});
|
|
66
|
+
const files = [
|
|
67
|
+
["subtitles_srt", "subtitles.srt"],
|
|
68
|
+
["subtitles_vtt", "subtitles.vtt"],
|
|
69
|
+
["subtitle_words", "subtitles.json"],
|
|
70
|
+
["subtitle_ass", "subtitles.ass"],
|
|
71
|
+
["subtitle_font", `fonts/selected${font.extension}`],
|
|
72
|
+
];
|
|
73
|
+
return {
|
|
74
|
+
directory,
|
|
75
|
+
burnIn: config.mode === "burn-in" && project.config.sources.video !== "off",
|
|
76
|
+
assets: files.map(([role, path]) => ({
|
|
77
|
+
role,
|
|
78
|
+
path: relative(dir, join(directory, path)).split(sep).join("/"),
|
|
79
|
+
})),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
rmSync(directory, { recursive: true, force: true });
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function timingKey(segments, signal) {
|
|
88
|
+
// Bump when alignment normalization/model changes. Hash file contents, not timestamps.
|
|
89
|
+
const hash = createHash("sha256").update("wav2vec2-en-a19f851-v1");
|
|
90
|
+
for (const segment of segments) {
|
|
91
|
+
signal.throwIfAborted();
|
|
92
|
+
hash.update(JSON.stringify({ kind: segment.kind, seconds: segment.seconds, text: segment.text }));
|
|
93
|
+
if (segment.path !== null) {
|
|
94
|
+
const stream = createReadStream(segment.path, { signal });
|
|
95
|
+
for await (const chunk of stream)
|
|
96
|
+
hash.update(chunk);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return hash.digest("hex");
|
|
100
|
+
}
|
|
101
|
+
async function alignSegments(deps, context, segments) {
|
|
102
|
+
if (deps.alignSubtitles === undefined)
|
|
103
|
+
throw new Error("Local subtitle alignment is unavailable in this build.");
|
|
104
|
+
const words = [];
|
|
105
|
+
const total = segments.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
106
|
+
let offset = 0;
|
|
107
|
+
for (const segment of segments) {
|
|
108
|
+
context.signal.throwIfAborted();
|
|
109
|
+
if (segment.path !== null) {
|
|
110
|
+
const aligned = await deps.alignSubtitles({
|
|
111
|
+
audioPath: segment.path,
|
|
112
|
+
text: segment.text,
|
|
113
|
+
cacheDir: join(deps.paths.dataDir, "models", "english-subtitles"),
|
|
114
|
+
ffmpeg: deps.ffmpeg,
|
|
115
|
+
signal: context.signal,
|
|
116
|
+
onProgress: (current, maximum) => {
|
|
117
|
+
const progress = Math.round((35 * (offset + segment.seconds * (maximum > 0 ? current / maximum : 0))) / total);
|
|
118
|
+
setStageProgress(deps.db, context.stage.id, progress, 100);
|
|
119
|
+
context.emit({
|
|
120
|
+
type: "stage.progress",
|
|
121
|
+
projectId: context.stage.projectId,
|
|
122
|
+
stage: "video",
|
|
123
|
+
current: progress,
|
|
124
|
+
total: 100,
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
for (const word of aligned) {
|
|
129
|
+
if (word.end > segment.seconds + 0.1)
|
|
130
|
+
throw new Error("Subtitle timing exceeds the narration duration.");
|
|
131
|
+
words.push({
|
|
132
|
+
...word,
|
|
133
|
+
start: word.start + offset,
|
|
134
|
+
end: Math.min(word.end, segment.seconds) + offset,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
offset += segment.seconds;
|
|
139
|
+
}
|
|
140
|
+
return words;
|
|
141
|
+
}
|
|
142
|
+
function readCache(deps, projectId, outputs) {
|
|
143
|
+
const output = outputs.find((one) => one.role === "subtitle_words");
|
|
144
|
+
if (output === undefined)
|
|
145
|
+
return undefined;
|
|
146
|
+
try {
|
|
147
|
+
return cacheSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, projectId, output.path), "utf8")));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function snapshotFont(deps, projectId, outputs, id, cache, directory) {
|
|
154
|
+
const previous = outputs.find((one) => one.role === "subtitle_font");
|
|
155
|
+
const previousPath = previous === undefined ? undefined : outputPath(deps.paths, projectId, previous.path);
|
|
156
|
+
const saved = cache?.font.id === id && previousPath !== undefined && existsSync(previousPath)
|
|
157
|
+
? { ...cache.font, path: previousPath }
|
|
158
|
+
: await resolveFont(deps.paths, id);
|
|
159
|
+
mkdirSync(join(directory, "fonts"), { mode: 0o700 });
|
|
160
|
+
copyFileSync(saved.path, join(directory, "fonts", `selected${saved.extension}`));
|
|
161
|
+
return { id: saved.id, name: saved.name, assName: saved.assName, extension: saved.extension };
|
|
162
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { piecesOf } from "../../kernel/runner/piece-repo.js";
|
|
4
|
+
import { stagesOf } from "../admission/repo.js";
|
|
5
|
+
import { outputPath } from "../storage/layout.js";
|
|
6
|
+
const textPayload = z.object({ text: z.string(), category: z.string().optional() });
|
|
7
|
+
export function spokenText(deps, projectId, kind, outputs) {
|
|
8
|
+
const stages = stagesOf(deps.db, projectId);
|
|
9
|
+
if (kind === "body") {
|
|
10
|
+
const audio = stages.find((stage) => stage.kind === "audio");
|
|
11
|
+
if (audio?.source === "generate") {
|
|
12
|
+
const chunks = piecesOf(deps.db, audio.id, "chunk");
|
|
13
|
+
if (chunks.length > 0)
|
|
14
|
+
return chunks.map((piece) => payload(piece.payload).text).join("\n");
|
|
15
|
+
}
|
|
16
|
+
const article = outputs.find((output) => output.role === "article_txt");
|
|
17
|
+
if (article !== undefined)
|
|
18
|
+
return readFileSync(outputPath(deps.paths, projectId, article.path), "utf8");
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
const article = stages.find((stage) => stage.kind === "article");
|
|
22
|
+
if (article !== undefined) {
|
|
23
|
+
const segment = piecesOf(deps.db, article.id, "segment")
|
|
24
|
+
.map((piece) => payload(piece.payload))
|
|
25
|
+
.find((piece) => piece.category === kind);
|
|
26
|
+
if (segment !== undefined)
|
|
27
|
+
return segment.text;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`The saved ${kind} narration text is missing. Subtitles need the text spoken in the audio.`);
|
|
31
|
+
}
|
|
32
|
+
function payload(raw) {
|
|
33
|
+
return textPayload.parse(JSON.parse(raw ?? "null"));
|
|
34
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { projectDir } from "../storage/layout.js";
|
|
3
|
+
import { outputsOf } from "../storage/repo.js";
|
|
4
|
+
import { prepareSubtitles } from "../subtitles/prepare.js";
|
|
5
|
+
import { audioInputs } from "./audio-inputs.js";
|
|
6
|
+
import { audioTimeline } from "./plan.js";
|
|
7
|
+
import { writeExport } from "./write-export.js";
|
|
8
|
+
const rate = 48000;
|
|
9
|
+
export async function exportAudioWav(deps, context, gapSeconds) {
|
|
10
|
+
const { projectId } = context.stage;
|
|
11
|
+
const input = await audioInputs(deps, projectId, outputsOf(deps.db, projectId), context.signal);
|
|
12
|
+
const audio = audioTimeline({ ...input, gapSeconds }, 1 / rate);
|
|
13
|
+
const totalSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
14
|
+
const dir = projectDir(deps.paths, projectId);
|
|
15
|
+
const plan = {
|
|
16
|
+
sampleRate: rate,
|
|
17
|
+
channels: 2,
|
|
18
|
+
codec: "pcm_s16le",
|
|
19
|
+
gapSeconds,
|
|
20
|
+
totalSeconds,
|
|
21
|
+
audio: audio.map((segment) => ({
|
|
22
|
+
...segment,
|
|
23
|
+
path: segment.path === null ? null : relative(dir, segment.path),
|
|
24
|
+
})),
|
|
25
|
+
output: "audio.wav",
|
|
26
|
+
};
|
|
27
|
+
const subtitles = await prepareSubtitles(deps, context, audio, { width: 1920, height: 1080 });
|
|
28
|
+
await writeExport(deps, context, {
|
|
29
|
+
role: "audio_export",
|
|
30
|
+
filename: "audio.wav",
|
|
31
|
+
partName: "audio.part.wav",
|
|
32
|
+
totalSeconds,
|
|
33
|
+
record: plan,
|
|
34
|
+
subtitles,
|
|
35
|
+
args: (part) => audioExportArgs(audio, part),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export function audioExportArgs(audio, output) {
|
|
39
|
+
const inputs = audio.flatMap((segment) => segment.path === null
|
|
40
|
+
? ["-f", "lavfi", "-t", segment.seconds.toFixed(6), "-i", `anullsrc=r=${rate}:cl=stereo`]
|
|
41
|
+
: ["-i", segment.path]);
|
|
42
|
+
const chains = audio.map((_segment, index) => `[${index}:a]aformat=sample_fmts=s16:sample_rates=${rate}:channel_layouts=stereo,asetpts=PTS-STARTPTS[a${index}]`);
|
|
43
|
+
chains.push(`${audio.map((_segment, index) => `[a${index}]`).join("")}concat=n=${audio.length}:v=0:a=1[a]`);
|
|
44
|
+
return [
|
|
45
|
+
"-hide_banner",
|
|
46
|
+
"-nostdin",
|
|
47
|
+
"-loglevel",
|
|
48
|
+
"error",
|
|
49
|
+
"-progress",
|
|
50
|
+
"pipe:1",
|
|
51
|
+
"-nostats",
|
|
52
|
+
"-y",
|
|
53
|
+
...inputs,
|
|
54
|
+
"-filter_complex",
|
|
55
|
+
chains.join(";"),
|
|
56
|
+
"-map",
|
|
57
|
+
"[a]",
|
|
58
|
+
"-vn",
|
|
59
|
+
"-c:a",
|
|
60
|
+
"pcm_s16le",
|
|
61
|
+
"-ar",
|
|
62
|
+
String(rate),
|
|
63
|
+
"-ac",
|
|
64
|
+
"2",
|
|
65
|
+
"-f",
|
|
66
|
+
"wav",
|
|
67
|
+
output,
|
|
68
|
+
];
|
|
69
|
+
}
|