MogensR commited on
Commit
eaec6c0
Β·
verified Β·
1 Parent(s): 7dc4184

Update processing/two_stage/two_stage_processor.py

Browse files
processing/two_stage/two_stage_processor.py CHANGED
@@ -1,18 +1,28 @@
1
  #!/usr/bin/env python3
2
  """
3
- ENHANCED Two-Stage Alpha Channel Processing System βœ… 2025-09-09
 
 
 
4
  VIDEO-TO-VIDEO PIPELINE with proper audio support via AudioProcessor
5
 
6
- Key fixes:
7
- - Uses AudioProcessor for proper ffmpeg-based audio handling
8
- - Ensures full video duration processing (no 5-second truncation)
9
- - Direct alpha compositing without green screen
 
 
 
 
 
10
  """
11
 
12
  # ==============================================================================
13
  # IMPORTS
14
  # ==============================================================================
15
 
 
 
16
  import os
17
  import sys
18
  import cv2
@@ -23,35 +33,39 @@
23
  import gc
24
  import time
25
  import traceback
26
- import subprocess
27
  from pathlib import Path
28
  from typing import Optional, Tuple, Dict, Any, List
29
- from moviepy.editor import VideoFileClip, AudioFileClip, CompositeVideoClip, ImageClip, concatenate_videoclips, VideoClip
30
- import moviepy.video.fx.all as vfx
 
 
 
 
31
 
32
  # Import the AudioProcessor for proper audio handling
 
33
  try:
34
  from processing.audio import AudioProcessor
35
- except ImportError:
36
  try:
37
  from audio import AudioProcessor # Fallback if in different location
38
- except ImportError:
39
  AudioProcessor = None
40
- logging.warning("AudioProcessor not found - audio features will be limited")
41
 
42
- # Setup logging
43
- logging.basicConfig(
44
- level=logging.INFO,
45
- format='%(asctime)s - %(levelname)s - %(message)s',
46
- datefmt='%Y-%m-%d %H:%M:%S'
47
- )
48
  logger = logging.getLogger(__name__)
 
 
 
 
 
 
49
 
50
- # PyTorch memory management
51
  try:
52
  import torch
53
  TORCH_AVAILABLE = True
54
- except ImportError:
55
  TORCH_AVAILABLE = False
56
  logger.warning("PyTorch not available, GPU memory management disabled")
57
 
@@ -66,11 +80,11 @@ class ProcessingConfig:
66
  REFERENCE_FRAMES = 3 # Number of frames to use for reference mask
67
 
68
  # Chunked processing settings
69
- MAX_CHUNK_DURATION = 300 # 5 minutes in seconds
70
  CHUNK_OVERLAP_FRAMES = 5
71
- MAX_PROCESSING_RESOLUTION = None
72
 
73
- # Quality settings
74
  VIDEO_CODEC = 'libx264'
75
  VIDEO_BITRATE = '8000k'
76
  AUDIO_CODEC = 'aac'
@@ -91,7 +105,7 @@ class ProcessingConfig:
91
  CLEAR_CACHE_AFTER_STAGE = True
92
  FORCE_GARBAGE_COLLECTION = True
93
  MEMORY_USAGE_THRESHOLD = 70
94
- MEMORY_CHECK_INTERVAL = 30
95
 
96
  # Debug settings
97
  SAVE_DEBUG_FILES = True
@@ -102,8 +116,9 @@ class ProcessingConfig:
102
  AUDIO_QUALITY = 'high'
103
  USE_FFMPEG_FOR_AUDIO = True # Prefer ffmpeg over MoviePy
104
 
 
105
  # ==============================================================================
106
- # MEMORY MANAGER (keeping your original)
107
  # ==============================================================================
108
 
109
  class MemoryManager:
@@ -113,54 +128,64 @@ def __init__(self, config: ProcessingConfig):
113
  self.config = config
114
  self.initial_memory = self.get_current_memory()
115
  self.peak_memory = self.initial_memory
116
- self.stage_memories = {}
117
 
118
  @staticmethod
119
- def clear_gpu_cache():
120
  if TORCH_AVAILABLE and torch.cuda.is_available():
121
  before = torch.cuda.memory_allocated()
122
  torch.cuda.empty_cache()
123
- torch.cuda.synchronize()
 
 
 
124
  after = torch.cuda.memory_allocated()
125
  freed = (before - after) / 1024**3
126
  logger.info(f"GPU cache cleared: {freed:.2f}GB freed")
127
  return freed
128
- return 0
129
 
130
  @staticmethod
131
- def force_garbage_collection():
132
  before = 0
133
  if TORCH_AVAILABLE and torch.cuda.is_available():
134
- before = torch.cuda.memory_allocated()
 
 
 
135
 
136
  collected = gc.collect()
137
 
138
  after = 0
139
  if TORCH_AVAILABLE and torch.cuda.is_available():
140
- after = torch.cuda.memory_allocated()
 
 
 
141
 
142
- freed = (before - after) / 1024**3 if before > 0 else 0
143
  logger.info(f"Garbage collection: {collected} objects freed, {freed:.2f}GB GPU memory recovered")
144
  return collected, freed
145
 
146
  def get_current_memory(self) -> Dict[str, float]:
147
  memory = {
148
- 'gpu_allocated': 0,
149
- 'gpu_reserved': 0,
150
- 'gpu_free': 0,
151
- 'gpu_total': 0,
152
- 'gpu_usage_percent': 0
153
  }
154
 
155
  if TORCH_AVAILABLE and torch.cuda.is_available():
156
- memory['gpu_allocated'] = torch.cuda.memory_allocated() / 1024**3
157
- memory['gpu_reserved'] = torch.cuda.memory_reserved() / 1024**3
158
-
159
- props = torch.cuda.get_device_properties(0)
160
- memory['gpu_total'] = props.total_memory / 1024**3
161
- memory['gpu_free'] = memory['gpu_total'] - memory['gpu_allocated']
162
- memory['gpu_usage_percent'] = (memory['gpu_allocated'] / memory['gpu_total']) * 100
163
-
 
164
  return memory
165
 
166
  def get_gpu_memory_info(self) -> str:
@@ -176,13 +201,13 @@ def should_clear_memory(self) -> bool:
176
  return memory['gpu_usage_percent'] > self.config.MEMORY_USAGE_THRESHOLD
177
 
178
  def cleanup_stage(self, stage_name: str, force: bool = False):
179
- logger.info(f"Cleaning up after {stage_name}...")
180
 
181
  before = self.get_current_memory()
182
 
183
- gpu_freed = 0
184
  gc_objects = 0
185
- gc_memory = 0
186
 
187
  if force or self.should_clear_memory():
188
  gpu_freed = self.clear_gpu_cache()
@@ -191,16 +216,15 @@ def cleanup_stage(self, stage_name: str, force: bool = False):
191
  after = self.get_current_memory()
192
 
193
  self.stage_memories[stage_name] = {
194
- 'before': before,
195
- 'after': after,
196
- 'freed': gpu_freed + gc_memory
197
  }
198
 
199
  if after['gpu_allocated'] > self.peak_memory.get('gpu_allocated', 0):
200
  self.peak_memory = after
201
 
202
- logger.info(f"Memory cleanup completed for {stage_name}: "
203
- f"{gpu_freed + gc_memory:.2f}GB total freed")
204
 
205
  def cleanup_model(self, model_handler: Any, handler_name: str, deep_cleanup: bool = False):
206
  try:
@@ -214,40 +238,34 @@ def cleanup_model(self, model_handler: Any, handler_name: str, deep_cleanup: boo
214
 
215
  if deep_cleanup:
216
  if hasattr(model_handler, 'model'):
217
- if model_handler.model is not None:
218
- del model_handler.model
219
- model_handler.model = None
220
  logger.info(f"{handler_name}: model deleted")
221
-
 
222
  if hasattr(model_handler, 'predictor'):
223
- if model_handler.predictor is not None:
224
- del model_handler.predictor
225
- model_handler.predictor = None
226
  logger.info(f"{handler_name}: predictor deleted")
227
-
 
228
  except Exception as e:
229
  logger.warning(f"Failed to cleanup {handler_name}: {e}")
230
 
231
  def get_memory_report(self) -> str:
232
- report = ["="*60]
233
- report.append("MEMORY USAGE REPORT")
234
- report.append("="*60)
235
-
236
  current = self.get_current_memory()
237
  report.append(f"Current GPU Memory: {current['gpu_allocated']:.2f}GB / {current['gpu_total']:.2f}GB ({current['gpu_usage_percent']:.0f}%)")
238
- report.append(f"Peak GPU Memory: {self.peak_memory.get('gpu_allocated', 0):.2f}GB")
239
-
240
  if self.stage_memories:
241
  report.append("\nStage Memory Usage:")
242
  for stage, mem in self.stage_memories.items():
243
- report.append(f" {stage}:")
244
- report.append(f" Before: {mem['before']['gpu_allocated']:.2f}GB")
245
- report.append(f" After: {mem['after']['gpu_allocated']:.2f}GB")
246
- report.append(f" Freed: {mem['freed']:.2f}GB")
247
-
248
  report.append("="*60)
249
  return "\n".join(report)
250
 
 
251
  # ==============================================================================
252
  # QUALITY MANAGER
253
  # ==============================================================================
@@ -280,6 +298,7 @@ class QualityManager:
280
  def get_profile(cls, quality: str = 'medium') -> Dict[str, Any]:
281
  return cls.PROFILES.get(quality, cls.PROFILES['medium'])
282
 
 
283
  # ==============================================================================
284
  # ALPHA REFINEMENT
285
  # ==============================================================================
@@ -299,18 +318,18 @@ def refine_alpha_matte(alpha: np.ndarray, config: ProcessingConfig) -> np.ndarra
299
 
300
  # Temporal smoothing
301
  if config.ALPHA_SMOOTHING > 0:
302
- kernel_size = config.ALPHA_SMOOTHING
303
- if kernel_size % 2 == 0:
304
- kernel_size += 1
305
- alpha = cv2.medianBlur((alpha * 255).astype(np.uint8), kernel_size).astype(np.float32) / 255.0
306
 
307
  # Edge feathering
308
  if config.ALPHA_EDGE_BLUR > 0:
309
- kernel_size = config.ALPHA_EDGE_BLUR * 2 + 1
310
- alpha = cv2.GaussianBlur(alpha, (kernel_size, kernel_size), 0)
311
 
312
  # Contrast adjustment
313
- if config.ALPHA_CONTRAST != 1.0:
314
  alpha = np.power(alpha, config.ALPHA_CONTRAST)
315
 
316
  return np.clip(alpha, 0, 1)
@@ -326,21 +345,22 @@ def defringe_edges(composite: np.ndarray, alpha: np.ndarray, radius: int = 1) ->
326
  kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (radius*2+1, radius*2+1))
