sbs working phase 1

This commit is contained in:
2025-07-30 18:07:26 -07:00
parent 6617acb1c9
commit 70044e1b10
8 changed files with 2417 additions and 7 deletions

306
core/video_assembler.py Normal file
View File

@@ -0,0 +1,306 @@
"""
Video assembler module for concatenating processed segments.
Handles merging processed segments and adding audio from original video.
"""
import os
import subprocess
import logging
from typing import List, Optional
from utils.file_utils import get_segments_directories, file_exists
logger = logging.getLogger(__name__)
class VideoAssembler:
"""Handles final video assembly from processed segments."""
def __init__(self, preserve_audio: bool = True, use_nvenc: bool = False,
output_mode: str = "green_screen"):
"""
Initialize video assembler.
Args:
preserve_audio: Whether to preserve audio from original video
use_nvenc: Whether to use hardware encoding for final output
output_mode: Output mode - "green_screen" or "alpha_channel"
"""
self.preserve_audio = preserve_audio
self.use_nvenc = use_nvenc
self.output_mode = output_mode
def create_concat_file(self, segments_dir: str, output_filename: str = "concat_list.txt") -> Optional[str]:
"""
Create a concatenation file for FFmpeg.
Args:
segments_dir: Directory containing processed segments
output_filename: Name for the concat file
Returns:
Path to concat file or None if no valid segments found
"""
concat_path = os.path.join(segments_dir, output_filename)
valid_segments = 0
try:
segments = get_segments_directories(segments_dir)
with open(concat_path, 'w') as f:
for i, segment in enumerate(segments):
segment_dir = os.path.join(segments_dir, segment)
if self.output_mode == "alpha_channel":
output_video = os.path.join(segment_dir, f"output_{i}.mov")
else:
output_video = os.path.join(segment_dir, f"output_{i}.mp4")
if file_exists(output_video):
# Use relative path for FFmpeg
relative_path = os.path.relpath(output_video, segments_dir)
f.write(f"file '{relative_path}'\n")
valid_segments += 1
else:
logger.warning(f"Output video not found for segment {i}: {output_video}")
if valid_segments == 0:
logger.error("No valid output segments found for concatenation")
os.remove(concat_path)
return None
logger.info(f"Created concatenation file with {valid_segments} segments: {concat_path}")
return concat_path
except Exception as e:
logger.error(f"Error creating concatenation file: {e}")
return None
def concatenate_segments(self, segments_dir: str, output_path: str,
bitrate: str = "50M") -> bool:
"""
Concatenate video segments using FFmpeg.
Args:
segments_dir: Directory containing processed segments
output_path: Path for final concatenated video
bitrate: Output video bitrate
Returns:
True if successful
"""
# Create concatenation file
concat_file = self.create_concat_file(segments_dir)
if not concat_file:
return False
try:
# Build FFmpeg command
if self.output_mode == "alpha_channel":
# For alpha channel, we need to maintain the ProRes codec
cmd = [
'ffmpeg',
'-y', # Overwrite output
'-f', 'concat',
'-safe', '0',
'-i', concat_file,
'-c:v', 'copy', # Copy video codec to preserve alpha
'-an', # No audio for now
output_path
]
else:
cmd = [
'ffmpeg',
'-y', # Overwrite output
'-f', 'concat',
'-safe', '0',
'-i', concat_file,
'-c:v', 'copy', # Copy video codec (no re-encoding)
'-an', # No audio for now
output_path
]
# Use hardware encoding if requested
if self.use_nvenc:
import sys
if sys.platform == 'darwin':
encoder = 'hevc_videotoolbox'
else:
encoder = 'hevc_nvenc'
# Re-encode with hardware acceleration
cmd = [
'ffmpeg',
'-y',
'-f', 'concat',
'-safe', '0',
'-i', concat_file,
'-c:v', encoder,
'-preset', 'slow',
'-b:v', bitrate,
'-pix_fmt', 'yuv420p',
'-an',
output_path
]
logger.info(f"Running concatenation command: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
logger.error(f"FFmpeg concatenation failed: {result.stderr}")
return False
logger.info(f"Successfully concatenated segments to: {output_path}")
# Clean up concat file
try:
os.remove(concat_file)
except:
pass
return True
except Exception as e:
logger.error(f"Error during concatenation: {e}")
return False
def copy_audio_from_original(self, original_video: str, processed_video: str,
final_output: str) -> bool:
"""
Copy audio track from original video to processed video.
Args:
original_video: Path to original video with audio
processed_video: Path to processed video without audio
final_output: Path for final output with audio
Returns:
True if successful
"""
if not self.preserve_audio:
logger.info("Audio preservation disabled, skipping audio copy")
return True
try:
# Check if original video has audio
probe_cmd = [
'ffprobe',
'-v', 'error',
'-select_streams', 'a:0',
'-show_entries', 'stream=codec_type',
'-of', 'csv=p=0',
original_video
]
result = subprocess.run(probe_cmd, capture_output=True, text=True)
if result.returncode != 0 or result.stdout.strip() != 'audio':
logger.warning("Original video has no audio track")
# Just copy the processed video
import shutil
shutil.copy2(processed_video, final_output)
return True
# Copy audio from original to processed video
cmd = [
'ffmpeg',
'-y',
'-i', processed_video, # Video input
'-i', original_video, # Audio input
'-c:v', 'copy', # Copy video stream
'-c:a', 'copy', # Copy audio stream
'-map', '0:v:0', # Map video from first input
'-map', '1:a:0', # Map audio from second input
'-shortest', # Match duration to shortest stream
final_output
]
logger.info("Copying audio from original video...")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
logger.error(f"FFmpeg audio copy failed: {result.stderr}")
return False
logger.info(f"Successfully added audio to final video: {final_output}")
return True
except Exception as e:
logger.error(f"Error copying audio: {e}")
return False
def assemble_final_video(self, segments_dir: str, original_video: str,
output_path: str, bitrate: str = "50M") -> bool:
"""
Complete pipeline to assemble final video with audio.
Args:
segments_dir: Directory containing processed segments
original_video: Path to original video (for audio)
output_path: Path for final output video
bitrate: Output video bitrate
Returns:
True if successful
"""
logger.info("Starting final video assembly...")
# Step 1: Concatenate segments
temp_concat_path = os.path.join(os.path.dirname(output_path), "temp_concat.mp4")
if not self.concatenate_segments(segments_dir, temp_concat_path, bitrate):
logger.error("Failed to concatenate segments")
return False
# Step 2: Add audio from original
if self.preserve_audio and file_exists(original_video):
success = self.copy_audio_from_original(original_video, temp_concat_path, output_path)
# Clean up temp file
try:
os.remove(temp_concat_path)
except:
pass
return success
else:
# No audio to add, just rename temp file
import shutil
try:
shutil.move(temp_concat_path, output_path)
logger.info(f"Final video saved to: {output_path}")
return True
except Exception as e:
logger.error(f"Error moving final video: {e}")
return False
def verify_segment_completeness(self, segments_dir: str) -> tuple[bool, List[int]]:
"""
Verify all segments have been processed.
Args:
segments_dir: Directory containing segments
Returns:
Tuple of (all_complete, missing_segments)
"""
segments = get_segments_directories(segments_dir)
missing_segments = []
for i, segment in enumerate(segments):
segment_dir = os.path.join(segments_dir, segment)
if self.output_mode == "alpha_channel":
output_video = os.path.join(segment_dir, f"output_{i}.mov")
else:
output_video = os.path.join(segment_dir, f"output_{i}.mp4")
if not file_exists(output_video):
missing_segments.append(i)
all_complete = len(missing_segments) == 0
if all_complete:
logger.info(f"All {len(segments)} segments have been processed")
else:
logger.warning(f"Missing output for segments: {missing_segments}")
return all_complete, missing_segments