Spaces:
Sleeping
Sleeping
| #!/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""" | |
| 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}" | |
| 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 | |
| # ====================== | |
| async def root(): | |
| """Root endpoint""" | |
| return { | |
| "name": "Burme Subtitle Editor API", | |
| "version": "1.0.0", | |
| "status": "running" | |
| } | |
| async def health_check(): | |
| """Health check endpoint""" | |
| return {"status": "healthy"} | |
| 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)) | |
| 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)) | |
| 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) | |
| } | |
| 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 | |
| } | |
| 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) |