From 65b9d85ac8902bce6e450525e2594e8bddad04ea Mon Sep 17 00:00:00 2001 From: Fillip Date: Mon, 10 Aug 2026 12:12:03 -0700 Subject: [PATCH] Add video node progress bars --- nodes/video/FL_LoadVideo.py | 9 +- pyproject.toml | 2 +- tests/test_load_video.py | 15 ++ tests/test_video_combine.py | 6 + web/nodes/video/FL_LoadVideo.js | 212 +++++++++++++++++++++++++---- web/nodes/video/FL_VideoCombine.js | 188 ++++++++++++++++++++++++- 6 files changed, 404 insertions(+), 28 deletions(-) diff --git a/nodes/video/FL_LoadVideo.py b/nodes/video/FL_LoadVideo.py index a0a0c04..0c9c334 100644 --- a/nodes/video/FL_LoadVideo.py +++ b/nodes/video/FL_LoadVideo.py @@ -9,7 +9,7 @@ import psutil import torch import folder_paths -from comfy.utils import common_upscale +from comfy.utils import ProgressBar, common_upscale from comfy_api.latest import InputImpl, Types @@ -330,23 +330,29 @@ class FL_LoadVideo: DESCRIPTION = "Loads, previews, trims, samples, and resizes a video from ComfyUI input." def load_video(self, video, load_settings=DEFAULT_SETTINGS_JSON): + progress = ProgressBar(5) + progress.update_absolute(0) settings = _parse_settings(load_settings) path = resolve_video_path(video) probe = probe_video(path) plan = build_load_plan(probe, settings) _check_memory(plan) + progress.update_absolute(1) source = InputImpl.VideoFromFile( str(path), start_time=plan["start_time"], duration=plan["decode_duration"], ) components = source.get_components() + progress.update_absolute(2) images, effective_fps = _sample_images(components.images, float(components.frame_rate), settings) + progress.update_absolute(3) images = _resize_images(images, settings) frame_count = int(images.shape[0]) loaded_duration = frame_count / effective_fps audio = _trim_audio(components.audio, loaded_duration, settings["include_audio"]) + progress.update_absolute(4) native_video = InputImpl.VideoFromComponents( Types.VideoComponents( images=images, @@ -375,6 +381,7 @@ class FL_LoadVideo: "has_audio": audio is not None, "bit_depth": probe["bit_depth"], } + progress.update_absolute(5) return { "ui": {"fl_load_video": [preview]}, "result": (images, audio, native_video, float(effective_fps), frame_count), diff --git a/pyproject.toml b/pyproject.toml index fd26c9c..d02ff44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui_fill-nodes" description = "Fill-Nodes is a versatile collection of custom nodes for ComfyUI that extends functionality across multiple domains. Features include advanced image processing (pixelation, slicing, masking), visual effects generation (glitch, halftone, pixel art), comprehensive file handling (PDF creation/extraction, Google Drive integration), AI model interfaces (GPT, DALL-E, Hugging Face), utility nodes for workflow enhancement, and specialized tools for video processing, captioning, and batch operations. The pack provides both practical workflow solutions and creative tools within a unified node collection." -version = "2.28.0" +version = "2.28.1" license = {file = "LICENSE"} dependencies = ["librosa", "sounddevice", "glitch_this", "PyOpenGL", "glfw", "scipy>=1.13.1", "requests", "aiohttp", "moviepy", "matplotlib", "reportlab", "openai", "PyPDF2", "pdf2image", "PyMuPDF", "reportlab", "PyPDF2", "ollama", "kornia", "opencv-python", "gdown", "open_clip_torch", "google-genai"] diff --git a/tests/test_load_video.py b/tests/test_load_video.py index 2b06f18..a567845 100644 --- a/tests/test_load_video.py +++ b/tests/test_load_video.py @@ -233,10 +233,16 @@ class LoadVideoExecutionTests(unittest.TestCase): mock.patch.object(load_video, "_check_memory"), mock.patch.object(load_video.InputImpl, "VideoFromFile", return_value=source) as create_source, mock.patch.object(load_video.InputImpl, "VideoFromComponents", return_value=native_video) as create_video, + mock.patch.object(load_video, "ProgressBar") as progress_type, ): result = load_video.FL_LoadVideo().load_video("nested/clip.mp4", json.dumps(configured)) create_source.assert_called_once_with(str(path.resolve()), start_time=0, duration=4 / 30) + progress_type.assert_called_once_with(5) + self.assertEqual( + [call.args[0] for call in progress_type.return_value.update_absolute.call_args_list], + [0, 1, 2, 3, 4, 5], + ) loaded_images, loaded_audio, returned_video, fps, frame_count = result["result"] self.assertEqual(loaded_images.shape, (2, 2, 2, 3)) self.assertEqual(loaded_audio["waveform"].shape[-1], 6400) @@ -292,6 +298,7 @@ class LoadVideoFrontendTests(unittest.TestCase): 'this.updateSetting("start_time"', 'this.updateSetting("end_time"', 'data-role="trim-frame-label"', + 'data-role="progress"', 'selected frames', 'syncFrameRange(name)', 'effectiveFrameRate()', @@ -305,12 +312,20 @@ class LoadVideoFrontendTests(unittest.TestCase): "/fl/load-video/info?", 'api.apiURL(`/view?', 'message?.fl_load_video?.[0]', + 'LOAD_PROGRESS_PHASES', + 'beginTransientProgress("uploading")', + 'beginTransientProgress("probing")', + 'api.addEventListener("progress"', + 'api.addEventListener("execution_cached"', + 'api.addEventListener("execution_interrupted"', 'MIN_NODE_WIDTH = 420', 'MIN_NODE_HEIGHT = 440', ): with self.subTest(behavior=behavior): self.assertIn(behavior, script) + self.assertNotIn("flvl-upload-progress", script) + menu_index = script.index('
span { - animation: flvl-upload 1s ease-in-out infinite alternate; - background: var(--flvl-accent); - display: block; - height: 100%; - width: 45%; - } - @keyframes flvl-upload { - from { transform: translateX(-20%); } - to { transform: translateX(140%); } - } `; function injectStyles() { @@ -618,6 +654,9 @@ class LoadVideoPanel { this.dragDepth = 0; this.trimDrag = null; this.disposed = false; + this.executionActive = false; + this.progressToken = 0; + this.progressHideTimer = null; this.node.properties ||= {}; if (!Number.isFinite(this.node.properties.previewVolume)) this.node.properties.previewVolume = 0.8; @@ -670,7 +709,9 @@ class LoadVideoPanel {
+
Drop a video here
or click to browse
- +
+
@@ -781,7 +822,8 @@ class LoadVideoPanel { this.dropZone = this.container.querySelector('[data-role="drop-zone"]'); this.dropTitle = this.container.querySelector('[data-role="drop-title"]'); this.dropHelp = this.container.querySelector('[data-role="drop-help"]'); - this.uploadProgress = this.container.querySelector('[data-role="upload-progress"]'); + this.progress = this.container.querySelector('[data-role="progress"]'); + this.progressFill = this.container.querySelector('[data-role="progress-fill"]'); this.error = this.container.querySelector('[data-role="error"]'); this.time = this.container.querySelector('[data-role="time"]'); this.volume = this.container.querySelector('[data-role="volume"]'); @@ -973,7 +1015,7 @@ class LoadVideoPanel { this.dropZone.hidden = false; this.dropTitle.textContent = `Uploading ${file.name}`; this.dropHelp.textContent = "Copying into ComfyUI input…"; - this.uploadProgress.hidden = false; + const progressToken = this.beginTransientProgress("uploading"); this.setObjectPreview(file); try { @@ -991,8 +1033,9 @@ class LoadVideoPanel { if (previousSource) await this.selectSource(previousSource); else this.removeSource(false); this.showError(message); + this.failProgress("upload failed"); } finally { - this.uploadProgress.hidden = true; + this.finishTransientProgress(progressToken); } } @@ -1031,6 +1074,9 @@ class LoadVideoPanel { removeSource(markGraph = true) { this.probeId += 1; + this.progressToken += 1; + this.executionActive = false; + this.hideProgress(); this.revokeObjectUrl(); this.video.pause(); this.video.removeAttribute("src"); @@ -1072,6 +1118,7 @@ class LoadVideoPanel { async probeSource(path) { const probeId = ++this.probeId; + const progressToken = this.beginTransientProgress("probing"); try { const params = new URLSearchParams({ filename: path }); const response = await api.fetchApi(`/fl/load-video/info?${params.toString()}`); @@ -1090,10 +1137,12 @@ class LoadVideoPanel { this.applyTrimWindow(); this.updateMemoryEstimate(); this.clearError(); + this.finishTransientProgress(progressToken, "ready"); } catch (error) { if (this.disposed || probeId !== this.probeId) return; this.sourceInfo = null; this.showError(error.message || "Could not inspect video."); + this.failTransientProgress(progressToken, "probe failed"); } } @@ -1535,7 +1584,93 @@ class LoadVideoPanel { this.node.properties.lastExecutionInfo = { ...info }; this.node.properties.lastLoadSettings = this.settingsWidget.value; this.updateSourceSummary(); - this.setStatus("ready", "loaded"); + this.finishExecution("loaded"); + } + + showProgress(value, max, label, state = "active", indeterminate = false) { + if (this.progressHideTimer !== null) { + clearTimeout(this.progressHideTimer); + this.progressHideTimer = null; + } + const total = Math.max(1, Number(max) || 1); + const current = Math.max(0, Math.min(total, Number(value) || 0)); + this.progress.hidden = false; + this.progress.dataset.state = state; + this.progress.dataset.indeterminate = String(indeterminate); + this.progress.setAttribute("aria-valuemin", "0"); + this.progress.setAttribute("aria-valuemax", String(total)); + this.progress.setAttribute("aria-valuenow", String(current)); + this.progressFill.style.width = `${current / total * 100}%`; + if (state === "error") this.setStatus("error", label); + else if (state === "complete") this.setStatus("ready", label); + else this.setStatus("busy", label); + } + + hideProgress() { + if (this.progressHideTimer !== null) clearTimeout(this.progressHideTimer); + this.progressHideTimer = null; + this.progress.hidden = true; + this.progressFill.style.width = "0%"; + } + + scheduleProgressHide() { + if (this.progressHideTimer !== null) clearTimeout(this.progressHideTimer); + this.progressHideTimer = window.setTimeout(() => { + this.progress.hidden = true; + this.progressHideTimer = null; + }, 900); + } + + beginTransientProgress(label) { + const token = ++this.progressToken; + if (!this.executionActive) this.showProgress(0, 1, label, "active", true); + return token; + } + + finishTransientProgress(token, label = "ready") { + if (this.executionActive || token !== this.progressToken) return; + this.showProgress(1, 1, label, "complete"); + this.scheduleProgressHide(); + } + + failTransientProgress(token, label) { + if (this.executionActive || token !== this.progressToken) return; + this.showProgress(1, 1, label, "error"); + } + + beginExecution() { + this.progressToken += 1; + this.executionActive = true; + this.showProgress(0, LOAD_PROGRESS_PHASES.length, LOAD_PROGRESS_PHASES[0], "active", true); + } + + updateExecutionProgress(value, max) { + this.executionActive = true; + const total = Math.max(1, Number(max) || LOAD_PROGRESS_PHASES.length); + const current = Math.max(0, Math.min(total, Number(value) || 0)); + if (current >= total) { + this.finishExecution("loaded"); + return; + } + const label = LOAD_PROGRESS_PHASES[Math.min(LOAD_PROGRESS_PHASES.length - 1, Math.floor(current))]; + this.showProgress(current, total, label, "active"); + } + + finishExecution(label) { + this.executionActive = false; + this.progressToken += 1; + this.showProgress(1, 1, label, "complete"); + this.scheduleProgressHide(); + } + + failProgress(label) { + this.executionActive = false; + this.progressToken += 1; + this.showProgress(1, 1, label, "error"); + } + + markCached() { + this.finishExecution("cached"); } setStatus(state, label) { @@ -1582,6 +1717,8 @@ class LoadVideoPanel { dispose() { this.disposed = true; this.probeId += 1; + loadVideoPanels.delete(nodeKey(this.node.id)); + if (this.progressHideTimer !== null) clearTimeout(this.progressHideTimer); document.removeEventListener("pointerdown", this.handleDocumentPointerDown); document.removeEventListener("keydown", this.handleDocumentKeyDown); this.trimResizeObserver?.disconnect(); @@ -1619,6 +1756,7 @@ app.registerExtension({ requestAnimationFrame(() => enforceMinimumNodeSize(node)); const panel = new LoadVideoPanel(node, videoWidget, settingsWidget, container); + loadVideoPanels.set(nodeKey(node.id), panel); const originalOnExecuted = node.onExecuted; node.onExecuted = function (message) { @@ -1630,6 +1768,7 @@ app.registerExtension({ node.onConfigure = function (...args) { const result = originalOnConfigure?.apply(this, args); panel.configure(); + loadVideoPanels.set(nodeKey(this.id), panel); requestAnimationFrame(() => enforceMinimumNodeSize(this)); return result; }; @@ -1637,3 +1776,28 @@ app.registerExtension({ domWidget.onRemove = () => panel.dispose(); }, }); + +api.addEventListener("executing", (event) => { + loadVideoPanels.get(nodeKey(eventNode(event.detail)))?.beginExecution(); +}); + +api.addEventListener("progress", (event) => { + const detail = event.detail || {}; + loadVideoPanels.get(nodeKey(detail.node))?.updateExecutionProgress(detail.value, detail.max); +}); + +api.addEventListener("execution_cached", (event) => { + const nodes = Array.isArray(event.detail?.nodes) ? event.detail.nodes : []; + for (const nodeId of nodes) loadVideoPanels.get(nodeKey(nodeId))?.markCached(); +}); + +api.addEventListener("execution_error", (event) => { + const detail = event.detail || {}; + loadVideoPanels.get(nodeKey(eventNode(detail)))?.failProgress("load failed"); +}); + +api.addEventListener("execution_interrupted", () => { + for (const panel of loadVideoPanels.values()) { + if (panel.executionActive) panel.failProgress("interrupted"); + } +}); diff --git a/web/nodes/video/FL_VideoCombine.js b/web/nodes/video/FL_VideoCombine.js index 966bb9e..ec26fc6 100644 --- a/web/nodes/video/FL_VideoCombine.js +++ b/web/nodes/video/FL_VideoCombine.js @@ -20,6 +20,17 @@ const DEFAULT_SETTINGS = { const MIN_NODE_WIDTH = 420; const MIN_NODE_HEIGHT = 360; const MIN_PANEL_HEIGHT = 280; +const COMBINE_PROGRESS_LABEL = "encoding MP4"; +const videoCombinePanelsByNode = new Map(); + +function nodeKey(value) { + return value === null || value === undefined ? "" : String(value); +} + +function eventNode(detail) { + if (detail && typeof detail === "object") return detail.node ?? detail.node_id; + return detail; +} const STYLES = ` .flvc-panel { @@ -220,7 +231,52 @@ const STYLES = ` } .flvc-status[data-state="ready"] { background: rgba(18, 59, 43, .9); color: #86efac; } .flvc-status[data-state="stale"] { background: rgba(70, 55, 24, .9); color: #fde68a; } + .flvc-status[data-state="busy"] { background: rgba(49, 46, 129, .9); color: #c4b5fd; } .flvc-status[data-state="error"] { background: rgba(76, 29, 36, .9); color: #fda4af; } + .flvc-progress { + background: rgba(23, 24, 29, .9); + border: 1px solid rgba(255, 255, 255, .09); + border-radius: 999px; + bottom: 38px; + height: 7px; + left: 7px; + overflow: hidden; + position: absolute; + right: 7px; + z-index: 4; + } + .flvc-progress[hidden] { display: none; } + .flvc-progress-fill { + background: linear-gradient(90deg, #7c3aed, #a78bfa); + display: block; + height: 100%; + overflow: hidden; + position: relative; + transition: width 160ms ease; + width: 0; + } + .flvc-progress[data-state="active"] .flvc-progress-fill::after { + animation: flvc-progress-sheen 1s linear infinite; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .5), transparent); + content: ""; + inset: 0; + position: absolute; + transform: translateX(-100%); + } + .flvc-progress[data-indeterminate="true"] .flvc-progress-fill { + animation: flvc-progress-slide 1s ease-in-out infinite alternate; + transition: none; + width: 42% !important; + } + .flvc-progress[data-state="complete"] .flvc-progress-fill { background: #22c55e; } + .flvc-progress[data-state="error"] .flvc-progress-fill { background: #ef4444; } + @keyframes flvc-progress-sheen { + to { transform: translateX(100%); } + } + @keyframes flvc-progress-slide { + from { transform: translateX(-20%); } + to { transform: translateX(140%); } + } .flvc-preview-controls { align-items: center; background: linear-gradient(transparent, rgba(0, 0, 0, .82) 55%); @@ -488,6 +544,10 @@ class VideoCombinePanel { this.handleDocumentKeyDown = null; this.playbackRequested = false; this.restartAtStart = false; + this.executionActive = false; + this.progressToken = 0; + this.progressHideTimer = null; + this.previewLoadToken = null; this.node.properties ||= {}; if (!Number.isFinite(this.node.properties.previewVolume)) { @@ -572,6 +632,9 @@ class VideoCombinePanel {
Queue the workflow to render a preview.
MP4 · H.264
not rendered
+
00:00 / 00:00 @@ -625,6 +688,8 @@ class VideoCombinePanel { this.status = this.container.querySelector('[data-role="status"]'); this.error = this.container.querySelector('[data-role="error"]'); this.summary = this.container.querySelector('[data-role="summary"]'); + this.progress = this.container.querySelector('[data-role="progress"]'); + this.progressFill = this.container.querySelector('[data-role="progress-fill"]'); this.playButton = this.container.querySelector('[data-role="play"]'); this.previewMuteButton = this.container.querySelector('[data-role="preview-mute"]'); this.previewVolume = this.container.querySelector('[data-role="preview-volume"]'); @@ -713,11 +778,13 @@ class VideoCombinePanel { } this.placeholder.style.display = "none"; this.updateTime(); + this.finishTransientProgress(this.previewLoadToken, "ready"); }); this.video.addEventListener("error", () => { if (!this.video.src) return; this.placeholder.textContent = "Preview unavailable. The rendered file may still be valid."; this.placeholder.style.display = "flex"; + this.failTransientProgress(this.previewLoadToken, "preview failed"); }); this.previewMuteButton.addEventListener("click", () => { @@ -854,6 +921,93 @@ class VideoCombinePanel { this.status.textContent = label; } + showProgress(value, max, label, state = "active", indeterminate = false) { + if (this.progressHideTimer !== null) { + clearTimeout(this.progressHideTimer); + this.progressHideTimer = null; + } + const total = Math.max(1, Number(max) || 1); + const current = Math.max(0, Math.min(total, Number(value) || 0)); + this.progress.hidden = false; + this.progress.dataset.state = state; + this.progress.dataset.indeterminate = String(indeterminate); + this.progress.setAttribute("aria-valuemin", "0"); + this.progress.setAttribute("aria-valuemax", String(total)); + this.progress.setAttribute("aria-valuenow", String(current)); + this.progressFill.style.width = `${current / total * 100}%`; + if (state === "error") this.setStatus("error", label); + else if (state === "complete") this.setStatus("ready", label); + else this.setStatus("busy", label); + } + + hideProgress() { + if (this.progressHideTimer !== null) clearTimeout(this.progressHideTimer); + this.progressHideTimer = null; + this.progress.hidden = true; + this.progressFill.style.width = "0%"; + } + + scheduleProgressHide() { + if (this.progressHideTimer !== null) clearTimeout(this.progressHideTimer); + this.progressHideTimer = window.setTimeout(() => { + this.progress.hidden = true; + this.progressHideTimer = null; + }, 900); + } + + beginTransientProgress(label) { + const token = ++this.progressToken; + if (!this.executionActive) this.showProgress(0, 1, label, "active", true); + return token; + } + + finishTransientProgress(token, label = "ready") { + if (this.executionActive || token !== this.progressToken) return; + this.showProgress(1, 1, label, "complete"); + this.scheduleProgressHide(); + } + + failTransientProgress(token, label) { + if (this.executionActive || token !== this.progressToken) return; + this.showProgress(1, 1, label, "error"); + } + + beginExecution() { + this.progressToken += 1; + this.executionActive = true; + this.showProgress(0, 1, "preparing", "active", true); + } + + updateExecutionProgress(value, max) { + this.executionActive = true; + const total = Math.max(1, Number(max) || 1); + const current = Math.max(0, Math.min(total, Number(value) || 0)); + if (current >= total) { + this.finishExecution("rendered"); + return; + } + const frameTotal = Math.max(1, total - 1); + const label = current > 0 ? `encoding ${Math.min(current, frameTotal)}/${frameTotal}` : COMBINE_PROGRESS_LABEL; + this.showProgress(current, total, label, "active"); + } + + finishExecution(label) { + this.executionActive = false; + this.progressToken += 1; + this.showProgress(1, 1, label, "complete"); + this.scheduleProgressHide(); + } + + failProgress(label) { + this.executionActive = false; + this.progressToken += 1; + this.showProgress(1, 1, label, "error"); + } + + markCached() { + this.finishExecution("cached"); + } + loadPreview(preview) { if (!preview?.filename) return; this.pausePlayback(); @@ -871,10 +1025,10 @@ class VideoCombinePanel { }); this.video.src = api.apiURL(`/view?${params.toString()}`); } - this.video.load(); this.placeholder.textContent = "Loading preview…"; this.placeholder.style.display = "flex"; - this.setStatus("ready", "ready"); + this.previewLoadToken = this.beginTransientProgress("loading preview"); + this.video.load(); const padded = preview.source_width !== preview.encoded_width || preview.source_height !== preview.encoded_height; const dimensions = padded @@ -890,6 +1044,7 @@ class VideoCombinePanel { if (!preview) return; this.node.properties.lastPreview = { ...preview }; this.node.properties.lastRenderSettings = this.settingsWidget.value; + this.finishExecution("rendered"); this.loadPreview(preview); } @@ -910,7 +1065,9 @@ class VideoCombinePanel { } dispose() { + videoCombinePanelsByNode.delete(nodeKey(this.node.id)); removeSynchronizedPanel(this); + if (this.progressHideTimer !== null) clearTimeout(this.progressHideTimer); if (this.handleDocumentPointerDown) { document.removeEventListener("pointerdown", this.handleDocumentPointerDown); } @@ -958,6 +1115,7 @@ app.registerExtension({ const panel = new VideoCombinePanel(node, settingsWidget, container); node._flVideoCombinePanel = panel; + videoCombinePanelsByNode.set(nodeKey(node.id), panel); const originalOnExecuted = node.onExecuted; node.onExecuted = function (message) { @@ -969,6 +1127,7 @@ app.registerExtension({ node.onConfigure = function (...args) { const result = originalOnConfigure?.apply(this, args); panel.configure(); + videoCombinePanelsByNode.set(nodeKey(this.id), panel); requestAnimationFrame(() => enforceMinimumNodeSize(this)); return result; }; @@ -976,3 +1135,28 @@ app.registerExtension({ domWidget.onRemove = () => panel.dispose(); }, }); + +api.addEventListener("executing", (event) => { + videoCombinePanelsByNode.get(nodeKey(eventNode(event.detail)))?.beginExecution(); +}); + +api.addEventListener("progress", (event) => { + const detail = event.detail || {}; + videoCombinePanelsByNode.get(nodeKey(detail.node))?.updateExecutionProgress(detail.value, detail.max); +}); + +api.addEventListener("execution_cached", (event) => { + const nodes = Array.isArray(event.detail?.nodes) ? event.detail.nodes : []; + for (const nodeId of nodes) videoCombinePanelsByNode.get(nodeKey(nodeId))?.markCached(); +}); + +api.addEventListener("execution_error", (event) => { + const detail = event.detail || {}; + videoCombinePanelsByNode.get(nodeKey(eventNode(detail)))?.failProgress("encode failed"); +}); + +api.addEventListener("execution_interrupted", () => { + for (const panel of videoCombinePanelsByNode.values()) { + if (panel.executionActive) panel.failProgress("interrupted"); + } +});