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 | |
| 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')
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 Binary files differnew file mode 100644 index 00000000..f534f429 --- /dev/null +++ b/apps/desktop/build/icon.png 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"
- }
- ]
-}
|
