Add support for merging video and chat with configurable layout options
This commit is contained in:
parent
e078cada3b
commit
832bf4cf36
6 changed files with 199 additions and 10 deletions
|
|
@ -9,6 +9,8 @@
|
||||||
"downloadVOD": true,
|
"downloadVOD": true,
|
||||||
"downloadCHAT": true,
|
"downloadCHAT": true,
|
||||||
"downloadLiveCHAT": true,
|
"downloadLiveCHAT": true,
|
||||||
|
"mergeVideoChat": true,
|
||||||
|
"mergeChatLayout": "side-by-side",
|
||||||
"vodTimeout": 300,
|
"vodTimeout": 300,
|
||||||
"uploadCloud": true,
|
"uploadCloud": true,
|
||||||
"deleteFiles": false,
|
"deleteFiles": false,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,17 @@
|
||||||
"default": true,
|
"default": true,
|
||||||
"description": "Download chat during live stream: false = disabled, true = enabled"
|
"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": {
|
"vodTimeout": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"default": 300,
|
"default": 300,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ TWITCH_GQL_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
||||||
PREFIX_LIVE = "LIVE_"
|
PREFIX_LIVE = "LIVE_"
|
||||||
PREFIX_VOD = "VOD_"
|
PREFIX_VOD = "VOD_"
|
||||||
PREFIX_CHAT = "CHAT_"
|
PREFIX_CHAT = "CHAT_"
|
||||||
|
PREFIX_MERGED = "MERGED_"
|
||||||
PREFIX_METADATA = "METADA_" # Note: keeping original typo for compatibility
|
PREFIX_METADATA = "METADA_" # Note: keeping original typo for compatibility
|
||||||
|
|
||||||
# Default configuration values
|
# Default configuration values
|
||||||
|
|
@ -27,6 +28,8 @@ DEFAULT_CONFIG = {
|
||||||
'downloadVOD': True,
|
'downloadVOD': True,
|
||||||
'downloadCHAT': True,
|
'downloadCHAT': True,
|
||||||
'downloadLiveCHAT': True,
|
'downloadLiveCHAT': True,
|
||||||
|
'mergeVideoChat': True, # Merge video and chat into single file
|
||||||
|
'mergeChatLayout': 'side-by-side', # Layout: 'side-by-side' or 'overlay'
|
||||||
'vodTimeout': 300,
|
'vodTimeout': 300,
|
||||||
'uploadCloud': True,
|
'uploadCloud': True,
|
||||||
'deleteFiles': False,
|
'deleteFiles': False,
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,17 @@ class ContentDownloader:
|
||||||
print(f'{Fore.RED}✗ Chat JSON file not found: {json_path}{Style.RESET_ALL}')
|
print(f'{Fore.RED}✗ Chat JSON file not found: {json_path}{Style.RESET_ALL}')
|
||||||
return False
|
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()
|
bin_path = get_bin_path()
|
||||||
|
|
||||||
# Chat rendering settings
|
# Chat rendering settings
|
||||||
|
|
@ -148,19 +159,20 @@ class ContentDownloader:
|
||||||
'--font-size', '22',
|
'--font-size', '22',
|
||||||
'--update-rate', '1.0',
|
'--update-rate', '1.0',
|
||||||
'--offline',
|
'--offline',
|
||||||
'--output-args', output_args,
|
|
||||||
'--ffmpeg-path', self.ffmpeg_path,
|
'--ffmpeg-path', self.ffmpeg_path,
|
||||||
'--temp-path', os.path.join(bin_path, 'temp'),
|
'--temp-path', os.path.join(bin_path, 'temp'),
|
||||||
'--collision', 'Rename'
|
'--collision', 'Rename'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Add output args using = syntax to avoid parsing issues
|
||||||
|
if output_args:
|
||||||
|
chat_settings.append(f'--output-args={output_args}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(f'{Fore.YELLOW}Rendering chat video...{Style.RESET_ALL}')
|
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
|
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)
|
result = subprocess.call(full_cmd)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,5 +167,96 @@ class StreamProcessor:
|
||||||
# Default software encoding
|
# Default software encoding
|
||||||
result = f'-c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p "{{save_path}}"'
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ from pytz import timezone
|
||||||
from dotenv import load_dotenv, find_dotenv
|
from dotenv import load_dotenv, find_dotenv
|
||||||
|
|
||||||
# Local module imports
|
# 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.config import ConfigManager
|
||||||
from modules.notifications import NotificationManager
|
from modules.notifications import NotificationManager
|
||||||
from modules.utils import (
|
from modules.utils import (
|
||||||
|
|
@ -215,6 +215,10 @@ class TwitchArchive:
|
||||||
self._print_toggle('Metadata download', self.downloadMETADATA)
|
self._print_toggle('Metadata download', self.downloadMETADATA)
|
||||||
self._print_toggle('VOD download', self.downloadVOD)
|
self._print_toggle('VOD download', self.downloadVOD)
|
||||||
self._print_toggle('Chat download & render', self.downloadCHAT)
|
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)
|
self._print_toggle('Cloud upload', self.uploadCloud)
|
||||||
|
|
||||||
# Warning messages
|
# Warning messages
|
||||||
|
|
@ -401,10 +405,23 @@ class TwitchArchive:
|
||||||
live_chat_downloaded = self.downloader.wait_for_chat_download(live_chat_process, chat_json_path)
|
live_chat_downloaded = self.downloader.wait_for_chat_download(live_chat_process, chat_json_path)
|
||||||
|
|
||||||
# Render live chat if downloaded successfully
|
# Render live chat if downloaded successfully
|
||||||
|
chat_rendered_successfully = False
|
||||||
|
chat_video_path = None
|
||||||
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()
|
||||||
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
|
# Skip VOD/chat download if shutdown was requested or vodTimeout is 0
|
||||||
vod_response = None
|
vod_response = None
|
||||||
|
|
@ -471,9 +488,29 @@ 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()
|
||||||
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:
|
else:
|
||||||
print(f'{Fore.CYAN}Chat already downloaded from live stream, skipping VOD chat download{Style.RESET_ALL}')
|
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:
|
else:
|
||||||
print(f'{Fore.YELLOW}⚠ No matching VOD found for this stream{Style.RESET_ALL}')
|
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
|
# Wait for live chat download if it was started
|
||||||
live_chat_downloaded = False
|
live_chat_downloaded = False
|
||||||
|
chat_rendered_successfully = False
|
||||||
|
chat_video_path = None
|
||||||
if live_chat_process is not None:
|
if live_chat_process is not None:
|
||||||
live_chat_downloaded = archiver.downloader.wait_for_chat_download(live_chat_process, chat_json_path)
|
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:
|
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()
|
||||||
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
|
# Wait for VOD and download it
|
||||||
vod_response = None
|
vod_response = None
|
||||||
|
|
@ -1031,9 +1081,29 @@ 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()
|
||||||
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:
|
elif live_chat_downloaded:
|
||||||
print(f'{Fore.CYAN}Chat already downloaded from live stream, skipping VOD chat download{Style.RESET_ALL}')
|
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:
|
else:
|
||||||
print(f'{Fore.YELLOW}⚠ No matching VOD found for this stream{Style.RESET_ALL}')
|
print(f'{Fore.YELLOW}⚠ No matching VOD found for this stream{Style.RESET_ALL}')
|
||||||
elif archiver.downloadMETADATA:
|
elif archiver.downloadMETADATA:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue