Add FL video load and combine nodes
This commit is contained in:
@@ -2,6 +2,8 @@ import logging
|
||||
|
||||
logger = logging.getLogger("fl_fill_nodes")
|
||||
|
||||
from . import routes
|
||||
|
||||
# AI NODES
|
||||
from .nodes.ai.FL_Fal_Gemini_ImageEdit import FL_Fal_Gemini_ImageEdit
|
||||
from .nodes.ai.FL_Fal_GPTImage2_Edit import FL_Fal_GPTImage2_Edit
|
||||
@@ -236,6 +238,8 @@ from .nodes.video.FL_VideoCadence import FL_VideoCadence
|
||||
from .nodes.video.FL_VideoCadenceCompile import FL_VideoCadenceCompile
|
||||
from .nodes.video.FL_VideoCrossfade import FL_VideoCrossfade
|
||||
from .nodes.video.FL_VideoCut import FL_VideoCut
|
||||
from .nodes.video.FL_LoadVideo import FL_LoadVideo
|
||||
from .nodes.video.FL_VideoCombine import FL_VideoCombine
|
||||
from .nodes.video.FL_VideoTrim import FL_VideoTrim
|
||||
|
||||
# WIP NODES
|
||||
@@ -367,6 +371,8 @@ NODE_CLASS_MAPPINGS = {
|
||||
"FL_ImageBatchToGrid": FL_ImageBatchToGrid,
|
||||
"FL_ApplyMask": FL_ApplyMask,
|
||||
"FL_ProResVideo": FL_ProResVideo,
|
||||
"FL_LoadVideo": FL_LoadVideo,
|
||||
"FL_VideoCombine": FL_VideoCombine,
|
||||
"FL_Padding": FL_Padding,
|
||||
"FL_GoogleDriveDownloader": FL_GoogleDriveDownloader,
|
||||
"FL_NodeLoader": FL_NodeLoader,
|
||||
@@ -569,6 +575,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"FL_ImageBatchToGrid": "FL Image Batch To Grid",
|
||||
"FL_ApplyMask": "FL Apply Mask",
|
||||
"FL_ProResVideo": "FL ProRes Video",
|
||||
"FL_LoadVideo": "FL Load Video",
|
||||
"FL_VideoCombine": "FL Video Combine",
|
||||
"FL_Padding": "FL Padding",
|
||||
"FL_GoogleDriveDownloader": "FL Google Drive Downloader",
|
||||
"FL_NodeLoader": "FL Node Loader",
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
|
||||
import av
|
||||
import psutil
|
||||
import torch
|
||||
|
||||
import folder_paths
|
||||
from comfy.utils import common_upscale
|
||||
from comfy_api.latest import InputImpl, Types
|
||||
|
||||
|
||||
VIDEO_EXTENSIONS = {".avi", ".gif", ".m4v", ".mkv", ".mov", ".mp4", ".webm"}
|
||||
DEFAULT_LOAD_SETTINGS = {
|
||||
"version": 1,
|
||||
"start_time": 0.0,
|
||||
"end_time": 0.0,
|
||||
"sample_mode": "source",
|
||||
"target_fps": 24.0,
|
||||
"select_every_nth": 1,
|
||||
"frame_load_cap": 0,
|
||||
"resize_mode": "original",
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"include_audio": True,
|
||||
}
|
||||
DEFAULT_SETTINGS_JSON = json.dumps(DEFAULT_LOAD_SETTINGS, separators=(",", ":"))
|
||||
|
||||
|
||||
def available_video_files():
|
||||
input_dir = Path(folder_paths.get_input_directory()).resolve()
|
||||
os.makedirs(input_dir, exist_ok=True)
|
||||
files, _ = folder_paths.recursive_search(str(input_dir))
|
||||
result = []
|
||||
for filename in files:
|
||||
if Path(filename).suffix.lower() not in VIDEO_EXTENSIONS:
|
||||
continue
|
||||
path = (input_dir / filename).resolve()
|
||||
try:
|
||||
path.relative_to(input_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.is_file():
|
||||
result.append(Path(filename).as_posix())
|
||||
return sorted(result, key=str.casefold)
|
||||
|
||||
|
||||
def resolve_video_path(filename):
|
||||
if not isinstance(filename, str) or not filename.strip():
|
||||
raise ValueError("Choose a video file.")
|
||||
|
||||
filename = filename.strip()
|
||||
name, annotated_dir = folder_paths.annotated_filepath(filename)
|
||||
input_dir = Path(folder_paths.get_input_directory()).resolve()
|
||||
if annotated_dir is not None and Path(annotated_dir).resolve() != input_dir:
|
||||
raise ValueError("FL Load Video files must be inside the ComfyUI input directory.")
|
||||
|
||||
try:
|
||||
path = Path(folder_paths.get_annotated_filepath(name, str(input_dir))).resolve()
|
||||
except ValueError as e:
|
||||
raise ValueError("FL Load Video files must be inside the ComfyUI input directory.") from e
|
||||
try:
|
||||
path.relative_to(input_dir)
|
||||
except ValueError as e:
|
||||
raise ValueError("FL Load Video files must be inside the ComfyUI input directory.") from e
|
||||
if not path.is_file():
|
||||
raise ValueError(f"Video file does not exist: {filename}")
|
||||
if path.suffix.lower() not in VIDEO_EXTENSIONS:
|
||||
raise ValueError(f"Unsupported video format: {path.suffix or filename}")
|
||||
return path
|
||||
|
||||
|
||||
def _parse_settings(load_settings):
|
||||
try:
|
||||
configured = json.loads(load_settings)
|
||||
except (TypeError, json.JSONDecodeError) as e:
|
||||
raise ValueError("FL Load Video settings are not valid JSON.") from e
|
||||
if not isinstance(configured, dict):
|
||||
raise ValueError("FL Load Video settings must be a JSON object.")
|
||||
|
||||
settings = DEFAULT_LOAD_SETTINGS.copy()
|
||||
settings.update(configured)
|
||||
|
||||
version = settings["version"]
|
||||
if not isinstance(version, int) or isinstance(version, bool) or version != 1:
|
||||
raise ValueError(f"FL Load Video settings version {version} is unsupported.")
|
||||
|
||||
for name in ("start_time", "end_time", "target_fps"):
|
||||
value = settings[name]
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value):
|
||||
raise ValueError(f"FL Load Video {name} must be a finite number.")
|
||||
|
||||
start_time = float(settings["start_time"])
|
||||
end_time = float(settings["end_time"])
|
||||
target_fps = float(settings["target_fps"])
|
||||
if start_time < 0:
|
||||
raise ValueError("FL Load Video start_time cannot be negative.")
|
||||
if end_time < 0:
|
||||
raise ValueError("FL Load Video end_time cannot be negative.")
|
||||
if end_time and end_time <= start_time:
|
||||
raise ValueError("FL Load Video end_time must be greater than start_time.")
|
||||
if not 1 <= target_fps <= 120:
|
||||
raise ValueError("FL Load Video target_fps must be between 1 and 120.")
|
||||
|
||||
sample_mode = settings["sample_mode"]
|
||||
if sample_mode not in ("source", "target_fps", "every_nth"):
|
||||
raise ValueError("FL Load Video sample_mode must be source, target_fps, or every_nth.")
|
||||
|
||||
for name in ("select_every_nth", "frame_load_cap", "width", "height"):
|
||||
value = settings[name]
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"FL Load Video {name} must be an integer.")
|
||||
|
||||
if settings["select_every_nth"] < 1:
|
||||
raise ValueError("FL Load Video select_every_nth must be at least 1.")
|
||||
if settings["frame_load_cap"] < 0:
|
||||
raise ValueError("FL Load Video frame_load_cap cannot be negative.")
|
||||
if not 0 <= settings["width"] <= 16384 or not 0 <= settings["height"] <= 16384:
|
||||
raise ValueError("FL Load Video width and height must be between 0 and 16384.")
|
||||
|
||||
resize_mode = settings["resize_mode"]
|
||||
if resize_mode not in ("original", "fit", "crop"):
|
||||
raise ValueError("FL Load Video resize_mode must be original, fit, or crop.")
|
||||
if resize_mode == "fit" and settings["width"] == 0 and settings["height"] == 0:
|
||||
raise ValueError("FL Load Video fit resize requires a width or height.")
|
||||
if resize_mode == "crop" and (settings["width"] == 0 or settings["height"] == 0):
|
||||
raise ValueError("FL Load Video crop resize requires both width and height.")
|
||||
if not isinstance(settings["include_audio"], bool):
|
||||
raise ValueError("FL Load Video include_audio must be true or false.")
|
||||
|
||||
settings["start_time"] = start_time
|
||||
settings["end_time"] = end_time
|
||||
settings["target_fps"] = target_fps
|
||||
return settings
|
||||
|
||||
|
||||
def probe_video(path):
|
||||
path = Path(path)
|
||||
with av.open(str(path), mode="r") as container:
|
||||
if not container.streams.video:
|
||||
raise ValueError(f"No video stream found in file: {path.name}")
|
||||
stream = container.streams.video[0]
|
||||
frame_rate = float(stream.average_rate) if stream.average_rate else 1.0
|
||||
if container.duration is not None:
|
||||
duration = float(container.duration / av.time_base)
|
||||
elif stream.duration is not None and stream.time_base is not None:
|
||||
duration = float(stream.duration * stream.time_base)
|
||||
elif stream.frames and frame_rate:
|
||||
duration = float(stream.frames / frame_rate)
|
||||
else:
|
||||
duration = 0.0
|
||||
|
||||
frame_count_estimated = not bool(stream.frames)
|
||||
frame_count = int(stream.frames) if stream.frames else int(round(duration * frame_rate))
|
||||
codec = stream.codec_context.name if stream.codec_context is not None else ""
|
||||
container_format = container.format.name or ""
|
||||
width = int(stream.width)
|
||||
height = int(stream.height)
|
||||
has_audio = bool(container.streams.audio)
|
||||
|
||||
bit_depth = int(InputImpl.VideoFromFile(str(path)).get_bit_depth())
|
||||
return {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"duration": duration,
|
||||
"frame_rate": frame_rate,
|
||||
"frame_count": frame_count,
|
||||
"frame_count_estimated": frame_count_estimated,
|
||||
"bit_depth": bit_depth,
|
||||
"codec": codec,
|
||||
"container": container_format,
|
||||
"has_audio": has_audio,
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
|
||||
|
||||
def _target_dimensions(source_width, source_height, settings):
|
||||
mode = settings["resize_mode"]
|
||||
if mode == "original":
|
||||
return source_width, source_height
|
||||
if mode == "crop":
|
||||
return settings["width"], settings["height"]
|
||||
|
||||
width = settings["width"]
|
||||
height = settings["height"]
|
||||
if width == 0:
|
||||
scale = height / source_height
|
||||
elif height == 0:
|
||||
scale = width / source_width
|
||||
else:
|
||||
scale = min(width / source_width, height / source_height)
|
||||
return max(1, round(source_width * scale)), max(1, round(source_height * scale))
|
||||
|
||||
|
||||
def build_load_plan(probe, settings):
|
||||
source_duration = float(probe["duration"])
|
||||
start_time = settings["start_time"]
|
||||
if start_time >= source_duration:
|
||||
raise ValueError("FL Load Video start_time is beyond the end of the video.")
|
||||
end_time = min(settings["end_time"] or source_duration, source_duration)
|
||||
selected_duration = end_time - start_time
|
||||
source_fps = float(probe["frame_rate"])
|
||||
|
||||
if settings["sample_mode"] == "target_fps":
|
||||
effective_fps = settings["target_fps"]
|
||||
elif settings["sample_mode"] == "every_nth":
|
||||
effective_fps = source_fps / settings["select_every_nth"]
|
||||
else:
|
||||
effective_fps = source_fps
|
||||
|
||||
estimated_output_frames = max(1, math.ceil(selected_duration * effective_fps))
|
||||
frame_cap = settings["frame_load_cap"]
|
||||
if frame_cap:
|
||||
estimated_output_frames = min(estimated_output_frames, frame_cap)
|
||||
decode_duration = min(selected_duration, frame_cap / effective_fps)
|
||||
else:
|
||||
decode_duration = selected_duration
|
||||
|
||||
output_width, output_height = _target_dimensions(probe["width"], probe["height"], settings)
|
||||
estimated_source_frames = max(1, math.ceil(decode_duration * source_fps))
|
||||
source_bytes = estimated_source_frames * probe["width"] * probe["height"] * 3 * 4
|
||||
output_bytes = estimated_output_frames * output_width * output_height * 3 * 4
|
||||
estimated_peak_bytes = source_bytes + output_bytes + min(source_bytes, output_bytes)
|
||||
return {
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"selected_duration": selected_duration,
|
||||
"decode_duration": decode_duration,
|
||||
"effective_fps": effective_fps,
|
||||
"estimated_source_frames": estimated_source_frames,
|
||||
"estimated_output_frames": estimated_output_frames,
|
||||
"output_width": output_width,
|
||||
"output_height": output_height,
|
||||
"estimated_peak_bytes": estimated_peak_bytes,
|
||||
}
|
||||
|
||||
|
||||
def _check_memory(plan):
|
||||
available = psutil.virtual_memory().available
|
||||
headroom = min(1024 * 1024 * 1024, available // 4)
|
||||
if plan["estimated_peak_bytes"] > available - headroom:
|
||||
required_gb = plan["estimated_peak_bytes"] / (1024 ** 3)
|
||||
raise RuntimeError(
|
||||
f"FL Load Video requires approximately {required_gb:.1f} GB of RAM. "
|
||||
"Reduce the range, frame count, FPS, or resolution."
|
||||
)
|
||||
|
||||
|
||||
def _sample_images(images, source_fps, settings):
|
||||
source_count = int(images.shape[0])
|
||||
if source_count == 0:
|
||||
raise RuntimeError("FL Load Video decoded no frames.")
|
||||
|
||||
if settings["sample_mode"] == "target_fps":
|
||||
effective_fps = settings["target_fps"]
|
||||
output_count = max(1, math.ceil((source_count / source_fps) * effective_fps))
|
||||
indices = [
|
||||
min(source_count - 1, round(index * source_fps / effective_fps))
|
||||
for index in range(output_count)
|
||||
]
|
||||
elif settings["sample_mode"] == "every_nth":
|
||||
nth = settings["select_every_nth"]
|
||||
effective_fps = source_fps / nth
|
||||
indices = list(range(0, source_count, nth))
|
||||
else:
|
||||
effective_fps = source_fps
|
||||
indices = list(range(source_count))
|
||||
|
||||
frame_cap = settings["frame_load_cap"]
|
||||
if frame_cap:
|
||||
indices = indices[:frame_cap]
|
||||
if not indices:
|
||||
raise RuntimeError("FL Load Video settings selected no frames.")
|
||||
|
||||
if len(indices) == source_count and all(index == value for index, value in enumerate(indices)):
|
||||
return images, effective_fps
|
||||
return images[indices], effective_fps
|
||||
|
||||
|
||||
def _trim_audio(audio, duration, include_audio):
|
||||
if audio is None or not include_audio:
|
||||
return None
|
||||
waveform = audio["waveform"]
|
||||
sample_rate = int(audio["sample_rate"])
|
||||
samples = min(int(waveform.shape[-1]), math.ceil(duration * sample_rate))
|
||||
if samples == waveform.shape[-1]:
|
||||
return audio
|
||||
return {
|
||||
"waveform": waveform[..., :samples].clone(),
|
||||
"sample_rate": sample_rate,
|
||||
}
|
||||
|
||||
|
||||
def _resize_images(images, settings):
|
||||
source_height = int(images.shape[1])
|
||||
source_width = int(images.shape[2])
|
||||
width, height = _target_dimensions(source_width, source_height, settings)
|
||||
if (width, height) == (source_width, source_height):
|
||||
return images
|
||||
|
||||
crop = "center" if settings["resize_mode"] == "crop" else "disabled"
|
||||
images = common_upscale(images.movedim(-1, 1), width, height, "lanczos", crop)
|
||||
return images.movedim(1, -1)
|
||||
|
||||
|
||||
def _preview_reference(filename):
|
||||
name, _ = folder_paths.annotated_filepath(filename)
|
||||
normalized = Path(name)
|
||||
return normalized.name, normalized.parent.as_posix() if normalized.parent != Path(".") else ""
|
||||
|
||||
|
||||
class FL_LoadVideo:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
files = available_video_files()
|
||||
return {
|
||||
"required": {
|
||||
"video": ([""] + files,),
|
||||
"load_settings": ("STRING", {"default": DEFAULT_SETTINGS_JSON, "multiline": False}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "AUDIO", "VIDEO", "FLOAT", "INT")
|
||||
RETURN_NAMES = ("images", "audio", "video", "fps", "frame_count")
|
||||
FUNCTION = "load_video"
|
||||
CATEGORY = "🏵️Fill Nodes/Video"
|
||||
DESCRIPTION = "Loads, previews, trims, samples, and resizes a video from ComfyUI input."
|
||||
|
||||
def load_video(self, video, load_settings=DEFAULT_SETTINGS_JSON):
|
||||
settings = _parse_settings(load_settings)
|
||||
path = resolve_video_path(video)
|
||||
probe = probe_video(path)
|
||||
plan = build_load_plan(probe, settings)
|
||||
_check_memory(plan)
|
||||
|
||||
source = InputImpl.VideoFromFile(
|
||||
str(path),
|
||||
start_time=plan["start_time"],
|
||||
duration=plan["decode_duration"],
|
||||
)
|
||||
components = source.get_components()
|
||||
images, effective_fps = _sample_images(components.images, float(components.frame_rate), settings)
|
||||
images = _resize_images(images, settings)
|
||||
frame_count = int(images.shape[0])
|
||||
loaded_duration = frame_count / effective_fps
|
||||
audio = _trim_audio(components.audio, loaded_duration, settings["include_audio"])
|
||||
native_video = InputImpl.VideoFromComponents(
|
||||
Types.VideoComponents(
|
||||
images=images,
|
||||
audio=audio,
|
||||
frame_rate=Fraction(round(effective_fps * 1000), 1000),
|
||||
metadata=components.metadata,
|
||||
),
|
||||
bit_depth=probe["bit_depth"],
|
||||
)
|
||||
|
||||
filename, subfolder = _preview_reference(video)
|
||||
preview = {
|
||||
"filename": filename,
|
||||
"subfolder": subfolder,
|
||||
"type": "input",
|
||||
"source_width": probe["width"],
|
||||
"source_height": probe["height"],
|
||||
"source_duration": probe["duration"],
|
||||
"source_fps": probe["frame_rate"],
|
||||
"source_frame_count": probe["frame_count"],
|
||||
"loaded_width": int(images.shape[2]),
|
||||
"loaded_height": int(images.shape[1]),
|
||||
"loaded_duration": loaded_duration,
|
||||
"loaded_fps": effective_fps,
|
||||
"loaded_frame_count": frame_count,
|
||||
"has_audio": audio is not None,
|
||||
"bit_depth": probe["bit_depth"],
|
||||
}
|
||||
return {
|
||||
"ui": {"fl_load_video": [preview]},
|
||||
"result": (images, audio, native_video, float(effective_fps), frame_count),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, video, load_settings=DEFAULT_SETTINGS_JSON):
|
||||
path = resolve_video_path(video)
|
||||
stat = path.stat()
|
||||
return f"{stat.st_mtime_ns}:{stat.st_size}"
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, video, load_settings=DEFAULT_SETTINGS_JSON):
|
||||
try:
|
||||
resolve_video_path(video)
|
||||
_parse_settings(load_settings)
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
return True
|
||||
@@ -0,0 +1,288 @@
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from fractions import Fraction
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
import folder_paths
|
||||
from comfy.cli_args import args
|
||||
from comfy_api.latest import InputImpl, Types
|
||||
|
||||
|
||||
DEFAULT_RENDER_SETTINGS = {
|
||||
"version": 1,
|
||||
"filename_prefix": "FillVideo",
|
||||
"frame_rate": 24.0,
|
||||
"format": "mp4",
|
||||
"codec": "h264",
|
||||
"crf": 19,
|
||||
"bit_depth": 8,
|
||||
"include_audio": True,
|
||||
"trim_video_to_audio": False,
|
||||
"audio_gain_db": 0.0,
|
||||
"output_directory": "",
|
||||
"save_output": True,
|
||||
"save_metadata": True,
|
||||
}
|
||||
DEFAULT_SETTINGS_JSON = json.dumps(DEFAULT_RENDER_SETTINGS, separators=(",", ":"))
|
||||
|
||||
_MAX_PREVIEW_FILES = 64
|
||||
_preview_files = OrderedDict()
|
||||
_preview_files_lock = threading.Lock()
|
||||
|
||||
|
||||
def register_preview_file(file_path):
|
||||
file_path = os.path.abspath(file_path)
|
||||
if not os.path.isfile(file_path):
|
||||
raise FileNotFoundError(f"FL Video Combine preview file does not exist: {file_path}")
|
||||
|
||||
token = secrets.token_urlsafe(24)
|
||||
with _preview_files_lock:
|
||||
_preview_files[token] = file_path
|
||||
while len(_preview_files) > _MAX_PREVIEW_FILES:
|
||||
_preview_files.popitem(last=False)
|
||||
return token
|
||||
|
||||
|
||||
def preview_file_for_token(token):
|
||||
if not isinstance(token, str) or not token:
|
||||
return None
|
||||
|
||||
with _preview_files_lock:
|
||||
file_path = _preview_files.get(token)
|
||||
if file_path is None:
|
||||
return None
|
||||
if not os.path.isfile(file_path):
|
||||
del _preview_files[token]
|
||||
return None
|
||||
_preview_files.move_to_end(token)
|
||||
return file_path
|
||||
|
||||
|
||||
def _parse_settings(render_settings):
|
||||
try:
|
||||
configured = json.loads(render_settings)
|
||||
except (TypeError, json.JSONDecodeError) as e:
|
||||
raise ValueError("FL Video Combine settings are not valid JSON.") from e
|
||||
if not isinstance(configured, dict):
|
||||
raise ValueError("FL Video Combine settings must be a JSON object.")
|
||||
|
||||
settings = DEFAULT_RENDER_SETTINGS.copy()
|
||||
settings.update(configured)
|
||||
|
||||
version = settings["version"]
|
||||
if not isinstance(version, int) or isinstance(version, bool) or version != 1:
|
||||
raise ValueError(f"FL Video Combine settings version {version} is unsupported.")
|
||||
|
||||
filename_prefix = settings["filename_prefix"]
|
||||
if not isinstance(filename_prefix, str) or not filename_prefix.strip():
|
||||
raise ValueError("FL Video Combine filename_prefix must be a non-empty string.")
|
||||
|
||||
frame_rate = settings["frame_rate"]
|
||||
if not isinstance(frame_rate, (int, float)) or isinstance(frame_rate, bool) or not 1 <= frame_rate <= 120:
|
||||
raise ValueError("FL Video Combine frame_rate must be between 1 and 120.")
|
||||
|
||||
if settings["format"] != "mp4":
|
||||
raise ValueError("FL Video Combine currently supports only MP4 output.")
|
||||
if settings["codec"] != "h264":
|
||||
raise ValueError("FL Video Combine currently supports only H.264 video.")
|
||||
|
||||
crf = settings["crf"]
|
||||
if not isinstance(crf, int) or isinstance(crf, bool) or not 0 <= crf <= 51:
|
||||
raise ValueError("FL Video Combine crf must be an integer between 0 and 51.")
|
||||
|
||||
bit_depth = settings["bit_depth"]
|
||||
if not isinstance(bit_depth, int) or isinstance(bit_depth, bool) or bit_depth not in (8, 10):
|
||||
raise ValueError("FL Video Combine bit_depth must be 8 or 10.")
|
||||
|
||||
audio_gain_db = settings["audio_gain_db"]
|
||||
if not isinstance(audio_gain_db, (int, float)) or isinstance(audio_gain_db, bool) or not -60 <= audio_gain_db <= 12:
|
||||
raise ValueError("FL Video Combine audio_gain_db must be between -60 and 12.")
|
||||
|
||||
output_directory = settings["output_directory"]
|
||||
if not isinstance(output_directory, str):
|
||||
raise ValueError("FL Video Combine output_directory must be a string.")
|
||||
output_directory = os.path.expanduser(output_directory.strip())
|
||||
if output_directory:
|
||||
if "\0" in output_directory:
|
||||
raise ValueError("FL Video Combine output_directory contains an invalid null character.")
|
||||
if not os.path.isabs(output_directory):
|
||||
raise ValueError("FL Video Combine output_directory must be an absolute path.")
|
||||
output_directory = os.path.normpath(output_directory)
|
||||
|
||||
for name in ("include_audio", "trim_video_to_audio", "save_output", "save_metadata"):
|
||||
if not isinstance(settings[name], bool):
|
||||
raise ValueError(f"FL Video Combine {name} must be true or false.")
|
||||
|
||||
settings["frame_rate"] = float(frame_rate)
|
||||
settings["audio_gain_db"] = float(audio_gain_db)
|
||||
settings["output_directory"] = output_directory
|
||||
return settings
|
||||
|
||||
|
||||
def _output_directory(settings):
|
||||
custom_directory = settings["output_directory"]
|
||||
if custom_directory:
|
||||
try:
|
||||
os.makedirs(custom_directory, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise ValueError(f"FL Video Combine could not create output_directory: {e}") from e
|
||||
if not os.path.isdir(custom_directory):
|
||||
raise ValueError("FL Video Combine output_directory is not a directory.")
|
||||
return custom_directory, "custom"
|
||||
if settings["save_output"]:
|
||||
return folder_paths.get_output_directory(), "output"
|
||||
return folder_paths.get_temp_directory(), "temp"
|
||||
|
||||
|
||||
def _prepare_images(images):
|
||||
if not isinstance(images, torch.Tensor) or images.ndim != 4 or images.shape[0] == 0:
|
||||
raise ValueError("FL Video Combine requires at least one image frame.")
|
||||
if images.shape[-1] not in (3, 4):
|
||||
raise ValueError("FL Video Combine supports RGB and RGBA image batches.")
|
||||
|
||||
source_height = int(images.shape[1])
|
||||
source_width = int(images.shape[2])
|
||||
if images.shape[-1] == 4:
|
||||
images = images[..., :3]
|
||||
|
||||
pad_width = source_width % 2
|
||||
pad_height = source_height % 2
|
||||
if pad_width or pad_height:
|
||||
images = images.movedim(-1, 1)
|
||||
images = F.pad(images, (0, pad_width, 0, pad_height), mode="replicate")
|
||||
images = images.movedim(1, -1)
|
||||
|
||||
return images, source_width, source_height
|
||||
|
||||
|
||||
def _prepare_audio(audio, include_audio, audio_gain_db):
|
||||
if audio is None or not include_audio:
|
||||
return None
|
||||
|
||||
waveform = audio["waveform"]
|
||||
if not isinstance(waveform, torch.Tensor) or waveform.ndim != 3:
|
||||
raise ValueError("FL Video Combine audio must have a [batch, channels, samples] waveform.")
|
||||
if waveform.shape[1] not in (1, 2, 6):
|
||||
raise ValueError("FL Video Combine supports mono, stereo, or 5.1 audio.")
|
||||
if audio_gain_db == 0:
|
||||
return audio
|
||||
|
||||
gain = 10 ** (audio_gain_db / 20)
|
||||
return {
|
||||
"waveform": waveform * gain,
|
||||
"sample_rate": audio["sample_rate"],
|
||||
}
|
||||
|
||||
|
||||
def _trim_images_to_audio(images, audio, frame_rate, enabled):
|
||||
if audio is None or not enabled:
|
||||
return images
|
||||
|
||||
audio_frame_count = max(1, round(audio["waveform"].shape[-1] / audio["sample_rate"] * frame_rate))
|
||||
if audio_frame_count >= images.shape[0]:
|
||||
return images
|
||||
return images[:audio_frame_count]
|
||||
|
||||
|
||||
def _build_metadata(prompt, extra_pnginfo, enabled):
|
||||
if not enabled or args.disable_metadata:
|
||||
return None
|
||||
|
||||
metadata = {}
|
||||
if extra_pnginfo is not None:
|
||||
metadata.update(extra_pnginfo)
|
||||
if prompt is not None:
|
||||
metadata["prompt"] = prompt
|
||||
return metadata or None
|
||||
|
||||
|
||||
class FL_VideoCombine:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"render_settings": ("STRING", {"default": DEFAULT_SETTINGS_JSON, "multiline": False}),
|
||||
},
|
||||
"optional": {
|
||||
"audio": ("AUDIO",),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("filepath",)
|
||||
FUNCTION = "combine_video"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🏵️Fill Nodes/Video"
|
||||
DESCRIPTION = "Combines an image batch and optional audio into an MP4 video."
|
||||
|
||||
def combine_video(self, images, render_settings=DEFAULT_SETTINGS_JSON, audio=None, prompt=None, extra_pnginfo=None):
|
||||
settings = _parse_settings(render_settings)
|
||||
images, source_width, source_height = _prepare_images(images)
|
||||
encoded_height = int(images.shape[1])
|
||||
encoded_width = int(images.shape[2])
|
||||
prepared_audio = _prepare_audio(audio, settings["include_audio"], settings["audio_gain_db"])
|
||||
images = _trim_images_to_audio(
|
||||
images,
|
||||
prepared_audio,
|
||||
settings["frame_rate"],
|
||||
settings["trim_video_to_audio"],
|
||||
)
|
||||
|
||||
output_dir, output_type = _output_directory(settings)
|
||||
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
|
||||
settings["filename_prefix"],
|
||||
output_dir,
|
||||
encoded_width,
|
||||
encoded_height,
|
||||
)
|
||||
file = f"{filename}_{counter:05}_.mp4"
|
||||
output_path = os.path.join(full_output_folder, file)
|
||||
|
||||
video = InputImpl.VideoFromComponents(
|
||||
Types.VideoComponents(
|
||||
images=images,
|
||||
audio=prepared_audio,
|
||||
frame_rate=Fraction(round(settings["frame_rate"] * 1000), 1000),
|
||||
),
|
||||
bit_depth=settings["bit_depth"],
|
||||
)
|
||||
video.save_to(
|
||||
output_path,
|
||||
format=Types.VideoContainer.MP4,
|
||||
codec=Types.VideoCodec.H264,
|
||||
metadata=_build_metadata(prompt, extra_pnginfo, settings["save_metadata"]),
|
||||
crf=settings["crf"],
|
||||
)
|
||||
|
||||
frame_count = int(images.shape[0])
|
||||
preview = {
|
||||
"filename": file,
|
||||
"subfolder": subfolder,
|
||||
"type": output_type,
|
||||
"frame_count": frame_count,
|
||||
"frame_rate": settings["frame_rate"],
|
||||
"duration": frame_count / settings["frame_rate"],
|
||||
"source_width": source_width,
|
||||
"source_height": source_height,
|
||||
"encoded_width": encoded_width,
|
||||
"encoded_height": encoded_height,
|
||||
"has_audio": prepared_audio is not None,
|
||||
"bit_depth": settings["bit_depth"],
|
||||
}
|
||||
if output_type == "custom":
|
||||
token = register_preview_file(output_path)
|
||||
preview["preview_url"] = f"/fl/video-combine/preview/{token}"
|
||||
return {
|
||||
"ui": {"fl_video_combine": [preview]},
|
||||
"result": (output_path,),
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui_fill-nodes"
|
||||
description = "Fill-Nodes is a versatile collection of custom nodes for ComfyUI that extends functionality across multiple domains. Features include advanced image processing (pixelation, slicing, masking), visual effects generation (glitch, halftone, pixel art), comprehensive file handling (PDF creation/extraction, Google Drive integration), AI model interfaces (GPT, DALL-E, Hugging Face), utility nodes for workflow enhancement, and specialized tools for video processing, captioning, and batch operations. The pack provides both practical workflow solutions and creative tools within a unified node collection."
|
||||
version = "2.9.2"
|
||||
version = "2.25.0"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["librosa", "sounddevice", "glitch_this", "PyOpenGL", "glfw", "scipy>=1.13.1", "requests", "aiohttp", "moviepy", "matplotlib", "reportlab", "openai", "PyPDF2", "pdf2image", "PyMuPDF", "reportlab", "PyPDF2", "ollama", "kornia", "opencv-python", "gdown", "open_clip_torch", "google-genai"]
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from . import load_video
|
||||
from . import video_combine
|
||||
|
||||
|
||||
__all__ = ["load_video", "video_combine"]
|
||||
@@ -0,0 +1,17 @@
|
||||
import asyncio
|
||||
|
||||
import av
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
from ..nodes.video.FL_LoadVideo import probe_video, resolve_video_path
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/fl/load-video/info")
|
||||
async def load_video_info(request):
|
||||
try:
|
||||
path = resolve_video_path(request.query.get("filename", ""))
|
||||
info = await asyncio.to_thread(probe_video, path)
|
||||
except (OSError, ValueError, av.error.FFmpegError) as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
return web.json_response(info)
|
||||
@@ -0,0 +1,12 @@
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
from ..nodes.video.FL_VideoCombine import preview_file_for_token
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/fl/video-combine/preview/{token}")
|
||||
async def video_combine_preview(request):
|
||||
file_path = preview_file_for_token(request.match_info["token"])
|
||||
if file_path is None:
|
||||
raise web.HTTPNotFound()
|
||||
return web.FileResponse(file_path)
|
||||
@@ -0,0 +1,326 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from fractions import Fraction
|
||||
from unittest import mock
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
MODULE_PATH = pathlib.Path(__file__).parents[1] / "nodes" / "video" / "FL_LoadVideo.py"
|
||||
SPEC = importlib.util.spec_from_file_location("fl_load_video_tests", MODULE_PATH)
|
||||
load_video = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(load_video)
|
||||
|
||||
|
||||
def settings(**updates):
|
||||
configured = load_video.DEFAULT_LOAD_SETTINGS.copy()
|
||||
configured.update(updates)
|
||||
return configured
|
||||
|
||||
|
||||
def probe(**updates):
|
||||
result = {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"duration": 10.0,
|
||||
"frame_rate": 30.0,
|
||||
"frame_count": 300,
|
||||
"frame_count_estimated": False,
|
||||
"bit_depth": 8,
|
||||
"codec": "h264",
|
||||
"container": "mov,mp4",
|
||||
"has_audio": True,
|
||||
"size": 1000,
|
||||
}
|
||||
result.update(updates)
|
||||
return result
|
||||
|
||||
|
||||
class LoadVideoSettingsTests(unittest.TestCase):
|
||||
def test_defaults_parse(self):
|
||||
parsed = load_video._parse_settings(load_video.DEFAULT_SETTINGS_JSON)
|
||||
|
||||
self.assertEqual(parsed, load_video.DEFAULT_LOAD_SETTINGS)
|
||||
self.assertIsInstance(parsed["start_time"], float)
|
||||
self.assertIsInstance(parsed["target_fps"], float)
|
||||
|
||||
def test_missing_fields_use_defaults(self):
|
||||
parsed = load_video._parse_settings('{"version":1,"frame_load_cap":81}')
|
||||
|
||||
self.assertEqual(parsed["frame_load_cap"], 81)
|
||||
self.assertEqual(parsed["sample_mode"], "source")
|
||||
self.assertEqual(parsed["resize_mode"], "original")
|
||||
|
||||
def test_invalid_json_and_version_fail(self):
|
||||
for value in ("{", "[]", "null"):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
load_video._parse_settings(value)
|
||||
with self.assertRaisesRegex(ValueError, "version 2 is unsupported"):
|
||||
load_video._parse_settings('{"version":2}')
|
||||
|
||||
def test_invalid_values_fail(self):
|
||||
cases = {
|
||||
"start_time": -1,
|
||||
"end_time": -1,
|
||||
"target_fps": 121,
|
||||
"sample_mode": "random",
|
||||
"select_every_nth": 0,
|
||||
"frame_load_cap": -1,
|
||||
"resize_mode": "stretch",
|
||||
"width": -1,
|
||||
"height": 20000,
|
||||
"include_audio": "yes",
|
||||
}
|
||||
for name, value in cases.items():
|
||||
with self.subTest(name=name), self.assertRaises(ValueError):
|
||||
load_video._parse_settings(json.dumps(settings(**{name: value})))
|
||||
|
||||
def test_trim_and_resize_combinations_are_validated(self):
|
||||
with self.assertRaisesRegex(ValueError, "greater than start_time"):
|
||||
load_video._parse_settings(json.dumps(settings(start_time=4, end_time=3)))
|
||||
with self.assertRaisesRegex(ValueError, "requires a width or height"):
|
||||
load_video._parse_settings(json.dumps(settings(resize_mode="fit")))
|
||||
with self.assertRaisesRegex(ValueError, "requires both width and height"):
|
||||
load_video._parse_settings(json.dumps(settings(resize_mode="crop", width=512)))
|
||||
|
||||
|
||||
class LoadVideoPathTests(unittest.TestCase):
|
||||
def test_video_must_be_inside_input_directory(self):
|
||||
with tempfile.TemporaryDirectory() as input_directory, tempfile.TemporaryDirectory() as outside:
|
||||
inside = pathlib.Path(input_directory) / "clip.mp4"
|
||||
inside.touch()
|
||||
outside_file = pathlib.Path(outside) / "clip.mp4"
|
||||
outside_file.touch()
|
||||
|
||||
with mock.patch.object(load_video.folder_paths, "get_input_directory", return_value=input_directory):
|
||||
self.assertEqual(load_video.resolve_video_path("clip.mp4"), inside.resolve())
|
||||
with self.assertRaisesRegex(ValueError, "inside the ComfyUI input"):
|
||||
load_video.resolve_video_path(str(outside_file))
|
||||
|
||||
def test_available_files_are_recursive_and_supported(self):
|
||||
with tempfile.TemporaryDirectory() as input_directory:
|
||||
root = pathlib.Path(input_directory)
|
||||
(root / "nested").mkdir()
|
||||
(root / "nested" / "clip.MOV").touch()
|
||||
(root / "notes.txt").touch()
|
||||
|
||||
with mock.patch.object(load_video.folder_paths, "get_input_directory", return_value=input_directory):
|
||||
files = load_video.available_video_files()
|
||||
|
||||
self.assertEqual(files, ["nested/clip.MOV"])
|
||||
|
||||
|
||||
class LoadVideoPlanningTests(unittest.TestCase):
|
||||
def test_fit_and_crop_dimensions(self):
|
||||
fit = load_video._target_dimensions(1920, 1080, settings(resize_mode="fit", width=512, height=512))
|
||||
crop = load_video._target_dimensions(1920, 1080, settings(resize_mode="crop", width=512, height=512))
|
||||
|
||||
self.assertEqual(fit, (512, 288))
|
||||
self.assertEqual(crop, (512, 512))
|
||||
|
||||
def test_frame_cap_shortens_decode_window(self):
|
||||
plan = load_video.build_load_plan(
|
||||
probe(),
|
||||
settings(sample_mode="target_fps", target_fps=24, frame_load_cap=48),
|
||||
)
|
||||
|
||||
self.assertEqual(plan["effective_fps"], 24)
|
||||
self.assertEqual(plan["estimated_output_frames"], 48)
|
||||
self.assertEqual(plan["decode_duration"], 2)
|
||||
self.assertEqual(plan["estimated_source_frames"], 60)
|
||||
|
||||
def test_trim_is_clamped_to_source_duration(self):
|
||||
plan = load_video.build_load_plan(probe(duration=5), settings(start_time=2, end_time=8))
|
||||
|
||||
self.assertEqual(plan["start_time"], 2)
|
||||
self.assertEqual(plan["end_time"], 5)
|
||||
self.assertEqual(plan["selected_duration"], 3)
|
||||
|
||||
def test_start_beyond_end_fails(self):
|
||||
with self.assertRaisesRegex(ValueError, "beyond the end"):
|
||||
load_video.build_load_plan(probe(duration=5), settings(start_time=5))
|
||||
|
||||
def test_memory_check_has_actionable_error(self):
|
||||
plan = {"estimated_peak_bytes": 900}
|
||||
memory = types.SimpleNamespace(available=1000)
|
||||
|
||||
with (
|
||||
mock.patch.object(load_video.psutil, "virtual_memory", return_value=memory),
|
||||
self.assertRaisesRegex(RuntimeError, "Reduce the range"),
|
||||
):
|
||||
load_video._check_memory(plan)
|
||||
|
||||
|
||||
class LoadVideoProcessingTests(unittest.TestCase):
|
||||
def test_target_fps_selects_even_frames(self):
|
||||
images = torch.arange(6, dtype=torch.float32).reshape(6, 1, 1, 1)
|
||||
|
||||
sampled, fps = load_video._sample_images(
|
||||
images,
|
||||
30,
|
||||
settings(sample_mode="target_fps", target_fps=15),
|
||||
)
|
||||
|
||||
self.assertEqual(fps, 15)
|
||||
self.assertEqual(sampled.flatten().tolist(), [0, 2, 4])
|
||||
|
||||
def test_every_nth_and_cap_apply_in_order(self):
|
||||
images = torch.arange(10, dtype=torch.float32).reshape(10, 1, 1, 1)
|
||||
|
||||
sampled, fps = load_video._sample_images(
|
||||
images,
|
||||
30,
|
||||
settings(sample_mode="every_nth", select_every_nth=3, frame_load_cap=2),
|
||||
)
|
||||
|
||||
self.assertEqual(fps, 10)
|
||||
self.assertEqual(sampled.flatten().tolist(), [0, 3])
|
||||
|
||||
def test_audio_is_trimmed_without_mutating_source(self):
|
||||
waveform = torch.arange(20, dtype=torch.float32).reshape(1, 1, 20)
|
||||
audio = {"waveform": waveform, "sample_rate": 10}
|
||||
|
||||
trimmed = load_video._trim_audio(audio, 0.6, True)
|
||||
|
||||
self.assertEqual(trimmed["waveform"].shape[-1], 6)
|
||||
self.assertIsNot(trimmed["waveform"], waveform)
|
||||
self.assertEqual(waveform.shape[-1], 20)
|
||||
self.assertIsNone(load_video._trim_audio(audio, 1, False))
|
||||
|
||||
def test_resize_fit_and_crop(self):
|
||||
images = torch.rand((2, 6, 10, 3))
|
||||
|
||||
fit = load_video._resize_images(images, settings(resize_mode="fit", width=5, height=5))
|
||||
crop = load_video._resize_images(images, settings(resize_mode="crop", width=4, height=4))
|
||||
|
||||
self.assertEqual(fit.shape, (2, 3, 5, 3))
|
||||
self.assertEqual(crop.shape, (2, 4, 4, 3))
|
||||
|
||||
|
||||
class LoadVideoExecutionTests(unittest.TestCase):
|
||||
def test_execution_returns_aligned_standard_outputs(self):
|
||||
with tempfile.TemporaryDirectory() as input_directory:
|
||||
path = pathlib.Path(input_directory) / "nested" / "clip.mp4"
|
||||
path.parent.mkdir()
|
||||
path.touch()
|
||||
images = torch.arange(6 * 2 * 4 * 3, dtype=torch.float32).reshape(6, 2, 4, 3)
|
||||
audio = {"waveform": torch.ones((1, 2, 48000)), "sample_rate": 48000}
|
||||
components = types.SimpleNamespace(
|
||||
images=images,
|
||||
audio=audio,
|
||||
frame_rate=Fraction(30, 1),
|
||||
metadata={"title": "test"},
|
||||
)
|
||||
source = mock.Mock()
|
||||
source.get_components.return_value = components
|
||||
native_video = mock.Mock()
|
||||
configured = settings(
|
||||
sample_mode="every_nth",
|
||||
select_every_nth=2,
|
||||
frame_load_cap=2,
|
||||
resize_mode="crop",
|
||||
width=2,
|
||||
height=2,
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch.object(load_video.folder_paths, "get_input_directory", return_value=input_directory),
|
||||
mock.patch.object(load_video, "probe_video", return_value=probe(width=4, height=2, duration=0.2)),
|
||||
mock.patch.object(load_video, "_check_memory"),
|
||||
mock.patch.object(load_video.InputImpl, "VideoFromFile", return_value=source) as create_source,
|
||||
mock.patch.object(load_video.InputImpl, "VideoFromComponents", return_value=native_video) as create_video,
|
||||
):
|
||||
result = load_video.FL_LoadVideo().load_video("nested/clip.mp4", json.dumps(configured))
|
||||
|
||||
create_source.assert_called_once_with(str(path.resolve()), start_time=0, duration=4 / 30)
|
||||
loaded_images, loaded_audio, returned_video, fps, frame_count = result["result"]
|
||||
self.assertEqual(loaded_images.shape, (2, 2, 2, 3))
|
||||
self.assertEqual(loaded_audio["waveform"].shape[-1], 6400)
|
||||
self.assertIs(returned_video, native_video)
|
||||
self.assertEqual(fps, 15)
|
||||
self.assertEqual(frame_count, 2)
|
||||
|
||||
video_components = create_video.call_args.args[0]
|
||||
self.assertIs(video_components.images, loaded_images)
|
||||
self.assertIs(video_components.audio, loaded_audio)
|
||||
self.assertEqual(float(video_components.frame_rate), 15)
|
||||
self.assertEqual(video_components.metadata, {"title": "test"})
|
||||
self.assertEqual(create_video.call_args.kwargs["bit_depth"], 8)
|
||||
|
||||
preview = result["ui"]["fl_load_video"][0]
|
||||
self.assertEqual(preview["filename"], "clip.mp4")
|
||||
self.assertEqual(preview["subfolder"], "nested")
|
||||
self.assertEqual(preview["type"], "input")
|
||||
self.assertEqual(preview["loaded_frame_count"], 2)
|
||||
self.assertEqual(preview["loaded_fps"], 15)
|
||||
self.assertTrue(preview["has_audio"])
|
||||
|
||||
def test_change_fingerprint_uses_metadata_not_file_hash(self):
|
||||
with tempfile.TemporaryDirectory() as input_directory:
|
||||
path = pathlib.Path(input_directory) / "clip.mp4"
|
||||
path.write_bytes(b"video")
|
||||
stat = path.stat()
|
||||
|
||||
with mock.patch.object(load_video.folder_paths, "get_input_directory", return_value=input_directory):
|
||||
fingerprint = load_video.FL_LoadVideo.IS_CHANGED("clip.mp4")
|
||||
|
||||
self.assertEqual(fingerprint, f"{stat.st_mtime_ns}:{stat.st_size}")
|
||||
|
||||
|
||||
class LoadVideoFrontendTests(unittest.TestCase):
|
||||
def test_frontend_contains_the_full_settings_contract_and_media_flow(self):
|
||||
script = (pathlib.Path(__file__).parents[1] / "web" / "nodes" / "video" / "FL_LoadVideo.js").read_text(encoding="utf-8")
|
||||
|
||||
for name in load_video.DEFAULT_LOAD_SETTINGS:
|
||||
with self.subTest(setting=name):
|
||||
self.assertIn(f"{name}:", script)
|
||||
for behavior in (
|
||||
'data-role="drop-zone"',
|
||||
'data-role="video"',
|
||||
'data-role="settings-menu"',
|
||||
'data-role="source-action"',
|
||||
'data-role="sample-value"',
|
||||
'data-role="audio-toggle"',
|
||||
'data-role="trim-timeline"',
|
||||
'data-role="trim-canvas"',
|
||||
'beginTrimPointer(event)',
|
||||
'renderTrimTimeline()',
|
||||
'this.updateSetting("start_time"',
|
||||
'this.updateSetting("end_time"',
|
||||
'data-role="trim-frame-label"',
|
||||
'selected frames',
|
||||
'syncFrameRange(name)',
|
||||
'effectiveFrameRate()',
|
||||
'this.settings.end_time = end >= bounds.duration',
|
||||
'flvl-range-row',
|
||||
'flvl-sampling-group',
|
||||
'flvl-output-group',
|
||||
'grid-template-rows: 29px minmax(0, 1fr) 40px 78px',
|
||||
'updateSourceAction(hasSource)',
|
||||
'"/upload/image"',
|
||||
"/fl/load-video/info?",
|
||||
'api.apiURL(`/view?',
|
||||
'message?.fl_load_video?.[0]',
|
||||
'MIN_NODE_WIDTH = 420',
|
||||
'MIN_NODE_HEIGHT = 440',
|
||||
):
|
||||
with self.subTest(behavior=behavior):
|
||||
self.assertIn(behavior, script)
|
||||
|
||||
menu_index = script.index('<div class="flvl-menu" data-role="settings-menu"')
|
||||
for visible_control in (
|
||||
'data-setting="resize_mode"',
|
||||
'data-setting="width"',
|
||||
'data-setting="height"',
|
||||
'data-setting="include_audio"',
|
||||
):
|
||||
with self.subTest(visible_control=visible_control):
|
||||
self.assertLess(script.index(visible_control), menu_index)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,304 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
MODULE_PATH = pathlib.Path(__file__).parents[1] / "nodes" / "video" / "FL_VideoCombine.py"
|
||||
SPEC = importlib.util.spec_from_file_location("fl_video_combine_tests", MODULE_PATH)
|
||||
video_combine = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(video_combine)
|
||||
|
||||
|
||||
class VideoCombineSettingsTests(unittest.TestCase):
|
||||
def test_defaults_parse(self):
|
||||
settings = video_combine._parse_settings(video_combine.DEFAULT_SETTINGS_JSON)
|
||||
|
||||
self.assertEqual(settings, video_combine.DEFAULT_RENDER_SETTINGS)
|
||||
self.assertIsInstance(settings["frame_rate"], float)
|
||||
self.assertIsInstance(settings["audio_gain_db"], float)
|
||||
|
||||
def test_missing_fields_use_defaults(self):
|
||||
settings = video_combine._parse_settings('{"version":1,"frame_rate":30}')
|
||||
|
||||
self.assertEqual(settings["frame_rate"], 30.0)
|
||||
self.assertEqual(settings["filename_prefix"], "FillVideo")
|
||||
self.assertEqual(settings["crf"], 19)
|
||||
self.assertEqual(settings["output_directory"], "")
|
||||
|
||||
def test_invalid_json_and_non_object_fail(self):
|
||||
for value in ("{", "[]", "null"):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
video_combine._parse_settings(value)
|
||||
|
||||
def test_unsupported_version_fails(self):
|
||||
with self.assertRaisesRegex(ValueError, "version 2 is unsupported"):
|
||||
video_combine._parse_settings('{"version":2}')
|
||||
|
||||
def test_invalid_settings_fail(self):
|
||||
cases = {
|
||||
"frame_rate": 0,
|
||||
"format": "webm",
|
||||
"codec": "hevc",
|
||||
"crf": 52,
|
||||
"bit_depth": 12,
|
||||
"audio_gain_db": 13,
|
||||
"output_directory": 42,
|
||||
"include_audio": "yes",
|
||||
"trim_video_to_audio": "yes",
|
||||
"save_output": 1,
|
||||
"save_metadata": None,
|
||||
}
|
||||
for name, value in cases.items():
|
||||
configured = video_combine.DEFAULT_RENDER_SETTINGS.copy()
|
||||
configured[name] = value
|
||||
with self.subTest(name=name), self.assertRaises(ValueError):
|
||||
video_combine._parse_settings(json.dumps(configured))
|
||||
|
||||
def test_custom_output_directory_must_be_absolute(self):
|
||||
with self.assertRaisesRegex(ValueError, "must be an absolute path"):
|
||||
video_combine._parse_settings('{"version":1,"output_directory":"relative/videos"}')
|
||||
|
||||
def test_custom_output_directory_is_normalized(self):
|
||||
directory = os.path.abspath(os.path.join("exports", "..", "video_exports"))
|
||||
settings = video_combine._parse_settings(json.dumps({
|
||||
"version": 1,
|
||||
"output_directory": directory,
|
||||
}))
|
||||
|
||||
self.assertEqual(settings["output_directory"], os.path.normpath(directory))
|
||||
|
||||
|
||||
class VideoCombineOutputDirectoryTests(unittest.TestCase):
|
||||
def test_custom_output_directory_is_created(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
directory = os.path.join(root, "nested", "exports")
|
||||
settings = video_combine.DEFAULT_RENDER_SETTINGS.copy()
|
||||
settings["output_directory"] = directory
|
||||
|
||||
output_directory, output_type = video_combine._output_directory(settings)
|
||||
|
||||
self.assertEqual(output_directory, directory)
|
||||
self.assertEqual(output_type, "custom")
|
||||
self.assertTrue(os.path.isdir(directory))
|
||||
|
||||
def test_preview_tokens_only_resolve_existing_registered_files(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp4") as video:
|
||||
token = video_combine.register_preview_file(video.name)
|
||||
self.assertEqual(video_combine.preview_file_for_token(token), os.path.abspath(video.name))
|
||||
|
||||
self.assertIsNone(video_combine.preview_file_for_token(token))
|
||||
self.assertIsNone(video_combine.preview_file_for_token("unknown"))
|
||||
|
||||
|
||||
class VideoCombineImageTests(unittest.TestCase):
|
||||
def test_rgba_odd_dimensions_are_rgb_and_edge_padded(self):
|
||||
images = torch.arange(1 * 3 * 5 * 4, dtype=torch.float32).reshape(1, 3, 5, 4)
|
||||
|
||||
prepared, source_width, source_height = video_combine._prepare_images(images)
|
||||
|
||||
self.assertEqual((source_width, source_height), (5, 3))
|
||||
self.assertEqual(prepared.shape, (1, 4, 6, 3))
|
||||
torch.testing.assert_close(prepared[:, :3, :5], images[..., :3])
|
||||
torch.testing.assert_close(prepared[:, :3, 5], images[:, :, 4, :3])
|
||||
torch.testing.assert_close(prepared[:, 3], prepared[:, 2])
|
||||
self.assertEqual(images.shape, (1, 3, 5, 4))
|
||||
|
||||
def test_even_rgb_input_is_reused(self):
|
||||
images = torch.zeros((2, 4, 6, 3))
|
||||
|
||||
prepared, _, _ = video_combine._prepare_images(images)
|
||||
|
||||
self.assertIs(prepared, images)
|
||||
|
||||
def test_empty_and_invalid_channel_inputs_fail(self):
|
||||
for images in (torch.empty((0, 4, 4, 3)), torch.empty((1, 4, 4, 1))):
|
||||
with self.subTest(shape=tuple(images.shape)), self.assertRaises(ValueError):
|
||||
video_combine._prepare_images(images)
|
||||
|
||||
|
||||
class VideoCombineAudioTests(unittest.TestCase):
|
||||
def test_excluded_audio_is_not_read(self):
|
||||
class UnreadableAudio:
|
||||
def __getitem__(self, key):
|
||||
raise AssertionError(f"Unexpected audio access: {key}")
|
||||
|
||||
self.assertIsNone(video_combine._prepare_audio(UnreadableAudio(), False, 6))
|
||||
|
||||
def test_zero_gain_reuses_audio(self):
|
||||
audio = {
|
||||
"waveform": torch.ones((1, 2, 8)),
|
||||
"sample_rate": 48000,
|
||||
}
|
||||
|
||||
prepared = video_combine._prepare_audio(audio, True, 0)
|
||||
|
||||
self.assertIs(prepared, audio)
|
||||
|
||||
def test_gain_changes_copy_without_mutating_input(self):
|
||||
waveform = torch.ones((1, 1, 8))
|
||||
audio = {"waveform": waveform, "sample_rate": 44100}
|
||||
|
||||
prepared = video_combine._prepare_audio(audio, True, -6)
|
||||
|
||||
self.assertIsNot(prepared, audio)
|
||||
self.assertEqual(prepared["sample_rate"], 44100)
|
||||
torch.testing.assert_close(prepared["waveform"], waveform * (10 ** (-6 / 20)))
|
||||
torch.testing.assert_close(audio["waveform"], waveform)
|
||||
|
||||
def test_unsupported_channels_fail(self):
|
||||
audio = {
|
||||
"waveform": torch.zeros((1, 4, 8)),
|
||||
"sample_rate": 48000,
|
||||
}
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "mono, stereo, or 5.1"):
|
||||
video_combine._prepare_audio(audio, True, 0)
|
||||
|
||||
def test_audio_duration_trims_extra_video_frames(self):
|
||||
images = torch.zeros((10, 4, 6, 3))
|
||||
audio = {
|
||||
"waveform": torch.zeros((1, 2, 5)),
|
||||
"sample_rate": 24,
|
||||
}
|
||||
|
||||
trimmed = video_combine._trim_images_to_audio(images, audio, 24, True)
|
||||
|
||||
self.assertEqual(trimmed.shape[0], 5)
|
||||
self.assertEqual(trimmed.data_ptr(), images.data_ptr())
|
||||
|
||||
def test_audio_trim_is_inactive_when_disabled_or_audio_is_longer(self):
|
||||
images = torch.zeros((10, 4, 6, 3))
|
||||
short_audio = {
|
||||
"waveform": torch.zeros((1, 2, 5)),
|
||||
"sample_rate": 24,
|
||||
}
|
||||
long_audio = {
|
||||
"waveform": torch.zeros((1, 2, 20)),
|
||||
"sample_rate": 24,
|
||||
}
|
||||
|
||||
self.assertIs(video_combine._trim_images_to_audio(images, short_audio, 24, False), images)
|
||||
self.assertIs(video_combine._trim_images_to_audio(images, None, 24, True), images)
|
||||
self.assertIs(video_combine._trim_images_to_audio(images, long_audio, 24, True), images)
|
||||
|
||||
|
||||
class VideoCombineExecutionTests(unittest.TestCase):
|
||||
def test_execution_builds_native_video_and_returns_preview(self):
|
||||
settings = video_combine.DEFAULT_RENDER_SETTINGS.copy()
|
||||
settings.update({
|
||||
"filename_prefix": "clips/test",
|
||||
"frame_rate": 12,
|
||||
"crf": 23,
|
||||
"bit_depth": 10,
|
||||
"audio_gain_db": -6,
|
||||
"save_output": False,
|
||||
})
|
||||
images = torch.ones((3, 5, 7, 4))
|
||||
audio = {
|
||||
"waveform": torch.ones((1, 2, 48000)),
|
||||
"sample_rate": 48000,
|
||||
}
|
||||
native_video = mock.Mock()
|
||||
|
||||
with (
|
||||
mock.patch.object(video_combine.folder_paths, "get_temp_directory", return_value="D:\\temp"),
|
||||
mock.patch.object(
|
||||
video_combine.folder_paths,
|
||||
"get_save_image_path",
|
||||
return_value=("D:\\temp\\clips", "test", 7, "clips", "clips/test"),
|
||||
) as save_path,
|
||||
mock.patch.object(video_combine.InputImpl, "VideoFromComponents", return_value=native_video) as create_video,
|
||||
mock.patch.object(video_combine.args, "disable_metadata", False),
|
||||
):
|
||||
result = video_combine.FL_VideoCombine().combine_video(
|
||||
images,
|
||||
json.dumps(settings),
|
||||
audio=audio,
|
||||
prompt={"1": {"class_type": "Test"}},
|
||||
extra_pnginfo={"workflow": {"nodes": []}},
|
||||
)
|
||||
|
||||
save_path.assert_called_once_with("clips/test", "D:\\temp", 8, 6)
|
||||
components = create_video.call_args.args[0]
|
||||
self.assertEqual(components.images.shape, (3, 6, 8, 3))
|
||||
self.assertEqual(float(components.frame_rate), 12.0)
|
||||
self.assertEqual(components.audio["sample_rate"], 48000)
|
||||
torch.testing.assert_close(components.audio["waveform"], audio["waveform"] * (10 ** (-6 / 20)))
|
||||
self.assertEqual(create_video.call_args.kwargs["bit_depth"], 10)
|
||||
|
||||
output_path = os.path.join("D:\\temp\\clips", "test_00007_.mp4")
|
||||
native_video.save_to.assert_called_once_with(
|
||||
output_path,
|
||||
format=video_combine.Types.VideoContainer.MP4,
|
||||
codec=video_combine.Types.VideoCodec.H264,
|
||||
metadata={
|
||||
"workflow": {"nodes": []},
|
||||
"prompt": {"1": {"class_type": "Test"}},
|
||||
},
|
||||
crf=23,
|
||||
)
|
||||
self.assertEqual(result["result"], (output_path,))
|
||||
preview = result["ui"]["fl_video_combine"][0]
|
||||
self.assertEqual(preview["filename"], "test_00007_.mp4")
|
||||
self.assertEqual(preview["type"], "temp")
|
||||
self.assertEqual(preview["frame_count"], 3)
|
||||
self.assertEqual(preview["duration"], 0.25)
|
||||
self.assertEqual((preview["source_width"], preview["source_height"]), (7, 5))
|
||||
self.assertEqual((preview["encoded_width"], preview["encoded_height"]), (8, 6))
|
||||
self.assertTrue(preview["has_audio"])
|
||||
|
||||
def test_custom_directory_overrides_default_destination_and_uses_token_preview(self):
|
||||
with tempfile.TemporaryDirectory() as output_directory:
|
||||
settings = video_combine.DEFAULT_RENDER_SETTINGS.copy()
|
||||
settings.update({
|
||||
"filename_prefix": "custom",
|
||||
"output_directory": output_directory,
|
||||
"save_output": False,
|
||||
})
|
||||
images = torch.ones((2, 4, 6, 3))
|
||||
native_video = mock.Mock()
|
||||
output_path = os.path.join(output_directory, "custom_00003_.mp4")
|
||||
|
||||
with (
|
||||
mock.patch.object(video_combine.folder_paths, "get_output_directory") as default_output,
|
||||
mock.patch.object(video_combine.folder_paths, "get_temp_directory") as temp_output,
|
||||
mock.patch.object(
|
||||
video_combine.folder_paths,
|
||||
"get_save_image_path",
|
||||
return_value=(output_directory, "custom", 3, "", "custom"),
|
||||
) as save_path,
|
||||
mock.patch.object(video_combine.InputImpl, "VideoFromComponents", return_value=native_video),
|
||||
mock.patch.object(video_combine, "register_preview_file", return_value="preview-token") as register_preview,
|
||||
):
|
||||
result = video_combine.FL_VideoCombine().combine_video(images, json.dumps(settings))
|
||||
|
||||
default_output.assert_not_called()
|
||||
temp_output.assert_not_called()
|
||||
save_path.assert_called_once_with("custom", output_directory, 6, 4)
|
||||
register_preview.assert_called_once_with(output_path)
|
||||
self.assertEqual(result["result"], (output_path,))
|
||||
preview = result["ui"]["fl_video_combine"][0]
|
||||
self.assertEqual(preview["type"], "custom")
|
||||
self.assertEqual(preview["preview_url"], "/fl/video-combine/preview/preview-token")
|
||||
|
||||
def test_disabled_metadata_returns_none(self):
|
||||
with mock.patch.object(video_combine.args, "disable_metadata", False):
|
||||
metadata = video_combine._build_metadata({"prompt": True}, {"workflow": True}, False)
|
||||
|
||||
self.assertIsNone(metadata)
|
||||
|
||||
def test_global_metadata_disable_wins(self):
|
||||
with mock.patch.object(video_combine.args, "disable_metadata", True):
|
||||
metadata = video_combine._build_metadata({"prompt": True}, {"workflow": True}, True)
|
||||
|
||||
self.assertIsNone(metadata)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,842 @@
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { api } from "../../../../scripts/api.js";
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
version: 1,
|
||||
filename_prefix: "FillVideo",
|
||||
frame_rate: 24,
|
||||
format: "mp4",
|
||||
codec: "h264",
|
||||
crf: 19,
|
||||
bit_depth: 8,
|
||||
include_audio: true,
|
||||
trim_video_to_audio: false,
|
||||
audio_gain_db: 0,
|
||||
output_directory: "",
|
||||
save_output: true,
|
||||
save_metadata: true,
|
||||
};
|
||||
|
||||
const MIN_NODE_WIDTH = 420;
|
||||
const MIN_NODE_HEIGHT = 360;
|
||||
const MIN_PANEL_HEIGHT = 280;
|
||||
|
||||
const STYLES = `
|
||||
.flvc-panel {
|
||||
--flvc-accent: #8b5cf6;
|
||||
--flvc-border: var(--border-color, #343741);
|
||||
--flvc-control: var(--comfy-input-bg, #24262d);
|
||||
--flvc-muted: var(--descrip-text, #979cab);
|
||||
background: var(--comfy-menu-bg, #18191e);
|
||||
border: 1px solid var(--flvc-border);
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
color: var(--input-text, #f4f4f5);
|
||||
display: grid;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 11px;
|
||||
gap: 6px;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 6px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.flvc-panel * { box-sizing: border-box; }
|
||||
.flvc-toolbar {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns:
|
||||
minmax(84px, 1.6fr)
|
||||
minmax(43px, .55fr)
|
||||
minmax(43px, .55fr)
|
||||
minmax(54px, .7fr)
|
||||
minmax(46px, .62fr)
|
||||
minmax(58px, .75fr)
|
||||
28px;
|
||||
min-width: 0;
|
||||
}
|
||||
.flvc-param {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.flvc-param-label {
|
||||
color: var(--flvc-muted);
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .05em;
|
||||
line-height: 9px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.flvc-param input[type="text"],
|
||||
.flvc-param input[type="number"],
|
||||
.flvc-param select {
|
||||
appearance: none;
|
||||
background: var(--flvc-control);
|
||||
border: 1px solid var(--flvc-border);
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
height: 25px;
|
||||
min-width: 0;
|
||||
padding: 2px 5px;
|
||||
width: 100%;
|
||||
}
|
||||
.flvc-param input[type="number"] { appearance: textfield; }
|
||||
.flvc-param input[type="number"]::-webkit-inner-spin-button,
|
||||
.flvc-param input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.flvc-param input:focus,
|
||||
.flvc-param select:focus {
|
||||
border-color: var(--flvc-accent);
|
||||
outline: none;
|
||||
}
|
||||
.flvc-param input:disabled { opacity: .45; }
|
||||
.flvc-toggle {
|
||||
align-items: center;
|
||||
background: var(--flvc-control);
|
||||
border: 1px solid var(--flvc-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
height: 25px;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.flvc-toggle input {
|
||||
accent-color: var(--flvc-accent);
|
||||
height: 13px;
|
||||
margin: 0;
|
||||
width: 13px;
|
||||
}
|
||||
.flvc-toggle-value {
|
||||
color: var(--flvc-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.flvc-toggle[data-enabled="true"] .flvc-toggle-value { color: #86efac; }
|
||||
.flvc-more-button,
|
||||
.flvc-icon-button,
|
||||
.flvc-menu-close,
|
||||
.flvc-reset-button {
|
||||
background: var(--flvc-control);
|
||||
border: 1px solid var(--flvc-border);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.flvc-more-button {
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
height: 25px;
|
||||
line-height: 18px;
|
||||
padding: 0;
|
||||
}
|
||||
.flvc-more-button:hover,
|
||||
.flvc-icon-button:hover,
|
||||
.flvc-menu-close:hover,
|
||||
.flvc-reset-button:hover {
|
||||
border-color: var(--flvc-accent);
|
||||
}
|
||||
.flvc-more-button[data-custom="true"] {
|
||||
border-color: var(--flvc-accent);
|
||||
color: #c4b5fd;
|
||||
}
|
||||
.flvc-more-button:focus-visible,
|
||||
.flvc-icon-button:focus-visible,
|
||||
.flvc-menu-close:focus-visible,
|
||||
.flvc-reset-button:focus-visible {
|
||||
outline: 2px solid var(--flvc-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.flvc-preview {
|
||||
background: #050507;
|
||||
border: 1px solid var(--flvc-border);
|
||||
border-radius: 6px;
|
||||
grid-row: 2;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.flvc-preview video {
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
.flvc-placeholder {
|
||||
align-items: center;
|
||||
color: #717784;
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
padding: 18px 18px 48px;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
}
|
||||
.flvc-status,
|
||||
.flvc-summary {
|
||||
backdrop-filter: blur(5px);
|
||||
background: rgba(24, 25, 30, .84);
|
||||
border-radius: 999px;
|
||||
max-width: calc(65% - 8px);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
text-overflow: ellipsis;
|
||||
top: 7px;
|
||||
white-space: nowrap;
|
||||
z-index: 2;
|
||||
}
|
||||
.flvc-summary {
|
||||
color: #c7cad1;
|
||||
font-size: 9px;
|
||||
left: 7px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.flvc-status {
|
||||
color: #c8ccd5;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 4px 8px;
|
||||
right: 7px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.flvc-status[data-state="ready"] { background: rgba(18, 59, 43, .9); color: #86efac; }
|
||||
.flvc-status[data-state="stale"] { background: rgba(70, 55, 24, .9); color: #fde68a; }
|
||||
.flvc-status[data-state="error"] { background: rgba(76, 29, 36, .9); color: #fda4af; }
|
||||
.flvc-preview-controls {
|
||||
align-items: center;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, .82) 55%);
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
left: 0;
|
||||
min-width: 0;
|
||||
padding: 22px 7px 7px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
.flvc-icon-button {
|
||||
border-radius: 4px;
|
||||
flex: 0 0 28px;
|
||||
height: 25px;
|
||||
padding: 0;
|
||||
}
|
||||
.flvc-time {
|
||||
color: #e4e4e7;
|
||||
flex: 0 0 82px;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.flvc-control-spacer { flex: 1 1 auto; min-width: 4px; }
|
||||
.flvc-preview-volume { flex: 0 1 92px; min-width: 50px; }
|
||||
.flvc-preview-value {
|
||||
color: #d4d4d8;
|
||||
flex: 0 0 31px;
|
||||
font-size: 9px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
.flvc-menu {
|
||||
background: var(--comfy-menu-bg, #1b1c21);
|
||||
border: 1px solid var(--flvc-border);
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 10px 26px rgba(0, 0, 0, .38);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: calc(100% - 54px);
|
||||
max-width: calc(100% - 12px);
|
||||
overflow-y: auto;
|
||||
padding: 9px;
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 47px;
|
||||
width: 300px;
|
||||
z-index: 5;
|
||||
}
|
||||
.flvc-menu[hidden] { display: none; }
|
||||
.flvc-menu-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.flvc-menu-title {
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
.flvc-menu-close {
|
||||
border-radius: 4px;
|
||||
height: 23px;
|
||||
padding: 0;
|
||||
width: 23px;
|
||||
}
|
||||
.flvc-menu-check {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
min-height: 24px;
|
||||
}
|
||||
.flvc-menu-check input {
|
||||
accent-color: var(--flvc-accent);
|
||||
margin: 0;
|
||||
}
|
||||
.flvc-menu-check[data-disabled="true"] { opacity: .45; }
|
||||
.flvc-menu-field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.flvc-menu-field > span {
|
||||
color: var(--flvc-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.flvc-menu-field input {
|
||||
background: var(--flvc-control);
|
||||
border: 1px solid var(--flvc-border);
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
height: 27px;
|
||||
min-width: 0;
|
||||
padding: 3px 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.flvc-menu-field input:focus {
|
||||
border-color: var(--flvc-accent);
|
||||
outline: none;
|
||||
}
|
||||
.flvc-menu-help {
|
||||
color: var(--flvc-muted);
|
||||
font-size: 9px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.flvc-menu-info {
|
||||
border-top: 1px solid var(--flvc-border);
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.flvc-menu-info-row {
|
||||
align-items: center;
|
||||
color: var(--flvc-muted);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.flvc-menu-info-row strong {
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
}
|
||||
.flvc-reset-button {
|
||||
border-radius: 4px;
|
||||
height: 26px;
|
||||
}
|
||||
.flvc-error {
|
||||
background: #3f1d25;
|
||||
border: 1px solid #7f1d2d;
|
||||
border-radius: 5px;
|
||||
color: #fecdd3;
|
||||
font-size: 9px;
|
||||
left: 8px;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 48px;
|
||||
z-index: 6;
|
||||
}
|
||||
.flvc-error[hidden] { display: none; }
|
||||
`;
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById("flvc-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "flvc-styles";
|
||||
style.textContent = STYLES;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function hideWidget(widget) {
|
||||
if (!widget) return;
|
||||
widget.hidden = true;
|
||||
widget.computeSize = () => [0, 0];
|
||||
widget.computedHeight = 0;
|
||||
widget.type = "converted-widget";
|
||||
if (widget.element) widget.element.style.display = "none";
|
||||
}
|
||||
|
||||
function enforceMinimumNodeSize(node) {
|
||||
node.min_size = [
|
||||
Math.max(node.min_size?.[0] || 0, MIN_NODE_WIDTH),
|
||||
Math.max(node.min_size?.[1] || 0, MIN_NODE_HEIGHT),
|
||||
];
|
||||
const width = Math.max(node.size[0], MIN_NODE_WIDTH);
|
||||
const height = Math.max(node.size[1], MIN_NODE_HEIGHT);
|
||||
if (width !== node.size[0] || height !== node.size[1]) {
|
||||
node.setSize([width, height]);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!Number.isFinite(value)) return "00:00";
|
||||
const minutes = Math.floor(value / 60);
|
||||
const seconds = Math.floor(value % 60);
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
class VideoCombinePanel {
|
||||
constructor(node, settingsWidget, container) {
|
||||
this.node = node;
|
||||
this.settingsWidget = settingsWidget;
|
||||
this.container = container;
|
||||
this.preview = null;
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
this.configError = "";
|
||||
this.handleDocumentPointerDown = null;
|
||||
this.handleDocumentKeyDown = null;
|
||||
|
||||
this.node.properties ||= {};
|
||||
if (!Number.isFinite(this.node.properties.previewVolume)) {
|
||||
this.node.properties.previewVolume = 0.8;
|
||||
}
|
||||
if (typeof this.node.properties.previewMuted !== "boolean") {
|
||||
this.node.properties.previewMuted = true;
|
||||
}
|
||||
|
||||
this.readSettings();
|
||||
this.build();
|
||||
this.bind();
|
||||
this.syncControls();
|
||||
|
||||
if (this.node.properties.lastPreview) {
|
||||
this.loadPreview(this.node.properties.lastPreview);
|
||||
}
|
||||
if (this.configError) {
|
||||
this.showConfigError();
|
||||
}
|
||||
}
|
||||
|
||||
readSettings() {
|
||||
try {
|
||||
const parsed = JSON.parse(this.settingsWidget.value);
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
||||
throw new Error("Render settings must be a JSON object.");
|
||||
}
|
||||
if (parsed.version !== undefined && parsed.version !== 1) {
|
||||
throw new Error(`Settings version ${parsed.version} is unsupported.`);
|
||||
}
|
||||
this.settings = { ...DEFAULT_SETTINGS, ...parsed, version: 1 };
|
||||
this.configError = "";
|
||||
} catch (error) {
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
this.configError = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
injectStyles();
|
||||
this.container.innerHTML = `
|
||||
<div class="flvc-panel">
|
||||
<div class="flvc-toolbar" role="group" aria-label="Video render settings">
|
||||
<label class="flvc-param">
|
||||
<span class="flvc-param-label">Prefix</span>
|
||||
<input data-setting="filename_prefix" aria-label="Filename prefix" type="text">
|
||||
</label>
|
||||
<label class="flvc-param">
|
||||
<span class="flvc-param-label">FPS</span>
|
||||
<input data-setting="frame_rate" aria-label="Frame rate" title="Frame rate" type="number" min="1" max="120" step="0.01">
|
||||
</label>
|
||||
<label class="flvc-param">
|
||||
<span class="flvc-param-label">CRF</span>
|
||||
<input data-setting="crf" aria-label="Video quality CRF" title="Lower CRF means higher quality" type="number" min="0" max="51" step="1">
|
||||
</label>
|
||||
<label class="flvc-param">
|
||||
<span class="flvc-param-label">Depth</span>
|
||||
<select data-setting="bit_depth" aria-label="Bit depth" title="Video bit depth">
|
||||
<option value="8">8-bit</option>
|
||||
<option value="10">10-bit</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flvc-param">
|
||||
<span class="flvc-param-label">Audio</span>
|
||||
<span class="flvc-toggle" data-role="audio-toggle">
|
||||
<input data-setting="include_audio" aria-label="Include connected audio" type="checkbox">
|
||||
<span class="flvc-toggle-value" data-role="audio-toggle-value">On</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flvc-param">
|
||||
<span class="flvc-param-label">Gain dB</span>
|
||||
<input data-setting="audio_gain_db" aria-label="Export audio gain" title="Export audio gain in decibels" type="number" min="-60" max="12" step="0.5">
|
||||
</label>
|
||||
<button class="flvc-more-button" data-role="more" type="button" title="Output settings" aria-label="Output settings" aria-expanded="false">⋯</button>
|
||||
</div>
|
||||
|
||||
<div class="flvc-error" data-role="error" role="alert" hidden></div>
|
||||
|
||||
<div class="flvc-preview">
|
||||
<video data-role="video" loop playsinline></video>
|
||||
<div class="flvc-placeholder" data-role="placeholder">Queue the workflow to render a preview.</div>
|
||||
<div class="flvc-summary" data-role="summary">MP4 · H.264</div>
|
||||
<div class="flvc-status" data-role="status" data-state="idle">not rendered</div>
|
||||
<div class="flvc-preview-controls">
|
||||
<button class="flvc-icon-button" data-role="play" type="button" title="Play or pause preview">▶</button>
|
||||
<span class="flvc-time" data-role="time">00:00 / 00:00</span>
|
||||
<span class="flvc-control-spacer"></span>
|
||||
<button class="flvc-icon-button" data-role="preview-mute" type="button" title="Mute preview">🔇</button>
|
||||
<input class="flvc-preview-volume" data-role="preview-volume" aria-label="Preview volume" type="range" min="0" max="100" step="1">
|
||||
<span class="flvc-preview-value" data-role="preview-volume-value">80%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flvc-menu" data-role="settings-menu" hidden>
|
||||
<div class="flvc-menu-header">
|
||||
<span class="flvc-menu-title">Output settings</span>
|
||||
<button class="flvc-menu-close" data-role="menu-close" type="button" aria-label="Close output settings">×</button>
|
||||
</div>
|
||||
<label class="flvc-menu-field">
|
||||
<span>Custom directory</span>
|
||||
<input data-setting="output_directory" aria-label="Custom output directory" title="Absolute directory for rendered videos" type="text" placeholder="D:/Video Exports">
|
||||
</label>
|
||||
<div class="flvc-menu-help">Use an absolute path. Leave blank to use the ComfyUI output or temporary directory.</div>
|
||||
<label class="flvc-menu-check" data-role="save-output-row">
|
||||
<input data-setting="save_output" type="checkbox">
|
||||
Save to output directory
|
||||
</label>
|
||||
<label class="flvc-menu-check">
|
||||
<input data-setting="save_metadata" type="checkbox">
|
||||
Embed workflow metadata
|
||||
</label>
|
||||
<label class="flvc-menu-check" data-role="trim-audio-row">
|
||||
<input data-setting="trim_video_to_audio" type="checkbox">
|
||||
Match video duration to connected audio
|
||||
</label>
|
||||
<div class="flvc-menu-info">
|
||||
<div class="flvc-menu-info-row">
|
||||
<span>Container</span>
|
||||
<strong>MP4</strong>
|
||||
</div>
|
||||
<div class="flvc-menu-info-row">
|
||||
<span>Codec</span>
|
||||
<strong>H.264</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button class="flvc-reset-button" data-role="reset-settings" type="button">Reset render settings</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.video = this.container.querySelector('[data-role="video"]');
|
||||
this.placeholder = this.container.querySelector('[data-role="placeholder"]');
|
||||
this.status = this.container.querySelector('[data-role="status"]');
|
||||
this.error = this.container.querySelector('[data-role="error"]');
|
||||
this.summary = this.container.querySelector('[data-role="summary"]');
|
||||
this.playButton = this.container.querySelector('[data-role="play"]');
|
||||
this.previewMuteButton = this.container.querySelector('[data-role="preview-mute"]');
|
||||
this.previewVolume = this.container.querySelector('[data-role="preview-volume"]');
|
||||
this.previewVolumeValue = this.container.querySelector('[data-role="preview-volume-value"]');
|
||||
this.time = this.container.querySelector('[data-role="time"]');
|
||||
this.audioToggle = this.container.querySelector('[data-role="audio-toggle"]');
|
||||
this.audioToggleValue = this.container.querySelector('[data-role="audio-toggle-value"]');
|
||||
this.moreButton = this.container.querySelector('[data-role="more"]');
|
||||
this.settingsMenu = this.container.querySelector('[data-role="settings-menu"]');
|
||||
this.saveOutputRow = this.container.querySelector('[data-role="save-output-row"]');
|
||||
this.menuCloseButton = this.container.querySelector('[data-role="menu-close"]');
|
||||
this.resetSettingsButton = this.container.querySelector('[data-role="reset-settings"]');
|
||||
this.saveOutputControl = this.container.querySelector('[data-setting="save_output"]');
|
||||
this.trimAudioControl = this.container.querySelector('[data-setting="trim_video_to_audio"]');
|
||||
this.trimAudioRow = this.container.querySelector('[data-role="trim-audio-row"]');
|
||||
this.settingControls = [...this.container.querySelectorAll("[data-setting]")];
|
||||
}
|
||||
|
||||
bind() {
|
||||
for (const control of this.settingControls) {
|
||||
const eventName = control.type === "text" || control.type === "number" ? "input" : "change";
|
||||
control.addEventListener(eventName, () => {
|
||||
let value;
|
||||
if (control.type === "checkbox") {
|
||||
value = control.checked;
|
||||
} else if (control.dataset.setting === "bit_depth" || control.dataset.setting === "crf") {
|
||||
value = Number.parseInt(control.value, 10);
|
||||
} else if (control.type === "number" || control.type === "range") {
|
||||
value = Number.parseFloat(control.value);
|
||||
} else {
|
||||
value = control.value;
|
||||
}
|
||||
if (typeof value === "number" && !Number.isFinite(value)) return;
|
||||
this.updateSetting(control.dataset.setting, value);
|
||||
});
|
||||
}
|
||||
|
||||
this.moreButton.addEventListener("click", () => {
|
||||
this.setMenuOpen(this.settingsMenu.hidden);
|
||||
});
|
||||
this.menuCloseButton.addEventListener("click", () => this.setMenuOpen(false));
|
||||
this.resetSettingsButton.addEventListener("click", () => this.resetSettings());
|
||||
|
||||
this.handleDocumentPointerDown = (event) => {
|
||||
if (
|
||||
!this.settingsMenu.hidden
|
||||
&& !this.settingsMenu.contains(event.target)
|
||||
&& !this.moreButton.contains(event.target)
|
||||
) {
|
||||
this.setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
this.handleDocumentKeyDown = (event) => {
|
||||
if (event.key === "Escape" && !this.settingsMenu.hidden) {
|
||||
this.setMenuOpen(false);
|
||||
this.moreButton.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", this.handleDocumentPointerDown);
|
||||
document.addEventListener("keydown", this.handleDocumentKeyDown);
|
||||
|
||||
this.playButton.addEventListener("click", () => {
|
||||
if (!this.video.src) return;
|
||||
if (this.video.paused) {
|
||||
this.video.play().catch(() => {});
|
||||
} else {
|
||||
this.video.pause();
|
||||
}
|
||||
});
|
||||
this.video.addEventListener("play", () => {
|
||||
this.playButton.textContent = "❚❚";
|
||||
});
|
||||
this.video.addEventListener("pause", () => {
|
||||
this.playButton.textContent = "▶";
|
||||
});
|
||||
this.video.addEventListener("timeupdate", () => this.updateTime());
|
||||
this.video.addEventListener("loadedmetadata", () => {
|
||||
this.placeholder.style.display = "none";
|
||||
this.updateTime();
|
||||
this.video.play().catch(() => {});
|
||||
});
|
||||
this.video.addEventListener("error", () => {
|
||||
if (!this.video.src) return;
|
||||
this.placeholder.textContent = "Preview unavailable. The rendered file may still be valid.";
|
||||
this.placeholder.style.display = "flex";
|
||||
});
|
||||
|
||||
this.previewMuteButton.addEventListener("click", () => {
|
||||
this.node.properties.previewMuted = !this.node.properties.previewMuted;
|
||||
this.applyPreviewAudio();
|
||||
});
|
||||
this.previewVolume.addEventListener("input", () => {
|
||||
this.node.properties.previewVolume = Number(this.previewVolume.value) / 100;
|
||||
this.applyPreviewAudio();
|
||||
});
|
||||
}
|
||||
|
||||
setMenuOpen(open) {
|
||||
this.settingsMenu.hidden = !open;
|
||||
this.moreButton.setAttribute("aria-expanded", String(open));
|
||||
}
|
||||
|
||||
updateSetting(name, value) {
|
||||
this.settings[name] = value;
|
||||
this.settingsWidget.value = JSON.stringify(this.settings);
|
||||
this.configError = "";
|
||||
this.showConfigError();
|
||||
this.syncControls(name);
|
||||
if (this.preview) {
|
||||
this.setStatus("stale", "settings changed");
|
||||
}
|
||||
this.node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
resetSettings() {
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
this.settingsWidget.value = JSON.stringify(this.settings);
|
||||
this.configError = "";
|
||||
this.syncControls();
|
||||
if (this.preview) {
|
||||
this.setStatus("stale", "settings changed");
|
||||
}
|
||||
this.node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
syncControls(changedName = null) {
|
||||
for (const control of this.settingControls) {
|
||||
const name = control.dataset.setting;
|
||||
if (changedName && name !== changedName) continue;
|
||||
if (control.type === "checkbox") {
|
||||
control.checked = Boolean(this.settings[name]);
|
||||
} else {
|
||||
control.value = this.settings[name];
|
||||
}
|
||||
}
|
||||
|
||||
const audioEnabled = Boolean(this.settings.include_audio);
|
||||
this.audioToggle.dataset.enabled = String(audioEnabled);
|
||||
this.audioToggleValue.textContent = audioEnabled ? "On" : "Off";
|
||||
for (const control of this.settingControls.filter((item) => item.dataset.setting === "audio_gain_db")) {
|
||||
control.disabled = !audioEnabled;
|
||||
}
|
||||
this.trimAudioControl.disabled = !audioEnabled;
|
||||
this.trimAudioRow.dataset.disabled = String(!audioEnabled);
|
||||
|
||||
const customDirectory = String(this.settings.output_directory || "").trim();
|
||||
const customOutput = Boolean(customDirectory);
|
||||
this.saveOutputControl.disabled = customOutput;
|
||||
this.saveOutputRow.dataset.disabled = String(customOutput);
|
||||
this.moreButton.dataset.custom = String(customOutput);
|
||||
this.moreButton.title = customOutput ? `Custom output: ${customDirectory}` : "Output settings";
|
||||
|
||||
this.showConfigError();
|
||||
this.applyPreviewAudio();
|
||||
}
|
||||
|
||||
showConfigError() {
|
||||
if (this.configError) {
|
||||
this.error.textContent = this.configError;
|
||||
this.error.hidden = false;
|
||||
this.setStatus("error", "invalid settings");
|
||||
} else {
|
||||
this.error.textContent = "";
|
||||
this.error.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
applyPreviewAudio() {
|
||||
const volume = Math.max(0, Math.min(1, Number(this.node.properties.previewVolume)));
|
||||
this.previewVolume.value = Math.round(volume * 100);
|
||||
this.previewVolumeValue.textContent = `${Math.round(volume * 100)}%`;
|
||||
this.video.volume = volume;
|
||||
this.video.muted = Boolean(this.node.properties.previewMuted);
|
||||
this.previewMuteButton.textContent = this.video.muted ? "🔇" : "🔊";
|
||||
this.previewMuteButton.title = this.video.muted ? "Unmute preview" : "Mute preview";
|
||||
}
|
||||
|
||||
updateTime() {
|
||||
this.time.textContent = `${formatTime(this.video.currentTime)} / ${formatTime(this.video.duration)}`;
|
||||
}
|
||||
|
||||
setStatus(state, label) {
|
||||
this.status.dataset.state = state;
|
||||
this.status.textContent = label;
|
||||
}
|
||||
|
||||
loadPreview(preview) {
|
||||
if (!preview?.filename) return;
|
||||
this.preview = preview;
|
||||
if (preview.preview_url) {
|
||||
const separator = preview.preview_url.includes("?") ? "&" : "?";
|
||||
this.video.src = api.apiURL(`${preview.preview_url}${separator}timestamp=${Date.now()}`);
|
||||
} else {
|
||||
const params = new URLSearchParams({
|
||||
filename: preview.filename,
|
||||
subfolder: preview.subfolder || "",
|
||||
type: preview.type || "output",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.video.src = api.apiURL(`/view?${params.toString()}`);
|
||||
}
|
||||
this.video.load();
|
||||
this.placeholder.textContent = "Loading preview…";
|
||||
this.placeholder.style.display = "flex";
|
||||
this.setStatus("ready", "ready");
|
||||
|
||||
const padded = preview.source_width !== preview.encoded_width || preview.source_height !== preview.encoded_height;
|
||||
const dimensions = padded
|
||||
? `${preview.source_width}×${preview.source_height} → ${preview.encoded_width}×${preview.encoded_height}`
|
||||
: `${preview.encoded_width}×${preview.encoded_height}`;
|
||||
const audio = preview.has_audio ? "audio" : "silent";
|
||||
this.summary.textContent = `${preview.frame_count} frames · ${Number(preview.frame_rate).toFixed(2)} fps · ${Number(preview.duration).toFixed(2)} sec · ${dimensions} · ${preview.bit_depth}-bit · ${audio}`;
|
||||
this.applyPreviewAudio();
|
||||
}
|
||||
|
||||
updateFromExecution(message) {
|
||||
const preview = message?.fl_video_combine?.[0];
|
||||
if (!preview) return;
|
||||
this.node.properties.lastPreview = { ...preview };
|
||||
this.node.properties.lastRenderSettings = this.settingsWidget.value;
|
||||
this.loadPreview(preview);
|
||||
}
|
||||
|
||||
configure() {
|
||||
hideWidget(this.settingsWidget);
|
||||
this.readSettings();
|
||||
this.setMenuOpen(false);
|
||||
this.syncControls();
|
||||
if (this.node.properties.lastPreview) {
|
||||
this.loadPreview(this.node.properties.lastPreview);
|
||||
if (this.node.properties.lastRenderSettings !== this.settingsWidget.value) {
|
||||
this.setStatus("stale", "settings changed");
|
||||
}
|
||||
}
|
||||
if (this.configError) {
|
||||
this.showConfigError();
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.handleDocumentPointerDown) {
|
||||
document.removeEventListener("pointerdown", this.handleDocumentPointerDown);
|
||||
}
|
||||
if (this.handleDocumentKeyDown) {
|
||||
document.removeEventListener("keydown", this.handleDocumentKeyDown);
|
||||
}
|
||||
this.video.pause();
|
||||
this.video.removeAttribute("src");
|
||||
this.video.load();
|
||||
this.container.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.FL_VideoCombine",
|
||||
nodeCreated(node) {
|
||||
if (node.comfyClass !== "FL_VideoCombine") return;
|
||||
|
||||
const settingsWidget = node.widgets?.find((widget) => widget.name === "render_settings");
|
||||
if (!settingsWidget) return;
|
||||
hideWidget(settingsWidget);
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.style.width = "100%";
|
||||
container.style.height = "100%";
|
||||
container.style.minHeight = `${MIN_PANEL_HEIGHT}px`;
|
||||
container.style.overflow = "hidden";
|
||||
|
||||
const domWidget = node.addDOMWidget("fl_video_combine_panel", "fl-video-combine", container, {
|
||||
getMinHeight: () => MIN_PANEL_HEIGHT,
|
||||
hideOnZoom: false,
|
||||
serialize: false,
|
||||
});
|
||||
enforceMinimumNodeSize(node);
|
||||
requestAnimationFrame(() => enforceMinimumNodeSize(node));
|
||||
|
||||
const panel = new VideoCombinePanel(node, settingsWidget, container);
|
||||
|
||||
const originalOnExecuted = node.onExecuted;
|
||||
node.onExecuted = function (message) {
|
||||
originalOnExecuted?.apply(this, arguments);
|
||||
panel.updateFromExecution(message);
|
||||
};
|
||||
|
||||
const originalOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function (...args) {
|
||||
const result = originalOnConfigure?.apply(this, args);
|
||||
panel.configure();
|
||||
requestAnimationFrame(() => enforceMinimumNodeSize(this));
|
||||
return result;
|
||||
};
|
||||
|
||||
domWidget.onRemove = () => panel.dispose();
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user