Add video duration handling for chat rendering and merging
This commit is contained in:
parent
832bf4cf36
commit
cdef8cf9bb
4 changed files with 126 additions and 19 deletions
|
|
@ -120,7 +120,8 @@ class ContentDownloader:
|
||||||
print(f'{Fore.RED}✗ Chat download failed: {str(e)}{Style.RESET_ALL}')
|
print(f'{Fore.RED}✗ Chat download failed: {str(e)}{Style.RESET_ALL}')
|
||||||
return False
|
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.
|
Render chat JSON as a video.
|
||||||
|
|
||||||
|
|
@ -128,6 +129,7 @@ class ContentDownloader:
|
||||||
json_path: Path to chat JSON file
|
json_path: Path to chat JSON file
|
||||||
video_path: Path to save rendered chat video
|
video_path: Path to save rendered chat video
|
||||||
output_args: FFmpeg output arguments for encoding
|
output_args: FFmpeg output arguments for encoding
|
||||||
|
video_duration: Optional video duration in seconds to trim chat to match
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if succeeded, False otherwise
|
bool: True if succeeded, False otherwise
|
||||||
|
|
@ -164,6 +166,13 @@ class ContentDownloader:
|
||||||
'--collision', 'Rename'
|
'--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
|
# Add output args using = syntax to avoid parsing issues
|
||||||
if output_args:
|
if output_args:
|
||||||
chat_settings.append(f'--output-args={output_args}')
|
chat_settings.append(f'--output-args={output_args}')
|
||||||
|
|
@ -188,7 +197,8 @@ class ContentDownloader:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def download_and_render_chat(self, vod_info: Dict[str, Any], json_path: str,
|
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.
|
Download chat logs and render them as video.
|
||||||
|
|
||||||
|
|
@ -197,6 +207,7 @@ class ContentDownloader:
|
||||||
json_path: Path to save chat JSON
|
json_path: Path to save chat JSON
|
||||||
video_path: Path to save rendered chat video
|
video_path: Path to save rendered chat video
|
||||||
output_args: FFmpeg output arguments for encoding
|
output_args: FFmpeg output arguments for encoding
|
||||||
|
video_duration: Optional video duration in seconds to trim chat to match
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if succeeded, False otherwise
|
bool: True if succeeded, False otherwise
|
||||||
|
|
@ -213,8 +224,8 @@ class ContentDownloader:
|
||||||
if not self.download_chat_json(vod_id, json_path):
|
if not self.download_chat_json(vod_id, json_path):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Render chat video
|
# Render chat video with optional duration trimming
|
||||||
return self.render_chat(json_path, video_path, output_args)
|
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]:
|
def start_live_chat_download(self, vod_id: str, json_path: str) -> Optional[subprocess.Popen]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,7 @@ class StreamProcessor:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print(f'{Fore.YELLOW}Merging video and chat ({layout})...{Style.RESET_ALL}')
|
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:
|
try:
|
||||||
if layout == 'overlay':
|
if layout == 'overlay':
|
||||||
|
|
@ -222,6 +223,7 @@ class StreamProcessor:
|
||||||
if self.hwaccel_type and self.hwaccel_type != 'none':
|
if self.hwaccel_type and self.hwaccel_type != 'none':
|
||||||
encoder = get_hwaccel_encoder(self.hwaccel_type)
|
encoder = get_hwaccel_encoder(self.hwaccel_type)
|
||||||
cmd.extend(['-c:v', encoder])
|
cmd.extend(['-c:v', encoder])
|
||||||
|
print(f'{Fore.CYAN} Using hardware encoder: {encoder}{Style.RESET_ALL}')
|
||||||
|
|
||||||
if 'nvenc' in encoder:
|
if 'nvenc' in encoder:
|
||||||
cmd.extend(['-preset', 'p4', '-cq', '18'])
|
cmd.extend(['-preset', 'p4', '-cq', '18'])
|
||||||
|
|
@ -232,6 +234,7 @@ class StreamProcessor:
|
||||||
else:
|
else:
|
||||||
cmd.extend(['-preset', 'medium', '-crf', '18'])
|
cmd.extend(['-preset', 'medium', '-crf', '18'])
|
||||||
else:
|
else:
|
||||||
|
print(f'{Fore.CYAN} Using software encoder: libx264{Style.RESET_ALL}')
|
||||||
cmd.extend(['-c:v', 'libx264', '-preset', 'medium', '-crf', '18'])
|
cmd.extend(['-c:v', 'libx264', '-preset', 'medium', '-crf', '18'])
|
||||||
|
|
||||||
# Audio codec
|
# Audio codec
|
||||||
|
|
@ -244,19 +247,20 @@ class StreamProcessor:
|
||||||
|
|
||||||
cmd.append(output_path)
|
cmd.append(output_path)
|
||||||
|
|
||||||
# Run FFmpeg
|
# Run FFmpeg with visible output to show progress
|
||||||
if self.ffmpeg_progress:
|
print(f'{Fore.CYAN} Processing...{Style.RESET_ALL}')
|
||||||
result = subprocess.call(cmd)
|
result = subprocess.call(cmd)
|
||||||
else:
|
|
||||||
result = subprocess.call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
|
|
||||||
|
|
||||||
if result == 0:
|
if result == 0:
|
||||||
print(f'{Fore.GREEN}✓ Video and chat merged successfully{Style.RESET_ALL}')
|
print(f'{Fore.GREEN}✓ Video and chat merged successfully{Style.RESET_ALL}')
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
print(f'{Fore.RED}✗ Merge failed with exit code: {result}{Style.RESET_ALL}')
|
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
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'{Fore.RED}✗ Merge failed: {str(e)}{Style.RESET_ALL}')
|
print(f'{Fore.RED}✗ Merge failed: {str(e)}{Style.RESET_ALL}')
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,47 @@ def get_unique_filename(filepath: str) -> str:
|
||||||
counter += 1
|
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:
|
def verify_streamlink() -> bool:
|
||||||
"""
|
"""
|
||||||
Verify that streamlink is available.
|
Verify that streamlink is available.
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ from modules.config import ConfigManager
|
||||||
from modules.notifications import NotificationManager
|
from modules.notifications import NotificationManager
|
||||||
from modules.utils import (
|
from modules.utils import (
|
||||||
detect_operating_system, get_ffmpeg_executable, get_twitch_downloader_executable,
|
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.stream_monitor import StreamMonitor
|
||||||
from modules.recorder import StreamRecorder
|
from modules.recorder import StreamRecorder
|
||||||
|
|
@ -410,7 +410,17 @@ class TwitchArchive:
|
||||||
if live_chat_downloaded:
|
if live_chat_downloaded:
|
||||||
chat_video_path = str(self.file_manager.chat_mp4_path / f"{PREFIX_CHAT}{filename_base}.mp4")
|
chat_video_path = str(self.file_manager.chat_mp4_path / f"{PREFIX_CHAT}{filename_base}.mp4")
|
||||||
output_args = self.processor.build_chat_output_args()
|
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
|
# Merge video and chat if configured
|
||||||
merged_video_path = None
|
merged_video_path = None
|
||||||
|
|
@ -488,7 +498,18 @@ class TwitchArchive:
|
||||||
if not live_chat_downloaded:
|
if not live_chat_downloaded:
|
||||||
chat_video_path = str(self.file_manager.chat_mp4_path / f"{PREFIX_CHAT}{filename_base}.mp4")
|
chat_video_path = str(self.file_manager.chat_mp4_path / f"{PREFIX_CHAT}{filename_base}.mp4")
|
||||||
output_args = self.processor.build_chat_output_args()
|
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
|
# 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):
|
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}')
|
print(f'{Fore.MAGENTA}[DEBUG {username}] Stream data: {stream_data}{Style.RESET_ALL}')
|
||||||
|
|
||||||
if stream_data:
|
if stream_data:
|
||||||
# Stream is live - check if it has required data
|
# Stream is live - check if it has required basic data (title and start time)
|
||||||
if stream_data.get('archiveVideo') and stream_data['archiveVideo'].get('id'):
|
if stream_data.get('title') and stream_data.get('createdAt'):
|
||||||
# Create composite stream ID like single-streamer mode
|
# Create composite stream ID like single-streamer mode
|
||||||
# This prevents duplicate recordings in the same session
|
# This prevents duplicate recordings in the same session
|
||||||
stream_id = f"{stream_data['createdAt']} - {username} - {stream_data.get('title', 'Untitled')}"
|
stream_id = f"{stream_data['createdAt']} - {username} - {stream_data.get('title', 'Untitled')}"
|
||||||
|
|
||||||
if self.verbose:
|
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}')
|
print(f'{Fore.MAGENTA}[DEBUG {username}] Composite Stream ID: {stream_id}{Style.RESET_ALL}')
|
||||||
|
|
||||||
# Check if we're currently recording this stream
|
# 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}Title: {stream_data.get("title", "No title")}{Style.RESET_ALL}')
|
||||||
print(f'{Fore.CYAN}Started at: {stream_data["createdAt"]}{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
|
# Mark as currently recording
|
||||||
self.active_recordings[username] = stream_id
|
self.active_recordings[username] = stream_id
|
||||||
|
|
||||||
|
|
@ -906,8 +936,8 @@ class TwitchArchiveManager:
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
print(f'{Fore.CYAN}[{username}] Currently recording this stream, skipping duplicate...{Style.RESET_ALL}')
|
print(f'{Fore.CYAN}[{username}] Currently recording this stream, skipping duplicate...{Style.RESET_ALL}')
|
||||||
else:
|
else:
|
||||||
# Stream is live but VOD ID not available yet
|
# Stream is live but not fully initialized 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}')
|
print(f'{Fore.YELLOW}[{username}] Stream starting up, waiting for stream data...{Style.RESET_ALL}')
|
||||||
else:
|
else:
|
||||||
# Not live
|
# Not live
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
|
|
@ -1000,7 +1030,17 @@ class TwitchArchiveManager:
|
||||||
if live_chat_downloaded:
|
if live_chat_downloaded:
|
||||||
chat_video_path = str(archiver.file_manager.chat_mp4_path / f"{PREFIX_CHAT}{filename_base}.mp4")
|
chat_video_path = str(archiver.file_manager.chat_mp4_path / f"{PREFIX_CHAT}{filename_base}.mp4")
|
||||||
output_args = archiver.processor.build_chat_output_args()
|
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
|
# Merge video and chat if configured
|
||||||
merged_video_path = None
|
merged_video_path = None
|
||||||
|
|
@ -1081,7 +1121,18 @@ class TwitchArchiveManager:
|
||||||
if archiver.downloadCHAT and not live_chat_downloaded:
|
if archiver.downloadCHAT and not live_chat_downloaded:
|
||||||
chat_video_path = str(archiver.file_manager.chat_mp4_path / f"{PREFIX_CHAT}{filename_base}.mp4")
|
chat_video_path = str(archiver.file_manager.chat_mp4_path / f"{PREFIX_CHAT}{filename_base}.mp4")
|
||||||
output_args = archiver.processor.build_chat_output_args()
|
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
|
# 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):
|
if chat_rendered_successfully and archiver.mergeVideoChat and os.path.exists(vod_path) and os.path.exists(chat_video_path):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue