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 | |
| 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')
| -rw-r--r-- | apps/desktop/src/export.ts | 293 | ||||
| -rw-r--r-- | apps/desktop/src/main.rs | 41 | ||||
| -rw-r--r-- | apps/desktop/src/main.ts | 131 | ||||
| -rw-r--r-- | apps/desktop/src/preload.ts | 22 |
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); |
