diff --git a/config/global.json.example b/config/global.json.example index bf2b07b..fb37572 100644 --- a/config/global.json.example +++ b/config/global.json.example @@ -9,6 +9,8 @@ "downloadVOD": true, "downloadCHAT": true, "downloadLiveCHAT": true, + "mergeVideoChat": true, + "mergeChatLayout": "side-by-side", "vodTimeout": 300, "uploadCloud": true, "deleteFiles": false, diff --git a/config/global.schema.json b/config/global.schema.json index f021775..84473ef 100644 --- a/config/global.schema.json +++ b/config/global.schema.json @@ -52,6 +52,17 @@ "default": true, "description": "Download chat during live stream: false = disabled, true = enabled" }, + "mergeVideoChat": { + "type": "boolean", + "default": true, + "description": "Merge video and chat into a single file: false = disabled, true = enabled" + }, + "mergeChatLayout": { + "type": "string", + "enum": ["side-by-side", "overlay"], + "default": "side-by-side", + "description": "Chat merge layout: side-by-side (video + chat horizontally) or overlay (chat overlaid on video)" + }, "vodTimeout": { "type": "integer", "default": 300, diff --git a/modules/constants.py b/modules/constants.py index b5e0cb3..7f09e35 100644 --- a/modules/constants.py +++ b/modules/constants.py @@ -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, diff --git a/modules/downloader.py b/modules/downloader.py index 09d515a..f4d2f29 100644 --- a/modules/downloader.py +++ b/modules/downloader.py @@ -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) diff --git a/modules/processor.py b/modules/processor.py index 0a696ee..c106546 100644 --- a/modules/processor.py +++ b/modules/processor.py @@ -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 diff --git a/twitch-archive.py b/twitch-archive.py index 8e2437a..af417d8 100644 --- a/twitch-archive.py +++ b/twitch-archive.py @@ -43,7 +43,7 @@ from pytz import timezone from dotenv import load_dotenv, find_dotenv # Local module imports -from modules.constants import DEFAULT_CONFIG, PREFIX_LIVE, PREFIX_VOD, PREFIX_CHAT, PREFIX_METADATA +from modules.constants import DEFAULT_CONFIG, PREFIX_LIVE, PREFIX_VOD, PREFIX_CHAT, PREFIX_MERGED, PREFIX_METADATA from modules.config import ConfigManager from modules.notifications import NotificationManager from modules.utils import ( @@ -215,6 +215,10 @@ class TwitchArchive: self._print_toggle('Metadata download', self.downloadMETADATA) self._print_toggle('VOD download', self.downloadVOD) self._print_toggle('Chat download & render', self.downloadCHAT) + if self.downloadCHAT: + self._print_toggle(' ↳ Merge video + chat', self.mergeVideoChat) + if self.mergeVideoChat: + print(f' Layout: {Fore.GREEN}{self.mergeChatLayout}{Style.RESET_ALL}') self._print_toggle('Cloud upload', self.uploadCloud) # Warning messages @@ -401,10 +405,23 @@ class TwitchArchive: live_chat_downloaded = self.downloader.wait_for_chat_download(live_chat_process, chat_json_path) # Render live chat if downloaded successfully + chat_rendered_successfully = False + chat_video_path = None if live_chat_downloaded: chat_video_path = str(self.file_manager.chat_mp4_path / f"{PREFIX_CHAT}{filename_base}.mp4") output_args = self.processor.build_chat_output_args() - self.downloader.render_chat(chat_json_path, chat_video_path, output_args) + chat_rendered_successfully = self.downloader.render_chat(chat_json_path, chat_video_path, output_args) + + # Merge video and chat if configured + merged_video_path = None + if chat_rendered_successfully and self.mergeVideoChat and os.path.exists(live_proc_path) and os.path.exists(chat_video_path): + merged_video_path = str(self.file_manager.video_path / f"{PREFIX_MERGED}{filename_base}{live_proc_ext}") + merge_success = self.processor.merge_video_and_chat( + live_proc_path, + chat_video_path, + merged_video_path, + self.mergeChatLayout + ) # Skip VOD/chat download if shutdown was requested or vodTimeout is 0 vod_response = None @@ -471,9 +488,29 @@ class TwitchArchive: if not live_chat_downloaded: chat_video_path = str(self.file_manager.chat_mp4_path / f"{PREFIX_CHAT}{filename_base}.mp4") output_args = self.processor.build_chat_output_args() - self.downloader.download_and_render_chat(current_vod, chat_json_path, chat_video_path, output_args) + chat_rendered_successfully = self.downloader.download_and_render_chat(current_vod, chat_json_path, chat_video_path, output_args) + + # Merge VOD and chat if configured + if chat_rendered_successfully and self.mergeVideoChat and os.path.exists(vod_path) and os.path.exists(chat_video_path): + merged_vod_path = str(self.file_manager.video_path / f"{PREFIX_MERGED}{PREFIX_VOD}{filename_base}{vod_ext}") + self.processor.merge_video_and_chat( + vod_path, + chat_video_path, + merged_vod_path, + self.mergeChatLayout + ) else: print(f'{Fore.CYAN}Chat already downloaded from live stream, skipping VOD chat download{Style.RESET_ALL}') + + # But still merge VOD with existing chat if configured + if self.mergeVideoChat and os.path.exists(vod_path) and chat_video_path and os.path.exists(chat_video_path): + merged_vod_path = str(self.file_manager.video_path / f"{PREFIX_MERGED}{PREFIX_VOD}{filename_base}{vod_ext}") + self.processor.merge_video_and_chat( + vod_path, + chat_video_path, + merged_vod_path, + self.mergeChatLayout + ) else: print(f'{Fore.YELLOW}⚠ No matching VOD found for this stream{Style.RESET_ALL}') @@ -954,6 +991,8 @@ class TwitchArchiveManager: # Wait for live chat download if it was started live_chat_downloaded = False + chat_rendered_successfully = False + chat_video_path = None if live_chat_process is not None: live_chat_downloaded = archiver.downloader.wait_for_chat_download(live_chat_process, chat_json_path) @@ -961,7 +1000,18 @@ class TwitchArchiveManager: if live_chat_downloaded: chat_video_path = str(archiver.file_manager.chat_mp4_path / f"{PREFIX_CHAT}{filename_base}.mp4") output_args = archiver.processor.build_chat_output_args() - archiver.downloader.render_chat(chat_json_path, chat_video_path, output_args) + chat_rendered_successfully = archiver.downloader.render_chat(chat_json_path, chat_video_path, output_args) + + # Merge video and chat if configured + merged_video_path = None + if chat_rendered_successfully and archiver.mergeVideoChat and os.path.exists(live_proc_path) and os.path.exists(chat_video_path): + merged_video_path = str(archiver.file_manager.video_path / f"{PREFIX_MERGED}{filename_base}{proc_extension}") + archiver.processor.merge_video_and_chat( + live_proc_path, + chat_video_path, + merged_video_path, + archiver.mergeChatLayout + ) # Wait for VOD and download it vod_response = None @@ -1031,9 +1081,29 @@ class TwitchArchiveManager: if archiver.downloadCHAT and not live_chat_downloaded: chat_video_path = str(archiver.file_manager.chat_mp4_path / f"{PREFIX_CHAT}{filename_base}.mp4") output_args = archiver.processor.build_chat_output_args() - archiver.downloader.download_and_render_chat(current_vod, chat_json_path, chat_video_path, output_args) + chat_rendered_successfully = archiver.downloader.download_and_render_chat(current_vod, chat_json_path, chat_video_path, output_args) + + # Merge VOD and chat if configured + if chat_rendered_successfully and archiver.mergeVideoChat and os.path.exists(vod_path) and os.path.exists(chat_video_path): + merged_vod_path = str(archiver.file_manager.video_path / f"{PREFIX_MERGED}{PREFIX_VOD}{filename_base}{vod_ext}") + archiver.processor.merge_video_and_chat( + vod_path, + chat_video_path, + merged_vod_path, + archiver.mergeChatLayout + ) elif live_chat_downloaded: print(f'{Fore.CYAN}Chat already downloaded from live stream, skipping VOD chat download{Style.RESET_ALL}') + + # But still merge VOD with existing chat if configured + if archiver.mergeVideoChat and archiver.downloadVOD and os.path.exists(vod_path) and chat_video_path and os.path.exists(chat_video_path): + merged_vod_path = str(archiver.file_manager.video_path / f"{PREFIX_MERGED}{PREFIX_VOD}{filename_base}{vod_ext}") + archiver.processor.merge_video_and_chat( + vod_path, + chat_video_path, + merged_vod_path, + archiver.mergeChatLayout + ) else: print(f'{Fore.YELLOW}⚠ No matching VOD found for this stream{Style.RESET_ALL}') elif archiver.downloadMETADATA: