Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import logging
import os
import shutil
import stat
import tempfile
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, Query, Request, HTTPException
from fastapi import FastAPI, File, Query, Request, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse

Expand Down Expand Up @@ -112,6 +114,15 @@ def iter_file():
)


@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
"""Accept a video/audio file upload and save it to a temp path for transcription."""
suffix = Path(file.filename).suffix.lower() if file.filename else ".tmp"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
shutil.copyfileobj(file.file, tmp)
return {"file_path": tmp.name}


@app.get("/health")
async def health():
return {"status": "ok"}
48 changes: 29 additions & 19 deletions backend/routers/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
import os
from typing import List, Optional

from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, BackgroundTasks, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel

from services.video_editor import export_stream_copy, export_reencode, export_reencode_with_subs
Expand All @@ -30,7 +31,7 @@ class ExportWordModel(BaseModel):

class ExportRequest(BaseModel):
input_path: str
output_path: str
output_path: str = "" # empty = browser mode: stream file back to client
keep_segments: List[SegmentModel]
mode: str = "fast"
resolution: str = "1080p"
Expand Down Expand Up @@ -61,24 +62,31 @@ def _mux_audio(video_path: str, audio_path: str, output_path: str) -> str:


@router.post("/export")
async def export_video(req: ExportRequest):
async def export_video(req: ExportRequest, background_tasks: BackgroundTasks):
try:
segments = [{"start": s.start, "end": s.end} for s in req.keep_segments]

if not segments:
raise HTTPException(status_code=400, detail="No segments to export")

use_stream_copy = req.mode == "fast" and len(segments) == 1
needs_reencode_for_subs = req.captions == "burn-in"
browser_mode = not req.output_path

# In browser mode, write to a temp file and stream it back to the client.
if browser_mode:
suffix = f".{req.format}" if req.format else ".mp4"
tmp_out = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
tmp_out.close()
output_path = tmp_out.name
else:
output_path = req.output_path

# Burn-in captions require re-encode
if needs_reencode_for_subs:
use_stream_copy = req.mode == "fast" and len(segments) == 1
if req.captions == "burn-in":
use_stream_copy = False

words_dicts = [w.model_dump() for w in req.words] if req.words else []
deleted_set = set(req.deleted_indices or [])

# Generate ASS file for burn-in
ass_path = None
if req.captions == "burn-in" and words_dicts:
ass_content = generate_ass(words_dicts, deleted_set)
Expand All @@ -89,11 +97,11 @@ async def export_video(req: ExportRequest):

try:
if use_stream_copy:
output = export_stream_copy(req.input_path, req.output_path, segments)
output = export_stream_copy(req.input_path, output_path, segments)
elif ass_path:
output = export_reencode_with_subs(
req.input_path,
req.output_path,
output_path,
segments,
ass_path,
resolution=req.resolution,
Expand All @@ -102,29 +110,27 @@ async def export_video(req: ExportRequest):
else:
output = export_reencode(
req.input_path,
req.output_path,
output_path,
segments,
resolution=req.resolution,
format_hint=req.format,
)
finally:
if ass_path and os.path.exists(ass_path):
os.unlink(ass_path)
if ass_path:
try:
os.unlink(ass_path)
except FileNotFoundError:
pass

# Audio enhancement: clean, then mux back into the exported video
if req.enhanceAudio:
try:
tmp_dir = tempfile.mkdtemp(prefix="cutscript_audio_")
cleaned_audio = os.path.join(tmp_dir, "cleaned.wav")
clean_audio(output, cleaned_audio)

muxed_path = output + ".muxed.mp4"
_mux_audio(output, cleaned_audio, muxed_path)

os.replace(muxed_path, output)
logger.info(f"Audio enhanced and muxed into {output}")

# Cleanup
try:
os.remove(cleaned_audio)
os.rmdir(tmp_dir)
Expand All @@ -133,7 +139,11 @@ async def export_video(req: ExportRequest):
except Exception as e:
logger.warning(f"Audio enhancement failed (non-fatal): {e}")

# Sidecar SRT: generate and save alongside video
if browser_mode:
suggested = os.path.splitext(os.path.basename(req.input_path))[0] + "_edited" + suffix
background_tasks.add_task(os.unlink, output)
return FileResponse(output, filename=suggested, media_type=f"video/{req.format or 'mp4'}")

srt_path = None
if req.captions == "sidecar" and words_dicts:
srt_content = generate_srt(words_dicts, deleted_set)
Expand Down
9 changes: 9 additions & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,12 @@ ipcMain.handle('fs:writeFile', async (_event, filePath, content) => {
fs.writeFileSync(filePath, content, 'utf-8');
return true;
});

ipcMain.handle('backend:restart', async () => {
if (!pythonBackend) return false;
pythonBackend.stop();
// Brief pause for the OS to release the port before re-spawning
await new Promise((r) => setTimeout(r, 600));
await pythonBackend.start();
return true;
});
1 change: 1 addition & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ contextBridge.exposeInMainWorld('electronAPI', {
decryptString: (encrypted) => ipcRenderer.invoke('safe-storage:decrypt', encrypted),
readFile: (path) => ipcRenderer.invoke('fs:readFile', path),
writeFile: (path, content) => ipcRenderer.invoke('fs:writeFile', path, content),
restartBackend: () => ipcRenderer.invoke('backend:restart'),
});
137 changes: 90 additions & 47 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState } from 'react';
import { useEditorStore } from './store/editorStore';
import VideoPlayer from './components/VideoPlayer';
import TranscriptEditor from './components/TranscriptEditor';
import WaveformTimeline from './components/WaveformTimeline';
import AIPanel from './components/AIPanel';
import ExportDialog from './components/ExportDialog';
import SettingsPanel from './components/SettingsPanel';
import BackendStatusDot from './components/BackendStatusDot';
import { IS_ELECTRON } from './utils/env';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import {
Film,
Expand All @@ -14,12 +16,9 @@ import {
Sparkles,
Download,
Loader2,
FolderSearch,
FileInput,
} from 'lucide-react';

const IS_ELECTRON = !!window.electronAPI;

type Panel = 'ai' | 'settings' | 'export' | null;

export default function App() {
Expand All @@ -33,12 +32,13 @@ export default function App() {
setTranscription,
setTranscribing,
backendUrl,
backendStatus,
setBackendStatus,
} = useEditorStore();

const [activePanel, setActivePanel] = useState<Panel>(null);
const [manualPath, setManualPath] = useState('');
const [whisperModel, setWhisperModel] = useState('base');
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);

useKeyboardShortcuts();

Expand All @@ -48,6 +48,23 @@ export default function App() {
}
}, [setBackendUrl]);

