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

161 lines
6.8 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.
"""
XB-BOX 保存视频
===============
套皮官方「Create Video」+「Save Video」二合一:
图片(+可选音频)→ 组帧 → 编码 → 保存到 output 目录,同时输出 VIDEO。
设置项顺序与需求一致:文件名前缀 → 帧率 → 位深 → 色彩空间 → 格式 → 编解码器。
其中「格式 / 编解码器」直接复用官方 Save Video 的输入定义(DynamicCombo),
因此编解码器下的「编码模式(auto / re-encode)+ CRF」等选项全部保留。
"""
import copy
import os
from fractions import Fraction
import folder_paths
from comfy.cli_args import args
from comfy_api.latest import io, ui, InputImpl, Types
from comfy_extras.nodes_video import CreateVideo, SaveVideo
from .xb_paths import resolve_filename_prefix
def _input_map(schema):
return {getattr(inp, "id", None): inp for inp in schema.inputs}
_CREATE_INPUTS = _input_map(CreateVideo.define_schema())
_SAVE_INPUTS = _input_map(SaveVideo.define_schema())
def _localize_dynamic(inp, names):
"""递归给 DynamicCombo 里的嵌套输入补中文显示名(官方只给了英文 id)。"""
name = names.get(getattr(inp, "id", None))
if name:
inp.display_name = name
for option in getattr(inp, "options", []) or []:
for child in getattr(option, "inputs", []) or []:
_localize_dynamic(child, names)
# 官方 Save Video 的「格式」是动态组合(格式 → 编解码器 → 编码模式 → CRF)。
# 深拷贝一份再汉化,避免改动官方节点本身的界面。
_FORMAT_INPUT = copy.deepcopy(_SAVE_INPUTS["format"])
_localize_dynamic(_FORMAT_INPUT, {"codec": "编解码器", "encoding": "编码模式", "crf": "CRF"})
def _resolve_save_settings(fmt):
"""把 DynamicCombo 的 format 值拆成 (容器, 编解码器, CRF)。"""
if isinstance(fmt, dict):
format_name = fmt.get("format", "auto")
codec = fmt.get("codec") or {}
else:
format_name, codec = (fmt or "auto"), {}
if not isinstance(codec, dict):
codec = {"codec": str(codec)}
codec_name = codec.get("codec") or "auto"
if format_name == "auto":
format_name = "webm" if codec_name == "av1" else "mp4"
encoding = codec.get("encoding") or {}
return format_name, codec_name, encoding.get("crf")
class XB_SaveVideo(io.ComfyNode):
"""保存视频 — 官方 Create Video + Save Video 的组合套皮。"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="XB_SaveVideo",
search_aliases=["save video", "保存视频", "导出视频", "create video"],
display_name="XB-BOX - 🎬 保存视频",
category="XB_ToolBox/Video",
description=(
"官方 Create Video + Save Video 二合一:图片(+可选音频)→ 组帧 → 编码 → 保存到 output 目录,"
"同时输出 VIDEO。设置项顺序:文件名前缀 → 帧率 → 位深 → 色彩空间 → 格式 → 编解码器 → 自动播放"
"(编解码器沿用官方动态选项,含编码模式 / CRF;自动播放控制预览是否自动开始,"
"鼠标悬停在画面上才播放声音)。"
),
inputs=[
_CREATE_INPUTS["images"],
_SAVE_INPUTS["filename_prefix"],
_CREATE_INPUTS["fps"],
io.Combo.Input(
"bit_depth",
options=["auto", 8, 10],
default="auto",
tooltip="Auto uses 8-bit for sRGB and 10-bit for HDR. Explicit 8-bit and 10-bit choices are independent of colorspace.",
),
io.Combo.Input(
"color_space",
options=["sRGB", "HDR", "HDR PQ"],
default="sRGB",
tooltip="Colorspace of the input images. HDR selects BT.2020/HLG and HDR PQ selects BT.2020/PQ.",
),
_FORMAT_INPUT,
io.Boolean.Input(
"autoplay",
default=True,
label_on="开",
label_off="关",
tooltip="开:视频生成后自动播放(默认静音,鼠标悬停在画面上才出声);"
"关:需要手动点击播放。",
),
_CREATE_INPUTS["audio"],
],
hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True,
outputs=[io.Video.Output(display_name="video")],
)
@classmethod
def execute(cls, images, filename_prefix="video/ComfyUI", fps=30.0,
bit_depth="auto", color_space="sRGB", format="auto", autoplay=True, audio=None):
# autoplay 只给前端播放器用(js/xb_save_video.js),后端无需处理
# 与官方 Create Video 一致:auto → sRGB 用 8bit、HDR / HDR PQ 用 10bit
if bit_depth == "auto":
bit_depth = 10 if color_space in ("HDR", "HDR PQ") else 8
video = InputImpl.VideoFromComponents(
Types.VideoComponents(images=images, audio=audio, frame_rate=Fraction(fps)),
bit_depth=bit_depth,
color_space=color_space,
)
format_name, codec_name, crf = _resolve_save_settings(format)
# 与官方 Save Video 一致:写进输出文件元数据
saved_metadata = None
if not args.disable_metadata:
hidden = getattr(cls, "hidden", None)
prompt = getattr(hidden, "prompt", None) if hidden else None
extra_pnginfo = getattr(hidden, "extra_pnginfo", None) if hidden else None
metadata = {}
if extra_pnginfo is not None:
metadata.update(extra_pnginfo)
if prompt is not None:
metadata["prompt"] = prompt
if metadata:
saved_metadata = metadata
width, height = video.get_dimensions()
# 展开 %date:yyyy-MM-dd% 之类的标记,否则会生成非法目录名(Windows: WinError 267)
filename_prefix = resolve_filename_prefix(filename_prefix)
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix,
folder_paths.get_output_directory(),
width,
height,
)
file = f"{filename}_{counter:05}_.{Types.VideoContainer.get_extension(format_name)}"
video.save_to(
os.path.join(full_output_folder, file),
format=Types.VideoContainer(format_name),
codec=Types.VideoCodec(codec_name),
metadata=saved_metadata,
crf=crf,
)
return io.NodeOutput(video, ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)]))