Merge 1.7.0
This commit is contained in:
+1
-1
@@ -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"]
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]))
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user