diff --git a/__init__.py b/__init__.py index 24414d4..2e3b43f 100644 --- a/__init__.py +++ b/__init__.py @@ -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", diff --git a/nodes/video/FL_LoadVideo.py b/nodes/video/FL_LoadVideo.py new file mode 100644 index 0000000..a0a0c04 --- /dev/null +++ b/nodes/video/FL_LoadVideo.py @@ -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 diff --git a/nodes/video/FL_VideoCombine.py b/nodes/video/FL_VideoCombine.py new file mode 100644 index 0000000..70b1451 --- /dev/null +++ b/nodes/video/FL_VideoCombine.py @@ -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,), + } diff --git a/pyproject.toml b/pyproject.toml index ad8ea6d..6af8228 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..bf70288 --- /dev/null +++ b/routes/__init__.py @@ -0,0 +1,5 @@ +from . import load_video +from . import video_combine + + +__all__ = ["load_video", "video_combine"] diff --git a/routes/load_video.py b/routes/load_video.py new file mode 100644 index 0000000..69ed175 --- /dev/null +++ b/routes/load_video.py @@ -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) diff --git a/routes/video_combine.py b/routes/video_combine.py new file mode 100644 index 0000000..74ccb86 --- /dev/null +++ b/routes/video_combine.py @@ -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) diff --git a/tests/test_load_video.py b/tests/test_load_video.py new file mode 100644 index 0000000..2b06f18 --- /dev/null +++ b/tests/test_load_video.py @@ -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('
span { + color: var(--flvl-muted); + font-size: 8px; + font-weight: 700; + letter-spacing: .05em; + line-height: 9px; + overflow: hidden; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + } + .flvl-param input, + .flvl-param select, + .flvl-menu-field input, + .flvl-menu-field select { + appearance: none; + background: var(--flvl-control); + border: 1px solid var(--flvl-border); + border-radius: 5px; + color: inherit; + font: inherit; + height: 26px; + min-width: 0; + padding: 2px 5px; + width: 100%; + } + .flvl-param input[type="number"], + .flvl-menu-field input[type="number"] { appearance: textfield; } + .flvl-param input[type="number"]::-webkit-inner-spin-button, + .flvl-menu-field input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + .flvl-param input:focus, + .flvl-param select:focus, + .flvl-menu-field input:focus, + .flvl-menu-field select:focus { + border-color: var(--flvl-accent); + outline: none; + } + .flvl-param input:disabled, + .flvl-param select:disabled { + color: var(--flvl-muted); + cursor: default; + opacity: .65; + } + .flvl-audio-toggle { + align-items: center; + background: var(--flvl-control); + border: 1px solid var(--flvl-border); + border-radius: 5px; + color: var(--flvl-muted); + cursor: pointer; + display: flex; + font-size: 9px; + font-weight: 700; + gap: 4px; + height: 26px; + justify-content: center; + min-width: 0; + padding: 0 4px; + } + .flvl-audio-toggle input { + appearance: none; + background: #30323b; + border: 1px solid #4a4d59; + border-radius: 999px; + cursor: pointer; + height: 12px; + margin: 0; + padding: 0; + position: relative; + transition: background-color 120ms ease, border-color 120ms ease; + width: 20px; + } + .flvl-audio-toggle input::after { + background: #a7abb5; + border-radius: 50%; + content: ""; + height: 6px; + left: 2px; + position: absolute; + top: 2px; + transition: background-color 120ms ease, transform 120ms ease; + width: 6px; + } + .flvl-audio-toggle input:checked { + background: var(--flvl-accent); + border-color: #a78bfa; + } + .flvl-audio-toggle input:checked::after { + background: white; + transform: translateX(8px); + } + .flvl-audio-toggle[data-enabled="true"] { color: #86efac; } + .flvl-menu { + background: var(--comfy-menu-bg, #1b1c22); + border: 1px solid var(--flvl-border); + border-radius: 7px; + box-shadow: 0 10px 30px rgba(0, 0, 0, .45); + display: grid; + gap: 8px; + max-height: calc(100% - 43px); + overflow: auto; + padding: 9px; + position: absolute; + right: 6px; + top: 39px; + width: min(310px, calc(100% - 12px)); + z-index: 8; + } + .flvl-menu[hidden] { display: none; } + .flvl-menu-header { + align-items: center; + display: flex; + justify-content: space-between; + } + .flvl-menu-title { + font-size: 11px; + font-weight: 750; + } + .flvl-menu-close { + border-radius: 4px; + height: 24px; + width: 25px; + } + .flvl-menu-help, + .flvl-memory { + color: var(--flvl-muted); + font-size: 9px; + line-height: 1.35; + } + .flvl-memory[data-warning="true"] { color: #fbbf24; } + .flvl-menu-divider { + border-top: 1px solid var(--flvl-border); + } + .flvl-reset-button { + border-radius: 4px; + height: 26px; + } + .flvl-error { + background: #3f1d25; + border: 1px solid #7f1d2d; + border-radius: 5px; + color: #fecdd3; + font-size: 9px; + left: 12px; + padding: 6px; + position: absolute; + right: 12px; + top: 42px; + z-index: 10; + } + .flvl-error[hidden] { display: none; } + .flvl-upload-progress { + background: #292b32; + border-radius: 999px; + height: 4px; + margin-top: 2px; + overflow: hidden; + width: min(180px, 75%); + } + .flvl-upload-progress > span { + animation: flvl-upload 1s ease-in-out infinite alternate; + background: var(--flvl-accent); + display: block; + height: 100%; + width: 45%; + } + @keyframes flvl-upload { + from { transform: translateX(-20%); } + to { transform: translateX(140%); } + } +`; + +function injectStyles() { + if (document.getElementById("flvl-styles")) return; + const style = document.createElement("style"); + style.id = "flvl-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 filenameFromPath(path) { + return String(path || "").replace(/ \[(input|output|temp)\]$/, "").replace(/\\/g, "/").split("/").pop() || ""; +} + +function previewReference(path) { + const normalized = String(path || "").replace(/ \[(input|output|temp)\]$/, "").replace(/\\/g, "/"); + const parts = normalized.split("/"); + return { + filename: parts.pop() || "", + subfolder: parts.join("/"), + }; +} + +function formatTime(value, precise = false) { + if (!Number.isFinite(value)) return precise ? "00:00.00" : "00:00"; + const minutes = Math.floor(value / 60); + const seconds = precise ? (value % 60).toFixed(2).padStart(5, "0") : String(Math.floor(value % 60)).padStart(2, "0"); + return `${String(minutes).padStart(2, "0")}:${seconds}`; +} + +function formatBytes(value) { + if (!Number.isFinite(value) || value <= 0) return "0 MB"; + const gb = value / (1024 ** 3); + if (gb >= 1) return `${gb.toFixed(gb >= 10 ? 1 : 2)} GB`; + return `${Math.max(1, Math.round(value / (1024 ** 2)))} MB`; +} + +function supportedVideoFile(file) { + const extension = file?.name?.split(".").pop()?.toLowerCase(); + return Boolean(file && (file.type?.startsWith("video/") || file.type === "image/gif" || VIDEO_EXTENSIONS.has(extension))); +} + +class LoadVideoPanel { + constructor(node, videoWidget, settingsWidget, container) { + this.node = node; + this.videoWidget = videoWidget; + this.settingsWidget = settingsWidget; + this.container = container; + this.settings = { ...DEFAULT_SETTINGS }; + this.sourceInfo = null; + this.executionInfo = null; + this.configError = ""; + this.objectUrl = null; + this.probeId = 0; + this.dragDepth = 0; + this.trimDrag = null; + this.disposed = false; + + 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(); + this.restoreSource(); + } + + readSettings() { + try { + const parsed = JSON.parse(this.settingsWidget.value); + if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error("Load 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 = ` +
+
+
No video selected
+ + +
+ + + +
+ +
Choose a video
+
empty
+ +
+
+
+
Drop a video here
+
or click to browse
+ +
+
+ + 00:00 / 00:00 + + + + 80% +
+
+ +
+
+ In 00:00.00 + 0 selected frames + Out 00:00.00 +
+ +
+ +
+
+ Range + + + +
+
+
+ Sample + + +
+
+ Output + + + + +
+
+
+ + +
+ `; + + this.panel = this.container.querySelector(".flvl-panel"); + this.filename = this.container.querySelector('[data-role="filename"]'); + this.preview = this.container.querySelector('[data-role="preview"]'); + this.video = this.container.querySelector('[data-role="video"]'); + this.previewInfo = this.container.querySelector('[data-role="preview-info"]'); + this.status = this.container.querySelector('[data-role="status"]'); + this.browserError = this.container.querySelector('[data-role="browser-error"]'); + this.dropZone = this.container.querySelector('[data-role="drop-zone"]'); + this.dropTitle = this.container.querySelector('[data-role="drop-title"]'); + this.dropHelp = this.container.querySelector('[data-role="drop-help"]'); + this.uploadProgress = this.container.querySelector('[data-role="upload-progress"]'); + this.error = this.container.querySelector('[data-role="error"]'); + this.time = this.container.querySelector('[data-role="time"]'); + this.volume = this.container.querySelector('[data-role="volume"]'); + this.volumeValue = this.container.querySelector('[data-role="volume-value"]'); + this.trimTimeline = this.container.querySelector('[data-role="trim-timeline"]'); + this.trimCanvas = this.container.querySelector('[data-role="trim-canvas"]'); + this.trimInLabel = this.container.querySelector('[data-role="trim-in-label"]'); + this.trimOutLabel = this.container.querySelector('[data-role="trim-out-label"]'); + this.trimFrameLabel = this.container.querySelector('[data-role="trim-frame-label"]'); + this.playButton = this.container.querySelector('[data-action="play"]'); + this.muteButton = this.container.querySelector('[data-action="mute"]'); + this.sourceAction = this.container.querySelector('[data-role="source-action"]'); + this.moreButton = this.container.querySelector('[data-action="more"]'); + this.settingsMenu = this.container.querySelector('[data-role="settings-menu"]'); + this.inputVideoSelect = this.container.querySelector('[data-role="input-video"]'); + this.memory = this.container.querySelector('[data-role="memory"]'); + this.sampleValue = this.container.querySelector('[data-role="sample-value"]'); + this.sampleValueLabel = this.container.querySelector('[data-role="sample-value-label"]'); + this.audioToggle = this.container.querySelector('[data-role="audio-toggle"]'); + this.audioToggleValue = this.container.querySelector('[data-role="audio-toggle-value"]'); + this.settingControls = [...this.container.querySelectorAll("[data-setting]")]; + + this.fileInput = document.createElement("input"); + this.fileInput.type = "file"; + this.fileInput.accept = "video/*,image/gif,.avi,.gif,.m4v,.mkv,.mov,.mp4,.webm"; + this.fileInput.hidden = true; + this.container.appendChild(this.fileInput); + } + + bind() { + this.container.querySelector('[data-action="replace"]').addEventListener("click", () => this.chooseFile()); + this.dropZone.addEventListener("click", () => this.chooseFile()); + this.dropZone.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.chooseFile(); + } + }); + this.fileInput.addEventListener("change", () => { + const file = this.fileInput.files?.[0]; + if (file) this.uploadFile(file); + this.fileInput.value = ""; + }); + + for (const control of this.settingControls) { + const eventName = control.type === "number" ? "input" : "change"; + control.addEventListener(eventName, () => { + let value; + if (control.type === "checkbox") { + value = control.checked; + } else if (control.type === "number") { + value = Number(control.value); + if (!Number.isFinite(value)) return; + if (["select_every_nth", "frame_load_cap", "width", "height"].includes(control.dataset.setting)) { + value = Math.trunc(value); + } + } else { + value = control.value; + } + this.updateSetting(control.dataset.setting, value); + }); + } + this.sampleValue.addEventListener("input", () => { + if (this.settings.sample_mode === "source") return; + let value = Number(this.sampleValue.value); + if (!Number.isFinite(value)) return; + const name = this.settings.sample_mode === "target_fps" ? "target_fps" : "select_every_nth"; + if (name === "select_every_nth") value = Math.trunc(value); + this.updateSetting(name, value); + }); + + this.moreButton.addEventListener("click", () => this.setMenuOpen(this.settingsMenu.hidden)); + this.container.querySelector('[data-action="menu-close"]').addEventListener("click", () => this.setMenuOpen(false)); + this.container.querySelector('[data-action="reset"]').addEventListener("click", () => this.resetSettings()); + this.container.querySelector('[data-action="remove"]').addEventListener("click", () => this.removeSource()); + this.inputVideoSelect.addEventListener("change", () => { + if (this.inputVideoSelect.value) this.selectSource(this.inputVideoSelect.value); + else this.removeSource(); + }); + + 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.panel.addEventListener("dragenter", (event) => { + if (!event.dataTransfer?.types?.includes("Files")) return; + event.preventDefault(); + this.dragDepth += 1; + this.panel.dataset.dragging = "true"; + }); + this.panel.addEventListener("dragover", (event) => { + if (!event.dataTransfer?.types?.includes("Files")) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }); + this.panel.addEventListener("dragleave", (event) => { + if (!event.dataTransfer?.types?.includes("Files")) return; + this.dragDepth = Math.max(0, this.dragDepth - 1); + if (this.dragDepth === 0) this.panel.dataset.dragging = "false"; + }); + this.panel.addEventListener("drop", (event) => { + event.preventDefault(); + this.dragDepth = 0; + this.panel.dataset.dragging = "false"; + const files = [...(event.dataTransfer?.files || [])]; + const file = files.find(supportedVideoFile); + if (!file) { + this.showError("Drop a supported video file."); + return; + } + this.uploadFile(file); + }); + + 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("loadedmetadata", () => { + this.browserError.hidden = true; + this.applyTrimWindow(); + this.updateTime(); + this.renderTrimTimeline(); + }); + this.video.addEventListener("timeupdate", () => { + const end = this.activePreviewEnd(); + if (!this.video.paused && end && this.video.currentTime >= end) { + this.video.currentTime = Math.min(this.settings.start_time, this.video.duration || 0); + this.video.play().catch(() => {}); + } + this.updateTime(); + this.renderTrimTimeline(); + }); + this.video.addEventListener("error", () => { + if (this.video.src) this.browserError.hidden = false; + }); + this.trimCanvas.addEventListener("pointerdown", (event) => this.beginTrimPointer(event)); + this.trimCanvas.addEventListener("pointermove", (event) => this.moveTrimPointer(event)); + this.trimCanvas.addEventListener("pointerup", (event) => this.endTrimPointer(event)); + this.trimCanvas.addEventListener("pointercancel", (event) => this.endTrimPointer(event)); + this.trimCanvas.addEventListener("keydown", (event) => this.handleTrimKey(event)); + this.muteButton.addEventListener("click", () => { + this.node.properties.previewMuted = !this.node.properties.previewMuted; + this.applyPreviewAudio(); + }); + this.volume.addEventListener("input", () => { + this.node.properties.previewVolume = Number(this.volume.value) / 100; + this.applyPreviewAudio(); + }); + + this.trimResizeObserver = new ResizeObserver(() => this.renderTrimTimeline()); + this.trimResizeObserver.observe(this.trimCanvas); + } + + chooseFile() { + this.fileInput.click(); + } + + updateSourceAction(hasSource) { + this.sourceAction.textContent = hasSource ? "Replace" : "Choose"; + this.sourceAction.title = hasSource ? "Replace the selected video" : "Choose a video"; + } + + async uploadFile(file) { + if (!supportedVideoFile(file)) { + this.showError("Choose a supported video file."); + return; + } + + const previousSource = this.videoWidget.value; + this.clearError(); + this.setStatus("busy", "uploading"); + this.dropZone.hidden = false; + this.dropTitle.textContent = `Uploading ${file.name}`; + this.dropHelp.textContent = "Copying into ComfyUI input…"; + this.uploadProgress.hidden = false; + this.setObjectPreview(file); + + try { + const body = new FormData(); + body.append("image", file); + body.append("type", "input"); + const response = await api.fetchApi("/upload/image", { method: "POST", body }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || `Upload failed (${response.status}).`); + const path = [payload.subfolder, payload.name].filter(Boolean).join("/").replace(/\\/g, "/"); + this.addVideoOption(path); + await this.selectSource(path); + } catch (error) { + const message = error.message || "Video upload failed."; + if (previousSource) await this.selectSource(previousSource); + else this.removeSource(false); + this.showError(message); + } finally { + this.uploadProgress.hidden = true; + } + } + + setObjectPreview(file) { + this.revokeObjectUrl(); + this.sourceInfo = null; + this.objectUrl = URL.createObjectURL(file); + this.video.src = this.objectUrl; + this.video.load(); + this.filename.textContent = file.name; + this.updateSourceAction(true); + } + + addVideoOption(path) { + const values = this.videoWidget.options?.values; + if (Array.isArray(values) && !values.includes(path)) values.push(path); + } + + async selectSource(path, markGraph = true) { + if (!path) { + this.removeSource(markGraph); + return; + } + this.addVideoOption(path); + this.videoWidget.value = path; + this.videoWidget.callback?.(path); + if (markGraph) this.node.graph?.change?.(); + this.sourceInfo = null; + this.executionInfo = null; + this.filename.textContent = filenameFromPath(path); + this.updateSourceAction(true); + this.syncInputVideoOptions(); + this.loadServerPreview(path); + await this.probeSource(path); + } + + removeSource(markGraph = true) { + this.probeId += 1; + this.revokeObjectUrl(); + this.video.pause(); + this.video.removeAttribute("src"); + this.video.load(); + this.videoWidget.value = ""; + this.videoWidget.callback?.(""); + if (markGraph) this.node.graph?.change?.(); + this.sourceInfo = null; + this.executionInfo = null; + this.filename.textContent = "No video selected"; + this.updateSourceAction(false); + this.previewInfo.textContent = "Choose a video"; + this.browserError.hidden = true; + this.dropTitle.textContent = "Drop a video here"; + this.dropHelp.textContent = "or click to browse"; + this.dropZone.hidden = false; + this.setStatus("idle", "empty"); + this.syncInputVideoOptions(); + this.syncSampleValue(); + this.updateMemoryEstimate(); + this.renderTrimTimeline(); + } + + loadServerPreview(path) { + this.revokeObjectUrl(); + const reference = previewReference(path); + const params = new URLSearchParams({ + filename: reference.filename, + subfolder: reference.subfolder, + type: "input", + timestamp: Date.now(), + }); + this.video.src = api.apiURL(`/view?${params.toString()}`); + this.video.load(); + this.browserError.hidden = true; + this.dropZone.hidden = true; + this.setStatus("busy", "probing"); + } + + async probeSource(path) { + const probeId = ++this.probeId; + try { + const params = new URLSearchParams({ filename: path }); + const response = await api.fetchApi(`/fl/load-video/info?${params.toString()}`); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || `Video probe failed (${response.status}).`); + if (this.disposed || probeId !== this.probeId || this.videoWidget.value !== path) return; + this.sourceInfo = payload; + this.node.properties.lastSourceInfo = { ...payload, filename: path }; + if (this.settings.frame_load_cap > 0 && this.syncFrameRange("frame_load_cap")) { + this.settingsWidget.value = JSON.stringify(this.settings); + this.syncControls(); + } + this.syncSampleValue(); + this.updateSourceSummary(); + this.setStatus("ready", "ready"); + this.applyTrimWindow(); + this.updateMemoryEstimate(); + this.clearError(); + } catch (error) { + if (this.disposed || probeId !== this.probeId) return; + this.sourceInfo = null; + this.showError(error.message || "Could not inspect video."); + } + } + + restoreSource() { + this.syncInputVideoOptions(); + const path = this.videoWidget.value; + if (!path) { + this.removeSource(false); + if (this.configError) this.showError(this.configError); + return; + } + const cached = this.node.properties.lastSourceInfo; + if (cached?.filename === path) { + this.sourceInfo = { ...cached }; + delete this.sourceInfo.filename; + } + this.filename.textContent = filenameFromPath(path); + this.updateSourceAction(true); + this.loadServerPreview(path); + this.updateSourceSummary(); + this.probeSource(path); + if (this.configError) this.showError(this.configError); + } + + syncInputVideoOptions() { + const current = this.videoWidget.value || ""; + const values = Array.isArray(this.videoWidget.options?.values) + ? this.videoWidget.options.values.filter(Boolean) + : []; + if (current && !values.includes(current)) values.push(current); + values.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + this.inputVideoSelect.replaceChildren(); + const empty = document.createElement("option"); + empty.value = ""; + empty.textContent = "Choose a video…"; + this.inputVideoSelect.appendChild(empty); + for (const value of values) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + this.inputVideoSelect.appendChild(option); + } + this.inputVideoSelect.value = current; + } + + updateSetting(name, value) { + this.settings[name] = value; + const linkedRangeChanged = this.syncFrameRange(name); + this.settingsWidget.value = JSON.stringify(this.settings); + this.configError = ""; + this.syncControls(linkedRangeChanged ? null : name); + this.applyTrimWindow(); + this.updateMemoryEstimate(); + if (this.executionInfo) this.setStatus("stale", "settings changed"); + this.node.graph?.change?.(); + this.node.setDirtyCanvas(true, true); + } + + resetSettings() { + this.settings = { ...DEFAULT_SETTINGS }; + this.settingsWidget.value = JSON.stringify(this.settings); + this.configError = ""; + this.syncControls(); + this.applyTrimWindow(); + this.updateMemoryEstimate(); + if (this.executionInfo) this.setStatus("stale", "settings changed"); + this.node.graph?.change?.(); + 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]; + } + + for (const control of this.settingControls.filter((item) => ["width", "height"].includes(item.dataset.setting))) { + control.disabled = this.settings.resize_mode === "original"; + } + this.syncSampleValue(); + const audioEnabled = Boolean(this.settings.include_audio); + this.audioToggle.dataset.enabled = String(audioEnabled); + this.audioToggleValue.textContent = audioEnabled ? "On" : "Off"; + this.applyPreviewAudio(); + } + + syncSampleValue() { + if (!this.sampleValue) return; + if (this.settings.sample_mode === "target_fps") { + this.sampleValueLabel.textContent = "FPS"; + this.sampleValue.disabled = false; + this.sampleValue.min = "1"; + this.sampleValue.max = "120"; + this.sampleValue.step = "0.01"; + this.sampleValue.value = String(this.settings.target_fps); + } else if (this.settings.sample_mode === "every_nth") { + this.sampleValueLabel.textContent = "Every N"; + this.sampleValue.disabled = false; + this.sampleValue.min = "1"; + this.sampleValue.removeAttribute("max"); + this.sampleValue.step = "1"; + this.sampleValue.value = String(this.settings.select_every_nth); + } else { + this.sampleValueLabel.textContent = "FPS"; + this.sampleValue.disabled = true; + this.sampleValue.min = "1"; + this.sampleValue.max = "120"; + this.sampleValue.step = "0.01"; + this.sampleValue.value = this.sourceInfo?.frame_rate + ? String(Number(this.sourceInfo.frame_rate).toFixed(2)) + : ""; + } + } + + timelineDuration() { + if (Number.isFinite(this.sourceInfo?.duration) && this.sourceInfo.duration > 0) return this.sourceInfo.duration; + if (Number.isFinite(this.video.duration) && this.video.duration > 0) return this.video.duration; + return 0; + } + + effectiveFrameRate() { + let fps = Number(this.sourceInfo?.frame_rate); + if (!Number.isFinite(fps) || fps <= 0) return 0; + if (this.settings.sample_mode === "target_fps") { + fps = Number(this.settings.target_fps); + } else if (this.settings.sample_mode === "every_nth") { + fps /= Math.max(1, Math.trunc(Number(this.settings.select_every_nth) || 1)); + } + return Number.isFinite(fps) && fps > 0 ? fps : 0; + } + + trimBounds() { + const duration = this.timelineDuration(); + const start = Math.min(duration, Math.max(0, Number(this.settings.start_time) || 0)); + const configuredEnd = Number(this.settings.end_time) || 0; + const end = configuredEnd ? Math.min(duration, Math.max(start, configuredEnd)) : duration; + return { duration, start, end }; + } + + selectedFrameCount(bounds = this.trimBounds()) { + const fps = this.effectiveFrameRate(); + if (!fps || !bounds.duration || bounds.end <= bounds.start) return 0; + return Math.max(1, Math.ceil((bounds.end - bounds.start) * fps - 1e-7)); + } + + syncFrameRange(changedName) { + const linkedSettings = new Set([ + "start_time", + "end_time", + "sample_mode", + "target_fps", + "select_every_nth", + "frame_load_cap", + ]); + if (!linkedSettings.has(changedName)) return false; + + const bounds = this.trimBounds(); + const fps = this.effectiveFrameRate(); + if (!bounds.duration || !fps) return false; + + if (changedName !== "frame_load_cap") { + const frameCount = this.selectedFrameCount(bounds); + if (!frameCount || this.settings.frame_load_cap === frameCount) return false; + this.settings.frame_load_cap = frameCount; + return true; + } + + const requestedFrames = Math.max(0, Math.trunc(Number(this.settings.frame_load_cap) || 0)); + this.settings.frame_load_cap = requestedFrames; + if (!requestedFrames) return false; + + const availableFrames = Math.max(1, Math.ceil((bounds.duration - bounds.start) * fps - 1e-7)); + const frameCount = Math.min(requestedFrames, availableFrames); + const end = Math.min(bounds.duration, bounds.start + frameCount / fps); + this.settings.frame_load_cap = frameCount; + this.settings.end_time = end >= bounds.duration - 1e-7 ? 0 : Number(end.toFixed(6)); + return frameCount !== requestedFrames || this.settings.end_time !== Number(bounds.end.toFixed(6)); + } + + trimGeometry() { + const rect = this.trimCanvas.getBoundingClientRect(); + const left = 9; + const right = Math.max(left + 1, rect.width - 9); + return { rect, left, right, width: right - left }; + } + + trimTimeAtPointer(event) { + const { duration } = this.trimBounds(); + const { rect, left, width } = this.trimGeometry(); + const x = Math.max(left, Math.min(left + width, event.clientX - rect.left)); + return duration * ((x - left) / width); + } + + trimXForTime(time, geometry, duration) { + if (!duration) return geometry.left; + return geometry.left + (Math.max(0, Math.min(duration, time)) / duration) * geometry.width; + } + + trimPointerMode(event) { + const bounds = this.trimBounds(); + if (!bounds.duration) return null; + const geometry = this.trimGeometry(); + const x = event.clientX - geometry.rect.left; + const inX = this.trimXForTime(bounds.start, geometry, bounds.duration); + const outX = this.trimXForTime(bounds.end, geometry, bounds.duration); + const inDistance = Math.abs(x - inX); + const outDistance = Math.abs(x - outX); + if (Math.min(inDistance, outDistance) <= 10) { + return inDistance <= outDistance ? "in" : "out"; + } + return "scrub"; + } + + beginTrimPointer(event) { + const mode = this.trimPointerMode(event); + if (!mode) return; + event.preventDefault(); + event.stopPropagation(); + this.trimDrag = mode; + this.trimCanvas.setPointerCapture(event.pointerId); + this.moveTrimPointer(event); + } + + moveTrimPointer(event) { + if (!this.trimDrag) { + const mode = this.trimPointerMode(event); + this.trimCanvas.style.cursor = mode === "in" || mode === "out" ? "ew-resize" : "pointer"; + return; + } + event.preventDefault(); + const bounds = this.trimBounds(); + const time = this.trimTimeAtPointer(event); + if (this.trimDrag === "scrub") { + this.video.currentTime = time; + this.updateTime(); + this.renderTrimTimeline(); + return; + } + + const frameStep = Math.max(0.01, 1 / Math.max(1, Number(this.sourceInfo?.frame_rate) || 100)); + if (this.trimDrag === "in") { + const value = Math.min(Math.max(0, time), Math.max(0, bounds.end - frameStep)); + this.updateSetting("start_time", Number(value.toFixed(3))); + } else { + const value = Math.max(bounds.start + frameStep, Math.min(bounds.duration, time)); + this.updateSetting( + "end_time", + value >= bounds.duration - frameStep / 2 ? 0 : Number(value.toFixed(3)), + ); + } + } + + endTrimPointer(event) { + if (!this.trimDrag) return; + if (this.trimCanvas.hasPointerCapture(event.pointerId)) { + this.trimCanvas.releasePointerCapture(event.pointerId); + } + this.trimDrag = null; + this.trimCanvas.style.cursor = this.trimPointerMode(event) === "scrub" ? "pointer" : "ew-resize"; + } + + handleTrimKey(event) { + const bounds = this.trimBounds(); + if (!bounds.duration || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const frameStep = 1 / Math.max(1, Number(this.sourceInfo?.frame_rate) || 30); + const direction = event.key === "ArrowLeft" ? -1 : 1; + if (event.shiftKey && (event.key === "ArrowLeft" || event.key === "ArrowRight")) { + const value = Math.max(0, Math.min(bounds.end - frameStep, bounds.start + direction * frameStep)); + this.updateSetting("start_time", Number(value.toFixed(3))); + return; + } + if (event.altKey && (event.key === "ArrowLeft" || event.key === "ArrowRight")) { + const value = Math.max(bounds.start + frameStep, Math.min(bounds.duration, bounds.end + direction * frameStep)); + this.updateSetting("end_time", value >= bounds.duration - frameStep / 2 ? 0 : Number(value.toFixed(3))); + return; + } + if (event.key === "Home") this.video.currentTime = 0; + else if (event.key === "End") this.video.currentTime = bounds.duration; + else this.video.currentTime = Math.max(0, Math.min(bounds.duration, this.video.currentTime + direction * frameStep)); + this.updateTime(); + this.renderTrimTimeline(); + } + + renderTrimTimeline() { + if (!this.trimCanvas) return; + const bounds = this.trimBounds(); + const geometry = this.trimGeometry(); + const cssWidth = Math.max(1, geometry.rect.width); + const cssHeight = Math.max(1, geometry.rect.height); + const ratio = Math.max(1, window.devicePixelRatio || 1); + const pixelWidth = Math.round(cssWidth * ratio); + const pixelHeight = Math.round(cssHeight * ratio); + if (this.trimCanvas.width !== pixelWidth || this.trimCanvas.height !== pixelHeight) { + this.trimCanvas.width = pixelWidth; + this.trimCanvas.height = pixelHeight; + } + + const context = this.trimCanvas.getContext("2d"); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + context.clearRect(0, 0, cssWidth, cssHeight); + const trackY = Math.round(cssHeight / 2); + const trackHeight = 6; + const inX = this.trimXForTime(bounds.start, geometry, bounds.duration); + const outX = this.trimXForTime(bounds.end, geometry, bounds.duration); + + context.fillStyle = "#30333d"; + context.fillRect(geometry.left, trackY - trackHeight / 2, geometry.width, trackHeight); + if (bounds.duration) { + context.fillStyle = "#8b5cf6"; + context.fillRect(inX, trackY - trackHeight / 2, Math.max(1, outX - inX), trackHeight); + context.fillStyle = "rgba(0, 0, 0, .42)"; + context.fillRect(geometry.left, trackY - trackHeight / 2, Math.max(0, inX - geometry.left), trackHeight); + context.fillRect(outX, trackY - trackHeight / 2, Math.max(0, geometry.left + geometry.width - outX), trackHeight); + + context.fillStyle = "#5d6270"; + for (let index = 0; index <= 4; index += 1) { + const x = geometry.left + geometry.width * (index / 4); + context.fillRect(Math.round(x), trackY - 5, 1, 10); + } + + const drawHandle = (x) => { + context.fillStyle = "#c4b5fd"; + context.fillRect(Math.round(x) - 2, trackY - 8, 4, 16); + context.beginPath(); + context.arc(x, trackY, 3.5, 0, Math.PI * 2); + context.fill(); + }; + drawHandle(inX); + drawHandle(outX); + + const playheadX = this.trimXForTime(Number(this.video.currentTime) || 0, geometry, bounds.duration); + context.fillStyle = "#ffffff"; + context.fillRect(Math.round(playheadX), trackY - 8, 1, 16); + context.beginPath(); + context.arc(playheadX + 0.5, trackY - 7, 2.5, 0, Math.PI * 2); + context.fill(); + } + + this.trimInLabel.textContent = formatTime(bounds.start, true); + this.trimOutLabel.textContent = formatTime(bounds.end, true); + this.trimFrameLabel.textContent = String(this.selectedFrameCount(bounds)); + this.trimTimeline.dataset.disabled = String(!bounds.duration); + } + + applyTrimWindow() { + if (!Number.isFinite(this.video.duration)) return; + const duration = this.video.duration; + const start = Math.min(Math.max(0, Number(this.settings.start_time) || 0), duration); + const end = this.activePreviewEnd(); + if (this.video.currentTime < start || (end && this.video.currentTime > end)) { + this.video.currentTime = start; + } + this.updateTime(); + this.renderTrimTimeline(); + } + + activePreviewEnd() { + const duration = Number.isFinite(this.video.duration) ? this.video.duration : 0; + const configured = Number(this.settings.end_time) || 0; + return configured ? Math.min(configured, duration || configured) : duration; + } + + applyPreviewAudio() { + const volume = Math.max(0, Math.min(1, Number(this.node.properties.previewVolume))); + this.volume.value = String(Math.round(volume * 100)); + this.volumeValue.textContent = `${Math.round(volume * 100)}%`; + this.video.volume = volume; + this.video.muted = Boolean(this.node.properties.previewMuted); + this.muteButton.textContent = this.video.muted ? "🔇" : "🔊"; + this.muteButton.title = this.video.muted ? "Unmute preview" : "Mute preview"; + } + + updateTime() { + const current = Number(this.video.currentTime) || 0; + const end = this.activePreviewEnd() || Number(this.video.duration) || 0; + this.time.textContent = `${formatTime(current)} / ${formatTime(end)}`; + } + + updateSourceSummary() { + const info = this.executionInfo || this.sourceInfo; + if (!info) return; + if (this.executionInfo) { + const audio = info.has_audio ? "audio" : "silent"; + this.previewInfo.textContent = `${info.loaded_width}×${info.loaded_height} · ${Number(info.loaded_fps).toFixed(2)} fps · ${info.loaded_frame_count} frames · ${audio}`; + return; + } + const audio = info.has_audio ? "audio" : "silent"; + const frameLabel = info.frame_count_estimated ? `~${info.frame_count}` : info.frame_count; + this.previewInfo.textContent = `${info.width}×${info.height} · ${Number(info.frame_rate).toFixed(2)} fps · ${frameLabel} frames · ${audio}`; + } + + updateMemoryEstimate() { + if (!this.sourceInfo) { + this.memory.textContent = "Decoded-memory estimate appears after probing."; + this.memory.dataset.warning = "false"; + return; + } + const info = this.sourceInfo; + const start = Math.min(this.settings.start_time, info.duration); + const end = Math.min(this.settings.end_time || info.duration, info.duration); + const duration = Math.max(0, end - start); + let fps = info.frame_rate; + if (this.settings.sample_mode === "target_fps") fps = this.settings.target_fps; + if (this.settings.sample_mode === "every_nth") fps /= Math.max(1, this.settings.select_every_nth); + let outputFrames = Math.max(1, Math.ceil(duration * fps)); + if (this.settings.frame_load_cap) outputFrames = Math.min(outputFrames, this.settings.frame_load_cap); + const decodeDuration = this.settings.frame_load_cap + ? Math.min(duration, this.settings.frame_load_cap / fps) + : duration; + const sourceFrames = Math.max(1, Math.ceil(decodeDuration * info.frame_rate)); + + let width = info.width; + let height = info.height; + if (this.settings.resize_mode === "crop") { + width = this.settings.width || width; + height = this.settings.height || height; + } else if (this.settings.resize_mode === "fit" && (this.settings.width || this.settings.height)) { + const scale = this.settings.width && this.settings.height + ? Math.min(this.settings.width / width, this.settings.height / height) + : this.settings.width ? this.settings.width / width : this.settings.height / height; + width = Math.max(1, Math.round(width * scale)); + height = Math.max(1, Math.round(height * scale)); + } + + const sourceBytes = sourceFrames * info.width * info.height * 3 * 4; + const outputBytes = outputFrames * width * height * 3 * 4; + const estimate = sourceBytes + outputBytes + Math.min(sourceBytes, outputBytes); + this.memory.textContent = `Estimated peak decoded memory: ${formatBytes(estimate)}.`; + this.memory.dataset.warning = String(estimate >= 1024 ** 3); + } + + updateFromExecution(message) { + const info = message?.fl_load_video?.[0]; + if (!info) return; + this.executionInfo = { ...info }; + this.node.properties.lastExecutionInfo = { ...info }; + this.node.properties.lastLoadSettings = this.settingsWidget.value; + this.updateSourceSummary(); + this.setStatus("ready", "loaded"); + } + + setStatus(state, label) { + this.status.dataset.state = state; + this.status.textContent = label; + } + + setMenuOpen(open) { + this.settingsMenu.hidden = !open; + this.moreButton.setAttribute("aria-expanded", String(open)); + if (open) this.syncInputVideoOptions(); + } + + showError(message) { + this.error.textContent = message; + this.error.hidden = false; + this.setStatus("error", "error"); + } + + clearError() { + this.error.textContent = ""; + this.error.hidden = true; + } + + configure() { + hideWidget(this.videoWidget); + hideWidget(this.settingsWidget); + this.readSettings(); + this.syncControls(); + this.setMenuOpen(false); + this.executionInfo = this.node.properties.lastExecutionInfo || null; + this.restoreSource(); + if (this.executionInfo && this.node.properties.lastLoadSettings !== this.settingsWidget.value) { + this.setStatus("stale", "settings changed"); + } + } + + revokeObjectUrl() { + if (!this.objectUrl) return; + URL.revokeObjectURL(this.objectUrl); + this.objectUrl = null; + } + + dispose() { + this.disposed = true; + this.probeId += 1; + document.removeEventListener("pointerdown", this.handleDocumentPointerDown); + document.removeEventListener("keydown", this.handleDocumentKeyDown); + this.trimResizeObserver?.disconnect(); + this.revokeObjectUrl(); + this.video.pause(); + this.video.removeAttribute("src"); + this.video.load(); + this.container.replaceChildren(); + } +} + +app.registerExtension({ + name: "ComfyUI.FL_LoadVideo", + nodeCreated(node) { + if (node.comfyClass !== "FL_LoadVideo") return; + + const videoWidget = node.widgets?.find((widget) => widget.name === "video"); + const settingsWidget = node.widgets?.find((widget) => widget.name === "load_settings"); + if (!videoWidget || !settingsWidget) return; + hideWidget(videoWidget); + 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_load_video_panel", "fl-load-video", container, { + getMinHeight: () => MIN_PANEL_HEIGHT, + hideOnZoom: false, + serialize: false, + }); + enforceMinimumNodeSize(node); + requestAnimationFrame(() => enforceMinimumNodeSize(node)); + + const panel = new LoadVideoPanel(node, videoWidget, 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(); + }, +}); diff --git a/web/nodes/video/FL_VideoCombine.js b/web/nodes/video/FL_VideoCombine.js new file mode 100644 index 0000000..cdd0bd8 --- /dev/null +++ b/web/nodes/video/FL_VideoCombine.js @@ -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 = ` +
+
+ + + + + + + +
+ + + +
+ +
Queue the workflow to render a preview.
+
MP4 · H.264
+
not rendered
+
+ + 00:00 / 00:00 + + + + 80% +
+
+ + +
+ `; + + 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(); + }, +});