新增「加载透明视频」节点:解决带 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:
co-authored by
Claude Opus 5
parent
becde22105
commit
61129bde89
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)",
|
||||
}
|
||||
Reference in New Issue
Block a user