From 4fc7affb4ba2459aa59262e8e6f4415e129447a9 Mon Sep 17 00:00:00 2001 From: LuqP2 Date: Wed, 14 Jan 2026 14:02:58 -0300 Subject: [PATCH] feat: add video metadata support for VHS integration --- __init__.py | 6 +- .../metahub_save_video_node.cpython-310.pyc | Bin 0 -> 8925 bytes .../video_metadata_utils.cpython-310.pyc | Bin 0 -> 12307 bytes metahub_save_video_node.py | 410 +++++++++++++ video_metadata_utils.py | 566 ++++++++++++++++++ 5 files changed, 981 insertions(+), 1 deletion(-) create mode 100644 __pycache__/metahub_save_video_node.cpython-310.pyc create mode 100644 __pycache__/video_metadata_utils.cpython-310.pyc create mode 100644 metahub_save_video_node.py create mode 100644 video_metadata_utils.py 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 0000000000000000000000000000000000000000..8382beb64ad03ef77c5d6fb3ce35b8b79bf4282d GIT binary patch literal 8925 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 literal 0 HcmV?d00001 diff --git a/__pycache__/video_metadata_utils.cpython-310.pyc b/__pycache__/video_metadata_utils.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ddf9644529b4fca84f97885b5661bf6748a071d3 GIT binary patch literal 12307 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 literal 0 HcmV?d00001 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