@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,32 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { outputPath, projectDir } from "../storage/layout.js";
|
|
3
|
+
import { probeDurationMs } from "./ffmpeg.js";
|
|
4
|
+
export async function audioInputs(deps, projectId, outputs, signal) {
|
|
5
|
+
const dir = projectDir(deps.paths, projectId);
|
|
6
|
+
const body = await audio(deps, outputs, "audio_body", dir, signal);
|
|
7
|
+
if (!body)
|
|
8
|
+
throw new Error("the project has no narration audio to render against");
|
|
9
|
+
if (body.seconds <= 0)
|
|
10
|
+
throw new Error("the narration audio decodes to no sound");
|
|
11
|
+
const intro = await audio(deps, outputs, "audio_intro", dir, signal);
|
|
12
|
+
const outro = await audio(deps, outputs, "audio_outro", dir, signal);
|
|
13
|
+
return { body, intro, outro };
|
|
14
|
+
}
|
|
15
|
+
async function audio(deps, outputs, role, dir, signal) {
|
|
16
|
+
const output = outputs.find((candidate) => candidate.role === role);
|
|
17
|
+
if (output === undefined) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const path = outputPath(deps.paths, output.projectId, output.path);
|
|
21
|
+
const durationMs = output.durationMs ?? (await probeDurationMs(deps.ffmpeg, path, signal, deps.log));
|
|
22
|
+
if (output.durationMs === null) {
|
|
23
|
+
// A provided file arrives with no duration; measuring it once is worth recording.
|
|
24
|
+
deps.db.prepare("UPDATE outputs SET duration_ms = ? WHERE id = ?").run(durationMs, output.id);
|
|
25
|
+
deps.log.write("info", "video.probe", {
|
|
26
|
+
projectId: output.projectId,
|
|
27
|
+
stage: "video",
|
|
28
|
+
detail: `${relative(dir, path)} is ${durationMs} ms`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return { path, seconds: durationMs / 1000 };
|
|
32
|
+
}
|
|
@@ -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,23 +53,21 @@ 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
|
-
"-map",
|
|
59
|
-
"[a]",
|
|
59
|
+
...(plan.audio.length > 0 ? ["-map", "[a]"] : []),
|
|
60
60
|
"-c:v",
|
|
61
61
|
"libx264",
|
|
62
62
|
"-pix_fmt",
|
|
63
63
|
"yuv420p",
|
|
64
|
-
"-c:a",
|
|
65
|
-
"aac",
|
|
64
|
+
...(plan.audio.length > 0 ? ["-c:a", "aac"] : ["-an"]),
|
|
66
65
|
"-movflags",
|
|
67
66
|
"+faststart",
|
|
68
67
|
plan.output,
|
|
69
68
|
];
|
|
70
69
|
}
|
|
71
|
-
function filterGraph(plan, audioAt) {
|
|
70
|
+
function filterGraph(plan, audioAt, burnSubtitles) {
|
|
72
71
|
const chains = [];
|
|
73
72
|
const wide = plan.width * prescale;
|
|
74
73
|
const tall = plan.height * prescale;
|
|
@@ -80,16 +79,20 @@ function filterGraph(plan, audioAt) {
|
|
|
80
79
|
`x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':` +
|
|
81
80
|
`s=${plan.width}x${plan.height}:fps=${plan.fps},setsar=1[v${at}]`);
|
|
82
81
|
});
|
|
83
|
-
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]");
|
|
84
85
|
audioAt.forEach((input, at) => {
|
|
85
86
|
// The segments come from different files and the silence from lavfi, so they are
|
|
86
87
|
// brought to one format before concat, which refuses to join mismatched streams.
|
|
87
88
|
chains.push(`[${input}:a]aformat=sample_fmts=fltp:sample_rates=${sampleRate}:channel_layouts=${channelLayout}[a${at}]`);
|
|
88
89
|
});
|
|
89
|
-
|
|
90
|
+
if (audioAt.length > 0) {
|
|
91
|
+
chains.push(`${audioAt.map((_input, at) => `[a${at}]`).join("")}concat=n=${audioAt.length}:v=0:a=1[a]`);
|
|
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,20 +4,22 @@ 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
|
-
// Admission
|
|
15
|
+
// Admission permits Video only with an image source, so an empty set is a bug upstream.
|
|
16
16
|
throw new Error("a render needs at least one image");
|
|
17
17
|
}
|
|
18
18
|
const frame = frames[input.format];
|
|
19
|
-
const audio =
|
|
20
|
-
const totalSeconds =
|
|
19
|
+
const audio = input.body === undefined ? [] : audioTimeline({ ...input, body: input.body });
|
|
20
|
+
const totalSeconds = input.body === undefined
|
|
21
|
+
? input.images.length * 5
|
|
22
|
+
: audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
21
23
|
const totalFrames = Math.max(input.images.length, Math.round(totalSeconds * fps));
|
|
22
24
|
return {
|
|
23
25
|
width: frame.width,
|
|
@@ -33,13 +35,12 @@ export function planRender(input) {
|
|
|
33
35
|
}
|
|
34
36
|
// Intro, gap, body, gap, outro, with a gap only where the segment on
|
|
35
37
|
// the other side of it exists.
|
|
36
|
-
function
|
|
38
|
+
export function audioTimeline(input, minimumGap = 1 / fps) {
|
|
37
39
|
const segments = [];
|
|
38
40
|
const gap = { kind: "gap", path: null, seconds: input.gapSeconds };
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
const audible = input.gapSeconds >= 1 / fps;
|
|
41
|
+
// Video keeps its existing minimum of one frame. WAV passes one sample instead,
|
|
42
|
+
// because its gap duration is independent of the slideshow's frame rate.
|
|
43
|
+
const audible = input.gapSeconds >= minimumGap;
|
|
43
44
|
if (input.intro !== undefined) {
|
|
44
45
|
segments.push({ kind: "intro", path: input.intro.path, seconds: input.intro.seconds });
|
|
45
46
|
if (audible) {
|
package/dist/slices/video/run.js
CHANGED
|
@@ -1,121 +1,46 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { transact } from "../../kernel/db/tx.js";
|
|
4
|
-
import { projectById, setStageProgress } from "../admission/repo.js";
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { projectById } from "../admission/repo.js";
|
|
5
3
|
import { outputPath, projectDir } from "../storage/layout.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
4
|
+
import { outputsOf } from "../storage/repo.js";
|
|
5
|
+
import { prepareSubtitles } from "../subtitles/prepare.js";
|
|
6
|
+
import { exportAudioWav } from "./audio-export.js";
|
|
7
|
+
import { audioInputs } from "./audio-inputs.js";
|
|
8
|
+
import { renderArgs } from "./ffmpeg.js";
|
|
8
9
|
import { planRender } from "./plan.js";
|
|
9
|
-
|
|
10
|
-
// columns are written on the same tick so a page that reconnects mid-render sees a bar
|
|
11
|
-
// rather than nothing.
|
|
12
|
-
const progressIntervalMs = 500;
|
|
13
|
-
// The previous video stays downloadable until the new render finishes. ffmpeg therefore writes
|
|
14
|
-
// beside the finished file rather than over it, and the swap below is what replaces it. The
|
|
15
|
-
// name keeps the `.mp4` extension because that is what ffmpeg picks its muxer from, and no row
|
|
16
|
-
// ever names it: a download resolves an outputs row, and the boot reconcile collects a part
|
|
17
|
-
// file left by a killed process as an orphan (`slices/storage/reconcile.ts`).
|
|
18
|
-
const partName = "video.part.mp4";
|
|
19
|
-
// The two outputs one render produces. Both are replaced together, so a project never
|
|
20
|
-
// carries the video of one render beside the parameters of another.
|
|
21
|
-
const renderRoles = ["video", "render_params"];
|
|
10
|
+
import { writeExport } from "./write-export.js";
|
|
22
11
|
export async function renderVideo(deps, context) {
|
|
23
12
|
const { projectId } = context.stage;
|
|
24
13
|
const project = projectById(deps.db, projectId);
|
|
25
|
-
if (project
|
|
14
|
+
if (!project)
|
|
26
15
|
throw new Error(`project ${projectId} has no row`);
|
|
16
|
+
if (project.config.sources.video === "off") {
|
|
17
|
+
if (project.config.sources.audio === "off")
|
|
18
|
+
throw new Error("the project has no media export enabled");
|
|
19
|
+
await exportAudioWav(deps, context, project.config.silenceGapSeconds);
|
|
20
|
+
return;
|
|
27
21
|
}
|
|
28
22
|
const outputs = outputsOf(deps.db, projectId);
|
|
29
23
|
const dir = projectDir(deps.paths, projectId);
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
throw new Error("the project has no narration audio to render against");
|
|
34
|
-
}
|
|
35
|
-
if (body.seconds <= 0) {
|
|
36
|
-
throw new Error("the narration audio decodes to no sound");
|
|
37
|
-
}
|
|
24
|
+
const narration = project.config.sources.audio === "off"
|
|
25
|
+
? {}
|
|
26
|
+
: await audioInputs(deps, projectId, outputs, context.signal);
|
|
38
27
|
const plan = planRender({
|
|
39
28
|
format: project.format,
|
|
40
29
|
gapSeconds: project.config.silenceGapSeconds,
|
|
41
|
-
...
|
|
42
|
-
body,
|
|
43
|
-
...withOutro(await audio(deps, outputs, "audio_outro", dir, context.signal)),
|
|
30
|
+
...narration,
|
|
44
31
|
images: slideshow(outputs).map((output) => outputPath(deps.paths, projectId, output.path)),
|
|
45
|
-
output:
|
|
46
|
-
});
|
|
47
|
-
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
48
|
-
// The plan is the record of what was rendered and names the file the user downloads,
|
|
49
|
-
// so it keeps the final path; only the ffmpeg invocation is pointed at the part file.
|
|
50
|
-
const part = outputPath(deps.paths, projectId, partName);
|
|
51
|
-
const totalMs = Math.round(plan.totalSeconds * 1000);
|
|
52
|
-
let announced = 0;
|
|
53
|
-
try {
|
|
54
|
-
// No retry and no timeout. A render that fails is terminal.
|
|
55
|
-
await runFfmpeg({
|
|
56
|
-
bin: deps.ffmpeg,
|
|
57
|
-
args: renderArgs({ ...plan, output: part }),
|
|
58
|
-
signal: context.signal,
|
|
59
|
-
log: deps.log,
|
|
60
|
-
onProgress: (elapsedMs) => {
|
|
61
|
-
const at = deps.clock.now().getTime();
|
|
62
|
-
if (at - announced < progressIntervalMs) {
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
announced = at;
|
|
66
|
-
const current = Math.min(elapsedMs, totalMs);
|
|
67
|
-
setStageProgress(deps.db, context.stage.id, current, totalMs);
|
|
68
|
-
context.emit({
|
|
69
|
-
type: "stage.progress",
|
|
70
|
-
projectId,
|
|
71
|
-
stage: "video",
|
|
72
|
-
current,
|
|
73
|
-
total: totalMs,
|
|
74
|
-
});
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
// A cancel protects an output that was already stored, not one about to be. One
|
|
78
|
-
// landing between ffmpeg exiting and the rows below discards the render rather than
|
|
79
|
-
// reporting done to a page that pressed Cancel.
|
|
80
|
-
context.signal.throwIfAborted();
|
|
81
|
-
}
|
|
82
|
-
catch (error) {
|
|
83
|
-
// A partial render file is discarded, never kept or served. Only the part file: the
|
|
84
|
-
// previous video is a finished output and stays downloadable.
|
|
85
|
-
rmSync(part, { force: true });
|
|
86
|
-
throw error;
|
|
87
|
-
}
|
|
88
|
-
// The swap. Everything below replaces the previous render, and nothing above it could.
|
|
89
|
-
renameSync(part, target);
|
|
90
|
-
const params = outputPath(deps.paths, projectId, "render.json");
|
|
91
|
-
writeFileSync(params, `${JSON.stringify(recorded(plan, dir), null, 2)}\n`, { mode: 0o600 });
|
|
92
|
-
// No version history. The files were written under the names the previous render
|
|
93
|
-
// already used, so replacing the rows that named them is all that is left to do. In one
|
|
94
|
-
// transaction, or a crash between the delete and the insert would leave the finished
|
|
95
|
-
// video with no row and the boot reconcile would collect it. Read back rather than
|
|
96
|
-
// reused from above: the rows are what a download resolves.
|
|
97
|
-
transact(deps.db, () => {
|
|
98
|
-
for (const previous of outputsOf(deps.db, projectId)) {
|
|
99
|
-
if (renderRoles.includes(previous.role)) {
|
|
100
|
-
deleteOutput(deps.db, previous.id);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
store(deps, projectId, "render_params", "render.json", null);
|
|
104
|
-
store(deps, projectId, "video", "video.mp4", totalMs);
|
|
32
|
+
output: outputPath(deps.paths, projectId, "video.mp4"),
|
|
105
33
|
});
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
34
|
+
const subtitles = await prepareSubtitles(deps, context, plan.audio, plan);
|
|
35
|
+
await writeExport(deps, context, {
|
|
36
|
+
role: "video",
|
|
37
|
+
filename: "video.mp4",
|
|
38
|
+
partName: "video.part.mp4",
|
|
39
|
+
totalSeconds: plan.totalSeconds,
|
|
40
|
+
record: { ...recorded(plan, dir), subtitles: project.config.subtitles ?? null },
|
|
41
|
+
subtitles,
|
|
42
|
+
args: (part) => renderArgs({ ...plan, output: part }, subtitles?.burnIn),
|
|
115
43
|
});
|
|
116
|
-
// Videos rendered (completed renders). One event per swap, so a render that failed or was
|
|
117
|
-
// canceled counts nothing and a re-render counts again No provider: ffmpeg is the machine's
|
|
118
|
-
// own, not a service.
|
|
119
44
|
deps.count("stage.completed", { stage: "video" });
|
|
120
45
|
}
|
|
121
46
|
// Written with project-relative paths, so the record of what was rendered can be read
|
|
@@ -138,41 +63,3 @@ function slideshow(outputs) {
|
|
|
138
63
|
.filter((output) => output.role === "image")
|
|
139
64
|
.toSorted((left, right) => (left.meta.index ?? 0) - (right.meta.index ?? 0));
|
|
140
65
|
}
|
|
141
|
-
async function audio(deps, outputs, role, dir, signal) {
|
|
142
|
-
const output = outputs.find((candidate) => candidate.role === role);
|
|
143
|
-
if (output === undefined) {
|
|
144
|
-
return undefined;
|
|
145
|
-
}
|
|
146
|
-
const path = outputPath(deps.paths, output.projectId, output.path);
|
|
147
|
-
const durationMs = output.durationMs ?? (await probeDurationMs(deps.ffmpeg, path, signal, deps.log));
|
|
148
|
-
if (output.durationMs === null) {
|
|
149
|
-
// A provided file arrives with no duration; measuring it once is worth recording.
|
|
150
|
-
deps.db.prepare("UPDATE outputs SET duration_ms = ? WHERE id = ?").run(durationMs, output.id);
|
|
151
|
-
deps.log.write("info", "video.probe", {
|
|
152
|
-
projectId: output.projectId,
|
|
153
|
-
stage: "video",
|
|
154
|
-
detail: `${relative(dir, path)} is ${durationMs} ms`,
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
return { path, seconds: durationMs / 1000 };
|
|
158
|
-
}
|
|
159
|
-
function withIntro(intro) {
|
|
160
|
-
return intro === undefined ? {} : { intro };
|
|
161
|
-
}
|
|
162
|
-
function withOutro(outro) {
|
|
163
|
-
return outro === undefined ? {} : { outro };
|
|
164
|
-
}
|
|
165
|
-
function store(deps, projectId, role, path, durationMs) {
|
|
166
|
-
insertOutput(deps.db, {
|
|
167
|
-
id: deps.ids.next(),
|
|
168
|
-
projectId,
|
|
169
|
-
stageKind: "video",
|
|
170
|
-
role,
|
|
171
|
-
path,
|
|
172
|
-
originalFilename: null,
|
|
173
|
-
bytes: statSync(outputPath(deps.paths, projectId, path)).size,
|
|
174
|
-
durationMs,
|
|
175
|
-
meta: {},
|
|
176
|
-
createdAt: deps.clock.now().toISOString(),
|
|
177
|
-
});
|
|
178
|
-
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
4
|
+
import { setStageProgress } from "../admission/repo.js";
|
|
5
|
+
import { outputPath } from "../storage/layout.js";
|
|
6
|
+
import { deleteOutput, insertOutput, outputsOf } from "../storage/repo.js";
|
|
7
|
+
import { subtitleRoles } from "../subtitles/prepare.js";
|
|
8
|
+
import { runFfmpeg } from "./ffmpeg.js";
|
|
9
|
+
const progressIntervalMs = 500;
|
|
10
|
+
const renderRoles = [
|
|
11
|
+
"video",
|
|
12
|
+
"audio_export",
|
|
13
|
+
"render_params",
|
|
14
|
+
...subtitleRoles,
|
|
15
|
+
];
|
|
16
|
+
// Both local exports retain the previous finished asset until ffmpeg succeeds and
|
|
17
|
+
// the abort check passes. The part file keeps its muxer's filename extension.
|
|
18
|
+
export async function writeExport(deps, context, output) {
|
|
19
|
+
const { projectId } = context.stage;
|
|
20
|
+
const target = outputPath(deps.paths, projectId, output.filename);
|
|
21
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
22
|
+
// The plan is the record of what was rendered and names the file the user downloads,
|
|
23
|
+
// so it keeps the final path; only the ffmpeg invocation is pointed at the part file.
|
|
24
|
+
const part = outputPath(deps.paths, projectId, output.partName);
|
|
25
|
+
const totalMs = Math.round(output.totalSeconds * 1000);
|
|
26
|
+
let announced = 0;
|
|
27
|
+
try {
|
|
28
|
+
// No retry and no timeout. A render that fails is terminal.
|
|
29
|
+
await runFfmpeg({
|
|
30
|
+
bin: deps.ffmpeg,
|
|
31
|
+
args: output.args(part),
|
|
32
|
+
cwd: output.subtitles?.directory,
|
|
33
|
+
signal: context.signal,
|
|
34
|
+
log: deps.log,
|
|
35
|
+
onProgress: (elapsedMs) => {
|
|
36
|
+
const at = deps.clock.now().getTime();
|
|
37
|
+
if (at - announced < progressIntervalMs) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
announced = at;
|
|
41
|
+
const current = output.subtitles === undefined
|
|
42
|
+
? Math.min(elapsedMs, totalMs)
|
|
43
|
+
: Math.round((0.35 + 0.65 * Math.min(elapsedMs / totalMs, 1)) * totalMs);
|
|
44
|
+
setStageProgress(deps.db, context.stage.id, current, totalMs);
|
|
45
|
+
context.emit({
|
|
46
|
+
type: "stage.progress",
|
|
47
|
+
projectId,
|
|
48
|
+
stage: "video",
|
|
49
|
+
current,
|
|
50
|
+
total: totalMs,
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
// A cancel protects an output that was already stored, not one about to be. One
|
|
55
|
+
// landing between ffmpeg exiting and the rows below discards the render rather than
|
|
56
|
+
// reporting done to a page that pressed Cancel.
|
|
57
|
+
context.signal.throwIfAborted();
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
// A partial render file is discarded, never kept or served. Only the part file: the
|
|
61
|
+
// previous export is a finished output and stays downloadable.
|
|
62
|
+
rmSync(part, { force: true });
|
|
63
|
+
if (output.subtitles !== undefined)
|
|
64
|
+
rmSync(output.subtitles.directory, { recursive: true, force: true });
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
const params = outputPath(deps.paths, projectId, "render.json");
|
|
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);
|
|
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;
|
|
109
|
+
}
|
|
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
|
+
}
|
|
139
|
+
// Written as well as emitted: a page opened after the render reads the row, and the
|
|
140
|
+
// throttled in-flight writes would have left it frozen short of the end.
|
|
141
|
+
setStageProgress(deps.db, context.stage.id, totalMs, totalMs);
|
|
142
|
+
context.emit({
|
|
143
|
+
type: "stage.progress",
|
|
144
|
+
projectId,
|
|
145
|
+
stage: "video",
|
|
146
|
+
current: totalMs,
|
|
147
|
+
total: totalMs,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
function store(deps, projectId, role, path, durationMs, meta = {}) {
|
|
151
|
+
insertOutput(deps.db, {
|
|
152
|
+
id: deps.ids.next(),
|
|
153
|
+
projectId,
|
|
154
|
+
stageKind: "video",
|
|
155
|
+
role,
|
|
156
|
+
path,
|
|
157
|
+
originalFilename: null,
|
|
158
|
+
bytes: statSync(outputPath(deps.paths, projectId, path)).size,
|
|
159
|
+
durationMs,
|
|
160
|
+
meta,
|
|
161
|
+
createdAt: deps.clock.now().toISOString(),
|
|
162
|
+
});
|
|
163
|
+
}
|