Files
WJLUOXIAO-XB_ToolBox/nodes_video.py
T
2026-10-21 18:08:43 +08:00

1153 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import itertools
import numpy as np
import torch
import cv2
import hashlib
import re
import time
import subprocess
import shutil as _shutil
import folder_paths
from comfy.utils import common_upscale, ProgressBar
import nodes
from .xb_av import open_av
from .xb_paths import resolve_filename_prefix
# ============================================================
# 画幅比例 / 倍数 公共逻辑
# 画幅比例 = 自定义 + 官方 Resolution Selector 的比例;
# 倍数(multiple)约束宽高:既作为调节步长,也保证宽高能整除。
# ============================================================
XB_ASPECT_RATIOS = [
("自定义", None),
("1:1 (Square)", (1, 1)),
("2:3 (Portrait Photo)", (2, 3)),
("3:2 (Photo)", (3, 2)),
("3:4 (Portrait Standard)", (3, 4)),
("4:3 (Standard)", (4, 3)),
("9:16 (Portrait Widescreen)", (9, 16)),
("16:9 (Widescreen)", (16, 9)),
("21:9 (Ultrawide)", (21, 9)),
]
XB_ASPECT_VALUES = [name for name, _ in XB_ASPECT_RATIOS]
XB_ASPECT_MAP = {name: r for name, r in XB_ASPECT_RATIOS if r}
# 旧工作流里的值 → 新值(保证老图照常开)
XB_ASPECT_LEGACY = {
"Free": "自定义",
"1:1": "1:1 (Square)",
"2:3": "2:3 (Portrait Photo)",
"3:2": "3:2 (Photo)",
"3:4": "3:4 (Portrait Standard)",
"4:3": "4:3 (Standard)",
"9:16": "9:16 (Portrait Widescreen)",
"16:9": "16:9 (Widescreen)",
"21:9": "21:9 (Ultrawide)",
}
XB_MULTIPLE_DEFAULT = 16
def xb_multiple(value, default=XB_MULTIPLE_DEFAULT):
"""把「倍数」规整成 >= 8 的整数。"""
try:
m = int(round(float(value)))
except Exception:
m = default
return max(8, m)
def xb_snap(value, m):
"""吸附到 m 的整数倍(并保证 >= m)。"""
try:
v = int(round(float(value)))
except Exception:
v = m
return max(m, int(round(v / m)) * m)
def xb_aspect_ratio(name):
"""画幅比例 → 宽高比(宽/高);自定义 / 未知返回 None。"""
if name in XB_ASPECT_MAP:
w, h = XB_ASPECT_MAP[name]
return w / h
name = XB_ASPECT_LEGACY.get(name, name)
if name in XB_ASPECT_MAP:
w, h = XB_ASPECT_MAP[name]
return w / h
# 兼容 "16:9 (LTX)" 这类旧写法
for key, ratio in XB_ASPECT_MAP.items():
if name and name.startswith(key.split(" ")[0]):
w, h = ratio
return w / h
return None
def xb_fit_size(name, width, height, m):
"""按比例 + 倍数算出最终宽高(两边都能被 m 整除)。"""
w = xb_snap(width, m)
h = xb_snap(height, m)
ratio = xb_aspect_ratio(name)
if ratio:
if w >= h:
h = xb_snap(w / ratio, m)
else:
w = xb_snap(h * ratio, m)
return max(m, w), max(m, h)
def xb_multiple_input():
"""「倍数」控件定义(放在最下面)。"""
return ("INT", {
"default": XB_MULTIPLE_DEFAULT, "min": 8, "max": 128, "step": 4,
"tooltip": "宽高必须是它的整数倍;调节分辨率时也按这个步长走(默认 16)。",
})
# ============================================================
# XB_ImageParamsMaster — 图像参数主控
# ============================================================
class XB_ImageParamsMaster:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"aspect_ratio": (XB_ASPECT_VALUES, {"default": "自定义"}),
"width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 16}),
"height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 16}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 1000, "step": 1}),
"strength_float": ("FLOAT", {"default": 1.00, "min": 0.00, "max": 10.00, "step": 0.01}),
"strength_int": ("INT", {"default": 1, "min": 0, "max": 1000, "step": 1}),
"multiple": xb_multiple_input(),
}
}
RETURN_TYPES = ("INT", "INT", "INT", "FLOAT", "INT", "INT")
RETURN_NAMES = ("Image Width", "Image Height", "Batch Size", "Float Control", "Int Control", "Scale Size")
FUNCTION = "process"
CATEGORY = "XB_ToolBox/Image_Params"
def process(self, aspect_ratio, width, height, batch_size, strength_float, strength_int, multiple=XB_MULTIPLE_DEFAULT):
m = xb_multiple(multiple)
safe_w, safe_h = xb_fit_size(aspect_ratio, width, height, m)
return (safe_w, safe_h, batch_size, float(strength_float), int(strength_int), max(safe_w, safe_h))
# ============================================================
# XB_ImageParamsMasterMini — 图像参数主控(精简版)
# 去掉「强度(小数)」「强度(整数)」两个控件与对应输出
# 保留画幅比例联动 + 16 步长联动(JS 端负责)
# ============================================================
class XB_ImageParamsMasterMini:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"aspect_ratio": (XB_ASPECT_VALUES, {"default": "自定义"}),
"width": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 16}),
"height": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 16}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 1000, "step": 1}),
"multiple": xb_multiple_input(),
}
}
RETURN_TYPES = ("INT", "INT", "INT", "INT")
RETURN_NAMES = ("Image Width", "Image Height", "Batch Size", "Scale Size")
FUNCTION = "process"
CATEGORY = "XB_ToolBox/Image_Params"
def process(self, aspect_ratio, width, height, batch_size, multiple=XB_MULTIPLE_DEFAULT):
m = xb_multiple(multiple)
safe_w, safe_h = xb_fit_size(aspect_ratio, width, height, m)
return (safe_w, safe_h, batch_size, max(safe_w, safe_h))
# ============================================================
# XB_VideoParamsMaster — 视频参数主控
# ============================================================
class XB_VideoParamsMaster:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"aspect_ratio": (XB_ASPECT_VALUES, {"default": "自定义"}),
"duration_display": ("STRING", {"default": "Video Duration: 0.00 s", "multiline": False}),
"width": ("INT", {"default": 480, "min": 64, "max": 8192, "step": 16}),
"height": ("INT", {"default": 832, "min": 64, "max": 8192, "step": 16}),
"length": ("INT", {"default": 81, "min": 1, "max": 9999, "step": 1}),
"fps": ("INT", {"default": 16, "min": 1, "max": 120, "step": 1}),
"fps_float": ("FLOAT", {"default": 16.0, "min": 1.0, "max": 120.0, "step": 0.01}),
"multiple": xb_multiple_input(),
}
}
RETURN_TYPES = ("INT", "INT", "INT", "INT", "FLOAT", "INT")
RETURN_NAMES = ("Width", "Height", "Frames", "FPS", "FPS_Float", "Scale Size")
FUNCTION = "process"
CATEGORY = "XB_ToolBox/Image_Params"
def process(self, aspect_ratio, duration_display, width, height, length, fps, fps_float,
multiple=XB_MULTIPLE_DEFAULT):
final_fps = int(round(fps))
m = xb_multiple(multiple)
safe_w, safe_h = xb_fit_size(aspect_ratio, width, height, m)
is_free = xb_aspect_ratio(aspect_ratio) is None
if is_free:
safe_len = max(1, int(length))
else:
safe_len = max(1, round((length - 1) / 8) * 8 + 1)
return (safe_w, safe_h, safe_len, final_fps, float(final_fps), max(safe_w, safe_h))
# ============================================================
# XB_MasterParameter — 万能参数控制器
# ============================================================
class XB_MasterParameter:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mode": (["Free Mode", "Model Mode", "Encode Mode", "Decode Mode", "Ratio Mode", "Other Mode"], {"default": "Free Mode"}),
"value": ("FLOAT", {"default": 1024.0, "min": 0.0, "max": 9999.0, "step": 0.01}),
}
}
RETURN_TYPES = ("INT", "FLOAT")
RETURN_NAMES = ("Integer (INT)", "Float (FLOAT)")
FUNCTION = "get_value"
CATEGORY = "XB_ToolBox/Params_Control"
def get_value(self, mode, value):
if mode == "Free Mode":
val = max(0.0, min(9999.0, value))
elif mode == "Model Mode":
val = max(0.0, min(50.0, value))
elif mode in ["Encode Mode", "Decode Mode"]:
val = max(64.0, min(3840.0, value))
val = round(val / 32) * 32
elif mode == "Ratio Mode":
val = max(0.0, min(1.0, value))
elif mode == "Other Mode":
val = max(0.0, min(9999.0, value))
else:
val = value
return (int(round(val)), float(val))
# ============================================================
# 以下代码一字不差搬运自 VHS load_video_nodes.py 和 utils.py
# ============================================================
DIMMAX = 8192
BIGMAX = (2**53-1)
video_extensions = ['webm', 'mp4', 'mkv', 'gif', 'mov']
VHSLoadFormats = {
'None': {},
'AnimateDiff': {'target_rate': 8, 'dim': (8,0,512,512)},
'Mochi': {'target_rate': 24, 'dim': (16,0,848,480), 'frames':(6,1)},
'LTXV': {'target_rate': 24, 'dim': (32,0,768,512), 'frames':(8,1)},
'Hunyuan': {'target_rate': 24, 'dim': (16,0,848,480), 'frames':(4,1)},
'Cosmos': {'target_rate': 24, 'dim': (16,0,1280,704), 'frames':(8,1)},
'Wan': {'target_rate': 16, 'dim': (8,0,832,480), 'frames':(4,1)},
}
if not hasattr(nodes, 'VHSLoadFormats'):
nodes.VHSLoadFormats = {}
def get_load_formats():
formats = {}
formats.update(nodes.VHSLoadFormats)
formats.update(VHSLoadFormats)
return (list(formats.keys()),
{'default': 'AnimateDiff', 'formats': formats})
def get_format(format):
if format in VHSLoadFormats:
return VHSLoadFormats[format]
return nodes.VHSLoadFormats.get(format, {})
def strip_path(path):
path = path.strip()
if path.startswith("\""):
path = path[1:]
if path.endswith("\""):
path = path[:-1]
return path
def calculate_file_hash(filename: str, hash_every_n: int = 1):
h = hashlib.sha256()
h.update(filename.encode())
h.update(str(os.path.getmtime(filename)).encode())
return h.hexdigest()
def target_size(width, height, custom_width, custom_height, downscale_ratio=8) -> tuple[int, int]:
if downscale_ratio is None:
downscale_ratio = 8
if custom_width == 0 and custom_height == 0:
pass
elif custom_height == 0:
height *= custom_width/width
width = custom_width
elif custom_width == 0:
width *= custom_height/height
height = custom_height
else:
width = custom_width
height = custom_height
width = int(width/downscale_ratio + 0.5) * downscale_ratio
height = int(height/downscale_ratio + 0.5) * downscale_ratio
return (width, height)
def cv_frame_generator(video, force_rate, frame_load_cap, skip_first_frames,
select_every_nth, meta_batch=None, unique_id=None):
video_cap = cv2.VideoCapture(video)
if not video_cap.isOpened() or not video_cap.grab():
raise ValueError(f"{video} could not be loaded with cv.")
# extract video metadata
fps = video_cap.get(cv2.CAP_PROP_FPS)
width = int(video_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(video_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
total_frames = int(video_cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = total_frames / fps
width = 0
if width <=0 or height <=0:
_, frame = video_cap.retrieve()
height, width, _ = frame.shape
# set video_cap to look at start_index frame
total_frame_count = 0
total_frames_evaluated = -1
frames_added = 0
base_frame_time = 1 / fps
prev_frame = None
if force_rate == 0:
target_frame_time = base_frame_time
else:
target_frame_time = 1/force_rate
if total_frames > 0:
if force_rate != 0:
yieldable_frames = int(total_frames / fps * force_rate)
else:
yieldable_frames = total_frames
if select_every_nth:
yieldable_frames //= select_every_nth
if frame_load_cap != 0:
yieldable_frames = min(frame_load_cap, yieldable_frames)
else:
yieldable_frames = 0
yield (width, height, fps, duration, total_frames, target_frame_time, yieldable_frames)
pbar = ProgressBar(yieldable_frames)
time_offset=target_frame_time
while video_cap.isOpened():
if time_offset < target_frame_time:
is_returned = video_cap.grab()
# if didn't return frame, video has ended
if not is_returned:
break
time_offset += base_frame_time
if time_offset < target_frame_time:
continue
time_offset -= target_frame_time
# if not at start_index, skip doing anything with frame
total_frame_count += 1
if total_frame_count <= skip_first_frames:
continue
else:
total_frames_evaluated += 1
# if should not be selected, skip doing anything with frame
if total_frames_evaluated%select_every_nth != 0:
continue
# opencv loads images in BGR format (yuck), so need to convert to RGB for ComfyUI use
# follow up: can videos ever have an alpha channel?
# To my testing: No. opencv has no support for alpha
unused, frame = video_cap.retrieve()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
# convert frame to comfyui's expected format
# TODO: frame contains no exif information. Check if opencv2 has already applied
frame = np.array(frame, dtype=np.float32)
torch.from_numpy(frame).div_(255)
if prev_frame is not None:
inp = yield prev_frame
if inp is not None:
#ensure the finally block is called
return
prev_frame = frame
frames_added += 1
if pbar is not None:
pbar.update_absolute(frames_added, yieldable_frames)
# if cap exists and we've reached it, stop processing frames
if frame_load_cap > 0 and frames_added >= frame_load_cap:
break
if meta_batch is not None:
meta_batch.inputs.pop(unique_id)
meta_batch.has_closed_inputs = True
if prev_frame is not None:
yield prev_frame
#Python 3.12 adds an itertools.batched, but it's easily replicated for legacy support
def batched(it, n):
while batch := tuple(itertools.islice(it, n)):
yield batch
def batched_vae_encode(images, vae, frames_per_batch):
for batch in batched(images, frames_per_batch):
image_batch = torch.from_numpy(np.array(batch))
yield from vae.encode(image_batch).numpy()
def resized_cv_frame_gen(custom_width, custom_height, downscale_ratio, **kwargs):
gen = cv_frame_generator(**kwargs)
info = next(gen)
width, height = info[0], info[1]
frames_per_batch = (1920 * 1080 * 16) // (width * height) or 1
if kwargs.get('meta_batch', None) is not None:
frames_per_batch = min(frames_per_batch, kwargs['meta_batch'].frames_per_batch)
if custom_width != 0 or custom_height != 0 or downscale_ratio is not None:
new_size = target_size(width, height, custom_width, custom_height, downscale_ratio)
yield (*info, new_size[0], new_size[1], False)
if new_size[0] != width or new_size[1] != height:
def rescale(frame):
s = torch.from_numpy(np.fromiter(frame, np.dtype((np.float32, (height, width, 3)))))
s = s.movedim(-1,1)
s = common_upscale(s, new_size[0], new_size[1], "lanczos", "center")
return s.movedim(1,-1).numpy()
yield from itertools.chain.from_iterable(map(rescale, batched(gen, frames_per_batch)))
return
else:
yield (*info, info[0], info[1], False)
yield from gen
def _lazy_get_audio(file, start_time=0, duration=0):
"""替代 VHS 的 lazy_get_audio,使用 PyAV。视频无音轨时返回 None,不伪造静音。"""
try:
import av
except ImportError as exc:
raise RuntimeError("Video audio loading requires PyAV (av)") from exc
# 只探测流元数据,不解码;这样可选 AUDIO 消费端能拿到 None 而不是非法的单采样点张量
try:
with open_av(file) as container:
if not any(stream.type == 'audio' for stream in container.streams):
return None
except Exception as exc:
raise RuntimeError("Unable to inspect video audio streams") from exc
class _LazyAudioMap:
def __init__(s, file, st, dur):
s._d = None; s.file = file; s.st = st; s.dur = dur
def _load(s):
if s._d is not None: return
try:
with open_av(s.file) as c:
a = next((x for x in c.streams if x.type == 'audio'), None)
if a is None:
raise ValueError("Audio stream disappeared after probing")
sr = a.codec_context.sample_rate or 44100
frames = []
sp = int(s.st*sr) if s.st>0 else 0
ep = int((s.st+s.dur)*sr) if s.dur>0 else None
for f in c.decode(a):
if f.pts is not None:
if sp>0 and f.pts<sp: continue
if ep is not None and f.pts>=ep: break
frames.append(f.to_ndarray())
if not frames:
raise ValueError("Audio stream has no decoded frames in the requested interval")
wav = np.concatenate(frames, axis=-1)
if wav.size == 0:
raise ValueError("Audio stream contains no decoded samples")
if wav.ndim==1: wav=wav[np.newaxis,:]
elif wav.shape[0]>1: wav=wav.mean(axis=0, keepdims=True)
wf = torch.from_numpy(wav.astype(np.float32))
if wf.dim()==2: wf=wf.unsqueeze(0)
s._d = {'waveform': wf, 'sample_rate': sr}
except Exception as exc:
raise RuntimeError("Unable to decode video audio") from exc
def __getitem__(s,k): s._load(); return s._d[k]
def __iter__(s): s._load(); return iter(s._d)
def __len__(s): s._load(); return len(s._d)
return _LazyAudioMap(file, start_time, duration)
def load_video(meta_batch=None, unique_id=None, memory_limit_mb=None, vae=None,
generator=resized_cv_frame_gen, format='None', **kwargs):
if 'force_size' in kwargs:
kwargs.pop('force_size')
format = get_format(format)
kwargs['video'] = strip_path(kwargs['video'])
if vae is not None:
downscale_ratio = getattr(vae, "downscale_ratio", 8)
else:
downscale_ratio = format.get('dim', (1,))[0]
if meta_batch is None or unique_id not in meta_batch.inputs:
gen = generator(meta_batch=meta_batch, unique_id=unique_id, downscale_ratio=downscale_ratio, **kwargs)
(width, height, fps, duration, total_frames, target_frame_time, yieldable_frames, new_width, new_height, alpha) = next(gen)
if meta_batch is not None:
meta_batch.inputs[unique_id] = (gen, width, height, fps, duration, total_frames, target_frame_time, yieldable_frames, new_width, new_height, alpha)
if yieldable_frames:
meta_batch.total_frames = min(meta_batch.total_frames, yieldable_frames)
else:
(gen, width, height, fps, duration, total_frames, target_frame_time, yieldable_frames, new_width, new_height, alpha) = meta_batch.inputs[unique_id]
memory_limit = None
if memory_limit_mb is not None:
memory_limit *= 2 ** 20
else:
try:
import psutil
memory_limit = (psutil.virtual_memory().available + psutil.swap_memory().free) - 2 ** 27
except:
memory_limit = BIGMAX
if vae is not None:
max_loadable_frames = int(memory_limit//(width*height*3*(4+4+1/10)))
else:
max_loadable_frames = int(memory_limit//(width*height*3*(.1)))
if meta_batch is not None:
if 'frames' in format:
if meta_batch.frames_per_batch % format['frames'][0] != format['frames'][1]:
error = (meta_batch.frames_per_batch - format['frames'][1]) % format['frames'][0]
suggested = meta_batch.frames_per_batch - error
if error > format['frames'][0] / 2:
suggested += format['frames'][0]
raise RuntimeError(f"The chosen frames per batch is incompatible with the selected format. Try {suggested}")
if meta_batch.frames_per_batch > max_loadable_frames:
raise RuntimeError(f"Meta Batch set to {meta_batch.frames_per_batch} frames but only {max_loadable_frames} can fit in memory")
gen = itertools.islice(gen, meta_batch.frames_per_batch)
else:
original_gen = gen
gen = itertools.islice(gen, max_loadable_frames)
frames_per_batch = (1920 * 1080 * 16) // (width * height) or 1
if vae is not None:
gen = batched_vae_encode(gen, vae, frames_per_batch)
vw,vh = new_width//downscale_ratio, new_height//downscale_ratio
channels = getattr(vae, 'latent_channels', 4)
images = torch.from_numpy(np.fromiter(gen, np.dtype((np.float32, (channels,vh,vw)))))
else:
#Some minor wizardry to eliminate a copy and reduce max memory by a factor of ~2
images = torch.from_numpy(np.fromiter(gen, np.dtype((np.float32, (new_height, new_width, 4 if alpha else 3)))))
if meta_batch is None and memory_limit is not None:
try:
next(original_gen)
raise RuntimeError(f"Memory limit hit after loading {len(images)} frames. Stopping execution.")
except StopIteration:
pass
if len(images) == 0:
raise RuntimeError("No frames generated")
if 'frames' in format and len(images) % format['frames'][0] != format['frames'][1]:
err_msg = f"The number of frames loaded {len(images)}, does not match the requirements of the currently selected format."
if len(format['frames']) > 2 and format['frames'][2]:
raise RuntimeError(err_msg)
div, mod = format['frames'][:2]
frames = (len(images) - mod) // div * div + mod
images = images[:frames]
if 'start_time' in kwargs:
start_time = kwargs['start_time']
else:
start_time = kwargs['skip_first_frames'] * target_frame_time
target_frame_time *= kwargs.get('select_every_nth', 1)
#Setup lambda for lazy audio capture
audio = _lazy_get_audio(kwargs['video'], start_time, kwargs['frame_load_cap']*target_frame_time)
#Adjust target_frame_time for select_every_nth
video_info = {
"source_fps": fps,
"source_frame_count": total_frames,
"source_duration": duration,
"source_width": width,
"source_height": height,
"loaded_fps": 1/target_frame_time,
"loaded_frame_count": len(images),
"loaded_duration": len(images) * target_frame_time,
"loaded_width": new_width,
"loaded_height": new_height,
}
if vae is None:
return (images, len(images), audio, video_info)
else:
return ({"samples": images}, len(images), audio, video_info)
# ============================================================
# XB_VideoLoader
# ============================================================
class XB_VideoLoader:
@classmethod
def INPUT_TYPES(cls):
input_dir = folder_paths.get_input_directory()
files = []
for f in os.listdir(input_dir):
if os.path.isfile(os.path.join(input_dir, f)):
file_parts = f.split('.')
if len(file_parts) > 1 and (file_parts[-1].lower() in video_extensions):
files.append(f)
return {"required": {
"video": (sorted(files),),
"force_rate": ("FLOAT", {"default": 0, "min": 0, "max": 60, "step": 1}),
"custom_width": ("INT", {"default": 0, "min": 0, "max": DIMMAX}),
"custom_height": ("INT", {"default": 0, "min": 0, "max": DIMMAX}),
"frame_load_cap": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}),
"skip_first_frames": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}),
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": BIGMAX, "step": 1}),
},
"optional": {
"meta_batch": ("VHS_BatchManager",),
"vae": ("VAE",),
"format": get_load_formats(),
},
"hidden": {
"unique_id": "UNIQUE_ID"
},
}
RETURN_TYPES = ("IMAGE", "INT", "AUDIO", "VHS_VIDEOINFO")
RETURN_NAMES = ("IMAGE", "frame_count", "audio", "video_info")
FUNCTION = "load_video"
CATEGORY = "XB_ToolBox/Video"
def load_video(self, **kwargs):
kwargs['video'] = folder_paths.get_annotated_filepath(strip_path(kwargs['video']))
return load_video(**kwargs)
@classmethod
def IS_CHANGED(s, video, **kwargs):
image_path = folder_paths.get_annotated_filepath(video)
return calculate_file_hash(image_path)
@classmethod
def VALIDATE_INPUTS(s, video):
if not folder_paths.exists_annotated_filepath(video):
return "Invalid video file: {}".format(video)
return True
# ============================================================
# 以下代码搬运自 VHS utils.py 和 nodes.py
# ============================================================
# ffmpeg_path
if "VHS_FORCE_FFMPEG_PATH" in os.environ:
_ffmpeg = os.environ.get("VHS_FORCE_FFMPEG_PATH")
else:
_ffmpeg_paths = []
try:
from imageio_ffmpeg import get_ffmpeg_exe
_ffmpeg_paths.append(get_ffmpeg_exe())
except:
pass
system_ffmpeg = _shutil.which("ffmpeg")
if system_ffmpeg:
_ffmpeg_paths.append(system_ffmpeg)
if os.path.isfile("ffmpeg"): _ffmpeg_paths.append(os.path.abspath("ffmpeg"))
if os.path.isfile("ffmpeg.exe"): _ffmpeg_paths.append(os.path.abspath("ffmpeg.exe"))
_ffmpeg = _ffmpeg_paths[0] if _ffmpeg_paths else None
import json as _json
import datetime as _dt
import sys as _sys
import functools as _functools
from PIL import Image as _PILImage, ExifTags as _ExifTags
from PIL.PngImagePlugin import PngInfo as _PngInfo
from string import Template as _Template
# gifski_path
_gifski_path = os.environ.get("VHS_GIFSKI", None)
if _gifski_path is None:
_gifski_path = os.environ.get("JOV_GIFSKI", None)
if _gifski_path is None:
_gifski_path = _shutil.which("gifski")
def _merge_filter_args(args, ftype="-vf"):
try:
start_index = args.index(ftype)+1
index = start_index
while True:
index = args.index(ftype, index)
args[start_index] += ',' + args[index+1]
args.pop(index)
args.pop(index)
except ValueError:
pass
def _cached(duration):
def dec(f):
cached_ret = None
cache_time = 0
def cached_func():
nonlocal cache_time, cached_ret
if time.time() > cache_time + duration or cached_ret is None:
cache_time = time.time()
cached_ret = f()
return cached_ret
return cached_func
return dec
class _MultiInput(str):
def __new__(cls, string, allowed_types="*"):
res = super().__new__(cls, string)
res.allowed_types=allowed_types
return res
def __ne__(self, other):
if self.allowed_types == "*" or other == "*":
return False
return other not in self.allowed_types
_imageOrLatent = _MultiInput("IMAGE", ["IMAGE", "LATENT"])
_floatOrInt = _MultiInput("FLOAT", ["FLOAT", "INT"])
class _ContainsAll(dict):
def __contains__(self, other):
return True
def __getitem__(self, key):
return super().get(key, (None, {}))
def _flatten_list(l):
ret = []
for e in l:
if isinstance(e, list):
ret.extend(e)
else:
ret.append(e)
return ret
def _iterate_format(video_format, for_widgets=True):
def indirector(cont, index):
if isinstance(cont[index], list) and (not for_widgets
or len(cont[index])> 1 and not isinstance(cont[index][1], dict)):
inp = yield cont[index]
if inp is not None:
cont[index] = inp
yield
for k in video_format:
if k == "extra_widgets":
if for_widgets:
yield from video_format["extra_widgets"]
elif k.endswith("_pass"):
for i in range(len(video_format[k])):
yield from indirector(video_format[k], i)
if not for_widgets:
video_format[k] = _flatten_list(video_format[k])
else:
yield from indirector(video_format, k)
if 'VHS_video_formats' not in folder_paths.folder_names_and_paths:
folder_paths.folder_names_and_paths["VHS_video_formats"] = ((),{".json"})
if len(folder_paths.folder_names_and_paths['VHS_video_formats'][1]) == 0:
folder_paths.folder_names_and_paths["VHS_video_formats"][1].add(".json")
_base_formats_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "video_formats")
@_cached(5)
def _get_video_formats():
format_files = {}
for format_name in folder_paths.get_filename_list("VHS_video_formats"):
format_files[format_name] = folder_paths.get_full_path("VHS_video_formats", format_name)
os.makedirs(_base_formats_dir, exist_ok=True)
for item in os.scandir(_base_formats_dir):
if not item.is_file() or not item.name.endswith('.json'):
continue
format_files[item.name[:-5]] = item.path
formats = []
format_widgets = {}
for format_name, path in format_files.items():
with open(path, 'r') as stream:
video_format = _json.load(stream)
if "gifski_pass" in video_format and _gifski_path is None:
continue
widgets = list(_iterate_format(video_format))
formats.append("video/" + format_name)
if (len(widgets) > 0):
format_widgets["video/"+ format_name] = widgets
return formats, format_widgets
def _apply_format_widgets(format_name, kwargs):
if os.path.exists(os.path.join(_base_formats_dir, format_name + ".json")):
video_format_path = os.path.join(_base_formats_dir, format_name + ".json")
else:
video_format_path = folder_paths.get_full_path("VHS_video_formats", format_name)
with open(video_format_path, 'r') as stream:
video_format = _json.load(stream)
for w in _iterate_format(video_format):
if w[0] not in kwargs:
if len(w) > 2 and 'default' in w[2]:
default = w[2]['default']
else:
if type(w[1]) is list:
default = w[1][0]
else:
default = {"BOOLEAN": False, "INT": 0, "FLOAT": 0, "STRING": ""}[w[1]]
kwargs[w[0]] = default
wit = _iterate_format(video_format, False)
for w in wit:
while isinstance(w, list):
if len(w) == 1:
w = [_Template(x).substitute(**kwargs) for x in w[0]]
break
elif isinstance(w[1], dict):
w = w[1][str(kwargs[w[0]])]
elif len(w) > 3:
w = _Template(w[3]).substitute(val=kwargs[w[0]])
else:
w = str(kwargs[w[0]])
wit.send(w)
return video_format
def _tensor_to_int(tensor, bits):
tensor = tensor.cpu().numpy() * (2**bits-1) + 0.5
return np.clip(tensor, 0, (2**bits-1))
def _tensor_to_shorts(tensor):
return _tensor_to_int(tensor, 16).astype(np.uint16)
def _tensor_to_bytes(tensor):
return _tensor_to_int(tensor, 8).astype(np.uint8)
def _ffmpeg_process(args, video_format, video_metadata, file_path, env):
res = None
frame_data = yield
total_frames_output = 0
if video_format.get('save_metadata', 'False') != 'False':
os.makedirs(folder_paths.get_temp_directory(), exist_ok=True)
metadata_path = os.path.join(folder_paths.get_temp_directory(), "metadata.txt")
def escape_ffmpeg_metadata(key, value):
value = str(value)
value = value.replace("\\","\\\\")
value = value.replace(";","\\;")
value = value.replace("#","\\#")
value = value.replace("=","\\=")
value = value.replace("\n","\\\n")
return f"{key}={value}"
with open(metadata_path, "w") as f:
f.write(";FFMETADATA1\n")
if "prompt" in video_metadata:
f.write(escape_ffmpeg_metadata("prompt", _json.dumps(video_metadata["prompt"])) + "\n")
if "workflow" in video_metadata:
f.write(escape_ffmpeg_metadata("workflow", _json.dumps(video_metadata["workflow"])) + "\n")
for k, v in video_metadata.items():
if k not in ["prompt", "workflow"]:
f.write(escape_ffmpeg_metadata(k, _json.dumps(v)) + "\n")
m_args = args[:1] + ["-i", metadata_path] + args[1:] + ["-metadata", "creation_time=now", "-movflags", "use_metadata_tags"]
with subprocess.Popen(m_args + [file_path], stderr=subprocess.PIPE,
stdin=subprocess.PIPE, env=env) as proc:
try:
while frame_data is not None:
proc.stdin.write(frame_data)
frame_data = yield
total_frames_output+=1
proc.stdin.flush()
proc.stdin.close()
res = proc.stderr.read()
except BrokenPipeError as e:
err = proc.stderr.read()
if os.path.exists(file_path):
raise Exception("An error occurred in the ffmpeg subprocess:\n" + err.decode(*ENCODE_ARGS))
print(err.decode(*ENCODE_ARGS), end="", file=_sys.stderr)
if res != b'':
with subprocess.Popen(args + [file_path], stderr=subprocess.PIPE,
stdin=subprocess.PIPE, env=env) as proc:
try:
while frame_data is not None:
proc.stdin.write(frame_data)
frame_data = yield
total_frames_output+=1
proc.stdin.flush()
proc.stdin.close()
res = proc.stderr.read()
except BrokenPipeError as e:
res = proc.stderr.read()
raise Exception("An error occurred in the ffmpeg subprocess:\n" + res.decode(*ENCODE_ARGS))
yield total_frames_output
if len(res) > 0:
print(res.decode(*ENCODE_ARGS), end="", file=_sys.stderr)
def _gifski_process(args, dimensions, frame_rate, video_format, file_path, env):
frame_data = yield
with subprocess.Popen(args + video_format['main_pass'] + ['-f', 'yuv4mpegpipe', '-'],
stderr=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, env=env) as procff:
with subprocess.Popen([_gifski_path] + video_format['gifski_pass']
+ ['-W', f'{dimensions[0]}', '-H', f'{dimensions[1]}']
+ ['-r', f'{frame_rate}']
+ ['-q', '-o', file_path, '-'], stderr=subprocess.PIPE,
stdin=procff.stdout, stdout=subprocess.PIPE,
env=env) as procgs:
try:
while frame_data is not None:
procff.stdin.write(frame_data)
frame_data = yield
procff.stdin.flush()
procff.stdin.close()
resff = procff.stderr.read()
resgs = procgs.stderr.read()
outgs = procgs.stdout.read()
except BrokenPipeError as e:
procff.stdin.close()
resff = procff.stderr.read()
resgs = procgs.stderr.read()
raise Exception("An error occurred while creating gifski output\n"
+ "Make sure you are using gifski --version >=1.32.0\nffmpeg: "
+ resff.decode(*ENCODE_ARGS) + '\ngifski: ' + resgs.decode(*ENCODE_ARGS))
if len(resff) > 0:
print(resff.decode(*ENCODE_ARGS), end="", file=_sys.stderr)
if len(resgs) > 0:
print(resgs.decode(*ENCODE_ARGS), end="", file=_sys.stderr)
if len(outgs) > 0:
print(outgs.decode(*ENCODE_ARGS))
def _to_pingpong(inp):
if not hasattr(inp, "__getitem__"):
inp = list(inp)
yield from inp
for i in range(len(inp)-2,0,-1):
yield inp[i]
# ============================================================
# XB_VideoCombine — 完整搬运 VHS VideoCombine,完全独立
# ============================================================
class XB_VideoCombine:
@classmethod
def INPUT_TYPES(cls):
ffmpeg_formats, format_widgets = _get_video_formats()
format_widgets["image/webp"] = [['lossless', "BOOLEAN", {'default': True}]]
return {
"required": {
"images": (_imageOrLatent,),
"frame_rate": (_floatOrInt, {"default": 8, "min": 1, "step": 1}),
"loop_count": ("INT", {"default": 0, "min": 0, "max": 100, "step": 1}),
"filename_prefix": ("STRING", {"default": "视频"}),
"format": (["image/gif", "image/webp"] + ffmpeg_formats, {'formats': format_widgets}),
"pingpong": ("BOOLEAN", {"default": False}),
"save_output": ("BOOLEAN", {"default": True}),
},
"optional": {
"audio": ("AUDIO",),
},
"hidden": _ContainsAll({
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
"unique_id": "UNIQUE_ID"
}),
}
RETURN_TYPES = ("VHS_FILENAMES",)
RETURN_NAMES = ("Filenames",)
OUTPUT_NODE = True
FUNCTION = "combine_video"
CATEGORY = "XB_ToolBox/Video"
def combine_video(self, frame_rate, loop_count, images=None, latents=None,
filename_prefix="视频", format="image/gif", pingpong=False,
save_output=True, prompt=None, extra_pnginfo=None,
audio=None, unique_id=None, manual_format_widgets=None, **kwargs):
if latents is not None:
images = latents
if images is None:
return ((save_output, []),)
if isinstance(images, torch.Tensor) and images.size(0) == 0:
return ((save_output, []),)
num_frames = len(images)
pbar = ProgressBar(num_frames)
first_image = images[0]
while len(first_image.shape) > 3:
first_image = first_image[0]
images = iter(images)
output_dir = folder_paths.get_output_directory() if save_output else folder_paths.get_temp_directory()
# 展开 %date:yyyy-MM-dd% 之类的标记,否则会生成非法目录名(Windows: WinError 267)
filename_prefix = resolve_filename_prefix(filename_prefix)
full_output_folder, filename, _, subfolder, _ = folder_paths.get_save_image_path(filename_prefix, output_dir)
output_files = []
metadata = _PngInfo()
video_metadata = {}
if prompt is not None:
metadata.add_text("prompt", _json.dumps(prompt))
video_metadata["prompt"] = prompt
if extra_pnginfo is not None:
for x in extra_pnginfo:
metadata.add_text(x, _json.dumps(extra_pnginfo[x]))
video_metadata[x] = extra_pnginfo[x]
extra_options = extra_pnginfo.get('workflow', {}).get('extra', {})
else:
extra_options = {}
metadata.add_text("CreationTime", _dt.datetime.now().isoformat(" ")[:19])
max_counter = 0
matcher = re.compile(f"{re.escape(filename)}_(\\d+)\\D*\\..+", re.IGNORECASE)
for existing_file in os.listdir(full_output_folder):
match = matcher.fullmatch(existing_file)
if match:
max_counter = max(max_counter, int(match.group(1)))
counter = max_counter + 1
first_image_file = f"{filename}_{counter:05}.png"
file_path = os.path.join(full_output_folder, first_image_file)
if extra_options.get('VHS_MetadataImage', True) != False:
_PILImage.fromarray(_tensor_to_bytes(first_image)).save(file_path, pnginfo=metadata, compress_level=4)
output_files.append(file_path)
format_type, format_ext = format.split("/")
if format_type == "image":
image_kwargs = {}
if format_ext == "gif":
image_kwargs['disposal'] = 2
if format_ext == "webp":
exif = _PILImage.Exif()
exif[_ExifTags.IFD.Exif] = {36867: _dt.datetime.now().isoformat(" ")[:19]}
image_kwargs['exif'] = exif
image_kwargs['lossless'] = kwargs.get("lossless", True)
file = f"{filename}_{counter:05}.{format_ext}"
file_path = os.path.join(full_output_folder, file)
if pingpong:
images = _to_pingpong(images)
def frames_gen(images):
for i in images:
pbar.update(1)
yield _PILImage.fromarray(_tensor_to_bytes(i))
frames = frames_gen(images)
next(frames).save(file_path, format=format_ext.upper(), save_all=True,
append_images=frames, duration=round(1000/frame_rate),
loop=loop_count, compress_level=4, **image_kwargs)
output_files.append(file_path)
else:
if _ffmpeg is None:
raise RuntimeError("ffmpeg 未找到")
if manual_format_widgets is not None:
kwargs.update(manual_format_widgets)
has_alpha = first_image.shape[-1] == 4
kwargs["has_alpha"] = has_alpha
video_format = _apply_format_widgets(format_ext, kwargs)
dim_alignment = video_format.get("dim_alignment", 2)
if (first_image.shape[1] % dim_alignment) or (first_image.shape[0] % dim_alignment):
to_pad = (-first_image.shape[1] % dim_alignment, -first_image.shape[0] % dim_alignment)
padding = (to_pad[0]//2, to_pad[0] - to_pad[0]//2, to_pad[1]//2, to_pad[1] - to_pad[1]//2)
padfunc = torch.nn.ReplicationPad2d(padding)
def pad(image):
image = image.permute((2,0,1))
padded = padfunc(image.to(dtype=torch.float32))
return padded.permute((1,2,0))
images = map(pad, images)
dimensions = (-first_image.shape[1] % dim_alignment + first_image.shape[1],
-first_image.shape[0] % dim_alignment + first_image.shape[0])
else:
dimensions = (first_image.shape[1], first_image.shape[0])
if pingpong:
images = _to_pingpong(images)
if num_frames > 2:
num_frames += num_frames -2
pbar.total = num_frames
if loop_count > 0:
loop_args = ["-vf", "loop=loop=" + str(loop_count)+":size=" + str(num_frames)]
else:
loop_args = []
if video_format.get('input_color_depth', '8bit') == '16bit':
images = map(_tensor_to_shorts, images)
i_pix_fmt = 'rgba64' if has_alpha else 'rgb48'
else:
images = map(_tensor_to_bytes, images)
i_pix_fmt = 'rgba' if has_alpha else 'rgb24'
file = f"{filename}_{counter:05}.{video_format['extension']}"
file_path = os.path.join(full_output_folder, file)
bitrate_arg = []
bitrate = video_format.get('bitrate')
if bitrate is not None:
bitrate_arg = ["-b:v", str(bitrate) + "M" if video_format.get('megabit') == 'True' else str(bitrate) + "K"]
args = [_ffmpeg, "-v", "error", "-f", "rawvideo", "-pix_fmt", i_pix_fmt,
"-color_range", "pc", "-colorspace", "rgb", "-color_primaries", "bt709",
"-color_trc", video_format.get("fake_trc", "iec61966-2-1"),
"-s", f"{dimensions[0]}x{dimensions[1]}", "-r", str(frame_rate), "-i", "-"] + loop_args
images = map(lambda x: x.tobytes(), images)
env = os.environ.copy()
if "environment" in video_format:
env.update(video_format["environment"])
if "pre_pass" in video_format:
images = [b''.join(images)]
os.makedirs(folder_paths.get_temp_directory(), exist_ok=True)
in_args_len = args.index("-i") + 2
pre_pass_args = args[:in_args_len] + video_format['pre_pass']
_merge_filter_args(pre_pass_args)
try:
subprocess.run(pre_pass_args, input=images[0], env=env, capture_output=True, check=True)
except subprocess.CalledProcessError as e:
raise Exception("An error occurred in the ffmpeg prepass:\n" + e.stderr.decode(*ENCODE_ARGS))
if "inputs_main_pass" in video_format:
in_args_len = args.index("-i") + 2
args = args[:in_args_len] + video_format['inputs_main_pass'] + args[in_args_len:]
if 'gifski_pass' in video_format:
format = 'image/gif'
output_process = _gifski_process(args, dimensions, frame_rate, video_format, file_path, env)
audio = None
else:
args += video_format['main_pass'] + bitrate_arg
_merge_filter_args(args)
output_process = _ffmpeg_process(args, video_format, video_metadata, file_path, env)
output_process.send(None)
for image in images:
pbar.update(1)
output_process.send(image)
try:
total_frames_output = output_process.send(None)
output_process.send(None)
except StopIteration:
pass
output_files.append(file_path)
a_waveform = None
if audio is not None:
try:
a_waveform = audio['waveform']
except:
pass
if a_waveform is not None:
output_file_with_audio = f"{filename}_{counter:05}-audio.{video_format['extension']}"
output_file_with_audio_path = os.path.join(full_output_folder, output_file_with_audio)
if "audio_pass" not in video_format:
video_format["audio_pass"] = ["-c:a", "libopus"]
channels = audio['waveform'].size(1)
min_audio_dur = total_frames_output / frame_rate + 1
if video_format.get('trim_to_audio', 'False') != 'False':
apad = []
else:
apad = ["-af", "apad=whole_dur="+str(min_audio_dur)]
mux_args = [_ffmpeg, "-v", "error", "-n", "-i", file_path,
"-ar", str(audio['sample_rate']), "-ac", str(channels),
"-f", "f32le", "-i", "-", "-c:v", "copy"] \
+ video_format["audio_pass"] + apad + ["-shortest", output_file_with_audio_path]
audio_data = audio['waveform'].squeeze(0).transpose(0,1).numpy().tobytes()
_merge_filter_args(mux_args, '-af')
try:
res = subprocess.run(mux_args, input=audio_data, env=env, capture_output=True, check=True)
except subprocess.CalledProcessError as e:
raise Exception("An error occured in the ffmpeg subprocess:\n" + e.stderr.decode(*ENCODE_ARGS))
output_files.append(output_file_with_audio_path)
file = output_file_with_audio
if extra_options.get('VHS_KeepIntermediate', True) == False:
for intermediate in output_files[1:-1]:
if os.path.exists(intermediate):
os.remove(intermediate)
preview = {
"filename": file,
"subfolder": subfolder,
"type": "output" if save_output else "temp",
"format": format,
"frame_rate": frame_rate,
"workflow": first_image_file,
"fullpath": output_files[-1],
}
if num_frames == 1 and 'png' in format and '%03d' in file:
preview['format'] = 'image/png'
preview['filename'] = file.replace('%03d', '001')
return {"ui": {"gifs": [preview]}, "result": ((save_output, output_files),)}