diff --git a/__init__.py b/__init__.py index ce46264..9771e64 100644 --- a/__init__.py +++ b/__init__.py @@ -1,22 +1,26 @@ """ -MetaHub Save Image - ComfyUI Custom Node +MetaHub Save Image/Video - ComfyUI Custom Node Entry point for node registration """ try: from .metahub_save_node import MetaHubSaveNode + from .metahub_save_video_node import MetaHubSaveVideoNode from .timer_node import MetaHubTimerNode except ImportError: from metahub_save_node import MetaHubSaveNode + from metahub_save_video_node import MetaHubSaveVideoNode from timer_node import MetaHubTimerNode NODE_CLASS_MAPPINGS = { "MetaHubSaveNode": MetaHubSaveNode, + "MetaHubSaveVideoNode": MetaHubSaveVideoNode, "MetaHubTimerNode": MetaHubTimerNode } NODE_DISPLAY_NAME_MAPPINGS = { "MetaHubSaveNode": "MetaHub Save Image", + "MetaHubSaveVideoNode": "MetaHub Save Video", "MetaHubTimerNode": "MetaHub Timer" } diff --git a/__pycache__/metahub_save_video_node.cpython-310.pyc b/__pycache__/metahub_save_video_node.cpython-310.pyc new file mode 100644 index 0000000..8382beb Binary files /dev/null and b/__pycache__/metahub_save_video_node.cpython-310.pyc differ diff --git a/__pycache__/video_metadata_utils.cpython-310.pyc b/__pycache__/video_metadata_utils.cpython-310.pyc new file mode 100644 index 0000000..ddf9644 Binary files /dev/null and b/__pycache__/video_metadata_utils.cpython-310.pyc differ diff --git a/metahub_save_video_node.py b/metahub_save_video_node.py new file mode 100644 index 0000000..30df437 --- /dev/null +++ b/metahub_save_video_node.py @@ -0,0 +1,410 @@ +""" +MetaHub Save Video Node for ComfyUI +Adds metadata to video files generated by VHS (Video Helper Suite) +""" + +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +try: + from . import metadata_utils as utils + from . import video_metadata_utils as video_utils + from .workflow_extractor import WorkflowExtractor +except ImportError: + import metadata_utils as utils + import video_metadata_utils as video_utils + from workflow_extractor import WorkflowExtractor + + +class MetaHubSaveVideoNode: + """ + ComfyUI custom node for adding MetaHub metadata to video files. + + Accepts VHS_FILENAMES from VHS Video Combine node and injects metadata + into the video container (MP4, WebM, MKV) without re-encoding. + + Features: + - A1111/Civitai compatible metadata (description field) + - Video MetaHub metadata (comment field as JSON) + - Auto-detection of workflow parameters + - Frame rate and frame count tracking + - Pass-through design for workflow chaining + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "filenames": ("VHS_FILENAMES",), + }, + "optional": { + # Generation parameter overrides + "seed": ("INT", { + "default": None, + "forceInput": True, + "tooltip": "Override seed" + }), + "steps": ("INT", { + "default": None, + "forceInput": True, + "tooltip": "Override steps" + }), + "cfg": ("FLOAT", { + "default": None, + "forceInput": True, + "tooltip": "Override CFG scale" + }), + "sampler_name": ("STRING", { + "default": None, + "forceInput": True, + "tooltip": "Override sampler name" + }), + "scheduler": ("STRING", { + "default": None, + "forceInput": True, + "tooltip": "Override scheduler" + }), + "model_name": ("STRING", { + "default": None, + "forceInput": True, + "tooltip": "Override model filename" + }), + "positive": ("STRING", { + "multiline": True, + "default": None, + "forceInput": True, + "tooltip": "Override positive prompt" + }), + "negative": ("STRING", { + "multiline": True, + "default": None, + "forceInput": True, + "tooltip": "Override negative prompt" + }), + "denoise": ("FLOAT", { + "default": None, + "forceInput": True, + "tooltip": "Override denoise strength" + }), + "vae_name": ("STRING", { + "default": None, + "forceInput": True, + "tooltip": "Override VAE filename" + }), + # Video-specific fields + "frame_rate": ("FLOAT", { + "default": 0.0, + "min": 0.0, + "max": 120.0, + "tooltip": "Video frame rate (FPS). 0 = auto-detect from file" + }), + "frame_count": ("INT", { + "default": 0, + "min": 0, + "tooltip": "Number of frames. 0 = auto-detect from file" + }), + "motion_model_name": ("STRING", { + "default": "", + "tooltip": "Motion model name (AnimateDiff, etc.)" + }), + # IMH Pro fields + "user_tags": ("STRING", { + "default": "", + "tooltip": "User tags (comma-separated)" + }), + "notes": ("STRING", { + "multiline": True, + "default": "", + "tooltip": "Notes" + }), + "project_name": ("STRING", { + "default": "", + "tooltip": "Project name" + }), + # Timer integration + "generation_time_override": ("FLOAT", { + "default": None, + "forceInput": True, + "tooltip": "Timestamp from MetaHub Timer Node" + }), + }, + "hidden": { + "prompt": "PROMPT", + "extra_pnginfo": "EXTRA_PNGINFO", + "unique_id": "UNIQUE_ID" + }, + } + + RETURN_TYPES = ("VHS_FILENAMES",) + RETURN_NAMES = ("filenames",) + FUNCTION = "add_metadata" + OUTPUT_NODE = True + CATEGORY = "video/save" + DESCRIPTION = "Add A1111/Civitai and Video MetaHub metadata to video files from VHS" + + def add_metadata( + self, + filenames, + seed=None, + steps=None, + cfg=None, + sampler_name=None, + scheduler=None, + model_name=None, + positive=None, + negative=None, + denoise=None, + vae_name=None, + frame_rate=0.0, + frame_count=0, + motion_model_name="", + user_tags="", + notes="", + project_name="", + generation_time_override=None, + prompt=None, + extra_pnginfo=None, + unique_id=None, + ): + """ + Adds metadata to video files from VHS_FILENAMES. + + 1. Extracts video paths from VHS_FILENAMES tuple + 2. Uses WorkflowExtractor to get generation parameters + 3. Builds A1111 and MetaHub metadata structures + 4. Injects metadata into video using ffmpeg + 5. Returns original filenames for chaining + """ + # Check ffmpeg availability first + ffmpeg = video_utils.find_ffmpeg_binary() + if not ffmpeg: + print("[MetaHub Video] Warning: FFmpeg not found. Metadata injection skipped.") + print("[MetaHub Video] Install FFmpeg and ensure it's in PATH, or set FFMPEG_PATH.") + return (filenames,) + + # Extract video files from VHS_FILENAMES + video_paths = video_utils.extract_video_files(filenames) + + if not video_paths: + print("[MetaHub Video] No video files found in VHS_FILENAMES") + return (filenames,) + + # Extract workflow data + try: + workflow_json = utils.get_workflow_json(extra_pnginfo) + prompt_data = prompt if isinstance(prompt, dict) else workflow_json.get("prompt", {}) + if not isinstance(prompt_data, dict): + prompt_data = {} + + workflow_json = utils.ensure_prompt_in_workflow(workflow_json, prompt_data) + + # Use WorkflowExtractor to get generation params + extractor = WorkflowExtractor(prompt_data) + extracted, missing_fields = extractor.extract(save_node_id=None) + lora_list = extracted.get("lora_list") or utils.extract_loras_from_workflow(workflow_json) + + except Exception as e: + print(f"[MetaHub Video] Warning: Could not extract workflow data: {e}") + extracted = {} + lora_list = [] + workflow_json = {} + + # Resolve values (manual override > extracted > default) + def resolve_value(manual_value, extracted_value, default_value): + if manual_value is not None: + return manual_value + if extracted_value is not None: + return extracted_value + return default_value + + def normalize_int(value, default_value): + try: + return int(value) + except (TypeError, ValueError): + return default_value + + def normalize_float(value, default_value): + try: + return float(value) + except (TypeError, ValueError): + return default_value + + seed_value = normalize_int(resolve_value(seed, extracted.get("seed"), 0), 0) + steps_value = normalize_int(resolve_value(steps, extracted.get("steps"), 20), 20) + cfg_value = normalize_float(resolve_value(cfg, extracted.get("cfg"), 7.0), 7.0) + sampler_value = resolve_value(sampler_name, extracted.get("sampler_name"), "euler") + scheduler_value = resolve_value(scheduler, extracted.get("scheduler"), "normal") + model_name_value = resolve_value(model_name, extracted.get("model_name"), "") + positive_value = resolve_value(positive, extracted.get("positive"), "") + negative_value = resolve_value(negative, extracted.get("negative"), "") + denoise_value = normalize_float(resolve_value(denoise, extracted.get("denoise"), 1.0), 1.0) + vae_name_value = resolve_value(vae_name, extracted.get("vae_name"), "") + + # Calculate model hash + if model_name_value: + model_hash = utils.calculate_model_hash(model_name_value, model_type="checkpoint") + else: + model_hash = "0000000000" + lora_hashes = utils.calculate_lora_hashes(lora_list) if lora_list else {} + + # Calculate generation time + if generation_time_override is not None and generation_time_override > 0: + final_time = time.time() - generation_time_override + else: + final_time = 0.0 + + generation_time_ms = int(final_time * 1000) if final_time > 0 else None + steps_per_second = None + if generation_time_ms and generation_time_ms > 0 and steps_value > 0: + steps_per_second = round((steps_value / (generation_time_ms / 1000)), 2) + + # Collect GPU metrics + gpu_metrics = utils.collect_gpu_metrics() + version_info = utils.collect_version_info() + + # Process each video file + for video_path in video_paths: + try: + self._process_video( + video_path=video_path, + seed=seed_value, + steps=steps_value, + cfg=cfg_value, + sampler=sampler_value, + scheduler=scheduler_value, + model_name=model_name_value, + model_hash=model_hash, + positive=positive_value, + negative=negative_value, + denoise=denoise_value, + vae_name=vae_name_value, + frame_rate=frame_rate, + frame_count=frame_count, + motion_model_name=motion_model_name, + lora_list=lora_list, + lora_hashes=lora_hashes, + user_tags=user_tags, + notes=notes, + project_name=project_name, + generation_time_ms=generation_time_ms, + steps_per_second=steps_per_second, + gpu_metrics=gpu_metrics, + version_info=version_info, + workflow_json=workflow_json, + ) + except Exception as e: + print(f"[MetaHub Video] Warning: Failed to add metadata to {video_path.name}: {e}") + + return (filenames,) + + def _process_video( + self, + video_path: Path, + seed: int, + steps: int, + cfg: float, + sampler: str, + scheduler: str, + model_name: str, + model_hash: str, + positive: str, + negative: str, + denoise: float, + vae_name: str, + frame_rate: float, + frame_count: int, + motion_model_name: str, + lora_list: List[Dict], + lora_hashes: Dict[str, str], + user_tags: str, + notes: str, + project_name: str, + generation_time_ms: Optional[int], + steps_per_second: Optional[float], + gpu_metrics: Dict[str, Any], + version_info: Dict[str, str], + workflow_json: Dict, + ) -> None: + """ + Processes a single video file and injects metadata. + """ + # Get video info from file if not provided + video_info = video_utils.get_video_info(video_path) + + if video_info: + if frame_rate <= 0: + frame_rate = video_info.get('frame_rate') or 0 + if frame_count <= 0: + frame_count = video_info.get('frame_count') or 0 + width = video_info.get('width', 512) + height = video_info.get('height', 512) + video_codec = video_info.get('codec', 'h264') + else: + width = 512 + height = 512 + video_codec = 'h264' + + video_format = video_utils.detect_video_format(video_path) or 'mp4' + + # Build params dict + params = { + "positive": positive, + "negative": negative, + "steps": steps, + "sampler": sampler, + "scheduler": scheduler, + "cfg": cfg, + "seed": seed, + "width": width, + "height": height, + "model_name": model_name, + "model_hash": model_hash, + "vae_name": vae_name, + "denoise": denoise, + # Video-specific + "frame_rate": frame_rate, + "frame_count": frame_count, + "video_format": video_format, + "video_codec": video_codec, + "motion_model_name": motion_model_name, + # LoRAs + "lora_list": lora_list, + "lora_hashes": lora_hashes, + # IMH Pro + "user_tags": user_tags, + "notes": notes, + "project_name": project_name, + # Performance metrics + "vram_peak_mb": gpu_metrics.get("vram_peak_mb"), + "gpu_device": gpu_metrics.get("gpu_device"), + "generation_time_ms": generation_time_ms, + "steps_per_second": steps_per_second, + "comfyui_version": version_info.get("comfyui_version"), + "torch_version": version_info.get("torch_version"), + "python_version": version_info.get("python_version"), + } + + # Build metadata + a1111_metadata = video_utils.build_video_a1111_metadata(params) + metahub_metadata = video_utils.build_video_metahub_metadata(params, workflow_json) + + # Inject metadata into video + video_utils.inject_video_metadata( + video_path=video_path, + a1111_metadata=a1111_metadata, + metahub_metadata=metahub_metadata, + ) + + print(f"[MetaHub Video] Added metadata to: {video_path.name}") + + # Verify metadata was injected + verification = video_utils.verify_video_metadata(video_path) + if verification and verification.get('has_metahub_data'): + print(f"[MetaHub Video] Metadata verified successfully") + else: + print(f"[MetaHub Video] Warning: Metadata verification failed") + + +NODE_CLASS_MAPPINGS = {"MetaHubSaveVideoNode": MetaHubSaveVideoNode} +NODE_DISPLAY_NAME_MAPPINGS = {"MetaHubSaveVideoNode": "MetaHub Save Video"} diff --git a/video_metadata_utils.py b/video_metadata_utils.py new file mode 100644 index 0000000..c50632e --- /dev/null +++ b/video_metadata_utils.py @@ -0,0 +1,566 @@ +""" +Video Metadata Utils for MetaHub Save Node +Handles video metadata injection using ffmpeg. +""" + +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any, Dict, List, Optional + +# Video extensions supported +VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mkv', '.mov', '.avi'} + + +def find_ffmpeg_binary() -> Optional[Path]: + """ + Locates ffmpeg executable. + + Search order: + 1. FFMPEG_PATH environment variable + 2. System PATH (shutil.which) + 3. Common installation locations + + Returns: + Path to ffmpeg executable or None if not found + """ + # Check environment variable first + env_path = os.environ.get('FFMPEG_PATH') + if env_path: + ffmpeg_path = Path(env_path) + if ffmpeg_path.exists(): + return ffmpeg_path + + # Check system PATH + which_result = shutil.which('ffmpeg') + if which_result: + return Path(which_result) + + # Common locations on Windows + common_paths = [ + Path('C:/ffmpeg/bin/ffmpeg.exe'), + Path('C:/Program Files/ffmpeg/bin/ffmpeg.exe'), + Path('C:/Program Files (x86)/ffmpeg/bin/ffmpeg.exe'), + ] + + for path in common_paths: + if path.exists(): + return path + + return None + + +def find_ffprobe_binary() -> Optional[Path]: + """ + Locates ffprobe executable (for verification). + + Returns: + Path to ffprobe executable or None if not found + """ + # Check environment variable + env_path = os.environ.get('FFPROBE_PATH') + if env_path: + ffprobe_path = Path(env_path) + if ffprobe_path.exists(): + return ffprobe_path + + # Check system PATH + which_result = shutil.which('ffprobe') + if which_result: + return Path(which_result) + + # Try to find next to ffmpeg + ffmpeg = find_ffmpeg_binary() + if ffmpeg: + ffprobe = ffmpeg.parent / ('ffprobe' + ffmpeg.suffix) + if ffprobe.exists(): + return ffprobe + + return None + + +def detect_video_format(file_path: Path) -> Optional[str]: + """ + Detects video format from file extension. + + Args: + file_path: Path to video file + + Returns: + Format string ('mp4', 'webm', 'mkv') or None if not a video + """ + ext = file_path.suffix.lower() + if ext in VIDEO_EXTENSIONS: + return ext[1:] # Remove the dot + return None + + +def is_video_file(file_path: Path) -> bool: + """ + Checks if file is a supported video format. + + Args: + file_path: Path to check + + Returns: + True if file is a supported video format + """ + return file_path.suffix.lower() in VIDEO_EXTENSIONS + + +def build_video_a1111_metadata(params: dict) -> str: + """ + Builds A1111/Civitai compatible metadata string for video. + + Similar to image metadata but includes video-specific fields. + + Format: + {positive_prompt} + Negative prompt: {negative_prompt} + Steps: {steps}, Sampler: {sampler}, CFG scale: {cfg}, Seed: {seed}, + Size: {width}x{height}, Model: {model_name}, Model hash: {hash}, + Frames: {frame_count}, FPS: {frame_rate} + + Args: + params: Dict containing all generation parameters + + Returns: + Formatted A1111 metadata string + """ + # Build LoRA hashes string + lora_hashes_str = ", ".join( + f"{name}: {hash_val}" + for name, hash_val in params.get('lora_hashes', {}).items() + ) + + # Build config parts + config_parts = [ + f"Steps: {params.get('steps', 20)}", + f"Sampler: {params.get('sampler', 'euler')}", + f"CFG scale: {params.get('cfg', 7.0)}", + f"Seed: {params.get('seed', 0)}", + f"Size: {params.get('width', 512)}x{params.get('height', 512)}", + f"Model: {params.get('model_name', '')}", + f"Model hash: {params.get('model_hash', '0000000000')}", + ] + + # Add video-specific fields + if params.get('frame_count'): + config_parts.append(f"Frames: {params['frame_count']}") + if params.get('frame_rate'): + config_parts.append(f"FPS: {params['frame_rate']}") + + # Add motion model if present + motion_model = params.get('motion_model_name') + if motion_model: + config_parts.append(f"Motion Model: {motion_model}") + + # Add LoRA hashes if present + if lora_hashes_str: + config_parts.append(f'Lora hashes: "{lora_hashes_str}"') + + config_line = ", ".join(config_parts) + + # Build final metadata string + positive = params.get('positive', '') + negative = params.get('negative', '') + + metadata = f"""{positive} +Negative prompt: {negative} +{config_line}""" + + return metadata + + +def build_video_metahub_metadata(params: dict, workflow_json: dict) -> dict: + """ + Builds Video MetaHub metadata as JSON object. + + Similar to image metadata but with video-specific fields. + + Args: + params: Dict containing generation parameters + workflow_json: Complete ComfyUI workflow from extra_pnginfo + + Returns: + JSON-serializable dict for video metadata + """ + import numpy as np + + def _sanitize(value: Any) -> Any: + if isinstance(value, float) and np.isnan(value): + return None + if isinstance(value, dict): + return {k: _sanitize(v) for k, v in value.items()} + if isinstance(value, list): + return [_sanitize(v) for v in value] + return value + + safe_workflow = _sanitize(workflow_json.get('workflow', {})) + safe_prompt = _sanitize(workflow_json.get('prompt', {})) + + # Calculate duration if we have frame count and rate + duration_seconds = None + frame_count = params.get('frame_count') + frame_rate = params.get('frame_rate') + if frame_count and frame_rate and frame_rate > 0: + duration_seconds = round(frame_count / frame_rate, 2) + + return { + # CRITICAL: Required field for detection + "generator": "ComfyUI", + "media_type": "video", + + # Main generation fields + "prompt": params.get('positive', ''), + "negativePrompt": params.get('negative', ''), + "seed": params.get('seed', 0), + "steps": params.get('steps', 20), + "cfg": params.get('cfg', 7.0), + "sampler_name": params.get('sampler', 'euler'), + "scheduler": params.get('scheduler', 'normal'), + "model": params.get('model_name', ''), + "model_hash": params.get('model_hash', ''), + "vae": params.get('vae_name', ''), + "denoise": params.get('denoise', 1.0), + "width": params.get('width', 512), + "height": params.get('height', 512), + + # Video-specific fields + "video": { + "frame_rate": frame_rate, + "frame_count": frame_count, + "duration_seconds": duration_seconds, + "width": params.get('width', 512), + "height": params.get('height', 512), + "format": params.get('video_format', 'mp4'), + "codec": params.get('video_codec', 'h264'), + }, + + # Motion model (AnimateDiff, etc.) + "motion_model": { + "name": params.get('motion_model_name'), + "hash": params.get('motion_model_hash'), + } if params.get('motion_model_name') else None, + + # LoRAs in array format + "loras": [ + {"name": lora['name'], "weight": lora['weight']} + for lora in params.get('lora_list', []) + ], + + # IMH Pro fields + "imh_pro": { + "user_tags": params.get('user_tags', ''), + "notes": params.get('notes', ''), + "project_name": params.get('project_name', ''), + }, + + # Analytics/Performance metrics + "analytics": { + "vram_peak_mb": params.get('vram_peak_mb'), + "gpu_device": params.get('gpu_device'), + "generation_time_ms": params.get('generation_time_ms'), + "steps_per_second": params.get('steps_per_second'), + "comfyui_version": params.get('comfyui_version'), + "torch_version": params.get('torch_version'), + "python_version": params.get('python_version'), + }, + + # Complete workflow data + "workflow": safe_workflow, + "prompt_api": safe_prompt, + } + + +def inject_video_metadata( + video_path: Path, + a1111_metadata: str, + metahub_metadata: dict, + output_path: Optional[Path] = None, + keep_original: bool = False, +) -> Path: + """ + Injects metadata into video container without re-encoding. + + Uses ffmpeg with -c copy to add metadata to video file. + Metadata is stored in: + - description: A1111/Civitai format + - comment: JSON videometahub_data + + Args: + video_path: Path to input video file + a1111_metadata: A1111 formatted metadata string + metahub_metadata: MetaHub metadata dict (will be JSON serialized) + output_path: Optional output path (default: replace original) + keep_original: If True, keep original file (only works if output_path differs) + + Returns: + Path to video with injected metadata + + Raises: + RuntimeError: If ffmpeg is not found or injection fails + """ + ffmpeg = find_ffmpeg_binary() + if not ffmpeg: + raise RuntimeError( + "FFmpeg not found. Please install FFmpeg and ensure it's in PATH, " + "or set FFMPEG_PATH environment variable." + ) + + video_path = Path(video_path) + if not video_path.exists(): + raise FileNotFoundError(f"Video file not found: {video_path}") + + # Determine output path + if output_path is None: + # Replace original - use temp file then rename + replace_original = True + temp_fd, temp_path = tempfile.mkstemp(suffix=video_path.suffix) + os.close(temp_fd) + final_output = Path(temp_path) + else: + replace_original = False + final_output = Path(output_path) + + # Serialize metadata JSON + metahub_json = json.dumps(metahub_metadata, ensure_ascii=False, separators=(',', ':')) + + # Detect format for format-specific flags + video_format = detect_video_format(video_path) + + # Build ffmpeg command + cmd = [ + str(ffmpeg), + '-y', # Overwrite output + '-i', str(video_path), + '-c', 'copy', # No re-encoding + '-map_metadata', '0', # Preserve existing metadata + ] + + # Add format-specific flags + if video_format == 'mp4': + cmd.extend(['-movflags', 'use_metadata_tags']) + + # Add metadata fields + # Use description for A1111 format (human-readable) + cmd.extend(['-metadata', f'description={a1111_metadata}']) + # Use comment for full JSON data + cmd.extend(['-metadata', f'comment={metahub_json}']) + # Also add a title with basic info + title = f"Generated with ComfyUI - Seed: {metahub_metadata.get('seed', 'unknown')}" + cmd.extend(['-metadata', f'title={title}']) + + # Output file + cmd.append(str(final_output)) + + # Execute ffmpeg + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60, # 1 minute timeout + ) + + if result.returncode != 0: + raise RuntimeError(f"FFmpeg failed: {result.stderr}") + + except subprocess.TimeoutExpired: + raise RuntimeError("FFmpeg timed out while injecting metadata") + except Exception as e: + # Clean up temp file on error + if replace_original and final_output.exists(): + final_output.unlink() + raise RuntimeError(f"Failed to inject metadata: {e}") + + # If replacing original, move temp file to original location + if replace_original: + try: + # Remove original + video_path.unlink() + # Move temp to original location + shutil.move(str(final_output), str(video_path)) + return video_path + except Exception as e: + # Try to clean up + if final_output.exists(): + final_output.unlink() + raise RuntimeError(f"Failed to replace original file: {e}") + + return final_output + + +def verify_video_metadata(video_path: Path) -> Optional[dict]: + """ + Verifies that metadata was successfully injected into video. + + Uses ffprobe to read metadata from video file. + + Args: + video_path: Path to video file + + Returns: + Dict with metadata fields or None if verification failed + """ + ffprobe = find_ffprobe_binary() + if not ffprobe: + print("[MetaHub Video] Warning: ffprobe not found, cannot verify metadata") + return None + + try: + cmd = [ + str(ffprobe), + '-v', 'quiet', + '-print_format', 'json', + '-show_format', + str(video_path), + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode != 0: + return None + + data = json.loads(result.stdout) + tags = data.get('format', {}).get('tags', {}) + + return { + 'title': tags.get('title'), + 'description': tags.get('description'), + 'comment': tags.get('comment'), + 'has_metahub_data': 'videometahub_data' in tags.get('comment', '') or + '"generator":"ComfyUI"' in tags.get('comment', ''), + } + + except Exception as e: + print(f"[MetaHub Video] Warning: Could not verify metadata: {e}") + return None + + +def get_video_info(video_path: Path) -> Optional[dict]: + """ + Gets video information using ffprobe. + + Args: + video_path: Path to video file + + Returns: + Dict with video info (frame_count, frame_rate, width, height, duration) or None + """ + ffprobe = find_ffprobe_binary() + if not ffprobe: + return None + + try: + cmd = [ + str(ffprobe), + '-v', 'quiet', + '-print_format', 'json', + '-show_streams', + '-show_format', + str(video_path), + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode != 0: + return None + + data = json.loads(result.stdout) + + # Find video stream + video_stream = None + for stream in data.get('streams', []): + if stream.get('codec_type') == 'video': + video_stream = stream + break + + if not video_stream: + return None + + # Parse frame rate (can be "30/1" or "29.97") + frame_rate = None + r_frame_rate = video_stream.get('r_frame_rate', '') + if '/' in r_frame_rate: + num, den = r_frame_rate.split('/') + if int(den) != 0: + frame_rate = round(int(num) / int(den), 2) + else: + try: + frame_rate = float(r_frame_rate) + except ValueError: + pass + + # Get frame count + frame_count = video_stream.get('nb_frames') + if frame_count: + frame_count = int(frame_count) + + # Get duration + duration = data.get('format', {}).get('duration') + if duration: + duration = float(duration) + + return { + 'width': int(video_stream.get('width', 0)), + 'height': int(video_stream.get('height', 0)), + 'frame_rate': frame_rate, + 'frame_count': frame_count, + 'duration': duration, + 'codec': video_stream.get('codec_name'), + } + + except Exception as e: + print(f"[MetaHub Video] Warning: Could not get video info: {e}") + return None + + +def extract_video_files(filenames_tuple: tuple) -> List[Path]: + """ + Extracts video file paths from VHS_FILENAMES tuple. + + VHS_FILENAMES format: (save_output: bool, [file_paths: List[str]]) + + Args: + filenames_tuple: VHS_FILENAMES tuple from Video Combine node + + Returns: + List of Path objects for video files + """ + video_paths = [] + + try: + if not isinstance(filenames_tuple, (tuple, list)): + return video_paths + + # VHS_FILENAMES is (bool, [paths]) + if len(filenames_tuple) < 2: + return video_paths + + file_list = filenames_tuple[1] + if not isinstance(file_list, (list, tuple)): + return video_paths + + for path_str in file_list: + path = Path(path_str) + if path.exists() and is_video_file(path): + video_paths.append(path) + + except Exception as e: + print(f"[MetaHub Video] Warning: Could not parse VHS_FILENAMES: {e}") + + return video_paths