Dreamspire's picture
custom_nodes
f2dbf59
import os
import re
import cv2
import sys
import json
import torch
import datetime
import itertools
import subprocess
import folder_paths
import numpy as np
from string import Template
from pathlib import Path
from PIL import Image, ExifTags
from PIL.PngImagePlugin import PngInfo
from .ffmpeg import ffmpeg_path, gifski_path
from ..utils import tensor_to_bytes, tensor_to_shorts, requeue_workflow
def gen_format_widgets(video_format):
for k in video_format:
if k.endswith("_pass"):
for i in range(len(video_format[k])):
if isinstance(video_format[k][i], list):
item = [video_format[k][i]]
yield item
video_format[k][i] = item[0]
else:
if isinstance(video_format[k], list):
item = [video_format[k]]
yield item
video_format[k] = item[0]
def get_format_widget_defaults(format_name):
video_format_path = folder_paths.get_full_path("VHS_video_formats", format_name + ".json")
with open(video_format_path, 'r') as stream:
video_format = json.load(stream)
results = {}
for w in gen_format_widgets(video_format):
if len(w[0]) > 2 and 'default' in w[0][2]:
default = w[0][2]['default']
else:
if type(w[0][1]) is list:
default = w[0][1][0]
else:
#NOTE: This doesn't respect max/min, but should be good enough as a fallback to a fallback to a fallback
default = {"BOOLEAN": False, "INT": 0, "FLOAT": 0, "STRING": ""}[w[0][1]]
results[w[0][0]] = default
return results
def get_video_formats():
formats = []
for format_name in folder_paths.get_filename_list("VHS_video_formats"):
format_name = format_name[:-5]
formats.append("video/" + format_name)
return formats
def gifski_process(args, video_format, file_path, env):
frame_data = yield
with subprocess.Popen(args + video_format['main_pass'] + ['-f', 'yuv4mpegpipe', '-'],
stderr=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, env=env) as procff:
with subprocess.Popen([gifski_path] + video_format['gifski_pass']
+ ['-q', '-o', file_path, '-'], stderr=subprocess.PIPE,
stdin=procff.stdout, stdout=subprocess.PIPE,
env=env) as procgs:
try:
while frame_data is not None:
procff.stdin.write(frame_data)
frame_data = yield
procff.stdin.flush()
procff.stdin.close()
resff = procff.stderr.read()
resgs = procgs.stderr.read()
outgs = procgs.stdout.read()
except BrokenPipeError as e:
procff.stdin.close()
resff = procff.stderr.read()
resgs = procgs.stderr.read()
raise Exception("An error occurred while creating gifski output\n" \
+ "Make sure you are using gifski --version >=1.32.0\nffmpeg: " \
+ resff.decode("utf-8") + '\ngifski: ' + resgs.decode("utf-8"))
if len(resff) > 0:
print(resff.decode("utf-8"), end="", file=sys.stderr)
if len(resgs) > 0:
print(resgs.decode("utf-8"), end="", file=sys.stderr)
#should always be empty as the quiet flag is passed
if len(outgs) > 0:
print(outgs.decode("utf-8"))
def ffmpeg_process(args, video_format, video_metadata, file_path, env):
res = None
frame_data = yield
total_frames_output = 0
if video_format.get('save_metadata', 'False') != 'False':
os.makedirs(folder_paths.get_temp_directory(), exist_ok=True)
metadata = json.dumps(video_metadata)
metadata_path = os.path.join(folder_paths.get_temp_directory(), "metadata.txt")
#metadata from file should escape = ; # \ and newline
metadata = metadata.replace("\\","\\\\")
metadata = metadata.replace(";","\\;")
metadata = metadata.replace("#","\\#")
metadata = metadata.replace("=","\\=")
metadata = metadata.replace("\n","\\\n")
metadata = "comment=" + metadata
with open(metadata_path, "w") as f:
f.write(";FFMETADATA1\n")
f.write(metadata)
m_args = args[:1] + ["-i", metadata_path] + args[1:] + ["-metadata", "creation_time=now"]
print(f'ffmpeg: {m_args}')
with subprocess.Popen(m_args + [file_path], stderr=subprocess.PIPE,
stdin=subprocess.PIPE, env=env) as proc:
try:
while frame_data is not None:
proc.stdin.write(frame_data)
#TODO: skip flush for increased speed
frame_data = yield
total_frames_output+=1
proc.stdin.flush()
proc.stdin.close()
res = proc.stderr.read()
except BrokenPipeError as e:
err = proc.stderr.read()
#Check if output file exists. If it does, the re-execution
#will also fail. This obscures the cause of the error
#and seems to never occur concurrent to the metadata issue
if os.path.exists(file_path):
raise Exception("An error occurred in the ffmpeg subprocess:\n" \
+ err.decode("utf-8"))
#Res was not set
print(err.decode("utf-8"), end="", file=sys.stderr)
print("An error occurred when saving with metadata")
if res != b'':
with subprocess.Popen(args + [file_path], stderr=subprocess.PIPE,
stdin=subprocess.PIPE, env=env) as proc:
try:
while frame_data is not None:
proc.stdin.write(frame_data)
frame_data = yield
total_frames_output+=1
proc.stdin.flush()
proc.stdin.close()
res = proc.stderr.read()
except BrokenPipeError as e:
res = proc.stderr.read()
raise Exception("An error occurred in the ffmpeg subprocess:\n" \
+ res.decode("utf-8"))
yield total_frames_output
if len(res) > 0:
print(res.decode("utf-8"), end="", file=sys.stderr)
def to_pingpong(inp):
if not hasattr(inp, "__getitem__"):
inp = list(inp)
yield from inp
for i in range(len(inp)-2,0,-1):
yield inp[i]
def apply_format_widgets(format_name, kwargs):
video_format_path = folder_paths.get_full_path("VHS_video_formats", format_name + ".json")
with open(video_format_path, 'r') as stream:
video_format = json.load(stream)
for w in gen_format_widgets(video_format):
assert(w[0][0] in kwargs)
if len(w[0]) > 3:
w[0] = Template(w[0][3]).substitute(val=kwargs[w[0][0]])
else:
w[0] = str(kwargs[w[0][0]])
return video_format
class SaveVideoNode:
@classmethod
def INPUT_TYPES(s):
ffmpeg_formats = get_video_formats()
return {
"required": {
"path": ("STRING", {"multiline": True, "dynamicPrompts": False}),
"format": (ffmpeg_formats,),
"quality": ([100, 95, 90, 85, 80, 75, 70, 60, 50], {"default": 100}),
"pingpong": ("BOOLEAN", {"default": False}),
},
"optional": {
"images": ("IMAGE",),
"audio": ("AUDIO",),
"frame_rate": ("INT,FLOAT", { "default": 25.0, "step": 1.0, "min": 1.0, "max": 60.0 }),
"meta_batch": ("BatchManager",),
},
"hidden": {
"prompt": "PROMPT",
"unique_id": "UNIQUE_ID"
},
}
RETURN_TYPES = ()
CATEGORY = "tbox/Video"
FUNCTION = "save_video"
OUTPUT_NODE = True
def save_video(
self,
path,
frame_rate=25,
images=None,
format="video/h264-mp4",
quality=85,
pingpong=False,
audio=None,
prompt=None,
meta_batch=None,
unique_id=None,
manual_format_widgets=None,
):
if images is None:
return {}
if isinstance(images, torch.Tensor) and images.size(0) == 0:
return {}
if frame_rate < 1:
frame_rate = 1
elif frame_rate > 120:
frame_rate = 120
num_frames = len(images)
first_image = images[0]
images = iter(images)
file_path = os.path.abspath(path.split('\n')[0])
output_dir = os.path.dirname(file_path)
filename = os.path.basename(file_path)
name, extension = os.path.splitext(filename)
output_process = None
video_metadata = {}
if prompt is not None:
video_metadata["prompt"] = prompt
if meta_batch is not None and unique_id in meta_batch.outputs:
(counter, output_process) = meta_batch.outputs[unique_id]
else:
counter = 0
output_process = None
format_type, format_ext = format.split("/")
# Use ffmpeg to save a video
if ffmpeg_path is None:
raise ProcessLookupError(f"ffmpeg is required for video outputs and could not be found.\nIn order to use video outputs, you must either:\n- Install imageio-ffmpeg with pip,\n- Place a ffmpeg executable in {os.path.abspath('')}, or\n- Install ffmpeg and add it to the system path.")
#Acquire additional format_widget values
kwargs = None
if manual_format_widgets is None:
if prompt is not None:
kwargs = prompt[unique_id]['inputs']
else:
manual_format_widgets = {}
if kwargs is None:
kwargs = get_format_widget_defaults(format_ext)
missing = {}
for k in kwargs.keys():
if k in manual_format_widgets:
kwargs[k] = manual_format_widgets[k]
else:
missing[k] = kwargs[k]
if len(missing) > 0:
print("Extra format values were not provided, the following defaults will be used: " + str(kwargs) + "\nThis is likely due to usage of ComfyUI-to-python. These values can be manually set by supplying a manual_format_widgets argument")
video_format = apply_format_widgets(format_ext, kwargs)
has_alpha = first_image.shape[-1] == 4
dim_alignment = video_format.get("dim_alignment", 8)
if (first_image.shape[1] % dim_alignment) or (first_image.shape[0] % dim_alignment):
#output frames must be padded
to_pad = (-first_image.shape[1] % dim_alignment,
-first_image.shape[0] % dim_alignment)
padding = (to_pad[0]//2, to_pad[0] - to_pad[0]//2,
to_pad[1]//2, to_pad[1] - to_pad[1]//2)
padfunc = torch.nn.ReplicationPad2d(padding)
def pad(image):
image = image.permute((2,0,1))#HWC to CHW
padded = padfunc(image.to(dtype=torch.float32))
return padded.permute((1,2,0))
images = map(pad, images)
new_dims = (-first_image.shape[1] % dim_alignment + first_image.shape[1],
-first_image.shape[0] % dim_alignment + first_image.shape[0])
dimensions = f"{new_dims[0]}x{new_dims[1]}"
print(f"Output images were not of valid resolution and have had padding applied: {dimensions}")
else:
dimensions = f"{first_image.shape[1]}x{first_image.shape[0]}"
if pingpong:
if meta_batch is not None:
print("pingpong is incompatible with batched output")
images = to_pingpong(images)
images = map(tensor_to_bytes, images)
if has_alpha:
i_pix_fmt = 'rgba'
else:
i_pix_fmt = 'rgb24'
args = [ffmpeg_path, "-v", "error", "-f", "rawvideo", "-pix_fmt", i_pix_fmt,
"-s", dimensions, "-r", str(frame_rate), "-i", "-"]
images = map(lambda x: x.tobytes(), images)
env=os.environ.copy()
if "environment" in video_format:
env.update(video_format["environment"])
if "pre_pass" in video_format:
images = [b''.join(images)]
os.makedirs(folder_paths.get_temp_directory(), exist_ok=True)
pre_pass_args = args[:13] + video_format['pre_pass']
try:
subprocess.run(pre_pass_args, input=images[0], env=env,
capture_output=True, check=True)
except subprocess.CalledProcessError as e:
raise Exception("An error occurred in the ffmpeg prepass:\n" \
+ e.stderr.decode("utf-8"))
if "inputs_main_pass" in video_format:
args = args[:13] + video_format['inputs_main_pass'] + args[13:]
if output_process is None:
args += video_format['main_pass']
output_process = ffmpeg_process(args, video_format, video_metadata, file_path, env)
#Proceed to first yield
output_process.send(None)
if meta_batch is not None:
meta_batch.outputs[unique_id] = (0, output_process)
for image in images:
output_process.send(image)
if meta_batch is not None:
requeue_workflow((meta_batch.unique_id, not meta_batch.has_closed_inputs))
if meta_batch is None or meta_batch.has_closed_inputs:
#Close pipe and wait for termination.
try:
total_frames_output = output_process.send(None)
output_process.send(None)
except StopIteration:
pass
if meta_batch is not None:
meta_batch.outputs.pop(unique_id)
#if len(meta_batch.outputs) == 0:
# meta_batch.reset()
else:
return {}
a_waveform = None
if audio is not None:
try:
#safely check if audio produced by VHS_LoadVideo actually exists
a_waveform = audio['waveform']
except:
print(f'save audio >> not waveform')
pass
if a_waveform is not None:
# Create audio file if input was provided
output_file_with_audio = f"{name}-audio{extension}"
output_file_with_audio_path = os.path.join(output_dir, output_file_with_audio)
if "audio_pass" not in video_format:
print("Selected video format does not have explicit audio support")
video_format["audio_pass"] = ["-c:a", "libopus"]
# FFmpeg command with audio re-encoding
#TODO: expose audio quality options if format widgets makes it in
#Reconsider forcing apad/shortest
channels = audio['waveform'].size(1)
min_audio_dur = total_frames_output / frame_rate + 1
mux_args = [ffmpeg_path, "-v", "error", "-i", file_path,
"-ar", str(audio['sample_rate']), "-ac", str(channels),
"-y","-f", "f32le", "-i", "-", "-c:v", "copy"] \
+ video_format["audio_pass"] \
+ ["-af", "apad=whole_dur="+str(min_audio_dur),
"-shortest", output_file_with_audio_path]
audio_data = audio['waveform'].squeeze(0).transpose(0,1) \
.numpy().tobytes()
try:
res = subprocess.run(mux_args, input=audio_data,
env=env, capture_output=True, check=True)
if res.returncode == 0:
self.replace_file(output_file_with_audio_path, file_path)
except subprocess.CalledProcessError as e:
raise Exception("An error occured in the ffmpeg subprocess:\n" \
+ e.stderr.decode("utf-8"))
if res.stderr:
print(res.stderr.decode("utf-8"), end="", file=sys.stderr)
return {}
@classmethod
def VALIDATE_INPUTS(self, format, **kwargs):
return True
def replace_file(self, audio_path, file_path):
try:
# 删除 file_path 文件(如果存在)
if os.path.exists(file_path):
os.remove(file_path)
print(f"Deleted file: {file_path}")
else:
print(f"File not found, skipping deletion: {file_path}")
# 将 output_file_with_audio_path 重命名为 file_path
os.rename(audio_path, file_path)
print(f"Renamed {audio_path} to {file_path}")
except Exception as e:
print(f"An error occurred: {e}")