@gentbajko/slopify 0.5.0 → 0.5.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/dist/edge/events/hub.js +4 -0
- package/dist/edge/http/actions.js +68 -14
- package/dist/edge/http/projects.js +17 -10
- package/dist/kernel/db/migrations/0002-project-controls.sql +4 -0
- package/dist/kernel/pipeline.js +10 -5
- 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/main.js +30 -3
- package/dist/slices/admission/model.js +2 -2
- package/dist/slices/admission/repo.js +20 -4
- package/dist/slices/admission/rules.js +26 -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/library/slots.js +3 -1
- package/dist/slices/reruns/cascade.js +11 -7
- package/dist/slices/reruns/index.js +23 -1
- package/dist/slices/storage/layout.js +2 -0
- package/dist/slices/storage/model.js +1 -0
- package/dist/slices/video/audio-export.js +66 -0
- package/dist/slices/video/audio-inputs.js +32 -0
- package/dist/slices/video/ffmpeg.js +5 -5
- package/dist/slices/video/plan.js +9 -8
- package/dist/slices/video/run.js +25 -141
- package/dist/slices/video/write-export.js +98 -0
- package/dist/web/assets/index-C1fsrIP5.css +1 -0
- package/dist/web/assets/index-DwJ_XTl3.js +81 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BZvZKvIv.js +0 -81
- package/dist/web/assets/index-cpjfvs5E.css +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { stageKinds } from "../../kernel/pipeline.js";
|
|
2
|
-
import {
|
|
2
|
+
import { dependenciesOf } from "../../kernel/runner/graph.js";
|
|
3
3
|
// The stages to redo, in pipeline order, each with how much of itself it throws away.
|
|
4
4
|
export function redoPlan(input) {
|
|
5
5
|
const stale = staleStages(input);
|
|
@@ -11,7 +11,7 @@ function staleStages(input) {
|
|
|
11
11
|
const { action } = input;
|
|
12
12
|
switch (action.kind) {
|
|
13
13
|
case "rerun":
|
|
14
|
-
return withDependents([action.stage]);
|
|
14
|
+
return withDependents([action.stage], input);
|
|
15
15
|
case "article-edit": {
|
|
16
16
|
// An article edit re-runs audio, LLM-mode intro/outro text, the LLM-written
|
|
17
17
|
// thumbnail and the video, leaving prompt-based images untouched. The video comes
|
|
@@ -20,14 +20,14 @@ function staleStages(input) {
|
|
|
20
20
|
if (input.thumbnailSource === "prompt_by_llm") {
|
|
21
21
|
roots.push("thumbnail");
|
|
22
22
|
}
|
|
23
|
-
return withDependents(roots);
|
|
23
|
+
return withDependents(roots, input);
|
|
24
24
|
}
|
|
25
25
|
case "image-deleted":
|
|
26
26
|
// The image is removed from the set and the video re-renders. The remaining
|
|
27
27
|
// images stand, so the images stage itself is not redone.
|
|
28
|
-
return withDependents(["video"]);
|
|
28
|
+
return input.videoSource === "off" ? new Set() : withDependents(["video"], input);
|
|
29
29
|
case "image-regenerated":
|
|
30
|
-
return withDependents(["images"]);
|
|
30
|
+
return withDependents(["images"], input);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
// `provided` → `running` and `skipped` → `running` are forbidden, so a stage whose output
|
|
@@ -49,11 +49,15 @@ function clearsOf(action, kind) {
|
|
|
49
49
|
}
|
|
50
50
|
// The transitive closure of the dependents of `roots` over `kernel/runner/graph.ts`. The
|
|
51
51
|
// graph has six nodes, so one pass per node closes it whatever the edges are.
|
|
52
|
-
function withDependents(roots) {
|
|
52
|
+
function withDependents(roots, input) {
|
|
53
53
|
const found = new Set(roots);
|
|
54
54
|
for (let pass = 0; pass < stageKinds.length; pass += 1) {
|
|
55
55
|
for (const kind of stageKinds) {
|
|
56
|
-
|
|
56
|
+
const dependencies = dependenciesOf(kind, {
|
|
57
|
+
thumbnail: input.thumbnailSource,
|
|
58
|
+
video: input.videoSource ?? "generate",
|
|
59
|
+
});
|
|
60
|
+
if (dependencies.some((dependency) => found.has(dependency))) {
|
|
57
61
|
found.add(kind);
|
|
58
62
|
}
|
|
59
63
|
}
|
|
@@ -142,13 +142,19 @@ function load(deps, projectId) {
|
|
|
142
142
|
stages,
|
|
143
143
|
outputs: outputsOf(deps.db, projectId),
|
|
144
144
|
thumbnailSource: project.config.sources.thumbnail,
|
|
145
|
+
videoSource: project.config.sources.video,
|
|
145
146
|
};
|
|
146
147
|
}
|
|
147
148
|
// One transaction for every row the action touches, then the filesystem: an unlink cannot
|
|
148
149
|
// be rolled back, so it waits for the commit. A file with no row is what the boot reconcile
|
|
149
150
|
// collects, where a row with no file is a broken download.
|
|
150
151
|
function apply(deps, projectId, action, loaded, own) {
|
|
151
|
-
const plan = redoPlan({
|
|
152
|
+
const plan = redoPlan({
|
|
153
|
+
action,
|
|
154
|
+
stages: loaded.stages,
|
|
155
|
+
thumbnailSource: loaded.thumbnailSource,
|
|
156
|
+
videoSource: loaded.videoSource,
|
|
157
|
+
});
|
|
152
158
|
// `own` is the action's own change - the new article, the image that goes - and it runs
|
|
153
159
|
// first and inside the same transaction, so it lands or rolls back with the cascade it
|
|
154
160
|
// triggers. The files it writes take the names they already had, replacing in place
|
|
@@ -190,6 +196,22 @@ function clearStage(deps, stage, outputs) {
|
|
|
190
196
|
deletePieces(deps.db, stage.id);
|
|
191
197
|
return files;
|
|
192
198
|
}
|
|
199
|
+
// Switching narrator halfway through a stage must not join two different voices.
|
|
200
|
+
// The caller includes these row changes with the config update's transaction and
|
|
201
|
+
// runs the returned cleanup only after commit. Finished narration is left intact.
|
|
202
|
+
export function clearUnfinishedAudio(deps, projectId) {
|
|
203
|
+
const stage = stagesOf(deps.db, projectId).find((one) => one.kind === "audio");
|
|
204
|
+
if (stage === undefined ||
|
|
205
|
+
stage.state === "done" ||
|
|
206
|
+
stage.state === "provided" ||
|
|
207
|
+
stage.state === "skipped")
|
|
208
|
+
return [];
|
|
209
|
+
const orphaned = clearStage(deps, stage, outputsOf(deps.db, projectId));
|
|
210
|
+
deps.db
|
|
211
|
+
.prepare("UPDATE stages SET progress_current = NULL, progress_total = NULL WHERE id = ?")
|
|
212
|
+
.run(stage.id);
|
|
213
|
+
return [() => removeFiles(deps, projectId, orphaned)];
|
|
214
|
+
}
|
|
193
215
|
// A regenerated image is replaced in place at the same index, so the piece keeps the prompt
|
|
194
216
|
// and the index it was planned with and only stops naming a file: `slices/images/run.ts`
|
|
195
217
|
// then sees one image of its plan still to make, and the file it named can be removed.
|
|
@@ -42,6 +42,8 @@ export function outputFileName(role, index, extension, stageKind) {
|
|
|
42
42
|
return "video.mp4";
|
|
43
43
|
case "audio_body":
|
|
44
44
|
return `audio-body${extension}`;
|
|
45
|
+
case "audio_export":
|
|
46
|
+
return "audio.wav";
|
|
45
47
|
case "audio_intro":
|
|
46
48
|
return `audio-intro${extension}`;
|
|
47
49
|
case "audio_outro":
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { projectDir } from "../storage/layout.js";
|
|
3
|
+
import { outputsOf } from "../storage/repo.js";
|
|
4
|
+
import { audioInputs } from "./audio-inputs.js";
|
|
5
|
+
import { audioTimeline } from "./plan.js";
|
|
6
|
+
import { writeExport } from "./write-export.js";
|
|
7
|
+
const rate = 48000;
|
|
8
|
+
export async function exportAudioWav(deps, context, gapSeconds) {
|
|
9
|
+
const { projectId } = context.stage;
|
|
10
|
+
const input = await audioInputs(deps, projectId, outputsOf(deps.db, projectId), context.signal);
|
|
11
|
+
const audio = audioTimeline({ ...input, gapSeconds }, 1 / rate);
|
|
12
|
+
const totalSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
|
|
13
|
+
const dir = projectDir(deps.paths, projectId);
|
|
14
|
+
const plan = {
|
|
15
|
+
sampleRate: rate,
|
|
16
|
+
channels: 2,
|
|
17
|
+
codec: "pcm_s16le",
|
|
18
|
+
gapSeconds,
|
|
19
|
+
totalSeconds,
|
|
20
|
+
audio: audio.map((segment) => ({
|
|
21
|
+
...segment,
|
|
22
|
+
path: segment.path === null ? null : relative(dir, segment.path),
|
|
23
|
+
})),
|
|
24
|
+
output: "audio.wav",
|
|
25
|
+
};
|
|
26
|
+
await writeExport(deps, context, {
|
|
27
|
+
role: "audio_export",
|
|
28
|
+
filename: "audio.wav",
|
|
29
|
+
partName: "audio.part.wav",
|
|
30
|
+
totalSeconds,
|
|
31
|
+
record: plan,
|
|
32
|
+
args: (part) => audioExportArgs(audio, part),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function audioExportArgs(audio, output) {
|
|
36
|
+
const inputs = audio.flatMap((segment) => segment.path === null
|
|
37
|
+
? ["-f", "lavfi", "-t", segment.seconds.toFixed(6), "-i", `anullsrc=r=${rate}:cl=stereo`]
|
|
38
|
+
: ["-i", segment.path]);
|
|
39
|
+
const chains = audio.map((_segment, index) => `[${index}:a]aformat=sample_fmts=s16:sample_rates=${rate}:channel_layouts=stereo,asetpts=PTS-STARTPTS[a${index}]`);
|
|
40
|
+
chains.push(`${audio.map((_segment, index) => `[a${index}]`).join("")}concat=n=${audio.length}:v=0:a=1[a]`);
|
|
41
|
+
return [
|
|
42
|
+
"-hide_banner",
|
|
43
|
+
"-nostdin",
|
|
44
|
+
"-loglevel",
|
|
45
|
+
"error",
|
|
46
|
+
"-progress",
|
|
47
|
+
"pipe:1",
|
|
48
|
+
"-nostats",
|
|
49
|
+
"-y",
|
|
50
|
+
...inputs,
|
|
51
|
+
"-filter_complex",
|
|
52
|
+
chains.join(";"),
|
|
53
|
+
"-map",
|
|
54
|
+
"[a]",
|
|
55
|
+
"-vn",
|
|
56
|
+
"-c:a",
|
|
57
|
+
"pcm_s16le",
|
|
58
|
+
"-ar",
|
|
59
|
+
String(rate),
|
|
60
|
+
"-ac",
|
|
61
|
+
"2",
|
|
62
|
+
"-f",
|
|
63
|
+
"wav",
|
|
64
|
+
output,
|
|
65
|
+
];
|
|
66
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -55,14 +55,12 @@ export function renderArgs(plan) {
|
|
|
55
55
|
filterGraph(plan, audioAt),
|
|
56
56
|
"-map",
|
|
57
57
|
"[v]",
|
|
58
|
-
"-map",
|
|
59
|
-
"[a]",
|
|
58
|
+
...(plan.audio.length > 0 ? ["-map", "[a]"] : []),
|
|
60
59
|
"-c:v",
|
|
61
60
|
"libx264",
|
|
62
61
|
"-pix_fmt",
|
|
63
62
|
"yuv420p",
|
|
64
|
-
"-c:a",
|
|
65
|
-
"aac",
|
|
63
|
+
...(plan.audio.length > 0 ? ["-c:a", "aac"] : ["-an"]),
|
|
66
64
|
"-movflags",
|
|
67
65
|
"+faststart",
|
|
68
66
|
plan.output,
|
|
@@ -86,7 +84,9 @@ function filterGraph(plan, audioAt) {
|
|
|
86
84
|
// brought to one format before concat, which refuses to join mismatched streams.
|
|
87
85
|
chains.push(`[${input}:a]aformat=sample_fmts=fltp:sample_rates=${sampleRate}:channel_layouts=${channelLayout}[a${at}]`);
|
|
88
86
|
});
|
|
89
|
-
|
|
87
|
+
if (audioAt.length > 0) {
|
|
88
|
+
chains.push(`${audioAt.map((_input, at) => `[a${at}]`).join("")}concat=n=${audioAt.length}:v=0:a=1[a]`);
|
|
89
|
+
}
|
|
90
90
|
return chains.join(";");
|
|
91
91
|
}
|
|
92
92
|
// Odd images 100% → 115%, even images 115% → 100%, linear over the slot. `on` is
|
|
@@ -12,12 +12,14 @@ export const zoomTo = 1.15;
|
|
|
12
12
|
export const zoomBy = 0.15;
|
|
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,43 @@
|
|
|
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 { exportAudioWav } from "./audio-export.js";
|
|
6
|
+
import { audioInputs } from "./audio-inputs.js";
|
|
7
|
+
import { renderArgs } from "./ffmpeg.js";
|
|
8
8
|
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"];
|
|
9
|
+
import { writeExport } from "./write-export.js";
|
|
22
10
|
export async function renderVideo(deps, context) {
|
|
23
11
|
const { projectId } = context.stage;
|
|
24
12
|
const project = projectById(deps.db, projectId);
|
|
25
|
-
if (project
|
|
13
|
+
if (!project)
|
|
26
14
|
throw new Error(`project ${projectId} has no row`);
|
|
15
|
+
if (project.config.sources.video === "off") {
|
|
16
|
+
if (project.config.sources.audio === "off")
|
|
17
|
+
throw new Error("the project has no media export enabled");
|
|
18
|
+
await exportAudioWav(deps, context, project.config.silenceGapSeconds);
|
|
19
|
+
return;
|
|
27
20
|
}
|
|
28
21
|
const outputs = outputsOf(deps.db, projectId);
|
|
29
22
|
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
|
-
}
|
|
23
|
+
const narration = project.config.sources.audio === "off"
|
|
24
|
+
? {}
|
|
25
|
+
: await audioInputs(deps, projectId, outputs, context.signal);
|
|
38
26
|
const plan = planRender({
|
|
39
27
|
format: project.format,
|
|
40
28
|
gapSeconds: project.config.silenceGapSeconds,
|
|
41
|
-
...
|
|
42
|
-
body,
|
|
43
|
-
...withOutro(await audio(deps, outputs, "audio_outro", dir, context.signal)),
|
|
29
|
+
...narration,
|
|
44
30
|
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);
|
|
31
|
+
output: outputPath(deps.paths, projectId, "video.mp4"),
|
|
105
32
|
});
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
current: totalMs,
|
|
114
|
-
total: totalMs,
|
|
33
|
+
await writeExport(deps, context, {
|
|
34
|
+
role: "video",
|
|
35
|
+
filename: "video.mp4",
|
|
36
|
+
partName: "video.part.mp4",
|
|
37
|
+
totalSeconds: plan.totalSeconds,
|
|
38
|
+
record: recorded(plan, dir),
|
|
39
|
+
args: (part) => renderArgs({ ...plan, output: part }),
|
|
115
40
|
});
|
|
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
41
|
deps.count("stage.completed", { stage: "video" });
|
|
120
42
|
}
|
|
121
43
|
// Written with project-relative paths, so the record of what was rendered can be read
|
|
@@ -138,41 +60,3 @@ function slideshow(outputs) {
|
|
|
138
60
|
.filter((output) => output.role === "image")
|
|
139
61
|
.toSorted((left, right) => (left.meta.index ?? 0) - (right.meta.index ?? 0));
|
|
140
62
|
}
|
|
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,98 @@
|
|
|
1
|
+
import { 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 { runFfmpeg } from "./ffmpeg.js";
|
|
8
|
+
const progressIntervalMs = 500;
|
|
9
|
+
const renderRoles = ["video", "audio_export", "render_params"];
|
|
10
|
+
// Both local exports retain the previous finished asset until ffmpeg succeeds and
|
|
11
|
+
// the abort check passes. The part file keeps its muxer's filename extension.
|
|
12
|
+
export async function writeExport(deps, context, output) {
|
|
13
|
+
const { projectId } = context.stage;
|
|
14
|
+
const target = outputPath(deps.paths, projectId, output.filename);
|
|
15
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
16
|
+
// The plan is the record of what was rendered and names the file the user downloads,
|
|
17
|
+
// so it keeps the final path; only the ffmpeg invocation is pointed at the part file.
|
|
18
|
+
const part = outputPath(deps.paths, projectId, output.partName);
|
|
19
|
+
const totalMs = Math.round(output.totalSeconds * 1000);
|
|
20
|
+
let announced = 0;
|
|
21
|
+
try {
|
|
22
|
+
// No retry and no timeout. A render that fails is terminal.
|
|
23
|
+
await runFfmpeg({
|
|
24
|
+
bin: deps.ffmpeg,
|
|
25
|
+
args: output.args(part),
|
|
26
|
+
signal: context.signal,
|
|
27
|
+
log: deps.log,
|
|
28
|
+
onProgress: (elapsedMs) => {
|
|
29
|
+
const at = deps.clock.now().getTime();
|
|
30
|
+
if (at - announced < progressIntervalMs) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
announced = at;
|
|
34
|
+
const current = Math.min(elapsedMs, totalMs);
|
|
35
|
+
setStageProgress(deps.db, context.stage.id, current, totalMs);
|
|
36
|
+
context.emit({
|
|
37
|
+
type: "stage.progress",
|
|
38
|
+
projectId,
|
|
39
|
+
stage: "video",
|
|
40
|
+
current,
|
|
41
|
+
total: totalMs,
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
// A cancel protects an output that was already stored, not one about to be. One
|
|
46
|
+
// landing between ffmpeg exiting and the rows below discards the render rather than
|
|
47
|
+
// reporting done to a page that pressed Cancel.
|
|
48
|
+
context.signal.throwIfAborted();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
// A partial render file is discarded, never kept or served. Only the part file: the
|
|
52
|
+
// previous export is a finished output and stays downloadable.
|
|
53
|
+
rmSync(part, { force: true });
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
// The swap. Everything below replaces the previous render, and nothing above it could.
|
|
57
|
+
renameSync(part, target);
|
|
58
|
+
const params = outputPath(deps.paths, projectId, "render.json");
|
|
59
|
+
writeFileSync(params, `${JSON.stringify(output.record, null, 2)}\n`, { mode: 0o600 });
|
|
60
|
+
// No version history. The files were written under the names the previous render
|
|
61
|
+
// already used, so replacing the rows that named them is all that is left to do. In one
|
|
62
|
+
// transaction, or a crash between the delete and the insert would leave the finished
|
|
63
|
+
// export with no row and the boot reconcile would collect it. Read back rather than
|
|
64
|
+
// reused from above: the rows are what a download resolves.
|
|
65
|
+
transact(deps.db, () => {
|
|
66
|
+
for (const previous of outputsOf(deps.db, projectId)) {
|
|
67
|
+
if (renderRoles.includes(previous.role)) {
|
|
68
|
+
deleteOutput(deps.db, previous.id);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
store(deps, projectId, "render_params", "render.json", null);
|
|
72
|
+
store(deps, projectId, output.role, output.filename, totalMs);
|
|
73
|
+
});
|
|
74
|
+
// Written as well as emitted: a page opened after the render reads the row, and the
|
|
75
|
+
// throttled in-flight writes would have left it frozen short of the end.
|
|
76
|
+
setStageProgress(deps.db, context.stage.id, totalMs, totalMs);
|
|
77
|
+
context.emit({
|
|
78
|
+
type: "stage.progress",
|
|
79
|
+
projectId,
|
|
80
|
+
stage: "video",
|
|
81
|
+
current: totalMs,
|
|
82
|
+
total: totalMs,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function store(deps, projectId, role, path, durationMs) {
|
|
86
|
+
insertOutput(deps.db, {
|
|
87
|
+
id: deps.ids.next(),
|
|
88
|
+
projectId,
|
|
89
|
+
stageKind: "video",
|
|
90
|
+
role,
|
|
91
|
+
path,
|
|
92
|
+
originalFilename: null,
|
|
93
|
+
bytes: statSync(outputPath(deps.paths, projectId, path)).size,
|
|
94
|
+
durationMs,
|
|
95
|
+
meta: {},
|
|
96
|
+
createdAt: deps.clock.now().toISOString(),
|
|
97
|
+
});
|
|
98
|
+
}
|