diff --git a/backend/main.py b/backend/main.py index 7954733..21ab4ba 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 @@ -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"} diff --git a/backend/routers/export.py b/backend/routers/export.py index 0aa623b..37ac426 100644 --- a/backend/routers/export.py +++ b/backend/routers/export.py @@ -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 @@ -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" @@ -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) @@ -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, @@ -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) @@ -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) diff --git a/electron/main.js b/electron/main.js index ac11da6..aed99f8 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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; +}); diff --git a/electron/preload.js b/electron/preload.js index 32d5ac0..d78408a 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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'), }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8542392..74f001f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -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'; @@ -6,6 +6,8 @@ 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, @@ -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() { @@ -33,12 +32,13 @@ export default function App() { setTranscription, setTranscribing, backendUrl, + backendStatus, + setBackendStatus, } = useEditorStore(); const [activePanel, setActivePanel] = useState(null); - const [manualPath, setManualPath] = useState(''); const [whisperModel, setWhisperModel] = useState('base'); - const fileInputRef = useRef(null); + const [uploadProgress, setUploadProgress] = useState(null); useKeyboardShortcuts(); @@ -48,6 +48,23 @@ export default function App() { } }, [setBackendUrl]); + // Health-check polling: 1s when offline, 5s when online + useEffect(() => { + let id: ReturnType; + 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 { @@ -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((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) => { @@ -154,35 +206,25 @@ export default function App() { ) : ( - /* Browser: manual path input */ -
-
- - Running in browser — paste the full path to your video file below. - -
-
-
- - 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 - /> -
- -
+ /* Browser: File System Access API */ +
+

Supported: MP4, AVI, MOV, MKV, WebM, M4A

