@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.
Files changed (84) hide show
  1. package/SUBTITLES.md +21 -0
  2. package/dist/adapter-registry.js +8 -3
  3. package/dist/adapters/alignment/audio.js +45 -0
  4. package/dist/adapters/alignment/cache.js +91 -0
  5. package/dist/adapters/alignment/ctc.js +128 -0
  6. package/dist/adapters/alignment/index.js +34 -0
  7. package/dist/adapters/alignment/lock.js +58 -0
  8. package/dist/adapters/alignment/numbers.js +91 -0
  9. package/dist/adapters/alignment/protocol.js +23 -0
  10. package/dist/adapters/alignment/quality.js +17 -0
  11. package/dist/adapters/alignment/runner.js +76 -0
  12. package/dist/adapters/alignment/text.js +61 -0
  13. package/dist/adapters/alignment/vocabulary.js +69 -0
  14. package/dist/adapters/alignment/worker.js +163 -0
  15. package/dist/adapters/llm/claude-code.js +6 -3
  16. package/dist/adapters/llm/codex.js +3 -2
  17. package/dist/adapters/llm/gemini-workspace.js +70 -0
  18. package/dist/adapters/llm/gemini.js +141 -0
  19. package/dist/adapters/llm/run-cli.js +6 -9
  20. package/dist/assets/fonts/Barlow-Regular.ttf +0 -0
  21. package/dist/assets/fonts/OFL.txt +93 -0
  22. package/dist/assets/fonts/SOURCE.txt +7 -0
  23. package/dist/edge/events/hub.js +4 -0
  24. package/dist/edge/http/actions.js +68 -14
  25. package/dist/edge/http/app.js +4 -0
  26. package/dist/edge/http/fonts.js +147 -0
  27. package/dist/edge/http/projects.js +40 -11
  28. package/dist/edge/http/providers.js +13 -0
  29. package/dist/edge/http/subtitles.js +92 -0
  30. package/dist/kernel/cli-command.js +45 -0
  31. package/dist/kernel/db/migrations/0002-project-controls.sql +4 -0
  32. package/dist/kernel/pipeline.js +10 -5
  33. package/dist/kernel/ports/subtitles.js +1 -0
  34. package/dist/kernel/runner/graph.js +18 -18
  35. package/dist/kernel/runner/index.js +10 -6
  36. package/dist/kernel/runner/piece-repo.js +8 -1
  37. package/dist/kernel/runner/providers.js +3 -2
  38. package/dist/main.js +32 -4
  39. package/dist/slices/admission/model.js +2 -2
  40. package/dist/slices/admission/repo.js +22 -4
  41. package/dist/slices/admission/rules.js +32 -12
  42. package/dist/slices/admission/start.js +3 -1
  43. package/dist/slices/article/provided-entries.js +62 -0
  44. package/dist/slices/article/run.js +63 -101
  45. package/dist/slices/article/segments.js +73 -0
  46. package/dist/slices/cancel/index.js +27 -4
  47. package/dist/slices/control/index.js +155 -0
  48. package/dist/slices/control/lock.js +19 -0
  49. package/dist/slices/control/providers.js +59 -0
  50. package/dist/slices/fonts/catalog.js +95 -0
  51. package/dist/slices/fonts/discovery.js +81 -0
  52. package/dist/slices/fonts/files.js +37 -0
  53. package/dist/slices/fonts/index.js +5 -0
  54. package/dist/slices/fonts/model.js +4 -0
  55. package/dist/slices/fonts/preview.js +14 -0
  56. package/dist/slices/fonts/sfnt.js +262 -0
  57. package/dist/slices/fonts/upload.js +40 -0
  58. package/dist/slices/library/slots.js +3 -1
  59. package/dist/slices/reruns/cascade.js +11 -7
  60. package/dist/slices/reruns/index.js +23 -1
  61. package/dist/slices/settings/cli-paths.js +98 -0
  62. package/dist/slices/settings/cli-status.js +21 -12
  63. package/dist/slices/settings/model.js +9 -0
  64. package/dist/slices/settings/readiness.js +13 -9
  65. package/dist/slices/storage/downloads.js +2 -0
  66. package/dist/slices/storage/layout.js +12 -0
  67. package/dist/slices/storage/model.js +6 -0
  68. package/dist/slices/storage/repo.js +1 -0
  69. package/dist/slices/subtitles/captions.js +88 -0
  70. package/dist/slices/subtitles/model.js +18 -0
  71. package/dist/slices/subtitles/prepare.js +162 -0
  72. package/dist/slices/subtitles/transcript.js +34 -0
  73. package/dist/slices/video/audio-export.js +69 -0
  74. package/dist/slices/video/audio-inputs.js +32 -0
  75. package/dist/slices/video/ffmpeg.js +19 -12
  76. package/dist/slices/video/plan.js +13 -12
  77. package/dist/slices/video/run.js +28 -141
  78. package/dist/slices/video/write-export.js +163 -0
  79. package/dist/web/assets/index-CNYs0noB.js +130 -0
  80. package/dist/web/assets/index-Mk0bvBg-.css +1 -0
  81. package/dist/web/index.html +2 -2
  82. package/package.json +4 -2
  83. package/dist/web/assets/index-BZvZKvIv.js +0 -81
  84. 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 115%.
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
- chains.push(`${audioAt.map((_input, at) => `[a${at}]`).join("")}concat=n=${audioAt.length}:v=0:a=1[a]`);
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% → 115%, even images 115% → 100%, linear over the slot. `on` is
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
- const child = spawn(run.bin, [...run.args], {
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% → 115%, linear, centred, alternating per image. The three are written out
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.15 - 1 is not 0.15 in binary floating point.
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.15;
12
- export const zoomBy = 0.15;
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 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,46 @@
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 { 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
- // 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"];
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 === undefined) {
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 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
- }
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
- ...withIntro(await audio(deps, outputs, "audio_intro", dir, context.signal)),
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: 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);
32
+ output: outputPath(deps.paths, projectId, "video.mp4"),
105
33
  });
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,
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
+ }