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'),
});
Loading