@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.
Files changed (37) hide show
  1. package/dist/edge/events/hub.js +4 -0
  2. package/dist/edge/http/actions.js +68 -14
  3. package/dist/edge/http/projects.js +17 -10
  4. package/dist/kernel/db/migrations/0002-project-controls.sql +4 -0
  5. package/dist/kernel/pipeline.js +10 -5
  6. package/dist/kernel/runner/graph.js +18 -18
  7. package/dist/kernel/runner/index.js +10 -6
  8. package/dist/kernel/runner/piece-repo.js +8 -1
  9. package/dist/main.js +30 -3
  10. package/dist/slices/admission/model.js +2 -2
  11. package/dist/slices/admission/repo.js +20 -4
  12. package/dist/slices/admission/rules.js +26 -12
  13. package/dist/slices/admission/start.js +3 -1
  14. package/dist/slices/article/provided-entries.js +62 -0
  15. package/dist/slices/article/run.js +63 -101
  16. package/dist/slices/article/segments.js +73 -0
  17. package/dist/slices/cancel/index.js +27 -4
  18. package/dist/slices/control/index.js +155 -0
  19. package/dist/slices/control/lock.js +19 -0
  20. package/dist/slices/control/providers.js +59 -0
  21. package/dist/slices/library/slots.js +3 -1
  22. package/dist/slices/reruns/cascade.js +11 -7
  23. package/dist/slices/reruns/index.js +23 -1
  24. package/dist/slices/storage/layout.js +2 -0
  25. package/dist/slices/storage/model.js +1 -0
  26. package/dist/slices/video/audio-export.js +66 -0
  27. package/dist/slices/video/audio-inputs.js +32 -0
  28. package/dist/slices/video/ffmpeg.js +5 -5
  29. package/dist/slices/video/plan.js +9 -8
  30. package/dist/slices/video/run.js +25 -141
  31. package/dist/slices/video/write-export.js +98 -0
  32. package/dist/web/assets/index-C1fsrIP5.css +1 -0
  33. package/dist/web/assets/index-DwJ_XTl3.js +81 -0
  34. package/dist/web/index.html +2 -2
  35. package/package.json +1 -1
  36. package/dist/web/assets/index-BZvZKvIv.js +0 -81
  37. package/dist/web/assets/index-cpjfvs5E.css +0 -1
@@ -1,5 +1,5 @@
1
1
  import { stageKinds } from "../../kernel/pipeline.js";
2
- import { deps as graph } from "../../kernel/runner/graph.js";
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
- if (graph[kind].some((dependency) => found.has(dependency))) {
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({ action, stages: loaded.stages, thumbnailSource: loaded.thumbnailSource });
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":
@@ -11,6 +11,7 @@ export const outputRoles = [
11
11
  "audio_body",
12
12
  "audio_intro",
13
13
  "audio_outro",
14
+ "audio_export",
14
15
  "image",
15
16
  "thumbnail",
16
17
  "video",
@@ -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
- chains.push(`${audioAt.map((_input, at) => `[a${at}]`).join("")}concat=n=${audioAt.length}:v=0:a=1[a]`);
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 makes an image source mandatory, so an empty set is a bug upstream.
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 = timeline(input);
20
- const totalSeconds = audio.reduce((sum, segment) => sum + segment.seconds, 0);
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 timeline(input) {
38
+ export function audioTimeline(input, minimumGap = 1 / fps) {
37
39
  const segments = [];
38
40
  const gap = { kind: "gap", path: null, seconds: input.gapSeconds };
39
- // A gap shorter than one frame is no gap. The renderer formats a segment's length to
40
- // milliseconds, so anything under that would become `-t 0.000`: an anullsrc input with
41
- // no samples that still had to be counted in the audio concat.
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) {
@@ -1,121 +1,43 @@
1
- import { mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
- import { dirname, relative } from "node:path";
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 { deleteOutput, insertOutput, outputsOf } from "../storage/repo.js";
7
- import { probeDurationMs, renderArgs, runFfmpeg } from "./ffmpeg.js";
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
- // ceiling: progress is coalesced to one event per 500 ms, and the stage's progress
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 === undefined) {
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 target = outputPath(deps.paths, projectId, "video.mp4");
31
- const body = await audio(deps, outputs, "audio_body", dir, context.signal);
32
- if (body === undefined) {
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
- ...withIntro(await audio(deps, outputs, "audio_intro", dir, context.signal)),
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: target,
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
- // Written as well as emitted: a page opened after the render reads the row, and the
107
- // throttled in-flight writes would have left it frozen short of the end.
108
- setStageProgress(deps.db, context.stage.id, totalMs, totalMs);
109
- context.emit({
110
- type: "stage.progress",
111
- projectId,
112
- stage: "video",
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
+ }