Merge 1.7.0

This commit is contained in:
AustinMroz
2025-07-26 15:59:14 -05:00
committed by GitHub
12 changed files with 917 additions and 326 deletions
+1 -1
View File
@@ -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"]
+3
View File
@@ -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")
+423
View File
@@ -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
}
}
+2 -2
View File
@@ -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
+17
View File
@@ -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"
}
+3 -2
View File
@@ -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
+18 -9
View File
@@ -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",),
+30 -21
View File
@@ -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)
+31 -40
View File
@@ -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"]))
+2
View File
@@ -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))
+381 -248
View File
@@ -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);
+6 -3
View File
@@ -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)) {