Files
rui40000-RUI-Nodes/load_video_alpha_node.py
T
rui40000andClaude Opus 5 61129bde89 新增「加载透明视频」节点:解决带 alpha 的 WebM 读入后透明通道丢失
带 alpha 的 WebM(VP8/VP9)不把透明度放在主视频流里。主流仍是 yuv420p,
alpha 被单独压成第二路藏在 Matroska 的 BlockAdditional 边带中,容器上只
留一条 alpha_mode=1 的元数据。ffmpeg 内置的 vp9/vp8 解码器不读这条边带,
只有 libvpx-vp9 / libvpx 才会——VideoHelperSuite 等常见加载节点走默认
解码器,因此每帧 alpha 恒为 255。

本节点显式指定 libvpx 系解码器并以 rgba 原始流读回,完整保留透明度
(含半透明边缘)。同一文件实测:默认解码器 alpha 全 255;libvpx-vp9
解出 alpha min=0 max=255,全透明 87.2%、半透明 2.0%。

- 自动探测 alpha_mode / pix_fmt,仅在需要时才换 libvpx(默认解码器更快)
- 解码器三档可手动覆盖,自动档失灵时可强制
- 支持强制帧率、帧数上限、跳过前N帧、间隔抽帧、等比缩放
- 输出 4 通道 rgba_image,直接接原生「保存图像」即存成 RGBA PNG 序列帧
  (原生节点不做 convert("RGB"),实测落盘与内存 alpha 逐像素零误差)
- 另输出 alpha 遮罩与 3 通道 rgb_image,兼容只吃 3 通道的下游节点
- 流式读管道 + 预分配张量,达到帧数上限立即中止解码
- 全部参数带中文悬浮说明,附示例工作流

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:06:11 +08:00

