Add FL video load and combine nodes

This commit is contained in:
Fillip
2026-08-05 18:02:15 -07:00
parent 2e74873fc9
commit a8cf834179
11 changed files with 3838 additions and 1 deletions
+8
View File
@@ -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",
+396
View File
@@ -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
+288
View File
@@ -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
View File
@@ -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"]
+5
View File
@@ -0,0 +1,5 @@
from . import load_video
from . import video_combine
__all__ = ["load_video", "video_combine"]
+17
View File
@@ -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)
+12
View File
@@ -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)
+326
View File
@@ -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()
+304
View File
@@ -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
+842
View File
@@ -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();
},
});