factorstudios commited on
Commit
de435e7
·
verified ·
1 Parent(s): 121db3b

Update server.py

Browse files
Files changed (1) hide show
  1. server.py +155 -148
server.py CHANGED
@@ -47,6 +47,7 @@ HOOKS_FOLDER = "hooks"
47
  READY_VIDEOS_FOLDER = "ready_videos"
48
  TRANSCRIPTION_FOLDER = "transcriptions"
49
 
 
50
  def timestamp_to_seconds(timestamp: str) -> float:
51
  """Convert HH:MM:SS to seconds."""
52
  try:
@@ -59,99 +60,90 @@ def timestamp_to_seconds(timestamp: str) -> float:
59
  print(f"Error converting timestamp {timestamp}: {e}")
60
  return 0.0
61
 
 
62
  def extract_captions_for_segment(transcript_content: str, start_time: str, end_time: str) -> List[tuple]:
63
  """Extract captions from transcript that fall within segment timeframe.
64
- Returns list of (timestamp, text) tuples."""
65
  captions = []
66
  start_seconds = timestamp_to_seconds(start_time)
67
  end_seconds = timestamp_to_seconds(end_time)
68
-
69
- # Parse transcript lines in format: [HH:MM:SS] text
70
  lines = transcript_content.strip().split('\n')
71
  for line in lines:
72
  match = re.match(r'\[(\d{2}):(\d{2}):(\d{2})\]\s+(.*)', line)
73
  if match:
74
  h, m, s, text = match.groups()
75
  line_seconds = int(h) * 3600 + int(m) * 60 + int(s)
76
-
77
  if start_seconds <= line_seconds <= end_seconds:
78
  relative_time = line_seconds - start_seconds
79
  captions.append((relative_time, text.strip()))
80
-
81
  return captions
82
 
 
83
  def apply_color_grading_wedding_retro(frame: np.ndarray) -> np.ndarray:
84
  """Apply cinematic wedding LUT + retro style with high sharpening."""
85
- # Convert BGR to LAB for better color manipulation
86
  lab = cv2.cvtColor(frame, cv2.COLOR_BGR2LAB)
87
-
88
- # Split LAB channels
89
  l_channel, a_channel, b_channel = cv2.split(lab)
90
-
91
- # 1. VINTAGE/RETRO EFFECT: Add warm tones
92
- # Increase yellows and reduce blues (warm vintage look)
93
- a_channel = cv2.add(a_channel, 5) # Shift towards magenta/red slightly
94
- b_channel = cv2.add(b_channel, 8) # Shift towards yellow/warm
95
-
96
- # 2. WEDDING LOOK: Soft highlights, skin tone enhancement
97
- # Boost highlights on L channel
98
  clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
99
  l_channel = clahe.apply(l_channel)
100
-
101
- # Merge back
102
  lab_enhanced = cv2.merge([l_channel, a_channel, b_channel])
103
  frame = cv2.cvtColor(lab_enhanced, cv2.COLOR_LAB2BGR)
104
-
105
- # 3. SATURATION BOOST (wedding cinematics are vibrant)
106
  hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV).astype(np.float32)
107
- hsv[:, :, 1] = hsv[:, :, 1] * 1.3 # Boost saturation by 30%
108
- hsv[:, :, 1] = np.clip(hsv[:, :, 1], 0, 255)
109
  frame = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR)
110
-
111
- # 4. CONTRAST ENHANCEMENT (cinematic look)
112
  frame = cv2.convertScaleAbs(frame, alpha=1.15, beta=10)
113
-
114
- # 5. HIGH SHARPENING (professional quality)
115
  kernel = np.array([[-1, -1, -1],
116
  [-1, 9, -1],
117
  [-1, -1, -1]]) / 1.2
118
  sharpened = cv2.filter2D(frame, -1, kernel)
119
- # Blend original with sharpened for natural look
120
  frame = cv2.addWeighted(frame, 0.4, sharpened, 0.6, 0)
121
-
122
- # 6. SLIGHT VIGNETTE (cinematic framing)
123
  rows, cols = frame.shape[:2]
124
- X_resultant_kernel = cv2.getGaussianKernel(cols, cols/2)
125
- Y_resultant_kernel = cv2.getGaussianKernel(rows, rows/2)
126
- kernel = Y_resultant_kernel * X_resultant_kernel.T
127
- mask = kernel / kernel.max()
128
- mask = mask ** 0.4 # Adjust intensity
129
-
130
- for i in range(3): # Apply to each channel
131
  frame[:, :, i] = frame[:, :, i] * mask
132
-
133
  return np.clip(frame, 0, 255).astype(np.uint8)
