Feature branch cleanup

Switch to new path widget code on remaining nodes

Performance tuning on video preview code
Surround preview code in try block in case request is closed
- May warrant a future pass, but ffmpeg errors will still log to console

Swap lack of common path in get_path to status=403
- This was mostly made to ease future changes to common path behaviour

Simplify the value field of preview widget.
- Reduces complexity and lessens duplicate requests on first loa

Introduce ui setting for advanced previews
- Reliability is shakey, so it's better kept as opt-in for now

Previews are properly integrated and apply to Video Combine nodes

More advanced formatting code when a path is too long to display

Reduced console spam from path suggestions
This commit is contained in:
Austin Mroz
2023-11-30 14:29:03 -06:00
parent e8e6971eaf
commit f63a0f789d
4 changed files with 110 additions and 83 deletions
+1 -2
View File
@@ -34,9 +34,8 @@ def target_size(width, height, force_size) -> tuple[int, int]:
return (width, height)
def load_video_cv(video: str, force_rate: int, force_size: str, frame_load_cap: int, skip_first_frames: int, select_every_nth: int):
filename = folder_paths.get_annotated_filepath(video.strip("\""))
try:
video_cap = cv2.VideoCapture(filename)
video_cap = cv2.VideoCapture(video)
if not video_cap.isOpened():
raise ValueError(f"{video} could not be loaded with cv.")
# set video_cap to look at start_index frame
+5 -4
View File
@@ -290,17 +290,18 @@ class LoadAudio:
#Hide ffmpeg formats if ffmpeg isn't available
return {
"required": {
"audio_file": ("STRING", {"default": ""}),
}
"audio_file": ("VHSPATH", {"default": "input/", "extensions": ['wav','mp3','ogg','m4a','flac']}),
},
"optional" : {"seek_seconds": ("FLOAT", {"default": 0, "min": 0})}
}
RETURN_TYPES = ("AUDIO",)
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
FUNCTION = "load_audio"
def load_audio(self, audio_file):
def load_audio(self, audio_file, seek_seconds):
#Eagerly fetch the audio since the user must be using it if the
#node executes, unlike Load Video
audio = get_audio(audio_file)
audio = get_audio(audio_file, start_time=seek_seconds)
return (lambda : audio,)
@classmethod
+22 -17
View File
@@ -51,23 +51,26 @@ async def view_video(request):
args += ["-vf", ",".join(vfilters)]
if int(query.get('frame_load_cap', 0)) > 0:
args += ["-frames:v", query['frame_load_cap']]
args += ['-f', 'webm', '-']
args += ['-c:v', 'libvpx-vp9','-deadline', 'realtime', '-cpu-used', '8', '-f', 'webm', '-']
with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
resp = web.StreamResponse()
resp.content_type = 'video/webm'
resp.headers["Content-Disposition"] = f"filename=\"{filename}\""
await resp.prepare(request)
while True:
bytes_read = proc.stdout.read()
if bytes_read is None:
#TODO: check for timeout here
time.sleep(.1)
continue
if len(bytes_read) == 0:
break
await resp.write(bytes_read)
return resp
try:
with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
resp = web.StreamResponse()
resp.content_type = 'video/webm'
resp.headers["Content-Disposition"] = f"filename=\"{filename}\""
await resp.prepare(request)
while True:
bytes_read = proc.stdout.read()
if bytes_read is None:
#TODO: check for timeout here
time.sleep(.1)
continue
if len(bytes_read) == 0:
break
await resp.write(bytes_read)
except:
pass
return resp
@server.PromptServer.instance.routes.get("/getpath")
async def get_path(request):
@@ -79,7 +82,9 @@ async def get_path(request):
#For now, only continue if subpath of comfui
basedir = os.path.abspath('.')
common_path = os.path.commonpath([basedir, path])
if common_path != basedir or not os.path.exists(path):
if common_path != basedir:
return web.Response(status=403)
if not os.path.exists(path):
return web.Response(status=404)
#Use get so None is default instead of keyerror
+82 -60
View File
@@ -380,7 +380,7 @@ function addVideoPreview(nodeType) {
//videopreview is widget name
//The previous implementation used type to distinguish between a video and gif,
//but the type is not serialized and would not survive a reload
var previewWidget = { name : "videopreview", type : "preview", value : "",
var previewWidget = { name : "videopreview", type : "preview",
draw : function(ctx, node, widgetWidth, widgetY, height) {
//update widget position, hide if off-screen
const transform = ctx.getTransform();
@@ -407,7 +407,7 @@ function addVideoPreview(nodeType) {
}
return [width, -4];//no loaded src, widget should not display
},
_value : {hidden: false, paused: false}
value : {hidden: false, paused: false}
};
//onRemoved isn't a litegraph supported function on widgets
//Given that onremoved widget and node callbacks are sparse, this
@@ -445,15 +445,25 @@ function addVideoPreview(nodeType) {
fitHeight(this);
};
this.setPreviewsrc = (params) => {previewWidget._value.params = params; this._setPreviewsrc(params)};
this.setPreviewsrc = (params) => {previewWidget.value.params = params; this._setPreviewsrc(params)};
this._setPreviewsrc = function (params) {
if (params == undefined) {
return
}
previewWidget.parentEl.hidden = previewWidget._value.hidden;
previewWidget.parentEl.hidden = previewWidget.value.hidden;
if (params?.format?.split('/')[0] == 'video') {
previewWidget.videoEl.autoplay = !previewWidget._value.paused && !previewWidget._value.hidden;
previewWidget.videoEl.src = api.apiURL('/view?' + new URLSearchParams(params));
previewWidget.videoEl.autoplay = !previewWidget.value.paused && !previewWidget.value.hidden;
//TODO: Add in ui config option to disable rendered previews
if (previewWidget.parentEl.style?.width) {
params.force_size = previewWidget.parentEl.style.width.slice(0,-2) + "x?";
} else {
params.force_size = "256x?"
}
if (app.ui.settings.getSettingValue("VHS.AdvancedPreviews", false)) {
previewWidget.videoEl.src = api.apiURL('/viewvideo?' + new URLSearchParams(params));
} else {
previewWidget.videoEl.src = api.apiURL('/view?' + new URLSearchParams(params));
}
previewWidget.videoEl.hidden = false;
previewWidget.imgEl.hidden = true;
} else {
@@ -463,21 +473,6 @@ function addVideoPreview(nodeType) {
previewWidget.imgEl.hidden = false;
}
}
Object.defineProperty(previewWidget, "value", {
set : (value) => {
if (value) {
if (typeof(value) == "string") {
//old workflow which saves a url instead of constituent params
return;
}
previewWidget._value = value
this._setPreviewsrc(value.params)
}
},
get : () => {
return previewWidget._value;
}
});
//Hide video element if offscreen
//The multiline input implementation moves offscreen every frame
//and doesn't apply until a node with an actual inputEl is loaded
@@ -502,35 +497,37 @@ function addAdvancedPreview(nodeType) {
var stale = false;
var recent_request = false;
this.testPreview = () => {
this.updatePreview = () => {
if (recent_request) {
stale = true;
return;
}
if (!app.ui.settings.getSettingValue("VHS.AdvancedPreviews", false)) {
return
}
recent_request = true;
//TODO: show some kind of load indicator/spin
//here would accommodate staleness with a likely tolerable flicker
let params = previewWidget._value.params;
let params = previewWidget.value.params;
params.frame_load_cap = frameCapWidget.value;
params.skip_first_frames = frameSkipWidget.value;
params.force_rate = rateWidget.value;
//TODO: consider size handing
//Reading from size is easy. It's a question of tuning responsiveness
params.force_size = this.size[0] + "x?";
//size is handled in preview widget as it applies to output as well.
previewWidget.videoEl.src = api.apiURL('viewvideo?' + new URLSearchParams(params));
this.setPreviewsrc(params)
setTimeout(() =>{
recent_request = false;
if (stale) {
stale = false;
this.testPreview();
this.updatePreview();
}
},2e3);
}
chainCallback(frameCapWidget, "callback", this.testPreview);
chainCallback(frameSkipWidget, "callback", this.testPreview);
chainCallback(rateWidget, "callback", this.testPreview);
chainCallback(frameCapWidget, "callback", this.updatePreview);
chainCallback(frameSkipWidget, "callback", this.updatePreview);
chainCallback(rateWidget, "callback", this.updatePreview);
this.updatePreview();
});
}
function addPreviewOptions(nodeType) {
@@ -543,14 +540,13 @@ function addPreviewOptions(nodeType) {
let url = null
if (previewWidget.videoEl?.hidden == false && previewWidget.videoEl.src) {
url = previewWidget.videoEl.src;
//Use full quality video
url = api.apiURL('/view?' + new URLSearchParams(previewWidget.value.params));
} else if (previewWidget.imgEl?.hidden == false && previewWidget.imgEl.src) {
url = previewWidget.imgEl.src;
url = new URL(url);
}
if (url) {
url = new URL(url);
//placeholder from Save Image, will matter once preview functionality is implemented
//url.searchParams.delete('preview')
optNew.push(
{
content: "Open preview",
@@ -571,32 +567,32 @@ function addPreviewOptions(nodeType) {
}
);
}
const PauseDesc = (previewWidget._value.paused ? "Resume" : "Pause") + " preview";
const PauseDesc = (previewWidget.value.paused ? "Resume" : "Pause") + " preview";
if(previewWidget.videoEl.hidden == false) {
optNew.push({content: PauseDesc, callback: () => {
//animated images can't be paused and are more likely to cause performance issues.
//changing src to a single keyframe is possible,
//For now, the option is disabled if an animated image is being displayed
if(previewWidget._value.paused) {
if(previewWidget.value.paused) {
previewWidget.videoEl?.play();
} else {
previewWidget.videoEl?.pause();
}
previewWidget._value.paused = !previewWidget._value.paused;
previewWidget.value.paused = !previewWidget.value.paused;
}});
}
//TODO: Consider hiding elements if video no preview is available yet.
//TODO: Consider hiding elements if no video preview is available yet.
//It would reduce confusion at the cost of functionality
//(if a video preview lags the computer, the user should be able to hide in advance)
const visDesc = (previewWidget._value.hidden ? "Show" : "Hide") + " preview";
const visDesc = (previewWidget.value.hidden ? "Show" : "Hide") + " preview";
optNew.push({content: visDesc, callback: () => {
if (!previewWidget.videoEl.hidden && !previewWidget._value.hidden) {
if (!previewWidget.videoEl.hidden && !previewWidget.value.hidden) {
previewWidget.videoEl.pause();
} else if (previewWidget._value.hidden && !previewWidget.videoEl.hidden && !previewWidget._value.paused) {
} else if (previewWidget.value.hidden && !previewWidget.videoEl.hidden && !previewWidget.value.paused) {
previewWidget.videoEl.play();
}
previewWidget._value.hidden = !previewWidget._value.hidden;
previewWidget.parentEl.hidden = previewWidget._value.hidden;
previewWidget.value.hidden = !previewWidget.value.hidden;
previewWidget.parentEl.hidden = previewWidget.value.hidden;
fitHeight(this);
}});
@@ -698,6 +694,13 @@ function addFormatWidgets(nodeType) {
});
}
function path_stem(path) {
let i = path.lastIndexOf("/");
if (i >= 0) {
return [path.slice(0,i+1),path.slice(i+1)];
}
return ["",path];
}
function searchBox(event, [x,y], node) {
//Ensure only one dialogue shows at a time
if (this.prompt)
@@ -802,13 +805,6 @@ function searchBox(event, [x,y], node) {
}
options_element.appendChild(el);
}
function path_stem(path) {
let i = path.lastIndexOf("/");
if (i >= 0) {
return [path.slice(0,i+1),path.slice(i+1)];
}
return ["",path];
}
async function updateOptions() {
timeout = null;
let [path, remainder] = path_stem(input.value);
@@ -816,11 +812,12 @@ function searchBox(event, [x,y], node) {
//fetch options. Must block execution here, so update should be async?
let params = {path : path, extensions : pathWidget.options.extensions}
let optionsURL = api.apiURL('getpath?' + new URLSearchParams(params));
let resp = await fetch(optionsURL);
if (resp.ok)
try {
let resp = await fetch(optionsURL);
options = await resp.json();
else
} catch(e) {
options = []
}
last_path = path;
}
options_element.innerHTML = '';
@@ -840,6 +837,12 @@ function searchBox(event, [x,y], node) {
return dialog;
}
app.ui.settings.addSetting({
id: "VHS.AdvancedPreviews",
name: "VHS Advanced Previews",
type: "boolean",
defaultValue: false,
});
app.registerExtension({
name: "VideoHelperSuite.Core",
@@ -955,25 +958,44 @@ app.registerExtension({
}
ctx.fillStyle = text_color;
ctx.textAlign = "right";
//TODO: from start of file if filename > 30, include each dirlevel on fit
let disp_text = ((this.value.length > 29) ? '…' : "") + this.value.substr(-29)
let disp_text = this.format_path(String(this.value))
ctx.fillText(disp_text, widget_width - margin * 2, y + H * 0.7); //30 chars max
ctx.restore();
}
},
mouse : searchBox,
options : {}
options : {},
format_path : function(path) {
//Formats the full path to be under 30 characters
if (path.length <= 30) {
return path;
}
let filename = path_stem(path)[1]
if (filename.length > 28) {
//may all fit, but can't squeeze more info
return filename.substr(0,30);
}
let isAbs = path[0] == '/';
let partial = path.substr(path.length - (isAbs ? 28:29))
let cutoff = partial.indexOf('/');
if (cutoff < 0) {
console.warn("Failed to pretty format: " + path);
return path.substr(path.length-30);
}
return (isAbs ? '/…':'…') + partial.substr(cutoff);
}
};
if (inputData.length > 1) {
if (inputData[1].extensions) {
w.options.extensions = inputData[1].extensions
w.options.extensions = inputData[1].extensions;
}
if (inputData[1].default) {
w.value = inputData[1].default
w.value = inputData[1].default;
}
}
if (!node.widgets) {
node.widgets = []
node.widgets = [];
}
node.widgets.push(w);
return w;