@@ -228,6 +270,7 @@ export default function App() { active={activePanel === 'settings'} onClick={() => togglePanel('settings')} /> +
diff --git a/frontend/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx index 25f6ff6..d09a5b2 100644 --- a/frontend/src/components/AIPanel.tsx +++ b/frontend/src/components/AIPanel.tsx @@ -1,11 +1,12 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useEditorStore } from '../store/editorStore'; import { useAIStore } from '../store/aiStore'; import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download } from 'lucide-react'; import type { ClipSuggestion } from '../types/project'; +import { exportToFile } from '../utils/exportToFile'; export default function AIPanel() { - const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore(); + const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime, getKeepSegments, deletedRanges } = useEditorStore(); const { defaultProvider, providers, @@ -106,35 +107,47 @@ export default function AIPanel() { [setCurrentTime], ); + // Compute keep-segments for each clip; recomputes when cuts or clip list change. + const clipSegmentMap = useMemo(() => { + const allSegments = getKeepSegments(); + return clipSuggestions.map((clip) => { + const clipped = allSegments + .filter((s) => s.end > clip.startTime && s.start < clip.endTime) + .map((s) => ({ start: Math.max(s.start, clip.startTime), end: Math.min(s.end, clip.endTime) })); + return clipped.length > 0 ? clipped : [{ start: clip.startTime, end: clip.endTime }]; + }); + }, [clipSuggestions, deletedRanges, getKeepSegments]); + const [exportingClipIndex, setExportingClipIndex] = useState(null); + const [exportedClipIndex, setExportedClipIndex] = useState(null); + const [exportClipError, setExportClipError] = useState(null); const handleExportClip = useCallback( - async (clip: ClipSuggestion, index: number) => { + async (clip: ClipSuggestion, index: number, keepSegments: Array<{ start: number; end: number }>) => { if (!videoPath) return; setExportingClipIndex(index); - try { - const safeName = clip.title.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40); - const dirSep = videoPath.lastIndexOf('\\') >= 0 ? '\\' : '/'; - const dir = videoPath.substring(0, videoPath.lastIndexOf(dirSep)); - const outputPath = `${dir}${dirSep}${safeName}_clip.mp4`; + setExportedClipIndex(null); + setExportClipError(null); - const res = await fetch(`${backendUrl}/export`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - input_path: videoPath, - output_path: outputPath, - keep_segments: [{ start: clip.startTime, end: clip.endTime }], - mode: 'fast', - format: 'mp4', - }), + const safeName = clip.title.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40); + const mode = keepSegments.length > 1 ? 'reencode' : 'fast'; + + try { + const saved = await exportToFile({ + backendUrl, + body: { input_path: videoPath, keep_segments: keepSegments, mode, format: 'mp4' }, + suggestedName: `${safeName}_clip.mp4`, + format: 'mp4', + electronDefaultPath: videoPath.replace(/[^/\\]*$/, `${safeName}_clip.mp4`), }); - if (!res.ok) throw new Error('Export failed'); - const data = await res.json(); - alert(`Clip exported to: ${data.output_path}`); + if (saved) { + setExportedClipIndex(index); + setTimeout(() => setExportedClipIndex(null), 3000); + } } catch (err) { console.error(err); - alert('Failed to export clip. Check console for details.'); + setExportClipError('Export failed'); + setTimeout(() => setExportClipError(null), 3000); } finally { setExportingClipIndex(null); } @@ -265,7 +278,10 @@ export default function AIPanel() { {clipSuggestions.length > 0 && (
- {clipSuggestions.map((clip, i) => ( + {clipSuggestions.map((clip, i) => { + const keepSegments = clipSegmentMap[i]; + const hasCuts = keepSegments.length > 1; + return (
{clip.title} @@ -274,6 +290,11 @@ export default function AIPanel() {

{clip.reason}

+ {hasCuts && ( +

+ Contains word-level cuts — will re-encode on export. +

+ )}
- ))} + ); + })} + {exportClipError && ( +

{exportClipError}

+ )}
)}
diff --git a/frontend/src/components/BackendStatusDot.tsx b/frontend/src/components/BackendStatusDot.tsx new file mode 100644 index 0000000..fde3e72 --- /dev/null +++ b/frontend/src/components/BackendStatusDot.tsx @@ -0,0 +1,28 @@ +import type { BackendStatus } from '../types/project'; + +const STATUS_COLORS: Record = { + online: 'bg-green-500', + offline: 'bg-red-500', + checking: 'bg-yellow-500 animate-pulse', +}; + +const STATUS_LABELS: Record = { + online: 'Backend online', + offline: 'Backend offline', + checking: 'Connecting to backend…', +}; + +export default function BackendStatusDot({ + status, + className = '', +}: { + status: BackendStatus; + className?: string; +}) { + return ( +
+ ); +} diff --git a/frontend/src/components/ExportDialog.tsx b/frontend/src/components/ExportDialog.tsx index 5b682da..58250ce 100644 --- a/frontend/src/components/ExportDialog.tsx +++ b/frontend/src/components/ExportDialog.tsx @@ -1,7 +1,9 @@ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback } from 'react'; import { useEditorStore } from '../store/editorStore'; -import { Download, Loader2, Zap, Cog, Info } from 'lucide-react'; +import { Download, Loader2, Zap, Cog, Info, Check } from 'lucide-react'; import type { ExportOptions } from '../types/project'; +import { buildDeletedSet } from '../utils/buildDeletedSet'; +import { exportToFile } from '../utils/exportToFile'; export default function ExportDialog() { const { videoPath, words, deletedRanges, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } = @@ -16,48 +18,47 @@ export default function ExportDialog() { enhanceAudio: false, captions: 'none', }); + const [exportSuccess, setExportSuccess] = useState(false); + const [exportError, setExportError] = useState(''); const handleExport = useCallback(async () => { if (!videoPath) return; - const outputPath = await window.electronAPI?.saveFile({ - defaultPath: videoPath.replace(/\.[^.]+$/, '_edited.mp4'), - filters: [ - { name: 'MP4', extensions: ['mp4'] }, - { name: 'MOV', extensions: ['mov'] }, - { name: 'WebM', extensions: ['webm'] }, - ], - }); - if (!outputPath) return; - - setExporting(true, 0); + setExportSuccess(false); + setExportError(''); + try { const keepSegments = getKeepSegments(); + const deletedSet = buildDeletedSet(deletedRanges); - const deletedSet = new Set(); - for (const range of deletedRanges) { - for (const idx of range.wordIndices) deletedSet.add(idx); - } + const suggestedName = videoPath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') + `_edited.${options.format}`; - const res = await fetch(`${backendUrl}/export`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + const ok = await exportToFile({ + backendUrl, + suggestedName, + format: options.format as 'mp4' | 'mov' | 'webm', + electronDefaultPath: videoPath.replace(/\.[^.]+$/, `_edited.${options.format}`), + body: { input_path: videoPath, - output_path: outputPath, keep_segments: keepSegments, words: options.captions !== 'none' ? words : undefined, deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined, ...options, - }), + }, + onStart: () => setExporting(true, 0), }); - if (!res.ok) throw new Error(`Export failed: ${res.statusText}`); - setExporting(false, 100); + + if (ok) { + setExporting(false, 100); + setExportSuccess(true); + setTimeout(() => setExportSuccess(false), 3000); + } } catch (err) { console.error('Export error:', err); + setExportError(String(err)); setExporting(false); } - }, [videoPath, options, backendUrl, setExporting, getKeepSegments]); + }, [videoPath, options, backendUrl, setExporting, getKeepSegments, deletedRanges, words]); return (
@@ -144,6 +145,11 @@ export default function ExportDialog() { Exporting... {Math.round(exportProgress)}% + ) : exportSuccess ? ( + <> + + Saved! + ) : ( <> @@ -151,6 +157,9 @@ export default function ExportDialog() { )} + {exportError && ( +

{exportError}

+ )} {options.mode === 'fast' && !hasCuts && (

diff --git a/frontend/src/components/PasteTranscriptDialog.tsx b/frontend/src/components/PasteTranscriptDialog.tsx new file mode 100644 index 0000000..6619836 --- /dev/null +++ b/frontend/src/components/PasteTranscriptDialog.tsx @@ -0,0 +1,132 @@ +import { useState, useEffect, useRef } from 'react'; +import { X } from 'lucide-react'; +import { useEditorStore } from '../store/editorStore'; +import { diffTranscript, groupContiguousIndices } from '../utils/diffTranscript'; +import { buildDeletedSet } from '../utils/buildDeletedSet'; + +interface Props { + onClose: () => void; +} + +interface Preview { + deletedCount: number; + rangeCount: number; + totalActive: number; +} + +export default function PasteTranscriptDialog({ onClose }: Props) { + const words = useEditorStore((s) => s.words); + const deletedRanges = useEditorStore((s) => s.deletedRanges); + const applyPastedTranscript = useEditorStore((s) => s.applyPastedTranscript); + + const [pastedText, setPastedText] = useState(''); + const [preview, setPreview] = useState(null); + const debounceRef = useRef | null>(null); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + + if (pastedText.trim() === '') { + setPreview(null); + return; + } + + debounceRef.current = setTimeout(() => { + const alreadyDeletedSet = buildDeletedSet(deletedRanges); + const totalActive = words.length - alreadyDeletedSet.size; + const deletedIndices = diffTranscript(words, pastedText, alreadyDeletedSet); + const rangeCount = groupContiguousIndices(deletedIndices).length; + setPreview({ deletedCount: deletedIndices.length, rangeCount, totalActive }); + }, 300); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [pastedText, words, deletedRanges]); + + const handleApply = () => { + applyPastedTranscript(pastedText); + onClose(); + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + + const allDeleted = preview !== null && preview.deletedCount === preview.totalActive; + const noChanges = preview !== null && preview.deletedCount === 0; + const percent = preview !== null && preview.totalActive > 0 + ? Math.round((preview.deletedCount / preview.totalActive) * 100) + : 0; + + return ( +

+
+
+

Paste Edited Transcript

+ +
+ +

+ Paste your edited transcript below. Deleted words will be cut automatically. + Modified words are ignored — only deletions are detected. +

+ +