openhands
Add FastAPI backend with subtitle processing
e3306bb
#!/usr/bin/env python3
"""
Burme Subtitle Editor - Backend API
FastAPI application for subtitle processing and file generation
"""
import os
import re
from datetime import datetime
from typing import List, Optional, Dict, Any
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response, JSONResponse
from pydantic import BaseModel
import uvicorn
# Create FastAPI app
app = FastAPI(
title="Burme Subtitle Editor API",
description="Backend API for subtitle processing and SRT file generation",
version="1.0.0"
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ======================
# Data Models
# ======================
class Subtitle(BaseModel):
"""Subtitle model"""
id: Optional[int] = None
text: str
start: int # in milliseconds
end: int # in milliseconds
fontSize: Optional[int] = 24
fontFamily: Optional[str] = "Inter"
textColor: Optional[str] = "#ffffff"
bgColor: Optional[str] = "#000000"
bgOpacity: Optional[int] = 50
position: Optional[str] = "bottom"
class SubtitleProject(BaseModel):
"""Project model"""
name: str
video_filename: Optional[str] = None
subtitles: List[Subtitle] = []
class SRTTime:
"""SRT time formatter"""
@staticmethod
def ms_to_srt_time(ms: int) -> str:
"""Convert milliseconds to SRT time format (00:00:00,000)"""
hours = ms // 3600000
minutes = (ms % 3600000) // 60000
seconds = (ms % 60000) // 1000
milliseconds = ms % 1000
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
@staticmethod
def srt_time_to_ms(time_str: str) -> int:
"""Convert SRT time format to milliseconds"""
match = re.match(r'(\d{2}):(\d{2}):(\d{2}),(\d{3})', time_str)
if not match:
raise ValueError(f"Invalid time format: {time_str}")
hours = int(match.group(1)) * 3600000
minutes = int(match.group(2)) * 60000
seconds = int(match.group(3)) * 1000
milliseconds = int(match.group(4))
return hours + minutes + seconds + milliseconds
# ======================
# API Routes
# ======================
@app.get("/")
async def root():
"""Root endpoint"""
return {
"name": "Burme Subtitle Editor API",
"version": "1.0.0",
"status": "running"
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy"}
@app.post("/api/subtitles/convert")
async def convert_srt_to_json(srt_content: str = Form(...)):
"""
Convert SRT content to JSON format
Args:
srt_content: Raw SRT file content
Returns:
JSON response with parsed subtitles
"""
try:
subtitles = parse_srt(srt_content)
return JSONResponse(content={
"success": True,
"subtitles": [sub.dict() for sub in subtitles]
})
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/subtitles/export")
async def export_to_srt(subtitles: List[Dict[str, Any]]):
"""
Export subtitles to SRT format
Args:
subtitles: List of subtitle dictionaries
Returns:
SRT file content
"""
try:
srt_content = generate_srt(subtitles)
return Response(
content=srt_content,
media_type="text/plain",
headers={"Content-Disposition": "attachment; filename=subtitles.srt"}
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/subtitles/validate")
async def validate_subtitles(subtitles: List[Dict[str, Any]]):
"""
Validate subtitle list for timing conflicts and errors
Args:
subtitles: List of subtitle dictionaries
Returns:
Validation result
"""
issues = []
for i, sub in enumerate(subtitles):
# Check empty text
if not sub.get("text", "").strip():
issues.append({
"index": i,
"type": "empty_text",
"message": f"Subtitle {i + 1} has empty text"
})
# Check invalid timing
start = sub.get("start", 0)
end = sub.get("end", 0)
if end <= start:
issues.append({
"index": i,
"type": "invalid_timing",
"message": f"Subtitle {i + 1}: end time must be after start time"
})
# Check negative values
if start < 0 or end < 0:
issues.append({
"index": i,
"type": "negative_time",
"message": f"Subtitle {i + 1}: timing values cannot be negative"
})
# Check for overlaps with previous subtitle
if i > 0:
prev_sub = subtitles[i - 1]
prev_end = prev_sub.get("end", 0)
if start < prev_end:
issues.append({
"index": i,
"type": "overlap",
"message": f"Subtitle {i + 1} overlaps with subtitle {i}"
})
return {
"valid": len(issues) == 0,
"issues": issues,
"subtitle_count": len(subtitles)
}
@app.post("/api/subtitles/shift")
async def shift_subtitles(subtitles: List[Dict[str, Any]], offset_ms: int = Form(...)):
"""
Shift all subtitle timestamps by an offset
Args:
subtitles: List of subtitle dictionaries
offset_ms: Milliseconds to shift (positive or negative)
Returns:
Shifted subtitles
"""
shifted = []
for sub in subtitles:
new_sub = sub.copy()
new_sub["start"] = max(0, sub.get("start", 0) + offset_ms)
new_sub["end"] = max(0, sub.get("end", 0) + offset_ms)
shifted.append(new_sub)
# Sort by start time
shifted.sort(key=lambda x: x["start"])
return {
"success": True,
"offset_ms": offset_ms,
"subtitles": shifted
}
@app.post("/api/subtitles/scale")
async def scale_subtitles(subtitles: List[Dict[str, Any]], scale_factor: float = Form(1.0)):
"""
Scale subtitle timings by a factor
Args:
subtitles: List of subtitle dictionaries
scale_factor: Factor to scale timings (e.g., 1.5 for slower video)
Returns:
Scaled subtitles
"""
scaled = []
for sub in subtitles:
new_sub = sub.copy()
new_sub["start"] = int(sub.get("start", 0) * scale_factor)
new_sub["end"] = int(sub.get("end", 0) * scale_factor)
scaled.append(new_sub)
# Sort by start time
scaled.sort(key=lambda x: x["start"])
return {
"success": True,
"scale_factor": scale_factor,
"subtitles": scaled
}
# ======================
# Helper Functions
# ======================
def parse_srt(srt_content: str) -> List[Subtitle]:
"""
Parse SRT content to subtitle objects
Args:
srt_content: Raw SRT file content
Returns:
List of Subtitle objects
"""
# Normalize line endings
srt_content = srt_content.replace('\r\n', '\n').replace('\r', '\n')
# Split into blocks
blocks = srt_content.strip().split('\n\n')
subtitles = []
for block in blocks:
lines = block.strip().split('\n')
if len(lines) < 3:
continue
# Skip index line
try:
int(lines[0])
except ValueError:
continue
# Parse timing line
timing_match = re.match(
r'(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})',
lines[1]
)
if not timing_match:
continue
start_ms = SRTTime.srt_time_to_ms(timing_match.group(1))
end_ms = SRTTime.srt_time_to_ms(timing_match.group(2))
# Parse text (remaining lines)
text = '\n'.join(lines[2:])
subtitles.append(Subtitle(
id=len(subtitles) + 1,
text=text,
start=start_ms,
end=end_ms
))
return subtitles
def generate_srt(subtitles: List[Dict[str, Any]]) -> str:
"""
Generate SRT content from subtitle objects
Args:
subtitles: List of subtitle dictionaries
Returns:
SRT formatted string
"""
# Sort by start time
sorted_subs = sorted(subtitles, key=lambda x: x.get("start", 0))
lines = []
for i, sub in enumerate(sorted_subs, 1):
start = SRTTime.ms_to_srt_time(sub.get("start", 0))
end = SRTTime.ms_to_srt_time(sub.get("end", 0))
text = sub.get("text", "")
lines.append(f"{i}")
lines.append(f"{start} --> {end}")
lines.append(f"{text}")
lines.append("") # Empty line between entries
return '\n'.join(lines)
# ======================
# Main Entry Point
# ======================
if __name__ == "__main__":
port = int(os.environ.get("PORT", 7860))
uvicorn.run(app, host="0.0.0.0", port=port)