@gentbajko/slopify 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -1
- package/SUBTITLES.md +21 -0
- package/dist/adapter-registry.js +18 -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/image/google.js +12 -15
- package/dist/adapters/image/models.js +72 -0
- package/dist/adapters/image/openai.js +3 -5
- package/dist/adapters/llm/catalogue-files.js +23 -0
- package/dist/adapters/llm/claude-code.js +9 -7
- package/dist/adapters/llm/codex-models.js +46 -0
- package/dist/adapters/llm/codex.js +7 -12
- package/dist/adapters/llm/gemini-models.js +85 -0
- package/dist/adapters/llm/gemini-workspace.js +70 -0
- package/dist/adapters/llm/gemini.js +138 -0
- package/dist/adapters/llm/openrouter.js +11 -2
- package/dist/adapters/llm/run-cli.js +6 -9
- package/dist/adapters/tts/cartesia.js +9 -1
- package/dist/adapters/tts/elevenlabs.js +33 -2
- package/dist/adapters/tts/inworld-async.js +150 -0
- package/dist/adapters/tts/inworld-text.js +32 -0
- package/dist/adapters/tts/inworld.js +170 -0
- package/dist/adapters/tts/openai.js +31 -4
- 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/cli.js +5 -0
- package/dist/edge/events/hub.js +9 -2
- package/dist/edge/events/preview-cache.js +40 -0
- package/dist/edge/http/actions.js +2 -0
- package/dist/edge/http/app.js +30 -1
- package/dist/edge/http/audio-preview.js +46 -0
- package/dist/edge/http/fonts.js +147 -0
- package/dist/edge/http/projects.js +23 -1
- package/dist/edge/http/providers.js +29 -0
- package/dist/edge/http/subtitles.js +92 -0
- package/dist/edge/http/update.js +57 -0
- package/dist/edge/update-worker.js +44 -0
- package/dist/kernel/audio-preview.js +188 -0
- package/dist/kernel/cli-command.js +45 -0
- package/dist/kernel/ports/subtitles.js +1 -0
- package/dist/kernel/runner/providers.js +75 -24
- package/dist/main.js +106 -25
- package/dist/model-catalog.js +37 -0
- package/dist/slices/admission/repo.js +2 -0
- package/dist/slices/admission/rules.js +6 -0
- package/dist/slices/control/index.js +1 -0
- package/dist/slices/control/providers.js +2 -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/narration/live.js +21 -0
- package/dist/slices/narration/run.js +9 -9
- package/dist/slices/research/run.js +3 -0
- package/dist/slices/settings/cli-paths.js +98 -0
- package/dist/slices/settings/cli-status.js +21 -12
- package/dist/slices/settings/model.js +11 -0
- package/dist/slices/settings/models.js +89 -0
- package/dist/slices/settings/readiness.js +13 -9
- package/dist/slices/storage/downloads.js +2 -0
- package/dist/slices/storage/layout.js +10 -0
- package/dist/slices/storage/model.js +5 -0
- package/dist/slices/storage/repo.js +1 -0
- package/dist/slices/subtitles/captions.js +91 -0
- package/dist/slices/subtitles/layout.js +19 -0
- package/dist/slices/subtitles/model.js +27 -0
- package/dist/slices/subtitles/prepare.js +167 -0
- package/dist/slices/subtitles/transcript.js +34 -0
- package/dist/slices/video/audio-export.js +3 -0
- package/dist/slices/video/ffmpeg.js +14 -7
- package/dist/slices/video/plan.js +4 -4
- package/dist/slices/video/run.js +5 -2
- package/dist/slices/video/write-export.js +85 -20
- package/dist/updater/candidate.js +28 -0
- package/dist/updater/forward.js +35 -0
- package/dist/updater/install-flow.js +26 -0
- package/dist/updater/install.js +50 -0
- package/dist/updater/model.js +24 -0
- package/dist/updater/plan.js +135 -0
- package/dist/updater/readiness.js +15 -0
- package/dist/updater/registry.js +19 -0
- package/dist/updater/service.js +128 -0
- package/dist/updater/worker.js +136 -0
- package/dist/web/assets/index-6QIQ-TzO.css +1 -0
- package/dist/web/assets/index-CNgWL-ct.js +130 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-C1fsrIP5.css +0 -1
- package/dist/web/assets/index-DwJ_XTl3.js +0 -81
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// These marketplaces host models with different input schemas. Discovery alone cannot
|
|
2
|
+
// make an arbitrary endpoint compatible with our image request.
|
|
3
|
+
export function allowsCustomModel(provider) {
|
|
4
|
+
return provider !== "fal" && provider !== "replicate";
|
|
5
|
+
}
|
|
6
|
+
const ttlMs = 5 * 60_000;
|
|
7
|
+
const retryMs = 30_000;
|
|
8
|
+
export function createModelCatalog(deps) {
|
|
9
|
+
const cache = new Map();
|
|
10
|
+
const pending = new Map();
|
|
11
|
+
const generations = new Map();
|
|
12
|
+
async function get(provider, family, refresh = false) {
|
|
13
|
+
const active = pending.get(provider);
|
|
14
|
+
if (active !== undefined)
|
|
15
|
+
return active;
|
|
16
|
+
const previous = cache.get(provider);
|
|
17
|
+
if (!refresh && previous !== undefined && previous.expires > deps.now())
|
|
18
|
+
return previous.value;
|
|
19
|
+
const generation = generations.get(provider) ?? 0;
|
|
20
|
+
const task = Promise.resolve()
|
|
21
|
+
.then(async () => {
|
|
22
|
+
let value;
|
|
23
|
+
let lifetime = ttlMs;
|
|
24
|
+
try {
|
|
25
|
+
const models = await deps.load(provider, family);
|
|
26
|
+
value = {
|
|
27
|
+
models: uniqueModels(models),
|
|
28
|
+
allowsCustom: allowsCustomModel(provider),
|
|
29
|
+
...catalogNotice(provider),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// A catalogue is metadata, not generation. Never expose an upstream response
|
|
34
|
+
// here: it can quote credentials that belong only inside its adapter.
|
|
35
|
+
deps.report(provider);
|
|
36
|
+
const saved = previous?.value.models;
|
|
37
|
+
value = {
|
|
38
|
+
models: saved ?? deps.fallback(provider),
|
|
39
|
+
...catalogNotice(provider),
|
|
40
|
+
allowsCustom: allowsCustomModel(provider),
|
|
41
|
+
warning: saved === undefined
|
|
42
|
+
? "Could not load the model list. Showing bundled choices; refresh to try again."
|
|
43
|
+
: "Could not refresh the model list. Showing the last loaded choices.",
|
|
44
|
+
};
|
|
45
|
+
lifetime = retryMs;
|
|
46
|
+
}
|
|
47
|
+
if ((generations.get(provider) ?? 0) === generation) {
|
|
48
|
+
cache.set(provider, { value, expires: deps.now() + lifetime });
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
})
|
|
52
|
+
.finally(() => {
|
|
53
|
+
if (pending.get(provider) === task)
|
|
54
|
+
pending.delete(provider);
|
|
55
|
+
});
|
|
56
|
+
pending.set(provider, task);
|
|
57
|
+
return task;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
get,
|
|
61
|
+
invalidate: (provider) => {
|
|
62
|
+
generations.set(provider, (generations.get(provider) ?? 0) + 1);
|
|
63
|
+
cache.delete(provider);
|
|
64
|
+
pending.delete(provider);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function uniqueModels(models) {
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
return models.filter((model) => {
|
|
71
|
+
if (model.id.trim() === "" || seen.has(model.id))
|
|
72
|
+
return false;
|
|
73
|
+
seen.add(model.id);
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function catalogNotice(provider) {
|
|
78
|
+
const notices = {
|
|
79
|
+
codex: "Models come from Codex's local catalogue. Open Codex to refresh that catalogue, then refresh this list.",
|
|
80
|
+
gemini: "Models and aliases come from the installed Gemini CLI. Update that CLI to discover newer models.",
|
|
81
|
+
"claude-code": "These aliases follow Claude Code's model families. You can also enter an exact model ID.",
|
|
82
|
+
inworld: "These are Inworld’s documented TTS models. You can also enter a compatible model ID.",
|
|
83
|
+
cartesia: "Cartesia has no model-list API. These are bundled compatible choices; you can enter a newer model ID.",
|
|
84
|
+
fal: "These models have input formats supported by Slopify's fal adapter.",
|
|
85
|
+
replicate: "These models have input formats supported by Slopify's Replicate adapter.",
|
|
86
|
+
};
|
|
87
|
+
const notice = Object.hasOwn(notices, provider) ? notices[provider] : undefined;
|
|
88
|
+
return notice === undefined ? {} : { notice };
|
|
89
|
+
}
|
|
@@ -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":
|
|
@@ -16,6 +16,11 @@ export const outputRoles = [
|
|
|
16
16
|
"thumbnail",
|
|
17
17
|
"video",
|
|
18
18
|
"render_params",
|
|
19
|
+
"subtitles_srt",
|
|
20
|
+
"subtitles_vtt",
|
|
21
|
+
"subtitle_words",
|
|
22
|
+
"subtitle_ass",
|
|
23
|
+
"subtitle_font",
|
|
19
24
|
"instructions",
|
|
20
25
|
];
|
|
21
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,91 @@
|
|
|
1
|
+
import { subtitlePlacement } from "./layout.js";
|
|
2
|
+
// Keep spoken spelling and punctuation. Word times come only from acoustic alignment.
|
|
3
|
+
export function captionCues(words) {
|
|
4
|
+
const cues = [];
|
|
5
|
+
let pending = [];
|
|
6
|
+
let previousEnd = 0;
|
|
7
|
+
const flush = () => {
|
|
8
|
+
const first = pending[0];
|
|
9
|
+
const last = pending.at(-1);
|
|
10
|
+
if (first !== undefined && last !== undefined) {
|
|
11
|
+
cues.push({
|
|
12
|
+
start: first.start,
|
|
13
|
+
end: last.end,
|
|
14
|
+
text: wrap(pending.map((word) => word.text)),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
pending = [];
|
|
18
|
+
};
|
|
19
|
+
for (const word of words) {
|
|
20
|
+
if (!Number.isFinite(word.start) ||
|
|
21
|
+
!Number.isFinite(word.end) ||
|
|
22
|
+
word.start < previousEnd - 0.001 ||
|
|
23
|
+
word.end <= word.start) {
|
|
24
|
+
throw new Error("The subtitle word timing is invalid; regenerate alignment.");
|
|
25
|
+
}
|
|
26
|
+
const first = pending[0];
|
|
27
|
+
if (first !== undefined &&
|
|
28
|
+
(word.start - previousEnd > 0.7 ||
|
|
29
|
+
word.end - first.start > 5 ||
|
|
30
|
+
wrap([...pending.map((one) => one.text), word.text]).split("\n").length > 2)) {
|
|
31
|
+
flush();
|
|
32
|
+
}
|
|
33
|
+
pending.push({ ...word, text: word.text.replace(/\s+/g, " ").trim() });
|
|
34
|
+
previousEnd = word.end;
|
|
35
|
+
if (/[.!?]["'’”)]*$/.test(word.text))
|
|
36
|
+
flush();
|
|
37
|
+
}
|
|
38
|
+
flush();
|
|
39
|
+
return cues;
|
|
40
|
+
}
|
|
41
|
+
function wrap(words) {
|
|
42
|
+
const lines = [];
|
|
43
|
+
let line = "";
|
|
44
|
+
for (const word of words) {
|
|
45
|
+
if (line !== "" && line.length + word.length + 1 > 42) {
|
|
46
|
+
lines.push(line);
|
|
47
|
+
line = "";
|
|
48
|
+
}
|
|
49
|
+
line = line === "" ? word : `${line} ${word}`;
|
|
50
|
+
}
|
|
51
|
+
if (line !== "")
|
|
52
|
+
lines.push(line);
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
export function serializeSrt(cues) {
|
|
56
|
+
return cues
|
|
57
|
+
.map((cue, index) => `${index + 1}\n${stamp(cue.start, ",")} --> ${stamp(cue.end, ",")}\n${markup(cue.text)}\n`)
|
|
58
|
+
.join("\n");
|
|
59
|
+
}
|
|
60
|
+
export function serializeVtt(cues) {
|
|
61
|
+
return `WEBVTT\n\n${cues.map((cue) => `${stamp(cue.start, ".")} --> ${stamp(cue.end, ".")}\n${markup(cue.text)}\n`).join("\n")}`;
|
|
62
|
+
}
|
|
63
|
+
export function serializeAss(cues, style) {
|
|
64
|
+
const name = style.fontName.replace(/[\p{Cc},]/gu, " ").trim();
|
|
65
|
+
const placement = subtitlePlacement(style.position ?? "bottom", style.height);
|
|
66
|
+
const position = `{\\an${placement.alignment}\\pos(${style.width / 2},${placement.y})}`;
|
|
67
|
+
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`;
|
|
68
|
+
return (header +
|
|
69
|
+
cues
|
|
70
|
+
.map((cue) => `Dialogue: 0,${assStamp(cue.start)},${assStamp(cue.end)},Default,,0,0,0,,${position}${assText(cue.text)}\n`)
|
|
71
|
+
.join(""));
|
|
72
|
+
}
|
|
73
|
+
function markup(text) {
|
|
74
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
75
|
+
}
|
|
76
|
+
function assText(text) {
|
|
77
|
+
// Full-width alternatives stay visible and cannot open an ASS override or escape.
|
|
78
|
+
return text
|
|
79
|
+
.replaceAll("\\", "\")
|
|
80
|
+
.replaceAll("{", "{")
|
|
81
|
+
.replaceAll("}", "}")
|
|
82
|
+
.replaceAll("\n", "\\N");
|
|
83
|
+
}
|
|
84
|
+
function stamp(seconds, separator) {
|
|
85
|
+
const ms = Math.max(0, Math.round(seconds * 1000));
|
|
86
|
+
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")}`;
|
|
87
|
+
}
|
|
88
|
+
function assStamp(seconds) {
|
|
89
|
+
const cs = Math.max(0, Math.round(seconds * 100));
|
|
90
|
+
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")}`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function subtitleFrame(format) {
|
|
2
|
+
return format === "9:16" ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 };
|
|
3
|
+
}
|
|
4
|
+
// ASS and the browser preview share anchors. Edge positions keep the existing
|
|
5
|
+
// 60-pixel safe margin; interior positions center both single and multiline cues.
|
|
6
|
+
export function subtitlePlacement(position, height) {
|
|
7
|
+
switch (position) {
|
|
8
|
+
case "top":
|
|
9
|
+
return { y: 60, alignment: 8, translateY: 0 };
|
|
10
|
+
case "upper-middle":
|
|
11
|
+
return { y: height / 4, alignment: 5, translateY: -50 };
|
|
12
|
+
case "center":
|
|
13
|
+
return { y: height / 2, alignment: 5, translateY: -50 };
|
|
14
|
+
case "lower-middle":
|
|
15
|
+
return { y: (height * 3) / 4, alignment: 5, translateY: -50 };
|
|
16
|
+
case "bottom":
|
|
17
|
+
return { y: height - 60, alignment: 2, translateY: -100 };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const subtitleModes = ["off", "files", "burn-in"];
|
|
3
|
+
export const subtitlePositions = [
|
|
4
|
+
"top",
|
|
5
|
+
"upper-middle",
|
|
6
|
+
"center",
|
|
7
|
+
"lower-middle",
|
|
8
|
+
"bottom",
|
|
9
|
+
];
|
|
10
|
+
export const subtitleConfigSchema = z.object({
|
|
11
|
+
mode: z.enum(subtitleModes),
|
|
12
|
+
language: z.literal("en").default("en"),
|
|
13
|
+
fontId: z
|
|
14
|
+
.string()
|
|
15
|
+
.max(160)
|
|
16
|
+
.regex(/^[A-Za-z0-9_-]+$/)
|
|
17
|
+
.default("default"),
|
|
18
|
+
position: z.enum(subtitlePositions).default("bottom"),
|
|
19
|
+
fontSize: z.number().int().min(16).max(120).default(48),
|
|
20
|
+
});
|
|
21
|
+
export const defaultSubtitles = {
|
|
22
|
+
mode: "off",
|
|
23
|
+
language: "en",
|
|
24
|
+
fontId: "default",
|
|
25
|
+
fontSize: 48,
|
|
26
|
+
position: "bottom",
|
|
27
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
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, {
|
|
63
|
+
...frame,
|
|
64
|
+
fontSize: config.fontSize,
|
|
65
|
+
fontName: font.assName,
|
|
66
|
+
position: config.position,
|
|
67
|
+
}), { mode: 0o600 });
|
|
68
|
+
writeFileSync(join(directory, "subtitles.json"), JSON.stringify({ key, words, font }), {
|
|
69
|
+
mode: 0o600,
|
|
70
|
+
});
|
|
71
|
+
const files = [
|
|
72
|
+
["subtitles_srt", "subtitles.srt"],
|
|
73
|
+
["subtitles_vtt", "subtitles.vtt"],
|
|
74
|
+
["subtitle_words", "subtitles.json"],
|
|
75
|
+
["subtitle_ass", "subtitles.ass"],
|
|
76
|
+
["subtitle_font", `fonts/selected${font.extension}`],
|
|
77
|
+
];
|
|
78
|
+
return {
|
|
79
|
+
directory,
|
|
80
|
+
burnIn: config.mode === "burn-in" && project.config.sources.video !== "off",
|
|
81
|
+
assets: files.map(([role, path]) => ({
|
|
82
|
+
role,
|
|
83
|
+
path: relative(dir, join(directory, path)).split(sep).join("/"),
|
|
84
|
+
})),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
rmSync(directory, { recursive: true, force: true });
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function timingKey(segments, signal) {
|
|
93
|
+
// Bump when alignment normalization/model changes. Hash file contents, not timestamps.
|
|
94
|
+
const hash = createHash("sha256").update("wav2vec2-en-a19f851-v1");
|
|
95
|
+
for (const segment of segments) {
|
|
96
|
+
signal.throwIfAborted();
|
|
97
|
+
hash.update(JSON.stringify({ kind: segment.kind, seconds: segment.seconds, text: segment.text }));
|
|
98
|
+
if (segment.path !== null) {
|
|
99
|
+
const stream = createReadStream(segment.path, { signal });
|
|
100
|
+
for await (const chunk of stream)
|
|
101
|
+
hash.update(chunk);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return hash.digest("hex");
|
|
105
|
+
}
|
|
106
|
+
async function alignSegments(deps, context, segments) {
|
|
107
|
+
if (deps.alignSubtitles === undefined)
|
|
108
|
+
throw new Error("Local subtitle alignment is unavailable in this build.");
|
|
109
|
+
const words = [];
|
|
110
|
+
const total = segments.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
111
|
+
let offset = 0;
|
|
112
|
+
for (const segment of segments) {
|
|
113
|
+
context.signal.throwIfAborted();
|
|
114
|
+
if (segment.path !== null) {
|
|
115
|
+
const aligned = await deps.alignSubtitles({
|
|
116
|
+
audioPath: segment.path,
|
|
117
|
+
text: segment.text,
|
|
118
|
+
cacheDir: join(deps.paths.dataDir, "models", "english-subtitles"),
|
|
119
|
+
ffmpeg: deps.ffmpeg,
|
|
120
|
+
signal: context.signal,
|
|
121
|
+
onProgress: (current, maximum) => {
|
|
122
|
+
const progress = Math.round((35 * (offset + segment.seconds * (maximum > 0 ? current / maximum : 0))) / total);
|
|
123
|
+
setStageProgress(deps.db, context.stage.id, progress, 100);
|
|
124
|
+
context.emit({
|
|
125
|
+
type: "stage.progress",
|
|
126
|
+
projectId: context.stage.projectId,
|
|
127
|
+
stage: "video",
|
|
128
|
+
current: progress,
|
|
129
|
+
total: 100,
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
for (const word of aligned) {
|
|
134
|
+
if (word.end > segment.seconds + 0.1)
|
|
135
|
+
throw new Error("Subtitle timing exceeds the narration duration.");
|
|
136
|
+
words.push({
|
|
137
|
+
...word,
|
|
138
|
+
start: word.start + offset,
|
|
139
|
+
end: Math.min(word.end, segment.seconds) + offset,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
offset += segment.seconds;
|
|
144
|
+
}
|
|
145
|
+
return words;
|
|
146
|
+
}
|
|
147
|
+
function readCache(deps, projectId, outputs) {
|
|
148
|
+
const output = outputs.find((one) => one.role === "subtitle_words");
|
|
149
|
+
if (output === undefined)
|
|
150
|
+
return undefined;
|
|
151
|
+
try {
|
|
152
|
+
return cacheSchema.parse(JSON.parse(readFileSync(outputPath(deps.paths, projectId, output.path), "utf8")));
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function snapshotFont(deps, projectId, outputs, id, cache, directory) {
|
|
159
|
+
const previous = outputs.find((one) => one.role === "subtitle_font");
|
|
160
|
+
const previousPath = previous === undefined ? undefined : outputPath(deps.paths, projectId, previous.path);
|
|
161
|
+
const saved = cache?.font.id === id && previousPath !== undefined && existsSync(previousPath)
|
|
162
|
+
? { ...cache.font, path: previousPath }
|
|
163
|
+
: await resolveFont(deps.paths, id);
|
|
164
|
+
mkdirSync(join(directory, "fonts"), { mode: 0o700 });
|
|
165
|
+
copyFileSync(saved.path, join(directory, "fonts", `selected${saved.extension}`));
|
|
166
|
+
return { id: saved.id, name: saved.name, assName: saved.assName, extension: saved.extension };
|
|
167
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
2
|
import { projectDir } from "../storage/layout.js";
|
|
3
3
|
import { outputsOf } from "../storage/repo.js";
|
|
4
|
+
import { prepareSubtitles } from "../subtitles/prepare.js";
|
|
4
5
|
import { audioInputs } from "./audio-inputs.js";
|
|
5
6
|
import { audioTimeline } from "./plan.js";
|
|
6
7
|
import { writeExport } from "./write-export.js";
|
|
@@ -23,12 +24,14 @@ export async function exportAudioWav(deps, context, gapSeconds) {
|
|
|
23
24
|
})),
|
|
24
25
|
output: "audio.wav",
|
|
25
26
|
};
|
|
27
|
+
const subtitles = await prepareSubtitles(deps, context, audio, { width: 1920, height: 1080 });
|
|
26
28
|
await writeExport(deps, context, {
|
|
27
29
|
role: "audio_export",
|
|
28
30
|
filename: "audio.wav",
|
|
29
31
|
partName: "audio.part.wav",
|
|
30
32
|
totalSeconds,
|
|
31
33
|
record: plan,
|
|
34
|
+
subtitles,
|
|
32
35
|
args: (part) => audioExportArgs(audio, part),
|
|
33
36
|
});
|
|
34
37
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { isAbsolute, resolve as resolvePath } from "node:path";
|
|
2
3
|
import { zoomBy, zoomFrom, zoomTo } from "./plan.js";
|
|
3
4
|
// Hand-rolled per the standards: the filtergraph is the load-bearing part of this slice
|
|
4
5
|
// and a wrapper would hide it. Every value goes into an argument array, never a shell
|
|
5
6
|
// string, so a title or a filename cannot become a command.
|
|
6
7
|
// zoompan works on a still that has been pre-scaled, because it steps the zoom in
|
|
7
8
|
// sub-pixel increments and a source at output resolution visibly jitters where the zoom is
|
|
8
|
-
// meant to be smooth and linear. Four times the frame is enough at
|
|
9
|
+
// meant to be smooth and linear. Four times the frame is enough at 122.5%.
|
|
9
10
|
const prescale = 4;
|
|
10
11
|
const sampleRate = 44100;
|
|
11
12
|
const channelLayout = "stereo";
|
|
@@ -26,7 +27,7 @@ export function resolveFfmpeg(env, bundled) {
|
|
|
26
27
|
throw new Error("No ffmpeg binary is available. Slopify ships one through ffmpeg-static; if this " +
|
|
27
28
|
"platform has no build, point SLOPIFY_FFMPEG at an ffmpeg executable.");
|
|
28
29
|
}
|
|
29
|
-
export function renderArgs(plan) {
|
|
30
|
+
export function renderArgs(plan, burnSubtitles = false) {
|
|
30
31
|
const inputs = [];
|
|
31
32
|
for (const slot of plan.images) {
|
|
32
33
|
inputs.push("-i", slot.path);
|
|
@@ -52,7 +53,7 @@ export function renderArgs(plan) {
|
|
|
52
53
|
"-y",
|
|
53
54
|
...inputs,
|
|
54
55
|
"-filter_complex",
|
|
55
|
-
filterGraph(plan, audioAt),
|
|
56
|
+
filterGraph(plan, audioAt, burnSubtitles),
|
|
56
57
|
"-map",
|
|
57
58
|
"[v]",
|
|
58
59
|
...(plan.audio.length > 0 ? ["-map", "[a]"] : []),
|
|
@@ -66,7 +67,7 @@ export function renderArgs(plan) {
|
|
|
66
67
|
plan.output,
|
|
67
68
|
];
|
|
68
69
|
}
|
|
69
|
-
function filterGraph(plan, audioAt) {
|
|
70
|
+
function filterGraph(plan, audioAt, burnSubtitles) {
|
|
70
71
|
const chains = [];
|
|
71
72
|
const wide = plan.width * prescale;
|
|
72
73
|
const tall = plan.height * prescale;
|
|
@@ -78,7 +79,9 @@ function filterGraph(plan, audioAt) {
|
|
|
78
79
|
`x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':` +
|
|
79
80
|
`s=${plan.width}x${plan.height}:fps=${plan.fps},setsar=1[v${at}]`);
|
|
80
81
|
});
|
|
81
|
-
chains.push(`${plan.images.map((_slot, at) => `[v${at}]`).join("")}concat=n=${plan.images.length}:v=1:a=0[v]`);
|
|
82
|
+
chains.push(`${plan.images.map((_slot, at) => `[v${at}]`).join("")}concat=n=${plan.images.length}:v=1:a=0${burnSubtitles ? "[uncaptioned]" : "[v]"}`);
|
|
83
|
+
if (burnSubtitles)
|
|
84
|
+
chains.push("[uncaptioned]ass=filename=subtitles.ass:fontsdir=fonts[v]");
|
|
82
85
|
audioAt.forEach((input, at) => {
|
|
83
86
|
// The segments come from different files and the silence from lavfi, so they are
|
|
84
87
|
// brought to one format before concat, which refuses to join mismatched streams.
|
|
@@ -89,7 +92,7 @@ function filterGraph(plan, audioAt) {
|
|
|
89
92
|
}
|
|
90
93
|
return chains.join(";");
|
|
91
94
|
}
|
|
92
|
-
// Odd images 100% →
|
|
95
|
+
// Odd images 100% → 122.5%, even images 122.5% → 100%, linear over the slot. `on` is
|
|
93
96
|
// zoompan's output frame counter, 0 to d-1, and a one-frame slot has no span to divide
|
|
94
97
|
// by, so it holds the zoom it starts at.
|
|
95
98
|
function zoomExpression(slot) {
|
|
@@ -120,9 +123,13 @@ export function runFfmpeg(run) {
|
|
|
120
123
|
reject(new Error("the render was canceled before it started"));
|
|
121
124
|
return;
|
|
122
125
|
}
|
|
123
|
-
|
|
126
|
+
// Subtitle filters use their own working directory. An explicit relative binary
|
|
127
|
+
// still resolves from the app launch directory, where boot verified it.
|
|
128
|
+
const executable = !isAbsolute(run.bin) && /[\\/]/.test(run.bin) ? resolvePath(run.bin) : run.bin;
|
|
129
|
+
const child = spawn(executable, [...run.args], {
|
|
124
130
|
stdio: ["ignore", "pipe", "pipe"],
|
|
125
131
|
windowsHide: true,
|
|
132
|
+
...(run.cwd === undefined ? {} : { cwd: run.cwd }),
|
|
126
133
|
});
|
|
127
134
|
const errors = [];
|
|
128
135
|
let pending = "";
|
|
@@ -4,12 +4,12 @@ const frames = {
|
|
|
4
4
|
"16:9": { width: 1920, height: 1080 },
|
|
5
5
|
"9:16": { width: 1080, height: 1920 },
|
|
6
6
|
};
|
|
7
|
-
// 100% →
|
|
7
|
+
// 100% → 122.5%, linear, centred, alternating per image. The three are written out
|
|
8
8
|
// rather than derived from one another, because they are formatted into an ffmpeg
|
|
9
|
-
// expression and 1.
|
|
9
|
+
// expression and 1.225 - 1 is not 0.225 in binary floating point.
|
|
10
10
|
export const zoomFrom = 1;
|
|
11
|
-
export const zoomTo = 1.
|
|
12
|
-
export const zoomBy = 0.
|
|
11
|
+
export const zoomTo = 1.225;
|
|
12
|
+
export const zoomBy = 0.225;
|
|
13
13
|
export function planRender(input) {
|
|
14
14
|
if (input.images.length === 0) {
|
|
15
15
|
// Admission permits Video only with an image source, so an empty set is a bug upstream.
|
package/dist/slices/video/run.js
CHANGED
|
@@ -2,6 +2,7 @@ import { relative } from "node:path";
|
|
|
2
2
|
import { projectById } from "../admission/repo.js";
|
|
3
3
|
import { outputPath, projectDir } from "../storage/layout.js";
|
|
4
4
|
import { outputsOf } from "../storage/repo.js";
|
|
5
|
+
import { prepareSubtitles } from "../subtitles/prepare.js";
|
|
5
6
|
import { exportAudioWav } from "./audio-export.js";
|
|
6
7
|
import { audioInputs } from "./audio-inputs.js";
|
|
7
8
|
import { renderArgs } from "./ffmpeg.js";
|
|
@@ -30,13 +31,15 @@ export async function renderVideo(deps, context) {
|
|
|
30
31
|
images: slideshow(outputs).map((output) => outputPath(deps.paths, projectId, output.path)),
|
|
31
32
|
output: outputPath(deps.paths, projectId, "video.mp4"),
|
|
32
33
|
});
|
|
34
|
+
const subtitles = await prepareSubtitles(deps, context, plan.audio, plan);
|
|
33
35
|
await writeExport(deps, context, {
|
|
34
36
|
role: "video",
|
|
35
37
|
filename: "video.mp4",
|
|
36
38
|
partName: "video.part.mp4",
|
|
37
39
|
totalSeconds: plan.totalSeconds,
|
|
38
|
-
record: recorded(plan, dir),
|
|
39
|
-
|
|
40
|
+
record: { ...recorded(plan, dir), subtitles: project.config.subtitles ?? null },
|
|
41
|
+
subtitles,
|
|
42
|
+
args: (part) => renderArgs({ ...plan, output: part }, subtitles?.burnIn),
|
|
40
43
|
});
|
|
41
44
|
deps.count("stage.completed", { stage: "video" });
|
|
42
45
|
}
|