openhands commited on
Commit
e3306bb
·
1 Parent(s): cc5a1a1

Add FastAPI backend with subtitle processing

Browse files
Files changed (4) hide show
  1. Dockerfile +25 -0
  2. README.md +32 -5
  3. main.py +361 -0
  4. requirements.txt +4 -0
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use official Python runtime as base image
2
+ FROM python:3.9-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies
8
+ RUN apt-get update && apt-get install -y \
9
+ gcc \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Copy requirements first for better caching
13
+ COPY backend/requirements.txt .
14
+
15
+ # Install Python dependencies
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ # Copy application code
19
+ COPY backend/ .
20
+
21
+ # Expose port 7860 (Hugging Face requirement)
22
+ EXPOSE 7860
23
+
24
+ # Run the application
25
+ CMD ["python", "main.py"]
README.md CHANGED
@@ -1,10 +1,37 @@
1
  ---
2
- title: Burme Subtitle Api
3
- emoji: 🔥
4
- colorFrom: pink
5
- colorTo: pink
6
  sdk: docker
 
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Burme Subtitle Editor API
3
+ emoji: 🎬
4
+ colorFrom: gray
5
+ colorTo: yellow
6
  sdk: docker
7
+ sdk_version: 3.9
8
+ app_port: 7860
9
  pinned: false
10
  ---
11
 
