@gentbajko/slopify 0.5.1 → 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/http/app.js +4 -0
- package/dist/edge/http/fonts.js +147 -0
- package/dist/edge/http/projects.js +23 -1
- 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/ports/subtitles.js +1 -0
- package/dist/kernel/runner/providers.js +3 -2
- package/dist/main.js +2 -1
- package/dist/slices/admission/repo.js +2 -0
- package/dist/slices/admission/rules.js +6 -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/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 +10 -0
- package/dist/slices/storage/model.js +5 -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 +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/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-C1fsrIP5.css +0 -1
- package/dist/web/assets/index-DwJ_XTl3.js +0 -81
|
@@ -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,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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import { mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { transact } from "../../kernel/db/tx.js";
|
|
4
4
|
import { setStageProgress } from "../admission/repo.js";
|
|
5
5
|
import { outputPath } from "../storage/layout.js";
|
|
6
6
|
import { deleteOutput, insertOutput, outputsOf } from "../storage/repo.js";
|
|
7
|
+
import { subtitleRoles } from "../subtitles/prepare.js";
|
|
7
8
|
import { runFfmpeg } from "./ffmpeg.js";
|
|
8
9
|
const progressIntervalMs = 500;
|
|
9
|
-
const renderRoles = [
|
|
10
|
+
const renderRoles = [
|
|
11
|
+
"video",
|
|
12
|
+
"audio_export",
|
|
13
|
+
"render_params",
|
|
14
|
+
...subtitleRoles,
|
|
15
|
+
];
|
|
10
16
|
// Both local exports retain the previous finished asset until ffmpeg succeeds and
|
|
11
17
|
// the abort check passes. The part file keeps its muxer's filename extension.
|
|
12
18
|
export async function writeExport(deps, context, output) {
|
|
@@ -23,6 +29,7 @@ export async function writeExport(deps, context, output) {
|
|
|
23
29
|
await runFfmpeg({
|
|
24
30
|
bin: deps.ffmpeg,
|
|
25
31
|
args: output.args(part),
|
|
32
|
+
cwd: output.subtitles?.directory,
|
|
26
33
|
signal: context.signal,
|
|
27
34
|
log: deps.log,
|
|
28
35
|
onProgress: (elapsedMs) => {
|
|
@@ -31,7 +38,9 @@ export async function writeExport(deps, context, output) {
|
|
|
31
38
|
return;
|
|
32
39
|
}
|
|
33
40
|
announced = at;
|
|
34
|
-
const current =
|
|
41
|
+
const current = output.subtitles === undefined
|
|
42
|
+
? Math.min(elapsedMs, totalMs)
|
|
43
|
+
: Math.round((0.35 + 0.65 * Math.min(elapsedMs / totalMs, 1)) * totalMs);
|
|
35
44
|
setStageProgress(deps.db, context.stage.id, current, totalMs);
|
|
36
45
|
context.emit({
|
|
37
46
|
type: "stage.progress",
|
|
@@ -51,26 +60,82 @@ export async function writeExport(deps, context, output) {
|
|
|
51
60
|
// A partial render file is discarded, never kept or served. Only the part file: the
|
|
52
61
|
// previous export is a finished output and stays downloadable.
|
|
53
62
|
rmSync(part, { force: true });
|
|
63
|
+
if (output.subtitles !== undefined)
|
|
64
|
+
rmSync(output.subtitles.directory, { recursive: true, force: true });
|
|
54
65
|
throw error;
|
|
55
66
|
}
|
|
56
|
-
// The swap. Everything below replaces the previous render, and nothing above it could.
|
|
57
|
-
renameSync(part, target);
|
|
58
67
|
const params = outputPath(deps.paths, projectId, "render.json");
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
const previousOutputs = outputsOf(deps.db, projectId);
|
|
69
|
+
const replaced = [];
|
|
70
|
+
const paramsPart = outputPath(deps.paths, projectId, "render.part.json");
|
|
71
|
+
let committed = false;
|
|
72
|
+
try {
|
|
73
|
+
writeFileSync(paramsPart, `${JSON.stringify(output.record, null, 2)}\n`, { mode: 0o600 });
|
|
74
|
+
// The DB commit can fail after ffmpeg succeeds. Keep rollback copies until all
|
|
75
|
+
// media, caption and font rows commit, so a failed export keeps its old playback.
|
|
76
|
+
for (const path of [target, params]) {
|
|
77
|
+
const saved = { path, backup: `${path}.previous`, existed: existsSync(path), swapped: false };
|
|
78
|
+
if (saved.existed)
|
|
79
|
+
copyFileSync(path, saved.backup);
|
|
80
|
+
replaced.push(saved);
|
|
81
|
+
}
|
|
82
|
+
for (const saved of replaced) {
|
|
83
|
+
renameSync(saved.path === target ? part : paramsPart, saved.path);
|
|
84
|
+
saved.swapped = true;
|
|
85
|
+
}
|
|
86
|
+
transact(deps.db, () => {
|
|
87
|
+
for (const previous of previousOutputs) {
|
|
88
|
+
if (renderRoles.includes(previous.role))
|
|
89
|
+
deleteOutput(deps.db, previous.id);
|
|
69
90
|
}
|
|
91
|
+
store(deps, projectId, "render_params", "render.json", null);
|
|
92
|
+
store(deps, projectId, output.role, output.filename, totalMs, {
|
|
93
|
+
subtitlesMode: output.subtitles === undefined ? "off" : output.subtitles.burnIn ? "burn-in" : "files",
|
|
94
|
+
});
|
|
95
|
+
for (const asset of output.subtitles?.assets ?? [])
|
|
96
|
+
store(deps, projectId, asset.role, asset.path, null);
|
|
97
|
+
});
|
|
98
|
+
committed = true;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
for (const saved of replaced) {
|
|
102
|
+
if (!saved.swapped)
|
|
103
|
+
continue;
|
|
104
|
+
if (saved.existed)
|
|
105
|
+
renameSync(saved.backup, saved.path);
|
|
106
|
+
else
|
|
107
|
+
rmSync(saved.path, { force: true });
|
|
108
|
+
saved.swapped = false;
|
|
70
109
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
110
|
+
rmSync(part, { force: true });
|
|
111
|
+
rmSync(paramsPart, { force: true });
|
|
112
|
+
if (output.subtitles !== undefined)
|
|
113
|
+
rmSync(output.subtitles.directory, { recursive: true, force: true });
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
for (const saved of replaced) {
|
|
118
|
+
if (!committed && saved.swapped)
|
|
119
|
+
continue;
|
|
120
|
+
try {
|
|
121
|
+
rmSync(saved.backup, { force: true });
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* A retained backup is collected at the next boot. */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const kept = new Set(outputsOf(deps.db, projectId).map((one) => one.path));
|
|
129
|
+
for (const previous of previousOutputs) {
|
|
130
|
+
if (renderRoles.includes(previous.role) && !kept.has(previous.path)) {
|
|
131
|
+
try {
|
|
132
|
+
rmSync(outputPath(deps.paths, projectId, previous.path), { force: true });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* Boot reconciliation collects a file that could not be removed. */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
74
139
|
// Written as well as emitted: a page opened after the render reads the row, and the
|
|
75
140
|
// throttled in-flight writes would have left it frozen short of the end.
|
|
76
141
|
setStageProgress(deps.db, context.stage.id, totalMs, totalMs);
|
|
@@ -82,7 +147,7 @@ export async function writeExport(deps, context, output) {
|
|
|
82
147
|
total: totalMs,
|
|
83
148
|
});
|
|
84
149
|
}
|
|
85
|
-
function store(deps, projectId, role, path, durationMs) {
|
|
150
|
+
function store(deps, projectId, role, path, durationMs, meta = {}) {
|
|
86
151
|
insertOutput(deps.db, {
|
|
87
152
|
id: deps.ids.next(),
|
|
88
153
|
projectId,
|
|
@@ -92,7 +157,7 @@ function store(deps, projectId, role, path, durationMs) {
|
|
|
92
157
|
originalFilename: null,
|
|
93
158
|
bytes: statSync(outputPath(deps.paths, projectId, path)).size,
|
|
94
159
|
durationMs,
|
|
95
|
-
meta
|
|
160
|
+
meta,
|
|
96
161
|
createdAt: deps.clock.now().toISOString(),
|
|
97
162
|
});
|
|
98
163
|
}
|