From cdef8cf9bbc2ee15deb8fcf6dcd2e0b819e18a32 Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Tue, 10 Feb 2026 08:04:08 +0100 Subject: [PATCH] Add video duration handling for chat rendering and merging --- modules/downloader.py | 19 +++++++++--- modules/processor.py | 14 ++++++--- modules/utils.py | 41 +++++++++++++++++++++++++ twitch-archive.py | 71 +++++++++++++++++++++++++++++++++++++------ 4 files changed, 126 insertions(+), 19 deletions(-) diff --git a/modules/downloader.py b/modules/downloader.py index f4d2f29..fcd4921 100644 --- a/modules/downloader.py +++ b/modules/downloader.py @@ -120,7 +120,8 @@ class ContentDownloader: print(f'{Fore.RED}✗ Chat download failed: {str(e)}{Style.RESET_ALL}') return False - def render_chat(self, json_path: str, video_path: str, output_args: str) -> bool: + def render_chat(self, json_path: str, video_path: str, output_args: str, + video_duration: Optional[float] = None) -> bool: """ Render chat JSON as a video. @@ -128,6 +129,7 @@ class ContentDownloader: json_path: Path to chat JSON file video_path: Path to save rendered chat video output_args: FFmpeg output arguments for encoding + video_duration: Optional video duration in seconds to trim chat to match Returns: bool: True if succeeded, False otherwise @@ -164,6 +166,13 @@ class ContentDownloader: '--collision', 'Rename' ] + # Trim chat to match video duration if provided + if video_duration is not None and video_duration > 0: + # Format duration as seconds with 1 decimal place + duration_str = f'{video_duration:.1f}s' + chat_settings.extend(['-e', duration_str]) + print(f'{Fore.CYAN} Trimming chat to match video duration: {duration_str}{Style.RESET_ALL}') + # Add output args using = syntax to avoid parsing issues if output_args: chat_settings.append(f'--output-args={output_args}') @@ -188,7 +197,8 @@ class ContentDownloader: return False def download_and_render_chat(self, vod_info: Dict[str, Any], json_path: str, - video_path: str, output_args: str) -> bool: + video_path: str, output_args: str, + video_duration: Optional[float] = None) -> bool: """ Download chat logs and render them as video. @@ -197,6 +207,7 @@ class ContentDownloader: json_path: Path to save chat JSON video_path: Path to save rendered chat video output_args: FFmpeg output arguments for encoding + video_duration: Optional video duration in seconds to trim chat to match Returns: bool: True if succeeded, False otherwise @@ -213,8 +224,8 @@ class ContentDownloader: if not self.download_chat_json(vod_id, json_path): return False - # Render chat video - return self.render_chat(json_path, video_path, output_args) + # Render chat video with optional duration trimming + return self.render_chat(json_path, video_path, output_args, video_duration=video_duration) def start_live_chat_download(self, vod_id: str, json_path: str) -> Optional[subprocess.Popen]: """ diff --git a/modules/processor.py b/modules/processor.py index c106546..b1cae71 100644 --- a/modules/processor.py +++ b/modules/processor.py @@ -191,6 +191,7 @@ class StreamProcessor: return False print(f'{Fore.YELLOW}Merging video and chat ({layout})...{Style.RESET_ALL}') + print(f'{Fore.CYAN} This may take several minutes depending on video length{Style.RESET_ALL}') try: if layout == 'overlay': @@ -222,6 +223,7 @@ class StreamProcessor: if self.hwaccel_type and self.hwaccel_type != 'none': encoder = get_hwaccel_encoder(self.hwaccel_type) cmd.extend(['-c:v', encoder]) + print(f'{Fore.CYAN} Using hardware encoder: {encoder}{Style.RESET_ALL}') if 'nvenc' in encoder: cmd.extend(['-preset', 'p4', '-cq', '18']) @@ -232,6 +234,7 @@ class StreamProcessor: else: cmd.extend(['-preset', 'medium', '-crf', '18']) else: + print(f'{Fore.CYAN} Using software encoder: libx264{Style.RESET_ALL}') cmd.extend(['-c:v', 'libx264', '-preset', 'medium', '-crf', '18']) # Audio codec @@ -244,19 +247,20 @@ class StreamProcessor: 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) + # Run FFmpeg with visible output to show progress + print(f'{Fore.CYAN} Processing...{Style.RESET_ALL}') + result = subprocess.call(cmd) 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}') + print(f'{Fore.YELLOW} Check FFmpeg output above for details{Style.RESET_ALL}') return False except Exception as e: print(f'{Fore.RED}✗ Merge failed: {str(e)}{Style.RESET_ALL}') + import traceback + traceback.print_exc() return False diff --git a/modules/utils.py b/modules/utils.py index 88ae7b4..280afa8 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -98,6 +98,47 @@ def get_unique_filename(filepath: str) -> str: counter += 1 +def get_video_duration(video_path: str, ffmpeg_path: str) -> Optional[float]: + """ + Get the duration of a video file in seconds using FFmpeg. + + Args: + video_path: Path to the video file + ffmpeg_path: Path to FFmpeg executable + + Returns: + float: Duration in seconds, or None if failed + """ + if not os.path.exists(video_path): + return None + + try: + # Use ffprobe (comes with ffmpeg) to get duration + ffprobe_path = ffmpeg_path.replace('ffmpeg', 'ffprobe') + + cmd = [ + ffprobe_path, + '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + video_path + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode == 0 and result.stdout.strip(): + return float(result.stdout.strip()) + except Exception: + pass + + return None + + def verify_streamlink() -> bool: """ Verify that streamlink is available. diff --git a/twitch-archive.py b/twitch-archive.py index af417d8..58b0158 100644 --- a/twitch-archive.py +++ b/twitch-archive.py @@ -48,7 +48,7 @@ from modules.config import ConfigManager from modules.notifications import NotificationManager from modules.utils import ( detect_operating_system, get_ffmpeg_executable, get_twitch_downloader_executable, - get_unique_filename, verify_streamlink, verify_ffmpeg, verify_twitch_downloader + get_unique_filename, get_video_duration, verify_streamlink, verify_ffmpeg, verify_twitch_downloader ) from modules.stream_monitor import StreamMonitor from modules.recorder import StreamRecorder @@ -410,7 +410,17 @@ class TwitchArchive: 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() - chat_rendered_successfully = self.downloader.render_chat(chat_json_path, chat_video_path, output_args) + + # Get video duration to trim chat accordingly + ffmpeg_path = get_ffmpeg_executable(self.os_type) + video_duration = get_video_duration(live_proc_path, ffmpeg_path) + + chat_rendered_successfully = self.downloader.render_chat( + chat_json_path, + chat_video_path, + output_args, + video_duration=video_duration + ) # Merge video and chat if configured merged_video_path = None @@ -488,7 +498,18 @@ 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() - chat_rendered_successfully = self.downloader.download_and_render_chat(current_vod, chat_json_path, chat_video_path, output_args) + + # Get VOD duration to trim chat accordingly + ffmpeg_path = get_ffmpeg_executable(self.os_type) + vod_duration = get_video_duration(vod_path, ffmpeg_path) + + chat_rendered_successfully = self.downloader.download_and_render_chat( + current_vod, + chat_json_path, + chat_video_path, + output_args, + video_duration=vod_duration + ) # 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): @@ -867,14 +888,18 @@ class TwitchArchiveManager: print(f'{Fore.MAGENTA}[DEBUG {username}] Stream data: {stream_data}{Style.RESET_ALL}') if stream_data: - # Stream is live - check if it has required data - if stream_data.get('archiveVideo') and stream_data['archiveVideo'].get('id'): + # Stream is live - check if it has required basic data (title and start time) + if stream_data.get('title') and stream_data.get('createdAt'): # Create composite stream ID like single-streamer mode # This prevents duplicate recordings in the same session stream_id = f"{stream_data['createdAt']} - {username} - {stream_data.get('title', 'Untitled')}" if self.verbose: - print(f'{Fore.MAGENTA}[DEBUG {username}] VOD ID: {stream_data["archiveVideo"]["id"]}{Style.RESET_ALL}') + # Check if VOD ID is available (for live chat) + if stream_data.get('archiveVideo') and stream_data['archiveVideo'].get('id'): + print(f'{Fore.MAGENTA}[DEBUG {username}] VOD ID: {stream_data["archiveVideo"]["id"]}{Style.RESET_ALL}') + else: + print(f'{Fore.MAGENTA}[DEBUG {username}] No VOD ID available (VODs may be disabled){Style.RESET_ALL}') print(f'{Fore.MAGENTA}[DEBUG {username}] Composite Stream ID: {stream_id}{Style.RESET_ALL}') # Check if we're currently recording this stream @@ -890,6 +915,11 @@ class TwitchArchiveManager: print(f'{Fore.CYAN}Title: {stream_data.get("title", "No title")}{Style.RESET_ALL}') print(f'{Fore.CYAN}Started at: {stream_data["createdAt"]}{Style.RESET_ALL}') + # Warn if VOD ID not available + if not (stream_data.get('archiveVideo') and stream_data['archiveVideo'].get('id')): + print(f'{Fore.YELLOW}⚠ VOD ID not available - live chat download will be skipped{Style.RESET_ALL}') + print(f'{Fore.YELLOW} Stream recording will proceed normally{Style.RESET_ALL}') + # Mark as currently recording self.active_recordings[username] = stream_id @@ -906,8 +936,8 @@ class TwitchArchiveManager: if self.verbose: print(f'{Fore.CYAN}[{username}] Currently recording this stream, skipping duplicate...{Style.RESET_ALL}') else: - # Stream is live but VOD ID not available yet - print(f'{Fore.YELLOW}[{username}] Stream is live but VOD ID not ready yet (title: {stream_data.get("title", "No title")}){Style.RESET_ALL}') + # Stream is live but not fully initialized yet + print(f'{Fore.YELLOW}[{username}] Stream starting up, waiting for stream data...{Style.RESET_ALL}') else: # Not live if self.verbose: @@ -1000,7 +1030,17 @@ 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() - chat_rendered_successfully = archiver.downloader.render_chat(chat_json_path, chat_video_path, output_args) + + # Get video duration to trim chat accordingly + ffmpeg_path = get_ffmpeg_executable(archiver.os_type) + video_duration = get_video_duration(live_proc_path, ffmpeg_path) + + chat_rendered_successfully = archiver.downloader.render_chat( + chat_json_path, + chat_video_path, + output_args, + video_duration=video_duration + ) # Merge video and chat if configured merged_video_path = None @@ -1081,7 +1121,18 @@ 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() - chat_rendered_successfully = archiver.downloader.download_and_render_chat(current_vod, chat_json_path, chat_video_path, output_args) + + # Get VOD duration to trim chat accordingly + ffmpeg_path = get_ffmpeg_executable(archiver.os_type) + vod_duration = get_video_duration(vod_path, ffmpeg_path) + + chat_rendered_successfully = archiver.downloader.download_and_render_chat( + current_vod, + chat_json_path, + chat_video_path, + output_args, + video_duration=vod_duration + ) # 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):