// Health-check polling: 1s when offline, 5s when online
useEffect(() => {
let id: ReturnType<typeof setTimeout>;
const check = async () => {
try {
const res = await fetch(`${backendUrl}/health`);
setBackendStatus(res.ok ? 'online' : 'offline');
} catch {
setBackendStatus('offline');
}
const delay = useEditorStore.getState().backendStatus === 'online' ? 5000 : 1000;
id = setTimeout(check, delay);
};
check();
return () => clearTimeout(id);
}, [backendUrl, setBackendStatus]);

const handleLoadProject = async () => {
if (!IS_ELECTRON) return;
try {
Expand All @@ -70,21 +87,56 @@ export default function App() {
await transcribeVideo(path);
}
} else {
// Browser: use the manual path input
const path = manualPath.trim();
if (path) {
loadVideo(path);
await transcribeVideo(path);
}
await handleBrowserOpenFile();
}
};

const handleManualSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const path = manualPath.trim();
if (!path) return;
loadVideo(path);
await transcribeVideo(path);
const handleBrowserOpenFile = async () => {
let fileHandle: FileSystemFileHandle;
try {
[fileHandle] = await window.showOpenFilePicker({
types: [{
description: 'Video / Audio',
accept: {
'video/*': ['.mp4', '.avi', '.mov', '.mkv', '.webm'],
'audio/*': ['.m4a', '.wav', '.mp3', '.flac'],
},
}],
});
} catch {
return; // user cancelled
}

const file = await fileHandle.getFile();
const formData = new FormData();
formData.append('file', file);

setUploadProgress(0);
try {
const xhr = new XMLHttpRequest();
const uploadPath = await new Promise<string>((resolve, reject) => {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setUploadProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText).file_path);
} else {
reject(new Error(`Upload failed: ${xhr.statusText}`));
}
};
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.open('POST', `${backendUrl}/upload`);
xhr.send(formData);
});
loadVideo(uploadPath);
await transcribeVideo(uploadPath);
} catch (err) {
console.error('Upload error:', err);
alert(`Upload failed: ${err}`);
} finally {
setUploadProgress(null);
}
};

const transcribeVideo = async (path: string) => {
Expand Down Expand Up @@ -154,35 +206,25 @@ export default function App() {
</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 &amp; Transcribe
</button>
</form>
/* Browser: File System Access API */
<div className="flex flex-col items-center gap-3">
<button
onClick={handleBrowserOpenFile}
disabled={uploadProgress !== null}
className="flex items-center gap-2 px-6 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-60 rounded-lg text-white font-medium transition-colors"
>
{uploadProgress !== null ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Uploading… {uploadProgress}%
</>
) : (
<>
<FolderOpen className="w-5 h-5" />
Open Video File
</>
)}
</button>
<p className="text-[11px] text-editor-text-muted text-center">
Supported: MP4, AVI, MOV, MKV, WebM, M4A
</p>
Expand Down Expand Up @@ -228,6 +270,7 @@ export default function App() {
active={activePanel === 'settings'}
onClick={() => togglePanel('settings')}
/>
<BackendStatusDot status={backendStatus} className="ml-2" />
</div>
</header>

Expand Down
Loading