371 lines
15 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.
# -*- coding: utf-8 -*-
"""
加载透明视频节点(Alpha)—— Ruinode
====================================
从带透明通道的视频中解出 RGBA 序列帧,alpha 不丢失。
为什么需要这个节点:
带 alpha 的 WebM(VP8/VP9)并不把透明度存在主视频流里——主流仍是
yuv420p,alpha 被单独压成第二路,藏在 Matroska 的 BlockAdditional
边带中,容器上只留一条 alpha_mode=1 的元数据作记号。
ffmpeg 内置的 vp9 / vp8 解码器不读这条边带,只有 libvpx 系列
(libvpx-vp9 / libvpx)才会。VideoHelperSuite 等常见加载节点走的是
默认解码器,因此拿到的每一帧 alpha 恒为 255,看起来「透明通道丢了」。
本节点显式指定 libvpx 解码器,并以 rgba 原始像素流读回,从而完整
保留透明度(含半透明边缘)。对 MOV/qtrle、ProRes 4444 等本身就带
alpha 通道的格式同样适用。
输出的 rgba_image 是 4 通道 IMAGE,可直接接 ComfyUI 原生「保存图像」
节点存成带透明通道的 PNG 序列帧。
"""
import os
import re
import shutil
import subprocess
import numpy as np
import torch
try:
import folder_paths
except Exception: # 脱离 ComfyUI 单测时
folder_paths = None
VIDEO_EXTS = (".webm", ".mkv", ".mov", ".mp4", ".avi", ".gif", ".apng", ".m4v")
# 这些编码把 alpha 存在边带里,必须换 libvpx 系解码器才读得到
LIBVPX_DECODER = {"vp9": "libvpx-vp9", "vp8": "libvpx"}
# pix_fmt 前缀带 alpha 的情形(ProRes 4444、qtrle、rgba 原始流等)
ALPHA_PIX_PREFIX = ("yuva", "rgba", "bgra", "argb", "abgr", "ya8", "ya16",
"gbrap", "rgba64", "bgra64")
def _find_ffmpeg():
"""按可靠性依次寻找可用的 ffmpeg。"""
try:
import imageio_ffmpeg
exe = imageio_ffmpeg.get_ffmpeg_exe()
if exe and os.path.isfile(exe):
return exe
except Exception:
pass
exe = shutil.which("ffmpeg")
if exe:
return exe
# VideoHelperSuite 装过的话通常也带一份
try:
from videohelpersuite import utils as vhs_utils
exe = getattr(vhs_utils, "ffmpeg_path", None)
if exe and os.path.isfile(exe):
return exe
except Exception:
pass
raise RuntimeError(
"找不到 ffmpeg。请安装 imageio-ffmpeg(pip install imageio-ffmpeg),"
"或把 ffmpeg 加入系统 PATH。")
def _probe(path):
"""探测视频的编码、尺寸、帧率与是否带 alpha。
优先用 PyAV(元数据最准),不可用时回退到解析 ffmpeg -i 的输出。
"""
info = {"codec": "", "width": 0, "height": 0, "fps": 0.0,
"has_alpha": False, "pix_fmt": ""}
try:
import av
with av.open(path) as c:
st = c.streams.video[0]
cc = st.codec_context
info["codec"] = (cc.name or "").lower()
info["width"] = int(cc.width or 0)
info["height"] = int(cc.height or 0)
info["pix_fmt"] = (cc.pix_fmt or "").lower()
try:
info["fps"] = float(st.average_rate) if st.average_rate else 0.0
except Exception:
info["fps"] = 0.0
meta = {}
meta.update(st.metadata or {})
meta.update(c.metadata or {})
alpha_mode = str(meta.get("alpha_mode", "")).strip()
info["has_alpha"] = (alpha_mode == "1") or any(
info["pix_fmt"].startswith(p) for p in ALPHA_PIX_PREFIX)
if info["width"] and info["height"]:
return info
except Exception:
pass
# 回退:ffmpeg -i 把流信息打在 stderr 上
try:
p = subprocess.run([_find_ffmpeg(), "-i", path],
capture_output=True, text=True, errors="ignore")
err = p.stderr
m = re.search(r"Stream #\d+:\d+.*?: Video: (\w+).*?, (\w+).*?, (\d+)x(\d+)",
err, re.S)
if m:
info["codec"] = m.group(1).lower()
info["pix_fmt"] = m.group(2).lower()
info["width"], info["height"] = int(m.group(3)), int(m.group(4))
m = re.search(r"(\d+(?:\.\d+)?) fps", err)
if m:
info["fps"] = float(m.group(1))
info["has_alpha"] = (
re.search(r"alpha_mode\s*:\s*1", err) is not None
or any(info["pix_fmt"].startswith(p) for p in ALPHA_PIX_PREFIX))
except Exception:
pass
return info
def _read_exact(pipe, n):
"""管道一次 read 未必给满一帧,循环补齐。"""
buf = bytearray()
while len(buf) < n:
chunk = pipe.read(n - len(buf))
if not chunk:
break
buf.extend(chunk)
return bytes(buf)
def _list_videos():
if folder_paths is None:
return []
try:
d = folder_paths.get_input_directory()
return sorted(f for f in os.listdir(d)
if f.lower().endswith(VIDEO_EXTS))
except Exception:
return []
class RuiLoadVideoAlpha:
"""加载带透明通道的视频,输出 RGBA 序列帧。"""
@classmethod
def INPUT_TYPES(cls):
files = _list_videos()
return {
"required": {
"video": (files if files else ["(input 目录没有视频)"], {
"video_upload": True,
"tooltip": "从 ComfyUI 的 input 目录里选择视频文件。\n"
"若视频不在该目录,可改用下方「视频路径」直接填绝对路径。"
}),
"强制帧率": ("FLOAT", {
"default": 0.0, "min": 0.0, "max": 240.0, "step": 0.01,
"tooltip": "按指定帧率重采样。填 0 表示保持视频原始帧率。\n"
"例如原视频 8fps、这里填 24,会补成 24fps(重复帧);\n"
"填 4 则抽帧减半。做游戏序列帧时通常保持 0 更安全。"
}),
"帧数上限": ("INT", {
"default": 0, "min": 0, "max": 100000, "step": 1,
"tooltip": "最多读取多少帧,填 0 表示读完整段。\n"
"达到上限会立即中止解码,用来快速试跑长视频。"
}),
"跳过前N帧": ("INT", {
"default": 0, "min": 0, "max": 100000, "step": 1,
"tooltip": "丢弃视频开头的若干帧。\n"
"用于跳过片头黑场或起手多余的静止帧。"
}),
"间隔": ("INT", {
"default": 1, "min": 1, "max": 100, "step": 1,
"tooltip": "每隔几帧取一帧。1=每帧都要,2=隔帧抽取(帧数减半)。\n"
"在「跳过前N帧」之后生效。"
}),
"自定义宽度": ("INT", {
"default": 0, "min": 0, "max": 8192, "step": 8,
"tooltip": "输出宽度,填 0 保持原始尺寸。\n"
"只填宽或只填高时,另一边按原比例自动换算。"
}),
"自定义高度": ("INT", {
"default": 0, "min": 0, "max": 8192, "step": 8,
"tooltip": "输出高度,填 0 保持原始尺寸。\n"
"像素素材缩放会让边缘被插值糊掉,做像素游戏时建议保持 0。"
}),
"解码器": (["自动", "强制 libvpx(保 alpha)", "默认解码器"], {
"default": "自动",
"tooltip": "自动:探测到 alpha_mode=1 的 VP8/VP9 时自动换用 libvpx,\n"
" 否则用默认解码器(更快)。一般保持「自动」即可。\n\n"
"强制 libvpx:无论探测结果如何都用 libvpx 解码。\n"
" 当自动模式仍然丢 alpha 时改用这一档。\n\n"
"默认解码器:用 ffmpeg 内置解码器,速度快但\n"
" 读不到 WebM 边带里的 alpha(这正是常见加载\n"
" 节点丢透明通道的原因)。"
}),
},
"optional": {
"视频路径": ("STRING", {
"default": "",
"tooltip": "视频文件的绝对路径。填写后优先于上方的下拉选择。\n"
"留空则使用下拉框选中的文件。"
}),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "INT", "FLOAT")
RETURN_NAMES = ("rgba_image", "alpha", "rgb_image", "帧数", "帧率")
OUTPUT_TOOLTIPS = (
"4 通道 RGBA 序列帧。直接接 ComfyUI 原生「保存图像」即可存成\n"
"带透明通道的 PNG 序列帧(原生保存节点不会丢 alpha)。",
"透明通道单独输出为遮罩,可接遮罩预览或参与后续合成。",
"3 通道 RGB 序列帧,供只接受 3 通道输入的下游节点使用。",
"实际读取到的帧数。",
"实际输出的帧率(未设强制帧率时即视频原始帧率)。",
)
FUNCTION = "load"
CATEGORY = "Rui-Node🐶/视频🎬"
@classmethod
def IS_CHANGED(cls, **kwargs):
path = (kwargs.get("视频路径") or "").strip()
if not path and folder_paths is not None:
try:
path = folder_paths.get_annotated_filepath(kwargs.get("video", ""))
except Exception:
path = ""
if path and os.path.isfile(path):
return os.path.getmtime(path)
return float("nan")
def load(self, **kwargs):
video = kwargs.get("video", "")
custom_path = (kwargs.get("视频路径") or "").strip().strip('"')
force_rate = float(kwargs.get("强制帧率", 0.0))
cap = int(kwargs.get("帧数上限", 0))
skip = int(kwargs.get("跳过前N帧", 0))
every = max(1, int(kwargs.get("间隔", 1)))
cw = int(kwargs.get("自定义宽度", 0))
ch = int(kwargs.get("自定义高度", 0))
dec_mode = kwargs.get("解码器", "自动")
# ── 定位文件 ──
if custom_path:
path = custom_path
else:
if folder_paths is None:
raise RuntimeError("未在 ComfyUI 环境中运行,请填写「视频路径」。")
path = folder_paths.get_annotated_filepath(video)
if not path or not os.path.isfile(path):
raise RuntimeError("视频文件不存在:%s" % path)
info = _probe(path)
src_w, src_h = info["width"], info["height"]
if not src_w or not src_h:
raise RuntimeError("无法解析视频尺寸:%s" % path)
# ── 选择解码器 ──
# 这是本节点的核心:WebM 的 alpha 藏在 BlockAdditional 边带里,
# 只有 libvpx 系解码器会去读它,内置 vp9/vp8 解码器直接无视。
decoder = None
if dec_mode == "强制 libvpx(保 alpha)":
decoder = LIBVPX_DECODER.get(info["codec"])
elif dec_mode == "自动":
if info["has_alpha"]:
decoder = LIBVPX_DECODER.get(info["codec"])
# 「默认解码器」保持 decoder = None
# ── 计算输出尺寸 ──
if cw > 0 and ch > 0:
out_w, out_h = cw, ch
elif cw > 0:
out_w = cw
out_h = max(1, int(round(src_h * cw / src_w)))
elif ch > 0:
out_h = ch
out_w = max(1, int(round(src_w * ch / src_h)))
else:
out_w, out_h = src_w, src_h
# ── 组装解码命令 ──
ff = _find_ffmpeg()
cmd = [ff, "-loglevel", "error", "-nostdin"]
if decoder:
cmd += ["-c:v", decoder] # 必须在 -i 之前才生效
cmd += ["-i", path]
if force_rate > 0:
cmd += ["-r", str(force_rate)]
if (out_w, out_h) != (src_w, src_h):
cmd += ["-vf", "scale=%d:%d:flags=lanczos" % (out_w, out_h)]
cmd += ["-f", "rawvideo", "-pix_fmt", "rgba", "-"]
frame_bytes = out_w * out_h * 4
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
raw_frames = []
idx = kept = 0
err = ""
try:
while True:
buf = _read_exact(proc.stdout, frame_bytes)
if len(buf) < frame_bytes:
break
if idx >= skip and (idx - skip) % every == 0:
raw_frames.append(
np.frombuffer(buf, np.uint8).reshape(out_h, out_w, 4))
kept += 1
if cap > 0 and kept >= cap:
break
idx += 1
finally:
try:
proc.stdout.close()
except Exception:
pass
if proc.poll() is None:
proc.terminate()
try:
err = proc.stderr.read().decode("utf-8", "ignore")
proc.stderr.close()
except Exception:
pass
proc.wait()
if not raw_frames:
raise RuntimeError(
"未能从视频解出任何帧。\n文件:%s\n编码:%s 解码器:%s\n"
"ffmpeg 输出:%s"
% (path, info["codec"], decoder or "默认",
err.strip()[:400] or "(无)"))
# ── 转张量:预分配后逐帧写入并及时释放,避免同时堆两份大数组 ──
n = len(raw_frames)
rgba = torch.empty((n, out_h, out_w, 4), dtype=torch.float32)
for i in range(n):
rgba[i] = torch.from_numpy(raw_frames[i].copy()).float().div_(255.0)
raw_frames[i] = None
alpha = rgba[..., 3].contiguous()
rgb = rgba[..., :3].contiguous()
fps = force_rate if force_rate > 0 else (info["fps"] or 0.0)
if every > 1 and fps > 0:
fps = fps / every
a_min, a_max = float(alpha.min()), float(alpha.max())
if a_min >= 1.0:
print("[Ruinode 加载透明视频] 注意:解出的 alpha 全为不透明。"
"编码=%s 解码器=%s 探测到alpha=%s。"
"若该视频确实带透明通道,请把「解码器」切到"
"「强制 libvpx(保 alpha)」再试。"
% (info["codec"], decoder or "默认", info["has_alpha"]))
else:
print("[Ruinode 加载透明视频] %d 帧 %dx%d 解码器=%s "
"alpha范围=[%.3f,%.3f] 全透明占比=%.3f"
% (n, out_w, out_h, decoder or "默认", a_min, a_max,
float((alpha < 0.004).float().mean())))
return (rgba, alpha, rgb, n, float(fps))
NODE_CLASS_MAPPINGS = {
"RuiLoadVideoAlpha": RuiLoadVideoAlpha,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RuiLoadVideoAlpha": "加载透明视频 / Load Video (Alpha)",
}