134
 
 
135
  def burn_captions_to_frame(frame: np.ndarray, text: str, font_size: int = 32) -> np.ndarray:
136
  """Burn caption text onto frame with semi-transparent background (centered)."""
137
  height, width = frame.shape[:2]
138
-
139
- # Convert frame to PIL for easier text rendering
140
  frame_pil = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
141
  draw = ImageDraw.Draw(frame_pil, 'RGBA')
142
-
143
- # Try to use a nice font, fall back to default
144
  try:
145
  font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
146
- except:
147
  font = ImageFont.load_default()
148
-
149
- # Wrap text for width
150
  max_width = width - 60
151
  wrapped_lines = []
152
  words = text.split()
153
  current_line = []
154
-
155
  for word in words:
156
  test_line = ' '.join(current_line + [word])
157
  bbox = draw.textbbox((0, 0), test_line, font=font)
@@ -163,24 +155,22 @@ def burn_captions_to_frame(frame: np.ndarray, text: str, font_size: int = 32) ->
163
  current_line.append(word)
164
  if current_line:
165
  wrapped_lines.append(' '.join(current_line))
166
-
167
- # Calculate dimensions for background
168
  line_height = font_size + 10
169
  text_height = len(wrapped_lines) * line_height + 20
170
  bg_y_start = max(height // 2 - text_height // 2 - 10, 20)
171
  bg_y_end = min(bg_y_start + text_height, height - 20)
172
-
173
- # Draw semi-transparent background
174
  overlay = Image.new('RGBA', frame_pil.size, (0, 0, 0, 0))
175
  overlay_draw = ImageDraw.Draw(overlay, 'RGBA')
176
  overlay_draw.rectangle(
177
  [(20, bg_y_start), (width - 20, bg_y_end)],
178
- fill=(0, 0, 0, 180) # Semi-transparent black
179
  )
180
  frame_pil = Image.alpha_composite(frame_pil.convert('RGBA'), overlay).convert('RGB')
181
  draw = ImageDraw.Draw(frame_pil)
182
-
183
- # Draw text centered
184
  y_position = bg_y_start + 10
185
  for line in wrapped_lines:
186
  bbox = draw.textbbox((0, 0), line, font=font)
@@ -188,10 +178,9 @@ def burn_captions_to_frame(frame: np.ndarray, text: str, font_size: int = 32) ->
188
  x_position = (width - line_width) // 2
189
  draw.text((x_position, y_position), line, font=font, fill=(255, 255, 255, 255))
190
  y_position += line_height
191
-
192
- # Convert back to OpenCV format
193
- frame = cv2.cvtColor(np.array(frame_pil), cv2.COLOR_RGB2BGR)
194
- return frame
195
 
196
  def process_video_segment(
197
  video_path: str,
@@ -202,104 +191,122 @@ def process_video_segment(
202
  target_width: int = 1080,
203
  target_height: int = 1350
204
  ) -> bool:
205
- """Process video segment: resize, cut, add captions, apply color grading."""
 
206
  try:
207
  print(f"Opening video: {video_path}")
208
  cap = cv2.VideoCapture(video_path)
209
-
210
  if not cap.isOpened():
211
  print(f"Error: Could not open video {video_path}")
212
  return False
213
-
214
- # Get video properties
215
  fps = cap.get(cv2.CAP_PROP_FPS)
216
- frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
217
  original_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
218
  original_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
219
-
220
  start_seconds = timestamp_to_seconds(start_time)
221
  end_seconds = timestamp_to_seconds(end_time)
222
  duration = end_seconds - start_seconds
223
-
224
  print(f"Video info: {fps} fps, {original_width}x{original_height}")
225
- print(f"Extracting segment: {start_time} to {end_time} ({duration} seconds)")
226
-
227
- # Setup video writer
228
- fourcc = cv2.VideoWriter_fourcc(*'mp4v')
229
- out = cv2.VideoWriter(output_path, fourcc, fps, (target_width, target_height))
230
-
231
- if not out.isOpened():
232
- print(f"Error: Could not create video writer for {output_path}")
233
- return False
234
-
235
- # Seek to start time
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  start_frame = int(start_seconds * fps)
237
  cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
238
-
239
- # Create a mapping of frame numbers to captions
240
  caption_map = {}
241
  for rel_time, caption_text in captions:
242
  frame_num = int(rel_time * fps)
243
  caption_map[frame_num] = caption_text
244
-
245
  current_caption = ""
246
  processed_frames = 0
247
  target_frames = int(duration * fps)
248
-
249
  print(f"Processing {target_frames} frames...")
250
-
251
  while processed_frames < target_frames:
252
  ret, frame = cap.read()
253
  if not ret:
254
  print(f"Warning: Could not read frame at position {processed_frames}")
255
  break
256
-
257
- # Resize frame to target aspect ratio
258
- # Calculate dimensions maintaining aspect ratio
259
  aspect_ratio = target_width / target_height
260
  if original_width / original_height > aspect_ratio:
261
- # Width is too large
262
- new_height = original_height
263
- new_width = int(new_height * aspect_ratio)
264
  x_offset = (original_width - new_width) // 2
265
  frame = frame[:, x_offset:x_offset + new_width]
266
  else:
267
- # Height is too large
268
- new_width = original_width
269
- new_height = int(new_width / aspect_ratio)
270
  y_offset = (original_height - new_height) // 2
271
  frame = frame[y_offset:y_offset + new_height, :]
272
-
273
  frame = cv2.resize(frame, (target_width, target_height), interpolation=cv2.INTER_LANCZOS4)
274
-
275
- # Apply color grading
276
  frame = apply_color_grading_wedding_retro(frame)
277
-
278
- # Update caption if needed
279
  if processed_frames in caption_map:
280
  current_caption = caption_map[processed_frames]
281
-
282
- # Burn caption
283
  if current_caption:
284
  frame = burn_captions_to_frame(frame, current_caption)
285
-
286
- out.write(frame)
287
  processed_frames += 1
288
-
289
  if processed_frames % max(1, target_frames // 10) == 0:
290
  progress = (processed_frames / target_frames) * 100
291
  print(f"Progress: {progress:.1f}%")
292
-
 
 
293
  cap.release()
294
- out.release()
295
-
 
 
 
296
  print(f"✓ Video segment saved: {output_path}")
297
  return True
298
-
299
  except Exception as e:
300
  print(f"✗ Error processing video segment: {e}")
 
 
 
 
 
 
301
  return False
302
 
 
303
  async def process_movie_segments(movie_name: str) -> bool:
304
  """Process all segments for a movie."""
305
  try:
@@ -307,11 +314,11 @@ async def process_movie_segments(movie_name: str) -> bool:
307
  print(f"\n{'='*80}")
308
  print(f"Processing movie: {movie_name}")
309
  print(f"{'='*80}")
310
-
311
  # Download transcript
312
  transcript_file = f"{TRANSCRIPTION_FOLDER}/{movie_name}.transcript.txt"
313
  print(f"Downloading transcript: {transcript_file}")
314
-
315
  try:
316
  transcript_path = hf_hub_download(
317
  repo_id=HF_DATASET_REPO,
@@ -325,11 +332,11 @@ async def process_movie_segments(movie_name: str) -> bool:
325
  except Exception as e:
326
  print(f"Warning: Could not download transcript: {e}")
327
  transcript_content = ""
328
-
329
  # Download original video
330
  video_file = f"{movie_name}.mkv"
331
  print(f"Downloading video: {video_file}")
332
-
333
  try:
334
  video_path = hf_hub_download(
335
  repo_id=HF_DATASET_REPO,
@@ -338,41 +345,38 @@ async def process_movie_segments(movie_name: str) -> bool:
338
  token=HF_TOKEN,
339
  cache_dir="/tmp/video_processor_cache"
340
  )
341
- # Resolve symlink if needed
342
  if os.path.islink(video_path):
343
  video_path = os.path.realpath(video_path)
344
  except Exception as e:
345
  print(f"Error: Could not download video: {e}")
346
  return False
347
-
348
  # List segment JSON files
349
  hooks_folder = f"{HOOKS_FOLDER}/{movie_name}"
350
  print(f"Listing segments from: {hooks_folder}")
351
-
352
  files = list_repo_files(
353
  repo_id=HF_DATASET_REPO,
354
  repo_type="dataset",
355
  token=HF_TOKEN
356
  )
357
-
358
  segment_files = sorted([
359
  f for f in files
360
  if f.startswith(f"{hooks_folder}/") and f.endswith(".json")
361
  ])
362
-
363
  if not segment_files:
364
  print(f"No segment JSON files found for {movie_name}")
365
  return False
366
-
367
  print(f"Found {len(segment_files)} segments")
368
-
369
- # Process each segment
370
  temp_dir = tempfile.mkdtemp()
371
-
372
  try:
373
  for segment_file in segment_files:
374
  try:
375
- # Download segment JSON
376
  segment_path = hf_hub_download(
377
  repo_id=HF_DATASET_REPO,
378
  filename=segment_file,
@@ -380,24 +384,22 @@ async def process_movie_segments(movie_name: str) -> bool:
380
  token=HF_TOKEN,
381
  cache_dir="/tmp/video_processor_cache"
382
  )
383
-
384
  with open(segment_path, 'r', encoding='utf-8') as f:
385
  segment_data = json.load(f)
386
-
387
  segment_number = segment_data.get("segment_number", 1)
388
  start_time = segment_data.get("start_time", "00:00:00")
389
  end_time = segment_data.get("end_time", "00:10:00")
390
-
391
  print(f"\nProcessing segment {segment_number}: {start_time} to {end_time}")
392
-
393
- # Extract captions for this segment
394
  captions = extract_captions_for_segment(transcript_content, start_time, end_time)
395
  print(f"Found {len(captions)} caption lines for this segment")
396
-
397
- # Process video
398
  output_filename = f"segment-{segment_number:02d}.mp4"
399
  output_path = os.path.join(temp_dir, output_filename)
400
-
401
  success = process_video_segment(
402
  video_path,
403
  output_path,
@@ -405,15 +407,14 @@ async def process_movie_segments(movie_name: str) -> bool:
405
  end_time,
406
  captions
407
  )
408
-
409
  if not success:
410
  print(f"Failed to process segment {segment_number}")
411
  continue
412
-
413
- # Upload to dataset
414
  upload_path = f"{READY_VIDEOS_FOLDER}/{movie_name}/{output_filename}"
415
  print(f"Uploading to: {upload_path}")
416
-
417
  upload_file(
418
  path_or_fileobj=output_path,
419
  path_in_repo=upload_path,
@@ -423,78 +424,81 @@ async def process_movie_segments(movie_name: str) -> bool:
423
  commit_message=f"Add processed video segment {segment_number} for {movie_name}"
424
  )
425
  print(f"✓ Segment {segment_number} uploaded successfully")
426
-
427
  except Exception as e:
428
  print(f"✗ Error processing segment: {e}")
429
  processing_state["error_count"] += 1
430
  continue
431
-
432
  finally:
433
  import shutil
434
  shutil.rmtree(temp_dir, ignore_errors=True)
435
-
436
  processing_state["processed_files"].append(movie_name)
437
  processing_state["total_processed"] += 1
438
  print(f"\n✓ Successfully processed all segments for {movie_name}")
439
  return True
440
-
441
  except Exception as e:
442
  processing_state["error_count"] += 1
443
  processing_state["last_error"] = str(e)
444
  print(f"✗ Error: {e}")
445
  return False
446
 
 
447
  async def scan_and_process_videos():
448
  """Scan hooks folder and process all movies."""
449
  if processing_state["is_running"]:
450
  print("Video processing already running, skipping...")
451
  return
452
-
 
 
 
453
  processing_state["is_running"] = True
454
  print("\n" + "="*80)
455
  print("STARTING VIDEO PROCESSING SERVICE")
456
  print("="*80)
457
-
458
  try:
459
  files = list_repo_files(
460
  repo_id=HF_DATASET_REPO,
461
  repo_type="dataset",
462
  token=HF_TOKEN
463
  )
464
-
465
- # Find all movie folders in hooks/
466
  movie_folders = set()
467
  for f in files:
468
  if f.startswith(f"{HOOKS_FOLDER}/") and f.endswith(".json"):
469
- # Extract movie name
470
  parts = f.split("/")
471
  if len(parts) >= 2:
472
- movie_name = parts[1]
473
- movie_folders.add(movie_name)
474
-
475
  print(f"Found {len(movie_folders)} movies to process")
476
-
477
  for movie_name in sorted(movie_folders):
478
  await process_movie_segments(movie_name)
479
  await asyncio.sleep(2)
480
-
481
  print("\n" + "="*80)
482
  print("VIDEO PROCESSING COMPLETE")
483
  print(f"Processed: {processing_state['total_processed']}")
484
  print(f"Errors: {processing_state['error_count']}")
485
  print("="*80 + "\n")
486
-
487
  except Exception as e:
488
  print(f"Critical error: {e}")
489
  processing_state["last_error"] = str(e)
490
  finally:
491
  processing_state["is_running"] = False
492
 
 
493
  @app.on_event("startup")
494
  async def startup_event():
495
  """Start video processing on server startup."""
496
  asyncio.create_task(scan_and_process_videos())
497
 
 
498
  @app.get("/")
499
  async def health():
500
  """Health check endpoint."""
@@ -509,6 +513,7 @@ async def health():
509
  "processed_files": processing_state["processed_files"]
510
  })
511
 
 
512
  @app.get("/status")
513
  async def get_status():
514
  """Get current processing status."""
@@ -521,22 +526,24 @@ async def get_status():
521
  "processed_files": processing_state["processed_files"]
522
  })
