From ed02ba872c02eafed45f406c68d389d0a1112a4e Mon Sep 17 00:00:00 2001 From: cashmere Date: Thu, 30 Jul 2026 18:17:45 +0200 Subject: 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. --- apps/desktop/.gitignore | 4 + apps/desktop/Cargo.toml | 11 - apps/desktop/README.md | 47 --- apps/desktop/build/icon.png | Bin 0 -> 741 bytes apps/desktop/electron-builder.yml | 28 ++ apps/desktop/package.json | 25 ++ apps/desktop/script/setup | 110 ------- apps/desktop/script/setup.ps1 | 27 -- apps/desktop/scripts/copy-web.mjs | 18 ++ apps/desktop/src/export.ts | 293 ++++++++++++++++++ apps/desktop/src/main.rs | 41 --- apps/desktop/src/main.ts | 131 ++++++++ apps/desktop/src/preload.ts | 22 ++ apps/desktop/tsconfig.json | 17 + apps/web/.env.example | 21 -- apps/web/.gitignore | 3 + apps/web/Dockerfile | 83 ++--- apps/web/content-collections.ts | 2 +- apps/web/drizzle.config.ts | 26 -- apps/web/migrations/0000_brainy_saracen.sql | 62 ---- apps/web/migrations/meta/0000_snapshot.json | 344 --------------------- apps/web/migrations/meta/_journal.json | 13 - apps/web/next.config.ts | 45 +-- apps/web/open-next.config.ts | 3 - apps/web/package.json | 28 +- apps/web/src/app/api/auth/[...all]/route.ts | 4 - apps/web/src/app/api/health/route.ts | 3 - apps/web/src/app/api/sounds/search/route.ts | 280 ----------------- apps/web/src/app/blog/[slug]/page.tsx | 135 -------- apps/web/src/app/blog/page.tsx | 52 ---- apps/web/src/app/changelog/[version]/page.tsx | 2 + apps/web/src/app/editor/[project_id]/page.tsx | 209 ------------- apps/web/src/app/editor/page.tsx | 217 +++++++++++++ apps/web/src/app/layout.tsx | 12 +- apps/web/src/app/projects/page.tsx | 10 +- apps/web/src/app/robots.ts | 2 + apps/web/src/app/rss.xml/route.ts | 46 --- apps/web/src/app/sitemap.ts | 100 +++--- apps/web/src/auth/client.ts | 6 - apps/web/src/auth/rate-limit.ts | 21 -- apps/web/src/auth/server.ts | 43 --- apps/web/src/blog/query.ts | 133 -------- apps/web/src/blog/types.ts | 92 ------ apps/web/src/changelog/entries/0.4.0.md | 50 ++- apps/web/src/components/editor/export-button.tsx | 18 ++ .../src/components/providers/editor-provider.tsx | 2 +- apps/web/src/core/managers/renderer-manager.ts | 42 ++- apps/web/src/db/index.ts | 19 -- apps/web/src/db/schema.ts | 70 ----- apps/web/src/desktop/bridge.ts | 35 +++ apps/web/src/env/web.ts | 30 -- apps/web/src/export/index.ts | 1 + apps/web/src/services/renderer/native-exporter.ts | 159 ++++++++++ apps/web/wrangler.jsonc | 17 - 54 files changed, 1119 insertions(+), 2095 deletions(-) create mode 100644 apps/desktop/.gitignore delete mode 100644 apps/desktop/Cargo.toml delete mode 100644 apps/desktop/README.md create mode 100644 apps/desktop/build/icon.png create mode 100644 apps/desktop/electron-builder.yml create mode 100644 apps/desktop/package.json delete mode 100644 apps/desktop/script/setup delete mode 100644 apps/desktop/script/setup.ps1 create mode 100644 apps/desktop/scripts/copy-web.mjs create mode 100644 apps/desktop/src/export.ts delete mode 100644 apps/desktop/src/main.rs create mode 100644 apps/desktop/src/main.ts create mode 100644 apps/desktop/src/preload.ts create mode 100644 apps/desktop/tsconfig.json delete mode 100644 apps/web/.env.example delete mode 100644 apps/web/drizzle.config.ts delete mode 100644 apps/web/migrations/0000_brainy_saracen.sql delete mode 100644 apps/web/migrations/meta/0000_snapshot.json delete mode 100644 apps/web/migrations/meta/_journal.json delete mode 100644 apps/web/open-next.config.ts delete mode 100644 apps/web/src/app/api/auth/[...all]/route.ts delete mode 100644 apps/web/src/app/api/health/route.ts delete mode 100644 apps/web/src/app/api/sounds/search/route.ts delete mode 100644 apps/web/src/app/blog/[slug]/page.tsx delete mode 100644 apps/web/src/app/blog/page.tsx delete mode 100644 apps/web/src/app/editor/[project_id]/page.tsx create mode 100644 apps/web/src/app/editor/page.tsx delete mode 100644 apps/web/src/app/rss.xml/route.ts delete mode 100644 apps/web/src/auth/client.ts delete mode 100644 apps/web/src/auth/rate-limit.ts delete mode 100644 apps/web/src/auth/server.ts delete mode 100644 apps/web/src/blog/query.ts delete mode 100644 apps/web/src/blog/types.ts delete mode 100644 apps/web/src/db/index.ts delete mode 100644 apps/web/src/db/schema.ts create mode 100644 apps/web/src/desktop/bridge.ts delete mode 100644 apps/web/src/env/web.ts create mode 100644 apps/web/src/services/renderer/native-exporter.ts delete mode 100644 apps/web/wrangler.jsonc (limited to 'apps') 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 Binary files /dev/null and b/apps/desktop/build/icon.png 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; + +type ExportSession = { + id: string; + filePath: string; + opts: DesktopExportBegin; + audioPath: string | null; + process: FfmpegProcess | null; + exitCode: Promise | null; + stderrTail: string; +}; + +const MP4_CRF: Record = { + low: 28, + medium: 23, + high: 19, + very_high: 15, +}; + +const WEBM_CRF: Record = { + low: 40, + medium: 32, + high: 24, + very_high: 16, +}; + +const STDERR_TAIL_LIMIT = 8192; + +const sessions = new Map(); + +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((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 { + 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((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) -> 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 = { + ".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 => + ipcRenderer.invoke("export:writeAudio", id, pcm), + exportWriteFrame: (id: string, rgba: ArrayBuffer): Promise => + ipcRenderer.invoke("export:writeFrame", id, rgba), + exportFinish: (id: string): Promise => + ipcRenderer.invoke("export:finish", id), + exportCancel: (id: string): Promise => + ipcRenderer.invoke("export:cancel", id), + revealFile: (path: string): Promise => + 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, -) { - 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 { - 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 ( - - - - - - ); -} - -function PostHeader({ post }: { post: Post }) { - const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", { - day: "numeric", - month: "long", - year: "numeric", - }); - - return ( -
- - - {post.coverImage && } -
- ); -} - -function PostCoverImage({ post }: { post: Post }) { - return ( -
- {post.title} -
- ); -} - -function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) { - return ( -
- -
- ); -} - -function PostTitle({ title }: { title: string }) { - return ( -

- {title} -

- ); -} - -function PostContent({ html }: { html: string }) { - return ( -
- -
- ); -} 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
No posts yet
; - - return ( - -
- {data.posts.map((post) => ( -
- - -
- ))} -
-
- ); -} - -function BlogPostItem({ post }: { post: Post }) { - return ( - -
-
-

{post.title}

-

{post.description}

-
-
- - ); -} 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/[project_id]/page.tsx deleted file mode 100644 index b759b9c2..00000000 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"use client"; - -import { useParams } from "next/navigation"; -import { - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, -} from "@/components/ui/resizable"; -import { AssetsPanel } from "@/components/editor/panels/assets"; -import { PropertiesPanel } from "@/components/editor/panels/properties"; -import { Timeline } from "@/timeline/components"; -import { PreviewPanel } from "@/preview/components"; -import { EditorHeader } from "@/components/editor/editor-header"; -import { EditorProvider } from "@/components/providers/editor-provider"; -import { Onboarding } from "@/components/editor/onboarding"; -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 { useEditor } from "@/editor/use-editor"; -import { Cancel01Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { Button } from "@/components/ui/button"; -import { ChangelogNotification } from "@/changelog/components/changelog-notification"; -import { - createPreviewOverlayControl, - isPreviewOverlayVisible, - mergePreviewOverlaySources, -} from "@/preview/overlays"; -import { usePreviewStore } from "@/preview/preview-store"; -import { getGuidePreviewOverlaySource } from "@/guides"; -import { - bookmarkNotesPreviewOverlay, - getBookmarkPreviewOverlaySource, -} from "@/timeline/bookmarks/index"; - -export default function Editor() { - const params = useParams<{ project_id: string }>(); - const projectId = params.project_id; - - return ( - - -
- - -
- -
- - - -
-
-
- ); -} - -function DegradedRendererBanner() { - const isDegraded = useEditor((e) => e.renderer.isDegraded); - const [dismissed, setDismissed] = useState(false); - if (!isDegraded || dismissed) return null; - - return ( -
- For the best experience, open OpenCut in Chrome. - -
- ); -} - -function EditorLayout() { - usePasteMedia(); - const { panels, setPanel } = usePanelStore(); - const activeScene = useEditor((editor) => - editor.scenes.getActiveSceneOrNull(), - ); - const currentTime = useEditor((editor) => editor.playback.getCurrentTime()); - const activeGuide = usePreviewStore((state) => state.activeGuide); - const overlays = usePreviewStore((state) => state.overlays); - const setOverlayVisibility = usePreviewStore( - (state) => state.setOverlayVisibility, - ); - const showBookmarkNotes = isPreviewOverlayVisible({ - overlay: bookmarkNotesPreviewOverlay, - overlays, - }); - - const overlaySource = useMemo( - () => - mergePreviewOverlaySources({ - sources: [ - getGuidePreviewOverlaySource({ - guideId: activeGuide, - }), - activeScene - ? getBookmarkPreviewOverlaySource({ - bookmarks: activeScene.bookmarks, - time: currentTime, - isVisible: showBookmarkNotes, - }) - : { - definitions: [bookmarkNotesPreviewOverlay], - instances: [], - }, - ], - }), - [activeGuide, activeScene, currentTime, showBookmarkNotes], - ); - - const overlayControls = useMemo( - () => - overlaySource.definitions.map((overlay) => - createPreviewOverlayControl({ overlay, overlays }), - ), - [overlaySource.definitions, overlays], - ); - - return ( - { - setPanel({ - panel: "mainContent", - size: sizes[0] ?? panels.mainContent, - }); - setPanel({ - panel: "timeline", - size: sizes[1] ?? panels.timeline, - }); - }} - > - - { - setPanel({ panel: "tools", size: sizes[0] ?? panels.tools }); - setPanel({ panel: "preview", size: sizes[1] ?? panels.preview }); - setPanel({ - panel: "properties", - size: sizes[2] ?? panels.properties, - }); - }} - > - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/web/src/app/editor/page.tsx b/apps/web/src/app/editor/page.tsx new file mode 100644 index 00000000..8229774a --- /dev/null +++ b/apps/web/src/app/editor/page.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { AssetsPanel } from "@/components/editor/panels/assets"; +import { PropertiesPanel } from "@/components/editor/panels/properties"; +import { Timeline } from "@/timeline/components"; +import { PreviewPanel } from "@/preview/components"; +import { EditorHeader } from "@/components/editor/editor-header"; +import { EditorProvider } from "@/components/providers/editor-provider"; +import { Onboarding } from "@/components/editor/onboarding"; +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 { Suspense, useMemo, useState } from "react"; +import { useEditor } from "@/editor/use-editor"; +import { Cancel01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Button } from "@/components/ui/button"; +import { ChangelogNotification } from "@/changelog/components/changelog-notification"; +import { + createPreviewOverlayControl, + isPreviewOverlayVisible, + mergePreviewOverlaySources, +} from "@/preview/overlays"; +import { usePreviewStore } from "@/preview/preview-store"; +import { getGuidePreviewOverlaySource } from "@/guides"; +import { + bookmarkNotesPreviewOverlay, + getBookmarkPreviewOverlaySource, +} from "@/timeline/bookmarks/index"; + +export default function Editor() { + return ( + + + + ); +} + +function EditorView() { + const searchParams = useSearchParams(); + const projectId = searchParams.get("project") ?? ""; + + return ( + + +
+ + +
+ +
+ + + +
+
+
+ ); +} + +function DegradedRendererBanner() { + const isDegraded = useEditor((e) => e.renderer.isDegraded); + const [dismissed, setDismissed] = useState(false); + if (!isDegraded || dismissed) return null; + + return ( +
+ For the best experience, open OpenCut in Chrome. + +
+ ); +} + +function EditorLayout() { + usePasteMedia(); + const { panels, setPanel } = usePanelStore(); + const activeScene = useEditor((editor) => + editor.scenes.getActiveSceneOrNull(), + ); + const currentTime = useEditor((editor) => editor.playback.getCurrentTime()); + const activeGuide = usePreviewStore((state) => state.activeGuide); + const overlays = usePreviewStore((state) => state.overlays); + const setOverlayVisibility = usePreviewStore( + (state) => state.setOverlayVisibility, + ); + const showBookmarkNotes = isPreviewOverlayVisible({ + overlay: bookmarkNotesPreviewOverlay, + overlays, + }); + + const overlaySource = useMemo( + () => + mergePreviewOverlaySources({ + sources: [ + getGuidePreviewOverlaySource({ + guideId: activeGuide, + }), + activeScene + ? getBookmarkPreviewOverlaySource({ + bookmarks: activeScene.bookmarks, + time: currentTime, + isVisible: showBookmarkNotes, + }) + : { + definitions: [bookmarkNotesPreviewOverlay], + instances: [], + }, + ], + }), + [activeGuide, activeScene, currentTime, showBookmarkNotes], + ); + + const overlayControls = useMemo( + () => + overlaySource.definitions.map((overlay) => + createPreviewOverlayControl({ overlay, overlays }), + ), + [overlaySource.definitions, overlays], + ); + + return ( + { + setPanel({ + panel: "mainContent", + size: sizes[0] ?? panels.mainContent, + }); + setPanel({ + panel: "timeline", + size: sizes[1] ?? panels.timeline, + }); + }} + > + + { + setPanel({ panel: "tools", size: sizes[0] ?? panels.tools }); + setPanel({ panel: "preview", size: sizes[1] ?? panels.preview }); + setPanel({ + panel: "properties", + size: sizes[2] ?? panels.properties, + }); + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + ); +} 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 ( - {process.env.NODE_ENV === "development" && ( <>