12
+ # Burme Subtitle Editor API
13
+
14
+ FastAPI backend for Burme Subtitle Editor. Deploys to Hugging Face Spaces with Docker.
15
+
16
+ ## Endpoints
17
+
18
+ | Method | Endpoint | Description |
19
+ |:-------|:---------|:-----------|
20
+ | `GET` | `/` | API information |
21
+ | `GET` | `/health` | Health check |
22
+ | `POST` | `/api/subtitles/convert` | SRT → JSON |
23
+ | `POST` | `/api/subtitles/export` | JSON → SRT |
24
+ | `POST` | `/api/subtitles/validate` | Validate subtitles |
25
+ | `POST` | `/api/subtitles/shift` | Shift timestamps |
26
+ | `POST` | `/api/subtitles/scale` | Scale timestamps |
27
+
28
+ ## Docker
29
+
30
+ ```bash
31
+ docker build -t burme-api .
32
+ docker run -p 7860:7860 burme-api
33
+ ```
34
+
35
+ ## Environment
36
+
37
+ - `PORT`: Server port (default: 7860)
main.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Burme Subtitle Editor - Backend API
4
+ FastAPI application for subtitle processing and file generation
5
+ """
6
+
7
+ import os
8
+ import re
9
+ from datetime import datetime
10
+ from typing import List, Optional, Dict, Any
11
+ from fastapi import FastAPI, HTTPException, UploadFile, File, Form
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import Response, JSONResponse
14
+ from pydantic import BaseModel
15
+ import uvicorn
16
+
17
+ # Create FastAPI app
18
+ app = FastAPI(
19
+ title="Burme Subtitle Editor API",
20
+ description="Backend API for subtitle processing and SRT file generation",
21
+ version="1.0.0"
22
+ )
23
+
24
+ # Configure CORS
25
+ app.add_middleware(
26
+ CORSMiddleware,
27
+ allow_origins=["*"],
28
+ allow_credentials=True,
29
+ allow_methods=["*"],
30
+ allow_headers=["*"],
31
+ )
32
+
33
+
34
+ # ======================
35
+ # Data Models
36
+ # ======================
37
+
38
+ class Subtitle(BaseModel):
39
+ """Subtitle model"""
40
+ id: Optional[int] = None
41
+ text: str
42
+ start: int # in milliseconds
43
+ end: int # in milliseconds
44
+ fontSize: Optional[int] = 24
45
+ fontFamily: Optional[str] = "Inter"
46
+ textColor: Optional[str] = "#ffffff"
47
+ bgColor: Optional[str] = "#000000"
48
+ bgOpacity: Optional[int] = 50
49
+ position: Optional[str] = "bottom"
50
+
51
+
52
+ class SubtitleProject(BaseModel):
53
+ """Project model"""
54
+ name: str
55
+ video_filename: Optional[str] = None
56
+ subtitles: List[Subtitle] = []
57
+
58
+
59
+ class SRTTime:
60
+ """SRT time formatter"""
61
+
62
+ @staticmethod
63
+ def ms_to_srt_time(ms: int) -> str:
64
+ """Convert milliseconds to SRT time format (00:00:00,000)"""
65
+ hours = ms // 3600000
66
+ minutes = (ms % 3600000) // 60000
67
+ seconds = (ms % 60000) // 1000
68
+ milliseconds = ms % 1000
69
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
70
+
71
+ @staticmethod
72
+ def srt_time_to_ms(time_str: str) -> int:
73
+ """Convert SRT time format to milliseconds"""
74
+ match = re.match(r'(\d{2}):(\d{2}):(\d{2}),(\d{3})', time_str)
75
+ if not match:
76
+ raise ValueError(f"Invalid time format: {time_str}")
77
+
78
+ hours = int(match.group(1)) * 3600000
79
+ minutes = int(match.group(2)) * 60000
80
+ seconds = int(match.group(3)) * 1000
81
+ milliseconds = int(match.group(4))
82
+
83
+ return hours + minutes + seconds + milliseconds
84
+
85
+
86
+ # ======================
87
+ # API Routes
88
+ # ======================
89
+
90
+ @app.get("/")
91
+ async def root():
92
+ """Root endpoint"""
93
+ return {
94
+ "name": "Burme Subtitle Editor API",
95
+ "version": "1.0.0",
96
+ "status": "running"
97
+ }
98
+
99
+
100
+ @app.get("/health")
101
+ async def health_check():
102
+ """Health check endpoint"""
103
+ return {"status": "healthy"}
104
+
105
+
106
+ @app.post("/api/subtitles/convert")
107
+ async def convert_srt_to_json(srt_content: str = Form(...)):
108
+ """
109
+ Convert SRT content to JSON format
110
+
111
+ Args:
112
+ srt_content: Raw SRT file content
113
+
114
+ Returns:
115
+ JSON response with parsed subtitles
116
+ """
117
+ try:
118
+ subtitles = parse_srt(srt_content)
119
+ return JSONResponse(content={
120
+ "success": True,
121
+ "subtitles": [sub.dict() for sub in subtitles]
122
+ })
123
+ except Exception as e:
124
+ raise HTTPException(status_code=400, detail=str(e))
125
+
126
+
127
+ @app.post("/api/subtitles/export")
128
+ async def export_to_srt(subtitles: List[Dict[str, Any]]):
129
+ """
130
+ Export subtitles to SRT format
131
+
132
+ Args:
133
+ subtitles: List of subtitle dictionaries
134
+
135
+ Returns:
136
+ SRT file content
137
+ """
138
+ try:
139
+ srt_content = generate_srt(subtitles)
140
+ return Response(
141
+ content=srt_content,
142
+ media_type="text/plain",
143
+ headers={"Content-Disposition": "attachment; filename=subtitles.srt"}
144
+ )
145
+ except Exception as e:
146
+ raise HTTPException(status_code=400, detail=str(e))
147
+
148
+
149
+ @app.post("/api/subtitles/validate")
150
+ async def validate_subtitles(subtitles: List[Dict[str, Any]]):
151
+ """
152
+ Validate subtitle list for timing conflicts and errors
153
+
154
+ Args:
155
+ subtitles: List of subtitle dictionaries
156
+
157
+ Returns:
158
+ Validation result
159
+ """
160
+ issues = []
161
+
162
+ for i, sub in enumerate(subtitles):
163
+ # Check empty text
164
+ if not sub.get("text", "").strip():
165
+ issues.append({
166
+ "index": i,
167
+ "type": "empty_text",
168
+ "message": f"Subtitle {i + 1} has empty text"
169
+ })
170
+
171
+ # Check invalid timing
172
+ start = sub.get("start", 0)
173
+ end = sub.get("end", 0)
174
+
175
+ if end <= start:
176
+ issues.append({
177
+ "index": i,
178
+ "type": "invalid_timing",
179
+ "message": f"Subtitle {i + 1}: end time must be after start time"
180
+ })
181
+
182
+ # Check negative values
183
+ if start < 0 or end < 0:
184
+ issues.append({
185
+ "index": i,
186
+ "type": "negative_time",
187
+ "message": f"Subtitle {i + 1}: timing values cannot be negative"
188
+ })
189
+
190
+ # Check for overlaps with previous subtitle
191
+ if i > 0:
192
+ prev_sub = subtitles[i - 1]
193
+ prev_end = prev_sub.get("end", 0)
194
+ if start < prev_end:
195
+ issues.append({
196
+ "index": i,
197
+ "type": "overlap",
198
+ "message": f"Subtitle {i + 1} overlaps with subtitle {i}"
199
+ })
200
+
201
+ return {
202
+ "valid": len(issues) == 0,
203
+ "issues": issues,
204
+ "subtitle_count": len(subtitles)
205
+ }
206
+
207
+
208
+ @app.post("/api/subtitles/shift")
209
+ async def shift_subtitles(subtitles: List[Dict[str, Any]], offset_ms: int = Form(...)):
210
+ """
211
+ Shift all subtitle timestamps by an offset
212
+
213
+ Args:
214
+ subtitles: List of subtitle dictionaries
215
+ offset_ms: Milliseconds to shift (positive or negative)
216
+
217
+ Returns:
218
+ Shifted subtitles
219
+ """
220
+ shifted = []
221
+
222
+ for sub in subtitles:
223
+ new_sub = sub.copy()
224
+ new_sub["start"] = max(0, sub.get("start", 0) + offset_ms)
225
+ new_sub["end"] = max(0, sub.get("end", 0) + offset_ms)
226
+ shifted.append(new_sub)
227
+
228
+ # Sort by start time
229
+ shifted.sort(key=lambda x: x["start"])
230
+
231
+ return {
232
+ "success": True,
233
+ "offset_ms": offset_ms,
234
+ "subtitles": shifted
235
+ }
236
+
237
+
238
+ @app.post("/api/subtitles/scale")
239
+ async def scale_subtitles(subtitles: List[Dict[str, Any]], scale_factor: float = Form(1.0)):
240
+ """
241
+ Scale subtitle timings by a factor
242
+
243
+ Args:
244
+ subtitles: List of subtitle dictionaries
245
+ scale_factor: Factor to scale timings (e.g., 1.5 for slower video)
246
+
247
+ Returns:
248
+ Scaled subtitles
249
+ """
250
+ scaled = []
251
+
252
+ for sub in subtitles:
253
+ new_sub = sub.copy()
254
+ new_sub["start"] = int(sub.get("start", 0) * scale_factor)
255
+ new_sub["end"] = int(sub.get("end", 0) * scale_factor)
256
+ scaled.append(new_sub)
257
+
258
+ # Sort by start time
259
+ scaled.sort(key=lambda x: x["start"])
260
+
261
+ return {
262
+ "success": True,
263
+ "scale_factor": scale_factor,
264
+ "subtitles": scaled
265
+ }
266
+
267
+
268
+ # ======================
269
+ # Helper Functions
270
+ # ======================
271
+
272
+ def parse_srt(srt_content: str) -> List[Subtitle]:
273
+ """
274
+ Parse SRT content to subtitle objects
275
+
276
+ Args:
277
+ srt_content: Raw SRT file content
278
+
279
+ Returns:
280
+ List of Subtitle objects
281
+ """
282
+ # Normalize line endings
283
+ srt_content = srt_content.replace('\r\n', '\n').replace('\r', '\n')
284
+
285
+ # Split into blocks
286
+ blocks = srt_content.strip().split('\n\n')
287
+
288
+ subtitles = []
289
+
290
+ for block in blocks:
291
+ lines = block.strip().split('\n')
292
+
293
+ if len(lines) < 3:
294
+ continue
295
+
296
+ # Skip index line
297
+ try:
298
+ int(lines[0])
299
+ except ValueError:
300
+ continue
301
+
302
+ # Parse timing line
303
+ timing_match = re.match(
304
+ r'(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})',
305
+ lines[1]
306
+ )
307
+
308
+ if not timing_match:
309
+ continue
310
+
311
+ start_ms = SRTTime.srt_time_to_ms(timing_match.group(1))
312
+ end_ms = SRTTime.srt_time_to_ms(timing_match.group(2))
313
+
314
+ # Parse text (remaining lines)
315
+ text = '\n'.join(lines[2:])
316
+
317
+ subtitles.append(Subtitle(
318
+ id=len(subtitles) + 1,
319
+ text=text,
320
+ start=start_ms,
321
+ end=end_ms
322
+ ))
323
+
324
+ return subtitles
325
+
326
+
327
+ def generate_srt(subtitles: List[Dict[str, Any]]) -> str:
328
+ """
329
+ Generate SRT content from subtitle objects
330
+
331
+ Args:
332
+ subtitles: List of subtitle dictionaries
333
+
334
+ Returns:
335
+ SRT formatted string
336
+ """
337
+ # Sort by start time
338
+ sorted_subs = sorted(subtitles, key=lambda x: x.get("start", 0))
339
+
340
+ lines = []
341
+
342
+ for i, sub in enumerate(sorted_subs, 1):
343
+ start = SRTTime.ms_to_srt_time(sub.get("start", 0))
344
+ end = SRTTime.ms_to_srt_time(sub.get("end", 0))
345
+ text = sub.get("text", "")
346
+
347
+ lines.append(f"{i}")
348
+ lines.append(f"{start} --> {end}")
349
+ lines.append(f"{text}")
350
+ lines.append("") # Empty line between entries
351
+
352
+ return '\n'.join(lines)
353
+
354
+
355
+ # ======================
356
+ # Main Entry Point
357
+ # ======================
358
+
359
+ if __name__ == "__main__":
360
+ port = int(os.environ.get("PORT", 7860))
361
+ uvicorn.run(app, host="0.0.0.0", port=port)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi>=0.100.0
2
+ uvicorn>=0.23.0
3
+ python-multipart>=0.0.6
4
+ pydantic>=2.0.0