523
 
 
524
  @app.post("/trigger-processing")
525
  async def trigger_processing():
526
- """Manually trigger video processing."""
527
  if processing_state["is_running"]:
528
  return JSONResponse({
529
  "status": "already_running",
530
  "message": "Video processing is already in progress"
531
  })
532
-
533
  asyncio.create_task(scan_and_process_videos())
534
  return JSONResponse({
535
  "status": "started",
536
  "message": "Video processing scan started"
537
  })
538
 
 
539
  if __name__ == "__main__":
540
- print("Starting Video Processing Service on port 7862...")
541
- print("Will automatically scan and process videos on startup")
542
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
47
  READY_VIDEOS_FOLDER = "ready_videos"
48
  TRANSCRIPTION_FOLDER = "transcriptions"
49
 
50
+
51
  def timestamp_to_seconds(timestamp: str) -> float:
52
  """Convert HH:MM:SS to seconds."""
53
  try:
 
60
  print(f"Error converting timestamp {timestamp}: {e}")
61
  return 0.0
62
 
63
+
64
  def extract_captions_for_segment(transcript_content: str, start_time: str, end_time: str) -> List[tuple]:
65
  """Extract captions from transcript that fall within segment timeframe.
66
+ Returns list of (relative_seconds, text) tuples."""
67
  captions = []