327
  dilated = cv2.dilate(alpha, kernel)
328
  eroded = cv2.erode(alpha, kernel)
329
- edge_mask = dilated - eroded
330
 
331
  blurred = cv2.GaussianBlur(composite, (3, 3), 0)
332
 
333
  edge_mask_3d = np.stack([edge_mask] * 3, axis=2)
334
  result = composite * (1 - edge_mask_3d) + blurred * edge_mask_3d
335
 
336
- return result.astype(np.uint8)
 
337
 
338
  # ==============================================================================
339
  # CHUNKED VIDEO PROCESSOR
340
  # ==============================================================================
341
 
342
  class ChunkedVideoProcessor:
343
- """Handles splitting and reassembling videos."""
344
 
345
  def __init__(self, temp_dir: str, config: ProcessingConfig):
346
  self.temp_dir = temp_dir
@@ -351,7 +371,7 @@ def __init__(self, temp_dir: str, config: ProcessingConfig):
351
  def should_chunk_video(self, video_path: str) -> bool:
352
  try:
353
  with VideoFileClip(video_path) as clip:
354
- duration = clip.duration
355
  should_chunk = duration > self.config.MAX_CHUNK_DURATION
356
  logger.info(f"Video duration: {duration:.1f}s, chunking: {should_chunk}")
357
  return should_chunk
@@ -362,14 +382,14 @@ def should_chunk_video(self, video_path: str) -> bool:
362
  def split_video_into_chunks(self, video_path: str) -> List[str]:
363
  try:
364
  with VideoFileClip(video_path) as clip:
365
- duration = clip.duration
366
- chunk_paths = []
367
 
368
  chunk_duration = self.config.MAX_CHUNK_DURATION
369
- current_time = 0
370
  chunk_index = 0
371
 
372
- while current_time < duration:
373
  end_time = min(current_time + chunk_duration, duration)
374
 
375
  remaining_after = duration - end_time
@@ -391,11 +411,11 @@ def split_video_into_chunks(self, video_path: str) -> List[str]:
391
  chunk_clip.close()
392
 
393
  chunk_paths.append(chunk_path)
394
- logger.info(f"Created chunk {chunk_index}: {current_time:.1f}s - {end_time:.1f}s")
395
 
396
  if end_time < duration:
397
- overlap_seconds = self.config.CHUNK_OVERLAP_FRAMES / clip.fps
398
- current_time = end_time - overlap_seconds
399
  else:
400
  current_time = duration
401
 
@@ -416,15 +436,10 @@ def reassemble_chunks(self, processed_chunk_paths: List[str], output_path: str)
416
  logger.info("Single chunk, copied directly")
417
  return output_path
418
 
419
- chunk_clips = []
420
- for chunk_path in processed_chunk_paths:
421
- clip = VideoFileClip(chunk_path)
422
- chunk_clips.append(clip)
423
-
424
- logger.info(f"Concatenating {len(chunk_clips)} chunks...")
425
  final_clip = concatenate_videoclips(chunk_clips, method="compose")
426
-
427
- # Write WITHOUT audio (we'll add it separately)
428
  final_clip.write_videofile(
429
  output_path,
430
  codec=self.config.VIDEO_CODEC,
@@ -432,10 +447,9 @@ def reassemble_chunks(self, processed_chunk_paths: List[str], output_path: str)
432
  verbose=False,
433
  logger=None
434
  )
435
-
436
  final_clip.close()
437
- for clip in chunk_clips:
438
- clip.close()
439
 
440
  logger.info(f"Successfully reassembled video: {output_path}")
441
  return output_path
@@ -444,6 +458,7 @@ def reassemble_chunks(self, processed_chunk_paths: List[str], output_path: str)
444
  logger.error(f"Chunk reassembly failed: {e}")
445
  raise
446
 
 
447
  # ==============================================================================
448
  # MAIN TWOSTAGEPROCESSOR CLASS
449
  # ==============================================================================
