新增「加载透明视频」节点:解决带 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>
This commit is contained in:
rui40000
2026-08-18 18:06:11 +08:00
co-authored by Claude Opus 5
parent becde22105
commit 61129bde89
4 changed files with 659 additions and 0 deletions
+57
View File
@@ -20,6 +20,7 @@ Rui-Node🐶 是一个功能丰富的 ComfyUI 节点集合,提供图像处理
- [像素化 / Pixelate](#24-像素化--pixelate)
- [八方向序列拆分 / 8-Direction Sprite Split](#25-八方向序列拆分--8-direction-sprite-split)
- [半透明抠图 / Unmult Matting](#27-半透明抠图--unmult-matting)
- [加载透明视频 / Load Video (Alpha)](#28-加载透明视频--load-video-alpha)
### 📁 文件存储与加载类
- [按路径加载图像 / Load Image By Path](#3-按路径加载图像--load-image-by-path)
@@ -1256,6 +1257,62 @@ Rui-Node🐶 致力于为 ComfyUI 用户提供实用、高效的节点工具集
## 📄 许可证
---
### 28. 加载透明视频 / Load Video (Alpha)
**分类**: `Rui-Node🐶/视频🎬`
**功能描述**:
从带透明通道的视频中解出 **RGBA 序列帧**,alpha 不丢失。用来解决一个很常见、
但排查起来相当隐蔽的问题:**带 alpha 的 WebM 用常规加载视频节点读进来,透明通道没了。**
**为什么会丢——根因**:
带 alpha 的 WebM(VP8/VP9)并不把透明度放在主视频流里。主流仍然是 `yuv420p`,
alpha 被单独压成第二路,藏在 Matroska 的 **BlockAdditional 边带**中,容器上只留
一条 `alpha_mode=1` 的元数据作记号。
ffmpeg 内置的 `vp9` / `vp8` 解码器**根本不读这条边带**,只有 `libvpx-vp9` / `libvpx`
才会。VideoHelperSuite 等常见加载节点走的是默认解码器,于是拿到的每一帧 alpha 恒为 255。
实测同一个文件的三条路径:
| 解码路径 | alpha 结果 |
|---|---|
| PyAV 默认(解码器 `vp9`) | 全 255,**丢失** |
| ffmpeg 默认 | 全 255,**丢失** |
| 显式 `-c:v libvpx-vp9` | min=0 max=255,全透明 87.2%、半透明 2.0%,**完整** |
本节点显式指定 libvpx 解码器,并以 rgba 原始像素流读回,因此连半透明边缘也一并保留。
对 MOV/qtrle、ProRes 4444 等本身带 alpha 通道的格式同样适用。
**输入参数**:
- `video`: 从 input 目录选择视频文件
- `强制帧率` (FLOAT): 按指定帧率重采样,0=保持原始帧率
- `帧数上限` (INT): 最多读取多少帧,0=读完整段(达到上限立即中止解码)
- `跳过前N帧` (INT): 丢弃开头若干帧
- `间隔` (INT): 每隔几帧取一帧,1=每帧都要
- `自定义宽度` / `自定义高度` (INT): 0=保持原始;只填一边时另一边按比例换算
- `解码器`: `自动`(探测到 alpha_mode=1 的 VP8/VP9 时自动换 libvpx)/ `强制 libvpx(保 alpha)` / `默认解码器`
- `视频路径` (STRING, 可选): 绝对路径,填写后优先于下拉选择
**输出**:
- `rgba_image` (IMAGE): 4 通道 RGBA 序列帧
- `alpha` (MASK): 透明通道
- `rgb_image` (IMAGE): 3 通道,供只吃 3 通道的下游节点使用
- `帧数` (INT) / `帧率` (FLOAT)
**存成带透明通道的 PNG 序列帧**:
`rgba_image` 直接接 ComfyUI **原生「保存图像」节点**即可。原生节点的像素处理是
`Image.fromarray(...)`,4 通道数组会被识别成 RGBA 模式,存出的 PNG 完整保留 alpha——
不需要额外的保存节点。实测 33 帧全部为 RGBA 模式,与内存中的 alpha 逐像素零误差。
**关于预览发黑**:
ComfyUI 的预览区不渲染透明,看到黑底是正常现象,不代表 alpha 丢了。
判断是否成功以 `alpha` 输出接遮罩预览为准,或直接看存出的 PNG 文件。
**示例工作流**: `example_workflow/透明视频转PNG序列帧.json`
本项目遵循开源协议,欢迎使用和贡献。
---
+10
View File
@@ -104,6 +104,14 @@ except Exception as _e:
# 新增:半透明抠图节点(Unmult 纯数学去底,适合光效/火焰/粒子/UI 特效)
from .unmult_node import NODE_CLASS_MAPPINGS as UNMULT_NODE_CLASS_MAPPINGS
from .unmult_node import NODE_DISPLAY_NAME_MAPPINGS as UNMULT_NODE_DISPLAY_NAME_MAPPINGS
# 新增:加载透明视频节点(显式 libvpx 解码,取出 WebM 边带里的 alpha)
try:
from .load_video_alpha_node import NODE_CLASS_MAPPINGS as LOADVIDA_NODE_CLASS_MAPPINGS
from .load_video_alpha_node import NODE_DISPLAY_NAME_MAPPINGS as LOADVIDA_NODE_DISPLAY_NAME_MAPPINGS
except Exception as _e:
print(f"[Ruinode] 加载透明视频 节点未加载:{_e}")
LOADVIDA_NODE_CLASS_MAPPINGS = {}
LOADVIDA_NODE_DISPLAY_NAME_MAPPINGS = {}
# 新增:满屏文字水印节点(平铺文字 + 旋转/密度/透明度/颜色可调)
try:
from .watermark_node import NODE_CLASS_MAPPINGS as WATERMARK_NODE_CLASS_MAPPINGS
@@ -142,6 +150,7 @@ NODE_CLASS_MAPPINGS.update(LUCIDA_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(PIXELATE_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(EIGHTDIR_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(UNMULT_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(LOADVIDA_NODE_CLASS_MAPPINGS)
# 合并节点显示名称映射
NODE_DISPLAY_NAME_MAPPINGS = {}
@@ -172,5 +181,6 @@ NODE_DISPLAY_NAME_MAPPINGS.update(LUCIDA_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(PIXELATE_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(EIGHTDIR_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(UNMULT_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(LOADVIDA_NODE_DISPLAY_NAME_MAPPINGS)
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
@@ -0,0 +1,222 @@
{
"id": "rui-load-video-alpha-demo",
"revision": 0,
"last_node_id": 9,
"last_link_id": 3,
"nodes": [
{
"id": 1,
"type": "RuiLoadVideoAlpha",
"pos": [
-1000,
200
],
"size": [
400,
300
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "rgba_image",
"type": "IMAGE",
"links": [
1,
2
],
"slot_index": 0
},
{
"name": "alpha",
"type": "MASK",
"links": [
3
],
"slot_index": 1
},
{
"name": "rgb_image",
"type": "IMAGE",
"links": null,
"slot_index": 2
},
{
"name": "帧数",
"type": "INT",
"links": null,
"slot_index": 3
},
{
"name": "帧率",
"type": "FLOAT",
"links": null,
"slot_index": 4
}
],
"properties": {
"Node name for S&R": "RuiLoadVideoAlpha"
},
"title": "① 加载透明视频(保留 alpha)",
"widgets_values": [
"HolopixAI_00001.webm",
0.0,
0,
0,
1,
0,
0,
"自动",
""
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 2,
"type": "SaveImage",
"pos": [
-540,
200
],
"size": [
420,
400
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveImage"
},
"title": "② 保存 PNG 序列帧(带透明通道)",
"widgets_values": [
"透明序列帧/frame"
],
"color": "#223",
"bgcolor": "#335"
},
{
"id": 3,
"type": "PreviewImage",
"pos": [
-540,
640
],
"size": [
420,
400
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"title": "③ 预览 RGBA(预览区黑底是正常现象)",
"color": "#223",
"bgcolor": "#335"
},
{
"id": 4,
"type": "RuiMaskPreview",
"pos": [
-540,
1080
],
"size": [
420,
400
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "mask",
"type": "MASK",
"link": 3
}
],
"outputs": [],
"properties": {
"Node name for S&R": "RuiMaskPreview"
},
"title": "④ 透明通道预览(白=不透明 黑=透明)",
"color": "#323",
"bgcolor": "#535"
},
{
"id": 9,
"type": "Note",
"pos": [
-1000,
560
],
"size": [
400,
340
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"title": "说明",
"widgets_values": [
"为什么普通加载视频节点会丢透明通道\n─────────────────────────────\n带 alpha 的 WebM 并不把透明度放在主视频流里。\n主流仍是 yuv420p,alpha 被单独压成第二路,藏在\nMatroska 的 BlockAdditional 边带中,容器上只留\n一条 alpha_mode=1 的元数据作记号。\n\nffmpeg 内置的 vp9/vp8 解码器不读这条边带,只有\nlibvpx-vp9 / libvpx 会。VideoHelperSuite 等节点\n走的是默认解码器,所以每帧 alpha 恒为 255。\n\n本节点会自动探测 alpha_mode 并换用 libvpx 解码。\n若仍然丢失,把「解码器」切到「强制 libvpx」。\n\n关于预览\n─────────────────────────────\nComfyUI 预览区不渲染透明,看到黑底属正常现象。\n以④的遮罩预览为准,或直接看存出的 PNG 文件。\n\n输出的 PNG 位于 output/透明序列帧/ 下,\n为 RGBA 模式,可直接导入 Unity / Godot / AE。"
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
1,
1,
0,
2,
0,
"IMAGE"
],
[
2,
1,
0,
3,
0,
"IMAGE"
],
[
3,
1,
1,
4,
0,
"MASK"
]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}
+370
View File
@@ -0,0 +1,370 @@
# -*- 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)",
}