diff --git a/.gitignore b/.gitignore index 8c1c296..1f89a79 100644 --- a/.gitignore +++ b/.gitignore @@ -21,9 +21,6 @@ htmlcov/ .idea/ .cursor/ -# Submodules (can be cloned separately if needed) -CutScript/ - # OS files .env .env.local diff --git a/README.md b/README.md index 5873147..aecdd5c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# CutScript +# TalkEdit An open-source, local-first, Descript-like text-based audio and video editor powered by AI. Edit audio/video by editing text — delete a word from the transcript and it's cut from the audio/video. @@ -7,7 +7,7 @@ An open-source, local-first, Descript-like text-based audio and video editor pow ## Architecture -- **Electron + React** desktop app with Tailwind CSS +- **Tauri + React** desktop app with Tailwind CSS - **FastAPI** Python backend (spawned as child process) - **WhisperX** for word-level transcription with alignment - **FFmpeg** for video processing (stream-copy and re-encode) @@ -25,7 +25,7 @@ An open-source, local-first, Descript-like text-based audio and video editor pow ### Install ```bash -# Root dependencies (Electron, concurrently) +# Root scripts npm install # Frontend dependencies (React, Tailwind, Zustand) @@ -37,32 +37,46 @@ cd backend && pip install -r requirements.txt && cd .. ### Run (Development) +Set a custom backend port once (optional): + ```bash -# Start all three (backend + frontend + electron) +export BACKEND_PORT=8000 +``` + +If you run frontend separately, you can also set: + +```bash +export VITE_BACKEND_PORT=$BACKEND_PORT +``` + +```bash +# Start frontend in browser npm run dev + +# Or start the full desktop app (backend + tauri) +npm run dev:tauri ``` Or run them separately: ```bash # Terminal 1: Backend -cd backend && python -m uvicorn main:app --reload --port 8642 +cd backend && python -m uvicorn main:app --reload --port 8000 # Terminal 2: Frontend cd frontend && npm run dev -# Terminal 3: Electron -npx electron . +# Terminal 3: Tauri app shell +cd frontend && cargo tauri dev ``` ## Project Structure ``` -cutscript/ -├── electron/ # Electron main process -│ ├── main.js # App entry, spawns Python backend -│ ├── preload.js # Secure IPC bridge -│ └── python-bridge.js +talkedit/ +├── src-tauri/ # Tauri Rust host +│ ├── src/main.rs # App entry and backend orchestration +│ └── tauri.conf.json ├── frontend/ # React + Vite + Tailwind │ └── src/ │ ├── components/ # VideoPlayer, TranscriptEditor, etc. @@ -97,7 +111,7 @@ cutscript/ | Speaker diarization | Done | | Virtualized transcript (react-virtuoso) | Done | | Encrypted API key storage | Done | -| Project save/load (.cutscript) | Done | +| Project save/load (.aive) | Done | | AI background removal | Planned | ## Keyboard Shortcuts diff --git a/backend/routers/export.py b/backend/routers/export.py index 91228ca..8ee3f2b 100644 --- a/backend/routers/export.py +++ b/backend/routers/export.py @@ -118,7 +118,7 @@ async def export_video(req: ExportRequest): # Audio enhancement: clean, then mux back into the exported video if req.enhanceAudio: try: - tmp_dir = tempfile.mkdtemp(prefix="cutscript_audio_") + tmp_dir = tempfile.mkdtemp(prefix="talkedit_audio_") cleaned_audio = os.path.join(tmp_dir, "cleaned.wav") clean_audio(output, cleaned_audio) diff --git a/close b/close index c30d6f1..14fd9ec 100755 --- a/close +++ b/close @@ -1,7 +1,8 @@ #!/bin/bash -# Close TalkEdit and/or CutScript processes (Tauri dev, Electron, and Python backends) +# Close TalkEdit processes (Tauri dev and Python backend) KILLED_ANY=0 +BACKEND_PORT="${BACKEND_PORT:-8000}" kill_port() { local port=$1 @@ -27,23 +28,17 @@ kill_pattern() { fi } -# --- TalkEdit (Tauri, port 8000) --- -kill_port 8000 "TalkEdit" +# --- TalkEdit (Tauri) --- +kill_port "$BACKEND_PORT" "TalkEdit" kill_pattern "tauri.*TalkEdit\|TalkEdit.*tauri\|cargo.*tauri dev\|/TalkEdit/target/debug" "TalkEdit (Tauri dev)" # Vite dev server for TalkEdit (port 5173) kill_pattern "vite.*5173\|rsbuild.*5173" "TalkEdit frontend dev server" -# --- CutScript (Electron, port 8642) --- -kill_port 8642 "CutScript" -kill_pattern "electron.*CutScript\|CutScript.*electron" "CutScript (Electron)" -kill_pattern "vite.*CutScript\|CutScript.*vite" "CutScript frontend dev server" - -# --- Orphaned uvicorn workers for either app --- -kill_pattern "uvicorn.*main:app.*--port 800[012]" "leftover uvicorn workers (TalkEdit)" -kill_pattern "uvicorn.*main:app.*--port 864" "leftover uvicorn workers (CutScript)" +# --- Orphaned uvicorn workers for TalkEdit --- +kill_pattern "uvicorn.*main:app.*--port ${BACKEND_PORT}" "leftover uvicorn workers (TalkEdit)" if [[ $KILLED_ANY -eq 0 ]]; then - echo "Nothing to close — no TalkEdit or CutScript processes found." + echo "Nothing to close — no TalkEdit processes found." else echo "Done." fi diff --git a/electron/main.js b/electron/main.js deleted file mode 100644 index ac11da6..0000000 --- a/electron/main.js +++ /dev/null @@ -1,131 +0,0 @@ -const { app, BrowserWindow, ipcMain, dialog, safeStorage } = require('electron'); -const path = require('path'); -const { PythonBackend } = require('./python-bridge'); - -let mainWindow = null; -let pythonBackend = null; - -const isDev = !app.isPackaged; -const BACKEND_PORT = 8642; - -function createWindow() { - mainWindow = new BrowserWindow({ - width: 1400, - height: 900, - minWidth: 1024, - minHeight: 700, - title: 'CutScript', - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - webSecurity: isDev ? false : true, - }, - show: false, - }); - - if (isDev) { - mainWindow.loadURL('http://localhost:5173'); - mainWindow.webContents.openDevTools(); - } else { - mainWindow.loadFile(path.join(__dirname, '..', 'frontend', 'dist', 'index.html')); - } - - mainWindow.once('ready-to-show', () => { - mainWindow.show(); - }); - - mainWindow.on('closed', () => { - mainWindow = null; - }); -} - -app.whenReady().then(async () => { - pythonBackend = new PythonBackend(BACKEND_PORT, isDev); - await pythonBackend.start(); - - createWindow(); - - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } - }); -}); - -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } -}); - -app.on('before-quit', () => { - if (pythonBackend) { - pythonBackend.stop(); - } -}); - -// IPC Handlers - -ipcMain.handle('dialog:openFile', async (_event, options) => { - const result = await dialog.showOpenDialog(mainWindow, { - properties: ['openFile'], - filters: [ - { name: 'Video Files', extensions: ['mp4', 'avi', 'mov', 'mkv', 'webm'] }, - { name: 'Audio Files', extensions: ['m4a', 'wav', 'mp3', 'flac'] }, - { name: 'All Files', extensions: ['*'] }, - ], - ...options, - }); - return result.canceled ? null : result.filePaths[0]; -}); - -ipcMain.handle('dialog:saveFile', async (_event, options) => { - const result = await dialog.showSaveDialog(mainWindow, { - filters: [ - { name: 'Video Files', extensions: ['mp4', 'mov', 'webm'] }, - { name: 'Project Files', extensions: ['aive'] }, - ], - ...options, - }); - return result.canceled ? null : result.filePath; -}); - -ipcMain.handle('dialog:openProject', async () => { - const result = await dialog.showOpenDialog(mainWindow, { - properties: ['openFile'], - filters: [ - { name: 'AI Video Editor Project', extensions: ['aive'] }, - ], - }); - return result.canceled ? null : result.filePaths[0]; -}); - -ipcMain.handle('safe-storage:encrypt', (_event, data) => { - if (safeStorage.isEncryptionAvailable()) { - return safeStorage.encryptString(data).toString('base64'); - } - return data; -}); - -ipcMain.handle('safe-storage:decrypt', (_event, encrypted) => { - if (safeStorage.isEncryptionAvailable()) { - return safeStorage.decryptString(Buffer.from(encrypted, 'base64')); - } - return encrypted; -}); - -ipcMain.handle('get-backend-url', () => { - return `http://localhost:${BACKEND_PORT}`; -}); - -ipcMain.handle('fs:readFile', async (_event, filePath) => { - const fs = require('fs'); - return fs.readFileSync(filePath, 'utf-8'); -}); - -ipcMain.handle('fs:writeFile', async (_event, filePath, content) => { - const fs = require('fs'); - fs.writeFileSync(filePath, content, 'utf-8'); - return true; -}); diff --git a/electron/preload.js b/electron/preload.js deleted file mode 100644 index 32d5ac0..0000000 --- a/electron/preload.js +++ /dev/null @@ -1,12 +0,0 @@ -const { contextBridge, ipcRenderer } = require('electron'); - -contextBridge.exposeInMainWorld('electronAPI', { - openFile: (options) => ipcRenderer.invoke('dialog:openFile', options), - saveFile: (options) => ipcRenderer.invoke('dialog:saveFile', options), - openProject: () => ipcRenderer.invoke('dialog:openProject'), - getBackendUrl: () => ipcRenderer.invoke('get-backend-url'), - encryptString: (data) => ipcRenderer.invoke('safe-storage:encrypt', data), - decryptString: (encrypted) => ipcRenderer.invoke('safe-storage:decrypt', encrypted), - readFile: (path) => ipcRenderer.invoke('fs:readFile', path), - writeFile: (path, content) => ipcRenderer.invoke('fs:writeFile', path, content), -}); diff --git a/electron/python-bridge.js b/electron/python-bridge.js deleted file mode 100644 index 7db1b41..0000000 --- a/electron/python-bridge.js +++ /dev/null @@ -1,105 +0,0 @@ -const { spawn } = require('child_process'); -const path = require('path'); -const http = require('http'); - -class PythonBackend { - constructor(port, isDev) { - this.port = port; - this.isDev = isDev; - this.process = null; - } - - async start() { - // In dev mode, check if a backend is already running (e.g. from `npm run dev:backend`) - // If so, reuse it instead of spawning a duplicate. - if (this.isDev) { - const alreadyRunning = await this._isPortOpen(2000); - if (alreadyRunning) { - console.log(`[backend] Dev backend already running on port ${this.port} — reusing it.`); - return; - } - } - - const backendDir = this.isDev - ? path.join(__dirname, '..', 'backend') - : path.join(process.resourcesPath, 'backend'); - - const pythonCmd = process.platform === 'win32' ? 'python' : '/home/dillon/.pyenv/versions/3.11.15/bin/python'; - - this.process = spawn(pythonCmd, [ - '-m', 'uvicorn', 'main:app', - '--host', '127.0.0.1', - '--port', String(this.port), - ], { - cwd: backendDir, - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, PYTHONUNBUFFERED: '1' }, - }); - - this.process.stdout.on('data', (data) => { - console.log(`[backend] ${data.toString().trim()}`); - }); - - this.process.stderr.on('data', (data) => { - console.error(`[backend] ${data.toString().trim()}`); - }); - - this.process.on('error', (err) => { - console.error('[backend] Failed to start Python backend:', err.message); - }); - - this.process.on('exit', (code) => { - console.log(`[backend] Process exited with code ${code}`); - this.process = null; - }); - - await this._waitForReady(30000); - console.log(`[backend] Ready on port ${this.port}`); - } - - _isPortOpen(timeoutMs) { - return new Promise((resolve) => { - const req = http.get(`http://127.0.0.1:${this.port}/health`, (res) => { - resolve(res.statusCode === 200); - }); - req.on('error', () => resolve(false)); - req.setTimeout(timeoutMs, () => { req.destroy(); resolve(false); }); - req.end(); - }); - } - - stop() { - if (this.process) { - if (process.platform === 'win32') { - spawn('taskkill', ['/pid', String(this.process.pid), '/f', '/t']); - } else { - this.process.kill('SIGTERM'); - } - this.process = null; - } - } - - _waitForReady(timeoutMs) { - const startTime = Date.now(); - return new Promise((resolve, reject) => { - const check = () => { - if (Date.now() - startTime > timeoutMs) { - reject(new Error('Backend startup timed out')); - return; - } - const req = http.get(`http://127.0.0.1:${this.port}/health`, (res) => { - if (res.statusCode === 200) { - resolve(); - } else { - setTimeout(check, 500); - } - }); - req.on('error', () => setTimeout(check, 500)); - req.end(); - }; - setTimeout(check, 1000); - }); - } -} - -module.exports = { PythonBackend }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 76feaf8..6db2115 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,7 +22,7 @@ import { VolumeX, } from 'lucide-react'; -const IS_ELECTRON = !!window.electronAPI; +const IS_DESKTOP = !!window.desktopAPI; type Panel = 'ai' | 'settings' | 'export' | 'silence' | null; @@ -66,8 +66,8 @@ export default function App() { }, []); useEffect(() => { - if (IS_ELECTRON) { - window.electronAPI!.getBackendUrl().then(setBackendUrl); + if (IS_DESKTOP) { + window.desktopAPI!.getBackendUrl().then(setBackendUrl); } // In Tauri on Linux/WebKit2GTK the ipc:// custom protocol is blocked by // WebKit internals; postMessage fallback works but logs noisy warnings. @@ -75,11 +75,11 @@ export default function App() { }, [setBackendUrl]); const handleLoadProject = async () => { - if (!IS_ELECTRON) return; + if (!IS_DESKTOP) return; try { - const projectPath = await window.electronAPI!.openProject(); + const projectPath = await window.desktopAPI!.openProject(); if (!projectPath) return; - const content = await window.electronAPI!.readFile(projectPath); + const content = await window.desktopAPI!.readFile(projectPath); const data = JSON.parse(content); useEditorStore.getState().loadProject(data); } catch (err) { @@ -89,13 +89,13 @@ export default function App() { }; const handleSaveProject = async () => { - if (!IS_ELECTRON) return; + if (!IS_DESKTOP) return; try { - const savePath = await window.electronAPI!.saveProject(); + const savePath = await window.desktopAPI!.saveProject(); if (!savePath) return; const data = useEditorStore.getState().saveProject(); const path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`; - await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2)); + await window.desktopAPI!.writeFile(path, JSON.stringify(data, null, 2)); } catch (err) { console.error('Failed to save project:', err); alert(`Failed to save project: ${err}`); @@ -103,8 +103,8 @@ export default function App() { }; const handleOpenFile = async () => { - if (IS_ELECTRON) { - const path = await window.electronAPI!.openFile(); + if (IS_DESKTOP) { + const path = await window.desktopAPI!.openFile(); if (path) { loadVideo(path); await transcribeVideo(path); @@ -130,7 +130,7 @@ export default function App() { const transcribeVideo = async (path: string) => { setTranscribing(true, 0, 'Checking model...'); try { - if (!window.electronAPI?.transcribe) { + if (!window.desktopAPI?.transcribe) { throw new Error('Transcription not available'); } // Step 1: ensure model is downloaded (may take a while on first run) @@ -153,11 +153,11 @@ export default function App() { }; const modelLabel = MODEL_SIZES[whisperModel] ?? 'unknown size'; setTranscribing(true, 5, `Downloading ${whisperModel} model (${modelLabel})...`); - await window.electronAPI.ensureModel(whisperModel); + await window.desktopAPI.ensureModel(whisperModel); // Step 2: run transcription setTranscribing(true, 20, 'Transcribing audio...'); - const data = await window.electronAPI.transcribe(path, whisperModel); + const data = await window.desktopAPI.transcribe(path, whisperModel); setTranscription(data); } catch (err) { console.error('Transcription error:', err); @@ -243,7 +243,7 @@ export default function App() { English-only models are ~10% faster and more accurate for English content.

- {IS_ELECTRON ? ( + {IS_DESKTOP ? (