diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-30 18:17:45 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-30 18:18:15 +0200 |
| commit | ed02ba872c02eafed45f406c68d389d0a1112a4e (patch) | |
| tree | 40530d77cfe306621915f067c66531cb9e783a4d /apps/desktop/src/export.ts | |
| parent | e837e7504379e100ff3b56282577ec67bb88bec9 (diff) | |
| download | opencut-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.ts | 293 |
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(); +} |
