From 001a508cb5325295eb87710d02cd241e56b85e35 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Tue, 6 May 2025 16:39:50 -0500 Subject: [PATCH 01/30] Allow drag drop on upload nodes. Fix out of date IS_CHANGED methods for Load Audio nodes fix incorrect parens in preview code. The string "0" is truthy. --- videohelpersuite/nodes.py | 4 +- videohelpersuite/server.py | 2 +- web/js/VHS.core.js | 76 ++++++++++++++++++++------------------ 3 files changed, 43 insertions(+), 39 deletions(-) diff --git a/videohelpersuite/nodes.py b/videohelpersuite/nodes.py index b42a0f3..13d757e 100755 --- a/videohelpersuite/nodes.py +++ b/videohelpersuite/nodes.py @@ -647,7 +647,7 @@ class LoadAudio: return (get_audio(audio_file, start_time=seek_seconds, duration=duration),) @classmethod - def IS_CHANGED(s, audio_file, seek_seconds): + def IS_CHANGED(s, audio_file, **kwargs): return hash_path(audio_file) @classmethod @@ -685,7 +685,7 @@ class LoadAudioUpload: return (get_audio(audio_file, start_time, 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..e353d06 100755 --- a/videohelpersuite/server.py +++ b/videohelpersuite/server.py @@ -67,7 +67,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']) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index fde859f..3d2127a 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -739,6 +739,8 @@ function initializeLoadFormat(nodeType, nodeData) { } 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 pathWidget = this.widgets.find((w) => w.name === widgetName); const fileInput = document.createElement("input"); @@ -783,50 +785,44 @@ 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) + if (resp.status != 200) { + return false + } + const filename = (await resp.json()).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 +832,8 @@ function addUploadWidget(nodeType, nodeData, widgetName, type="video") { fileInput.click(); }); uploadWidget.options.serialize = false; + + }); } @@ -885,6 +883,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"); From 38fbe354b1eff10ef8066a580402ae3e33a59f5d Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Tue, 6 May 2025 17:23:59 -0500 Subject: [PATCH 02/30] Update num_frames when pingpong is applied Closes #467 --- videohelpersuite/nodes.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/videohelpersuite/nodes.py b/videohelpersuite/nodes.py index 13d757e..a6e7e1e 100755 --- a/videohelpersuite/nodes.py +++ b/videohelpersuite/nodes.py @@ -450,14 +450,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: From 0d5b445a70a66e36b5ac8aa791d1a6e67db62aec Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Mon, 12 May 2025 15:50:18 -0500 Subject: [PATCH 03/30] Fix connections to format widgets --- web/js/VHS.core.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index 3d2127a..6bf30d8 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -1179,8 +1179,27 @@ function addFormatWidgets(nodeType, nodeData) { } 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; From 6ec6007553126948dd5729a9423374ab32ca3921 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Tue, 13 May 2025 12:55:42 -0500 Subject: [PATCH 04/30] Test fixes. Auto-close workflow if a test completes successfully. Add pro-res test Remove accidental absolute path from test --- testframework/web/js/testRunner.js | 3 + tests/old-prores.json | 423 +++++++++++++++++++++++++++++ tests/old-vae-conversion.json | 4 +- 3 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 tests/old-prores.json 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 From c953e12408166831a0e56534fa93c6422384dd63 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Wed, 21 May 2025 15:39:08 -0500 Subject: [PATCH 05/30] Cleanup optional arguments on Load Audio nodes duration, start_time, and the equivalent seek_seconds are now all marked as optional inputs and properly set to have default values. --- videohelpersuite/nodes.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/videohelpersuite/nodes.py b/videohelpersuite/nodes.py index a6e7e1e..877dbd2 100755 --- a/videohelpersuite/nodes.py +++ b/videohelpersuite/nodes.py @@ -630,8 +630,9 @@ 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"}), } } @@ -639,7 +640,7 @@ class LoadAudio: RETURN_NAMES = ("audio",) 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) @@ -668,9 +669,10 @@ 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"}), }, } @@ -680,7 +682,7 @@ class LoadAudioUpload: RETURN_NAMES = ("audio",) 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) From 2ab65b9c4cfabccd7905d332f0088f1c10ac09f0 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Wed, 21 May 2025 15:40:53 -0500 Subject: [PATCH 06/30] Cleanup timestamp widget conversion code --- videohelpersuite/load_video_nodes.py | 4 ++-- web/js/VHS.core.js | 22 +++++++--------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/videohelpersuite/load_video_nodes.py b/videohelpersuite/load_video_nodes.py index c102da4..46e9fe7 100644 --- a/videohelpersuite/load_video_nodes.py +++ b/videohelpersuite/load_video_nodes.py @@ -526,7 +526,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 +576,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/web/js/VHS.core.js b/web/js/VHS.core.js index 6bf30d8..4abb373 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -1236,7 +1236,6 @@ 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 : () => { @@ -1916,16 +1915,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"); @@ -2135,16 +2126,17 @@ app.registerExtension({ return w; }, VHSFLOAT(node, inputName, inputData) { - let w = app.widgets.FLOAT(node, inputName, inputData, app) - return makeAnnotated(w, inputData); + let {widget} = app.widgets.FLOAT(node, inputName, inputData, app) + return makeAnnotated(widget, inputData); }, VHSINT(node, inputName, inputData) { - let w = app.widgets.INT(node, inputName, inputData, app) - return makeAnnotated(w, inputData); + let {widget} = app.widgets.INT(node, inputName, inputData, app) + return makeAnnotated(widget, inputData); }, VHSTIMESTAMP(node, inputName, inputData) { - let w = app.widgets.FLOAT(node, inputName, inputData, app) - return makeTimestamp(w, inputData) + inputData = ["FLOAT", {...inputData[1], type: "FLOAT"}] + let {widget} = app.widgets.FLOAT(node, inputName, inputData, app) + return makeTimestamp(widget, inputData) }, } }, From d5ba30a754bf367b42e3406422fb5285a3d5cfe0 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Wed, 21 May 2025 17:09:22 -0500 Subject: [PATCH 07/30] Stroke widget buttons manually The buttons added to reset to either reset to default value, reset to a disabled value, or indicate that one of these is currently selected, displaying poorly depending on availability of system fonts. This is resolved by stroking the shapes manually. --- web/js/VHS.core.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index 4abb373..231a1f9 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -1482,21 +1482,32 @@ 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) + //approx 4*sin(PI*2/3), 4*cos(PI*2/3) + ctx.moveTo(widget_width - margin - 26 - 3.5, y + H/2 + 2) + ctx.lineTo(widget_width - margin - 26 + 3.5, y + H/2 - 2) + ctx.stroke() } ctx.restore() } @@ -1521,7 +1532,6 @@ function drawAnnotated(ctx, node, widget_width, y, H) { 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 From 63bfea3d747a3afa14088466eff18c6951e008b8 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Thu, 22 May 2025 01:54:41 -0500 Subject: [PATCH 08/30] Clean up mouse interaction, Also modifies angle of disable icon --- web/js/VHS.core.js | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index 231a1f9..9aea48f 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -1504,9 +1504,8 @@ function drawAnnotated(ctx, node, widget_width, y, H) { ctx.fill() } else { ctx.arc(widget_width - margin - 26, y + H/2, 4, Math.PI*2/3, Math.PI*8/3) - //approx 4*sin(PI*2/3), 4*cos(PI*2/3) - ctx.moveTo(widget_width - margin - 26 - 3.5, y + H/2 + 2) - ctx.lineTo(widget_width - margin - 26 + 3.5, y + H/2 - 2) + 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() @@ -1570,17 +1569,21 @@ function drawAnnotated(ctx, node, widget_width, y, H) { } } 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) @@ -1591,14 +1594,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 * 0.1 * (this.options.step || 1) if (this.options.min != null && this.value < this.options.min) { this.value = this.options.min } @@ -1608,7 +1612,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]) From a20beefa20734597cac7788ab8b6dbace51cc0ad Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Fri, 23 May 2025 01:09:31 -0500 Subject: [PATCH 09/30] Rework widget display to properly measure text Previously displaying values was a mix of naive guesswork and giving up. This display process has been rewritten to produce output that is much more likely to be usable even under tight width restraints. As part of this, the ordering in which different widget components are drawn has been reworked. --- web/js/VHS.core.js | 118 +++++++++++++++++++++++++++------------------ 1 file changed, 71 insertions(+), 47 deletions(-) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index 9aea48f..764bbd1 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -1460,6 +1460,46 @@ 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) -2 + + 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 inner_value_change(widget, value, node, pos) { widget.value = value if (widget.options?.property && widget.options.property in node.properties) { @@ -1473,7 +1513,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() @@ -1523,29 +1562,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.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 @@ -1554,15 +1601,10 @@ 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 ) } @@ -2090,41 +2132,23 @@ app.registerExtension({ ctx.clip(); //ctx.stroke(); + let freeWidth = widget_width - (margin * 2 + 20) 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] From a5946e88a2c5ae04f3e692d8ea485848501b6836 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Fri, 23 May 2025 11:17:49 -0500 Subject: [PATCH 10/30] More robust animated latent preview tracking --- web/js/VHS.core.js | 58 ++++++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index 764bbd1..64e1101 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -1822,7 +1822,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() @@ -2287,21 +2288,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 = app.graph.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, }); @@ -2329,7 +2325,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; } @@ -2339,8 +2335,27 @@ 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") +} +api.addEventListener('VHS_latentpreview', ({ detail }) => { + let setting = app.ui.settings.getSettingValue("VHS.LatentPreview") + if (!setting) { + return + } + let id = app.runningNodeId + if (id == null) { + return + } + latentPreviewNodes.add(id) + previewImages = [] previewImages.length = detail.length let displayIndex = 0 @@ -2356,15 +2371,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); }); From 688b796e588437ba0b3a782bb81aac0433c4bfcb Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Mon, 2 Jun 2025 20:55:40 -0500 Subject: [PATCH 11/30] Remove inheritance of VHS widgets from builtins Resolves #484 Fix truncation of trailing 0 when displaying values --- web/js/VHS.core.js | 223 +++++++++++++++++++++++++++------------------ 1 file changed, 135 insertions(+), 88 deletions(-) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index 64e1101..48af221 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -1164,15 +1164,12 @@ 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) } @@ -1500,6 +1497,21 @@ function fitPath(ctx, path, maxLength) { } 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) { @@ -1696,81 +1708,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", @@ -1883,6 +1820,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) { @@ -1894,8 +1840,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) } @@ -2165,17 +2109,120 @@ app.registerExtension({ return w; }, VHSFLOAT(node, inputName, inputData) { - let {widget} = app.widgets.FLOAT(node, inputName, inputData, app) - return makeAnnotated(widget, inputData); + let w = { + name: inputName, + type: "VHS.ANNOTATED", + draw: drawAnnotated, + mouse: mouseAnnotated, + computeSize(width) { + return [width, 20] + }, + callback(v) { + if (this.options.round) { + 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 {widget} = app.widgets.INT(node, inputName, inputData, app) - return makeAnnotated(widget, 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 (v == 0) { + return + } + const s = this.options.step / 10 + 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) { - inputData = ["FLOAT", {...inputData[1], type: "FLOAT"}] - let {widget} = app.widgets.FLOAT(node, inputName, inputData, app) - return makeTimestamp(widget, 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] == '.' && (minutes > 0 || hours > 0)) { + seconds = '0'+seconds + } + display += seconds + return display + } + } + if (!node.widgets) { + node.widgets = [] + } + node.widgets.push(w) + return w }, } }, From d7c47abc5e40d733f43b2de48c468a034d3920c1 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Mon, 2 Jun 2025 21:57:48 -0500 Subject: [PATCH 12/30] Use 204 for most failed server requests Previously, detailed response codes were used to indicate what went wrong on a failed server request. However, this produces un catchable error messages which are undesirable. Instead, these responses now use 204 to indicate that the request was successfully processed, but returned no content. --- videohelpersuite/server.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/videohelpersuite/server.py b/videohelpersuite/server.py index e353d06..179da0e 100755 --- a/videohelpersuite/server.py +++ b/videohelpersuite/server.py @@ -35,7 +35,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: @@ -191,7 +191,7 @@ async def query_video(request): 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 +211,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 +224,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 +235,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"])) From 7b26c04c8a9a3a92476eece5c9ed0f91a17ad094 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Tue, 3 Jun 2025 14:17:35 -0500 Subject: [PATCH 13/30] Apply Max/Min to VHSINT, initial value in VHSFLOAT --- web/js/VHS.core.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index 48af221..a3f9d9b 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -2112,6 +2112,7 @@ app.registerExtension({ let w = { name: inputName, type: "VHS.ANNOTATED", + value: inputData[1]?.default ?? 0, draw: drawAnnotated, mouse: mouseAnnotated, computeSize(width) { @@ -2153,6 +2154,12 @@ app.registerExtension({ return [width, 20] }, callback(v) { + if (this.options.max && v > this.options.max) { + v = this.options.max + } + if (this.options.min && v < this.options.max) { + v = this.options.min + } if (v == 0) { return } From 66e95e94718e858c6648053d243104cf4cd29b4a Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Tue, 3 Jun 2025 15:43:02 -0500 Subject: [PATCH 14/30] Marginal path display improvements --- web/js/VHS.core.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index a3f9d9b..e8f8eb8 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -1476,7 +1476,7 @@ function fitPath(ctx, path, maxLength) { return [path, fullLength] } //determine approx safe cutoff - let len = (maxLength / fullLength * path.length | 0) -2 + let len = (maxLength / fullLength * path.length | 0) - 1 let displayPath = '' let filename = path_stem(path)[1] @@ -2077,7 +2077,7 @@ app.registerExtension({ ctx.clip(); //ctx.stroke(); - let freeWidth = widget_width - (margin * 2 + 20) + let freeWidth = widget_width - (margin * 2 + 40) ctx.fillStyle = secondary_text_color; const label = this.label || this.name; if (label != null) { From 66e443b583439c3254c1d51835da683c42e6d6ee Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Thu, 12 Jun 2025 12:04:47 -0500 Subject: [PATCH 15/30] Add duration output to LoadAudio nodes. Resolves #488 Commit to bumping minor version with next update as this change introduces a new output. --- pyproject.toml | 2 +- videohelpersuite/nodes.py | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) 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/videohelpersuite/nodes.py b/videohelpersuite/nodes.py index 877dbd2..d081403 100755 --- a/videohelpersuite/nodes.py +++ b/videohelpersuite/nodes.py @@ -636,8 +636,8 @@ class LoadAudio: } } - 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=0, duration=0): @@ -648,7 +648,9 @@ 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, **kwargs): @@ -678,8 +680,8 @@ class LoadAudioUpload: 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=0, duration=0, **kwargs): @@ -687,7 +689,9 @@ class LoadAudioUpload: 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, **kwargs): From dc98c8be65c654f2abd23e45f1345ec7a8b6fdd9 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Sun, 22 Jun 2025 17:16:31 -0500 Subject: [PATCH 16/30] Fix hashing nonexistant files, widget rounding Some nodes "hash" (actually just mtime) input files to determine if re-execution is needed. A check has been added to not attempt this hashing if no file exists. Fix an incorrect bounds check in the newer frontend widget code. --- videohelpersuite/utils.py | 2 ++ web/js/VHS.core.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) 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 e8f8eb8..d740a2d 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -2157,7 +2157,7 @@ app.registerExtension({ if (this.options.max && v > this.options.max) { v = this.options.max } - if (this.options.min && v < this.options.max) { + if (this.options.min && v < this.options.min) { v = this.options.min } if (v == 0) { From 266f27aebaca22d43c2c9be53d3f6e862d0047e9 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Sun, 13 Jul 2025 20:03:35 -0500 Subject: [PATCH 17/30] Support loading transparent VP9 Resolves #502 --- videohelpersuite/load_video_nodes.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/videohelpersuite/load_video_nodes.py b/videohelpersuite/load_video_nodes.py index 46e9fe7..f94c608 100644 --- a/videohelpersuite/load_video_nodes.py +++ b/videohelpersuite/load_video_nodes.py @@ -180,6 +180,15 @@ 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_dummy = args_dummy[:1] + ["-c:v", "libvpx-vp9"] + args_dummy[1:] + 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) From 9e963ff5ed0b69e588ee3d965711766b0f3cf829 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Sun, 13 Jul 2025 20:59:47 -0500 Subject: [PATCH 18/30] Add progress bar to uploads Resolves #507 --- web/js/VHS.core.js | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index fde859f..1317822 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -448,8 +448,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 +462,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); } @@ -762,10 +764,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 +780,7 @@ function addUploadWidget(nodeType, nodeData, widgetName, type="video") { } } } + this.progress = undefined pathWidget.options.values.push(path); pathWidget.value = path; if (pathWidget.callback) { @@ -790,7 +795,9 @@ function addUploadWidget(nodeType, nodeData, widgetName, type="video") { style: "display: none", onchange: async () => { if (fileInput.files.length) { - let resp = await uploadFile(fileInput.files[0]) + let resp = await uploadFile(fileInput.files[0], + (p) => this.progress = p) + this.progress = undefined if (resp.status != 200) { //upload failed and file can not be added to options return; @@ -811,7 +818,8 @@ function addUploadWidget(nodeType, nodeData, widgetName, type="video") { style: "display: none", onchange: async () => { if (fileInput.files.length) { - let resp = await uploadFile(fileInput.files[0]) + let resp = await uploadFile(fileInput.files[0], + (p) => this.progress = p) if (resp.status != 200) { //upload failed and file can not be added to options return; @@ -1855,7 +1863,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) => { From f1698cbd92385876d4564ad5b1e1bcabd21407a2 Mon Sep 17 00:00:00 2001 From: Hoernchen <2532692+Hoernchen@users.noreply.github.com> Date: Mon, 21 Jul 2025 23:03:36 +0200 Subject: [PATCH 19/30] add ffv1 support FFV1 is a nice lossless format that allows recovering the original images for postprocessing. It is nowadays specified for MP4 containers as well, but ffmpeg support was only added in nov '24: https://git.ffmpeg.org/gitweb/ffmpeg.git/commit/0b8c9cf5b490a0231949ab3c6d797462ae001730 so for now MKV is enough. --- video_formats/ffv1-mkv.json | 17 +++++++++++++++++ web/js/videoinfo.js | 9 ++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 video_formats/ffv1-mkv.json 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/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)) { From f76436dcf2b958e9a3f5c3fa6beaa79650dce466 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Tue, 22 Jul 2025 01:16:39 -0500 Subject: [PATCH 20/30] Remove step multiplier, further timestamp fixes The VHSINT and VHSFLOAT widget constructors failed to multiply the step by 10. Since this step value is internal to VHS, this is instead fixed by removing the x10 multiplier entirely from the logic Integer seconds between 0 and 9 are now correctly padded in timestamps --- web/js/VHS.core.js | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index d740a2d..34d52e3 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -690,8 +690,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 +700,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) { @@ -1233,12 +1233,6 @@ function addLoadCommon(nodeType, nodeData) { } const offsetWidget = this.widgets.find((w) => w.name === "start_time"); if (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 : () => { @@ -1640,7 +1634,7 @@ function mouseAnnotated(event, [x, y], node) { 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 } @@ -1656,7 +1650,7 @@ function mouseAnnotated(event, [x, y], node) { this.value = this.options.disable } } else { - this.value += isButton * 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 } @@ -2163,7 +2157,7 @@ app.registerExtension({ if (v == 0) { return } - const s = this.options.step / 10 + const s = this.options.step let sh = this.options.mod ?? 1 this.value = Math.round((v - sh) / s) * s + sh }, @@ -2218,7 +2212,7 @@ app.registerExtension({ display += minutes + ":" } seconds = roundToPrecision(seconds, 4) - if (seconds[1] == '.' && (minutes > 0 || hours > 0)) { + if ((seconds[1] == '.' || seconds.length == 1) && (minutes > 0 || hours > 0)) { seconds = '0'+seconds } display += seconds From 7a50ec7ec817ab991511f657f0f1ad93b27de327 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Tue, 22 Jul 2025 15:09:58 -0500 Subject: [PATCH 21/30] Use PyAV for querrying videos, Round frame est Prior ffmpeg implementation provided insufficient precision for frame estimation and was heavier than desired. Regexes are no longer used Round frame_rate annotation to at most 2 digits --- videohelpersuite/server.py | 56 ++++++++++++++++---------------------- web/js/VHS.core.js | 3 +- 2 files changed, 26 insertions(+), 33 deletions(-) diff --git a/videohelpersuite/server.py b/videohelpersuite/server.py index 179da0e..3a03efc 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 @@ -12,6 +13,7 @@ from comfy.k_diffusion.utils import FolderOfImages web = server.web +vpxcc = av.Codec('libvpx-vp9', 'r').create() @server.PromptServer.instance.routes.get("/vhs/viewvideo") @server.PromptServer.instance.routes.get("/viewvideo") @@ -146,47 +148,37 @@ 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 * stream.time_base / 1000) - 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 = vpxcc + 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): diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index 34d52e3..fadd2a1 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -732,7 +732,7 @@ 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" } } }); @@ -2114,6 +2114,7 @@ app.registerExtension({ }, callback(v) { if (this.options.round) { + //TODO adopt ComfyUI_frontend#4291? v = Math.round((v + Number.EPSILON) / this.options.round) * this.options.round } From 8b62aa458d78462206eca7f52acfe244f194cb6a Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Thu, 24 Jul 2025 20:44:21 -0500 Subject: [PATCH 22/30] Ignore audio for gifski output format. Gifs do not support audio. Rather than crashing, VHS will now ignore any audio input when outputting to the gifski format. Thanks Mel. --- videohelpersuite/nodes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/videohelpersuite/nodes.py b/videohelpersuite/nodes.py index d081403..fa212d2 100755 --- a/videohelpersuite/nodes.py +++ b/videohelpersuite/nodes.py @@ -533,6 +533,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) From 3b2fb5f518e9b0be447ab9443c77ba0fcdfdaf1f Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Thu, 24 Jul 2025 22:45:24 -0500 Subject: [PATCH 23/30] Fix incorrect time base in query video --- videohelpersuite/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/videohelpersuite/server.py b/videohelpersuite/server.py index 3a03efc..dbd90e1 100755 --- a/videohelpersuite/server.py +++ b/videohelpersuite/server.py @@ -153,7 +153,7 @@ async def query_video(request): cont = av.open(filepath) stream = cont.streams.video[0] source['fps'] = float(stream.average_rate) - source['duration'] = float(cont.duration * stream.time_base / 1000) + source['duration'] = float(cont.duration / av.time_base) if stream.codec_context.name == 'vp9': cc = vpxcc From f01332d42c3be89cfc43cc486d9c9e19bd960f88 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Sat, 26 Jul 2025 01:38:31 -0500 Subject: [PATCH 24/30] Fix animated latent previews Resolves #516 This functions so long as multiple nodes aren't providing latent previews at once. I will need to take time to consider if such functionality would even be desirable. Does not resolve subgraphs. Graph traversal functions don't appear to be exposed, so I'll most likely need to re-implement myself, but I need to take the time to exhaustively verify first. --- videohelpersuite/latent_preview.py | 5 +++-- web/js/VHS.core.js | 14 +++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) 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/web/js/VHS.core.js b/web/js/VHS.core.js index b2926ac..8b39882 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -2400,12 +2400,14 @@ function getLatentPreviewCtx(id, width, height) { } 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 = app.runningNodeId + let id = detail.id if (id == null) { return } @@ -2418,7 +2420,7 @@ api.addEventListener('VHS_latentpreview', ({ detail }) => { clearTimeout(animateInterval) } animateInterval = setInterval(() => { - if (app.runningNodeId != id) { + if (!app.graph.getNodeById(id).progress) { clearTimeout(animateInterval) animateInterval = undefined return @@ -2438,8 +2440,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); From 253251fa5c4c64672d5858bc1b9cbcfa53770ca8 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Sat, 26 Jul 2025 12:20:14 -0500 Subject: [PATCH 25/30] Fix latent preview race cond, subgraphs interop Since the subgraph utils aren't exposed from core, a small stub function is just re implemented in VHS. Execution state seems be robust enough to trust core to clear this state, but it's initially set to 0 by VHS now to ensure the preview display doesn't race the message that the node is being processed. Unfortunately, the lack of ability to determine if a node is actually running means swapping the active workflow once again breaks the display of previews. I don't see a way to fix this without changes to core. While disappointing as a maintainer, the fix never being rolled out means this isn't a loss of functionality to end users. --- web/js/VHS.core.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index 8b39882..7b27bd2 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"], @@ -2344,7 +2354,7 @@ api.addEventListener('executing', ({ detail }) => { } }) function getLatentPreviewCtx(id, width, height) { - const node = app.graph.getNodeById(id) + const node = getNodeById(id) if (!node) { return undefined } @@ -2419,8 +2429,11 @@ api.addEventListener('VHS_latentpreview', ({ detail }) => { 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.graph.getNodeById(id).progress) { + if (getNodeById(id).progress == undefined) { clearTimeout(animateInterval) animateInterval = undefined return From be11f43838fd4eaa938a0fe602728107659f3a55 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Sat, 26 Jul 2025 15:24:34 -0500 Subject: [PATCH 26/30] Remove global vpx codec context Since codec contexts have state, reusing them is dangerous. --- videohelpersuite/server.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/videohelpersuite/server.py b/videohelpersuite/server.py index dbd90e1..4bda173 100755 --- a/videohelpersuite/server.py +++ b/videohelpersuite/server.py @@ -13,7 +13,6 @@ from comfy.k_diffusion.utils import FolderOfImages web = server.web -vpxcc = av.Codec('libvpx-vp9', 'r').create() @server.PromptServer.instance.routes.get("/vhs/viewvideo") @server.PromptServer.instance.routes.get("/viewvideo") @@ -156,7 +155,7 @@ async def query_video(request): source['duration'] = float(cont.duration / av.time_base) if stream.codec_context.name == 'vp9': - cc = vpxcc + cc = av.Codec('libvpx-vp9', 'r').create() else: cc = stream def fit(): From 285568f5303b9cf5b8754d4dee3fcd9996d76173 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Sat, 26 Jul 2025 15:38:20 -0500 Subject: [PATCH 27/30] Use libvpx-vp9 for main decode too An extra pass was added to the ffmpeg_frame_generation code to properly detect and handle alpha when loading vp9 videos. This early commit allowed for correct detection, but then failed to apply the decoder to the main pass, so no alpha was output. This has been fixed. --- videohelpersuite/load_video_nodes.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/videohelpersuite/load_video_nodes.py b/videohelpersuite/load_video_nodes.py index f94c608..03f4f1a 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: @@ -181,7 +182,8 @@ def ffmpeg_frame_generator(video, force_rate, frame_load_cap, start_time, + e.stderr.decode(*ENCODE_ARGS)) lines = dummy_res.stderr.decode(*ENCODE_ARGS) if "Video: vp9 " in lines: - args_dummy = args_dummy[:1] + ["-c:v", "libvpx-vp9"] + args_dummy[1:] + 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) @@ -214,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: From 97a57e38a899f9878f5a0c49f04edbe3e6cc637c Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Sat, 26 Jul 2025 15:49:56 -0500 Subject: [PATCH 28/30] Round tensors when converting to int This has been long considered. Special thanks to Mel for providing additional insight. --- videohelpersuite/nodes.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/videohelpersuite/nodes.py b/videohelpersuite/nodes.py index fa212d2..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) From 9601d8792ee39fc6f7edd1800f5d2ea6eca6f196 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Sat, 26 Jul 2025 15:53:25 -0500 Subject: [PATCH 29/30] Add bgra as known pixfmt with alpha Quick workaround for improved compatibility with ffv1 --- videohelpersuite/load_video_nodes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/videohelpersuite/load_video_nodes.py b/videohelpersuite/load_video_nodes.py index 03f4f1a..2c4ee3f 100644 --- a/videohelpersuite/load_video_nodes.py +++ b/videohelpersuite/load_video_nodes.py @@ -201,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) From eb36470be4942e2052a3c8975aefeec7d3eeac95 Mon Sep 17 00:00:00 2001 From: Austin Mroz Date: Sat, 26 Jul 2025 15:54:58 -0500 Subject: [PATCH 30/30] Fix missed step multiplier adjustment The step calculated from frame rate for start_time on load video ffmpeg nodes has now been adjusted to not have an unneeded x10 multiplier TODO: reconsider if this creates overly undesirable breakage if VHS frontend code is not used. --- web/js/VHS.core.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/js/VHS.core.js b/web/js/VHS.core.js index 7b27bd2..df8fa41 100755 --- a/web/js/VHS.core.js +++ b/web/js/VHS.core.js @@ -1253,7 +1253,7 @@ function addLoadCommon(nodeType, nodeData) { Object.defineProperty(offsetWidget.options, "step", { set : (value) => {}, get : () => { - return 10 / (this.video_query?.loaded?.fps ?? 1) + return 1 / (this.video_query?.loaded?.fps ?? 1) } }) }