The_ltX-files / nodes /create_save_video.py
marduk191's picture
Rename create_save_video.py to nodes/create_save_video.py
d8d38f0 verified
"""
CreateSaveVideo β€” Single-File Standalone ComfyUI Custom Node
============================================================
Drop this file into: ComfyUI/custom_nodes/create_save_video.py
"""
import os
import wave
import time
import tempfile
import subprocess
import shutil
from concurrent.futures import ThreadPoolExecutor
import numpy as np
import torch
import folder_paths
# ---------------------------------------------------------------------------
# FFmpeg binary resolution
# ---------------------------------------------------------------------------
def _find_ffmpeg() -> str:
try:
import imageio_ffmpeg
exe = imageio_ffmpeg.get_ffmpeg_exe()
if exe and os.path.isfile(exe):
print(f"[CreateSaveVideo] FFmpeg via imageio-ffmpeg: {exe}")
return exe
except ImportError:
pass
exe = shutil.which("ffmpeg")
if exe:
print(f"[CreateSaveVideo] FFmpeg on PATH: {exe}")
return exe
from pathlib import Path
candidates = [
r"C:\ffmpeg\bin\ffmpeg.exe",
r"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
r"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe",
os.path.expanduser(r"~\ffmpeg\bin\ffmpeg.exe"),
]
try:
comfy_root = Path(folder_paths.base_path)
for rel in ["ffmpeg/bin/ffmpeg.exe", "ffmpeg/ffmpeg.exe",
"../ffmpeg/bin/ffmpeg.exe"]:
candidates.append(str(comfy_root / rel))
except Exception:
pass
for path in candidates:
if os.path.isfile(path):
print(f"[CreateSaveVideo] FFmpeg found at: {path}")
return path
raise FileNotFoundError(
"[CreateSaveVideo] FFmpeg not found!\n"
"Fix: pip install imageio-ffmpeg"
)
FFMPEG_EXE = None
# ---------------------------------------------------------------------------
# Codec / container compatibility matrix
# ---------------------------------------------------------------------------
CODEC_MAP = {
("mp4", "h264"): ("libx264", ["-movflags", "+faststart"]),
("mp4", "h265"): ("libx265", ["-movflags", "+faststart", "-tag:v", "hvc1"]),
("mp4", "av1"): ("libaom-av1", ["-movflags", "+faststart", "-cpu-used", "4"]),
("mp4", "prores"): ("prores_ks", ["-profile:v", "3"]),
("mov", "h264"): ("libx264", ["-movflags", "+faststart"]),
("mov", "h265"): ("libx265", ["-movflags", "+faststart", "-tag:v", "hvc1"]),
("mov", "prores"): ("prores_ks", ["-profile:v", "3"]),
("webm", "vp9"): ("libvpx-vp9", ["-b:v", "0", "-deadline", "realtime"]),
("webm", "av1"): ("libaom-av1", ["-b:v", "0", "-cpu-used", "4"]),
("avi", "h264"): ("libx264", []),
("mkv", "h264"): ("libx264", []),
("mkv", "h265"): ("libx265", []),
("mkv", "vp9"): ("libvpx-vp9", ["-b:v", "0", "-deadline", "realtime"]),
("mkv", "av1"): ("libaom-av1", ["-b:v", "0", "-cpu-used", "4"]),
}
LOSSLESS_CODECS = {"prores"}
# ---------------------------------------------------------------------------
# Audio helper β€” stdlib only, no torchaudio / torchcodec
# ---------------------------------------------------------------------------
def _save_wav_stdlib(path: str, waveform: torch.Tensor, sample_rate: int):
"""
Write a WAV file using Python's built-in `wave` module.
No torchaudio, no torchcodec, no external dependencies.
waveform: (C, T) or (1, C, T) float32, range [-1.0, 1.0]
"""
# normalise batch dim
if waveform.dim() == 3:
waveform = waveform.squeeze(0) # (1,C,T) β†’ (C,T)
audio_np = waveform.cpu().numpy() # (C, T)
n_channels, n_samples = audio_np.shape
# float32 [-1,1] β†’ int16
pcm = (audio_np * 32767.0).clip(-32768, 32767).astype(np.int16)
# interleave channels: (C, T) β†’ (T, C) β†’ flatten
interleaved = pcm.T.flatten()
with wave.open(path, "wb") as wf:
wf.setnchannels(n_channels)
wf.setsampwidth(2) # 16-bit PCM
wf.setframerate(sample_rate)
wf.writeframes(interleaved.tobytes())
# ---------------------------------------------------------------------------
# Frame conversion helper
# ---------------------------------------------------------------------------
def _tensor_to_bytes(frame: torch.Tensor) -> bytes:
return (frame.numpy() * 255.0).clip(0, 255).astype(np.uint8).tobytes()
# ---------------------------------------------------------------------------
# Node
# ---------------------------------------------------------------------------
class CreateSaveVideo:
CATEGORY = "video/optimized"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("filepath",)
OUTPUT_NODE = True
FUNCTION = "encode_and_save"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"fps": (
"FLOAT",
{"default": 24.0, "min": 1.0, "max": 120.0,
"step": 0.5, "display": "number"},
),
"filename_prefix": ("STRING", {"default": "video/output"}),
"format": (["mp4", "mov", "mkv", "webm", "avi"], {"default": "mp4"}),
"codec": (["h264", "h265", "vp9", "av1", "prores"], {"default": "h264"}),
"crf": (
"INT",
{"default": 18, "min": 0, "max": 63, "step": 1,
"display": "slider",
"tooltip": "Quality β€” lower = better. Ignored for prores."},
),
"preset": (
["ultrafast", "superfast", "veryfast", "faster",
"fast", "medium", "slow", "veryslow"],
{"default": "veryfast"},
),
"worker_threads": (
"INT",
{"default": 4, "min": 1, "max": 32, "step": 1,
"display": "number"},
),
},
"optional": {
"audio": ("AUDIO",),
"ffmpeg_path": (
"STRING",
{"default": "",
"tooltip": "Absolute path to ffmpeg.exe β€” leave blank for auto-detect."},
),
"ffmpeg_extra_args": (
"STRING",
{"default": "",
"tooltip": "Raw extra FFmpeg flags, space-separated (advanced)."},
),
},
}
# ------------------------------------------------------------------
def encode_and_save(
self,
images: torch.Tensor,
fps: float,
filename_prefix: str,
format: str,
codec: str,
crf: int,
preset: str,
worker_threads: int,
audio=None,
ffmpeg_path: str = "",
ffmpeg_extra_args: str = "",
):
global FFMPEG_EXE
t_start = time.perf_counter()
# ── resolve ffmpeg binary ────────────────────────────────────────────
if ffmpeg_path.strip() and os.path.isfile(ffmpeg_path.strip()):
ffmpeg_exe = ffmpeg_path.strip()
else:
if FFMPEG_EXE is None:
FFMPEG_EXE = _find_ffmpeg()
ffmpeg_exe = FFMPEG_EXE
# ── resolve output path ──────────────────────────────────────────────
output_dir, prefix, _, _, _ = folder_paths.get_save_image_path(
filename_prefix, folder_paths.get_output_directory()
)
os.makedirs(output_dir, exist_ok=True)
existing = [f for f in os.listdir(output_dir) if f.startswith(prefix)]
counter = len(existing) + 1
filename = f"{prefix}_{counter:05d}.{format}"
out_path = os.path.join(output_dir, filename)
# ── frame geometry ───────────────────────────────────────────────────
n_frames, H, W, C = images.shape
pix_fmt_in = "rgb24" if C == 3 else "rgba"
pix_fmt_out = "yuv422p10le" if codec == "prores" else "yuv420p"
# ── codec lookup ─────────────────────────────────────────────────────
vcodec, extra_flags = CODEC_MAP.get(
(format, codec), ("libx264", ["-movflags", "+faststart"])
)
# ── write audio to temp WAV using stdlib only ────────────────────────
tmp_audio_path = None
has_audio = False
if audio is not None:
try:
waveform = audio["waveform"]
sample_rate = audio["sample_rate"]
tmp_fd, tmp_audio_path = tempfile.mkstemp(suffix=".wav")
os.close(tmp_fd)
_save_wav_stdlib(tmp_audio_path, waveform, sample_rate)
has_audio = True
print(f"[CreateSaveVideo] Audio written (stdlib WAV): "
f"{waveform.shape} @ {sample_rate}Hz")
except Exception as exc:
print(f"[CreateSaveVideo] Audio error β€” {exc}")
tmp_audio_path = None
has_audio = False
# ── build FFmpeg command ─────────────────────────────────────────────
# Input 0: raw video frames from stdin pipe
cmd = [
ffmpeg_exe, "-y",
"-f", "rawvideo",
"-vcodec", "rawvideo",
"-s", f"{W}x{H}",
"-pix_fmt", pix_fmt_in,
"-r", str(fps),
"-i", "pipe:0",
]
# Input 1: audio WAV (if present)
if has_audio:
cmd += ["-i", tmp_audio_path]
# Video codec + quality
cmd += ["-vcodec", vcodec]
if codec not in LOSSLESS_CODECS:
cmd += ["-crf", str(crf)]
if codec in ("h264", "h265"):
cmd += ["-preset", preset]
cmd += ["-pix_fmt", pix_fmt_out]
cmd += extra_flags
# Explicit stream mapping β€” required when multiple inputs exist
if has_audio:
cmd += [
"-map", "0:v:0",
"-map", "1:a:0",
"-c:a", "aac",
"-b:a", "192k",
"-shortest",
]
else:
cmd += ["-map", "0:v:0"]
if ffmpeg_extra_args.strip():
cmd += ffmpeg_extra_args.strip().split()
cmd.append(out_path)
# ── parallel frame β†’ bytes ────────────────────────────────────────────
images_cpu = images.contiguous().cpu()
def convert(i):
return _tensor_to_bytes(images_cpu[i])
print(f"[CreateSaveVideo] Converting {n_frames} frames ({W}x{H}) "
f"with {worker_threads} threads …")
t_conv = time.perf_counter()
with ThreadPoolExecutor(max_workers=worker_threads) as pool:
raw_frames = list(pool.map(convert, range(n_frames)))
print(f"[CreateSaveVideo] Frame conversion : {time.perf_counter() - t_conv:.2f}s")
# ── stream into FFmpeg ────────────────────────────────────────────────
print(f"[CreateSaveVideo] Encoding β†’ {out_path}")
t_enc = time.perf_counter()
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
for raw in raw_frames:
proc.stdin.write(raw)
proc.stdin.close()
_, stderr = proc.communicate()
if proc.returncode != 0:
raise RuntimeError(
f"[CreateSaveVideo] FFmpeg failed (rc={proc.returncode}):\n"
+ stderr.decode(errors="replace")
)
print(f"[CreateSaveVideo] Encode time : {time.perf_counter() - t_enc:.2f}s")
print(f"[CreateSaveVideo] Total time : {time.perf_counter() - t_start:.2f}s")
print(f"[CreateSaveVideo] Saved β†’ {out_path}")
if tmp_audio_path and os.path.exists(tmp_audio_path):
os.unlink(tmp_audio_path)
return {"ui": {"video": [filename]}, "result": (out_path,)}
# ---------------------------------------------------------------------------
# ComfyUI registration
# ---------------------------------------------------------------------------
NODE_CLASS_MAPPINGS = {
"CreateSaveVideo": CreateSaveVideo,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"CreateSaveVideo": "Create & Save Video (Optimized)",
}