@@ -465,8 +480,12 @@ def __init__(self, sam2_handler, matanyone_handler, temp_dir: Optional[str] = No
465
 
466
  # Initialize AudioProcessor for proper audio handling
467
  if AudioProcessor and self.config.USE_FFMPEG_FOR_AUDIO:
468
- self.audio_processor = AudioProcessor(temp_dir=self.temp_dir)
469
- logger.info("AudioProcessor initialized for ffmpeg-based audio handling")
 
 
 
 
470
  else:
471
  self.audio_processor = None
472
  logger.warning("AudioProcessor not available - using MoviePy fallback for audio")
@@ -481,19 +500,26 @@ def process_video(self,
481
  background_path: str,
482
  output_path: str,
483
  quality: str = 'medium',
484
- trim_seconds: Optional[int] = None,
485
  callback: Optional[callable] = None,
486
- **kwargs) -> Tuple[str, str]:
487
  """
488
  Main processing pipeline with proper audio handling.
 
 
 
489
  """
490
  try:
491
  logger.info(f"🎬 Enhanced Two-Stage Alpha Pipeline: {video_path}")
492
  logger.info(f"🎯 Background: {background_path}")
493
  logger.info(f"πŸ“ Temp: {self.temp_dir}")
494
  logger.info(f"🎀 Audio: {'Enabled (ffmpeg)' if self.audio_processor else 'Enabled (MoviePy)'}")
 
 
 
 
495
 
496
- # Extract original audio using AudioProcessor if available
497
  original_audio_path = None
498
  if self.config.PRESERVE_ORIGINAL_AUDIO:
499
  original_audio_path = self._extract_audio(video_path)
@@ -502,22 +528,17 @@ def process_video(self,
502
  else:
503
  logger.info("ℹ️ No audio found in source video")
504
 
505
- # Check video duration
506
- with VideoFileClip(video_path) as clip:
507
- duration = clip.duration
508
- logger.info(f"πŸ“ Input video duration: {duration:.1f} seconds")
509
-
510
  # Determine if chunking is needed
511
  needs_chunking = self.chunked_processor.should_chunk_video(video_path)
512
 
513
  if needs_chunking:
514
- logger.info("Using chunked processing for long video...")
515
  result = self._process_chunked_video(
516
  video_path, background_path, output_path,
517
  quality, original_audio_path, callback
518
  )
519
  else:
520
- logger.info("Processing full video in single pass...")
521
  result = self._process_single_video(
522
  video_path, background_path, output_path,
523
  quality, trim_seconds, original_audio_path, callback
@@ -525,10 +546,13 @@ def process_video(self,
525
 
526
  # Verify final output
527
  if result[0] and os.path.exists(result[0]):
528
- with VideoFileClip(result[0]) as clip:
529
- final_duration = clip.duration
530
- has_audio = clip.audio is not None
531
- logger.info(f"βœ… Final output: {final_duration:.1f}s, Audio: {has_audio}")
 
 
 
532
 
533
  logger.info(self.memory_manager.get_memory_report())
534
  return result
@@ -539,12 +563,15 @@ def process_video(self,
539
  logger.error(f"Traceback: {traceback.format_exc()}")
540
  return None, error_msg
541
 
 
 
 
542
  def _extract_audio(self, video_path: str) -> Optional[str]:
543
  """Extract audio using AudioProcessor (ffmpeg) or MoviePy fallback."""
544
  try:
545
  if self.audio_processor:
546
  # Use AudioProcessor with ffmpeg
547
- logger.info("Extracting audio using ffmpeg...")
548
  audio_path = self.audio_processor.extract_audio(
549
  video_path=video_path,
550
  output_path=os.path.join(self.temp_dir, "original_audio.aac"),
@@ -554,7 +581,7 @@ def _extract_audio(self, video_path: str) -> Optional[str]:
554
  return audio_path
555
  else:
556
  # Fallback to MoviePy
557
- logger.info("Extracting audio using MoviePy...")
558
  audio_path = os.path.join(self.temp_dir, "original_audio.m4a")
559
  with VideoFileClip(video_path) as clip:
560
  if clip.audio is not None:
@@ -572,21 +599,23 @@ def _extract_audio(self, video_path: str) -> Optional[str]:
572
  logger.warning(f"Could not extract audio: {e}")
573
  return None
574
 
575
- def _add_audio_to_video(self, video_path: str, audio_path: str, output_path: str) -> str:
576
  """Add audio to video using AudioProcessor (ffmpeg) or MoviePy fallback."""
577
  try:
578
  if self.audio_processor and audio_path:
579
- # Use AudioProcessor with ffmpeg for lossless muxing
580
- logger.info("Adding audio using ffmpeg (lossless if possible)...")
 
 
581
  return self.audio_processor.add_audio_to_video(
582
- original_video=audio_path, # Source of audio
583
- processed_video=video_path, # Video without audio
584
  output_path=output_path,
585
  audio_quality=self.config.AUDIO_QUALITY
586
  )
587
  elif audio_path:
588
  # Fallback to MoviePy
589
- logger.info("Adding audio using MoviePy...")
590
  with VideoFileClip(video_path) as video:
591
  with AudioFileClip(audio_path) as audio:
592
  final = video.set_audio(audio)
@@ -602,34 +631,42 @@ def _add_audio_to_video(self, video_path: str, audio_path: str, output_path: str
602
  )
603
  return output_path
604
  else:
605
- # No audio to add
606
  shutil.copy2(video_path, output_path)
607
  return output_path
608
 
609
  except Exception as e:
610
  logger.error(f"Failed to add audio: {e}")
611
  # Return video without audio rather than failing completely
612
- shutil.copy2(video_path, output_path)
 
 
 
613
  return output_path
614
 
 
 
 
615
  def _process_chunked_video(self, video_path: str, background_path: str,
616
  output_path: str, quality: str,
617
  original_audio_path: Optional[str],
618
- callback: Optional[callable]) -> Tuple[str, str]:
619
  """Process long video in chunks."""
620
  try:
621
  if callback:
622
- callback("Splitting video into chunks...", 5)
623
 
624
  chunk_paths = self.chunked_processor.split_video_into_chunks(video_path)
 
 
625
 
626
- processed_chunks = []
627
  total_chunks = len(chunk_paths)
628
 
629
  for i, chunk_path in enumerate(chunk_paths):
630
  if callback:
631
- progress = 10 + (i * 70 // total_chunks)
632
- callback(f"Processing chunk {i+1}/{total_chunks}...", progress)
633
 
634
  logger.info(f"Processing chunk {i+1}/{total_chunks}")
635
 
@@ -649,19 +686,19 @@ def _process_chunked_video(self, video_path: str, background_path: str,
649
  self.memory_manager.cleanup_stage(f"Chunk_{i+1}", force=True)
650
 
651
  if callback:
652
- callback("Reassembling video...", 85)
653
 
654
- # Reassemble chunks
655
  temp_output = os.path.join(self.temp_dir, "reassembled_no_audio.mp4")
656
- final_path = self.chunked_processor.reassemble_chunks(processed_chunks, temp_output)
657
 
658
  # Add original audio back
659
  if original_audio_path:
660
  if callback:
661
- callback("Adding audio track...", 95)
662
- final_path = self._add_audio_to_video(temp_output, original_audio_path, output_path)
663
  else:
664
- shutil.move(temp_output, output_path)
665
  final_path = output_path
666
 
667
  if callback:
@@ -671,43 +708,59 @@ def _process_chunked_video(self, video_path: str, background_path: str,
671
 
672
  except Exception as e:
673
  logger.error(f"Chunked processing failed: {e}")
674
- raise
675
 
 
 
 
676
  def _process_single_video(self, video_path: str, background_path: str,
677
  output_path: str, quality: str,
678
- trim_seconds: Optional[int],
679
  original_audio_path: Optional[str],
680
- callback: Optional[callable]) -> Tuple[str, str]:
681
- """Process single video/chunk."""
682
  try:
 
 
 
 
 
 
 
 
 
 
 
683
  # Stage 1: Reference mask
684
  if callback:
685
- callback("Creating reference mask...", 10)
686
- reference_mask_path = self._stage1_create_reference_mask(video_path)
687
  self.memory_manager.cleanup_stage("SAM2", force=True)
688
 
689
  # Stage 2: MatAnyone
690
  if callback:
691
- callback("Processing with MatAnyone...", 30)
692
  alpha_video_path = self._stage2_matanyone_processing(
693
- video_path, reference_mask_path, trim_seconds
694
  )
695
  self.memory_manager.cleanup_stage("MatAnyone", force=True)
696
 
697
- # Stage 3: Alpha compositing
698
  if callback:
699
- callback("Compositing with background...", 70)
700
-
701
- # Composite WITHOUT audio first
702
  temp_output = os.path.join(self.temp_dir, "composite_no_audio.mp4")
703
  composite_path = self._stage3_alpha_composite(
704
- video_path, alpha_video_path, background_path, temp_output, quality
 
 
 
 
705
  )
706
 
707
- # Add audio if provided
708
  if original_audio_path:
709
  if callback:
710
- callback("Adding audio track...", 90)
711
  final_path = self._add_audio_to_video(composite_path, original_audio_path, output_path)
712
  else:
713
  shutil.move(composite_path, output_path)
@@ -720,48 +773,47 @@ def _process_single_video(self, video_path: str, background_path: str,
720
 
721
  except Exception as e:
722
  logger.error(f"Single video processing failed: {e}")
723
- raise
724
 
725
- # [Keeping all your original stage methods unchanged]
 
 
726
  def _stage1_create_reference_mask(self, video_path: str) -> str:
727
- """Stage 1: Create reference mask from first frames using SAM2."""
 
728
  try:
729
  cap = cv2.VideoCapture(video_path)
730
  if not cap.isOpened():
731
  raise RuntimeError(f"Cannot open video: {video_path}")
732
 
733
- frames = []
734
  frame_count = 0
735
 
736
  while frame_count < self.config.REFERENCE_FRAMES:
737
  ret, frame = cap.read()
738
  if not ret:
739
  break
740
-
741
  frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
742
  frames.append(frame_rgb)
743
  frame_count += 1
744
 
745
- cap.release()
746
-
747
  if not frames:
748
  raise RuntimeError("No frames extracted for reference mask")
749
 
750
  logger.info(f"Extracted {len(frames)} reference frames")
751
 
752
- masks = []
753
  for i, frame in enumerate(frames):
754
  try:
755
- logger.info(f"Creating mask for frame {i+1}/{len(frames)}...")
756
  mask = self._create_sam2_mask(frame)
757
-
758
  if mask is not None:
 
 
759
  masks.append(mask)
760
-
761
  if self.config.SAVE_DEBUG_FILES:
762
  debug_path = os.path.join(self.temp_dir, f"debug_mask_{i}.png")
763
- cv2.imwrite(debug_path, (mask * 255).astype(np.uint8))
764
-
765
  except Exception as e:
766
  logger.warning(f"Failed to create mask for frame {i}: {e}")
767
  continue
@@ -770,107 +822,125 @@ def _stage1_create_reference_mask(self, video_path: str) -> str:
770
  raise RuntimeError("No valid reference masks created")
771
 
772
  combined_mask = self._combine_reference_masks(masks)
773
-
774
  reference_mask_path = os.path.join(self.temp_dir, "reference_mask.png")
775
- cv2.imwrite(reference_mask_path, (combined_mask * 255).astype(np.uint8))
776
-
777
  logger.info(f"βœ… Reference mask created: {reference_mask_path}")
778
  return reference_mask_path
779
 
780
  except Exception as e:
781
  logger.error(f"Stage 1 failed: {e}")
782
  raise
 
 
 
 
 
 
783
 
784
  def _create_sam2_mask(self, frame_rgb: np.ndarray) -> Optional[np.ndarray]:
785
- """Create mask using SAM2Handler."""
786
  try:
787
  methods_to_try = [
788
  lambda: self.sam2_handler.create_mask(frame_rgb),
789
  lambda: self.sam2_handler.create_mask(frame_rgb, None, None, None),
790
  lambda: self.sam2_handler.predict(frame_rgb) if hasattr(self.sam2_handler, 'predict') else None
791
  ]
792
-
793
  for method in methods_to_try:
794
  try:
795
  mask = method()
796
  if mask is not None:
 
 
797
  if mask.dtype == np.uint8:
798
  mask = mask.astype(np.float32) / 255.0
799
- return mask
800
- except (TypeError, AttributeError) as e:
801
- logger.debug(f"Method failed: {e}")
802
  continue
803
-
804
  logger.warning("All SAM2 methods failed")
805
  return None
806
-
807
  except Exception as e:
808
  logger.error(f"SAM2 mask creation failed: {e}")
809
  return None
810
 
811
- def _combine_reference_masks(self, masks: list) -> np.ndarray:
812
- """Combine multiple masks with voting."""
813
  if len(masks) == 1:
814
  return masks[0]
815
-
816
  stacked = np.stack(masks, axis=0)
817
  avg_mask = np.mean(stacked, axis=0)
818
-
819
  threshold = 0.3
820
- result = np.where(avg_mask > threshold, avg_mask, 0.0)
821
-
822
  logger.info(f"Combined {len(masks)} masks with threshold {threshold}")
823
  return result
824
 
825
- def _stage2_matanyone_processing(self, video_path: str,
826
- reference_mask_path: str,
827
- trim_seconds: Optional[int]) -> str:
828
- """Stage 2: Process with MatAnyone."""
 
829
  try:
830
- input_video = video_path
831
- if trim_seconds:
832
- trimmed_path = os.path.join(self.temp_dir, "trimmed_input.mp4")
833
- self._trim_video(video_path, trimmed_path, trim_seconds)
834
- input_video = trimmed_path
835
-
836
  matanyone_dir = os.path.join(self.temp_dir, "matanyone_out")
837
  os.makedirs(matanyone_dir, exist_ok=True)
838
 
839
  profile = QualityManager.get_profile('high')
840
  max_size = profile.get('max_size', 1920)
841
 
 
 
 
842
  try:
843
  self.matanyone_handler.process_video(
844
- input_path=input_video,
845
  mask_path=reference_mask_path,
846
  output_path=matanyone_dir,
847
- n_warmup=0, # CRITICAL: Don't limit to 5 seconds!
848
  r_erode=0,
849
  r_dilate=15,
850
  suffix='pha',
851
  save_image=False,
852
  max_size=max_size
853
  )
 
854
  except Exception as e:
855
- logger.warning(f"Explicit kwargs failed: {e}, trying positional")
856
- self.matanyone_handler.process_video(
857
- input_video, reference_mask_path, matanyone_dir
858
- )
 
 
 
 
 
 
 
 
859
 
860
- # Find output
861
  alpha_output_path = None
862
  for filename in os.listdir(matanyone_dir):
863
- if filename.endswith('_pha.mp4') or filename == 'pha.mp4':
 
864
  alpha_output_path = os.path.join(matanyone_dir, filename)
865
  break
866
 
867
  if not alpha_output_path or not os.path.exists(alpha_output_path):
868
- raise RuntimeError("MatAnyone did not create alpha video")
 
 
 
 
 
 
 
 
869
 
870
- # Verify
 
 
 
871
  cap = cv2.VideoCapture(alpha_output_path)
872
- frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
873
- fps = cap.get(cv2.CAP_PROP_FPS)
874
  duration = frame_count / fps if fps > 0 else 0
875
  cap.release()
876
 
@@ -884,12 +954,18 @@ def _stage2_matanyone_processing(self, video_path: str,
884
  logger.error(f"Stage 2 failed: {e}")
885
  raise
886
 
 
 
 
887
  def _stage3_alpha_composite(self, original_video_path: str,
888
  alpha_video_path: str,
889
  background_path: str,
890
  output_path: str,
891
  quality: str) -> str:
892
- """Stage 3: Alpha compositing WITHOUT audio (audio added separately)."""
 
 
 
893
  try:
894
  original_cap = cv2.VideoCapture(original_video_path)
895
  alpha_cap = cv2.VideoCapture(alpha_video_path)
@@ -897,25 +973,30 @@ def _stage3_alpha_composite(self, original_video_path: str,
897
  if not original_cap.isOpened() or not alpha_cap.isOpened():
898
  raise RuntimeError("Cannot open videos for compositing")
899
 
900
- fps = original_cap.get(cv2.CAP_PROP_FPS)
901
- width = int(original_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
902
- height = int(original_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
903
- total_frames = int(original_cap.get(cv2.CAP_PROP_FRAME_COUNT))
904
 
905
- # Load background
906
- if background_path.lower().endswith(('.mp4', '.avi', '.mov', '.mkv')):
 
 
907
  bg_cap = cv2.VideoCapture(background_path)
 
 
908
  bg_is_video = True
909
  else:
910
  bg_image = cv2.imread(background_path)
911
  if bg_image is None:
912
- raise RuntimeError(f"Cannot load background: {background_path}")
913
  bg_image = cv2.resize(bg_image, (width, height))
914
- bg_is_video = False
915
 
916
  # Setup video writer (NO AUDIO)
917
  fourcc = cv2.VideoWriter_fourcc(*'mp4v')
918
  out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
 
 
919
 
920
  frame_count = 0
921
  while True:
@@ -925,65 +1006,79 @@ def _stage3_alpha_composite(self, original_video_path: str,
925
  if not ret_orig or not ret_alpha:
926
  break
927
 
928
- # Get background frame
929
  if bg_is_video:
930
  ret_bg, bg_frame = bg_cap.read()
931
  if not ret_bg:
932
- bg_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
933
  ret_bg, bg_frame = bg_cap.read()
934
  bg_frame = cv2.resize(bg_frame, (width, height))
935
  else:
936
  bg_frame = bg_image.copy()
937
 
938
- # Process alpha
939
- if len(alpha_frame.shape) == 3:
940
  alpha_mask = cv2.cvtColor(alpha_frame, cv2.COLOR_BGR2GRAY)
941
  else:
942
  alpha_mask = alpha_frame
943
-
944
  if alpha_mask.shape[:2] != orig_frame.shape[:2]:
945
- alpha_mask = cv2.resize(alpha_mask, (width, height))
946
 
947
- # Refine alpha
948
  alpha_refined = self.alpha_refiner.refine_alpha_matte(alpha_mask, self.config)
949
 
950
- # Composite
951
- alpha_3d = np.stack([alpha_refined] * 3, axis=2)
952
- composite = alpha_3d * orig_frame + (1 - alpha_3d) * bg_frame
 
 
953
 
954
- # Optional defringing
955
  if self.config.ENABLE_DEFRINGING:
956
- composite = self.alpha_refiner.defringe_edges(
957
- composite.astype(np.uint8),
958
  alpha_refined,
959
  self.config.DEFRINGE_RADIUS
960
  )
961
 
962
- out.write(composite.astype(np.uint8))
963
  frame_count += 1
964
 
965
- if frame_count % self.config.MEMORY_CHECK_INTERVAL == 0:
966
  logger.info(f"Compositing: {frame_count}/{total_frames}")
967
 
968
- # Cleanup
969
- original_cap.release()
970
- alpha_cap.release()
971
  out.release()
972
- if bg_is_video:
973
- bg_cap.release()
974
-
975
- logger.info(f"βœ… Compositing completed: {frame_count} frames")
976
  return output_path
977
 
978
  except Exception as e:
979
  logger.error(f"Stage 3 compositing failed: {e}")
980
  raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
981
 
982
- def _trim_video(self, input_path: str, output_path: str, seconds: int):
983
- """Trim video to specified duration."""
 
 
 
984
  try:
985
  with VideoFileClip(input_path) as clip:
986
- trimmed = clip.subclip(0, min(seconds, clip.duration))
987
  trimmed.write_videofile(
988
  output_path,
989
  codec=self.config.VIDEO_CODEC,
@@ -991,7 +1086,7 @@ def _trim_video(self, input_path: str, output_path: str, seconds: int):
991
  verbose=False,
992
  logger=None
993
  )
994
- logger.info(f"Video trimmed to {seconds}s")
995
  except Exception as e:
996
  logger.error(f"Video trimming failed: {e}")
997
  raise
@@ -1012,7 +1107,7 @@ def cleanup(self):
1012
 
1013
  def get_processing_stats(self) -> Dict[str, Any]:
1014
  """Get processing statistics."""
1015
- stats = {
1016
  'temp_dir': self.temp_dir,
1017
  'memory_info': self.memory_manager.get_gpu_memory_info(),
1018
  'audio_processor': 'ffmpeg' if self.audio_processor else 'MoviePy',
@@ -1022,26 +1117,30 @@ def get_processing_stats(self) -> Dict[str, Any]:
1022
  'chunk_duration': self.config.MAX_CHUNK_DURATION
1023
  }
1024
  }
1025
-
1026
- if self.audio_processor:
1027
- stats['audio_stats'] = self.audio_processor.get_stats()
1028
-
 
1029
  return stats
1030
 
 
1031
  # ==============================================================================
1032
  # EXPORT INTERFACE
1033
  # ==============================================================================
1034
 
1035
- def create_two_stage_processor(sam2_handler, matanyone_handler, **kwargs):
1036
  """Factory function to create TwoStageProcessor."""
1037
  return TwoStageProcessor(sam2_handler, matanyone_handler, **kwargs)
1038
 
 
1039
  if __name__ == "__main__":
1040
  logger.info("Enhanced TwoStageProcessor with Audio Support")
1041
  logger.info("Features:")
1042
  logger.info(" βœ… ffmpeg-based audio via AudioProcessor")
1043
- logger.info(" βœ… Full video duration processing (no 5-second limit)")
1044
  logger.info(" βœ… Direct alpha compositing (no green screen)")
1045
  logger.info(" βœ… Memory-efficient chunked processing")
 
1046
  logger.info("")
1047
  logger.info("Use create_two_stage_processor(sam2_handler, matanyone_handler) to initialize")
 
1
  #!/usr/bin/env python3
2
  """
3
+ ENHANCED Two-Stage Alpha Channel Processing System βœ…
4
+ (Full file, ready to drop in)
5
+ Date: 2025-09-09
6
+
7
  VIDEO-TO-VIDEO PIPELINE with proper audio support via AudioProcessor
8
 
9
+ Key fixes in this build:
10
+ - Uses AudioProcessor for ffmpeg-based audio extraction & muxing
11
+ - Ensures full video duration processing (no 5-second truncation unless you pass trim_seconds explicitly)
12
+ - Direct alpha compositing (no green screen intermediate), supports image or video backgrounds
13
+ - Chunked processing for long videos, with seamless reassembly (video first, audio added after)
14
+ - Robust MatAnyone integration (supports both known signature and positional fallback)
15
+ - Defensive handling for SAM2 mask creation; combines first N frames for a stable trimap
16
+ - Careful memory cleanup between stages (GPU/system) to avoid leaks in long runs
17
+ - Extra logging and debug artifacts to help diagnose issues quickly
18
  """
19
 
20
  # ==============================================================================
21
  # IMPORTS
22
  # ==============================================================================
23
 
24
+ from __future__ import annotations
25
+
26
  import os
27
  import sys
28
  import cv2
 
33
  import gc
34
  import time
35
  import traceback
 
36
  from pathlib import Path
37
  from typing import Optional, Tuple, Dict, Any, List
38
+
39
+ # MoviePy (only used for probing durations, optional trimming, concat *video only*, and fallback audio mux)
40
+ from moviepy.editor import (
41
+ VideoFileClip, AudioFileClip, concatenate_videoclips
42
+ )
43
+ import moviepy.video.fx.all as vfx # noqa: F401 (kept for future effects)
44
 
45
  # Import the AudioProcessor for proper audio handling
46
+ # Search both "processing.audio" and local "audio"
47
  try:
48
  from processing.audio import AudioProcessor
49
+ except Exception:
50
  try:
51
  from audio import AudioProcessor # Fallback if in different location
52
+ except Exception:
53
  AudioProcessor = None
 
54
 
55
+ # Setup logging (do not override global logging level outside this module)
 
 
 
 
 
56
  logger = logging.getLogger(__name__)
57
+ if not logger.handlers:
58
+ logging.basicConfig(
59
+ level=logging.INFO,
60
+ format='%(asctime)s - %(levelname)s - %(message)s',
61
+ datefmt='%Y-%m-%d %H:%M:%S'
62
+ )
63
 
64
+ # PyTorch memory management (optional, if available on the host)
65
  try:
66
  import torch
67
  TORCH_AVAILABLE = True
68
+ except Exception:
69
  TORCH_AVAILABLE = False
70
  logger.warning("PyTorch not available, GPU memory management disabled")
71
 
 
80
  REFERENCE_FRAMES = 3 # Number of frames to use for reference mask
81
 
82
  # Chunked processing settings
83
+ MAX_CHUNK_DURATION = 300 # seconds (5 minutes). Videos longer than this will be chunked.
84
  CHUNK_OVERLAP_FRAMES = 5
85
+ MAX_PROCESSING_RESOLUTION = None # e.g., 1920 for downscaling if needed
86
 
87
+ # Quality settings (for writes we control)
88
  VIDEO_CODEC = 'libx264'
89
  VIDEO_BITRATE = '8000k'
90
  AUDIO_CODEC = 'aac'
 
105
  CLEAR_CACHE_AFTER_STAGE = True
106
  FORCE_GARBAGE_COLLECTION = True
107
  MEMORY_USAGE_THRESHOLD = 70
108
+ MEMORY_CHECK_INTERVAL = 30 # every N frames during compositing
109
 
110
  # Debug settings
111
  SAVE_DEBUG_FILES = True
 
116
  AUDIO_QUALITY = 'high'
117
  USE_FFMPEG_FOR_AUDIO = True # Prefer ffmpeg over MoviePy
118
 
119
+
120
  # ==============================================================================
121
+ # MEMORY MANAGER
122
  # ==============================================================================
123
 
124
  class MemoryManager:
 
128
  self.config = config
129
  self.initial_memory = self.get_current_memory()
130
  self.peak_memory = self.initial_memory
131
+ self.stage_memories: Dict[str, Dict[str, float]] = {}
132
 
133
  @staticmethod
134
+ def clear_gpu_cache() -> float:
135
  if TORCH_AVAILABLE and torch.cuda.is_available():
136
  before = torch.cuda.memory_allocated()
137
  torch.cuda.empty_cache()
138
+ try:
139
+ torch.cuda.synchronize()
140
+ except Exception:
141
+ pass
142
  after = torch.cuda.memory_allocated()
143
  freed = (before - after) / 1024**3
144
  logger.info(f"GPU cache cleared: {freed:.2f}GB freed")
145
  return freed
146
+ return 0.0
147
 
148
  @staticmethod
149
+ def force_garbage_collection() -> Tuple[int, float]:
150
  before = 0
151
  if TORCH_AVAILABLE and torch.cuda.is_available():
152
+ try:
153
+ before = torch.cuda.memory_allocated()
154
+ except Exception:
155
+ before = 0
156
 
157
  collected = gc.collect()
158
 
159
  after = 0
160
  if TORCH_AVAILABLE and torch.cuda.is_available():
161
+ try:
162
+ after = torch.cuda.memory_allocated()
163
+ except Exception:
164
+ after = 0
165
 
166
+ freed = (before - after) / 1024**3 if before > 0 else 0.0
167
  logger.info(f"Garbage collection: {collected} objects freed, {freed:.2f}GB GPU memory recovered")
168
  return collected, freed
169
 
170
  def get_current_memory(self) -> Dict[str, float]:
171
  memory = {
172
+ 'gpu_allocated': 0.0,
173
+ 'gpu_reserved': 0.0,
174
+ 'gpu_free': 0.0,
175
+ 'gpu_total': 0.0,
176
+ 'gpu_usage_percent': 0.0
177
  }
178
 
179
  if TORCH_AVAILABLE and torch.cuda.is_available():
180
+ try:
181
+ memory['gpu_allocated'] = torch.cuda.memory_allocated() / 1024**3
182
+ memory['gpu_reserved'] = torch.cuda.memory_reserved() / 1024**3
183
+ props = torch.cuda.get_device_properties(0)
184
+ memory['gpu_total'] = props.total_memory / 1024**3
185
+ memory['gpu_free'] = memory['gpu_total'] - memory['gpu_allocated']
186
+ memory['gpu_usage_percent'] = (memory['gpu_allocated'] / memory['gpu_total']) * 100 if memory['gpu_total'] > 0 else 0
187
+ except Exception:
188
+ pass
189
  return memory
190
 
191
  def get_gpu_memory_info(self) -> str:
 
201
  return memory['gpu_usage_percent'] > self.config.MEMORY_USAGE_THRESHOLD
202
 
203
  def cleanup_stage(self, stage_name: str, force: bool = False):
204
+ logger.info(f"Cleaning up after {stage_name}…")
205
 
206
  before = self.get_current_memory()
207
 
208
+ gpu_freed = 0.0
209
  gc_objects = 0
210
+ gc_memory = 0.0
211
 
212
  if force or self.should_clear_memory():
213
  gpu_freed = self.clear_gpu_cache()
 
216
  after = self.get_current_memory()
217
 
218
  self.stage_memories[stage_name] = {
219
+ 'before_alloc': before['gpu_allocated'],
220
+ 'after_alloc': after['gpu_allocated'],
221
+ 'freed_total': gpu_freed + gc_memory
222
  }
223
 
224
  if after['gpu_allocated'] > self.peak_memory.get('gpu_allocated', 0):
225
  self.peak_memory = after
226
 
227
+ logger.info(f"Memory cleanup completed for {stage_name}: {gpu_freed + gc_memory:.2f}GB total freed")
 
228
 
229
  def cleanup_model(self, model_handler: Any, handler_name: str, deep_cleanup: bool = False):
230
  try:
 
238
 
239
  if deep_cleanup:
240
  if hasattr(model_handler, 'model'):
241
+ try:
242
+ delattr(model_handler, 'model')
 
243
  logger.info(f"{handler_name}: model deleted")
244
+ except Exception:
245
+ pass
246
  if hasattr(model_handler, 'predictor'):
247
+ try:
248
+ delattr(model_handler, 'predictor')
 
249
  logger.info(f"{handler_name}: predictor deleted")
250
+ except Exception:
251
+ pass
252
  except Exception as e:
253
  logger.warning(f"Failed to cleanup {handler_name}: {e}")
254
 
255
  def get_memory_report(self) -> str:
256
+ report = ["="*60, "MEMORY USAGE REPORT", "="*60]
 
 
 
257
  current = self.get_current_memory()
258
  report.append(f"Current GPU Memory: {current['gpu_allocated']:.2f}GB / {current['gpu_total']:.2f}GB ({current['gpu_usage_percent']:.0f}%)")
259
+ peak = self.peak_memory.get('gpu_allocated', 0.0)
260
+ report.append(f"Peak GPU Memory: {peak:.2f}GB")
261
  if self.stage_memories:
262
  report.append("\nStage Memory Usage:")
263
  for stage, mem in self.stage_memories.items():
264
+ report.append(f" {stage}: before={mem['before_alloc']:.2f}GB after={mem['after_alloc']:.2f}GB freed={mem['freed_total']:.2f}GB")
 
 
 
 
265
  report.append("="*60)
266
  return "\n".join(report)
267
 
268
+
269
  # ==============================================================================
270
  # QUALITY MANAGER
271
  # ==============================================================================
 
298
  def get_profile(cls, quality: str = 'medium') -> Dict[str, Any]:
299
  return cls.PROFILES.get(quality, cls.PROFILES['medium'])
300
 
301
+
302
  # ==============================================================================
303
  # ALPHA REFINEMENT
304
  # ==============================================================================
 
318
 
319
  # Temporal smoothing
320
  if config.ALPHA_SMOOTHING > 0:
321
+ k = config.ALPHA_SMOOTHING
322
+ if k % 2 == 0:
323
+ k += 1
324
+ alpha = cv2.medianBlur((alpha * 255).astype(np.uint8), k).astype(np.float32) / 255.0
325
 
326
  # Edge feathering
327
  if config.ALPHA_EDGE_BLUR > 0:
328
+ ks = config.ALPHA_EDGE_BLUR * 2 + 1
329
+ alpha = cv2.GaussianBlur(alpha, (ks, ks), 0)
330
 
331
  # Contrast adjustment
332
+ if abs(config.ALPHA_CONTRAST - 1.0) > 1e-6:
333
  alpha = np.power(alpha, config.ALPHA_CONTRAST)
334
 
335
  return np.clip(alpha, 0, 1)
 
345
  kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (radius*2+1, radius*2+1))
346
  dilated = cv2.dilate(alpha, kernel)
347
  eroded = cv2.erode(alpha, kernel)
348
+ edge_mask = np.clip(dilated - eroded, 0, 1)
349
 
350
  blurred = cv2.GaussianBlur(composite, (3, 3), 0)
351
 
352
  edge_mask_3d = np.stack([edge_mask] * 3, axis=2)
353
  result = composite * (1 - edge_mask_3d) + blurred * edge_mask_3d
354
 
355
+ return np.clip(result, 0, 255).astype(np.uint8)
356
+
357
 
358
  # ==============================================================================
359
  # CHUNKED VIDEO PROCESSOR
360
  # ==============================================================================
361
 
362
  class ChunkedVideoProcessor:
363
+ """Handles splitting and reassembling videos (video-only; audio added later)."""
364
 
365
  def __init__(self, temp_dir: str, config: ProcessingConfig):
366
  self.temp_dir = temp_dir
 
371
  def should_chunk_video(self, video_path: str) -> bool:
372
  try:
373
  with VideoFileClip(video_path) as clip:
374
+ duration = clip.duration or 0
375
  should_chunk = duration > self.config.MAX_CHUNK_DURATION
376
  logger.info(f"Video duration: {duration:.1f}s, chunking: {should_chunk}")
377
  return should_chunk
 
382
  def split_video_into_chunks(self, video_path: str) -> List[str]:
383
  try:
384
  with VideoFileClip(video_path) as clip:
385
+ duration = clip.duration or 0.0
386
+ chunk_paths: List[str] = []
387
 
388
  chunk_duration = self.config.MAX_CHUNK_DURATION
389
+ current_time = 0.0
390
  chunk_index = 0
391
 
392
+ while current_time < duration - 1e-6:
393
  end_time = min(current_time + chunk_duration, duration)
394
 
395
  remaining_after = duration - end_time
 
411
  chunk_clip.close()
412
 
413
  chunk_paths.append(chunk_path)
414
+ logger.info(f"Created chunk {chunk_index}: {current_time:.2f}s - {end_time:.2f}s")
415
 
416
  if end_time < duration:
417
+ overlap_seconds = self.config.CHUNK_OVERLAP_FRAMES / (clip.fps or 30.0)
418
+ current_time = max(0.0, end_time - overlap_seconds)
419
  else:
420
  current_time = duration
421
 
 
436
  logger.info("Single chunk, copied directly")
437
  return output_path
438
 
439
+ # Compose video-only
440
+ chunk_clips = [VideoFileClip(p) for p in processed_chunk_paths]
441
+ logger.info(f"Concatenating {len(chunk_clips)} chunks…")
 
 
 
442
  final_clip = concatenate_videoclips(chunk_clips, method="compose")
 
 
443
  final_clip.write_videofile(
444
  output_path,
445
  codec=self.config.VIDEO_CODEC,
 
447
  verbose=False,
448
  logger=None
449
  )
 
450
  final_clip.close()
451
+ for c in chunk_clips:
452
+ c.close()
453
 
454
  logger.info(f"Successfully reassembled video: {output_path}")
455
  return output_path
 
458
  logger.error(f"Chunk reassembly failed: {e}")
459
  raise
460
 
461
+
462
  # ==============================================================================
463
  # MAIN TWOSTAGEPROCESSOR CLASS
464
  # ==============================================================================
 
480
 
481
  # Initialize AudioProcessor for proper audio handling
482
  if AudioProcessor and self.config.USE_FFMPEG_FOR_AUDIO:
483
+ try:
484
+ self.audio_processor = AudioProcessor(temp_dir=self.temp_dir)
485
+ logger.info("AudioProcessor initialized for ffmpeg-based audio handling")
486
+ except Exception as e:
487
+ self.audio_processor = None
488
+ logger.warning(f"AudioProcessor failed to initialize ({e}). Using MoviePy fallback for audio.")
489
  else:
490
  self.audio_processor = None
491
  logger.warning("AudioProcessor not available - using MoviePy fallback for audio")
 
500
  background_path: str,
501
  output_path: str,
502
  quality: str = 'medium',
503
+ trim_seconds: Optional[float] = None,
504
  callback: Optional[callable] = None,
505
+ **kwargs) -> Tuple[Optional[str], str]:
506
  """
507
  Main processing pipeline with proper audio handling.
508
+
509
+ Returns:
510
+ (final_output_path, status_message)
511
  """
512
  try:
513
  logger.info(f"🎬 Enhanced Two-Stage Alpha Pipeline: {video_path}")
514
  logger.info(f"🎯 Background: {background_path}")
515
  logger.info(f"πŸ“ Temp: {self.temp_dir}")
516
  logger.info(f"🎀 Audio: {'Enabled (ffmpeg)' if self.audio_processor else 'Enabled (MoviePy)'}")
517
+ if trim_seconds is not None:
518
+ logger.info(f"βœ‚οΈ Trim requested: first {trim_seconds}s will be processed")
519
+ else:
520
+ logger.info("⏱️ No trimming requested; full duration will be processed")
521
 
522
+ # Extract original audio (if any)
523
  original_audio_path = None
524
  if self.config.PRESERVE_ORIGINAL_AUDIO:
525
  original_audio_path = self._extract_audio(video_path)
 
528
  else:
529
  logger.info("ℹ️ No audio found in source video")
530
 
 
 
 
 
 
531
  # Determine if chunking is needed
532
  needs_chunking = self.chunked_processor.should_chunk_video(video_path)
533
 
534
  if needs_chunking:
535
+ logger.info("Using chunked processing for long video…")
536
  result = self._process_chunked_video(
537
  video_path, background_path, output_path,
538
  quality, original_audio_path, callback
539
  )
540
  else:
541
+ logger.info("Processing full video in single pass…")
542
  result = self._process_single_video(
543
  video_path, background_path, output_path,
544
  quality, trim_seconds, original_audio_path, callback
 
546
 
547
  # Verify final output
548
  if result[0] and os.path.exists(result[0]):
549
+ try:
550
+ with VideoFileClip(result[0]) as clip:
551
+ final_duration = clip.duration or 0.0
552
+ has_audio = clip.audio is not None
553
+ logger.info(f"βœ… Final output: {final_duration:.1f}s, Audio: {has_audio}")
554
+ except Exception:
555
+ pass
556
 
557
  logger.info(self.memory_manager.get_memory_report())
558
  return result
 
563
  logger.error(f"Traceback: {traceback.format_exc()}")
564
  return None, error_msg
565
 
566
+ # ------------------------------------------------------------------
567
+ # Audio helpers
568
+ # ------------------------------------------------------------------
569
  def _extract_audio(self, video_path: str) -> Optional[str]:
570
  """Extract audio using AudioProcessor (ffmpeg) or MoviePy fallback."""
571
  try:
572
  if self.audio_processor:
573
  # Use AudioProcessor with ffmpeg
574
+ logger.info("Extracting audio using ffmpeg…")
575
  audio_path = self.audio_processor.extract_audio(
576
  video_path=video_path,
577
  output_path=os.path.join(self.temp_dir, "original_audio.aac"),
 
581
  return audio_path
582
  else:
583
  # Fallback to MoviePy
584
+ logger.info("Extracting audio using MoviePy…")
585
  audio_path = os.path.join(self.temp_dir, "original_audio.m4a")
586
  with VideoFileClip(video_path) as clip:
587
  if clip.audio is not None:
 
599
  logger.warning(f"Could not extract audio: {e}")
600
  return None
601
 
602
+ def _add_audio_to_video(self, video_path: str, audio_path: Optional[str], output_path: str) -> str:
603
  """Add audio to video using AudioProcessor (ffmpeg) or MoviePy fallback."""
604
  try:
605
  if self.audio_processor and audio_path:
606
+ # Using ffmpeg via AudioProcessor.
607
+ # Note: AudioProcessor.add_audio_to_video accepts an input for "original_video".
608
+ # It's OK to pass an audio-only file here; the function maps [1:a:0] as audio.
609
+ logger.info("Adding audio using ffmpeg (lossless where possible)…")
610
  return self.audio_processor.add_audio_to_video(
611
+ original_video=audio_path, # Source of audio (audio-only or original video)
612
+ processed_video=video_path, # Video without audio
613
  output_path=output_path,
614
  audio_quality=self.config.AUDIO_QUALITY
615
  )
616
  elif audio_path:
617
  # Fallback to MoviePy
618
+ logger.info("Adding audio using MoviePy…")
619
  with VideoFileClip(video_path) as video:
620
  with AudioFileClip(audio_path) as audio:
621
  final = video.set_audio(audio)
 
631
  )
632
  return output_path
633
  else:
634
+ # No audio to add; just copy
635
  shutil.copy2(video_path, output_path)
636
  return output_path
637
 
638
  except Exception as e:
639
  logger.error(f"Failed to add audio: {e}")
640
  # Return video without audio rather than failing completely
641
+ try:
642
+ shutil.copy2(video_path, output_path)
643
+ except Exception:
644
+ pass
645
  return output_path
646
 
647
+ # ------------------------------------------------------------------
648
+ # Chunked processing
649
+ # ------------------------------------------------------------------
650
  def _process_chunked_video(self, video_path: str, background_path: str,
651
  output_path: str, quality: str,
652
  original_audio_path: Optional[str],
653
+ callback: Optional[callable]) -> Tuple[Optional[str], str]:
654
  """Process long video in chunks."""
655
  try:
656
  if callback:
657
+ callback("Splitting video into chunks…", 5)
658
 
659
  chunk_paths = self.chunked_processor.split_video_into_chunks(video_path)
660
+ if not chunk_paths:
661
+ raise RuntimeError("No chunks were created")
662
 
663
+ processed_chunks: List[str] = []
664
  total_chunks = len(chunk_paths)
665
 
666
  for i, chunk_path in enumerate(chunk_paths):
667
  if callback:
668
+ progress = 10 + (i * 70 // max(1, total_chunks))
669
+ callback(f"Processing chunk {i+1}/{total_chunks}…", progress)
670
 
671
  logger.info(f"Processing chunk {i+1}/{total_chunks}")
672
 
 
686
  self.memory_manager.cleanup_stage(f"Chunk_{i+1}", force=True)
687
 
688
  if callback:
689
+ callback("Reassembling video…", 85)
690
 
691
+ # Reassemble chunks (video only)
692
  temp_output = os.path.join(self.temp_dir, "reassembled_no_audio.mp4")
693
+ final_video_no_audio = self.chunked_processor.reassemble_chunks(processed_chunks, temp_output)
694
 
695
  # Add original audio back
696
  if original_audio_path:
697
  if callback:
698
+ callback("Adding audio track…", 95)
699
+ final_path = self._add_audio_to_video(final_video_no_audio, original_audio_path, output_path)
700
  else:
701
+ shutil.move(final_video_no_audio, output_path)
702
  final_path = output_path
703
 
704
  if callback:
 
708
 
709
  except Exception as e:
710
  logger.error(f"Chunked processing failed: {e}")
711
+ return None, f"Chunked processing failed: {e}"
712
 
713
+ # ------------------------------------------------------------------
714
+ # Single-pass processing
715
+ # ------------------------------------------------------------------
716
  def _process_single_video(self, video_path: str, background_path: str,
717
  output_path: str, quality: str,
718
+ trim_seconds: Optional[float],
719
  original_audio_path: Optional[str],
720
+ callback: Optional[callable]) -> Tuple[Optional[str], str]:
721
+ """Process single video/chunk (video-only here; audio added at the end)."""
722
  try:
723
+ # Optional trim (explicit only)
724
+ input_for_masks = video_path
725
+ if trim_seconds is not None and trim_seconds > 0:
726
+ # If you pass trim_seconds, we trim for *this* run.
727
+ trimmed_path = os.path.join(self.temp_dir, "trimmed_input.mp4")
728
+ self._trim_video(video_path, trimmed_path, trim_seconds)
729
+ input_for_masks = trimmed_path
730
+ logger.info(f"Trim applied: {trim_seconds}s (only because trim_seconds was provided)")
731
+ else:
732
+ logger.info("No trimming applied in this run")
733
+
734
  # Stage 1: Reference mask
735
  if callback:
736
+ callback("Creating reference mask…", 10)
737
+ reference_mask_path = self._stage1_create_reference_mask(input_for_masks)
738
  self.memory_manager.cleanup_stage("SAM2", force=True)
739
 
740
  # Stage 2: MatAnyone
741
  if callback:
742
+ callback("Processing with MatAnyone…", 30)
743
  alpha_video_path = self._stage2_matanyone_processing(
744
+ video_path=input_for_masks, reference_mask_path=reference_mask_path
745
  )
746
  self.memory_manager.cleanup_stage("MatAnyone", force=True)
747
 
748
+ # Stage 3: Alpha compositing (video-only)
749
  if callback:
750
+ callback("Compositing with background…", 70)
 
 
751
  temp_output = os.path.join(self.temp_dir, "composite_no_audio.mp4")
752
  composite_path = self._stage3_alpha_composite(
753
+ original_video_path=input_for_masks,
754
+ alpha_video_path=alpha_video_path,
755
+ background_path=background_path,
756
+ output_path=temp_output,
757
+ quality=quality
758
  )
759
 
760
+ # Add audio if we have it
761
  if original_audio_path:
762
  if callback:
763
+ callback("Adding audio track…", 90)
764
  final_path = self._add_audio_to_video(composite_path, original_audio_path, output_path)
765
  else:
766
  shutil.move(composite_path, output_path)
 
773
 
774
  except Exception as e:
775
  logger.error(f"Single video processing failed: {e}")
776
+ return None, f"Single video processing failed: {e}"
777
 
778
+ # ------------------------------------------------------------------
779
+ # Stage 1: Reference mask creation
780
+ # ------------------------------------------------------------------
781
  def _stage1_create_reference_mask(self, video_path: str) -> str:
782
+ """Create reference mask from first frames using SAM2."""
783
+ cap = None
784
  try:
785
  cap = cv2.VideoCapture(video_path)
786
  if not cap.isOpened():
787
  raise RuntimeError(f"Cannot open video: {video_path}")
788
 
789
+ frames: List[np.ndarray] = []
790
  frame_count = 0
791
 
792
  while frame_count < self.config.REFERENCE_FRAMES:
793
  ret, frame = cap.read()
794
  if not ret:
795
  break
 
796
  frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
797
  frames.append(frame_rgb)
798
  frame_count += 1
799
 
 
 
800
  if not frames:
801
  raise RuntimeError("No frames extracted for reference mask")
802
 
803
  logger.info(f"Extracted {len(frames)} reference frames")
804
 
805
+ masks: List[np.ndarray] = []
806
  for i, frame in enumerate(frames):
807
  try:
808
+ logger.info(f"Creating mask for frame {i+1}/{len(frames)}…")
809
  mask = self._create_sam2_mask(frame)
 
810
  if mask is not None:
811
+ if mask.dtype == np.uint8:
812
+ mask = mask.astype(np.float32) / 255.0
813
  masks.append(mask)
 
814
  if self.config.SAVE_DEBUG_FILES:
815
  debug_path = os.path.join(self.temp_dir, f"debug_mask_{i}.png")
816
+ cv2.imwrite(debug_path, np.clip(mask * 255, 0, 255).astype(np.uint8))
 
817
  except Exception as e:
818
  logger.warning(f"Failed to create mask for frame {i}: {e}")
819
  continue
 
822
  raise RuntimeError("No valid reference masks created")
823
 
824
  combined_mask = self._combine_reference_masks(masks)
 
825
  reference_mask_path = os.path.join(self.temp_dir, "reference_mask.png")
826
+ cv2.imwrite(reference_mask_path, np.clip(combined_mask * 255, 0, 255).astype(np.uint8))
 
827
  logger.info(f"βœ… Reference mask created: {reference_mask_path}")
828
  return reference_mask_path
829
 
830
  except Exception as e:
831
  logger.error(f"Stage 1 failed: {e}")
832
  raise
833
+ finally:
834
+ try:
835
+ if cap is not None:
836
+ cap.release()
837
+ except Exception:
838
+ pass
839
 
840
  def _create_sam2_mask(self, frame_rgb: np.ndarray) -> Optional[np.ndarray]:
841
+ """Create mask using SAM2Handler; try a few signatures."""
842
  try:
843
  methods_to_try = [
844
  lambda: self.sam2_handler.create_mask(frame_rgb),
845
  lambda: self.sam2_handler.create_mask(frame_rgb, None, None, None),
846
  lambda: self.sam2_handler.predict(frame_rgb) if hasattr(self.sam2_handler, 'predict') else None
847
  ]
 
848
  for method in methods_to_try:
849
  try:
850
  mask = method()
851
  if mask is not None:
852
+ if isinstance(mask, dict) and 'mask' in mask:
853
+ mask = mask['mask']
854
  if mask.dtype == np.uint8:
855
  mask = mask.astype(np.float32) / 255.0
856
+ return np.clip(mask, 0.0, 1.0)
857
+ except (TypeError, AttributeError):
 
858
  continue
 
859
  logger.warning("All SAM2 methods failed")
860
  return None
 
861
  except Exception as e:
862
  logger.error(f"SAM2 mask creation failed: {e}")
863
  return None
864
 
865
+ def _combine_reference_masks(self, masks: List[np.ndarray]) -> np.ndarray:
866
+ """Combine multiple masks with simple voting/averaging."""
867
  if len(masks) == 1:
868
  return masks[0]
 
869
  stacked = np.stack(masks, axis=0)
870
  avg_mask = np.mean(stacked, axis=0)
 
871
  threshold = 0.3
872
+ result = np.where(avg_mask > threshold, avg_mask, 0.0).astype(np.float32)
 
873
  logger.info(f"Combined {len(masks)} masks with threshold {threshold}")
874
  return result
875
 
876
+ # ------------------------------------------------------------------
877
+ # Stage 2: MatAnyone
878
+ # ------------------------------------------------------------------
879
+ def _stage2_matanyone_processing(self, video_path: str, reference_mask_path: str) -> str:
880
+ """Run MatAnyone to produce an alpha video (no trimming here)."""
881
  try:
 
 
 
 
 
 
882
  matanyone_dir = os.path.join(self.temp_dir, "matanyone_out")
883
  os.makedirs(matanyone_dir, exist_ok=True)
884
 
885
  profile = QualityManager.get_profile('high')
886
  max_size = profile.get('max_size', 1920)
887
 
888
+ # Known kwargs signature (from your logs):
889
+ # ['input_path','mask_path','output_path','n_warmup','r_erode','r_dilate','suffix','save_image','max_size']
890
+ tried = False
891
  try:
892
  self.matanyone_handler.process_video(
893
+ input_path=video_path,
894
  mask_path=reference_mask_path,
895
  output_path=matanyone_dir,
896
+ n_warmup=0, # βœ… do NOT limit to 5 seconds
897
  r_erode=0,
898
  r_dilate=15,
899
  suffix='pha',
900
  save_image=False,
901
  max_size=max_size
902
  )
903
+ tried = True
904
  except Exception as e:
905
+ logger.warning(f"Explicit kwargs failed: {e}")
906
+
907
+ if not tried:
908
+ # Positional fallback (older builds sometimes expose a simpler signature)
909
+ try:
910
+ self.matanyone_handler.process_video(video_path, reference_mask_path, matanyone_dir)
911
+ tried = True
912
+ except Exception as e:
913
+ logger.error(f"Positional call to MatAnyone failed: {e}")
914
+
915
+ if not tried:
916
+ raise RuntimeError("MatAnyone.process_video could not be invoked with known signatures")
917
 
918
+ # Find alpha video in output
919
  alpha_output_path = None
920
  for filename in os.listdir(matanyone_dir):
921
+ low = filename.lower()
922
+ if low.endswith('_pha.mp4') or low == 'pha.mp4' or ('alpha' in low and low.endswith('.mp4')):
923
  alpha_output_path = os.path.join(matanyone_dir, filename)
924
  break
925
 
926
  if not alpha_output_path or not os.path.exists(alpha_output_path):
927
+ # Sometimes MatAnyone may place files inside a nested folder; check subdirs
928
+ for root, _, files in os.walk(matanyone_dir):
929
+ for f in files:
930
+ low = f.lower()
931
+ if low.endswith('_pha.mp4') or low == 'pha.mp4' or ('alpha' in low and low.endswith('.mp4')):
932
+ alpha_output_path = os.path.join(root, f)
933
+ break
934
+ if alpha_output_path:
935
+ break
936
 
937
+ if not alpha_output_path or not os.path.exists(alpha_output_path):
938
+ raise RuntimeError("MatAnyone did not create an alpha video")
939
+
940
+ # Verify basic properties
941
  cap = cv2.VideoCapture(alpha_output_path)
942
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
943
+ fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
944
  duration = frame_count / fps if fps > 0 else 0
945
  cap.release()
946
 
 
954
  logger.error(f"Stage 2 failed: {e}")
955
  raise
956
 
957
+ # ------------------------------------------------------------------
958
+ # Stage 3: Alpha compositing (video-only)
959
+ # ------------------------------------------------------------------
960
  def _stage3_alpha_composite(self, original_video_path: str,
961
  alpha_video_path: str,
962
  background_path: str,
963
  output_path: str,
964
  quality: str) -> str:
965
+ """Composite original RGB over background using alpha video (no audio here)."""
966
+ original_cap = None
967
+ alpha_cap = None
968
+ bg_cap = None
969
  try:
970
  original_cap = cv2.VideoCapture(original_video_path)
971
  alpha_cap = cv2.VideoCapture(alpha_video_path)
 
973
  if not original_cap.isOpened() or not alpha_cap.isOpened():
974
  raise RuntimeError("Cannot open videos for compositing")
975
 
976
+ fps = float(original_cap.get(cv2.CAP_PROP_FPS) or 30.0)
977
+ width = int(original_cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
978
+ height = int(original_cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
979
+ total_frames = int(original_cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
980
 
981
+ # Load background (image or looping video)
982
+ bg_is_video = False
983
+ bg_image = None
984
+ if background_path.lower().endswith(('.mp4', '.avi', '.mov', '.mkv', '.webm')):
985
  bg_cap = cv2.VideoCapture(background_path)
986
+ if not bg_cap.isOpened():
987
+ raise RuntimeError(f"Cannot open background video: {background_path}")
988
  bg_is_video = True
989
  else:
990
  bg_image = cv2.imread(background_path)
991
  if bg_image is None:
992
+ raise RuntimeError(f"Cannot load background image: {background_path}")
993
  bg_image = cv2.resize(bg_image, (width, height))
 
994
 
995
  # Setup video writer (NO AUDIO)
996
  fourcc = cv2.VideoWriter_fourcc(*'mp4v')
997
  out = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
998
+ if not out.isOpened():
999
+ raise RuntimeError("Failed to open VideoWriter for composite")
1000
 
1001
  frame_count = 0
1002
  while True:
 
1006
  if not ret_orig or not ret_alpha:
1007
  break
1008
 
1009
+ # Background frame
1010
  if bg_is_video:
1011
  ret_bg, bg_frame = bg_cap.read()
1012
  if not ret_bg:
1013
+ bg_cap.set(cv2.CAP_PROP_POS_FRAMES, 0) # loop
1014
  ret_bg, bg_frame = bg_cap.read()
1015
  bg_frame = cv2.resize(bg_frame, (width, height))
1016
  else:
1017
  bg_frame = bg_image.copy()
1018
 
1019
+ # Alpha extraction (single channel 0..1)
1020
+ if alpha_frame.ndim == 3:
1021
  alpha_mask = cv2.cvtColor(alpha_frame, cv2.COLOR_BGR2GRAY)
1022
  else:
1023
  alpha_mask = alpha_frame
 
1024
  if alpha_mask.shape[:2] != orig_frame.shape[:2]:
1025
+ alpha_mask = cv2.resize(alpha_mask, (width, height), interpolation=cv2.INTER_LINEAR)
1026
 
1027
+ # Refine & normalize
1028
  alpha_refined = self.alpha_refiner.refine_alpha_matte(alpha_mask, self.config)
1029
 
1030
+ # Composite (use float math, then cast)
1031
+ a3 = np.dstack([alpha_refined] * 3).astype(np.float32)
1032
+ fg = orig_frame.astype(np.float32)
1033
+ bg = bg_frame.astype(np.float32)
1034
+ mixed = a3 * fg + (1.0 - a3) * bg
1035
 
1036
+ # Optional defringing (light blur at edges)
1037
  if self.config.ENABLE_DEFRINGING:
1038
+ mixed = self.alpha_refiner.defringe_edges(
1039
+ mixed.astype(np.uint8),
1040
  alpha_refined,
1041
  self.config.DEFRINGE_RADIUS
1042
  )
1043
 
1044
+ out.write(mixed.astype(np.uint8))
1045
  frame_count += 1
1046
 
1047
+ if frame_count % max(1, self.config.MEMORY_CHECK_INTERVAL) == 0:
1048
  logger.info(f"Compositing: {frame_count}/{total_frames}")
1049
 
 
 
 
1050
  out.release()
1051
+ logger.info(f"βœ… Compositing completed: {frame_count} frames β†’ {output_path}")
 
 
 
1052
  return output_path
1053
 
1054
  except Exception as e:
1055
  logger.error(f"Stage 3 compositing failed: {e}")
1056
  raise
1057
+ finally:
1058
+ try:
1059
+ if original_cap is not None:
1060
+ original_cap.release()
1061
+ except Exception:
1062
+ pass
1063
+ try:
1064
+ if alpha_cap is not None:
1065
+ alpha_cap.release()
1066
+ except Exception:
1067
+ pass
1068
+ try:
1069
+ if bg_cap is not None:
1070
+ bg_cap.release()
1071
+ except Exception:
1072
+ pass
1073
 
1074
+ # ------------------------------------------------------------------
1075
+ # Small helpers
1076
+ # ------------------------------------------------------------------
1077
+ def _trim_video(self, input_path: str, output_path: str, seconds: float):
1078
+ """Trim video to specified duration (video-only)."""
1079
  try:
1080
  with VideoFileClip(input_path) as clip:
1081
+ trimmed = clip.subclip(0, min(seconds, clip.duration or seconds))
1082
  trimmed.write_videofile(
1083
  output_path,
1084
  codec=self.config.VIDEO_CODEC,
 
1086
  verbose=False,
1087
  logger=None
1088
  )
1089
+ logger.info(f"Video trimmed to {seconds}s β†’ {output_path}")
1090
  except Exception as e:
1091
  logger.error(f"Video trimming failed: {e}")
1092
  raise
 
1107
 
1108
  def get_processing_stats(self) -> Dict[str, Any]:
1109
  """Get processing statistics."""
1110
+ stats: Dict[str, Any] = {
1111
  'temp_dir': self.temp_dir,
1112
  'memory_info': self.memory_manager.get_gpu_memory_info(),
1113
  'audio_processor': 'ffmpeg' if self.audio_processor else 'MoviePy',
 
1117
  'chunk_duration': self.config.MAX_CHUNK_DURATION
1118
  }
1119
  }
1120
+ try:
1121
+ if self.audio_processor:
1122
+ stats['audio_stats'] = self.audio_processor.get_stats()
1123
+ except Exception:
1124
+ pass
1125
  return stats
1126
 
1127
+
1128
  # ==============================================================================
1129
  # EXPORT INTERFACE
1130
  # ==============================================================================
1131
 
1132
+ def create_two_stage_processor(sam2_handler, matanyone_handler, **kwargs) -> TwoStageProcessor:
1133
  """Factory function to create TwoStageProcessor."""
1134
  return TwoStageProcessor(sam2_handler, matanyone_handler, **kwargs)
1135
 
1136
+
1137
  if __name__ == "__main__":
1138
  logger.info("Enhanced TwoStageProcessor with Audio Support")
1139
  logger.info("Features:")
1140
  logger.info(" βœ… ffmpeg-based audio via AudioProcessor")
1141
+ logger.info(" βœ… Full video duration processing (no 5-second limit unless you pass trim_seconds)")
1142
  logger.info(" βœ… Direct alpha compositing (no green screen)")
1143
  logger.info(" βœ… Memory-efficient chunked processing")
1144
+ logger.info(" βœ… Robust MatAnyone integration with signature fallbacks")
1145
  logger.info("")
1146
  logger.info("Use create_two_stage_processor(sam2_handler, matanyone_handler) to initialize")