68
  start_seconds = timestamp_to_seconds(start_time)
69
  end_seconds = timestamp_to_seconds(end_time)
70
+
 
71
  lines = transcript_content.strip().split('\n')
72
  for line in lines:
73
  match = re.match(r'\[(\d{2}):(\d{2}):(\d{2})\]\s+(.*)', line)
74
  if match:
75
  h, m, s, text = match.groups()
76
  line_seconds = int(h) * 3600 + int(m) * 60 + int(s)
77
+
78
  if start_seconds <= line_seconds <= end_seconds:
79
  relative_time = line_seconds - start_seconds
80
  captions.append((relative_time, text.strip()))
81
+
82
  return captions
83
 
84
+
85
  def apply_color_grading_wedding_retro(frame: np.ndarray) -> np.ndarray:
86
  """Apply cinematic wedding LUT + retro style with high sharpening."""
 
87
  lab = cv2.cvtColor(frame, cv2.COLOR_BGR2LAB)
 
 
88
  l_channel, a_channel, b_channel = cv2.split(lab)
89
+
90
+ # 1. VINTAGE/RETRO EFFECT: warm tones
91
+ a_channel = cv2.add(a_channel, 5)
92
+ b_channel = cv2.add(b_channel, 8)
93
+
94
+ # 2. WEDDING LOOK: soft highlights via CLAHE
 
 
95
  clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
