summaryrefslogtreecommitdiff
path: root/apps/desktop/src/export.ts
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-30 18:17:45 +0200
committercashmere <cashmere@autistici.org>2026-07-30 18:18:15 +0200
commited02ba872c02eafed45f406c68d389d0a1112a4e (patch)
tree40530d77cfe306621915f067c66531cb9e783a4d /apps/desktop/src/export.ts
parente837e7504379e100ff3b56282577ec67bb88bec9 (diff)
downloadopencut-ed02ba872c02eafed45f406c68d389d0a1112a4e.tar.gz
feat: Electron desktop app, native ffmpeg export, fully static web
- apps/desktop: Electron shell serving the static web build over a privileged opencut:// protocol; exports stream frames over IPC to a bundled ffmpeg (libx264/libvpx-vp9, BT.709 tagged) with backpressure. electron-builder packaging: AppImage, deb, rpm. - apps/web: dead backend deleted (better-auth, drizzle/pg, upstash, botid, freesound proxy, blog, rss, env validation); next build emits a zero-env static export; /editor/[project_id] -> /editor?project=. - Native exporter mirrors SceneExporter; activates when the preload bridge is present; web keeps the mediabunny path. - Docker self-hosting now builds and serves the static export via nginx. - Release workflow: v* tags publish static web tarball + Linux packages. - Changelog 0.4.0; README/AGENTS/CONTRIBUTING/roadmap updated. - GPUI/Rust rewrite parked on branch rust-rewrite.
Diffstat (limited to 'apps/desktop/src/export.ts')
-rw-r--r--apps/desktop/src/export.ts293
1 files changed, 293 insertions, 0 deletions
diff --git a/apps/desktop/src/export.ts b/apps/desktop/src/export.ts
new file mode 100644
index 00000000..4cbfe613
--- /dev/null
+++ b/apps/desktop/src/export.ts
@@ -0,0 +1,293 @@
+import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
+import { type ChildProcessByStdio, spawn } from "node:child_process";
+import type { Readable, Writable } from "node:stream";
+import { randomUUID } from "node:crypto";
+import { unlink, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import ffmpegStaticPath from "ffmpeg-static";
+
+export type DesktopExportBegin = {
+ width: number;
+ height: number;
+ fpsNumerator: number;
+ fpsDenominator: number;
+ format: "mp4" | "webm";
+ quality: "low" | "medium" | "high" | "very_high";
+ hasAudio: boolean;
+ sampleRate: number;
+ channels: number;
+ defaultFileName: string;
+};
+
+type ExportQuality = DesktopExportBegin["quality"];
+
+type FfmpegProcess = ChildProcessByStdio<Writable, null, Readable>;
+
+type ExportSession = {
+ id: string;
+ filePath: string;
+ opts: DesktopExportBegin;
+ audioPath: string | null;
+ process: FfmpegProcess | null;
+ exitCode: Promise<number> | null;
+ stderrTail: string;
+};
+
+const MP4_CRF: Record<ExportQuality, number> = {
+ low: 28,
+ medium: 23,
+ high: 19,
+ very_high: 15,
+};
+
+const WEBM_CRF: Record<ExportQuality, number> = {
+ low: 40,
+ medium: 32,
+ high: 24,
+ very_high: 16,
+};
+
+const STDERR_TAIL_LIMIT = 8192;
+
+const sessions = new Map<string, ExportSession>();
+
+function resolveFfmpegPath(): string {
+ if (!ffmpegStaticPath) {
+ throw new Error("ffmpeg binary is not available for this platform");
+ }
+ if (app.isPackaged) {
+ return ffmpegStaticPath.replace("app.asar", "app.asar.unpacked");
+ }
+ return ffmpegStaticPath;
+}
+
+function buildFfmpegArgs(session: ExportSession): string[] {
+ const { opts, filePath, audioPath } = session;
+
+ const args = [
+ "-y",
+ "-f",
+ "rawvideo",
+ "-pix_fmt",
+ "rgba",
+ "-s",
+ `${opts.width}x${opts.height}`,
+ "-r",
+ `${opts.fpsNumerator}/${opts.fpsDenominator}`,
+ "-i",
+ "pipe:0",
+ ];
+
+ if (audioPath) {
+ args.push(
+ "-f",
+ "f32le",
+ "-ar",
+ String(opts.sampleRate),
+ "-ac",
+ String(opts.channels),
+ "-i",
+ audioPath,
+ );
+ }
+
+ if (opts.format === "mp4") {
+ args.push(
+ "-c:v",
+ "libx264",
+ "-preset",
+ "medium",
+ "-crf",
+ String(MP4_CRF[opts.quality]),
+ "-pix_fmt",
+ "yuv420p",
+ "-color_primaries",
+ "bt709",
+ "-color_trc",
+ "bt709",
+ "-colorspace",
+ "bt709",
+ );
+ if (audioPath) {
+ args.push("-c:a", "aac", "-b:a", "192k");
+ }
+ args.push("-movflags", "+faststart");
+ } else {
+ args.push(
+ "-c:v",
+ "libvpx-vp9",
+ "-deadline",
+ "good",
+ "-cpu-used",
+ "2",
+ "-row-mt",
+ "1",
+ "-crf",
+ String(WEBM_CRF[opts.quality]),
+ "-b:v",
+ "0",
+ "-pix_fmt",
+ "yuv420p",
+ );
+ if (audioPath) {
+ args.push("-c:a", "libopus", "-b:a", "192k");
+ }
+ }
+
+ if (audioPath) {
+ args.push("-shortest");
+ }
+
+ args.push(filePath);
+ return args;
+}
+
+function getSession(id: string): ExportSession {
+ const session = sessions.get(id);
+ if (!session) {
+ throw new Error(`Unknown export session: ${id}`);
+ }
+ return session;
+}
+
+function toBuffer(data: ArrayBuffer | Uint8Array): Buffer {
+ if (data instanceof Uint8Array) {
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
+ }
+ return Buffer.from(data);
+}
+
+function spawnFfmpeg(session: ExportSession): FfmpegProcess {
+ const proc = spawn(resolveFfmpegPath(), buildFfmpegArgs(session), {
+ stdio: ["pipe", "ignore", "pipe"],
+ });
+
+ proc.stderr.on("data", (chunk: Buffer) => {
+ session.stderrTail = (session.stderrTail + chunk.toString()).slice(
+ -STDERR_TAIL_LIMIT,
+ );
+ });
+
+ session.exitCode = new Promise<number>((resolve, reject) => {
+ proc.on("error", reject);
+ proc.on("close", (code) => resolve(code ?? 1));
+ });
+ session.exitCode.catch(() => {});
+ session.process = proc;
+ return proc;
+}
+
+async function teardownSession(session: ExportSession): Promise<void> {
+ if (session.process) {
+ session.process.kill("SIGKILL");
+ await session.exitCode?.catch(() => {});
+ }
+ if (session.audioPath) {
+ await unlink(session.audioPath).catch(() => {});
+ }
+ await unlink(session.filePath).catch(() => {});
+}
+
+export function registerExportHandlers(): void {
+ ipcMain.handle("export:begin", async (event, opts: DesktopExportBegin) => {
+ const win = BrowserWindow.fromWebContents(event.sender);
+ const filters =
+ opts.format === "mp4"
+ ? [{ name: "MP4 Video", extensions: ["mp4"] }]
+ : [{ name: "WebM Video", extensions: ["webm"] }];
+ const saveOptions = { defaultPath: opts.defaultFileName, filters };
+ const result = win
+ ? await dialog.showSaveDialog(win, saveOptions)
+ : await dialog.showSaveDialog(saveOptions);
+
+ if (result.canceled || !result.filePath) {
+ return null;
+ }
+
+ const id = randomUUID();
+ sessions.set(id, {
+ id,
+ filePath: result.filePath,
+ opts,
+ audioPath: null,
+ process: null,
+ exitCode: null,
+ stderrTail: "",
+ });
+ return { id, filePath: result.filePath };
+ });
+
+ ipcMain.handle(
+ "export:writeAudio",
+ async (_event, id: string, pcm: ArrayBuffer | Uint8Array) => {
+ const session = getSession(id);
+ const audioPath = path.join(os.tmpdir(), `opencut-audio-${id}.f32`);
+ await writeFile(audioPath, toBuffer(pcm));
+ session.audioPath = audioPath;
+ },
+ );
+
+ ipcMain.handle(
+ "export:writeFrame",
+ async (_event, id: string, rgba: ArrayBuffer | Uint8Array) => {
+ const session = getSession(id);
+ const proc = session.process ?? spawnFfmpeg(session);
+ const buffer = toBuffer(rgba);
+
+ await new Promise<void>((resolve, reject) => {
+ const onError = (error: Error) => reject(error);
+ proc.stdin.once("error", onError);
+ if (proc.stdin.write(buffer)) {
+ proc.stdin.removeListener("error", onError);
+ resolve();
+ return;
+ }
+ proc.stdin.once("drain", () => {
+ proc.stdin.removeListener("error", onError);
+ resolve();
+ });
+ });
+ },
+ );
+
+ ipcMain.handle("export:finish", async (_event, id: string) => {
+ const session = getSession(id);
+ sessions.delete(id);
+
+ try {
+ if (session.process && session.exitCode) {
+ session.process.stdin.end();
+ const code = await session.exitCode;
+ if (code !== 0) {
+ const tail = session.stderrTail.trim();
+ throw new Error(
+ `ffmpeg exited with code ${code}${tail ? `: ${tail}` : ""}`,
+ );
+ }
+ }
+ } finally {
+ if (session.audioPath) {
+ await unlink(session.audioPath).catch(() => {});
+ }
+ }
+ });
+
+ ipcMain.handle("export:cancel", async (_event, id: string) => {
+ const session = sessions.get(id);
+ if (!session) return;
+ sessions.delete(id);
+ await teardownSession(session);
+ });
+
+ ipcMain.handle("reveal-file", (_event, filePath: string) => {
+ shell.showItemInFolder(filePath);
+ });
+}
+
+export function cancelAllExports(): void {
+ for (const session of sessions.values()) {
+ session.process?.kill("SIGKILL");
+ }
+ sessions.clear();
+}