diff --git a/pyproject.toml b/pyproject.toml index ad2e971..533ce29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-videohelpersuite" description = "Nodes related to video workflows" -version = "1.6.1" +version = "1.7.0" license = { file = "LICENSE" } dependencies = ["opencv-python", "imageio-ffmpeg"] diff --git a/testframework/web/js/testRunner.js b/testframework/web/js/testRunner.js index fd5b9da..521a220 100644 --- a/testframework/web/js/testRunner.js +++ b/testframework/web/js/testRunner.js @@ -28,6 +28,8 @@ api.addEventListener("executed", async function ({detail}) { } }); +const workflowService = app.extensionManager.workflow + async function runTest(file) { if (!file?.name?.endsWith(".json")) { return false @@ -46,6 +48,7 @@ async function runTest(file) { app.ui.dialog.show("Failed " + errors.length + " tests:\n" + errors) return true } + await workflowService.closeWorkflow(workflowService.activeWorkflow, {warnIfUnsaved: false}) return false } let iconOverride = document.createElement("style") diff --git a/tests/old-prores.json b/tests/old-prores.json new file mode 100644 index 0000000..17e77c1 --- /dev/null +++ b/tests/old-prores.json @@ -0,0 +1,423 @@ +{ + "id": "bc7448ba-118f-4226-95ab-32227992f954", + "revision": 0, + "last_node_id": 6, + "last_link_id": 4, + "nodes": [ + { + "id": 1, + "type": "VHS_LoadVideo", + "pos": [ + 54, + 89 + ], + "size": [ + 245.1999969482422, + 492.7751770019531 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "name": "meta_batch", + "shape": 7, + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "shape": 7, + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 1, + 2, + 3, + 4 + ] + }, + { + "name": "frame_count", + "type": "INT", + "links": null + }, + { + "name": "audio", + "type": "AUDIO", + "links": null + }, + { + "name": "video_info", + "type": "VHS_VIDEOINFO", + "links": null + } + ], + "properties": { + "Node name for S&R": "VHS_LoadVideo" + }, + "widgets_values": { + "video": "leader.webm", + "force_rate": 8, + "custom_width": 512, + "custom_height": 0, + "frame_load_cap": 64, + "skip_first_frames": 1, + "select_every_nth": 1, + "format": "AnimateDiff", + "choose video to upload": "image", + "videopreview": { + "hidden": false, + "paused": false, + "params": { + "frame_load_cap": 64, + "skip_first_frames": 0, + "force_rate": 8, + "filename": "leader.webm", + "type": "input", + "format": "video/mp4", + "select_every_nth": 1 + } + } + } + }, + { + "id": 4, + "type": "VHS_VideoCombine", + "pos": [ + 630.9500122070312, + 136.90997314453125 + ], + "size": [ + 315, + 497.25 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 2 + }, + { + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "name": "meta_batch", + "shape": 7, + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "shape": 7, + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": null + } + ], + "properties": { + "Node name for S&R": "VHS_VideoCombine" + }, + "widgets_values": { + "frame_rate": 8, + "loop_count": 0, + "filename_prefix": "AnimateDiff", + "format": "video/ProRes", + "profile": "2", + "pingpong": false, + "save_output": false, + "videopreview": { + "hidden": false, + "paused": false, + "params": { + "filename": "AnimateDiff_00004.mov", + "subfolder": "", + "type": "temp", + "format": "video/ProRes", + "frame_rate": 8, + "workflow": "AnimateDiff_00004.png" + } + } + } + }, + { + "id": 3, + "type": "VHS_VideoCombine", + "pos": [ + 635.0499267578125, + -407.1000061035156 + ], + "size": [ + 315, + 497.25 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 1 + }, + { + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "name": "meta_batch", + "shape": 7, + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "shape": 7, + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": null + } + ], + "properties": { + "Node name for S&R": "VHS_VideoCombine" + }, + "widgets_values": { + "frame_rate": 8, + "loop_count": 0, + "filename_prefix": "AnimateDiff", + "format": "video/ProRes", + "profile": "1", + "pingpong": false, + "save_output": false, + "videopreview": { + "hidden": false, + "paused": false, + "params": { + "filename": "AnimateDiff_00001.mov", + "subfolder": "", + "type": "temp", + "format": "video/ProRes", + "frame_rate": 8, + "workflow": "AnimateDiff_00001.png" + } + } + } + }, + { + "id": 5, + "type": "VHS_VideoCombine", + "pos": [ + 974.6401977539062, + -409.33984375 + ], + "size": [ + 315, + 497.25 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 3 + }, + { + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "name": "meta_batch", + "shape": 7, + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "shape": 7, + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": null + } + ], + "properties": { + "Node name for S&R": "VHS_VideoCombine" + }, + "widgets_values": { + "frame_rate": 8, + "loop_count": 0, + "filename_prefix": "AnimateDiff", + "format": "video/ProRes", + "profile": "3", + "pingpong": false, + "save_output": false, + "videopreview": { + "hidden": false, + "paused": false, + "params": { + "filename": "AnimateDiff_00002.mov", + "subfolder": "", + "type": "temp", + "format": "video/ProRes", + "frame_rate": 8, + "workflow": "AnimateDiff_00002.png" + } + } + } + }, + { + "id": 6, + "type": "VHS_VideoCombine", + "pos": [ + 968.7000122070312, + 138.7698974609375 + ], + "size": [ + 315, + 497.25 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 4 + }, + { + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "name": "meta_batch", + "shape": 7, + "type": "VHS_BatchManager", + "link": null + }, + { + "name": "vae", + "shape": 7, + "type": "VAE", + "link": null + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": null + } + ], + "properties": { + "Node name for S&R": "VHS_VideoCombine" + }, + "widgets_values": { + "frame_rate": 8, + "loop_count": 0, + "filename_prefix": "AnimateDiff", + "format": "video/ProRes", + "profile": "4", + "pingpong": false, + "save_output": false, + "videopreview": { + "hidden": false, + "paused": false, + "params": { + "filename": "AnimateDiff_00003.mov", + "subfolder": "", + "type": "temp", + "format": "video/ProRes", + "frame_rate": 8 + } + } + } + } + ], + "links": [ + [ + 1, + 1, + 0, + 3, + 0, + "IMAGE" + ], + [ + 2, + 1, + 0, + 4, + 0, + "IMAGE" + ], + [ + 3, + 1, + 0, + 5, + 0, + "IMAGE" + ], + [ + 4, + 1, + 0, + 6, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "frontendVersion": "1.17.0", + "VHS_latentpreview": true, + "VHS_latentpreviewrate": 0, + "VHS_MetadataImage": true, + "VHS_KeepIntermediate": true + }, + "version": 0.4, + "tests": { + "6": [{"type": "video", "key": "pix_fmt", "value": "yuv444p12le"}], + "length": 1 + } +} \ No newline at end of file diff --git a/tests/old-vae-conversion.json b/tests/old-vae-conversion.json index 70dcd4f..e751918 100644 --- a/tests/old-vae-conversion.json +++ b/tests/old-vae-conversion.json @@ -132,7 +132,7 @@ "Node name for S&R": "VHS_LoadVideoPath" }, "widgets_values": { - "video": "/home/austin/Downloads/bigbuckbunny.mp4", + "video": "input/bigbuckbunny.mp4", "force_rate": 8, "force_size": "Disabled", "custom_width": 512, @@ -147,7 +147,7 @@ "frame_load_cap": 64, "skip_first_frames": 0, "force_rate": 8, - "filename": "/home/austin/Downloads/bigbuckbunny.mp4", + "filename": "input/bigbuckbunny.mp4", "type": "path", "format": "video/mp4", "select_every_nth": 1 diff --git a/video_formats/ffv1-mkv.json b/video_formats/ffv1-mkv.json new file mode 100644 index 0000000..549e727 --- /dev/null +++ b/video_formats/ffv1-mkv.json @@ -0,0 +1,17 @@ +{ + "main_pass": [ + "-n", + "-c:v", "ffv1", + "-level", ["level", ["0", "1", "3"], {"default": "3"}], + "-coder", ["coder", ["0", "1", "2"], {"default": "1"}], + "-context", ["context", ["0", "1"], {"default": "1"}], + "-g", ["gop_size", "INT", {"default": 1, "min": 1, "max": 300, "step": 1}], + "-slices", ["slices", ["4", "6", "9", "12", "16", "20", "24", "30"], {"default": "16"}], + "-slicecrc", ["slicecrc", ["0", "1"], {"default": "1"}], + "-pix_fmt", ["pix_fmt", ["bgra", "rgba64le", "yuv420p", "yuv422p", "yuv444p", "yuva420p", "yuva422p", "yuva444p", "yuv420p10le", "yuv422p10le", "yuv444p10le", "yuv420p12le", "yuv422p12le", "yuv444p12le", "yuv420p14le", "yuv422p14le", "yuv444p14le", "yuv420p16le", "yuv422p16le", "yuv444p16le", "gray", "gray10le", "gray12le", "gray16le"], {"default": "bgra"}] + ], + "audio_pass": ["-c:a", "flac"], + "save_metadata": ["save_metadata", "BOOLEAN", {"default": true}], + "trim_to_audio": ["trim_to_audio", "BOOLEAN", {"default": false}], + "extension": "mkv" +} \ No newline at end of file diff --git a/videohelpersuite/latent_preview.py b/videohelpersuite/latent_preview.py index ff754ff..a9b8757 100644 --- a/videohelpersuite/latent_preview.py +++ b/videohelpersuite/latent_preview.py @@ -1,6 +1,7 @@ from PIL import Image import time import io +import struct from threading import Thread import torch.nn.functional as F import torch @@ -43,7 +44,7 @@ class WrappedPreviewer(latent_preview.LatentPreviewer): return None if self.first_preview: self.first_preview = False - serv.send_sync('VHS_latentpreview', {'length':num_images, 'rate': self.rate}) + serv.send_sync('VHS_latentpreview', {'length':num_images, 'rate': self.rate, 'id': serv.last_node_id}) self.last_time = new_time + 1/self.rate if self.c_index + num_previews > num_images: x0 = x0.roll(-self.c_index, 0)[:num_previews] @@ -72,6 +73,7 @@ class WrappedPreviewer(latent_preview.LatentPreviewer): message = io.BytesIO() message.write((1).to_bytes(length=4, byteorder='big')*2) message.write(ind.to_bytes(length=4, byteorder='big')) + message.write(struct.pack('16p', serv.last_node_id.encode('ascii'))) i.save(message, format="JPEG", quality=95, compress_level=1) #NOTE: send sync already uses call_soon_threadsafe serv.send_sync(server.BinaryEventTypes.PREVIEW_IMAGE, @@ -89,7 +91,6 @@ class WrappedPreviewer(latent_preview.LatentPreviewer): bias=self.latent_rgb_factors_bias) return latent_image - @hook(latent_preview, 'get_previewer') def get_latent_video_previewer(device, latent_format, *args, **kwargs): node_id = serv.last_node_id diff --git a/videohelpersuite/load_video_nodes.py b/videohelpersuite/load_video_nodes.py index c102da4..2c4ee3f 100644 --- a/videohelpersuite/load_video_nodes.py +++ b/videohelpersuite/load_video_nodes.py @@ -170,7 +170,8 @@ def cv_frame_generator(video, force_rate, frame_load_cap, skip_first_frames, def ffmpeg_frame_generator(video, force_rate, frame_load_cap, start_time, custom_width, custom_height, downscale_ratio=8, meta_batch=None, unique_id=None): - args_dummy = [ffmpeg_path, "-i", video, '-c', 'copy', '-frames:v', '1', "-f", "null", "-"] + args_input = ["-i", video] + args_dummy = [ffmpeg_path] + args_input +['-c', 'copy', '-frames:v', '1', "-f", "null", "-"] size_base = None fps_base = None try: @@ -180,6 +181,16 @@ def ffmpeg_frame_generator(video, force_rate, frame_load_cap, start_time, raise Exception("An error occurred in the ffmpeg subprocess:\n" \ + e.stderr.decode(*ENCODE_ARGS)) lines = dummy_res.stderr.decode(*ENCODE_ARGS) + if "Video: vp9 " in lines: + args_input = ["-c:v", "libvpx-vp9"] + args_input + args_dummy = [ffmpeg_path] + args_input +['-c', 'copy', '-frames:v', '1', "-f", "null", "-"] + try: + dummy_res = subprocess.run(args_dummy, stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, check=True) + except subprocess.CalledProcessError as e: + raise Exception("An error occurred in the ffmpeg subprocess:\n" \ + + e.stderr.decode(*ENCODE_ARGS)) + lines = dummy_res.stderr.decode(*ENCODE_ARGS) for line in lines.split('\n'): match = re.search("^ *Stream .* Video.*, ([1-9]|\\d{2,})x(\\d+)", line) @@ -190,7 +201,7 @@ def ffmpeg_frame_generator(video, force_rate, frame_load_cap, start_time, fps_base = float(fps_match.group(1)) else: fps_base = 1 - alpha = re.search("(yuva|rgba)", line) is not None + alpha = re.search("(yuva|rgba|bgra)", line) is not None break else: raise Exception("Failed to parse video/image information. FFMPEG output:\n" + lines) @@ -205,15 +216,13 @@ def ffmpeg_frame_generator(video, force_rate, frame_load_cap, start_time, if start_time > 0: if start_time > 4: post_seek = ['-ss', '4'] - pre_seek = ['-ss', str(start_time - 4)] + args_input = ['-ss', str(start_time - 4)] + args_input else: post_seek = ['-ss', str(start_time)] - pre_seek = [] else: - pre_seek = [] post_seek = [] - args_all_frames = [ffmpeg_path, "-v", "error", "-an"] + pre_seek + \ - ["-i", video, "-pix_fmt", "rgba64le"] + post_seek + args_all_frames = [ffmpeg_path, "-v", "error", "-an"] + \ + args_input + ["-pix_fmt", "rgba64le"] + post_seek vfilters = [] if force_rate != 0: @@ -526,7 +535,7 @@ class LoadVideoFFmpegUpload: "custom_width": ("INT", {"default": 0, "min": 0, "max": DIMMAX, 'disable': 0}), "custom_height": ("INT", {"default": 0, "min": 0, "max": DIMMAX, 'disable': 0}), "frame_load_cap": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1, "disable": 0}), - "start_time": ("FLOAT", {"default": 0, "min": 0, "max": BIGMAX, "step": .001}), + "start_time": ("FLOAT", {"default": 0, "min": 0, "max": BIGMAX, "step": .001, "widgetType": "VHSTIMESTAMP"}), }, "optional": { "meta_batch": ("VHS_BatchManager",), @@ -576,7 +585,7 @@ class LoadVideoFFmpegPath: "custom_width": ("INT", {"default": 0, "min": 0, "max": DIMMAX, 'disable': 0}), "custom_height": ("INT", {"default": 0, "min": 0, "max": DIMMAX, 'disable': 0}), "frame_load_cap": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1, "disable": 0}), - "start_time": ("FLOAT", {"default": 0, "min": 0, "max": BIGMAX, "step": .001}), + "start_time": ("FLOAT", {"default": 0, "min": 0, "max": BIGMAX, "step": .001, "widgetType": "VHSTIMESTAMP"}), }, "optional": { "meta_batch": ("VHS_BatchManager",), diff --git a/videohelpersuite/nodes.py b/videohelpersuite/nodes.py index b42a0f3..4fdcad1 100755 --- a/videohelpersuite/nodes.py +++ b/videohelpersuite/nodes.py @@ -122,8 +122,7 @@ def apply_format_widgets(format_name, kwargs): return video_format def tensor_to_int(tensor, bits): - #TODO: investigate benefit of rounding by adding 0.5 before clip/cast - tensor = tensor.cpu().numpy() * (2**bits-1) + tensor = tensor.cpu().numpy() * (2**bits-1) + 0.5 return np.clip(tensor, 0, (2**bits-1)) def tensor_to_shorts(tensor): return tensor_to_int(tensor, 16).astype(np.uint16) @@ -450,14 +449,17 @@ class VideoCombine: logger.warn("Output images were not of valid resolution and have had padding applied") else: dimensions = (first_image.shape[1], first_image.shape[0]) - if loop_count > 0: - loop_args = ["-vf", "loop=loop=" + str(loop_count)+":size=" + str(num_frames)] - else: - loop_args = [] if pingpong: if meta_batch is not None: logger.error("pingpong is incompatible with batched output") images = to_pingpong(images) + if num_frames > 2: + num_frames += num_frames -2 + pbar.total = num_frames + if loop_count > 0: + loop_args = ["-vf", "loop=loop=" + str(loop_count)+":size=" + str(num_frames)] + else: + loop_args = [] if video_format.get('input_color_depth', '8bit') == '16bit': images = map(tensor_to_shorts, images) if has_alpha: @@ -530,6 +532,7 @@ class VideoCombine: output_process.send(None) if meta_batch is not None: meta_batch.outputs[unique_id] = (counter, output_process) + audio = None for image in images: pbar.update(1) @@ -627,16 +630,17 @@ class LoadAudio: "required": { "audio_file": ("STRING", {"default": "input/", "vhs_path_extensions": ['wav','mp3','ogg','m4a','flac']}), }, - "optional" : {"seek_seconds": ("FLOAT", {"default": 0, "min": 0}), - "duration": ("FLOAT" , {"default": 0, "min": 0, "max": 10000000, "step": 0.01}), + "optional" : { + "seek_seconds": ("FLOAT", {"default": 0, "min": 0, "widgetType": "VHSTIMESTAMP"}), + "duration": ("FLOAT" , {"default": 0, "min": 0, "max": 10000000, "step": 0.01, "widgetType": "VHSTIMESTAMP"}), } } - RETURN_TYPES = ("AUDIO",) - RETURN_NAMES = ("audio",) + RETURN_TYPES = ("AUDIO", "FLOAT") + RETURN_NAMES = ("audio", "duration") CATEGORY = "Video Helper Suite 🎥🅥🅗🅢/audio" FUNCTION = "load_audio" - def load_audio(self, audio_file, seek_seconds, duration): + def load_audio(self, audio_file, seek_seconds=0, duration=0): audio_file = strip_path(audio_file) if audio_file is None or validate_path(audio_file) != True: raise Exception("audio_file is not a valid path: " + audio_file) @@ -644,10 +648,12 @@ class LoadAudio: audio_file = try_download_video(audio_file) or audio_file #Eagerly fetch the audio since the user must be using it if the #node executes, unlike Load Video - return (get_audio(audio_file, start_time=seek_seconds, duration=duration),) + audio = get_audio(audio_file, start_time=seek_seconds, duration=duration) + loaded_duration = audio['waveform'].size(2)/audio['sample_rate'] + return (audio, loaded_duration) @classmethod - def IS_CHANGED(s, audio_file, seek_seconds): + def IS_CHANGED(s, audio_file, **kwargs): return hash_path(audio_file) @classmethod @@ -665,27 +671,30 @@ class LoadAudioUpload: if len(file_parts) > 1 and (file_parts[-1] in audio_extensions): files.append(f) return {"required": { - "audio": (sorted(files),), - "start_time": ("FLOAT" , {"default": 0, "min": 0, "max": 10000000, "step": 0.01}), - "duration": ("FLOAT" , {"default": 0, "min": 0, "max": 10000000, "step": 0.01}), + "audio": (sorted(files),),}, + "optional": { + "start_time": ("FLOAT" , {"default": 0, "min": 0, "max": 10000000, "step": 0.01, "widgetType": "VHSTIMESTAMP"}), + "duration": ("FLOAT" , {"default": 0, "min": 0, "max": 10000000, "step": 0.01, "widgetType": "VHSTIMESTAMP"}), }, } CATEGORY = "Video Helper Suite 🎥🅥🅗🅢/audio" - RETURN_TYPES = ("AUDIO", ) - RETURN_NAMES = ("audio",) + RETURN_TYPES = ("AUDIO", "FLOAT") + RETURN_NAMES = ("audio", "duration") FUNCTION = "load_audio" - def load_audio(self, start_time, duration, **kwargs): + def load_audio(self, start_time=0, duration=0, **kwargs): audio_file = folder_paths.get_annotated_filepath(strip_path(kwargs['audio'])) if audio_file is None or validate_path(audio_file) != True: raise Exception("audio_file is not a valid path: " + audio_file) - return (get_audio(audio_file, start_time, duration),) + audio = get_audio(audio_file, start_time, duration) + loaded_duration = audio['waveform'].size(2)/audio['sample_rate'] + return (audio, loaded_duration) @classmethod - def IS_CHANGED(s, audio, start_time, duration): + def IS_CHANGED(s, audio, **kwargs): audio_file = folder_paths.get_annotated_filepath(strip_path(audio)) return hash_path(audio_file) diff --git a/videohelpersuite/server.py b/videohelpersuite/server.py index ef57da3..4bda173 100755 --- a/videohelpersuite/server.py +++ b/videohelpersuite/server.py @@ -5,6 +5,7 @@ import subprocess import re import asyncio +import av from .utils import is_url, get_sorted_dir_files_from_directory, ffmpeg_path, \ validate_sequence, is_safe_path, strip_path, try_download_video, ENCODE_ARGS @@ -35,7 +36,7 @@ async def view_video(request): select_every_nth = int(query.get('select_every_nth', 1)) or 1 valid_images = get_sorted_dir_files_from_directory(file, skip_first_images, select_every_nth, FolderOfImages.IMG_EXTENSIONS) if len(valid_images) == 0: - return web.Response(status=400) + return web.Response(status=204) with open(concat_file, "w") as f: f.write("ffconcat version 1.0\n") for path in valid_images: @@ -67,7 +68,7 @@ async def view_video(request): return web.Response(status=500) vfilters = [] target_rate = float(query.get('force_rate', 0)) or base_fps - modified_rate = target_rate / float(query.get('select_every_nth',1) or 1) + modified_rate = target_rate / (float(query.get('select_every_nth',1)) or 1) start_time = 0 if 'start_time' in query: start_time = float(query['start_time']) @@ -146,52 +147,42 @@ async def query_video(request): if filepath in query_cache and query_cache[filepath][0] == os.stat(filepath).st_mtime: source = query_cache[filepath][1] else: - args_dummy = [ffmpeg_path, "-i", filepath, '-c', 'copy', '-frames:v', '1', "-f", "null", "-"] - try: - dummy_res = subprocess.run(args_dummy, stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, check=True) - except subprocess.CalledProcessError as e: - raise Exception("An error occurred in the ffmpeg subprocess:\n" \ - + e.stderr.decode(*ENCODE_ARGS)) - lines = dummy_res.stderr.decode(*ENCODE_ARGS) source = {} + try: + cont = av.open(filepath) + stream = cont.streams.video[0] + source['fps'] = float(stream.average_rate) + source['duration'] = float(cont.duration / av.time_base) - for line in lines.split('\n'): - match = re.search("^ *Stream .* Video.*, ([1-9]|\\d{2,})x(\\d+)", line) - if match is not None: - source['size'] = [int(match.group(1)), int(match.group(2))] - fps_match = re.search(", ([\\d\\.]+) fps", line) - if not fps_match: - return web.json_response({}) - source['fps'] = float(fps_match.group(1)) - if re.search("(yuva|rgba)", line): - source['alpha'] = True - break - else: - raise Exception("Failed to parse video/image information. FFMPEG output:\n" + lines) + if stream.codec_context.name == 'vp9': + cc = av.Codec('libvpx-vp9', 'r').create() + else: + cc = stream + def fit(): + for packet in cont.demux(video=0): + yield from cc.decode(packet) + frame = next(fit()) - durs_match = re.search("Duration: (\\d+:\\d+:\\d+\\.\\d+),", lines) - if not (durs_match and 'fps' in source): - return web.json_response({}) - durs = durs_match.group(1).split(':') - duration = int(durs[0])*360 + int(durs[1])*60 + float(durs[2]) - source['duration'] = duration - source['frames'] = int(duration*source['fps']) - query_cache[filepath] = (os.stat(filepath).st_mtime, source) - loaded = {} - if 'duration' not in source: + source['size'] = [frame.width, frame.height] + source['alpha'] = 'a' in frame.format.name + source['frames'] = stream.metadata.get('NUMBER_OF_FRAMES', round(source['duration'] * source['fps'])) + query_cache[filepath] = (os.stat(filepath).st_mtime, source) + except Exception: + pass + if not 'frames' in source: return web.json_response({}) + loaded = {} loaded['duration'] = source['duration'] loaded['duration'] -= float(query.get('start_time',0)) loaded['fps'] = float(query.get('force_rate', 0)) or source['fps'] loaded['duration'] -= int(query.get('skip_first_frames', 0)) / loaded['fps'] loaded['fps'] /= int(query.get('select_every_nth', 1)) or 1 - loaded['frames'] = loaded['duration'] * loaded['fps'] + loaded['frames'] = round(loaded['duration'] * loaded['fps']) return web.json_response({'source': source, 'loaded': loaded}) async def resolve_path(query): if "filename" not in query: - return web.Response(status=404) + return web.Response(status=204) filename = query["filename"] #Path code misformats urls on windows and must be skipped @@ -211,10 +202,10 @@ async def resolve_path(query): output_dir = folder_paths.get_directory_by_type(type) if output_dir is None: - return web.Response(status=400) + return web.Response(status=204) if not is_safe_path(output_dir): - return web.Response(status=403) + return web.Response(status=204) if "subfolder" in query: output_dir = os.path.join(output_dir, query["subfolder"]) @@ -224,10 +215,10 @@ async def resolve_path(query): if query.get('format', 'video') == 'folder': if not os.path.isdir(file): - return web.Response(status=404) + return web.Response(status=204) else: if not os.path.isfile(file) and not validate_sequence(file): - return web.Response(status=404) + return web.Response(status=204) return file, filename, output_dir @server.PromptServer.instance.routes.get("/vhs/getpath") @@ -235,7 +226,7 @@ async def resolve_path(query): async def get_path(request): query = request.rel_url.query if "path" not in query: - return web.Response(status=404) + return web.Response(status=204) #NOTE: path always ends in `/`, so this is functionally an lstrip path = os.path.abspath(strip_path(query["path"])) diff --git a/videohelpersuite/utils.py b/videohelpersuite/utils.py index 5a52f85..75431d5 100644 --- a/videohelpersuite/utils.py +++ b/videohelpersuite/utils.py @@ -289,6 +289,8 @@ def hash_path(path): return "input" if is_url(path): return "url" + if not os.path.isfile(path): + return "DNE" return calculate_file_hash(strip_path(path)) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index fde859f..df8fa41 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -20,6 +20,16 @@ function chainCallback(object, property, callback) { } } +function getNodeById(id, graph=app.graph) { + let cg = graph + let node = undefined + for (let sid of (''+id).split(':')) { + node = cg?.getNodeById?.(sid) + cg = node?.subgraph + } + return node +} + const convDict = { VHS_LoadImages : ["directory", null, "image_load_cap", "skip_first_images", "select_every_nth"], VHS_LoadImagesPath : ["directory", "image_load_cap", "skip_first_images", "select_every_nth"], @@ -448,8 +458,7 @@ function allowDragFromWidget(widget) { } } -async function uploadFile(file) { - //TODO: Add uploaded file to cache with Cache.put()? +async function uploadFile(file, progressCallback) { try { // Wrap file in formdata so it includes filename const body = new FormData(); @@ -463,16 +472,19 @@ async function uploadFile(file) { if (i > 0) { body.append("subfolder", subfolder); } - const resp = await api.fetchApi("/upload/image", { - method: "POST", - body, - }); + const url = api.apiURL("/upload/image") + const resp = await new Promise((resolve) => { + let req = new XMLHttpRequest() + req.upload.onprogress = (e) => progressCallback?.(e.loaded/e.total) + req.onload = () => resolve(req) + req.open('post', url, true) + req.send(body) + }) - if (resp.status === 200) { - return resp - } else { + if (resp.status !== 200) { alert(resp.status + " - " + resp.statusText); } + return resp } catch (error) { alert(error); } @@ -690,8 +702,8 @@ function initializeLoadFormat(nodeType, nodeData) { format.force_rate = {'reset': format.target_rate} } if ('dim' in format) { - format.custom_width = {'step': format.dim[0]*10, 'mod': format.dim[1]} - format.custom_height = {'step': format.dim[0]*10, 'mod': format.dim[1]} + format.custom_width = {'step': format.dim[0], 'mod': format.dim[1]} + format.custom_height = {'step': format.dim[0], 'mod': format.dim[1]} if (format.dim[2]) { format.custom_width.reset = format.dim[2] } @@ -700,7 +712,7 @@ function initializeLoadFormat(nodeType, nodeData) { } } if ('frames' in format) { - format.frame_load_cap = {'step': format.frames[0]*10, 'mod': format.frames[1]} + format.frame_load_cap = {'step': format.frames[0], 'mod': format.frames[1]} } for (let widget of node.widgets) { if (widget.name in base) { @@ -732,14 +744,17 @@ function initializeLoadFormat(nodeType, nodeData) { let rateWidget = this.widgets.find((w) => w.name === "force_rate") rateWidget.annotation = (value, width) => { if (value == 0 && this.video_query?.source?.fps != undefined) { - return this.video_query.source.fps + "\u21FD" + return roundToPrecision(this.video_query.source.fps, 2) + "\u21FD" } } }); } function addUploadWidget(nodeType, nodeData, widgetName, type="video") { + let accept = {'video': ["video/webm","video/mp4","video/x-matroska","image/gif"], + 'audio': ["audio/mpeg","audio/wav","audio/x-wav","audio/ogg"]} chainCallback(nodeType.prototype, "onNodeCreated", function() { + const node = this const pathWidget = this.widgets.find((w) => w.name === widgetName); const fileInput = document.createElement("input"); chainCallback(this, "onRemoved", () => { @@ -762,10 +777,12 @@ function addUploadWidget(nodeType, nodeData, widgetName, type="video") { return; } let successes = 0; + const onProg = (p) => this.progress = (successes + p) / fileInput.files.length for(const file of fileInput.files) { - if ((await uploadFile(file)).status == 200) { + if ((await uploadFile(file, onProg)).status == 200) { successes++; } else { + this.progress = undefined //Upload failed, but some prior uploads may have succeeded //Stop future uploads to prevent cascading failures //and only add to list if an upload has succeeded @@ -776,6 +793,7 @@ function addUploadWidget(nodeType, nodeData, widgetName, type="video") { } } } + this.progress = undefined pathWidget.options.values.push(path); pathWidget.value = path; if (pathWidget.callback) { @@ -783,50 +801,45 @@ function addUploadWidget(nodeType, nodeData, widgetName, type="video") { } }, }); - } else if (type == "video") { + } else { + let accept = {'video': ["video/webm","video/mp4","video/x-matroska","image/gif"], + 'audio': ["audio/mpeg","audio/wav","audio/x-wav","audio/ogg"]}[type] + async function doUpload(file) { + let resp = await uploadFile(file, (p) => node.progress = p) + node.progress = undefined + if (resp.status != 200) { + return false + } + const filename = JSON.parse(resp.responseText).name; + pathWidget.options.values.push(filename); + pathWidget.value = filename; + if (pathWidget.callback) { + pathWidget.callback(filename) + } + return true + } Object.assign(fileInput, { type: "file", - accept: "video/webm,video/mp4,video/x-matroska,image/gif", + accept: accept.join(','), style: "display: none", onchange: async () => { if (fileInput.files.length) { - let resp = await uploadFile(fileInput.files[0]) - if (resp.status != 200) { - //upload failed and file can not be added to options - return; - } - const filename = (await resp.json()).name; - pathWidget.options.values.push(filename); - pathWidget.value = filename; - if (pathWidget.callback) { - pathWidget.callback(filename) - } + return await doUpload(fileInput.files[0]) } }, }); - } else if (type == "audio") { - Object.assign(fileInput, { - type: "file", - accept: "audio/mpeg,audio/wav,audio/x-wav,audio/ogg", - style: "display: none", - onchange: async () => { - if (fileInput.files.length) { - let resp = await uploadFile(fileInput.files[0]) - if (resp.status != 200) { - //upload failed and file can not be added to options - return; - } - const filename = (await resp.json()).name; - pathWidget.options.values.push(filename); - pathWidget.value = filename; - if (pathWidget.callback) { - pathWidget.callback(filename) - } - } - }, - }); - }else { - throw "Unknown upload type" + this.onDragOver = (e) => !!e?.dataTransfer?.types?.includes?.('Files') + this.onDragDrop = async function(e) { + if (!e?.dataTransfer?.types?.includes?.('Files')) { + return false + } + //TODO: Allow dragging multiple files at once? + const item = e.dataTransfer?.files?.[0] + if (accept.includes(item?.type)) { + return await doUpload(item) + } + return false + } } document.body.append(fileInput); let uploadWidget = this.addWidget("button", "choose " + type + " to upload", "image", () => { @@ -836,6 +849,8 @@ function addUploadWidget(nodeType, nodeData, widgetName, type="video") { fileInput.click(); }); uploadWidget.options.serialize = false; + + }); } @@ -885,6 +900,12 @@ function addVideoPreview(nodeType, isInput=true) { e.preventDefault() return app.canvas._mouseup_callback(e) }, true); + element.addEventListener('dragover', (e) => { + //A little hacky, but allows drag events onto the preview itself + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + app.dragOverNode = this + }) previewWidget.value = {hidden: false, paused: false, params: {}, muted: app.ui.settings.getSettingValue("VHS.DefaultMute")} previewWidget.parentEl = document.createElement("div"); @@ -1160,23 +1181,39 @@ function addFormatWidgets(nodeType, nodeData) { if (formats?.[value]) { let formatWidgets = formats[value] for (let wDef of formatWidgets) { - let type = wDef[1] + let type = wDef[2]?.widgetType ?? wDef[1] if (Array.isArray(type)) { type = "COMBO" } app.widgets[type](this, wDef[0], wDef.slice(1), app) let w = this.widgets.pop() - if (['INT', 'FLOAT'].includes(type)) { - makeAnnotated(w, wDef.slice(1)) - } w.config = wDef.slice(1) newWidgets.push(w) } } let removed = this.widgets.splice(formatWidgetIndex, formatWidgetsCount, ...newWidgets); + let newNames = new Set(newWidgets.map((w) => w.name)) for (let w of removed) { w?.onRemove?.() + if (w.name in newNames) { + continue + } + //I do not like the performance of this, but it's safe + let slot = this.inputs.findIndex((i) => i.name == w.name) + if (slot >= 0) { + this.removeInput(slot) + } + } + for (let w of newWidgets) { + let existingInput = this.inputs.find((i) => i.name == w.name) + if (existingInput) { + setWidgetConfig(existingInput, w.config) + //TODO: Consider forcing disconnection if props change? + } else { + //NOTE: config is applied in wrapped addInput call + this.addInput(w.name, w.config[0], {widget: {name: w.name}}) + } } fitHeight(this); formatWidgetsCount = newWidgets.length; @@ -1213,17 +1250,10 @@ function addLoadCommon(nodeType, nodeData) { } const offsetWidget = this.widgets.find((w) => w.name === "start_time"); if (offsetWidget) { - makeTimestamp(offsetWidget) - Object.defineProperty(offsetWidget.options, "step2", { - set : (value) => {}, - get : () => { - return 1 / (this.video_query?.loaded?.fps ?? 1) - } - }) Object.defineProperty(offsetWidget.options, "step", { set : (value) => {}, get : () => { - return 10 / (this.video_query?.loaded?.fps ?? 1) + return 1 / (this.video_query?.loaded?.fps ?? 1) } }) } @@ -1438,6 +1468,61 @@ function button_action(widget) { } return 'No Disable' } +function fitText(ctx, text, maxLength) { + if (maxLength <= 0) { + return ['', 0] + } + let fullLength = ctx.measureText(text).width + if (fullLength < maxLength) { + return [text, fullLength] + } + //determine approx safe cutoff + let cutoff = maxLength / fullLength * text.length | 0 + let shortened = text.slice(0, Math.max(0, cutoff - 2)) + '…' + return [shortened, ctx.measureText(shortened).width] +} +function fitPath(ctx, path, maxLength) { + let fullLength = ctx.measureText(path).width + if (fullLength < maxLength) { + return [path, fullLength] + } + //determine approx safe cutoff + let len = (maxLength / fullLength * path.length | 0) - 1 + + let displayPath = '' + let filename = path_stem(path)[1] + if (filename.length > len-2) { + //may all fit, but can't squeeze more info + displayPath = filename.substr(0,len); + } else { + //TODO: find solution for windows, path[1] == ':'? + let isAbs = path[0] == '/'; + let partial = path.substr(path.length - (isAbs ? len-2:len-1)) + let cutoff = partial.indexOf('/'); + if (cutoff < 0) { + //Can occur, but there isn't a nicer way to format + displayPath = path.substr(path.length-len); + } else { + displayPath = (isAbs ? '/…':'…') + partial.substr(cutoff); + } + } + return [displayPath, ctx.measureText(displayPath).width] +} +function roundToPrecision(num, precision) { + let strnum = Number(num).toFixed(precision) + let deci = strnum.indexOf('.') + if (deci > 0) { + let i = strnum.length - 1 + while (i > deci && strnum[i] == '0') { + i-- + } + if (i == deci) { + i-- + } + return strnum.slice(0, i+1) + } + return strnum +} function inner_value_change(widget, value, node, pos) { widget.value = value if (widget.options?.property && widget.options.property in node.properties) { @@ -1451,7 +1536,6 @@ function drawAnnotated(ctx, node, widget_width, y, H) { const litegraph_base = LiteGraph const show_text = app.canvas.ds.scale >= (app.canvas.low_quality_zoom_threshold ?? 0.5) const margin = 15 - ctx.textAlign = 'left' ctx.strokeStyle = litegraph_base.WIDGET_OUTLINE_COLOR ctx.fillStyle = litegraph_base.WIDGET_BGCOLOR ctx.beginPath() @@ -1460,21 +1544,31 @@ function drawAnnotated(ctx, node, widget_width, y, H) { else ctx.rect(margin, y, widget_width - margin * 2, H) ctx.fill() if (show_text) { - const monospace_font = ctx.font.split(' ')[0] + ' monospace' if (!this.disabled) ctx.stroke() const button = button_action(this) if (button != 'None') { ctx.save() - ctx.font = monospace_font if (button.startsWith('No ')) { ctx.fillStyle = litegraph_base.WIDGET_OUTLINE_COLOR + ctx.strokeStyle = litegraph_base.WIDGET_OUTLINE_COLOR } else { ctx.fillStyle = litegraph_base.WIDGET_TEXT_COLOR + ctx.strokeStyle = litegraph_base.WIDGET_TEXT_COLOR } + ctx.beginPath() if (button.endsWith('Reset')) { - ctx.fillText('\u21ba', widget_width - margin - 33, y + H * 0.7) + ctx.arc(widget_width - margin - 26, y + H/2, 4, Math.PI*3/2, Math.PI) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(widget_width - margin - 26, y + H/2 - 1.5) + ctx.lineTo(widget_width - margin - 26, y + H/2 - 6.5) + ctx.lineTo(widget_width - margin - 30, y + H/2 - 3.5) + ctx.fill() } else { - ctx.fillText('\u2298', widget_width - margin - 33, y + H * 0.7) + ctx.arc(widget_width - margin - 26, y + H/2, 4, Math.PI*2/3, Math.PI*8/3) + ctx.moveTo(widget_width - margin - 26 - 8 ** .5, y + H/2 + 8 ** .5) + ctx.lineTo(widget_width - margin - 26 + 8 ** .5, y + H/2 - 8 ** .5) + ctx.stroke() } ctx.restore() } @@ -1491,30 +1585,37 @@ function drawAnnotated(ctx, node, widget_width, y, H) { ctx.lineTo(widget_width - margin - 16, y + H - 5) ctx.fill() } + let freeWidth = widget_width - (40 + margin * 2 + 20) + let [valueText, valueWidth] = fitText(ctx, this.displayValue(), freeWidth) + freeWidth -= valueWidth + + ctx.textAlign = 'left' ctx.fillStyle = litegraph_base.WIDGET_SECONDARY_TEXT_COLOR - ctx.fillText(this.label || this.name, margin * 2 + 5, y + H * 0.7) - ctx.fillStyle = litegraph_base.WIDGET_TEXT_COLOR + if (freeWidth > 20) { + let [name, nameWidth] = fitText(ctx, this.label || this.name, freeWidth) + freeWidth -= nameWidth + ctx.fillText(name, margin * 2 + 5, y + H * 0.7) + } + + let value_offset = margin * 2 + 20 ctx.textAlign = 'right' - const text = this.displayValue() - let value_offset = margin * 2 + 20 if (this.options.unit) { - ctx.save() - ctx.font = monospace_font ctx.fillStyle = litegraph_base.WIDGET_OUTLINE_COLOR - ctx.fillText(this.options.unit, widget_width - value_offset, y + H * 0.7) - value_offset += ctx.measureText(this.options.unit).width - ctx.restore() + let [unitText, unitWidth] = fitText(ctx, this.options.unit, freeWidth) + if (unitText == this.options.unit) { + ctx.fillText(this.options.unit, widget_width - value_offset, y + H * 0.7) + value_offset += unitWidth + freeWidth -= unitWidth + } } - ctx.fillText(text, widget_width - value_offset, y + H * 0.7) + ctx.fillStyle = litegraph_base.WIDGET_TEXT_COLOR + ctx.fillText(valueText, widget_width - value_offset, y + H * 0.7) + ctx.fillStyle = litegraph_base.WIDGET_SECONDARY_TEXT_COLOR - const value_width = ctx.measureText(text).width - const name_width = ctx.measureText(this.label || this.name).width - const free_width = - widget_width - (value_width + name_width + value_offset + 40) let annotation = '' if (this.annotation) { - annotation = this.annotation(this.value, free_width) + annotation = this.annotation(this.value, freeWidth) } else if ( this.options.annotation && this.value in this.options.annotation @@ -1523,35 +1624,34 @@ function drawAnnotated(ctx, node, widget_width, y, H) { } if (annotation) { ctx.fillStyle = litegraph_base.WIDGET_OUTLINE_COLOR - const annotation_width = ctx.measureText(annotation).width - if (free_width < annotation_width) { - //Enforcing a widget's requested minimum width seems ill supported - //hiding annotation is best, but existence should still be indicated - annotation = '…' - } + let [annoDisplay, annoWidth] = fitText(ctx, annotation, freeWidth) ctx.fillText( - annotation, - widget_width - 5 - value_width - value_offset, + annoDisplay, + widget_width - 5 - valueWidth - value_offset, y + H * 0.7 ) } } } function mouseAnnotated(event, [x, y], node) { - const button = button_action(this) + //NOTE: Mouse actions contain no history element. + //This can cause overlapping actions since each triggers on different event type (down/move/up) + //TODO: Consider further rework const widget_width = this.width || node.size[0] const old_value = this.value - const delta = x < 40 ? -1 : x > widget_width - 48 ? 1 : 0 const margin = 15 - var allow_scroll = true - if (delta) { - if (x > -3 && x < widget_width + 3) { - allow_scroll = false - } + let isButton = 0 + if (x > margin + 6 && x < margin + 16) { + isButton = -1 + } else if (x > widget_width - margin - 16 & x < widget_width - margin - 6) { + isButton = 1 + } else if (x > widget_width - margin - 34 && x < widget_width - margin - 18) { + isButton = 2 } + var allow_scroll = true if (allow_scroll && event.type == 'pointermove') { if (event.deltaX) - this.value += event.deltaX * 0.1 * (this.options.step || 1) + this.value += event.deltaX * (this.options.step || 1) if (this.options.min != null && this.value < this.options.min) { this.value = this.options.min } @@ -1559,14 +1659,15 @@ function mouseAnnotated(event, [x, y], node) { this.value = this.options.max } } else if (event.type == 'pointerdown') { - if (x > widget_width - margin - 34 && x < widget_width - margin - 18) { - if (button == 'Reset') { + const buttonType = button_action(this) + if (isButton == 2) { + if (buttonType == 'Reset') { this.value = this.options.reset - } else if (button == 'Disable') { + } else if (buttonType == 'Disable') { this.value = this.options.disable } } else { - this.value += delta * 0.1 * (this.options.step || 1) + this.value += isButton * (this.options.step || 1) if (this.options.min != null && this.value < this.options.min) { this.value = this.options.min } @@ -1576,7 +1677,7 @@ function mouseAnnotated(event, [x, y], node) { } } //end mousedown else if (event.type == 'pointerup') { - if (event.click_time < 200 && delta == 0) { + if (event.click_time < 200 && !isButton) { const d_callback = (v) => { this.value = this.parseValue?.(v) ?? Number(v) inner_value_change(this, this.value, node, [x, y]) @@ -1618,81 +1719,6 @@ function mouseAnnotated(event, [x, y], node) { ) return true } -function makeAnnotated(widget, inputData) { - const callback_orig = widget.callback - Object.assign(widget, { - type: "BOOLEAN",//Horrific, not namespaced, nonsensical, easier than upstreaming - draw: drawAnnotated, - mouse: mouseAnnotated, - computeSize(width) { - return [width, 20] - }, - callback(v) { - if (v == 0) { - return - } - if (this.options?.mod == undefined) { - return callback_orig.apply(this, arguments); - } - const s = this.options.step / 10 - let sh = this.options.mod - this.value = Math.round((v - sh) / s) * s + sh - }, - config: inputData, - displayValue: function() { - return Number(this.value).toFixed(this.options.precision !== - undefined ? this.options.precision : 3) - }, - options: Object.assign({}, inputData[1], widget.options) - }) - return widget -} -function makeTimestamp(widget, inputData=["FLOAT",{"disable": 0}]) { - Object.assign(widget, { - type: "BOOLEAN", - draw: drawAnnotated, - mouse: mouseAnnotated, - computeSize(width) { - return [width, 20] - }, - parseValue(v) { - if (typeof(v) == "string") { - let val = 0 - for (let chunk of v.split(":")) { - val = val * 60 + parseFloat(chunk) - } - return val - } - }, - callback(v) {}, - config: inputData, - options: Object.assign({}, inputData[1], widget.options), - displayValue() { - let seconds = this.value - let hours = seconds / 3600 | 0 - seconds -= 3600 * hours - let minutes = seconds / 60 | 0 - seconds -= 60 * minutes - let display = "" - if (hours > 0) { - display += hours + ":" - } - if (hours > 0 || minutes > 0) { - if (hours > 0) { - minutes = (''+minutes).padStart(2,'0') - } - display += minutes + ":" - } - seconds = seconds.toFixed(4) - if (seconds[1] == '.' && (minutes > 0 || hours > 0)) { - seconds = '0'+seconds - } - display += seconds - return display - } - }) - return widget -} let latentPreviewNodes = new Set() app.registerExtension({ name: "VideoHelperSuite.Core", @@ -1744,7 +1770,8 @@ app.registerExtension({ onChange(value) { if (!value) { //Remove any previewWidgets - for (let n of latentPreviewNodes) { + for (let id of latentPreviewNodes) { + let n = app.graph.getNodeById(id) let i = n?.widgets?.findIndex((w) => w.name == 'vhslatentpreview') if (i >= 0) { n.widgets.splice(i,1)[0].onRemove() @@ -1804,6 +1831,15 @@ app.registerExtension({ this.setSize(this.computeSize()) }) } + //set widgetType to use VHS widgets where possible + for(let inp of Object.values({...nodeData.input?.required, ...nodeData.input?.optional})) { + if (["INT", "FLOAT"].includes(inp[0])) { + if (!inp[1]) { + inp[1] = {} + } + inp[1].widgetType ??= "VHS" + inp[0] + } + } chainCallback(nodeType.prototype, "onNodeCreated", function () { let new_widgets = [] if (this.widgets) { @@ -1815,8 +1851,6 @@ app.registerExtension({ } if (w?.type == "text" && config[1].vhs_path_extensions) { new_widgets.push(app.widgets.VHSPATH({}, w.name, ["VHSPATH", config[1]])); - } else if (w?.type == "number") { - new_widgets.push(makeAnnotated(w, config)) } else { new_widgets.push(w) } @@ -1855,7 +1889,6 @@ app.registerExtension({ }); addLoadCommon(nodeType, nodeData); } else if (nodeData?.name == "VHS_LoadImagesPath") { - addUploadWidget(nodeType, nodeData, "directory", "folder"); chainCallback(nodeType.prototype, "onNodeCreated", function() { const pathWidget = this.widgets.find((w) => w.name === "directory"); chainCallback(pathWidget, "callback", (value) => { @@ -1893,16 +1926,8 @@ app.registerExtension({ } else if (nodeData?.name == "VHS_LoadAudioUpload") { addUploadWidget(nodeType, nodeData, "audio", "audio"); applyVHSAudioLinksFix(nodeType, nodeData, 0) - chainCallback(nodeType.prototype, "onNodeCreated", function() { - const w = this.widgets.find((w) => w.name === "start_time"); - makeTimestamp(w) - }) } else if (nodeData?.name == "VHS_LoadAudio"){ applyVHSAudioLinksFix(nodeType, nodeData, 0) - chainCallback(nodeType.prototype, "onNodeCreated", function() { - const w = this.widgets.find((w) => w.name === "seek_seconds"); - makeTimestamp(w) - }) } else if (nodeData?.name == "VHS_LoadVideoPath" || nodeData?.name == "VHS_LoadVideoFFmpegPath") { chainCallback(nodeType.prototype, "onNodeCreated", function() { const pathWidget = this.widgets.find((w) => w.name === "video"); @@ -2062,41 +2087,23 @@ app.registerExtension({ ctx.clip(); //ctx.stroke(); + let freeWidth = widget_width - (margin * 2 + 40) ctx.fillStyle = secondary_text_color; const label = this.label || this.name; if (label != null) { - ctx.fillText(label, margin * 2, y + H * 0.7); + let [labelDisplay, labelWidth] = fitText(ctx, label, freeWidth) + freeWidth -= labelWidth + ctx.fillText(labelDisplay, margin * 2, y + H * 0.7); } ctx.fillStyle = this.value ? text_color : '#777'; ctx.textAlign = "right"; - let disp_text = this.format_path(String(this.value || this.options.placeholder)) - ctx.fillText(disp_text, widget_width - margin * 2, y + H * 0.7); //30 chars max + let disp_text = fitPath(ctx, String(this.value || this.options.placeholder), freeWidth)[0] + ctx.fillText(disp_text, widget_width - margin * 2, y + H * 0.7); ctx.restore(); } }, mouse : searchBox, options : {}, - format_path : function(path, len=30) { - //Formats the full path to be under 30 characters - if (path.length <= len) { - return path; - } - let filename = path_stem(path)[1] - if (filename.length > len-2) { - //may all fit, but can't squeeze more info - return filename.substr(0,len); - } - //TODO: find solution for windows, path[1] == ':'? - let isAbs = path[0] == '/'; - let partial = path.substr(path.length - (isAbs ? len-2:len-1)) - let cutoff = partial.indexOf('/'); - if (cutoff < 0) { - //Can occur, but there isn't a nicer way to format - return path.substr(path.length-len); - } - return (isAbs ? '/…':'…') + partial.substr(cutoff); - - } }; if (inputData.length > 1) { w.options = inputData[1] @@ -2112,16 +2119,128 @@ app.registerExtension({ return w; }, VHSFLOAT(node, inputName, inputData) { - let w = app.widgets.FLOAT(node, inputName, inputData, app) - return makeAnnotated(w, inputData); + let w = { + name: inputName, + type: "VHS.ANNOTATED", + value: inputData[1]?.default ?? 0, + draw: drawAnnotated, + mouse: mouseAnnotated, + computeSize(width) { + return [width, 20] + }, + callback(v) { + if (this.options.round) { + //TODO adopt ComfyUI_frontend#4291? + v = Math.round((v + Number.EPSILON) / + this.options.round) * this.options.round + } + if (this.options.max && v > this.options.max) { + v = this.options.max + } + if (this.options.min && v < this.options.max) { + v = this.options.min + } + this.value = v + }, + config: inputData, + displayValue: function() { + return roundToPrecision(this.value, this.options.precision ?? 3) + }, + options: Object.assign({}, inputData[1]) + } + if (!node.widgets) { + node.widgets = [] + } + node.widgets.push(w) + return w }, VHSINT(node, inputName, inputData) { - let w = app.widgets.INT(node, inputName, inputData, app) - return makeAnnotated(w, inputData); + let w = { + name: inputName, + type: "VHS.ANNOTATED", + value: inputData[1]?.default ?? 0, + draw: drawAnnotated, + mouse: mouseAnnotated, + computeSize(width) { + return [width, 20] + }, + callback(v) { + if (this.options.max && v > this.options.max) { + v = this.options.max + } + if (this.options.min && v < this.options.min) { + v = this.options.min + } + if (v == 0) { + return + } + const s = this.options.step + let sh = this.options.mod ?? 1 + this.value = Math.round((v - sh) / s) * s + sh + }, + config: inputData, + displayValue: function() { + return this.value | 0 + }, + options: Object.assign({}, inputData[1]) + } + if (!node.widgets) { + node.widgets = [] + } + node.widgets.push(w) + return w }, VHSTIMESTAMP(node, inputName, inputData) { - let w = app.widgets.FLOAT(node, inputName, inputData, app) - return makeTimestamp(w, inputData) + let w = { + name: inputName, + type: "VHS.TIMESTAMP", + value: inputData[1]?.default ?? 0, + draw: drawAnnotated, + mouse: mouseAnnotated, + computeSize(width) { + return [width, 20] + }, + parseValue(v) { + if (typeof(v) == "string") { + let val = 0 + for (let chunk of v.split(":")) { + val = val * 60 + parseFloat(chunk) + } + return val + } + }, + callback(v) {}, + config: inputData, + options: Object.assign({}, inputData[1]), + displayValue() { + let seconds = this.value + let hours = seconds / 3600 | 0 + seconds -= 3600 * hours + let minutes = seconds / 60 | 0 + seconds -= 60 * minutes + let display = "" + if (hours > 0) { + display += hours + ":" + } + if (hours > 0 || minutes > 0) { + if (hours > 0) { + minutes = (''+minutes).padStart(2,'0') + } + display += minutes + ":" + } + seconds = roundToPrecision(seconds, 4) + if ((seconds[1] == '.' || seconds.length == 1) && (minutes > 0 || hours > 0)) { + seconds = '0'+seconds + } + display += seconds + return display + } + } + if (!node.widgets) { + node.widgets = [] + } + node.widgets.push(w) + return w }, } }, @@ -2234,21 +2353,16 @@ api.addEventListener('executing', ({ detail }) => { } } }) -api.addEventListener('VHS_latentpreview', ({ detail }) => { - let setting = app.ui.settings.getSettingValue("VHS.LatentPreview") - if (!setting) { - return +function getLatentPreviewCtx(id, width, height) { + const node = getNodeById(id) + if (!node) { + return undefined } - let id = app.runningNodeId - if (id == null) { - return - } - let previewNode = app.graph.getNodeById(id) - latentPreviewNodes.add(previewNode) - let previewWidget = previewNode.widgets.find((w) => w.name == "vhslatentpreview") + + let previewWidget = node.widgets.find((w) => w.name == "vhslatentpreview") if (!previewWidget) { let canvasEl = document.createElement("canvas") - previewWidget = previewNode.addDOMWidget("vhslatentpreview", "vhscanvas", canvasEl, { + previewWidget = node.addDOMWidget("vhslatentpreview", "vhscanvas", canvasEl, { serialize: false, hideOnZoom: false, }); @@ -2276,7 +2390,7 @@ api.addEventListener('VHS_latentpreview', ({ detail }) => { previewWidget.computeSize = function(width) { if (this.aspectRatio) { - let height = (previewNode.size[0]-20)/ this.aspectRatio + 10; + let height = (node.size[0]-20)/ this.aspectRatio + 10; if (!(height > 0)) { height = 0; } @@ -2286,16 +2400,40 @@ api.addEventListener('VHS_latentpreview', ({ detail }) => { return [width, -4];//no loaded src, widget should not display } } - let firstPreview = true - let ctx + let canvasEl = previewWidget.element + if (!previewWidget.ctx || canvasEl.width != width + || canvasEl.height != height) { + previewWidget.aspectRatio = width / height + canvasEl.width = width + canvasEl.height = height + fitHeight(node) + } + return canvasEl.getContext("2d") +} +//TODO: Figure out means of concurrency here. map of active nodes and finish event? +// Information has been squirreled away to the execution store which isn't exposed. +api.addEventListener('VHS_latentpreview', ({ detail }) => { + let setting = app.ui.settings.getSettingValue("VHS.LatentPreview") + if (!setting) { + return + } + let id = detail.id + if (id == null) { + return + } + latentPreviewNodes.add(id) + previewImages = [] previewImages.length = detail.length let displayIndex = 0 if (animateInterval) { clearTimeout(animateInterval) } + //While progress is safely cleared on execution completion. + //Initial progress must be started here to avoid a race condition + node.progress = 0 animateInterval = setInterval(() => { - if (app.runningNodeId != id) { + if (getNodeById(id).progress == undefined) { clearTimeout(animateInterval) animateInterval = undefined return @@ -2303,15 +2441,8 @@ api.addEventListener('VHS_latentpreview', ({ detail }) => { if (!previewImages[displayIndex]) { return } - let canvasEl = previewWidget.element - if (!ctx) { - previewWidget.aspectRatio = previewImages[displayIndex].width / previewImages[displayIndex].height - canvasEl.width = previewImages[displayIndex].width - canvasEl.height = previewImages[displayIndex].height - ctx = canvasEl.getContext("2d") - fitHeight(previewNode) - } - ctx.drawImage(previewImages[displayIndex],0,0) + getLatentPreviewCtx(id, previewImages[displayIndex].width, + previewImages[displayIndex].height)?.drawImage?.(previewImages[displayIndex],0,0) displayIndex = (displayIndex + 1) % previewImages.length }, 1000/detail.rate); }); @@ -2322,8 +2453,10 @@ api.addEventListener('b_preview', async (e) => { e.preventDefault() e.stopImmediatePropagation() e.stopPropagation() - const ab = await e.detail.slice(0,8).arrayBuffer() - const index = new DataView(ab).getUint32(4) - previewImages[index] = await window.createImageBitmap(e.detail.slice(8)) + const dv = new DataView(await e.detail.slice(0,24).arrayBuffer()) + const index = dv.getUint32(4) + //const idlen = dv.getUint8(5) + //const id = dv.getstring???(6,idlen) + previewImages[index] = await window.createImageBitmap(e.detail.slice(24)) return false }, true); diff --git a/web/js/videoinfo.js b/web/js/videoinfo.js index 2e52020..b1a9871 100644 --- a/web/js/videoinfo.js +++ b/web/js/videoinfo.js @@ -11,7 +11,7 @@ function getVideoMetadata(file) { let decoder = new TextDecoder(); // Check for known valid magic strings if (dataView.getUint32(0) == 0x1A45DFA3) { - //webm + //webm/mkv (both use EBML/Matroska format) //see http://wiki.webmproject.org/webm-metadata/global-metadata //and https://www.matroska.org/technical/elements.html //contrary to specs, tag seems consistently at start @@ -76,6 +76,9 @@ function isVideoFile(file) { if (file?.name?.endsWith(".mp4")) { return true; } + if (file?.name?.endsWith(".mkv")) { + return true; + } return false; } @@ -83,8 +86,8 @@ function isVideoFile(file) { let originalHandleFile = app.handleFile; app.handleFile = handleFile; let fileInput = document.getElementById("comfy-file-input") -//hijack comfy-file-input to allow webm/mp4 -fileInput.accept += ",video/webm,video/mp4"; +//hijack comfy-file-input to allow webm/mp4/mkv +fileInput.accept += ",video/webm,video/mp4,video/x-matroska"; async function handleFile(file) { if (file?.type?.startsWith("video/") || isVideoFile(file)) {