Add support for merging video and chat with configurable layout options

This commit is contained in:
MaddoScientisto 2026-02-10 00:06:49 +01:00
commit 832bf4cf36
6 changed files with 199 additions and 10 deletions

View file

@ -12,6 +12,7 @@ TWITCH_GQL_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
PREFIX_LIVE = "LIVE_"
PREFIX_VOD = "VOD_"
PREFIX_CHAT = "CHAT_"
PREFIX_MERGED = "MERGED_"
PREFIX_METADATA = "METADA_" # Note: keeping original typo for compatibility
# Default configuration values
@ -27,6 +28,8 @@ DEFAULT_CONFIG = {
'downloadVOD': True,
'downloadCHAT': True,
'downloadLiveCHAT': True,
'mergeVideoChat': True, # Merge video and chat into single file
'mergeChatLayout': 'side-by-side', # Layout: 'side-by-side' or 'overlay'
'vodTimeout': 300,
'uploadCloud': True,
'deleteFiles': False,

View file

@ -136,6 +136,17 @@ class ContentDownloader:
print(f'{Fore.RED}✗ Chat JSON file not found: {json_path}{Style.RESET_ALL}')
return False
# Validate JSON file has content (check if file size is reasonable)
try:
file_size = os.path.getsize(json_path)
if file_size < 100: # Less than 100 bytes means likely empty or invalid
print(f'{Fore.RED}✗ Chat JSON file is too small or incomplete ({file_size} bytes){Style.RESET_ALL}')
print(f'{Fore.YELLOW} This can happen when stream recording is interrupted{Style.RESET_ALL}')
return False
except Exception as e:
print(f'{Fore.RED}✗ Error checking chat JSON file: {str(e)}{Style.RESET_ALL}')
return False
bin_path = get_bin_path()
# Chat rendering settings
@ -148,19 +159,20 @@ class ContentDownloader:
'--font-size', '22',
'--update-rate', '1.0',
'--offline',
'--output-args', output_args,
'--ffmpeg-path', self.ffmpeg_path,
'--temp-path', os.path.join(bin_path, 'temp'),
'--collision', 'Rename'
]
# Add output args using = syntax to avoid parsing issues
if output_args:
chat_settings.append(f'--output-args={output_args}')
try:
print(f'{Fore.YELLOW}Rendering chat video...{Style.RESET_ALL}')
# Debug output
# Build complete command
full_cmd = [self.twitch_downloader_path, 'chatrender', '-i', json_path, '-o', video_path] + chat_settings
print(f'{Fore.CYAN}DEBUG - Chat render command:{Style.RESET_ALL}')
print(f'{Fore.CYAN} Output args passed: {repr(output_args)}{Style.RESET_ALL}')
result = subprocess.call(full_cmd)

View file

@ -167,5 +167,96 @@ class StreamProcessor:
# Default software encoding
result = f'-c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p "{{save_path}}"'
print(f'{Fore.CYAN}DEBUG - Generated output_args: {result}{Style.RESET_ALL}')
return result
def merge_video_and_chat(self, video_path: str, chat_path: str, output_path: str, layout: str = 'side-by-side') -> bool:
"""
Merge video and chat into a single file.
Args:
video_path: Path to the main video file
chat_path: Path to the chat video file
output_path: Path for the merged output file
layout: Merge layout - 'side-by-side' or 'overlay' (default: 'side-by-side')
Returns:
bool: True if merge succeeded, False otherwise
"""
if not os.path.exists(video_path):
print(f'{Fore.RED}✗ Video file not found: {video_path}{Style.RESET_ALL}')
return False
if not os.path.exists(chat_path):
print(f'{Fore.RED}✗ Chat file not found: {chat_path}{Style.RESET_ALL}')
return False
print(f'{Fore.YELLOW}Merging video and chat ({layout})...{Style.RESET_ALL}')
try:
if layout == 'overlay':
# Overlay chat on top of video (right side)
filter_complex = (
'[0:v]scale=-2:1080[main];'
'[1:v]scale=500:1080[chat];'
'[main][chat]overlay=main_w-overlay_w:0[outv]'
)
else:
# Side-by-side layout (default)
filter_complex = (
'[0:v]scale=-2:1080[main];'
'[1:v]scale=500:1080[chat];'
'[main][chat]hstack=inputs=2[outv]'
)
cmd = [
self.ffmpeg_path,
'-y',
'-i', video_path,
'-i', chat_path,
'-filter_complex', filter_complex,
'-map', '[outv]',
'-map', '0:a?', # Use audio from main video if available
]
# Add hardware acceleration for encoding if available
if self.hwaccel_type and self.hwaccel_type != 'none':
encoder = get_hwaccel_encoder(self.hwaccel_type)
cmd.extend(['-c:v', encoder])
if 'nvenc' in encoder:
cmd.extend(['-preset', 'p4', '-cq', '18'])
elif 'qsv' in encoder:
cmd.extend(['-global_quality', '18'])
elif 'amf' in encoder:
cmd.extend(['-qp_i', '18'])
else:
cmd.extend(['-preset', 'medium', '-crf', '18'])
else:
cmd.extend(['-c:v', 'libx264', '-preset', 'medium', '-crf', '18'])
# Audio codec
cmd.extend(['-c:a', 'copy']) # Copy audio without re-encoding
# Pixel format and faststart
cmd.extend(['-pix_fmt', 'yuv420p'])
if self.ffmpeg_faststart and output_path.endswith('.mp4'):
cmd.extend(['-movflags', '+faststart'])
cmd.append(output_path)
# Run FFmpeg
if self.ffmpeg_progress:
result = subprocess.call(cmd)
else:
result = subprocess.call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
if result == 0:
print(f'{Fore.GREEN}✓ Video and chat merged successfully{Style.RESET_ALL}')
return True
else:
print(f'{Fore.RED}✗ Merge failed with exit code: {result}{Style.RESET_ALL}')
return False
except Exception as e:
print(f'{Fore.RED}✗ Merge failed: {str(e)}{Style.RESET_ALL}')
return False