Files
olivv-cs-ComfyUI-FunPack/core/video_pipe.py
T

200 lines
8.8 KiB
Python

"""Stream a video file through a per-chunk function into a new file, keeping its sound.
Frames go through ffmpeg in small chunks (never the whole clip in RAM). Shared by the post-render
steps that rewrite a finished video (upscale, interpolate), which cannot import each other.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
CHUNK = 8 # frames per call, at most
CHUNK_PIXELS = 2_000_000 # ...and at most this many input pixels per call: a big upscale of 1080p is gigabytes per frame
FFMPEG_MISSING = "ffmpeg was not found on PATH: install it to preview, export or render clips."
class ClipError(Exception):
"""A clip cannot be used, with the sentence the person should read."""
def ffmpeg() -> str:
path = shutil.which("ffmpeg")
if not path:
raise ClipError(FFMPEG_MISSING)
return path
def comfy_dir(kind: str) -> str | None:
"""ComfyUI's output or temp folder, or None when ComfyUI is not here to ask."""
try:
import folder_paths
return folder_paths.get_output_directory() if kind == "output" else folder_paths.get_temp_directory()
except Exception: # noqa: BLE001 -- tests, headless
return None
def comfy_path(filename, subfolder="", kind="output", dir_of=None) -> str | None:
"""The file ComfyUI calls (filename, subfolder, kind), or None if the name does not
resolve to a place under that folder."""
if not isinstance(filename, str) or not filename:
return None
base = (dir_of or comfy_dir)("output" if kind != "temp" else "temp")
if not base:
return None
base = os.path.realpath(base)
full = os.path.realpath(os.path.join(base, subfolder if isinstance(subfolder, str) else "",
os.path.basename(filename)))
return full if os.path.commonpath((full, base)) == base else None
def probe(path):
"""-> (width, height, fps string, has sound)."""
ffprobe = shutil.which("ffprobe")
if not ffprobe:
raise ClipError("ffprobe was not found on PATH: install ffmpeg to upscale videos.")
out = subprocess.run([ffprobe, "-v", "error", "-show_entries",
"stream=codec_type,width,height,r_frame_rate", "-of", "json", path],
capture_output=True, text=True)
if out.returncode:
raise ClipError(f"{os.path.basename(path)} could not be read: {out.stderr.strip()[-300:] or 'ffprobe failed'}")
streams = json.loads(out.stdout).get("streams") or []
video = next((s for s in streams if s.get("codec_type") == "video"), None)
if not video:
raise ClipError(f"no video stream in {os.path.basename(path)}")
return (int(video["width"]), int(video["height"]), video.get("r_frame_rate") or "25",
any(s.get("codec_type") == "audio" for s in streams))
def _read(stream, n):
buf = bytearray()
while len(buf) < n:
part = stream.read(n - len(buf))
if not part:
break
buf += part
return bytes(buf)
def scaled_fps(fps, k):
""""24/1" -> "48/1", "30000/1001" -> "60000/1001": the frame rate after every frame became k."""
num, _, den = str(fps).partition("/")
return f"{int(num) * k}/{den or 1}"
def stream_file(src, dst, upscale, interrupted=lambda: None, fps_factor=1):
"""Stream `src` through `upscale` ([B,H,W,3] float 0-1 -> same, larger, or more frames when `fps_factor` > 1)
into `dst`, played at `fps_factor` times the source's frame rate so the length and the sound stay in step.
Returns the frames written. `interrupted()` raises to stop between chunks."""
import numpy as np
import torch
w, h, fps, has_sound = probe(src)
frame = w * h * 3
chunk = max(1, min(CHUNK, CHUNK_PIXELS // max(1, w * h)))
exe = ffmpeg()
# The decoder's complaints go to a file, not away: ffmpeg exits 0 on a video that was cut short, and the only
# sign is what it printed ("partial file"). Read after the last frame; a file avoids a full pipe stalling it.
problems = tempfile.TemporaryFile()
dec = subprocess.Popen([exe, "-v", "error", "-i", src, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
stdout=subprocess.PIPE, stderr=problems)
enc, frames = None, 0
try:
while True:
interrupted()
raw = _read(dec.stdout, frame * chunk)
n = len(raw) // frame
if n == 0:
break
x = torch.from_numpy(np.frombuffer(raw[:n * frame], dtype=np.uint8)
.reshape(n, h, w, 3).copy()).float() / 255.0
y = (upscale(x).clamp(0, 1) * 255.0).round().to(torch.uint8).cpu().numpy()
if enc is None:
oh, ow = y.shape[1], y.shape[2]
cmd = [exe, "-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "rgb24",
"-s", f"{ow}x{oh}", "-r", scaled_fps(fps, fps_factor), "-i", "-"]
if has_sound:
cmd += ["-i", src, "-map", "0:v:0", "-map", "1:a:0", "-c:a", "aac", "-b:a", "192k"]
cmd += ["-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-c:v", "libx264", "-crf", "16",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", dst]
enc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
try:
enc.stdin.write(y.tobytes())
except BrokenPipeError: # the encoder died: its own message says why
enc.wait()
raise ClipError(f"ffmpeg could not encode the video: {enc.stderr.read().decode(errors='replace')[-500:]}") from None
frames += y.shape[0]
tail = y[-1:]
if enc is not None and fps_factor > 1: # the last picture has no partner to blend with: hold it, so frames = source x factor and the length stays
hold = np.repeat(tail, fps_factor - 1, axis=0)
try:
enc.stdin.write(hold.tobytes())
except BrokenPipeError:
enc.wait()
raise ClipError(f"ffmpeg could not encode the video: {enc.stderr.read().decode(errors='replace')[-500:]}") from None
frames += hold.shape[0]
dec.wait()
problems.seek(0)
said = problems.read().decode(errors="replace").strip()
if dec.returncode or said:
raise ClipError(f"{os.path.basename(src)} is damaged or cut short (read {frames} frame(s) before the problem): {said[-300:] or 'ffmpeg failed'}")
if enc is None:
raise ClipError(f"no frames could be read from {os.path.basename(src)}")
interrupted() # a stop that came during the last read is still a stop
enc.stdin.close()
err = enc.stderr.read().decode(errors="replace")
if enc.wait() != 0:
raise ClipError(f"ffmpeg could not encode the video: {err[-500:]}")
interrupted() # ... and one that came while the encoder flushed: the file is removed below
problems.close()
return frames
except BaseException:
problems.close()
for p in (dec, enc):
if p is not None and p.poll() is None:
p.kill()
if p is not None:
p.wait()
for pipe in (p.stdin, p.stdout, p.stderr):
try:
if pipe:
pipe.close()
except OSError: # a killed encoder's unflushed pipe
pass
if os.path.exists(dst):
os.remove(dst)
raise
finally:
if dec.poll() is None:
dec.kill()
def last_frame_name(video_file: str) -> str:
"""The lossless copy of a render's last picture, kept in ComfyUI's temp folder (Save Video writes it; the carry reads it)."""
return "funui_last_" + os.path.splitext(os.path.basename(video_file))[0] + ".png"
def inherit_last_frame(old_file: str, new_file: str) -> None:
"""A file made from `old_file` (upscaled, interpolated) continues from the same picture: the one the sampler made, taken before
this step touched it. The copy is under the new file's name so the carry finds it. Nothing to inherit, or no temp folder: nothing happens."""
try:
folder = comfy_dir("temp")
src = os.path.join(folder or "", last_frame_name(old_file))
if folder and os.path.isfile(src):
shutil.copyfile(src, os.path.join(folder, last_frame_name(new_file)))
except OSError:
pass
def free_name(out_dir, filename, model):
stem = os.path.splitext(os.path.basename(filename))[0]
tag = os.path.splitext(os.path.basename(model))[0]
n, name = 0, f"{stem}_{tag}.mp4"
while os.path.exists(os.path.join(out_dir, name)):
n += 1
name = f"{stem}_{tag}_{n}.mp4"
return name