Files

568 lines
23 KiB
Python

import os
import subprocess
import shlex
import json
import re
import sys
# ======================================================================================
# KY_FFmpeg core configuration and utility functions
# ======================================================================================
# Common GPU-accelerated encoder mapping (primarily NVIDIA, common in ComfyUI environments)
# Users pick a generic codec name; code selects the actual encoder based on GPU/CPU option
CODEC_MAP = {
"H.264 (MP4)": {"cpu": "libx264", "gpu": "h264_nvenc", "ext": "mp4"},
"H.265 (MP4)": {"cpu": "libx265", "gpu": "hevc_nvenc", "ext": "mp4"},
"ProRes (MOV)": {"cpu": "prores_ks", "gpu": None, "ext": "mov"},
"VP9 (WebM)": {"cpu": "libvpx-vp9", "gpu": None, "ext": "webm"},
}
# Common color space / transfer characteristics
COLORSPACE_MAP = {
"BT.709 (HD)": "bt709",
"BT.2020 (UHD/HDR)": "bt2020",
"Unspecified": "unspecified",
}
COMMON_FPS = [2, 3, 6, 12.0, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 59.94, 60.0, 120.0]
QUALITY_VALUE_CHOICES = ["12","13","14","15" ,"16","17", "18", "19","20","21","22", "23","24","25", "28", "35", "40", "45", "50", "5M", "8M", "10M", "15M", "20M"]
ENCODER_CHOICES = ["libx264", "h264_nvenc", "libx265", "hevc_nvenc", "prores_ks", "libvpx-vp9"]
HW_ACCEL_CHOICES = ["", "-hwaccel cuda -vsync passthrough", "-hwaccel cuda", "-hwaccel vulkan", "-hwaccel dxva2"]
PIX_FMT_CHOICES = [
"yuv420p",
"yuv420p10le",
"yuv444p",
"yuv444p10le",
"nv12",
"p010le",
"rgb24"
]
ENCODER_DEFAULT_CONTAINER = {
"libx264": "mp4",
"h264_nvenc": "mp4",
"libx265": "mp4",
"hevc_nvenc": "mp4",
"prores_ks": "mov",
"libvpx-vp9": "webm",
}
def _get_codec_params(codec_choice, quality_type, quality_value, gpu_accel):
"""Generate encoder and quality parameters based on user choices."""
# 1) Determine encoder name (CPU vs GPU)
codec_info = CODEC_MAP.get(codec_choice)
if not codec_info:
raise ValueError(f"Unknown codec choice: {codec_choice}")
# Prefer GPU encoder when selected and available
encoder = codec_info["cpu"]
hw_accel_flags = []
if gpu_accel and codec_info["gpu"]:
# Common hardware acceleration flags for NVIDIA (NVENC)
# Note: Different hardware/drivers may need different methods; use the most universal here.
hw_accel_flags.extend(["-vsync", "passthrough", "-hwaccel", "cuda"])
encoder = codec_info["gpu"]
codec_flags = ["-c:v", encoder]
# 2) Determine quality parameters (CRF/QP/Bitrate)
quality_flags = []
if quality_type == "CRF (Constant Rate Factor)":
# CRF works for libx264/libx265/VP9 (CPU)
quality_flags.extend(["-crf", str(quality_value)])
elif quality_type == "QP (Quantization Parameter)":
# QP works for NVENC (GPU)
# QP is recommended for GPU encoders, typically between 1-51
if "nvenc" in encoder:
quality_flags.extend(["-qp", str(quality_value)])
else:
# If QP selected but not a GPU encoder, fall back to CRF
quality_flags.extend(["-crf", str(quality_value)])
print(f"WARN: QP is best for NVENC. Using CRF {quality_value} instead for {encoder}.")
elif quality_type == "Bitrate (k/M)":
# Bitrate works for any encoder
quality_flags.extend(["-b:v", quality_value])
return hw_accel_flags, codec_flags, quality_flags, codec_info["ext"]
def _get_colorspace_flags(colorspace_choice):
"""Generate color space flags for KY_FFmpeg."""
colorspace_value = COLORSPACE_MAP.get(colorspace_choice, "unspecified")
if colorspace_value == "unspecified":
return []
# Set color_primaries, color_trc, colorspace consistently
return [
"-color_primaries", colorspace_value,
"-color_trc", colorspace_value,
"-colorspace", colorspace_value
]
def _detect_image_sequence(input_dir):
exts = ["png", "jpg", "jpeg", "webp", "tiff"]
if not os.path.isdir(input_dir):
return None
try:
files = os.listdir(input_dir)
except Exception:
return None
ext_any = {ext: False for ext in exts}
for f in files:
fl = f.lower()
for ext in exts:
if fl.endswith(f".{ext}"):
ext_any[ext] = True
break
for ext in exts:
prefix_groups = {}
for f in files:
if not f.lower().endswith(f".{ext}"):
continue
base = f[:-(len(ext) + 1)]
m = re.search(r"(\d+)$", base)
if m:
prefix = base[:m.start()]
num = m.group(1)
prefix_groups.setdefault(prefix, []).append(num)
if prefix_groups:
best_prefix = max(prefix_groups.keys(), key=lambda k: len(prefix_groups[k]))
nums = prefix_groups[best_prefix]
if nums:
widths = [len(n) for n in nums]
width = max(widths) if max(widths) > 1 else 0
if width > 1:
pattern = os.path.join(input_dir, f"{best_prefix}%0{width}d.{ext}")
else:
pattern = os.path.join(input_dir, f"{best_prefix}%d.{ext}")
start_number = min(int(n) for n in nums)
return pattern, start_number, ext, False
for ext in exts:
if ext_any[ext]:
pattern = os.path.join(input_dir, f"*.{ext}")
return pattern, None, ext, True
return None
def _run_KY_FFmpeg(command_list):
"""Execute KY_FFmpeg command and handle errors."""
command_str = subprocess.list2cmdline(command_list) if os.name == "nt" else " ".join(shlex.quote(c) for c in command_list)
print(f"[KY_FFmpeg] Running command: {command_str}", flush=True)
# Use pipes to capture output and Popen to avoid blocking
process = subprocess.Popen(
command_list,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
encoding='utf-8',
# Use multithreading to improve efficiency
env=dict(os.environ, FF_THREAD_FLAGS="0x8")
)
# Wait for the process to finish
stdout, stderr = process.communicate()
if process.returncode != 0:
error_message = f"KY_FFmpeg Error (Code {process.returncode}):\n{stderr}"
print(f"ERROR: {error_message}", flush=True)
raise RuntimeError(error_message)
print("[KY_FFmpeg] Command executed successfully.", flush=True)
# Optionally print partial output, especially progress (stdout) and detailed logs (stderr)
# print(f"KY_FFmpeg STDOUT:\n{stdout[-500:]}")
return {"status": "success", "command": command_str, "output": stdout}
# ======================================================================================
# 节点类定义
# ======================================================================================
class KY_FFmpegVideoToImages:
"""Node 1: Convert a video into an image sequence."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"video_path": ("STRING", {"default": "C:/path/to/input.mp4", "multiline": False}),
"output_dir": ("STRING", {"default": "C:/path/to/output/frames", "multiline": False}),
"output_fps": (COMMON_FPS, {"default": 30.0}),
"image_format": (["png", "jpg", "webp", "tiff"], {"default": "png"}),
},
}
RETURN_TYPES = ("STRING", "INT", "FLOAT")
RETURN_NAMES = ("output_dir_path", "frame_count", "total_size_mb")
FUNCTION = "execute"
CATEGORY = "Video/KY_FFmpeg"
DESCRIPTION = "Converts a video to an image sequence (e.g., for frame editing). High-quality PNG is default."
OUTPUT_NODE = True
def execute(self, video_path, output_dir, output_fps, image_format):
video_path = video_path.strip()
output_dir = output_dir.strip()
try:
output_fps = float(output_fps)
except Exception:
output_fps = 30.0
if not os.path.exists(video_path):
raise FileNotFoundError(f"Input video file not found: {video_path}")
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
# KY_FFmpeg command: -i input, -vf filter (set fps), %08d 8-digit sequence naming
output_pattern = os.path.join(output_dir, f"%08d.{image_format}")
pix_fmt = "rgb24"
if image_format.lower() == "jpg":
pix_fmt = "yuvj420p"
elif image_format.lower() == "webp":
pix_fmt = "yuv420p"
command = [
"ffmpeg", "-hide_banner",
"-i", video_path,
"-vf", f"fps={output_fps}",
"-pix_fmt", pix_fmt,
output_pattern
]
_run_KY_FFmpeg(command)
files = []
try:
for f in os.listdir(output_dir):
fl = f.lower()
if fl.endswith(f".{image_format.lower()}") and re.match(r"^\d+\.", f):
files.append(f)
except Exception:
files = []
frame_count = len(files)
total_size_bytes = 0
for f in files:
fp = os.path.join(output_dir, f)
try:
total_size_bytes += os.path.getsize(fp)
except Exception:
pass
total_size_mb = round(total_size_bytes / (1024 * 1024), 3)
return (output_dir, frame_count, total_size_mb)
class KY_FFmpegImagesToVideo:
"""Node 2: Encode an image sequence into a video."""
@classmethod
def INPUT_TYPES(s):
colorspace_choices = list(COLORSPACE_MAP.keys())
return {
"required": {
"input_dir": ("STRING", {"default": "C:/path/to/input/frames", "multiline": False}),
"output_path": ("STRING", {"default": "C:/path/to/output.mp4", "multiline": False}),
"input_fps": (COMMON_FPS, {"default": 30.0}),
"encoder_name": (ENCODER_CHOICES, {"default": "libx264"}),
"quality_type": (["CRF (Constant Rate Factor)", "QP (Quantization Parameter)", "Bitrate (k/M)"], {"default": "CRF (Constant Rate Factor)"}),
"quality_value": (QUALITY_VALUE_CHOICES, {"default": "18"}),
"colorspace_choice": (colorspace_choices, {"default": "BT.709 (HD)"}),
"pix_fmt": (PIX_FMT_CHOICES, {"default": "yuv420p"}),
},
"optional": {
"audio_in_path": ("STRING", {"default": "", "multiline": False}),
"audio_codec": (["aac", "copy"], {"default": "aac"}),
"audio_shortest": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("video_path",)
FUNCTION = "execute"
CATEGORY = "Video/KY_FFmpeg"
DESCRIPTION = "Encodes an image sequence into a video file with advanced options."
OUTPUT_NODE = True
def execute(self, input_dir, output_path, input_fps, encoder_name, quality_type, quality_value, colorspace_choice, pix_fmt, audio_in_path="", audio_codec="aac", audio_shortest=True):
input_dir = input_dir.strip()
output_path = output_path.strip()
try:
input_fps = float(input_fps)
except Exception:
input_fps = 30.0
# 1) Detect sequence pattern automatically (supports 00000.png or 00000001.webp, etc.)
seq_info = _detect_image_sequence(input_dir)
if not seq_info:
raise FileNotFoundError(f"No image sequence found in: {input_dir}")
pattern_path, start_number, detected_ext, use_glob = seq_info
# 2) Build encoder and quality parameters
if quality_type == "Bitrate (k/M)":
if not (str(quality_value).lower().endswith("m") or str(quality_value).lower().endswith("k")):
quality_value = "10M"
else:
try:
int(quality_value)
except Exception:
quality_value = "18" if quality_type == "CRF (Constant Rate Factor)" else "20"
codec_flags = ["-c:v", encoder_name]
if quality_type == "CRF (Constant Rate Factor)":
quality_flags = ["-crf", str(quality_value)]
elif quality_type == "QP (Quantization Parameter)":
quality_flags = ["-qp", str(quality_value)] if "nvenc" in encoder_name else ["-crf", str(quality_value)]
else:
quality_flags = ["-b:v", str(quality_value)]
default_ext = ENCODER_DEFAULT_CONTAINER.get(encoder_name, "mp4")
colorspace_flags = _get_colorspace_flags(colorspace_choice)
# 3) Construct output path (ensure extension matches container unless explicitly set)
if not output_path.lower().endswith(f".{default_ext}"):
output_path = f"{output_path.rsplit('.', 1)[0]}.{default_ext}"
print(f"INFO: Output path adjusted to match container: {output_path}")
# KY_FFmpeg command
command = [
"ffmpeg", "-hide_banner",
# Input settings
"-framerate", str(input_fps),
]
if use_glob:
command.extend(["-pattern_type", "glob", "-i", pattern_path])
else:
if start_number is not None:
command.extend(["-start_number", str(start_number)])
command.extend(["-i", pattern_path])
audio_in_path = (audio_in_path or "").strip()
if audio_in_path:
if not os.path.exists(audio_in_path):
raise FileNotFoundError(f"Input audio file not found: {audio_in_path}")
command.extend(["-i", audio_in_path])
# Video encoding settings (H.264/H.265/ProRes etc.)
command.extend(codec_flags)
# Quality/compression parameters (CRF/QP/Bitrate)
command.extend(quality_flags)
# Color space parameters
command.extend(colorspace_flags)
if audio_in_path:
command.extend(["-map", "0:v:0", "-map", "1:a:0", "-c:a", audio_codec])
if audio_shortest:
command.append("-shortest")
# Pixel format from dropdown
command.extend(["-pix_fmt", pix_fmt])
# Multithreading/speed optimization (important for CPU encoders)
command.extend(["-preset", "medium"])
# Force overwrite output file
command.extend(["-y", output_path])
_run_KY_FFmpeg(command)
return (output_path,)
class KY_FFmpegAddAudio:
"""Node 3: Add an audio track to a video."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"video_in_path": ("STRING", {"default": "C:/path/to/video_only.mp4", "multiline": False}),
"audio_in_path": ("STRING", {"default": "C:/path/to/audio_track.mp3", "multiline": False}),
"output_path": ("STRING", {"default": "C:/path/to/final_video.mp4", "multiline": False}),
"audio_codec": (["aac", "mp3", "copy"], {"default": "aac"}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("video_path",)
FUNCTION = "execute"
CATEGORY = "Video/KY_FFmpeg"
DESCRIPTION = "Merges a video file and an audio file. Uses 'copy' for video stream for speed."
OUTPUT_NODE = True
def execute(self, video_in_path, audio_in_path, output_path, audio_codec):
video_in_path = video_in_path.strip()
audio_in_path = audio_in_path.strip()
output_path = output_path.strip()
if not os.path.exists(video_in_path):
raise FileNotFoundError(f"Input video file not found: {video_in_path}")
if not os.path.exists(audio_in_path):
raise FileNotFoundError(f"Input audio file not found: {audio_in_path}")
# KY_FFmpeg command: -i with two inputs, -c:v copy (no re-encode), -c:a audio codec
# -map 0:v:0 (first video stream from first input), -map 1:a:0 (first audio stream from second input)
# -shortest (stop at the shortest stream to match durations)
command = [
"ffmpeg", "-hide_banner",
"-i", video_in_path,
"-i", audio_in_path,
"-c:v", "copy",
"-c:a", audio_codec,
"-map", "0:v:0",
"-map", "1:a:0",
"-shortest",
"-y",
output_path
]
_run_KY_FFmpeg(command)
return (output_path,)
class KY_FFmpegTrimVideo:
"""Node 4: Trim a video by start time and duration."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"video_in_path": ("STRING", {"default": "C:/path/to/input_video.mp4", "multiline": False}),
"output_path": ("STRING", {"default": "C:/path/to/trimmed_video.mp4", "multiline": False}),
"start_time_seconds": ("FLOAT", {"default": 10.0, "min": 0.0, "step": 0.1}),
"duration_seconds": ("FLOAT", {"default": 5.0, "min": 0.1, "step": 0.1}),
"copy_stream": ("BOOLEAN", {"default": True, "label": "Stream Copy (Fast, less precise start)"}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("video_path",)
FUNCTION = "execute"
CATEGORY = "Video/KY_FFmpeg"
DESCRIPTION = "Trims a video using start time and duration. Stream Copy is fastest but start time might be slightly off."
OUTPUT_NODE = True
def execute(self, video_in_path, output_path, start_time_seconds, duration_seconds, copy_stream):
video_in_path = video_in_path.strip()
output_path = output_path.strip()
if not os.path.exists(video_in_path):
raise FileNotFoundError(f"Input video file not found: {video_in_path}")
# Convert seconds to HH:MM:SS.ms format accepted by KY_FFmpeg
def _to_hhmmss(seconds):
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = seconds % 60
return f"{h:02d}:{m:02d}:{s:06.3f}"
start_time_str = _to_hhmmss(start_time_seconds)
duration_str = _to_hhmmss(duration_seconds)
# Recommended fast trim: place -ss (seek) before -i (input)
command = ["ffmpeg", "-hide_banner", "-ss", start_time_str, "-i", video_in_path]
# -t sets duration
command.extend(["-t", duration_str])
# Stream copy or re-encode
if copy_stream:
# -c copy is fastest (no re-encode) but seeking with -ss is less precise
command.extend(["-c", "copy"])
else:
# For precise trimming, re-encode (slower)
# You can add encoder and quality params here; keep defaults for simplicity
command.extend(["-c:v", "libx264", "-c:a", "aac"])
command.extend(["-y", output_path])
_run_KY_FFmpeg(command)
return (output_path,)
class KY_FFmpegCustomCmd:
"""New node: allow users to execute a custom KY_FFmpeg command."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"command_template": ("STRING", {"default": "-i {input_1} -c:v {param_1} -crf 23 {output_file}", "multiline": True, "placeholder": "Use {input_1}, {output_file}, {param_1} etc. to reference parameters"}),
"input_1": ("STRING", {"default": "C:/path/to/input.mp4", "multiline": False, "placeholder": "Input file path 1 ({input_1})"}),
"output_file": ("STRING", {"default": "C:/path/to/custom_output.mp4", "multiline": False, "placeholder": "Output file path ({output_file})"}),
},
"optional": {
"input_2": ("STRING", {"default": "", "multiline": False, "placeholder": "Input file path 2 ({input_2}, optional)"}),
"param_1": (ENCODER_CHOICES, {"default": "libx264"}),
"param_2": ("STRING", {"default": "", "multiline": False, "placeholder": "Custom parameter 2 ({param_2})"}),
"param_3": ("STRING", {"default": "", "multiline": False, "placeholder": "Custom parameter 3 ({param_3})"}),
"hw_accel_pre_flags": (HW_ACCEL_CHOICES, {"default": ""}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("output_video_path",)
FUNCTION = "execute"
CATEGORY = "Video/KY_FFmpeg/Advanced"
DESCRIPTION = "Executes a custom KY_FFmpeg command. Use {input_1}, {output_file}, {param_1}, etc., in the template."
OUTPUT_NODE = True
def execute(self, command_template, input_1, output_file, input_2="", param_1="", param_2="", param_3="", hw_accel_pre_flags=""):
# 1) Build parameter dictionary
params = {
"input_1": input_1.strip(),
"input_2": input_2.strip(),
"output_file": output_file.strip(),
"param_1": param_1.strip(),
"param_2": param_2.strip(),
"param_3": param_3.strip(),
}
# 2) Substitute parameters into the template
try:
# Use format() to substitute values
# Note: using raw params; empty strings are fine and avoid KeyError for optional keys.
custom_command_string = command_template.format(**params)
except KeyError as e:
raise ValueError(f"Command template contains an un-provided key: {e}. Available keys: {list(params.keys())}")
# 3) Build final command list
final_command_list = ["ffmpeg"]
# Prepend hardware acceleration flags (e.g. -hwaccel cuda), typically before -i
if hw_accel_pre_flags.strip():
final_command_list.extend(shlex.split(hw_accel_pre_flags.strip()))
# Append user-defined command section
# shlex.split safely tokenizes command strings and handles quoted paths
final_command_list.extend(shlex.split(custom_command_string))
# 4) Run KY_FFmpeg (ensure -y to force overwrite and avoid prompts)
if "-y" not in final_command_list:
final_command_list.append("-y")
_run_KY_FFmpeg(final_command_list)
return (output_file,)
# ======================================================================================
# ComfyUI node registration
# ======================================================================================
NODE_CLASS_MAPPINGS = {
"KY_FFmpegVideoToImages": KY_FFmpegVideoToImages,
"KY_FFmpegImagesToVideo": KY_FFmpegImagesToVideo,
"KY_FFmpegAddAudio": KY_FFmpegAddAudio,
"KY_FFmpegTrimVideo": KY_FFmpegTrimVideo,
"KY_FFmpegCustomCmd": KY_FFmpegCustomCmd, # 新增节点
}
NODE_DISPLAY_NAME_MAPPINGS = {
"KY_FFmpegVideoToImages": "KY_FFmpeg: Video to Image Sequence",
"KY_FFmpegImagesToVideo": "KY_FFmpeg: Image Sequence to Video",
"KY_FFmpegAddAudio": "KY_FFmpeg: Merge Video/Audio",
"KY_FFmpegTrimVideo": "KY_FFmpeg: Trim Video (Duration)",
"KY_FFmpegCustomCmd": "KY_FFmpeg: Custom Command (Advanced)",
}