96
  l_channel = clahe.apply(l_channel)
97
+
 
98
  lab_enhanced = cv2.merge([l_channel, a_channel, b_channel])
99
  frame = cv2.cvtColor(lab_enhanced, cv2.COLOR_LAB2BGR)
100
+
101
+ # 3. SATURATION BOOST
102
  hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV).astype(np.float32)
103
+ hsv[:, :, 1] = np.clip(hsv[:, :, 1] * 1.3, 0, 255)
 
104
  frame = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR)
105
+
106
+ # 4. CONTRAST ENHANCEMENT
107
  frame = cv2.convertScaleAbs(frame, alpha=1.15, beta=10)
108
+
109
+ # 5. HIGH SHARPENING
110
  kernel = np.array([[-1, -1, -1],
111
  [-1, 9, -1],
112
  [-1, -1, -1]]) / 1.2
113
  sharpened = cv2.filter2D(frame, -1, kernel)
 
114
  frame = cv2.addWeighted(frame, 0.4, sharpened, 0.6, 0)
115
+
116
+ # 6. SLIGHT VIGNETTE
117
  rows, cols = frame.shape[:2]
118
+ X_kernel = cv2.getGaussianKernel(cols, cols / 2)
119
+ Y_kernel = cv2.getGaussianKernel(rows, rows / 2)
120
+ mask = (Y_kernel * X_kernel.T)
121
+ mask = (mask / mask.max()) ** 0.4
122
+
123
+ for i in range(3):
 
124
  frame[:, :, i] = frame[:, :, i] * mask
125
+
126
  return np.clip(frame, 0, 255).astype(np.uint8)
127
 
128
+
129
  def burn_captions_to_frame(frame: np.ndarray, text: str, font_size: int = 32) -> np.ndarray:
130
  """Burn caption text onto frame with semi-transparent background (centered)."""
131
  height, width = frame.shape[:2]
132
+
 
133
  frame_pil = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
134
  draw = ImageDraw.Draw(frame_pil, 'RGBA')
135
+
 
136
  try:
137
  font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
