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
|
||||
- Product: local-first, AI-powered, text-based audio/video editor.
|
||||
- Primary runtime today: Tauri + React frontend + Python FastAPI backend.
|
||||
- Legacy/transition artifacts may still exist (for example Electron paths/APIs), but default implementation direction is TalkEdit + Tauri.
|
||||
- Primary runtime: Tauri + React frontend + Python FastAPI backend.
|
||||
- Desktop only (Electron has been removed; Tauri is the exclusive desktop runtime).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- 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`.
|
||||
- Media tooling: FFmpeg for edit/export and codec operations.
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
## Known Risk Areas
|
||||
@ -65,6 +65,11 @@ Always update these sections if affected:
|
||||
- `Tech Stack`
|
||||
- `Code Map`
|
||||
- `Run And Build (Preferred)`
|
||||
- `Working Conventions`
|
||||
- `Known Risk Areas`
|
||||
- Recent changes section (if applicable)
|
||||
- `Code Map`
|
||||
- `Run And Build (Preferred)`
|
||||
- `Known Risk Areas`
|
||||
|
||||
If behavior changed significantly, add a short note under a new `Recent Changes` section with:
|
||||
|
||||
30
README.md
30
README.md
@ -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,10 +25,8 @@ An open-source, local-first, Descript-like text-based audio and video editor pow
|
||||
### Install
|
||||
|
||||
```bash
|
||||
# Root dependencies (Electron, concurrently)
|
||||
# Root and frontend dependencies
|
||||
npm install
|
||||
|
||||
# Frontend dependencies (React, Tailwind, Zustand)
|
||||
cd frontend && npm install && cd ..
|
||||
|
||||
# Backend dependencies
|
||||
@ -38,8 +36,8 @@ cd backend && pip install -r requirements.txt && cd ..
|
||||
### Run (Development)
|
||||
|
||||
```bash
|
||||
# Start all three (backend + frontend + electron)
|
||||
npm run dev
|
||||
# Start Tauri dev environment (includes backend + frontend)
|
||||
npm run dev:tauri
|
||||
```
|
||||
|
||||
Or run them separately:
|
||||
@ -48,26 +46,24 @@ Or run them separately:
|
||||
# Terminal 1: Backend
|
||||
cd backend && python -m uvicorn main:app --reload --port 8642
|
||||
|
||||
# Terminal 2: Frontend
|
||||
cd frontend && npm run dev
|
||||
|
||||
# Terminal 3: Electron
|
||||
npx electron .
|
||||
# Terminal 2: Frontend + Tauri
|
||||
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 runtime
|
||||
│ ├── Cargo.toml
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # App entry & backend spawner
|
||||
│ │ └── commands/ # Tauri IPC handlers
|
||||
├── frontend/ # React + Vite + Tailwind
|
||||
│ └── src/
|
||||
│ ├── components/ # VideoPlayer, TranscriptEditor, etc.
|
||||
│ ├── store/ # Zustand state (editorStore, aiStore)
|
||||
│ ├── hooks/ # useVideoSync, useKeyboardShortcuts
|
||||
│ ├── lib/tauri-bridge.ts # Tauri API polyfill
|
||||
│ └── types/ # TypeScript interfaces
|
||||
├── backend/ # FastAPI Python backend
|
||||
│ ├── main.py
|
||||
|
||||
15
close
15
close
@ -1,5 +1,5 @@
|
||||
#!/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
|
||||
|
||||
@ -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)
|
||||
kill_pattern "[/ ]vite([[:space:]]|$)\|[/ ]rsbuild([[:space:]]|$)" "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 ---
|
||||
kill_pattern "uvicorn.*main:app.*--port 8000" "leftover uvicorn workers (TalkEdit)"
|
||||
kill_pattern "uvicorn.*main:app.*--port 8642" "leftover uvicorn workers"
|
||||
|
||||
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
|
||||
|
||||
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 VideoPlayer from './components/VideoPlayer';
|
||||
import TranscriptEditor from './components/TranscriptEditor';
|
||||
@ -16,7 +16,6 @@ import {
|
||||
Settings,
|
||||
Sparkles,
|
||||
Download,
|
||||
FolderSearch,
|
||||
FileInput,
|
||||
Save,
|
||||
Scissors,
|
||||
@ -27,7 +26,6 @@ import {
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
const IS_ELECTRON = !!window.electronAPI;
|
||||
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
||||
|
||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'volume' | null;
|
||||
@ -60,7 +58,6 @@ export default function App() {
|
||||
} = useEditorStore();
|
||||
|
||||
const [activePanel, setActivePanel] = useState<Panel>(null);
|
||||
const [manualPath, setManualPath] = useState('');
|
||||
const [whisperModel, setWhisperModel] = useState('base');
|
||||
const [cutMode, setCutMode] = useState(false);
|
||||
const [muteMode, setMuteMode] = useState(false);
|
||||
@ -70,7 +67,6 @@ export default function App() {
|
||||
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
|
||||
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
|
||||
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const projectSignature = useMemo(() => {
|
||||
if (!videoPath) return null;
|
||||
@ -125,7 +121,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const runGuarded = async (action: () => Promise<void>) => {
|
||||
if (!IS_ELECTRON || !hasUnsavedChanges) {
|
||||
if (!hasUnsavedChanges) {
|
||||
await action();
|
||||
return;
|
||||
}
|
||||
@ -150,16 +146,11 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_ELECTRON) {
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_ELECTRON || videoPath) return;
|
||||
if (videoPath) return;
|
||||
const savedPath = sessionStorage.getItem(LAST_MEDIA_PATH_KEY);
|
||||
if (savedPath) {
|
||||
loadVideo(savedPath);
|
||||
@ -167,7 +158,6 @@ export default function App() {
|
||||
}, [videoPath, loadVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_ELECTRON) return;
|
||||
if (videoPath) {
|
||||
sessionStorage.setItem(LAST_MEDIA_PATH_KEY, videoPath);
|
||||
return;
|
||||
@ -176,7 +166,6 @@ export default function App() {
|
||||
}, [videoPath]);
|
||||
|
||||
const handleLoadProject = async () => {
|
||||
if (!IS_ELECTRON) return;
|
||||
await runGuarded(async () => {
|
||||
try {
|
||||
const projectPath = await window.electronAPI!.openProject();
|
||||
@ -192,7 +181,6 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleSaveProject = async (): Promise<boolean> => {
|
||||
if (!IS_ELECTRON) return false;
|
||||
try {
|
||||
const savePath = await window.electronAPI!.saveProject();
|
||||
if (!savePath) return false;
|
||||
@ -208,26 +196,16 @@ export default function App() {
|
||||
alert(`Failed to save project: ${err}`);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleOpenFile = async () => {
|
||||
await runGuarded(async () => {
|
||||
if (IS_ELECTRON) {
|
||||
const path = await window.electronAPI!.openFile();
|
||||
if (path) {
|
||||
setLastSavedSignature(null);
|
||||
loadVideo(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();
|
||||
setLastSavedSignature(null);
|
||||
setActivePanel(null);
|
||||
setManualPath('');
|
||||
setCutMode(false);
|
||||
setMuteMode(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) => {
|
||||
setTranscribing(true, 0, 'Checking model...');
|
||||
try {
|
||||
if (!window.electronAPI?.transcribe) {
|
||||
throw new Error('Transcription not available');
|
||||
}
|
||||
// Step 1: ensure model is downloaded (may take a while on first run)
|
||||
const MODEL_SIZES: Record<string, string> = {
|
||||
'tiny': '~75 MB',
|
||||
@ -294,10 +260,6 @@ export default function App() {
|
||||
|
||||
const handleReprocessProject = async () => {
|
||||
if (!videoPath) return;
|
||||
if (!window.electronAPI?.transcribe) {
|
||||
alert('Reprocessing is only available in desktop mode.');
|
||||
return;
|
||||
}
|
||||
|
||||
await runGuarded(async () => {
|
||||
setShowReprocessConfirm(true);
|
||||
@ -428,7 +390,6 @@ export default function App() {
|
||||
English-only models are ~10% faster and more accurate for English content.
|
||||
</p>
|
||||
|
||||
{IS_ELECTRON ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<button
|
||||
onClick={handleOpenFile}
|
||||
@ -445,41 +406,6 @@ export default function App() {
|
||||
Load Project (.aive)
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -508,23 +434,19 @@ export default function App() {
|
||||
<ToolbarButton
|
||||
icon={<FolderOpen className="w-4 h-4" />}
|
||||
label="Open"
|
||||
onClick={IS_ELECTRON ? handleOpenFile : () => useEditorStore.getState().reset()}
|
||||
onClick={handleOpenFile}
|
||||
/>
|
||||
{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
|
||||
icon={<Scissors className="w-4 h-4" />}
|
||||
label="Cut"
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
// Forward console.error/warn/log to backend in dev mode so we can tail webview.log
|
||||
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 App from './App';
|
||||
import './index.css';
|
||||
|
||||
@ -30,30 +30,19 @@ async function encryptAndStore(key: string, value: string): Promise<void> {
|
||||
localStorage.removeItem(ENCRYPTED_KEY_PREFIX + key);
|
||||
return;
|
||||
}
|
||||
if (window.electronAPI) {
|
||||
const encrypted = await window.electronAPI.encryptString(value);
|
||||
localStorage.setItem(ENCRYPTED_KEY_PREFIX + key, encrypted);
|
||||
} else {
|
||||
localStorage.setItem(ENCRYPTED_KEY_PREFIX + key, btoa(value));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndDecrypt(key: string): Promise<string> {
|
||||
const stored = localStorage.getItem(ENCRYPTED_KEY_PREFIX + key);
|
||||
if (!stored) return '';
|
||||
if (window.electronAPI) {
|
||||
try {
|
||||
return await window.electronAPI.decryptString(stored);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
try {
|
||||
return atob(stored);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export const useAIStore = create<AIState & AIActions>()(
|
||||
persist(
|
||||
|
||||
4
frontend/src/vite-env.d.ts
vendored
4
frontend/src/vite-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ElectronAPI {
|
||||
interface DesktopAPI {
|
||||
openFile: (options?: Record<string, unknown>) => Promise<string | null>;
|
||||
saveFile: (options?: Record<string, unknown>) => Promise<string | null>;
|
||||
openProject: () => Promise<string | null>;
|
||||
@ -15,5 +15,5 @@ interface ElectronAPI {
|
||||
}
|
||||
|
||||
interface Window {
|
||||
electronAPI?: ElectronAPI;
|
||||
electronAPI: DesktopAPI;
|
||||
}
|
||||
|
||||
28
package.json
28
package.json
@ -3,7 +3,6 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "TalkEdit — Open-source AI-powered text-based video editor",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
"dev": "cd frontend && npm run dev -- --host",
|
||||
@ -15,36 +14,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.0",
|
||||
"electron": "^33.2.0",
|
||||
"electron-builder": "^25.1.0",
|
||||
"wait-on": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"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