Files
scofano-ComfyUI-Simple-vide…/scripts/comfy_video_overlay.py
T
scofano 454eea334f chore: switch project license to AGPL and ignore analysis output
Replace the bundled GPLv3 license text with AGPLv3 to better reflect network-use distribution terms, and add codescene_analysis to .gitignore so generated analysis artifacts are not committed.chore: switch project license to AGPL and ignore analysis output

Replace the bundled GPLv3 license text with AGPLv3 to better reflect network-use distribution terms, and add codescene_analysis to .gitignore so generated analysis artifacts are not committed.
2026-04-24 01:04:58 -03:00

344 lines
11 KiB
Python

import os
import shutil
import subprocess
import json
import tempfile
from pathlib import Path
import torch
import numpy as np
from PIL import Image
import comfy.utils
# ---- ffmpeg helpers (adapted from videoverlay.py) ----
def which_or_die(name: str) -> str:
p = shutil.which(name)
if not p:
raise RuntimeError(f"Error: '{name}' not found on PATH.")
return p
FFMPEG = which_or_die("ffmpeg")
FFPROBE = which_or_die("ffprobe")
def run(cmd: list[str]) -> None:
print("[VideoOverlay] Running command:", " ".join(cmd), flush=True)
try:
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
pretty = " ".join(f'"{c}"' if " " in c else c for c in cmd)
raise RuntimeError(f"Command failed ({e.returncode}):\n{pretty}\n{e}") from e
def ffprobe_json(path: Path) -> dict:
prog = FFPROBE if "ffprobe" in FFPROBE else FFMPEG.replace("ffmpeg", "ffprobe")
out = subprocess.check_output([
prog,
"-v", "error",
"-show_streams",
"-show_format", # include format so we can get duration reliably
"-print_format", "json",
str(path),
])
return json.loads(out.decode("utf-8"))
def get_video_stream_info(path: Path) -> dict | None:
info = ffprobe_json(path)
for s in info.get("streams", []):
if s.get("codec_type") == "video":
return s
return None
def get_dimensions(path: Path) -> tuple[int, int]:
s = get_video_stream_info(path)
if not s:
raise RuntimeError(f"No video stream found in: {path}")
try:
w, h = int(s["width"]), int(s["height"])
if w <= 0 or h <= 0:
raise ValueError
return w, h
except Exception:
raise RuntimeError(f"Could not determine dimensions for: {path}")
def get_duration(path: Path) -> float:
"""
Returns duration in seconds (float) using ffprobe format/stream info.
"""
info = ffprobe_json(path)
dur = None
# Prefer container (format) duration if available
fmt = info.get("format", {})
if "duration" in fmt:
try:
dur = float(fmt["duration"])
except Exception:
dur = None
# Fallback to video stream duration
if dur is None:
for s in info.get("streams", []):
if s.get("codec_type") == "video" and "duration" in s:
try:
dur = float(s["duration"])
break
except Exception:
pass
if dur is None or dur <= 0:
raise RuntimeError(f"Could not determine duration for: {path}")
return dur
def build_filter_complex(base_w: int, base_h: int, opacity: float) -> str:
"""
Simpler, robust filter graph:
- Convert both inputs to planar RGB.
- Scale overlay to match base dimensions.
- Make overlay grayscale (so multiply behaves as a neutral luminance mask).
- Multiply blend.
- Convert to yuv420p for libx264.
"""
return (
# Base: just get to RGB planar, fix SAR
f"[0:v]format=gbrp,setsar=1[base];"
# Overlay: scale to base size, gray -> gbrp so channels match
f"[1:v]scale={base_w}:{base_h},format=gray,format=gbrp,setsar=1[ov];"
# Multiply blend
f"[base][ov]blend=all_mode=multiply:all_opacity={opacity}[rgb];"
# Back to yuv420p for encoding
f"[rgb]format=yuv420p[v]"
)
def run_ffmpeg_overlay(base: Path, overlay: Path, out_path: Path,
opacity: float = 0.75,
crf: int = 18,
preset: str = "medium") -> None:
print("[VideoOverlay] Probing base video for dimensions and duration...", flush=True)
base_w, base_h = get_dimensions(base)
base_dur = get_duration(base)
overlay_dur = get_duration(overlay)
print(f"[VideoOverlay] Base size: {base_w}x{base_h}, duration: {base_dur:.3f}s", flush=True)
print(f"[VideoOverlay] Overlay duration: {overlay_dur:.3f}s", flush=True)
fc = build_filter_complex(base_w, base_h, opacity)
# Decide how to handle overlay vs base duration
overlay_for_blend = overlay
eps = 0.01 # small epsilon for float comparison
if overlay_dur + eps < base_dur:
# Overlay is shorter -> create a looped version exactly as long as base
print("[VideoOverlay] Overlay shorter than base. Creating looped overlay to match base duration...", flush=True)
looped_overlay = out_path.with_name("overlay_looped.mp4")
cmd_loop = [
FFMPEG,
"-hide_banner", "-loglevel", "error",
"-y",
"-stream_loop", "-1", # loop overlay
"-i", str(overlay),
"-t", f"{base_dur:.3f}", # cut to base duration
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
str(looped_overlay),
]
run(cmd_loop)
overlay_for_blend = looped_overlay
elif overlay_dur > base_dur + eps:
print("[VideoOverlay] Overlay longer than base. It will be trimmed to base duration.", flush=True)
else:
print("[VideoOverlay] Overlay and base durations are approximately equal.", flush=True)
# Final blend: always cut output to base duration
cmd = [
FFMPEG,
"-hide_banner", "-loglevel", "error",
"-y",
"-i", str(base),
"-i", str(overlay_for_blend),
"-filter_complex", fc,
"-map", "[v]",
"-c:v", "libx264",
"-crf", str(crf),
"-preset", preset,
"-t", f"{base_dur:.3f}", # ensure output duration == base duration
"-color_primaries", "bt709",
"-color_trc", "bt709",
"-colorspace", "bt709",
"-color_range", "tv",
str(out_path),
]
run(cmd)
# ---- IMAGE <-> VIDEO helpers for ComfyUI ----
def tensor_to_png_sequence(images: torch.Tensor, out_dir: Path) -> None:
"""
images: [N, H, W, C] float32 0..1 (ComfyUI IMAGE)
Saves as out_dir/frame_000001.png ...
"""
out_dir.mkdir(parents=True, exist_ok=True)
images = images.detach().cpu()
n = images.shape[0]
print(f"[VideoOverlay] Saving {n} input frames to {out_dir}", flush=True)
pbar = comfy.utils.ProgressBar(n)
for i in range(n):
frame = images[i]
# Clamp & convert to uint8
frame = (frame.clamp(0.0, 1.0) * 255.0).round().to(torch.uint8).numpy()
# Ensure 3 channels
if frame.shape[-1] == 1:
frame = np.repeat(frame, 3, axis=-1)
elif frame.shape[-1] == 4:
frame = frame[..., :3]
img = Image.fromarray(frame)
img.save(out_dir / f"frame_{i:06d}.png")
pbar.update_absolute(i + 1)
def png_sequence_to_tensor(in_dir: Path) -> torch.Tensor:
"""
Loads frame_*.png and returns [N, H, W, C] float32 0..1 tensor.
"""
frames = sorted(
[p for p in in_dir.iterdir() if p.is_file() and p.suffix.lower() == ".png"]
)
if not frames:
raise RuntimeError(f"No PNG frames found in {in_dir}")
print(f"[VideoOverlay] Loading {len(frames)} output frames from {in_dir}", flush=True)
imgs = []
pbar = comfy.utils.ProgressBar(len(frames))
for i, p in enumerate(frames):
img = Image.open(p).convert("RGB")
arr = np.array(img).astype(np.float32) / 255.0
imgs.append(arr)
pbar.update_absolute(i + 1)
arr = np.stack(imgs, axis=0) # [N, H, W, C]
return torch.from_numpy(arr)
def png_sequence_to_video(in_dir: Path, out_path: Path, fps: int = 24) -> None:
"""
Uses ffmpeg to encode PNG sequence -> mp4.
"""
print(f"[VideoOverlay] Encoding PNGs -> video at {out_path}", flush=True)
cmd = [
FFMPEG,
"-hide_banner", "-loglevel", "error",
"-y",
"-framerate", str(fps),
"-i", str(in_dir / "frame_%06d.png"),
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
str(out_path),
]
run(cmd)
def video_to_png_sequence(video_path: Path, out_dir: Path) -> None:
"""
Uses ffmpeg to decode video -> PNG frames.
"""
print(f"[VideoOverlay] Decoding video -> PNGs from {video_path} to {out_dir}", flush=True)
out_dir.mkdir(parents=True, exist_ok=True)
cmd = [
FFMPEG,
"-hide_banner", "-loglevel", "error",
"-y",
"-i", str(video_path),
str(out_dir / "frame_%06d.png"),
]
run(cmd)
# ---- ComfyUI Node ----
class VideoOverlay:
"""
ComfyUI node:
- Input: IMAGE (video sequence)
- Input: overlay_video_path (STRING)
- Output: IMAGE (overlayed sequence)
Video operations are done only with ffmpeg.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"overlay_video_path": (
"STRING",
{
"multiline": False,
"default": "",
},
),
"opacity": (
"FLOAT",
{
"default": 0.75,
"min": 0.0,
"max": 1.0,
"step": 0.01,
},
),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "apply_overlay"
CATEGORY = "Simple Video Effects/Image Processing"
def apply_overlay(self, images, overlay_video_path: str, opacity: float):
if not overlay_video_path:
raise RuntimeError("overlay_video_path is empty.")
overlay_video = Path(overlay_video_path)
if not overlay_video.exists():
raise RuntimeError(f"Overlay video not found: {overlay_video}")
# Clamp opacity
opacity = float(max(0.0, min(1.0, opacity)))
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)
print(f"[VideoOverlay] Working in temp dir: {tmpdir}", flush=True)
# 1) IMAGE tensor -> PNG sequence -> base video
png_in_dir = tmpdir / "in_frames"
tensor_to_png_sequence(images, png_in_dir)
base_video_path = tmpdir / "base.mp4"
png_sequence_to_video(png_in_dir, base_video_path, fps=24)
# 2) ffmpeg overlay + duration logic
overlaid_video_path = tmpdir / "overlaid.mp4"
run_ffmpeg_overlay(
base=base_video_path,
overlay=overlay_video,
out_path=overlaid_video_path,
opacity=opacity,
crf=18,
preset="medium",
)
# 3) overlaid video -> PNG sequence -> IMAGE tensor
png_out_dir = tmpdir / "out_frames"
video_to_png_sequence(overlaid_video_path, png_out_dir)
out_tensor = png_sequence_to_tensor(png_out_dir)
# ComfyUI expects a batch of images: [N, H, W, C]
print("[VideoOverlay] Done, returning tensor with shape", tuple(out_tensor.shape), flush=True)
return (out_tensor,)