Files
TalkEdit/backend/main.py
2026-04-08 01:42:00 -06:00

214 lines
6.7 KiB
Python

import logging
import os
import stat
import sys
import tempfile
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Query, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, FileResponse
import ffmpeg
from routers import transcribe, export, ai, captions, audio
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Dev log file — keep outside workspace to avoid dev watcher reload loops.
DEV_LOG_PATH = Path(
os.environ.get("TALKEDIT_DEV_LOG_PATH", str(Path(tempfile.gettempdir()) / "talkedit-webview.log"))
)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("AI Video Editor backend starting up")
yield
logger.info("AI Video Editor backend shutting down")
app = FastAPI(
title="AI Video Editor Backend",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["Content-Range", "Accept-Ranges", "Content-Length"],
)
app.include_router(transcribe.router)
app.include_router(export.router)
app.include_router(ai.router)
app.include_router(captions.router)
app.include_router(audio.router)
MIME_MAP = {
".mp4": "video/mp4",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".webm": "video/webm",
".m4a": "audio/mp4",
".wav": "audio/wav",
".mp3": "audio/mpeg",
".flac": "audio/flac",
}
@app.get("/file")
async def serve_local_file(request: Request, path: str = Query(...), format: str = Query(None)):
"""Stream a local file with HTTP Range support (required for video seeking).
Optionally transcode audio files to MP3 for better browser compatibility."""
file_path = Path(path)
if not file_path.is_file():
logger.warning(f"[serve_file] File not found: {path}")
raise HTTPException(status_code=404, detail=f"File not found: {path}")
file_size = file_path.stat().st_size
original_ext = file_path.suffix.lower()
# Check if we should transcode this file
should_transcode = (
original_ext == '.wav' and
(format == 'mp3' or file_size > 10 * 1024 * 1024) # Transcode WAV if > 10MB or explicitly requested
)
if should_transcode:
logger.info(f"[serve_file] Transcoding {file_path.name} to MP3 (size: {file_size})")
# Create cache directory
cache_dir = Path(__file__).parent / "cache"
cache_dir.mkdir(exist_ok=True)
# Create cache filename
import hashlib
file_hash = hashlib.md5(str(file_path).encode()).hexdigest()
cache_path = cache_dir / f"{file_hash}.mp3"
# Check if cached version exists
if not cache_path.exists():
logger.info(f"[serve_file] Creating cached MP3: {cache_path}")
try:
# Transcode to MP3 using ffmpeg
(
ffmpeg
.input(str(file_path))
.output(str(cache_path), acodec='libmp3lame', ab='128k')
.run(overwrite_output=True, quiet=True)
)
except ffmpeg.Error as e:
logger.error(f"[serve_file] Transcoding failed: {e}")
# Fall back to original file
cache_path = file_path
else:
logger.info(f"[serve_file] Transcoding completed: {cache_path}")
else:
logger.info(f"[serve_file] Using cached MP3: {cache_path}")
# Use the transcoded file
file_path = cache_path
file_size = file_path.stat().st_size
content_type = "audio/mpeg"
else:
content_type = MIME_MAP.get(original_ext, "application/octet-stream")
range_header = request.headers.get("range")
logger.info(
f"[serve_file] Serving {file_path.name} | size={file_size} | "
f"type={content_type} | range={range_header or 'none'} | transcoded={should_transcode}"
)
if content_type == "application/octet-stream":
logger.warning(
f"[serve_file] Unknown MIME type for extension '{file_path.suffix}'"
f"browser may fail to decode audio/video for '{file_path.name}'"
)
if file_size == 0:
logger.error(f"[serve_file] File is empty: {path}")
raise HTTPException(status_code=422, detail=f"File is empty: {path}")
if range_header:
try:
range_spec = range_header.replace("bytes=", "")
range_start_str, range_end_str = range_spec.split("-")
range_start = int(range_start_str) if range_start_str else 0
range_end = int(range_end_str) if range_end_str else file_size - 1
range_end = min(range_end, file_size - 1)
except (ValueError, TypeError) as e:
logger.error(f"[serve_file] Malformed Range header '{range_header}': {e}")
raise HTTPException(status_code=416, detail=f"Invalid Range header: {range_header}")
content_length = range_end - range_start + 1
def iter_range():
with open(file_path, "rb") as f:
f.seek(range_start)
remaining = content_length
while remaining > 0:
chunk = f.read(min(65536, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
return StreamingResponse(
iter_range(),
status_code=206,
media_type=content_type,
headers={
"Content-Range": f"bytes {range_start}-{range_end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(content_length),
},
)
def iter_file():
with open(file_path, "rb") as f:
while chunk := f.read(65536):
yield chunk
return StreamingResponse(
iter_file(),
media_type=content_type,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
},
)
@app.get("/health")
async def health():
return {"status": "ok"}
import datetime
@app.post("/dev/log")
async def dev_log(request: Request):
data = await request.json()
level = data.get("level", "log")
msg = str(data.get("message", ""))
args = [str(a) for a in data.get("args", [])]
ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
line = f"[{ts}] [{level.upper():5}] {msg}"
if args:
line += " " + " ".join(args)
line += "\n"
DEV_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(DEV_LOG_PATH, "a") as f:
f.write(line)
return {"ok": True, "path": str(DEV_LOG_PATH)}