summaryrefslogtreecommitdiff
path: root/apps
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
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')
-rw-r--r--apps/desktop/.gitignore4
-rw-r--r--apps/desktop/Cargo.toml11
-rw-r--r--apps/desktop/README.md47
-rw-r--r--apps/desktop/build/icon.pngbin0 -> 741 bytes
-rw-r--r--apps/desktop/electron-builder.yml28
-rw-r--r--apps/desktop/package.json25
-rw-r--r--apps/desktop/script/setup110
-rw-r--r--apps/desktop/script/setup.ps127
-rw-r--r--apps/desktop/scripts/copy-web.mjs18
-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
-rw-r--r--apps/desktop/tsconfig.json17
-rw-r--r--apps/web/.env.example21
-rw-r--r--apps/web/.gitignore3
-rw-r--r--apps/web/Dockerfile83
-rw-r--r--apps/web/content-collections.ts2
-rw-r--r--apps/web/drizzle.config.ts26
-rw-r--r--apps/web/migrations/0000_brainy_saracen.sql62
-rw-r--r--apps/web/migrations/meta/0000_snapshot.json344
-rw-r--r--apps/web/migrations/meta/_journal.json13
-rw-r--r--apps/web/next.config.ts45
-rw-r--r--apps/web/open-next.config.ts3
-rw-r--r--apps/web/package.json28
-rw-r--r--apps/web/src/app/api/auth/[...all]/route.ts4
-rw-r--r--apps/web/src/app/api/health/route.ts3
-rw-r--r--apps/web/src/app/api/sounds/search/route.ts280
-rw-r--r--apps/web/src/app/blog/[slug]/page.tsx135
-rw-r--r--apps/web/src/app/blog/page.tsx52
-rw-r--r--apps/web/src/app/changelog/[version]/page.tsx2
-rw-r--r--apps/web/src/app/editor/page.tsx (renamed from apps/web/src/app/editor/[project_id]/page.tsx)16
-rw-r--r--apps/web/src/app/layout.tsx12
-rw-r--r--apps/web/src/app/projects/page.tsx10
-rw-r--r--apps/web/src/app/robots.ts2
-rw-r--r--apps/web/src/app/rss.xml/route.ts46
-rw-r--r--apps/web/src/app/sitemap.ts100
-rw-r--r--apps/web/src/auth/client.ts6
-rw-r--r--apps/web/src/auth/rate-limit.ts21
-rw-r--r--apps/web/src/auth/server.ts43
-rw-r--r--apps/web/src/blog/query.ts133
-rw-r--r--apps/web/src/blog/types.ts92
-rw-r--r--apps/web/src/changelog/entries/0.4.0.md50
-rw-r--r--apps/web/src/components/editor/export-button.tsx18
-rw-r--r--apps/web/src/components/providers/editor-provider.tsx2
-rw-r--r--apps/web/src/core/managers/renderer-manager.ts42
-rw-r--r--apps/web/src/db/index.ts19
-rw-r--r--apps/web/src/db/schema.ts70
-rw-r--r--apps/web/src/desktop/bridge.ts35
-rw-r--r--apps/web/src/env/web.ts30
-rw-r--r--apps/web/src/export/index.ts1
-rw-r--r--apps/web/src/services/renderer/native-exporter.ts159
-rw-r--r--apps/web/wrangler.jsonc17
53 files changed, 914 insertions, 1890 deletions
diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore
new file mode 100644
index 00000000..090e2f63
--- /dev/null
+++ b/apps/desktop/.gitignore
@@ -0,0 +1,4 @@
+dist/
+web/
+release/
+node_modules/
diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml
deleted file mode 100644
index e09443ab..00000000
--- a/apps/desktop/Cargo.toml
+++ /dev/null
@@ -1,11 +0,0 @@
-[package]
-name = "opencut-desktop"
-version = "0.1.0"
-edition = "2021"
-
-[[bin]]
-name = "opencut"
-path = "src/main.rs"
-
-[dependencies]
-gpui = "0.2.2"
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
deleted file mode 100644
index 9cff04e1..00000000
--- a/apps/desktop/README.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Desktop
-
-The native desktop app, built with [GPUI](https://gpui.rs).
-
-## Getting started
-
-**1. Install Rust:**
-
-```bash
-# Linux / macOS / WSL
-./script/setup-rust
-```
-
-```powershell
-# Windows
-powershell -ExecutionPolicy Bypass -File .\script\setup-rust.ps1
-```
-
-Both scripts skip installation if Rust is already present. On Linux/macOS/WSL only: after a fresh install, reload your shell with `source "$HOME/.cargo/env"`
-
-**2. Install native dependencies:**
-
-```bash
-# Linux / macOS / WSL
-./apps/desktop/script/setup
-```
-
-```powershell
-# Windows
-powershell -ExecutionPolicy Bypass -File .\apps\desktop\script\setup.ps1
-```
-
-**3. Run:**
-
-```bash
-cargo run -p opencut-desktop
-```
-
-## Platform notes
-
-**Linux:** supports apt (Debian/Ubuntu/Mint), dnf (Fedora/RHEL), and pacman (Arch).
-
-**macOS:** installs Xcode Command Line Tools if missing.
-
-**Windows:** the setup script checks for Visual Studio Build Tools. If missing, it prints the install link.
-
-**WSL:** runs the same scripts as Linux. Window rendering works via WSLg on Windows 11 and Windows 10 22H2+. If you're on an older build, test on the host instead.
diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png
new file mode 100644
index 00000000..f534f429
--- /dev/null
+++ b/apps/desktop/build/icon.png
Binary files differ
diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml
new file mode 100644
index 00000000..b47cf1d7
--- /dev/null
+++ b/apps/desktop/electron-builder.yml
@@ -0,0 +1,28 @@
+appId: rs.cashmere.opencut
+productName: OpenCut
+directories:
+ output: release
+files:
+ - dist/**
+ - package.json
+asarUnpack:
+ - "**/ffmpeg-static/**"
+extraResources:
+ - from: web
+ to: web
+linux:
+ executableName: opencut
+ icon: build/icon.png
+ syncDesktopName: true
+ category: AudioVideo;Video
+ target:
+ - AppImage
+ - deb
+ - rpm
+ artifactName: OpenCut-${version}-linux-${arch}.${ext}
+deb:
+ maintainer: cashmere
+ description: Offline-first video editor
+rpm:
+ maintainer: cashmere
+ description: Offline-first video editor
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
new file mode 100644
index 00000000..99934d7b
--- /dev/null
+++ b/apps/desktop/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@opencut/desktop",
+ "version": "0.1.0",
+ "private": true,
+ "main": "dist/main.js",
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "dev": "bun run build && electron .",
+ "pack": "bun run build && electron-builder --dir",
+ "dist": "bun run build && electron-builder --linux AppImage deb rpm",
+ "copy:web": "node scripts/copy-web.mjs"
+ },
+ "dependencies": {
+ "ffmpeg-static": "^5.3.0"
+ },
+ "devDependencies": {
+ "@types/node": "^26.1.2",
+ "electron": "^43.2.0",
+ "electron-builder": "^26.15.3",
+ "typescript": "^7.0.2"
+ },
+ "author": "cashmere",
+ "homepage": "https://github.com/cashmeredev/opencut",
+ "desktopName": "OpenCut"
+}
diff --git a/apps/desktop/script/setup b/apps/desktop/script/setup
deleted file mode 100644
index 3152a080..00000000
--- a/apps/desktop/script/setup
+++ /dev/null
@@ -1,110 +0,0 @@
-#!/usr/bin/env bash
-
-# Installs the native system libraries GPUI needs to compile on Linux/macOS.
-# Run script/setup-rust first if you don't have Rust installed yet.
-
-set -euo pipefail
-
-if [ "$(id -u)" -eq 0 ]; then
- maysudo=''
-else
- maysudo="$(command -v sudo || command -v doas || true)"
-fi
-
-# Ubuntu, Debian, Mint, Pop!_OS, Kali, Raspbian, etc.
-apt=$(command -v apt-get || true)
-if [[ -n $apt ]]; then
- deps=(
- curl
- build-essential
- pkg-config
- gcc
- g++
- make
- libasound2-dev
- libfontconfig-dev
- libglib2.0-dev
- libssl-dev
- libva-dev
- libvulkan1
- libwayland-dev
- libx11-xcb-dev
- libxkbcommon-x11-dev
- libzstd-dev
- libsqlite3-dev
- )
-
- $maysudo "$apt" update
- $maysudo "$apt" install -y "${deps[@]}"
- echo "Desktop native dependencies installed."
- exit 0
-fi
-
-# Fedora, RHEL, CentOS, Alma, etc.
-dnf=$(command -v dnf || true)
-if [[ -n $dnf ]]; then
- deps=(
- gcc
- gcc-c++
- make
- pkg-config
- alsa-lib-devel
- fontconfig-devel
- glib2-devel
- openssl-devel
- libva-devel
- vulkan-loader
- wayland-devel
- libxcb-devel
- libxkbcommon-x11-devel
- libzstd-devel
- sqlite-devel
- curl
- )
-
- $maysudo "$dnf" install -y "${deps[@]}"
- echo "Desktop native dependencies installed."
- exit 0
-fi
-
-# Arch, Manjaro, etc.
-pacman=$(command -v pacman || true)
-if [[ -n $pacman ]]; then
- deps=(
- gcc
- make
- pkgconf
- alsa-lib
- fontconfig
- glib2
- openssl
- libva
- vulkan-loader
- wayland
- libxcb
- libxkbcommon-x11
- zstd
- sqlite
- curl
- )
-
- $maysudo "$pacman" -Syu --needed --noconfirm "${deps[@]}"
- echo "Desktop native dependencies installed."
- exit 0
-fi
-
-# macOS
-if [[ "$(uname)" == "Darwin" ]]; then
- if ! xcode-select -p &> /dev/null; then
- echo "Installing Xcode Command Line Tools..."
- xcode-select --install
- echo "Re-run this script after the Xcode install completes."
- exit 0
- fi
-
- echo "Desktop native dependencies installed."
- exit 0
-fi
-
-echo "Unsupported platform. See apps/desktop/README.md for manual setup instructions."
-exit 1
diff --git a/apps/desktop/script/setup.ps1 b/apps/desktop/script/setup.ps1
deleted file mode 100644
index 63f7fd1a..00000000
--- a/apps/desktop/script/setup.ps1
+++ /dev/null
@@ -1,27 +0,0 @@
-# Installs the native build tools GPUI needs to compile on Windows.
-# Run script/setup-rust.ps1 first if you don't have Rust installed yet.
-
-$ErrorActionPreference = "Stop"
-
-function Check-VSBuildTools {
- $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe"
-
- if (-not (Test-Path $vswhere)) {
- return $false
- }
-
- $result = & $vswhere -latest -requires Microsoft.VisualStudio.Workload.NativeDesktop -property installationPath
- return $result -and $result.Trim().Length -gt 0
-}
-
-if (-not (Check-VSBuildTools)) {
- Write-Output ""
- Write-Output "Visual Studio Build Tools with 'Desktop development with C++' not found."
- Write-Output ""
- Write-Output "Install it from: https://visualstudio.microsoft.com/visual-cpp-build-tools/"
- Write-Output "Check the 'Desktop development with C++' workload during setup, then re-run this script."
- Write-Output ""
- exit 1
-}
-
-Write-Output "Desktop native dependencies ready."
diff --git a/apps/desktop/scripts/copy-web.mjs b/apps/desktop/scripts/copy-web.mjs
new file mode 100644
index 00000000..f5f6f4f6
--- /dev/null
+++ b/apps/desktop/scripts/copy-web.mjs
@@ -0,0 +1,18 @@
+import { cpSync, existsSync, rmSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const desktopDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const source = path.join(desktopDir, "..", "web", "out");
+const target = path.join(desktopDir, "web");
+
+if (!existsSync(source)) {
+ process.stderr.write(
+ `Web bundle not found at ${source}. Run \`bun build:web\` first.\n`,
+ );
+ process.exit(1);
+}
+
+rmSync(target, { recursive: true, force: true });
+cpSync(source, target, { recursive: true });
+process.stdout.write(`Copied ${source} -> ${target}\n`);
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);
diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json
new file mode 100644
index 00000000..1948e8df
--- /dev/null
+++ b/apps/desktop/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022"],
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "sourceMap": false,
+ "types": ["node"]
+ },
+ "include": ["src"]
+}
diff --git a/apps/web/.env.example b/apps/web/.env.example
deleted file mode 100644
index b15779a5..00000000
--- a/apps/web/.env.example
+++ /dev/null
@@ -1,21 +0,0 @@
-# Environment variables example
-# Copy this file to .env.local and update the values as needed
-
-# Node
-NODE_ENV=development
-
-# Public
-NEXT_PUBLIC_SITE_URL=http://localhost:3000
-NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
-
-# Server
-DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
-BETTER_AUTH_SECRET=your_better_auth_secret
-
-UPSTASH_REDIS_REST_URL=http://localhost:8079
-UPSTASH_REDIS_REST_TOKEN=example_token
-
-MARBLE_WORKSPACE_KEY=your_workspace_key_here
-
-FREESOUND_CLIENT_ID=your_client_id_here
-FREESOUND_API_KEY=your_api_key_here
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index ddb836b6..2a8d87bb 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -12,3 +12,6 @@
next-env.d.ts
*.tsbuildinfo
+
+# Static export
+out/
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index a08d9b68..27a9db70 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -1,62 +1,21 @@
-FROM oven/bun:alpine AS base
-
-FROM base AS builder
-
-WORKDIR /app
-
-ARG FREESOUND_CLIENT_ID
-ARG FREESOUND_API_KEY
-ARG NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
-ARG MARBLE_WORKSPACE_KEY=build-placeholder
-
-COPY package.json package.json
-COPY bun.lock bun.lock
-COPY turbo.json turbo.json
-
-COPY apps/web/package.json apps/web/package.json
-RUN bun install
-
-COPY apps/web/ apps/web/
-
-ENV NODE_ENV=production
-ENV NEXT_TELEMETRY_DISABLED=1
-
-# Build-time env stubs to pass zod validation
-ENV DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
-ENV BETTER_AUTH_SECRET="build-time-secret"
-ENV UPSTASH_REDIS_REST_URL="http://localhost:8079"
-ENV UPSTASH_REDIS_REST_TOKEN="example_token"
-ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000"
-ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL
-ENV MARBLE_WORKSPACE_KEY=$MARBLE_WORKSPACE_KEY
-
-ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID
-ENV FREESOUND_API_KEY=$FREESOUND_API_KEY
-
-WORKDIR /app/apps/web
-RUN bun run build
-
-# Production image
-FROM base AS runner
-WORKDIR /app
-
-ENV NODE_ENV=production
-ENV NEXT_TELEMETRY_DISABLED=1
-
-RUN addgroup --system --gid 1001 nodejs
-RUN adduser --system --uid 1001 nextjs
-
-COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
-COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
-COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
-
-RUN chown nextjs:nodejs apps
-
-USER nextjs
-
-EXPOSE 3000
-
-ENV PORT=3000
-ENV HOSTNAME="0.0.0.0"
-
-CMD ["bun", "apps/web/server.js"]
+FROM oven/bun:1 AS builder
+
+WORKDIR /app
+
+COPY . .
+
+RUN bun install --frozen-lockfile
+
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+
+WORKDIR /app/apps/web
+RUN bun run build
+
+FROM nginx:alpine AS runner
+
+COPY --from=builder /app/apps/web/out /usr/share/nginx/html
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/apps/web/content-collections.ts b/apps/web/content-collections.ts
index 0fdd7234..56be4030 100644
--- a/apps/web/content-collections.ts
+++ b/apps/web/content-collections.ts
@@ -3,7 +3,7 @@ import { z } from "zod";
const changelog = defineCollection({
name: "changelog",
- directory: "src/lib/changelog/entries",
+ directory: "src/changelog/entries",
include: "*.md",
schema: z.object({
content: z.string(),
diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts
deleted file mode 100644
index f3c4b357..00000000
--- a/apps/web/drizzle.config.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { Config } from "drizzle-kit";
-import * as dotenv from "dotenv";
-
-if (process.env.NODE_ENV === "production") {
- dotenv.config({ path: ".env.production" });
-} else {
- dotenv.config({ path: ".env.local" });
-}
-
-const databaseUrl = process.env.DATABASE_URL;
-if (!databaseUrl) {
- throw new Error("DATABASE_URL is not set");
-}
-
-export default {
- schema: "./src/lib/db/schema.ts",
- dialect: "postgresql",
- migrations: {
- table: "drizzle_migrations",
- },
- dbCredentials: {
- url: databaseUrl,
- },
- out: "./migrations",
- strict: process.env.NODE_ENV === "production",
-} satisfies Config;
diff --git a/apps/web/migrations/0000_brainy_saracen.sql b/apps/web/migrations/0000_brainy_saracen.sql
deleted file mode 100644
index 3179d6c5..00000000
--- a/apps/web/migrations/0000_brainy_saracen.sql
+++ /dev/null
@@ -1,62 +0,0 @@
-CREATE TABLE "accounts" (
- "id" text PRIMARY KEY NOT NULL,
- "account_id" text NOT NULL,
- "provider_id" text NOT NULL,
- "user_id" text NOT NULL,
- "access_token" text,
- "refresh_token" text,
- "id_token" text,
- "access_token_expires_at" timestamp,
- "refresh_token_expires_at" timestamp,
- "scope" text,
- "password" text,
- "created_at" timestamp NOT NULL,
- "updated_at" timestamp NOT NULL
-);
---> statement-breakpoint
-ALTER TABLE "accounts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
-CREATE TABLE "sessions" (
- "id" text PRIMARY KEY NOT NULL,
- "expires_at" timestamp NOT NULL,
- "token" text NOT NULL,
- "created_at" timestamp NOT NULL,
- "updated_at" timestamp NOT NULL,
- "ip_address" text,
- "user_agent" text,
- "user_id" text NOT NULL,
- CONSTRAINT "sessions_token_unique" UNIQUE("token")
-);
---> statement-breakpoint
-ALTER TABLE "sessions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
-CREATE TABLE "users" (
- "id" text PRIMARY KEY NOT NULL,
- "name" text NOT NULL,
- "email" text NOT NULL,
- "email_verified" boolean NOT NULL,
- "image" text,
- "created_at" timestamp NOT NULL,
- "updated_at" timestamp NOT NULL,
- CONSTRAINT "users_email_unique" UNIQUE("email")
-);
---> statement-breakpoint
-ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
-CREATE TABLE "verifications" (
- "id" text PRIMARY KEY NOT NULL,
- "identifier" text NOT NULL,
- "value" text NOT NULL,
- "expires_at" timestamp NOT NULL,
- "created_at" timestamp,
- "updated_at" timestamp
-);
---> statement-breakpoint
-ALTER TABLE "verifications" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
-CREATE TABLE "waitlist" (
- "id" text PRIMARY KEY NOT NULL,
- "email" text NOT NULL,
- "created_at" timestamp NOT NULL,
- CONSTRAINT "waitlist_email_unique" UNIQUE("email")
-);
---> statement-breakpoint
-ALTER TABLE "waitlist" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
-ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
-ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file
diff --git a/apps/web/migrations/meta/0000_snapshot.json b/apps/web/migrations/meta/0000_snapshot.json
deleted file mode 100644
index 42bae1c6..00000000
--- a/apps/web/migrations/meta/0000_snapshot.json
+++ /dev/null
@@ -1,344 +0,0 @@
-{
- "id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
- "prevId": "00000000-0000-0000-0000-000000000000",
- "version": "7",
- "dialect": "postgresql",
- "tables": {
- "public.accounts": {
- "name": "accounts",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "text",
- "primaryKey": true,
- "notNull": true
- },
- "account_id": {
- "name": "account_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "provider_id": {
- "name": "provider_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "access_token": {
- "name": "access_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "refresh_token": {
- "name": "refresh_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "id_token": {
- "name": "id_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "access_token_expires_at": {
- "name": "access_token_expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "refresh_token_expires_at": {
- "name": "refresh_token_expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "scope": {
- "name": "scope",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "password": {
- "name": "password",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "accounts_user_id_users_id_fk": {
- "name": "accounts_user_id_users_id_fk",
- "tableFrom": "accounts",
- "tableTo": "users",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": true
- },
- "public.sessions": {
- "name": "sessions",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "text",
- "primaryKey": true,
- "notNull": true
- },
- "expires_at": {
- "name": "expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "token": {
- "name": "token",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "ip_address": {
- "name": "ip_address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "user_agent": {
- "name": "user_agent",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "user_id": {
- "name": "user_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "sessions_user_id_users_id_fk": {
- "name": "sessions_user_id_users_id_fk",
- "tableFrom": "sessions",
- "tableTo": "users",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "sessions_token_unique": {
- "name": "sessions_token_unique",
- "nullsNotDistinct": false,
- "columns": ["token"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": true
- },
- "public.users": {
- "name": "users",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "text",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "email_verified": {
- "name": "email_verified",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true
- },
- "image": {
- "name": "image",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "users_email_unique": {
- "name": "users_email_unique",
- "nullsNotDistinct": false,
- "columns": ["email"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": true
- },
- "public.verifications": {
- "name": "verifications",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "text",
- "primaryKey": true,
- "notNull": true
- },
- "identifier": {
- "name": "identifier",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "value": {
- "name": "value",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "expires_at": {
- "name": "expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": true
- },
- "public.waitlist": {
- "name": "waitlist",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "text",
- "primaryKey": true,
- "notNull": true
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "waitlist_email_unique": {
- "name": "waitlist_email_unique",
- "nullsNotDistinct": false,
- "columns": ["email"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": true
- }
- },
- "enums": {},
- "schemas": {},
- "sequences": {},
- "roles": {},
- "policies": {},
- "views": {},
- "_meta": {
- "columns": {},
- "schemas": {},
- "tables": {}
- }
-}
diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json
deleted file mode 100644
index a91398ed..00000000
--- a/apps/web/migrations/meta/_journal.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "version": "7",
- "dialect": "postgresql",
- "entries": [
- {
- "idx": 0,
- "version": "7",
- "when": 1750753385927,
- "tag": "0000_brainy_saracen",
- "breakpoints": true
- }
- ]
-}
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 66babe11..644c7079 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -1,5 +1,4 @@
import type { NextConfig } from "next";
-import { withBotId } from "botid/next/config";
import { withContentCollections } from "@content-collections/next";
const nextConfig: NextConfig = {
@@ -9,47 +8,11 @@ const nextConfig: NextConfig = {
devIndicators: false,
reactStrictMode: true,
productionBrowserSourceMaps: true,
- output: "standalone",
+ output: "export",
+ trailingSlash: true,
images: {
- remotePatterns: [
- {
- protocol: "https",
- hostname: "plus.unsplash.com",
- },
- {
- protocol: "https",
- hostname: "images.unsplash.com",
- },
- {
- protocol: "https",
- hostname: "images.marblecms.com",
- },
- {
- protocol: "https",
- hostname: "lh3.googleusercontent.com",
- },
- {
- protocol: "https",
- hostname: "avatars.githubusercontent.com",
- },
- {
- protocol: "https",
- hostname: "api.iconify.design",
- },
- {
- protocol: "https",
- hostname: "api.simplesvg.com",
- },
- {
- protocol: "https",
- hostname: "api.unisvg.com",
- },
- {
- protocol: "https",
- hostname: "cdn.brandfetch.io",
- },
- ],
+ unoptimized: true,
},
};
-export default withContentCollections(withBotId(nextConfig));
+export default withContentCollections(nextConfig);
diff --git a/apps/web/open-next.config.ts b/apps/web/open-next.config.ts
deleted file mode 100644
index 36144eb1..00000000
--- a/apps/web/open-next.config.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { defineCloudflareConfig } from "@opennextjs/cloudflare";
-
-export default defineCloudflareConfig(); \ No newline at end of file
diff --git a/apps/web/package.json b/apps/web/package.json
index 816dfe01..d8b4f333 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -7,22 +7,15 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
- "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
- "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
- "format": "prettier src --write",
- "db:generate": "drizzle-kit generate",
- "db:migrate": "drizzle-kit migrate",
- "db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
- "db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
+ "format": "prettier src --write"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"@hugeicons/core-free-icons": "^3.3.0",
"@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1",
- "@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -33,18 +26,12 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/culori": "^4.0.1",
- "@upstash/ratelimit": "^2.0.6",
- "@upstash/redis": "^1.35.4",
- "better-auth": "^1.2.7",
- "botid": "^1.5.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"culori": "^4.0.2",
- "drizzle-orm": "^0.44.2",
"embla-carousel-react": "^8.5.1",
"eventemitter3": "^5.0.1",
- "feed": "^5.1.0",
"fflate": "^0.8.3",
"input-otp": "^1.4.1",
"lucide-react": "^0.562.0",
@@ -54,8 +41,6 @@
"next": "16.1.3",
"next-themes": "^0.4.4",
"opencut-wasm": "^0.2.10",
- "pg": "^8.16.2",
- "postgres": "^3.4.5",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-day-picker": "^8.10.1",
@@ -65,11 +50,7 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
"react-window": "^2.2.7",
- "rehype-autolink-headings": "^7.1.0",
"rehype-parse": "^9.0.1",
- "rehype-sanitize": "^6.0.0",
- "rehype-slug": "^6.0.0",
- "rehype-stringify": "^10.0.1",
"sonner": "^1.7.1",
"soundtouchjs": "^0.3.0",
"tailwind-merge": "^3.5.0",
@@ -88,16 +69,11 @@
"@tailwindcss/typography": "^0.5.19",
"@types/bun": "latest",
"@types/node": "^24.2.1",
- "@types/pg": "^8.15.4",
"@types/react": "^19",
"@types/react-dom": "^19",
- "cross-env": "^7.0.3",
- "dotenv": "^16.5.0",
- "drizzle-kit": "^0.31.4",
"postcss": "^8",
"sharp": "^0.34.5",
"tailwindcss": "^4.2.1",
- "typescript": "^5.8.3",
- "wrangler": "^4.77.0"
+ "typescript": "^5.8.3"
}
}
diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts
deleted file mode 100644
index edf0d0c2..00000000
--- a/apps/web/src/app/api/auth/[...all]/route.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { auth } from "@/auth/server";
-import { toNextJsHandler } from "better-auth/next-js";
-
-export const { POST, GET } = toNextJsHandler(auth);
diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts
deleted file mode 100644
index dbe6fdb1..00000000
--- a/apps/web/src/app/api/health/route.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export async function GET() {
- return new Response("OK", { status: 200 });
-}
diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts
deleted file mode 100644
index aa7a1b60..00000000
--- a/apps/web/src/app/api/sounds/search/route.ts
+++ /dev/null
@@ -1,280 +0,0 @@
-import { webEnv } from "@/env/web";
-import { type NextRequest, NextResponse } from "next/server";
-import { z } from "zod";
-import { checkRateLimit } from "@/auth/rate-limit";
-
-const searchParamsSchema = z.object({
- q: z.string().max(500, "Query too long").optional(),
- type: z.enum(["songs", "effects"]).optional(),
- page: z.coerce.number().int().min(1).max(1000).default(1),
- page_size: z.coerce.number().int().min(1).max(150).default(20),
- sort: z
- .enum(["downloads", "rating", "created", "score"])
- .default("downloads"),
- min_rating: z.coerce.number().min(0).max(5).default(3),
- commercial_only: z.coerce.boolean().default(true),
-});
-
-const freesoundResultSchema = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string(),
- url: z.string().url(),
- previews: z
- .object({
- "preview-hq-mp3": z.string().url(),
- "preview-lq-mp3": z.string().url(),
- "preview-hq-ogg": z.string().url(),
- "preview-lq-ogg": z.string().url(),
- })
- .optional(),
- download: z.string().url().optional(),
- duration: z.number(),
- filesize: z.number(),
- type: z.string(),
- channels: z.number(),
- bitrate: z.number(),
- bitdepth: z.number(),
- samplerate: z.number(),
- username: z.string(),
- tags: z.array(z.string()),
- license: z.string(),
- created: z.string(),
- num_downloads: z.number().optional(),
- avg_rating: z.number().optional(),
- num_ratings: z.number().optional(),
-});
-
-const freesoundResponseSchema = z.object({
- count: z.number(),
- next: z.string().url().nullable(),
- previous: z.string().url().nullable(),
- results: z.array(freesoundResultSchema),
-});
-
-const transformedResultSchema = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string(),
- url: z.string(),
- previewUrl: z.string().optional(),
- downloadUrl: z.string().optional(),
- duration: z.number(),
- filesize: z.number(),
- type: z.string(),
- channels: z.number(),
- bitrate: z.number(),
- bitdepth: z.number(),
- samplerate: z.number(),
- username: z.string(),
- tags: z.array(z.string()),
- license: z.string(),
- created: z.string(),
- downloads: z.number().optional(),
- rating: z.number().optional(),
- ratingCount: z.number().optional(),
-});
-
-const apiResponseSchema = z.object({
- count: z.number(),
- next: z.string().nullable(),
- previous: z.string().nullable(),
- results: z.array(transformedResultSchema),
- query: z.string().optional(),
- type: z.string(),
- page: z.number(),
- pageSize: z.number(),
- sort: z.string(),
- minRating: z.number().optional(),
-});
-
-function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
- if (!query) return `${sort}_desc`;
- return sort === "score" ? "score" : `${sort}_desc`;
-}
-
-function applyEffectsFilters({
- params,
- min_rating,
- commercial_only,
-}: {
- params: URLSearchParams;
- min_rating: number;
- commercial_only: boolean;
-}) {
- params.append("filter", "duration:[* TO 30.0]");
- params.append("filter", `avg_rating:[${min_rating} TO *]`);
-
- if (commercial_only) {
- params.append(
- "filter",
- 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
- );
- }
-
- params.append(
- "filter",
- "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
- );
-}
-
-function transformFreesoundResult(
- result: z.infer<typeof freesoundResultSchema>,
-) {
- return {
- id: result.id,
- name: result.name,
- description: result.description,
- url: result.url,
- previewUrl:
- result.previews?.["preview-hq-mp3"] ||
- result.previews?.["preview-lq-mp3"],
- downloadUrl: result.download,
- duration: result.duration,
- filesize: result.filesize,
- type: result.type,
- channels: result.channels,
- bitrate: result.bitrate,
- bitdepth: result.bitdepth,
- samplerate: result.samplerate,
- username: result.username,
- tags: result.tags,
- license: result.license,
- created: result.created,
- downloads: result.num_downloads || 0,
- rating: result.avg_rating || 0,
- ratingCount: result.num_ratings || 0,
- };
-}
-
-export async function GET(request: NextRequest) {
- try {
- const { limited } = await checkRateLimit({ request });
- if (limited) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 });
- }
-
- const { searchParams } = new URL(request.url);
-
- const validationResult = searchParamsSchema.safeParse({
- q: searchParams.get("q") || undefined,
- type: searchParams.get("type") || undefined,
- page: searchParams.get("page") || undefined,
- page_size: searchParams.get("page_size") || undefined,
- sort: searchParams.get("sort") || undefined,
- min_rating: searchParams.get("min_rating") || undefined,
- });
-
- if (!validationResult.success) {
- return NextResponse.json(
- {
- error: "Invalid parameters",
- details: validationResult.error.flatten().fieldErrors,
- },
- { status: 400 },
- );
- }
-
- const {
- q: query,
- type,
- page,
- page_size: pageSize,
- sort,
- min_rating,
- commercial_only,
- } = validationResult.data;
-
- if (type === "songs") {
- return NextResponse.json(
- {
- error: "Songs are not available yet",
- message:
- "Song search functionality is coming soon. Try searching for sound effects instead.",
- },
- { status: 501 },
- );
- }
-
- const baseUrl = "https://freesound.org/apiv2/search/text/";
-
- const sortParam = buildSortParameter({ query, sort });
-
- const params = new URLSearchParams({
- query: query || "",
- token: webEnv.FREESOUND_API_KEY,
- page: page.toString(),
- page_size: pageSize.toString(),
- sort: sortParam,
- fields:
- "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
- });
-
- const isEffectsSearch = type === "effects" || !type;
- if (isEffectsSearch) {
- applyEffectsFilters({ params, min_rating, commercial_only });
- }
-
- const response = await fetch(`${baseUrl}?${params.toString()}`);
-
- if (!response.ok) {
- const errorText = await response.text();
- console.error("Freesound API error:", response.status, errorText);
- return NextResponse.json(
- { error: "Failed to search sounds" },
- { status: response.status },
- );
- }
-
- const rawData = await response.json();
-
- const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
- if (!freesoundValidation.success) {
- console.error(
- "Invalid Freesound API response:",
- freesoundValidation.error,
- );
- return NextResponse.json(
- { error: "Invalid response from Freesound API" },
- { status: 502 },
- );
- }
-
- const data = freesoundValidation.data;
-
- const transformedResults = data.results.map(transformFreesoundResult);
-
- const responseData = {
- count: data.count,
- next: data.next,
- previous: data.previous,
- results: transformedResults,
- query: query || "",
- type: type || "effects",
- page,
- pageSize,
- sort,
- minRating: min_rating,
- };
-
- const responseValidation = apiResponseSchema.safeParse(responseData);
- if (!responseValidation.success) {
- console.error(
- "Invalid API response structure:",
- responseValidation.error,
- );
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 },
- );
- }
-
- return NextResponse.json(responseValidation.data);
- } catch (error) {
- console.error("Error searching sounds:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
- );
- }
-}
diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx
deleted file mode 100644
index dba9b504..00000000
--- a/apps/web/src/app/blog/[slug]/page.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import type { Metadata } from "next";
-import Image from "next/image";
-import { notFound } from "next/navigation";
-import { BasePage } from "@/app/base-page";
-import Prose from "@/components/ui/prose";
-import { Separator } from "@/components/ui/separator";
-import { getPosts, getSinglePost, processHtmlContent } from "@/blog/query";
-import type { Author, Post } from "@/blog/types";
-
-type PageProps = {
- params: Promise<{ slug: string }>;
- searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
-};
-
-export async function generateMetadata({
- params,
-}: PageProps): Promise<Metadata> {
- const slug = (await params).slug;
-
- const data = await getSinglePost({ slug });
-
- if (!data || !data.post) return {};
-
- return {
- title: data.post.title,
- description: data.post.description,
- twitter: {
- title: `${data.post.title}`,
- description: `${data.post.description}`,
- card: "summary_large_image",
- images: [
- {
- url: data.post.coverImage,
- width: "1200",
- height: "630",
- alt: data.post.title,
- },
- ],
- },
- openGraph: {
- type: "article",
- images: [
- {
- url: data.post.coverImage,
- width: "1200",
- height: "630",
- alt: data.post.title,
- },
- ],
- title: data.post.title,
- description: data.post.description,
- publishedTime: new Date(data.post.publishedAt).toISOString(),
- authors: data.post.authors.map((author: Author) => author.name),
- },
- };
-}
-
-export async function generateStaticParams() {
- const data = await getPosts();
- if (!data || !data.posts.length) return [];
-
- return data.posts.map((post) => ({
- slug: post.slug,
- }));
-}
-
-export default async function BlogPostPage({ params }: PageProps) {
- const slug = (await params).slug;
- const data = await getSinglePost({ slug });
- if (!data || !data.post) return notFound();
-
- const html = await processHtmlContent({ html: data.post.content });
-
- return (
- <BasePage>
- <PostHeader post={data.post} />
- <Separator />
- <PostContent html={html} />
- </BasePage>
- );
-}
-
-function PostHeader({ post }: { post: Post }) {
- const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
- day: "numeric",
- month: "long",
- year: "numeric",
- });
-
- return (
- <div className="flex flex-col items-center justify-center gap-8">
- <PostMeta date={formattedDate} publishedAt={post.publishedAt} />
- <PostTitle title={post.title} />
- {post.coverImage && <PostCoverImage post={post} />}
- </div>
- );
-}
-
-function PostCoverImage({ post }: { post: Post }) {
- return (
- <div className="relative aspect-video overflow-hidden rounded-lg w-full mt-4">
- <Image
- src={post.coverImage}
- alt={post.title}
- loading="eager"
- fill
- className="rounded-lg object-cover"
- />
- </div>
- );
-}
-
-function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
- return (
- <div className="flex items-center justify-center">
- <time dateTime={publishedAt.toString()}>{date}</time>
- </div>
- );
-}
-
-function PostTitle({ title }: { title: string }) {
- return (
- <h1 className="text-5xl font-bold tracking-tight md:text-4xl text-center">
- {title}
- </h1>
- );
-}
-
-function PostContent({ html }: { html: string }) {
- return (
- <section className="">
- <Prose html={html} />
- </section>
- );
-}
diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx
deleted file mode 100644
index b40bc2fc..00000000
--- a/apps/web/src/app/blog/page.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import { BasePage } from "@/app/base-page";
-import { Separator } from "@/components/ui/separator";
-import { getPosts } from "@/blog/query";
-import type { Post } from "@/blog/types";
-
-export const metadata: Metadata = {
- title: "Blog - OpenCut",
- description:
- "Read the latest news and updates about OpenCut, the free and open-source video editor.",
- openGraph: {
- title: "Blog - OpenCut",
- description:
- "Read the latest news and updates about OpenCut, the free and open-source video editor.",
- type: "website",
- },
-};
-
-export default async function BlogPage() {
- const data = await getPosts().catch(() => null);
- if (!data || !data.posts) return <div>No posts yet</div>;
-
- return (
- <BasePage
- title="Blog"
- description="Read the latest news and updates about OpenCut, the free and open-source video editor."
- >
- <div className="flex flex-col">
- {data.posts.map((post) => (
- <div key={post.id} className="flex flex-col">
- <BlogPostItem post={post} />
- <Separator />
- </div>
- ))}
- </div>
- </BasePage>
- );
-}
-
-function BlogPostItem({ post }: { post: Post }) {
- return (
- <Link href={`/blog/${post.slug}`}>
- <div className="flex h-auto w-full items-center justify-between py-6 opacity-100 hover:opacity-75">
- <div className="flex flex-col gap-2">
- <h2 className="text-xl font-semibold">{post.title}</h2>
- <p className="text-muted-foreground">{post.description}</p>
- </div>
- </div>
- </Link>
- );
-}
diff --git a/apps/web/src/app/changelog/[version]/page.tsx b/apps/web/src/app/changelog/[version]/page.tsx
index 6c529ce1..b6623cf3 100644
--- a/apps/web/src/app/changelog/[version]/page.tsx
+++ b/apps/web/src/app/changelog/[version]/page.tsx
@@ -15,6 +15,8 @@ import { CopyMarkdownButton } from "@/changelog/components/copy-markdown-button"
type Props = { params: Promise<{ version: string }> };
+export const dynamicParams = false;
+
export async function generateStaticParams() {
return getSortedReleases().map((release) => ({ version: release.version }));
}
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/page.tsx
index b759b9c2..8229774a 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useParams } from "next/navigation";
+import { useSearchParams } from "next/navigation";
import {
ResizablePanelGroup,
ResizablePanel,
@@ -17,7 +17,7 @@ import { MigrationDialog } from "@/project/components/migration-dialog";
import { usePanelStore } from "@/editor/panel-store";
import { usePasteMedia } from "@/media/use-paste-media";
import { MobileGate } from "@/components/editor/mobile-gate";
-import { useMemo, useState } from "react";
+import { Suspense, useMemo, useState } from "react";
import { useEditor } from "@/editor/use-editor";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@@ -36,8 +36,16 @@ import {
} from "@/timeline/bookmarks/index";
export default function Editor() {
- const params = useParams<{ project_id: string }>();
- const projectId = params.project_id;
+ return (
+ <Suspense>
+ <EditorView />
+ </Suspense>
+ );
+}
+
+function EditorView() {
+ const searchParams = useSearchParams();
+ const projectId = searchParams.get("project") ?? "";
return (
<MobileGate>
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 558ae538..9713d9d5 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -5,21 +5,12 @@ import { Toaster } from "../components/ui/sonner";
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
import { TooltipProvider } from "../components/ui/tooltip";
import { baseMetaData } from "./metadata";
-import { BotIdClient } from "botid/client";
-import { webEnv } from "@/env/web";
import { Inter } from "next/font/google";
const siteFont = Inter({ subsets: ["latin"] });
export const metadata = baseMetaData;
-const protectedRoutes = [
- {
- path: "/none",
- method: "GET",
- },
-];
-
export default function RootLayout({
children,
}: Readonly<{
@@ -28,7 +19,6 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<head>
- <BotIdClient protect={protectedRoutes} />
{process.env.NODE_ENV === "development" && (
<>
<Script
@@ -52,7 +42,7 @@ export default function RootLayout({
strategy="afterInteractive"
async
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
- data-disabled={webEnv.NODE_ENV === "development"}
+ data-disabled={process.env.NODE_ENV === "development"}
data-track-attributes={false}
data-track-errors={true}
data-track-outgoing-links={false}
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index d238af9e..7cf50c93 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -515,7 +515,7 @@ function NewProjectButton() {
const projectId = await editor.project.createNewProject({
name: "New project",
});
- router.push(`/editor/${projectId}`);
+ router.push(`/editor?project=${projectId}`);
};
return (
@@ -553,7 +553,7 @@ function ImportProjectButton() {
try {
const projectId = await editor.project.importArchive({ file });
if (projectId) {
- router.push(`/editor/${projectId}`);
+ router.push(`/editor?project=${projectId}`);
}
} finally {
setIsImporting(false);
@@ -727,7 +727,7 @@ function ProjectItem({
className="size-5 shrink-0"
/>
- <Link href={`/editor/${project.id}`} className="flex-1 min-w-0">
+ <Link href={`/editor?project=${project.id}`} className="flex-1 min-w-0">
{listRowContent}
</Link>
@@ -753,7 +753,7 @@ function ProjectItem({
<div className="group relative">
{isGridView ? (
<>
- <Link href={`/editor/${project.id}`} className="block">
+ <Link href={`/editor?project=${project.id}`} className="block">
{gridContent}
</Link>
@@ -1043,7 +1043,7 @@ function EmptyState() {
const projectId = await editor.project.createNewProject({
name: "New project",
});
- router.push(`/editor/${projectId}`);
+ router.push(`/editor?project=${projectId}`);
} catch (error) {
toast.error("Failed to create project", {
description:
diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts
index 1b1ca865..6f316d98 100644
--- a/apps/web/src/app/robots.ts
+++ b/apps/web/src/app/robots.ts
@@ -1,6 +1,8 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/site/brand";
+export const dynamic = "force-static";
+
export default function robots(): MetadataRoute.Robots {
return {
rules: {
diff --git a/apps/web/src/app/rss.xml/route.ts b/apps/web/src/app/rss.xml/route.ts
deleted file mode 100644
index d4e9570a..00000000
--- a/apps/web/src/app/rss.xml/route.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { Feed } from "feed";
-import { getPosts } from "@/blog/query";
-import { SITE_INFO, SITE_URL } from "@/site/brand";
-
-export async function GET() {
- try {
- const { posts } = await getPosts();
-
- const feed = new Feed({
- title: `${SITE_INFO.title} Blog`,
- description: SITE_INFO.description,
- id: `${SITE_URL}`,
- link: `${SITE_URL}/blog/`,
- language: "en",
- image: `${SITE_INFO.openGraphImage}`,
- favicon: `${SITE_INFO.favicon}`,
- copyright: `All rights reserved ${new Date().getFullYear()}, ${
- SITE_INFO.title
- }`,
- });
-
- for (const post of posts) {
- feed.addItem({
- title: post.title,
- id: `${SITE_URL}/blog/${post.slug}`,
- link: `${SITE_URL}/blog/${post.slug}`,
- description: post.description,
- author: post.authors.map((author) => ({
- name: author.name,
- })),
- date: new Date(post.publishedAt),
- image: post.coverImage || SITE_INFO.openGraphImage,
- });
- }
-
- return new Response(feed.rss2(), {
- headers: {
- "Content-Type": "text/xml",
- "Cache-Control": "public, max-age=86400, stale-while-revalidate",
- },
- });
- } catch (error) {
- console.error("Error generating RSS feed", error);
- return new Response("Internal Server Error", { status: 500 });
- }
-}
diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts
index 73b4180f..a473d2d9 100644
--- a/apps/web/src/app/sitemap.ts
+++ b/apps/web/src/app/sitemap.ts
@@ -1,61 +1,39 @@
-import { SITE_URL } from "@/site/brand";
-import { getPosts } from "@/blog/query";
-import type { MetadataRoute } from "next";
-
-export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
- const data = await getPosts();
-
- const postPages: MetadataRoute.Sitemap =
- data?.posts?.map((post) => ({
- url: `${SITE_URL}/blog/${post.slug}`,
- lastModified: new Date(post.publishedAt),
- changeFrequency: "weekly",
- priority: 0.8,
- })) ?? [];
-
- return [
- {
- url: SITE_URL,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/contributors`,
- lastModified: new Date(),
- changeFrequency: "daily",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/roadmap`,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/privacy`,
- lastModified: new Date(),
- changeFrequency: "monthly",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/terms`,
- lastModified: new Date(),
- changeFrequency: "monthly",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/why-not-capcut`,
- lastModified: new Date(),
- changeFrequency: "yearly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/blog`,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- ...postPages,
- ];
-}
+import { SITE_URL } from "@/site/brand";
+import type { MetadataRoute } from "next";
+
+export const dynamic = "force-static";
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ return [
+ {
+ url: SITE_URL,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ {
+ url: `${SITE_URL}/contributors`,
+ lastModified: new Date(),
+ changeFrequency: "daily",
+ priority: 0.5,
+ },
+ {
+ url: `${SITE_URL}/roadmap`,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ {
+ url: `${SITE_URL}/privacy`,
+ lastModified: new Date(),
+ changeFrequency: "monthly",
+ priority: 0.5,
+ },
+ {
+ url: `${SITE_URL}/terms`,
+ lastModified: new Date(),
+ changeFrequency: "monthly",
+ priority: 0.5,
+ },
+ ];
+}
diff --git a/apps/web/src/auth/client.ts b/apps/web/src/auth/client.ts
deleted file mode 100644
index 58be51c3..00000000
--- a/apps/web/src/auth/client.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { createAuthClient } from "better-auth/react";
-import { webEnv } from "@/env/web";
-
-export const { signIn, signUp, useSession } = createAuthClient({
- baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
-});
diff --git a/apps/web/src/auth/rate-limit.ts b/apps/web/src/auth/rate-limit.ts
deleted file mode 100644
index c10c3819..00000000
--- a/apps/web/src/auth/rate-limit.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Ratelimit } from "@upstash/ratelimit";
-import { Redis } from "@upstash/redis";
-import { webEnv } from "@/env/web";
-
-const redis = new Redis({
- url: webEnv.UPSTASH_REDIS_REST_URL,
- token: webEnv.UPSTASH_REDIS_REST_TOKEN,
-});
-
-export const baseRateLimit = new Ratelimit({
- redis,
- limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
- analytics: true,
- prefix: "rate-limit",
-});
-
-export async function checkRateLimit({ request }: { request: Request }) {
- const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
- const { success } = await baseRateLimit.limit(ip);
- return { success, limited: !success };
-}
diff --git a/apps/web/src/auth/server.ts b/apps/web/src/auth/server.ts
deleted file mode 100644
index 5e49387a..00000000
--- a/apps/web/src/auth/server.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { betterAuth, type RateLimit } from "better-auth";
-import { drizzleAdapter } from "better-auth/adapters/drizzle";
-import { Redis } from "@upstash/redis";
-import { db } from "@/db";
-import { webEnv } from "@/env/web";
-
-const redis = new Redis({
- url: webEnv.UPSTASH_REDIS_REST_URL,
- token: webEnv.UPSTASH_REDIS_REST_TOKEN,
-});
-
-export const auth = betterAuth({
- database: drizzleAdapter(db, {
- provider: "pg",
- usePlural: true,
- }),
- secret: webEnv.BETTER_AUTH_SECRET,
- user: {
- deleteUser: {
- enabled: true,
- },
- },
- emailAndPassword: {
- enabled: true,
- },
- rateLimit: {
- storage: "secondary-storage",
- customStorage: {
- get: async (key) => {
- const value = await redis.get(key);
- return value as RateLimit | undefined;
- },
- set: async (key, value) => {
- await redis.set(key, value);
- },
- },
- },
- baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
- appName: "OpenCut",
- trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
-});
-
-export type Auth = typeof auth;
diff --git a/apps/web/src/blog/query.ts b/apps/web/src/blog/query.ts
deleted file mode 100644
index a7742db2..00000000
--- a/apps/web/src/blog/query.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import type {
- MarbleAuthorList,
- MarbleCategoryList,
- Pagination,
- MarblePost,
- MarblePostList,
- MarbleTagList,
-} from "@/blog/types";
-import { unified } from "unified";
-import rehypeParse from "rehype-parse";
-import rehypeStringify from "rehype-stringify";
-import rehypeSlug from "rehype-slug";
-import rehypeAutolinkHeadings from "rehype-autolink-headings";
-import rehypeSanitize from "rehype-sanitize";
-
-const url =
- process.env.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com";
-const key = process.env.MARBLE_WORKSPACE_KEY ?? "cmd4iw9mm0006l804kwqv0k46";
-
-const EMPTY_PAGINATION: Pagination = {
- limit: 0,
- currpage: 1,
- nextPage: null,
- prevPage: null,
- totalItems: 0,
- totalPages: 0,
-};
-
-const EMPTY_POSTS: MarblePostList = {
- posts: [],
- pagination: EMPTY_PAGINATION,
-};
-
-const EMPTY_TAGS: MarbleTagList = {
- tags: [],
- pagination: EMPTY_PAGINATION,
-};
-
-const EMPTY_CATEGORIES: MarbleCategoryList = {
- categories: [],
- pagination: EMPTY_PAGINATION,
-};
-
-const EMPTY_AUTHORS: MarbleAuthorList = {
- authors: [],
- pagination: EMPTY_PAGINATION,
-};
-
-function isMarbleConfigured() {
- return ![
- "",
- "placeholder",
- "build-placeholder",
- "your_workspace_key_here",
- ].includes(key);
-}
-
-async function fetchFromMarble<T>({
- endpoint,
- fallback,
-}: {
- endpoint: string;
- fallback: T;
-}): Promise<T> {
- if (!isMarbleConfigured()) {
- return fallback;
- }
-
- try {
- const response = await fetch(`${url}/${key}/${endpoint}`);
- if (!response.ok) {
- console.warn(
- `Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`,
- );
- return fallback;
- }
- return (await response.json()) as T;
- } catch (error) {
- console.error(`Error fetching ${endpoint}:`, error);
- return fallback;
- }
-}
-
-export async function getPosts() {
- return fetchFromMarble<MarblePostList>({
- endpoint: "posts",
- fallback: EMPTY_POSTS,
- });
-}
-
-export async function getTags() {
- return fetchFromMarble<MarbleTagList>({
- endpoint: "tags",
- fallback: EMPTY_TAGS,
- });
-}
-
-export async function getSinglePost({ slug }: { slug: string }) {
- return fetchFromMarble<MarblePost | null>({
- endpoint: `posts/${slug}`,
- fallback: null,
- });
-}
-
-export async function getCategories() {
- return fetchFromMarble<MarbleCategoryList>({
- endpoint: "categories",
- fallback: EMPTY_CATEGORIES,
- });
-}
-
-export async function getAuthors() {
- return fetchFromMarble<MarbleAuthorList>({
- endpoint: "authors",
- fallback: EMPTY_AUTHORS,
- });
-}
-
-export async function processHtmlContent({
- html,
-}: {
- html: string;
-}): Promise<string> {
- const processor = unified()
- .use(rehypeSanitize)
- .use(rehypeParse, { fragment: true })
- .use(rehypeSlug)
- .use(rehypeAutolinkHeadings, { behavior: "append" })
- .use(rehypeStringify);
-
- const file = await processor.process({ value: html, type: "html" });
- return String(file);
-}
diff --git a/apps/web/src/blog/types.ts b/apps/web/src/blog/types.ts
deleted file mode 100644
index 7b2f450a..00000000
--- a/apps/web/src/blog/types.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-export type Post = {
- id: string;
- slug: string;
- title: string;
- content: string;
- description: string;
- coverImage: string;
- publishedAt: Date;
- updatedAt: Date;
- authors: {
- id: string;
- name: string;
- image: string;
- }[];
- category: {
- id: string;
- slug: string;
- name: string;
- };
- tags: {
- id: string;
- slug: string;
- name: string;
- }[];
- attribution: {
- author: string;
- url: string;
- } | null;
-};
-
-export type Pagination = {
- limit: number;
- currpage: number;
- nextPage: number | null;
- prevPage: number | null;
- totalItems: number;
- totalPages: number;
-};
-
-export type MarblePostList = {
- posts: Post[];
- pagination: Pagination;
-};
-
-export type MarblePost = {
- post: Post;
-};
-
-export type Tag = {
- id: string;
- name: string;
- slug: string;
-};
-
-export type MarbleTag = {
- tag: Tag;
-};
-
-export type MarbleTagList = {
- tags: Tag[];
- pagination: Pagination;
-};
-
-export type Category = {
- id: string;
- name: string;
- slug: string;
-};
-
-export type MarbleCategory = {
- category: Category;
-};
-
-export type MarbleCategoryList = {
- categories: Category[];
- pagination: Pagination;
-};
-
-export type Author = {
- id: string;
- name: string;
- image: string;
-};
-
-export type MarbleAuthor = {
- author: Author;
-};
-
-export type MarbleAuthorList = {
- authors: Author[];
- pagination: Pagination;
-};
diff --git a/apps/web/src/changelog/entries/0.4.0.md b/apps/web/src/changelog/entries/0.4.0.md
index 141df133..8d9e32da 100644
--- a/apps/web/src/changelog/entries/0.4.0.md
+++ b/apps/web/src/changelog/entries/0.4.0.md
@@ -1,19 +1,35 @@
---
-version: "0.3.0"
-date: "2026-03-04"
-published: false
-title: "Title"
-description: "Description"
+version: "0.4.0"
+date: "2026-07-30"
+published: true
+title: "Desktop app, native exports & fully static web"
+description: "OpenCut is now a real desktop app for Linux with native ffmpeg-powered exports, and the web version runs anywhere as plain static files — no server, no account, no backend."
+summary: "Linux desktop app (AppImage, deb, rpm), native x264/VP9 exports, static web build, project versioning, .ocp files, voice-over."
changes:
-- type: new
- text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
-- type: new
- text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke."
-- type: fixed
- text: "Preview panning now keeps working while the Masks tab is open."
-- type: new
- text: "Audio clips have a horizontal volume line you can drag up or down to change volume. The line position reflects the current volume setting and is consistent across clips: same dB always means the same position."
-- type: improved
- text: "Audio waveforms now show the actual shape of the audio. Quiet parts are visible, loud parts are tall, and any bar that peaks above 0 dBFS gets a colored tip at the top."
-- type: fixed
- text: "Trimming or cutting an audio clip no longer causes the waveform to compress or show the wrong section of audio." \ No newline at end of file
+ - type: new
+ text: "Desktop app for Linux. The full editor as a self-contained native app, distributed as AppImage, .deb, and .rpm. Download, run, edit — no browser required."
+ - type: new
+ text: "Native export engine in the desktop app. Exports are encoded by a bundled ffmpeg with libx264 (H.264) and libvpx-vp9 (VP9) on a proper quality ladder, instead of the browser's built-in encoder. MP4 exports now actually hit the quality you picked."
+ - type: fixed
+ text: "Browser MP4 exports silently undershot the selected bitrate — 'high quality' came out at a fraction of the intended bitrate. The desktop app's native encoder honors it, and colors are now correctly tagged as BT.709."
+ - type: new
+ text: "The web version is now a fully static site. No server, no database, no signup — it can be hosted anywhere as plain files, and everything still lives locally in your browser."
+ - type: new
+ text: "Project versioning. Create named checkpoints of a project, and restore any of them later — restoring first creates a safety checkpoint of the current state. Media files are pinned per version."
+ - type: new
+ text: "Single-file projects. Export an entire project — timeline, settings, and all media — as one .ocp file, and import it back anywhere."
+ - type: new
+ text: "Voice-over recording. Record from your microphone straight onto the timeline at the playhead."
+ - type: new
+ text: "Freeze frame. One click captures the current frame at the playhead and inserts it as a still."
+ - type: improved
+ text: "Video clips in the timeline now render real per-position thumbnails that adapt their density to the zoom level."
+ - type: improved
+ text: "Ripple-extend: dragging the edge of a static element (image, text, freeze still) past its neighbor pushes the whole chain of clips after it, instead of stopping at the boundary."
+ - type: improved
+ text: "Export encoding is frame-rate aware when computing bitrate, and keyframes are placed every 2 seconds for smoother scrubbing in players."
+ - type: technical
+ text: "The backend is gone. Authentication, database, rate limiting, and the blog were dead weight — the editor was already fully client-side on IndexedDB, so the entire server surface was deleted. `next build` now emits a static export with zero environment variables."
+ - type: technical
+ text: "The desktop shell is Electron serving the static web build over a privileged `opencut://` protocol. Exports stream raw frames over IPC to a bundled ffmpeg with full backpressure, so memory stays flat regardless of project length."
+---
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index e738fb68..5e724384 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -34,6 +34,8 @@ import {
} from "@/components/section";
import { useEditor } from "@/editor/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/export/defaults";
+import { getDesktopBridge } from "@/desktop/bridge";
+import { toast } from "sonner";
function isExportFormat(value: string): value is ExportFormat {
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
@@ -128,6 +130,22 @@ function ExportPopover({
return;
}
+ const filePath = result.success ? result.filePath : undefined;
+ if (result.success && filePath) {
+ toast.success(`Export saved to ${filePath}`, {
+ action: {
+ label: "Reveal",
+ onClick: () => {
+ getDesktopBridge()?.revealFile(filePath);
+ },
+ },
+ });
+
+ editor.project.clearExportState();
+ onOpenChange(false);
+ return;
+ }
+
if (result.success && result.buffer) {
downloadBuffer({
buffer: result.buffer,
diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx
index fdc84af0..96c8a306 100644
--- a/apps/web/src/components/providers/editor-provider.tsx
+++ b/apps/web/src/components/providers/editor-provider.tsx
@@ -59,7 +59,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
const newProjectId = await editor.project.createNewProject({
name: "Untitled Project",
});
- router.replace(`/editor/${newProjectId}`);
+ router.replace(`/editor?project=${newProjectId}`);
} catch (_createErr) {
setError("Failed to create project");
setIsLoading(false);
diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts
index d73e3e58..29324eed 100644
--- a/apps/web/src/core/managers/renderer-manager.ts
+++ b/apps/web/src/core/managers/renderer-manager.ts
@@ -3,6 +3,8 @@ import type { RootNode } from "@/services/renderer/nodes/root-node";
import type { ExportOptions, ExportResult } from "@/export";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import { SceneExporter } from "@/services/renderer/scene-exporter";
+import { NativeExporter } from "@/services/renderer/native-exporter";
+import { getDesktopBridge } from "@/desktop/bridge";
import { buildScene } from "@/services/renderer/scene-builder";
import { createTimelineAudioBuffer } from "@/media/audio";
import { formatTimecode } from "opencut-wasm";
@@ -214,7 +216,7 @@ export class RendererManager {
background: activeProject.settings.background,
});
- const exporter = new SceneExporter({
+ const exportParams = {
width: exportSize.width,
height: exportSize.height,
fps: exportFps,
@@ -222,7 +224,33 @@ export class RendererManager {
quality,
shouldIncludeAudio: !!includeAudio,
audioBuffer: audioBuffer || undefined,
- });
+ };
+
+ type ProjectExporter = {
+ on(
+ event: "progress",
+ listener: (progress: number) => void,
+ ): unknown;
+ cancel(): void;
+ export({
+ rootNode,
+ }: {
+ rootNode: RootNode;
+ }): Promise<ArrayBuffer | string | null>;
+ };
+
+ let exporter: ProjectExporter;
+ if (getDesktopBridge()) {
+ const safeName =
+ activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
+ "export";
+ exporter = new NativeExporter({
+ ...exportParams,
+ fileName: `${safeName}.${format}`,
+ });
+ } else {
+ exporter = new SceneExporter(exportParams);
+ }
exporter.on("progress", (progress) => {
const adjustedProgress = includeAudio
@@ -242,20 +270,24 @@ export class RendererManager {
const cancelInterval = setInterval(checkCancel, 100);
try {
- const buffer = await exporter.export({ rootNode: scene });
+ const result = await exporter.export({ rootNode: scene });
clearInterval(cancelInterval);
if (cancelled) {
return { success: false, cancelled: true };
}
- if (!buffer) {
+ if (typeof result === "string") {
+ return { success: true, filePath: result };
+ }
+
+ if (!result) {
return { success: false, error: "Export failed to produce buffer" };
}
return {
success: true,
- buffer,
+ buffer: result,
};
} finally {
clearInterval(cancelInterval);
diff --git a/apps/web/src/db/index.ts b/apps/web/src/db/index.ts
deleted file mode 100644
index 29e3ffda..00000000
--- a/apps/web/src/db/index.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { drizzle } from "drizzle-orm/postgres-js";
-import postgres from "postgres";
-import * as schema from "./schema";
-import { webEnv } from "@/env/web";
-
-let _db: ReturnType<typeof drizzle> | null = null;
-
-function getDb() {
- if (!_db) {
- const client = postgres(webEnv.DATABASE_URL);
- _db = drizzle(client, { schema });
- }
-
- return _db;
-}
-
-export const db = getDb();
-
-export * from "./schema";
diff --git a/apps/web/src/db/schema.ts b/apps/web/src/db/schema.ts
deleted file mode 100644
index 8f8be248..00000000
--- a/apps/web/src/db/schema.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
-
-export const users = pgTable("users", {
- id: text("id").primaryKey(),
-
- // todo: implement fully anonymous sign-in for privacy
- // we don't have any auth flows currently so this is fine for now
- name: text("name").notNull(),
- email: text("email").notNull().unique(),
- emailVerified: boolean("email_verified").default(false).notNull(),
- image: text("image"),
- createdAt: timestamp("created_at")
- .$defaultFn(() => /* @__PURE__ */ new Date())
- .notNull(),
- updatedAt: timestamp("updated_at")
- .$defaultFn(() => /* @__PURE__ */ new Date())
- .notNull(),
-}).enableRLS();
-
-export const sessions = pgTable("sessions", {
- id: text("id").primaryKey(),
- expiresAt: timestamp("expires_at").notNull(),
- token: text("token").notNull().unique(),
- createdAt: timestamp("created_at").notNull(),
- updatedAt: timestamp("updated_at").notNull(),
- ipAddress: text("ip_address"),
- userAgent: text("user_agent"),
- userId: text("user_id")
- .notNull()
- .references(() => users.id, { onDelete: "cascade" }),
-}).enableRLS();
-
-export const accounts = pgTable("accounts", {
- id: text("id").primaryKey(),
- accountId: text("account_id").notNull(),
- providerId: text("provider_id").notNull(),
- userId: text("user_id")
- .notNull()
- .references(() => users.id, { onDelete: "cascade" }),
- accessToken: text("access_token"),
- refreshToken: text("refresh_token"),
- idToken: text("id_token"),
- accessTokenExpiresAt: timestamp("access_token_expires_at"),
- refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
- scope: text("scope"),
- password: text("password"),
- createdAt: timestamp("created_at").notNull(),
- updatedAt: timestamp("updated_at").notNull(),
-}).enableRLS();
-
-export const feedback = pgTable("feedback", {
- id: text("id").primaryKey(),
- message: text("message").notNull(),
- createdAt: timestamp("created_at")
- .$defaultFn(() => new Date())
- .notNull(),
-});
-
-export const verifications = pgTable("verifications", {
- id: text("id").primaryKey(),
- identifier: text("identifier").notNull(),
- value: text("value").notNull(),
- expiresAt: timestamp("expires_at").notNull(),
- createdAt: timestamp("created_at").$defaultFn(
- () => /* @__PURE__ */ new Date(),
- ),
- updatedAt: timestamp("updated_at").$defaultFn(
- () => /* @__PURE__ */ new Date(),
- ),
-}).enableRLS();
diff --git a/apps/web/src/desktop/bridge.ts b/apps/web/src/desktop/bridge.ts
new file mode 100644
index 00000000..ea9f48f3
--- /dev/null
+++ b/apps/web/src/desktop/bridge.ts
@@ -0,0 +1,35 @@
+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;
+};
+
+export type OpenCutDesktop = {
+ isDesktop: true;
+ exportBegin: (
+ opts: DesktopExportBegin,
+ ) => Promise<{ id: string; filePath: string } | null>;
+ exportWriteAudio: (id: string, pcm: ArrayBuffer) => Promise<void>;
+ exportWriteFrame: (id: string, rgba: ArrayBuffer) => Promise<void>;
+ exportFinish: (id: string) => Promise<void>;
+ exportCancel: (id: string) => Promise<void>;
+ revealFile: (path: string) => Promise<void>;
+};
+
+declare global {
+ interface Window {
+ opencutDesktop?: OpenCutDesktop;
+ }
+}
+
+export function getDesktopBridge(): OpenCutDesktop | null {
+ if (typeof window === "undefined") return null;
+ return window.opencutDesktop ?? null;
+}
diff --git a/apps/web/src/env/web.ts b/apps/web/src/env/web.ts
deleted file mode 100644
index aa051615..00000000
--- a/apps/web/src/env/web.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { z } from "zod";
-
-const webEnvSchema = z.object({
- // Node
- NODE_ENV: z.enum(["development", "production", "test"]),
- ANALYZE: z.string().optional(),
- NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(),
-
- // Public
- NEXT_PUBLIC_SITE_URL: z.url().default("http://localhost:3000"),
- NEXT_PUBLIC_MARBLE_API_URL: z.url(),
-
- // Server
- DATABASE_URL: z.string().refine(
- (url) =>
- url.startsWith("postgres://") || url.startsWith("postgresql://"),
- "DATABASE_URL must be a postgres:// or postgresql:// URL",
- ),
-
- BETTER_AUTH_SECRET: z.string(),
- UPSTASH_REDIS_REST_URL: z.url(),
- UPSTASH_REDIS_REST_TOKEN: z.string(),
- MARBLE_WORKSPACE_KEY: z.string(),
- FREESOUND_CLIENT_ID: z.string(),
- FREESOUND_API_KEY: z.string(),
-});
-
-export type WebEnv = z.infer<typeof webEnvSchema>;
-
-export const webEnv = webEnvSchema.parse(process.env);
diff --git a/apps/web/src/export/index.ts b/apps/web/src/export/index.ts
index c5c522b3..1d91dbca 100644
--- a/apps/web/src/export/index.ts
+++ b/apps/web/src/export/index.ts
@@ -23,6 +23,7 @@ export interface ExportOptions {
export interface ExportResult {
success: boolean;
buffer?: ArrayBuffer;
+ filePath?: string;
error?: string;
cancelled?: boolean;
}
diff --git a/apps/web/src/services/renderer/native-exporter.ts b/apps/web/src/services/renderer/native-exporter.ts
new file mode 100644
index 00000000..f525f050
--- /dev/null
+++ b/apps/web/src/services/renderer/native-exporter.ts
@@ -0,0 +1,159 @@
+import EventEmitter from "eventemitter3";
+
+import type { FrameRate } from "opencut-wasm";
+import { TICKS_PER_SECOND } from "@/wasm";
+import { frameRateToFloat } from "@/fps/utils";
+import { getDesktopBridge } from "@/desktop/bridge";
+import type { ExportFormat, ExportQuality } from "@/export";
+import { CanvasRenderer } from "./canvas-renderer";
+import type { RootNode } from "./nodes/root-node";
+
+type NativeExportParams = {
+ width: number;
+ height: number;
+ fps: FrameRate;
+ format: ExportFormat;
+ quality: ExportQuality;
+ shouldIncludeAudio?: boolean;
+ audioBuffer?: AudioBuffer;
+ fileName: string;
+};
+
+export type NativeExporterEvents = {
+ progress: [progress: number];
+ complete: [];
+ error: [error: Error];
+ cancelled: [];
+};
+
+export class NativeExporter extends EventEmitter<NativeExporterEvents> {
+ private renderer: CanvasRenderer;
+ private format: ExportFormat;
+ private quality: ExportQuality;
+ private shouldIncludeAudio: boolean;
+ private audioBuffer?: AudioBuffer;
+ private fileName: string;
+
+ private isCancelled = false;
+
+ constructor({
+ width,
+ height,
+ fps,
+ format,
+ quality,
+ shouldIncludeAudio,
+ audioBuffer,
+ fileName,
+ }: NativeExportParams) {
+ super();
+ this.renderer = new CanvasRenderer({
+ width,
+ height,
+ fps,
+ });
+
+ this.format = format;
+ this.quality = quality;
+ this.shouldIncludeAudio = shouldIncludeAudio ?? false;
+ this.audioBuffer = audioBuffer;
+ this.fileName = fileName;
+ }
+
+ cancel(): void {
+ this.isCancelled = true;
+ }
+
+ private buildAudioPcm(): Float32Array | null {
+ if (!this.shouldIncludeAudio || !this.audioBuffer) return null;
+
+ const { numberOfChannels, length } = this.audioBuffer;
+ const pcm = new Float32Array(length * numberOfChannels);
+ for (let channel = 0; channel < numberOfChannels; channel++) {
+ const channelData = this.audioBuffer.getChannelData(channel);
+ for (let i = 0; i < length; i++) {
+ pcm[i * numberOfChannels + channel] = channelData[i];
+ }
+ }
+ return pcm;
+ }
+
+ async export({ rootNode }: { rootNode: RootNode }): Promise<string | null> {
+ const bridge = getDesktopBridge();
+ if (!bridge) {
+ throw new Error("Desktop bridge is not available");
+ }
+
+ const fps = this.renderer.fps;
+ const fpsFloat = frameRateToFloat(fps);
+ const ticksPerFrame = Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
+ );
+ const frameCount = Math.floor(rootNode.duration / ticksPerFrame);
+
+ const pcm = this.buildAudioPcm();
+
+ const begin = await bridge.exportBegin({
+ width: this.renderer.width,
+ height: this.renderer.height,
+ fpsNumerator: fps.numerator,
+ fpsDenominator: fps.denominator,
+ format: this.format,
+ quality: this.quality,
+ hasAudio: pcm !== null,
+ sampleRate: this.audioBuffer?.sampleRate ?? 48000,
+ channels: this.audioBuffer?.numberOfChannels ?? 2,
+ defaultFileName: this.fileName,
+ });
+
+ if (!begin) {
+ this.emit("cancelled");
+ return null;
+ }
+
+ const { id, filePath } = begin;
+
+ try {
+ if (pcm) {
+ await bridge.exportWriteAudio(id, pcm.buffer as ArrayBuffer);
+ }
+
+ for (let i = 0; i < frameCount; i++) {
+ if (this.isCancelled) {
+ await bridge.exportCancel(id);
+ this.emit("cancelled");
+ return null;
+ }
+
+ const timeTicks = i * ticksPerFrame;
+ await this.renderer.render({ node: rootNode, time: timeTicks });
+
+ const frame = new VideoFrame(this.renderer.getOutputCanvas(), {
+ timestamp: Math.round((i * 1e6) / fpsFloat),
+ duration: Math.round(1e6 / fpsFloat),
+ });
+ const size = frame.allocationSize({ format: "RGBA" });
+ const buffer = new ArrayBuffer(size);
+ await frame.copyTo(buffer, { format: "RGBA" });
+ frame.close();
+
+ await bridge.exportWriteFrame(id, buffer);
+ this.emit("progress", i / frameCount);
+ }
+
+ if (this.isCancelled) {
+ await bridge.exportCancel(id);
+ this.emit("cancelled");
+ return null;
+ }
+
+ await bridge.exportFinish(id);
+ this.emit("progress", 1);
+ this.emit("complete");
+ return filePath;
+ } catch (error) {
+ await bridge.exportCancel(id).catch(() => {});
+ throw error;
+ }
+ }
+}
diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc
deleted file mode 100644
index 7162e584..00000000
--- a/apps/web/wrangler.jsonc
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "$schema": "node_modules/wrangler/config-schema.json",
- "name": "opencut",
- "main": ".open-next/worker.js",
- "compatibility_date": "2025-04-01",
- "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
- "assets": {
- "directory": ".open-next/assets",
- "binding": "ASSETS"
- },
- "services": [
- {
- "binding": "WORKER_SELF_REFERENCE",
- "service": "opencut"
- }
- ]
-}