removed electron
This commit is contained in:
13
.github/copilot-instructions.md
vendored
13
.github/copilot-instructions.md
vendored
@ -12,13 +12,13 @@ Purpose: give AI assistants immediate, accurate context for this repository and
|
|||||||
|
|
||||||
- Name: TalkEdit
|
- Name: TalkEdit
|
||||||
- Product: local-first, AI-powered, text-based audio/video editor.
|
- Product: local-first, AI-powered, text-based audio/video editor.
|
||||||
- Primary runtime today: Tauri + React frontend + Python FastAPI backend.
|
- Primary runtime: Tauri + React frontend + Python FastAPI backend.
|
||||||
- Legacy/transition artifacts may still exist (for example Electron paths/APIs), but default implementation direction is TalkEdit + Tauri.
|
- Desktop only (Electron has been removed; Tauri is the exclusive desktop runtime).
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- Frontend: React 19, TypeScript, Vite, Tailwind, Zustand.
|
- Frontend: React 19, TypeScript, Vite, Tailwind, Zustand.
|
||||||
- Desktop bridge: Tauri API with compatibility shim exposing `window.electronAPI` in `frontend/src/lib/tauri-bridge.ts`.
|
- Desktop bridge: Tauri API (IPC commands via `window.electronAPI` polyfill in `frontend/src/lib/tauri-bridge.ts` for unified call-site interface).
|
||||||
- Backend: FastAPI + Uvicorn (`backend/main.py`) with routers in `backend/routers` and core services in `backend/services`.
|
- Backend: FastAPI + Uvicorn (`backend/main.py`) with routers in `backend/routers` and core services in `backend/services`.
|
||||||
- Media tooling: FFmpeg for edit/export and codec operations.
|
- Media tooling: FFmpeg for edit/export and codec operations.
|
||||||
- AI tooling: WhisperX/faster-whisper for transcription; provider layer supports OpenAI/Anthropic/Ollama.
|
- AI tooling: WhisperX/faster-whisper for transcription; provider layer supports OpenAI/Anthropic/Ollama.
|
||||||
@ -46,7 +46,7 @@ Use project virtualenvs where available (`.venv312`, `.venv`, or `venv`) for bac
|
|||||||
|
|
||||||
- Keep router files thin; put heavy logic in `backend/services`.
|
- Keep router files thin; put heavy logic in `backend/services`.
|
||||||
- Preserve response compatibility for existing frontend callers unless task explicitly allows API breakage.
|
- Preserve response compatibility for existing frontend callers unless task explicitly allows API breakage.
|
||||||
- Keep frontend bridge compatibility stable: if desktop APIs change, update both Tauri-side implementation and the `window.electronAPI` shim contract.
|
- Frontend uses unified `window.electronAPI` interface (Tauri-backed via tauri-bridge.ts); desktop APIs are implemented exclusively in Tauri.
|
||||||
- Prefer small, focused edits over broad refactors.
|
- Prefer small, focused edits over broad refactors.
|
||||||
|
|
||||||
## Known Risk Areas
|
## Known Risk Areas
|
||||||
@ -65,6 +65,11 @@ Always update these sections if affected:
|
|||||||
- `Tech Stack`
|
- `Tech Stack`
|
||||||
- `Code Map`
|
- `Code Map`
|
||||||
- `Run And Build (Preferred)`
|
- `Run And Build (Preferred)`
|
||||||
|
- `Working Conventions`
|
||||||
|
- `Known Risk Areas`
|
||||||
|
- Recent changes section (if applicable)
|
||||||
|
- `Code Map`
|
||||||
|
- `Run And Build (Preferred)`
|
||||||
- `Known Risk Areas`
|
- `Known Risk Areas`
|
||||||
|
|
||||||
If behavior changed significantly, add a short note under a new `Recent Changes` section with:
|
If behavior changed significantly, add a short note under a new `Recent Changes` section with:
|
||||||
|
|||||||
36
README.md
36
README.md
@ -7,7 +7,7 @@ An open-source, local-first, Descript-like text-based audio and video editor pow
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Electron + React** desktop app with Tailwind CSS
|
- **Tauri + React** desktop app with Tailwind CSS
|
||||||
- **FastAPI** Python backend (spawned as child process)
|
- **FastAPI** Python backend (spawned as child process)
|
||||||
- **WhisperX** for word-level transcription with alignment
|
- **WhisperX** for word-level transcription with alignment
|
||||||
- **FFmpeg** for video processing (stream-copy and re-encode)
|
- **FFmpeg** for video processing (stream-copy and re-encode)
|
||||||
@ -25,10 +25,8 @@ An open-source, local-first, Descript-like text-based audio and video editor pow
|
|||||||
### Install
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Root dependencies (Electron, concurrently)
|
# Root and frontend dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Frontend dependencies (React, Tailwind, Zustand)
|
|
||||||
cd frontend && npm install && cd ..
|
cd frontend && npm install && cd ..
|
||||||
|
|
||||||
# Backend dependencies
|
# Backend dependencies
|
||||||
@ -38,8 +36,8 @@ cd backend && pip install -r requirements.txt && cd ..
|
|||||||
### Run (Development)
|
### Run (Development)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start all three (backend + frontend + electron)
|
# Start Tauri dev environment (includes backend + frontend)
|
||||||
npm run dev
|
npm run dev:tauri
|
||||||
```
|
```
|
||||||
|
|
||||||
Or run them separately:
|
Or run them separately:
|
||||||
@ -48,26 +46,24 @@ Or run them separately:
|
|||||||
# Terminal 1: Backend
|
# Terminal 1: Backend
|
||||||
cd backend && python -m uvicorn main:app --reload --port 8642
|
cd backend && python -m uvicorn main:app --reload --port 8642
|
||||||
|
|
||||||
# Terminal 2: Frontend
|
# Terminal 2: Frontend + Tauri
|
||||||
cd frontend && npm run dev
|
cd frontend && cargo tauri dev
|
||||||
|
|
||||||
# Terminal 3: Electron
|
|
||||||
npx electron .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
cutscript/
|
talkedit/
|
||||||
├── electron/ # Electron main process
|
├── src-tauri/ # Tauri Rust runtime
|
||||||
│ ├── main.js # App entry, spawns Python backend
|
│ ├── Cargo.toml
|
||||||
│ ├── preload.js # Secure IPC bridge
|
│ ├── src/
|
||||||
│ └── python-bridge.js
|
│ │ ├── main.rs # App entry & backend spawner
|
||||||
├── frontend/ # React + Vite + Tailwind
|
│ │ └── commands/ # Tauri IPC handlers
|
||||||
|
├── frontend/ # React + Vite + Tailwind
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── components/ # VideoPlayer, TranscriptEditor, etc.
|
│ ├── components/ # VideoPlayer, TranscriptEditor, etc.
|
||||||
│ ├── store/ # Zustand state (editorStore, aiStore)
|
│ ├── store/ # Zustand state (editorStore, aiStore)
|
||||||
│ ├── hooks/ # useVideoSync, useKeyboardShortcuts
|
│ ├── lib/tauri-bridge.ts # Tauri API polyfill
|
||||||
│ └── types/ # TypeScript interfaces
|
│ └── types/ # TypeScript interfaces
|
||||||
├── backend/ # FastAPI Python backend
|
├── backend/ # FastAPI Python backend
|
||||||
│ ├── main.py
|
│ ├── main.py
|
||||||
|
|||||||
15
close
15
close
@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/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
|
KILLED_ANY=0
|
||||||
|
|
||||||
@ -75,17 +75,12 @@ kill_pattern "tauri.*TalkEdit\|TalkEdit.*tauri\|cargo.*tauri dev\|/TalkEdit/targ
|
|||||||
# Vite dev server for TalkEdit (fallback when not bound to 5173 yet)
|
# Vite dev server for TalkEdit (fallback when not bound to 5173 yet)
|
||||||
kill_pattern "[/ ]vite([[:space:]]|$)\|[/ ]rsbuild([[:space:]]|$)" "TalkEdit frontend dev server"
|
kill_pattern "[/ ]vite([[:space:]]|$)\|[/ ]rsbuild([[:space:]]|$)" "TalkEdit frontend dev server"
|
||||||
|
|
||||||
# --- CutScript (Electron, port 8642) ---
|
# --- Orphaned uvicorn workers ---
|
||||||
kill_port 8642 "CutScript"
|
kill_pattern "uvicorn.*main:app.*--port 8000" "leftover uvicorn workers (TalkEdit)"
|
||||||
kill_pattern "electron.*CutScript\|CutScript.*electron" "CutScript (Electron)"
|
kill_pattern "uvicorn.*main:app.*--port 8642" "leftover uvicorn workers"
|
||||||
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)"
|
|
||||||
|
|
||||||
if [[ $KILLED_ANY -eq 0 ]]; then
|
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
|
else
|
||||||
echo "Done."
|
echo "Done."
|
||||||
fi
|
fi
|
||||||
|
|||||||
198
electron/main.js
198
electron/main.js
@ -1,198 +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 getProjectDirectory() {
|
|
||||||
const fs = require('fs');
|
|
||||||
// Keep project files alongside the TalkEdit workspace in development.
|
|
||||||
// electron/main.js lives under <repo>/electron, so repo root is ../
|
|
||||||
const projectsDir = path.join(__dirname, '..', 'Projects');
|
|
||||||
if (!fs.existsSync(projectsDir)) {
|
|
||||||
fs.mkdirSync(projectsDir, { recursive: true });
|
|
||||||
}
|
|
||||||
return projectsDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 projectDir = getProjectDirectory();
|
|
||||||
const result = await dialog.showOpenDialog(mainWindow, {
|
|
||||||
defaultPath: projectDir,
|
|
||||||
properties: ['openFile'],
|
|
||||||
filters: [
|
|
||||||
{ name: 'AI Video Editor Project', extensions: ['aive'] },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return result.canceled ? null : result.filePaths[0];
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('dialog:saveProject', async (_event, options) => {
|
|
||||||
const projectDir = getProjectDirectory();
|
|
||||||
const result = await dialog.showSaveDialog(mainWindow, {
|
|
||||||
defaultPath: path.join(projectDir, 'project.aive'),
|
|
||||||
filters: [
|
|
||||||
{ name: 'AI Video Editor Project', extensions: ['aive'] },
|
|
||||||
],
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return result.canceled ? null : result.filePath;
|
|
||||||
});
|
|
||||||
|
|
||||||
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('backend:ensureModel', async (_event, modelName) => {
|
|
||||||
// Python backend downloads models lazily during transcription.
|
|
||||||
// Keep this IPC for compatibility with existing renderer flow.
|
|
||||||
return modelName;
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('backend:transcribe', async (_event, payload) => {
|
|
||||||
const filePath = payload?.filePath;
|
|
||||||
const modelName = payload?.modelName || 'base';
|
|
||||||
const language = payload?.language;
|
|
||||||
|
|
||||||
if (!filePath) {
|
|
||||||
throw new Error('Missing file path for transcription');
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`http://127.0.0.1:${BACKEND_PORT}/transcribe`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
file_path: filePath,
|
|
||||||
model: modelName,
|
|
||||||
language,
|
|
||||||
use_gpu: true,
|
|
||||||
use_cache: true,
|
|
||||||
diarize: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
let detail = `${res.status} ${res.statusText}`;
|
|
||||||
try {
|
|
||||||
const body = await res.json();
|
|
||||||
if (body?.detail) detail = `${detail}: ${body.detail}`;
|
|
||||||
} catch (_err) {
|
|
||||||
// ignore JSON parse errors for non-JSON responses
|
|
||||||
}
|
|
||||||
throw new Error(`Transcription failed: ${detail}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await res.json();
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
@ -1,15 +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'),
|
|
||||||
saveProject: (options) => ipcRenderer.invoke('dialog:saveProject', options),
|
|
||||||
getBackendUrl: () => ipcRenderer.invoke('get-backend-url'),
|
|
||||||
ensureModel: (modelName) => ipcRenderer.invoke('backend:ensureModel', modelName),
|
|
||||||
transcribe: (filePath, modelName, language) => ipcRenderer.invoke('backend:transcribe', { filePath, modelName, language }),
|
|
||||||
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),
|
|
||||||
});
|
|
||||||
@ -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 };
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useEditorStore } from './store/editorStore';
|
import { useEditorStore } from './store/editorStore';
|
||||||
import VideoPlayer from './components/VideoPlayer';
|
import VideoPlayer from './components/VideoPlayer';
|
||||||
import TranscriptEditor from './components/TranscriptEditor';
|
import TranscriptEditor from './components/TranscriptEditor';
|
||||||
@ -16,7 +16,6 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Download,
|
Download,
|
||||||
FolderSearch,
|
|
||||||
FileInput,
|
FileInput,
|
||||||
Save,
|
Save,
|
||||||
Scissors,
|
Scissors,
|
||||||
@ -27,7 +26,6 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const IS_ELECTRON = !!window.electronAPI;
|
|
||||||
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
||||||
|
|
||||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'volume' | null;
|
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'volume' | null;
|
||||||
@ -60,7 +58,6 @@ export default function App() {
|
|||||||
} = useEditorStore();
|
} = useEditorStore();
|
||||||
|
|
||||||
const [activePanel, setActivePanel] = useState<Panel>(null);
|
const [activePanel, setActivePanel] = useState<Panel>(null);
|
||||||
const [manualPath, setManualPath] = useState('');
|
|
||||||
const [whisperModel, setWhisperModel] = useState('base');
|
const [whisperModel, setWhisperModel] = useState('base');
|
||||||
const [cutMode, setCutMode] = useState(false);
|
const [cutMode, setCutMode] = useState(false);
|
||||||
const [muteMode, setMuteMode] = useState(false);
|
const [muteMode, setMuteMode] = useState(false);
|
||||||
@ -70,7 +67,6 @@ export default function App() {
|
|||||||
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
|
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
|
||||||
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
|
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
|
||||||
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const projectSignature = useMemo(() => {
|
const projectSignature = useMemo(() => {
|
||||||
if (!videoPath) return null;
|
if (!videoPath) return null;
|
||||||
@ -125,7 +121,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const runGuarded = async (action: () => Promise<void>) => {
|
const runGuarded = async (action: () => Promise<void>) => {
|
||||||
if (!IS_ELECTRON || !hasUnsavedChanges) {
|
if (!hasUnsavedChanges) {
|
||||||
await action();
|
await action();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -150,16 +146,11 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (IS_ELECTRON) {
|
window.electronAPI!.getBackendUrl().then(setBackendUrl);
|
||||||
window.electronAPI!.getBackendUrl().then(setBackendUrl);
|
|
||||||
}
|
|
||||||
// In Tauri on Linux/WebKit2GTK the ipc:// custom protocol is blocked by
|
|
||||||
// WebKit internals; postMessage fallback works but logs noisy warnings.
|
|
||||||
// The backend URL is fixed at 127.0.0.1:8000 so we rely on the store default.
|
|
||||||
}, [setBackendUrl]);
|
}, [setBackendUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_ELECTRON || videoPath) return;
|
if (videoPath) return;
|
||||||
const savedPath = sessionStorage.getItem(LAST_MEDIA_PATH_KEY);
|
const savedPath = sessionStorage.getItem(LAST_MEDIA_PATH_KEY);
|
||||||
if (savedPath) {
|
if (savedPath) {
|
||||||
loadVideo(savedPath);
|
loadVideo(savedPath);
|
||||||
@ -167,7 +158,6 @@ export default function App() {
|
|||||||
}, [videoPath, loadVideo]);
|
}, [videoPath, loadVideo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_ELECTRON) return;
|
|
||||||
if (videoPath) {
|
if (videoPath) {
|
||||||
sessionStorage.setItem(LAST_MEDIA_PATH_KEY, videoPath);
|
sessionStorage.setItem(LAST_MEDIA_PATH_KEY, videoPath);
|
||||||
return;
|
return;
|
||||||
@ -176,7 +166,6 @@ export default function App() {
|
|||||||
}, [videoPath]);
|
}, [videoPath]);
|
||||||
|
|
||||||
const handleLoadProject = async () => {
|
const handleLoadProject = async () => {
|
||||||
if (!IS_ELECTRON) return;
|
|
||||||
await runGuarded(async () => {
|
await runGuarded(async () => {
|
||||||
try {
|
try {
|
||||||
const projectPath = await window.electronAPI!.openProject();
|
const projectPath = await window.electronAPI!.openProject();
|
||||||
@ -192,7 +181,6 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveProject = async (): Promise<boolean> => {
|
const handleSaveProject = async (): Promise<boolean> => {
|
||||||
if (!IS_ELECTRON) return false;
|
|
||||||
try {
|
try {
|
||||||
const savePath = await window.electronAPI!.saveProject();
|
const savePath = await window.electronAPI!.saveProject();
|
||||||
if (!savePath) return false;
|
if (!savePath) return false;
|
||||||
@ -208,25 +196,15 @@ export default function App() {
|
|||||||
alert(`Failed to save project: ${err}`);
|
alert(`Failed to save project: ${err}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenFile = async () => {
|
const handleOpenFile = async () => {
|
||||||
await runGuarded(async () => {
|
await runGuarded(async () => {
|
||||||
if (IS_ELECTRON) {
|
const path = await window.electronAPI!.openFile();
|
||||||
const path = await window.electronAPI!.openFile();
|
if (path) {
|
||||||
if (path) {
|
setLastSavedSignature(null);
|
||||||
setLastSavedSignature(null);
|
loadVideo(path);
|
||||||
loadVideo(path);
|
await transcribeVideo(path);
|
||||||
await transcribeVideo(path);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Browser: use the manual path input
|
|
||||||
const path = manualPath.trim();
|
|
||||||
if (path) {
|
|
||||||
loadVideo(path);
|
|
||||||
await transcribeVideo(path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -236,27 +214,15 @@ export default function App() {
|
|||||||
useEditorStore.getState().reset();
|
useEditorStore.getState().reset();
|
||||||
setLastSavedSignature(null);
|
setLastSavedSignature(null);
|
||||||
setActivePanel(null);
|
setActivePanel(null);
|
||||||
setManualPath('');
|
|
||||||
setCutMode(false);
|
setCutMode(false);
|
||||||
setMuteMode(false);
|
setMuteMode(false);
|
||||||
setGainMode(false);
|
setGainMode(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleManualSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const path = manualPath.trim();
|
|
||||||
if (!path) return;
|
|
||||||
loadVideo(path);
|
|
||||||
await transcribeVideo(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const transcribeVideo = async (path: string) => {
|
const transcribeVideo = async (path: string) => {
|
||||||
setTranscribing(true, 0, 'Checking model...');
|
setTranscribing(true, 0, 'Checking model...');
|
||||||
try {
|
try {
|
||||||
if (!window.electronAPI?.transcribe) {
|
|
||||||
throw new Error('Transcription not available');
|
|
||||||
}
|
|
||||||
// Step 1: ensure model is downloaded (may take a while on first run)
|
// Step 1: ensure model is downloaded (may take a while on first run)
|
||||||
const MODEL_SIZES: Record<string, string> = {
|
const MODEL_SIZES: Record<string, string> = {
|
||||||
'tiny': '~75 MB',
|
'tiny': '~75 MB',
|
||||||
@ -294,10 +260,6 @@ export default function App() {
|
|||||||
|
|
||||||
const handleReprocessProject = async () => {
|
const handleReprocessProject = async () => {
|
||||||
if (!videoPath) return;
|
if (!videoPath) return;
|
||||||
if (!window.electronAPI?.transcribe) {
|
|
||||||
alert('Reprocessing is only available in desktop mode.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await runGuarded(async () => {
|
await runGuarded(async () => {
|
||||||
setShowReprocessConfirm(true);
|
setShowReprocessConfirm(true);
|
||||||
@ -428,58 +390,22 @@ export default function App() {
|
|||||||
English-only models are ~10% faster and more accurate for English content.
|
English-only models are ~10% faster and more accurate for English content.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{IS_ELECTRON ? (
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<button
|
||||||
<button
|
onClick={handleOpenFile}
|
||||||
onClick={handleOpenFile}
|
className="flex items-center gap-2 px-6 py-3 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-white font-medium transition-colors"
|
||||||
className="flex items-center gap-2 px-6 py-3 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-white font-medium transition-colors"
|
>
|
||||||
>
|
<FolderOpen className="w-5 h-5" />
|
||||||
<FolderOpen className="w-5 h-5" />
|
Open Video File
|
||||||
Open Video File
|
</button>
|
||||||
</button>
|
<button
|
||||||
<button
|
onClick={handleLoadProject}
|
||||||
onClick={handleLoadProject}
|
className="flex items-center gap-2 px-4 py-2 text-sm text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded-lg transition-colors"
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded-lg transition-colors"
|
>
|
||||||
>
|
<FileInput className="w-4 h-4" />
|
||||||
<FileInput className="w-4 h-4" />
|
Load Project (.aive)
|
||||||
Load Project (.aive)
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Browser: manual path input */
|
|
||||||
<div className="w-full max-w-lg space-y-3">
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-editor-warning/10 border border-editor-warning/30 rounded-lg">
|
|
||||||
<span className="text-editor-warning text-xs">
|
|
||||||
Running in browser — paste the full path to your video file below.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleManualSubmit} className="flex gap-2">
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<FolderSearch className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-editor-text-muted pointer-events-none" />
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="text"
|
|
||||||
value={manualPath}
|
|
||||||
onChange={(e) => setManualPath(e.target.value)}
|
|
||||||
placeholder="C:\Videos\my-video.mp4"
|
|
||||||
className="w-full pl-9 pr-3 py-2.5 bg-editor-surface border border-editor-border rounded-lg text-sm text-editor-text placeholder:text-editor-text-muted/40 focus:outline-none focus:border-editor-accent"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!manualPath.trim()}
|
|
||||||
className="flex items-center gap-2 px-5 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm text-white font-medium transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<Film className="w-4 h-4" />
|
|
||||||
Load & Transcribe
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<p className="text-[11px] text-editor-text-muted text-center">
|
|
||||||
Supported: MP4, AVI, MOV, MKV, WebM, M4A
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -508,23 +434,19 @@ export default function App() {
|
|||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<FolderOpen className="w-4 h-4" />}
|
icon={<FolderOpen className="w-4 h-4" />}
|
||||||
label="Open"
|
label="Open"
|
||||||
onClick={IS_ELECTRON ? handleOpenFile : () => useEditorStore.getState().reset()}
|
onClick={handleOpenFile}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={<Save className="w-4 h-4" />}
|
||||||
|
label="Save"
|
||||||
|
onClick={handleSaveProject}
|
||||||
|
disabled={words.length === 0}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={<FileInput className="w-4 h-4" />}
|
||||||
|
label="Load"
|
||||||
|
onClick={handleLoadProject}
|
||||||
/>
|
/>
|
||||||
{IS_ELECTRON && (
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<Save className="w-4 h-4" />}
|
|
||||||
label="Save"
|
|
||||||
onClick={handleSaveProject}
|
|
||||||
disabled={words.length === 0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{IS_ELECTRON && (
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<FileInput className="w-4 h-4" />}
|
|
||||||
label="Load"
|
|
||||||
onClick={handleLoadProject}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<Scissors className="w-4 h-4" />}
|
icon={<Scissors className="w-4 h-4" />}
|
||||||
label="Cut"
|
label="Cut"
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
// Forward console.error/warn/log to backend in dev mode so we can tail webview.log
|
// Forward console.error/warn/log to backend in dev mode so we can tail webview.log
|
||||||
import './lib/dev-logger';
|
import './lib/dev-logger';
|
||||||
// Must be imported before App so window.electronAPI is patched before any component runs.
|
// Tauri bridge polyfill: must be imported before App so window.electronAPI is available to all components
|
||||||
import './lib/tauri-bridge';
|
import './lib/tauri-bridge';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|||||||
@ -30,26 +30,15 @@ async function encryptAndStore(key: string, value: string): Promise<void> {
|
|||||||
localStorage.removeItem(ENCRYPTED_KEY_PREFIX + key);
|
localStorage.removeItem(ENCRYPTED_KEY_PREFIX + key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (window.electronAPI) {
|
const encrypted = await window.electronAPI.encryptString(value);
|
||||||
const encrypted = await window.electronAPI.encryptString(value);
|
localStorage.setItem(ENCRYPTED_KEY_PREFIX + key, encrypted);
|
||||||
localStorage.setItem(ENCRYPTED_KEY_PREFIX + key, encrypted);
|
|
||||||
} else {
|
|
||||||
localStorage.setItem(ENCRYPTED_KEY_PREFIX + key, btoa(value));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAndDecrypt(key: string): Promise<string> {
|
async function loadAndDecrypt(key: string): Promise<string> {
|
||||||
const stored = localStorage.getItem(ENCRYPTED_KEY_PREFIX + key);
|
const stored = localStorage.getItem(ENCRYPTED_KEY_PREFIX + key);
|
||||||
if (!stored) return '';
|
if (!stored) return '';
|
||||||
if (window.electronAPI) {
|
|
||||||
try {
|
|
||||||
return await window.electronAPI.decryptString(stored);
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
return atob(stored);
|
return await window.electronAPI.decryptString(stored);
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
4
frontend/src/vite-env.d.ts
vendored
4
frontend/src/vite-env.d.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ElectronAPI {
|
interface DesktopAPI {
|
||||||
openFile: (options?: Record<string, unknown>) => Promise<string | null>;
|
openFile: (options?: Record<string, unknown>) => Promise<string | null>;
|
||||||
saveFile: (options?: Record<string, unknown>) => Promise<string | null>;
|
saveFile: (options?: Record<string, unknown>) => Promise<string | null>;
|
||||||
openProject: () => Promise<string | null>;
|
openProject: () => Promise<string | null>;
|
||||||
@ -15,5 +15,5 @@ interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
electronAPI?: ElectronAPI;
|
electronAPI: DesktopAPI;
|
||||||
}
|
}
|
||||||
|
|||||||
28
package.json
28
package.json
@ -3,7 +3,6 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "TalkEdit — Open-source AI-powered text-based video editor",
|
"description": "TalkEdit — Open-source AI-powered text-based video editor",
|
||||||
"main": "electron/main.js",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"dev": "cd frontend && npm run dev -- --host",
|
"dev": "cd frontend && npm run dev -- --host",
|
||||||
@ -15,36 +14,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.0",
|
"concurrently": "^9.1.0",
|
||||||
"electron": "^33.2.0",
|
|
||||||
"electron-builder": "^25.1.0",
|
|
||||||
"wait-on": "^8.0.0"
|
"wait-on": "^8.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"python-shell": "^5.0.0"
|
"python-shell": "^5.0.0"
|
||||||
},
|
|
||||||
"build": {
|
|
||||||
"appId": "com.talkedit.app",
|
|
||||||
"productName": "TalkEdit",
|
|
||||||
"files": [
|
|
||||||
"electron/**/*",
|
|
||||||
"frontend/dist/**/*",
|
|
||||||
"backend/**/*",
|
|
||||||
"shared/**/*"
|
|
||||||
],
|
|
||||||
"extraResources": [
|
|
||||||
{
|
|
||||||
"from": "backend",
|
|
||||||
"to": "backend"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"win": {
|
|
||||||
"target": "nsis"
|
|
||||||
},
|
|
||||||
"mac": {
|
|
||||||
"target": "dmg"
|
|
||||||
},
|
|
||||||
"linux": {
|
|
||||||
"target": "AppImage"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user