summaryrefslogtreecommitdiff
path: root/apps/desktop/src
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
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')
-rw-r--r--apps/desktop/src/export.ts293
-rw-r--r--apps/desktop/src/main.rs41
-rw-r--r--apps/desktop/src/main.ts131
-rw-r--r--apps/desktop/src/preload.ts22
4 files changed, 446 insertions, 41 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();
+}
diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs
deleted file mode 100644
index 0ba70959..00000000
--- a/apps/desktop/src/main.rs
+++ /dev/null
@@ -1,41 +0,0 @@
-use gpui::{
- div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window,
- WindowBounds, WindowOptions,
-};
-
-struct AppWindow {
- title: SharedString,
-}
-
-impl Render for AppWindow {
- fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
- div()
- .size_full()
- .bg(rgb(0x0f0f0f))
- .flex()
- .justify_center()
- .items_center()
- .text_xl()
- .text_color(rgb(0xffffff))
- .child(self.title.clone())
- }
-}
-
-fn main() {
- Application::new().run(|cx: &mut App| {
- let bounds = Bounds::centered(None, size(px(1280.), px(720.)), cx);
- cx.open_window(
- WindowOptions {
- window_bounds: Some(WindowBounds::Windowed(bounds)),
- ..Default::default()
- },
- |_, cx| {
- cx.new(|_| AppWindow {
- title: "OpenCut".into(),
- })
- },
- )
- .unwrap();
- cx.activate(true);
- });
-}
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
new file mode 100644
index 00000000..fccd7c1d
--- /dev/null
+++ b/apps/desktop/src/main.ts
@@ -0,0 +1,131 @@
+import { app, BrowserWindow, protocol } from "electron";
+import { stat } from "node:fs/promises";
+import { createReadStream } from "node:fs";
+import path from "node:path";
+import { Readable } from "node:stream";
+import { cancelAllExports, registerExportHandlers } from "./export.js";
+
+const contentTypes: Record<string, string> = {
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".wasm": "application/wasm",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".svg": "image/svg+xml",
+ ".ico": "image/x-icon",
+ ".woff2": "font/woff2",
+ ".mp4": "video/mp4",
+ ".webm": "video/webm",
+ ".txt": "text/plain; charset=utf-8",
+ ".xml": "application/xml; charset=utf-8",
+ ".map": "application/json; charset=utf-8",
+};
+
+protocol.registerSchemesAsPrivileged([
+ {
+ scheme: "opencut",
+ privileges: {
+ standard: true,
+ secure: true,
+ supportFetchAPI: true,
+ stream: true,
+ bypassCSP: false,
+ },
+ },
+]);
+
+function registerWebProtocol(): void {
+ const webDir = app.isPackaged
+ ? path.join(process.resourcesPath, "web")
+ : path.join(__dirname, "..", "web");
+
+ protocol.handle("opencut", async (request) => {
+ const url = new URL(request.url);
+ const pathname = decodeURIComponent(url.pathname);
+ const resolved = path.normalize(path.join(webDir, pathname));
+
+ if (resolved !== webDir && !resolved.startsWith(`${webDir}${path.sep}`)) {
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ let filePath = resolved;
+ let info = await stat(filePath).catch(() => null);
+
+ if (info?.isDirectory() || (!info && path.extname(filePath) === "")) {
+ filePath = path.join(resolved, "index.html");
+ info = await stat(filePath).catch(() => null);
+ }
+
+ if (!info?.isFile()) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const contentType = contentTypes[path.extname(filePath).toLowerCase()];
+ if (!contentType) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const stream = Readable.toWeb(createReadStream(filePath));
+ return new Response(stream as ReadableStream, {
+ headers: { "Content-Type": contentType },
+ });
+ });
+}
+
+function createWindow(): void {
+ const win = new BrowserWindow({
+ width: 1440,
+ height: 900,
+ minWidth: 960,
+ minHeight: 600,
+ backgroundColor: "#0a0a0a",
+ autoHideMenuBar: true,
+ webPreferences: {
+ preload: path.join(__dirname, "preload.js"),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: false,
+ },
+ });
+
+ const devServerUrl = process.env.OPENCUT_DEV_SERVER_URL;
+ if (devServerUrl) {
+ win.loadURL(devServerUrl);
+ return;
+ }
+
+ win.loadURL("opencut://app/");
+}
+
+const gotSingleInstanceLock = app.requestSingleInstanceLock();
+
+if (!gotSingleInstanceLock) {
+ app.quit();
+} else {
+ app.on("second-instance", () => {
+ const [win] = BrowserWindow.getAllWindows();
+ if (!win) return;
+ if (win.isMinimized()) win.restore();
+ win.focus();
+ });
+
+ app.whenReady().then(() => {
+ registerWebProtocol();
+ registerExportHandlers();
+ createWindow();
+
+ app.on("activate", () => {
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
+ });
+ });
+}
+
+app.on("window-all-closed", () => {
+ app.quit();
+});
+
+app.on("will-quit", () => {
+ cancelAllExports();
+});
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
new file mode 100644
index 00000000..ad3f6cf9
--- /dev/null
+++ b/apps/desktop/src/preload.ts
@@ -0,0 +1,22 @@
+import { contextBridge, ipcRenderer } from "electron";
+import type { DesktopExportBegin } from "./export.js";
+
+const api = {
+ isDesktop: true as const,
+ exportBegin: (
+ opts: DesktopExportBegin,
+ ): Promise<{ id: string; filePath: string } | null> =>
+ ipcRenderer.invoke("export:begin", opts),
+ exportWriteAudio: (id: string, pcm: ArrayBuffer): Promise<void> =>
+ ipcRenderer.invoke("export:writeAudio", id, pcm),
+ exportWriteFrame: (id: string, rgba: ArrayBuffer): Promise<void> =>
+ ipcRenderer.invoke("export:writeFrame", id, rgba),
+ exportFinish: (id: string): Promise<void> =>
+ ipcRenderer.invoke("export:finish", id),
+ exportCancel: (id: string): Promise<void> =>
+ ipcRenderer.invoke("export:cancel", id),
+ revealFile: (path: string): Promise<void> =>
+ ipcRenderer.invoke("reveal-file", path),
+};
+
+contextBridge.exposeInMainWorld("opencutDesktop", api);