Initial CutScript release - Open-source AI-powered text-based video editor
CutScript is a local-first, Descript-like video editor where you edit video by editing text. Delete a word from the transcript and it's cut from the video. Features: - Word-level transcription with WhisperX - Text-based video editing with undo/redo - AI filler word removal (Ollama/OpenAI/Claude) - AI clip creation for shorts - Waveform timeline with virtualized transcript - FFmpeg stream-copy (fast) and re-encode (4K) export - Caption burn-in and sidecar SRT generation - Studio Sound audio enhancement (DeepFilterNet) - Keyboard shortcuts (J/K/L, Space, Delete, Ctrl+Z/S/E) - Encrypted API key storage - Project save/load (.aive files) Architecture: - Electron + React + Tailwind (frontend) - FastAPI + Python (backend) - WhisperX for transcription - FFmpeg for video processing - Multi-provider AI support Performance optimizations: - RAF-throttled time updates - Zustand selectors for granular subscriptions - Dual-canvas waveform rendering - Virtualized transcript with react-virtuoso Built on top of DataAnts-AI/VideoTranscriber, completely rewritten as a desktop application. License: MIT
This commit is contained in:
131
electron/main.js
Normal file
131
electron/main.js
Normal file
@ -0,0 +1,131 @@
|
||||
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;
|
||||
});
|
||||
12
electron/preload.js
Normal file
12
electron/preload.js
Normal file
@ -0,0 +1,12 @@
|
||||
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),
|
||||
});
|
||||
105
electron/python-bridge.js
Normal file
105
electron/python-bridge.js
Normal file
@ -0,0 +1,105 @@
|
||||
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' : 'python3';
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user