138
+ except Exception:
139
  font = ImageFont.load_default()
140
+
141
+ # Word-wrap text
142
  max_width = width - 60
143
  wrapped_lines = []
144
  words = text.split()
145
  current_line = []
146
+
147
  for word in words:
148
  test_line = ' '.join(current_line + [word])
149
  bbox = draw.textbbox((0, 0), test_line, font=font)
 
155
  current_line.append(word)
156
  if current_line:
157
  wrapped_lines.append(' '.join(current_line))
158
+
159
+ # Background box dimensions
160
  line_height = font_size + 10
161
  text_height = len(wrapped_lines) * line_height + 20
162
  bg_y_start = max(height // 2 - text_height // 2 - 10, 20)
163
  bg_y_end = min(bg_y_start + text_height, height - 20)
164
+
 
165
  overlay = Image.new('RGBA', frame_pil.size, (0, 0, 0, 0))
166
  overlay_draw = ImageDraw.Draw(overlay, 'RGBA')
167
  overlay_draw.rectangle(
168
  [(20, bg_y_start), (width - 20, bg_y_end)],
169
+ fill=(0, 0, 0, 180)
170
  )
171
  frame_pil = Image.alpha_composite(frame_pil.convert('RGBA'), overlay).convert('RGB')
172
  draw = ImageDraw.Draw(frame_pil)
173
+
 
174
  y_position = bg_y_start + 10
175
  for line in wrapped_lines:
176
  bbox = draw.textbbox((0, 0), line, font=font)
 
178
  x_position = (width - line_width) // 2
179
  draw.text((x_position, y_position), line, font=font, fill=(255, 255, 255, 255))
180
  y_position += line_height
181
+
182
+ return cv2.cvtColor(np.array(frame_pil), cv2.COLOR_RGB2BGR)
183
+
 
184
 
185
  def process_video_segment(
186
  video_path: str,
 
191
  target_width: int = 1080,
192
  target_height: int = 1350
193
  ) -> bool:
194
+ """Process video segment: crop, resize, color grade, burn captions, encode via FFmpeg."""
195
+ ffmpeg_proc = None
196
  try:
197
  print(f"Opening video: {video_path}")
198
  cap = cv2.VideoCapture(video_path)
199
+
200
  if not cap.isOpened():
201
  print(f"Error: Could not open video {video_path}")
202
  return False
203
+
 
204
  fps = cap.get(cv2.CAP_PROP_FPS)
 
205
  original_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
206
  original_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
207
+
208
  start_seconds = timestamp_to_seconds(start_time)
209
  end_seconds = timestamp_to_seconds(end_time)
210
  duration = end_seconds - start_seconds
211
+
212
  print(f"Video info: {fps} fps, {original_width}x{original_height}")
213
+ print(f"Extracting segment: {start_time} to {end_time} ({duration:.1f}s)")
214
+
215
+ # Pipe frames into FFmpeg — proper H.264 with real compression
216
+ ffmpeg_cmd = [
217
+ "ffmpeg", "-y",
218
+ "-f", "rawvideo",
219
+ "-vcodec", "rawvideo",
220
+ "-s", f"{target_width}x{target_height}",
221
+ "-pix_fmt", "bgr24",
222
+ "-r", str(fps),
223
+ "-i", "pipe:0",
224
+ "-vcodec", "libx264",
225
+ "-preset", "fast",
226
+ "-crf", "23", # 0=lossless, 51=worst; 23 is a solid default
227
+ "-pix_fmt", "yuv420p", # broad playback compatibility
228
+ "-movflags", "+faststart",
229
+ output_path
230
+ ]
231
+
232
+ ffmpeg_proc = subprocess.Popen(
233
+ ffmpeg_cmd,
234
+ stdin=subprocess.PIPE,
235
+ stdout=subprocess.DEVNULL,
236
+ stderr=subprocess.DEVNULL
237
+ )
238
+
239
+ # Seek to start frame
240
  start_frame = int(start_seconds * fps)
241
  cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
242
+
243
+ # Build caption lookup: frame_number -> text
244
  caption_map = {}
245
  for rel_time, caption_text in captions:
246
  frame_num = int(rel_time * fps)
247
  caption_map[frame_num] = caption_text
248
+
249
  current_caption = ""
250
  processed_frames = 0
251
  target_frames = int(duration * fps)
252
+
253
  print(f"Processing {target_frames} frames...")
254
+
255
  while processed_frames < target_frames:
256
  ret, frame = cap.read()
257
  if not ret:
258
  print(f"Warning: Could not read frame at position {processed_frames}")
259
  break
260
+
261
+ # Crop to target aspect ratio
 
262
  aspect_ratio = target_width / target_height
263
  if original_width / original_height > aspect_ratio:
264
+ new_width = int(original_height * aspect_ratio)
 
 
265
  x_offset = (original_width - new_width) // 2
266
  frame = frame[:, x_offset:x_offset + new_width]
267
  else:
268
+ new_height = int(original_width / aspect_ratio)
 
 
269
  y_offset = (original_height - new_height) // 2
270
  frame = frame[y_offset:y_offset + new_height, :]
271
+
272
  frame = cv2.resize(frame, (target_width, target_height), interpolation=cv2.INTER_LANCZOS4)
 
 
273
  frame = apply_color_grading_wedding_retro(frame)
274
+
 
275
  if processed_frames in caption_map:
276
  current_caption = caption_map[processed_frames]
277
+
 
278
  if current_caption:
279
  frame = burn_captions_to_frame(frame, current_caption)
280
+
281
+ ffmpeg_proc.stdin.write(frame.tobytes())
282
  processed_frames += 1
283
+
284
  if processed_frames % max(1, target_frames // 10) == 0:
285
  progress = (processed_frames / target_frames) * 100
286
  print(f"Progress: {progress:.1f}%")
287
+
288
+ ffmpeg_proc.stdin.close()
289
+ ffmpeg_proc.wait()
290
  cap.release()
291
+
292
+ if ffmpeg_proc.returncode != 0:
293
+ print(f"✗ FFmpeg encoding failed with return code {ffmpeg_proc.returncode}")
294
+ return False
295
+
296
  print(f"✓ Video segment saved: {output_path}")
297
  return True
298
+
299
  except Exception as e:
300
  print(f"✗ Error processing video segment: {e}")
301
+ if ffmpeg_proc is not None:
302
+ try:
303
+ ffmpeg_proc.stdin.close()
304
+ except Exception:
305
+ pass
306
+ ffmpeg_proc.wait()
307
  return False
308
 
309
+
310
  async def process_movie_segments(movie_name: str) -> bool:
311
  """Process all segments for a movie."""
312
  try:
 
314
  print(f"\n{'='*80}")
315
  print(f"Processing movie: {movie_name}")
316
  print(f"{'='*80}")
317
+
318
  # Download transcript
319
  transcript_file = f"{TRANSCRIPTION_FOLDER}/{movie_name}.transcript.txt"
320
  print(f"Downloading transcript: {transcript_file}")
321
+
322
  try:
323
  transcript_path = hf_hub_download(
324
  repo_id=HF_DATASET_REPO,
 
332
  except Exception as e:
333
  print(f"Warning: Could not download transcript: {e}")
334
  transcript_content = ""
335
+
336
  # Download original video
337
  video_file = f"{movie_name}.mkv"
338
  print(f"Downloading video: {video_file}")
339
+
340
  try:
341
  video_path = hf_hub_download(
342
  repo_id=HF_DATASET_REPO,
 
345
  token=HF_TOKEN,
346
  cache_dir="/tmp/video_processor_cache"
347
  )
 
348
  if os.path.islink(video_path):
349
  video_path = os.path.realpath(video_path)
350
  except Exception as e:
351
  print(f"Error: Could not download video: {e}")
352
  return False
353
+
354
  # List segment JSON files
355
  hooks_folder = f"{HOOKS_FOLDER}/{movie_name}"
356
  print(f"Listing segments from: {hooks_folder}")
357
+
358
  files = list_repo_files(
359
  repo_id=HF_DATASET_REPO,
360
  repo_type="dataset",
361
  token=HF_TOKEN
362
  )
363
+
364
  segment_files = sorted([
365
  f for f in files
366
  if f.startswith(f"{hooks_folder}/") and f.endswith(".json")
367
  ])
368
+
369
  if not segment_files:
370
  print(f"No segment JSON files found for {movie_name}")
371
  return False
372
+
373
  print(f"Found {len(segment_files)} segments")
374
+
 
375
  temp_dir = tempfile.mkdtemp()
376
+
377
  try:
378
  for segment_file in segment_files:
379
  try:
 
380
  segment_path = hf_hub_download(
381
  repo_id=HF_DATASET_REPO,
382
  filename=segment_file,
 
384
  token=HF_TOKEN,
385
  cache_dir="/tmp/video_processor_cache"
386
  )
387
+
388
  with open(segment_path, 'r', encoding='utf-8') as f:
389
  segment_data = json.load(f)
390
+
391
  segment_number = segment_data.get("segment_number", 1)
392
  start_time = segment_data.get("start_time", "00:00:00")
393
  end_time = segment_data.get("end_time", "00:10:00")
394
+
395
  print(f"\nProcessing segment {segment_number}: {start_time} to {end_time}")
396
+
 
397
  captions = extract_captions_for_segment(transcript_content, start_time, end_time)
398
  print(f"Found {len(captions)} caption lines for this segment")
399
+
 
400
  output_filename = f"segment-{segment_number:02d}.mp4"
401
  output_path = os.path.join(temp_dir, output_filename)
402
+
403
  success = process_video_segment(
404
  video_path,
405
  output_path,
 
407
  end_time,
408
  captions
409
  )
410
+
411
  if not success:
412
  print(f"Failed to process segment {segment_number}")
413
  continue
414
+
 
415
  upload_path = f"{READY_VIDEOS_FOLDER}/{movie_name}/{output_filename}"
416
  print(f"Uploading to: {upload_path}")
417
+
418
  upload_file(
419
  path_or_fileobj=output_path,
420
  path_in_repo=upload_path,
 
424
  commit_message=f"Add processed video segment {segment_number} for {movie_name}"
425
  )
426
  print(f"✓ Segment {segment_number} uploaded successfully")
427
+
428
  except Exception as e:
429
  print(f"✗ Error processing segment: {e}")
430
  processing_state["error_count"] += 1
431
  continue
432
+
433
  finally:
434
  import shutil
435
  shutil.rmtree(temp_dir, ignore_errors=True)
436
+
437
  processing_state["processed_files"].append(movie_name)
438
  processing_state["total_processed"] += 1
439
  print(f"\n✓ Successfully processed all segments for {movie_name}")
440
  return True
441
+
442
  except Exception as e:
443
  processing_state["error_count"] += 1
444
  processing_state["last_error"] = str(e)
445
  print(f"✗ Error: {e}")
446
  return False
447
 
448
+
449
  async def scan_and_process_videos():
450
  """Scan hooks folder and process all movies."""
451
  if processing_state["is_running"]:
452
  print("Video processing already running, skipping...")
453
  return
454
+
455
+ print("Waiting 3 minutes before starting video processing...")
456
+ await asyncio.sleep(180) # 3-minute startup delay
457
+
458
  processing_state["is_running"] = True
459
  print("\n" + "="*80)
460
  print("STARTING VIDEO PROCESSING SERVICE")
461
  print("="*80)
462
+
463
  try:
464
  files = list_repo_files(
465
  repo_id=HF_DATASET_REPO,
466
  repo_type="dataset",
467
  token=HF_TOKEN
468
  )
469
+
 
470
  movie_folders = set()
471
  for f in files:
472
  if f.startswith(f"{HOOKS_FOLDER}/") and f.endswith(".json"):
 
473
  parts = f.split("/")
474
  if len(parts) >= 2:
475
+ movie_folders.add(parts[1])
476
+
 
477
  print(f"Found {len(movie_folders)} movies to process")
478
+
479
  for movie_name in sorted(movie_folders):
480
  await process_movie_segments(movie_name)
481
  await asyncio.sleep(2)
482
+
483
  print("\n" + "="*80)
484
  print("VIDEO PROCESSING COMPLETE")
485
  print(f"Processed: {processing_state['total_processed']}")
486
  print(f"Errors: {processing_state['error_count']}")
487
  print("="*80 + "\n")
488
+
489
  except Exception as e:
490
  print(f"Critical error: {e}")
491
  processing_state["last_error"] = str(e)
492
  finally:
493
  processing_state["is_running"] = False
494
 
495
+
496
  @app.on_event("startup")
497
  async def startup_event():
498
  """Start video processing on server startup."""
499
  asyncio.create_task(scan_and_process_videos())
500
 
501
+
502
  @app.get("/")
503
  async def health():
504
  """Health check endpoint."""
 
513
  "processed_files": processing_state["processed_files"]
514
  })
515
 
516
+
517
  @app.get("/status")
518
  async def get_status():
519
  """Get current processing status."""
 
526
  "processed_files": processing_state["processed_files"]
527
  })
528
 
529
+
530
  @app.post("/trigger-processing")
531
  async def trigger_processing():
532
+ """Manually trigger video processing (skips the startup delay)."""
533
  if processing_state["is_running"]:
534
  return JSONResponse({
535
  "status": "already_running",
536
  "message": "Video processing is already in progress"
537
  })
538
+
539
  asyncio.create_task(scan_and_process_videos())
540
  return JSONResponse({
541
  "status": "started",
542
  "message": "Video processing scan started"
543
  })
544
 
545
+
546
  if __name__ == "__main__":
547
+ print("Starting Video Processing Service on port 7860...")
548
+ print("Processing will begin 3 minutes after startup")
549
+ uvicorn.run(app, host="0.0.0.0", port=7860)