feat: add video metadata support for VHS integration

This commit is contained in:
LuqP2
2026-01-14 14:02:58 -03:00
parent ef50806ab5
commit 4fc7affb4b
5 changed files with 981 additions and 1 deletions
+5 -1
View File
@@ -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"
}
Binary file not shown.
Binary file not shown.
+410
View File
@@ -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"}
+566
View File
@@ -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