diff --git a/pyproject.toml b/pyproject.toml index 61bbfb0..e9c1d97 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.26.0" +version = "2.27.0" 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_video_combine.py b/tests/test_video_combine.py index 58f177d..833d386 100644 --- a/tests/test_video_combine.py +++ b/tests/test_video_combine.py @@ -356,5 +356,30 @@ class VideoCombineExecutionTests(unittest.TestCase): self.assertIsNone(metadata) +class VideoCombineFrontendTests(unittest.TestCase): + def test_frontend_requires_explicit_playback_and_synchronizes_previews(self): + script = (pathlib.Path(__file__).parents[1] / "web" / "nodes" / "video" / "FL_VideoCombine.js").read_text(encoding="utf-8") + + for behavior in ( + 'data-role="sync"', + "syncVideoCombinePreviews()", + "prepareForSynchronization()", + "this.video.currentTime = 0", + "maintainSynchronization()", + "Math.abs(video.currentTime - leader.currentTime) > 0.08", + 'document.addEventListener("visibilitychange"', + 'window.addEventListener("pagehide"', + "pauseAllVideoCombinePreviews()", + ): + with self.subTest(behavior=behavior): + self.assertIn(behavior, script) + + loaded_metadata = script.split('this.video.addEventListener("loadedmetadata"', 1)[1].split( + 'this.video.addEventListener("error"', 1 + )[0] + self.assertNotIn(".play(", loaded_metadata) + self.assertEqual(script.count("this.video.play().catch"), 1) + + if __name__ == "__main__": unittest.main() diff --git a/web/nodes/video/FL_VideoCombine.js b/web/nodes/video/FL_VideoCombine.js index 3a37a6f..966bb9e 100644 --- a/web/nodes/video/FL_VideoCombine.js +++ b/web/nodes/video/FL_VideoCombine.js @@ -410,25 +410,72 @@ function formatTime(value) { return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; } -function syncVideoCombinePreviews() { - const videos = []; - for (const node of app.graph?._nodes || []) { - if (node.comfyClass !== "FL_VideoCombine") continue; - const video = node._flVideoCombinePanel?.video; - if (!video?.src || video.readyState < HTMLMediaElement.HAVE_METADATA) continue; - try { - video.pause(); - video.currentTime = 0; - videos.push(video); - } catch { - // A preview can become unavailable while the graph is changing. +const synchronizedPanels = new Set(); +let synchronizationFrame = null; + +function getVideoCombinePanels() { + return (app.graph?._nodes || []) + .filter((node) => node.comfyClass === "FL_VideoCombine") + .map((node) => node._flVideoCombinePanel) + .filter(Boolean); +} + +function stopSynchronization() { + if (synchronizationFrame !== null) { + cancelAnimationFrame(synchronizationFrame); + synchronizationFrame = null; + } + synchronizedPanels.clear(); +} + +function removeSynchronizedPanel(panel) { + synchronizedPanels.delete(panel); + if (synchronizedPanels.size < 2 && synchronizationFrame !== null) { + cancelAnimationFrame(synchronizationFrame); + synchronizationFrame = null; + } +} + +function maintainSynchronization() { + synchronizationFrame = null; + for (const panel of synchronizedPanels) { + if (!panel.isSynchronizationActive()) synchronizedPanels.delete(panel); + } + const panels = [...synchronizedPanels]; + if (panels.length < 2 || document.hidden) return; + + const leader = panels[0].video; + if (!leader.paused && leader.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + for (const panel of panels.slice(1)) { + const video = panel.video; + if (video.paused || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) continue; + if (Number.isFinite(video.duration) && leader.currentTime >= video.duration) continue; + if (Math.abs(video.currentTime - leader.currentTime) > 0.08) { + video.currentTime = leader.currentTime; + } } } - for (const video of videos) { - video.play().catch(() => {}); + synchronizationFrame = requestAnimationFrame(maintainSynchronization); +} + +function syncVideoCombinePreviews() { + const panels = getVideoCombinePanels().filter((panel) => panel.hasPreview()); + if (!panels.length) return; + + stopSynchronization(); + for (const panel of panels) panel.prepareForSynchronization(); + for (const panel of panels) synchronizedPanels.add(panel); + for (const panel of panels) panel.requestPlayback(true); + if (panels.length > 1) { + synchronizationFrame = requestAnimationFrame(maintainSynchronization); } } +function pauseAllVideoCombinePreviews() { + stopSynchronization(); + for (const panel of getVideoCombinePanels()) panel.pausePlayback(false); +} + class VideoCombinePanel { constructor(node, settingsWidget, container) { this.node = node; @@ -439,6 +486,8 @@ class VideoCombinePanel { this.configError = ""; this.handleDocumentPointerDown = null; this.handleDocumentKeyDown = null; + this.playbackRequested = false; + this.restartAtStart = false; this.node.properties ||= {}; if (!Number.isFinite(this.node.properties.previewVolume)) { @@ -641,12 +690,16 @@ class VideoCombinePanel { this.playButton.addEventListener("click", () => { if (!this.video.src) return; if (this.video.paused) { - this.video.play().catch(() => {}); + this.requestPlayback(); } else { - this.video.pause(); + this.pausePlayback(); } }); this.video.addEventListener("play", () => { + if (!this.playbackRequested || document.hidden) { + this.pausePlayback(); + return; + } this.playButton.textContent = "❚❚"; }); this.video.addEventListener("pause", () => { @@ -654,9 +707,12 @@ class VideoCombinePanel { }); this.video.addEventListener("timeupdate", () => this.updateTime()); this.video.addEventListener("loadedmetadata", () => { + if (this.restartAtStart) { + this.video.currentTime = 0; + this.restartAtStart = false; + } this.placeholder.style.display = "none"; this.updateTime(); - this.video.play().catch(() => {}); }); this.video.addEventListener("error", () => { if (!this.video.src) return; @@ -675,6 +731,40 @@ class VideoCombinePanel { this.syncButton.addEventListener("click", syncVideoCombinePreviews); } + hasPreview() { + return Boolean(this.preview && this.video.src); + } + + isSynchronizationActive() { + return this.playbackRequested && !this.video.ended; + } + + requestPlayback(keepSynchronized = false) { + if (!this.video.src) return; + if (!keepSynchronized) removeSynchronizedPanel(this); + this.playbackRequested = true; + this.video.play().catch(() => { + this.playbackRequested = false; + removeSynchronizedPanel(this); + }); + } + + pausePlayback(removeFromSynchronization = true) { + this.playbackRequested = false; + this.video.pause(); + if (removeFromSynchronization) removeSynchronizedPanel(this); + } + + prepareForSynchronization() { + this.pausePlayback(false); + this.restartAtStart = true; + if (this.video.readyState !== HTMLMediaElement.HAVE_NOTHING) { + this.video.currentTime = 0; + this.restartAtStart = false; + this.updateTime(); + } + } + setMenuOpen(open) { this.settingsMenu.hidden = !open; this.moreButton.setAttribute("aria-expanded", String(open)); @@ -766,6 +856,8 @@ class VideoCombinePanel { loadPreview(preview) { if (!preview?.filename) return; + this.pausePlayback(); + this.restartAtStart = false; this.preview = preview; if (preview.preview_url) { const separator = preview.preview_url.includes("?") ? "&" : "?"; @@ -818,13 +910,14 @@ class VideoCombinePanel { } dispose() { + removeSynchronizedPanel(this); if (this.handleDocumentPointerDown) { document.removeEventListener("pointerdown", this.handleDocumentPointerDown); } if (this.handleDocumentKeyDown) { document.removeEventListener("keydown", this.handleDocumentKeyDown); } - this.video.pause(); + this.pausePlayback(false); this.video.removeAttribute("src"); this.video.load(); if (this.node._flVideoCombinePanel === this) { @@ -836,6 +929,12 @@ class VideoCombinePanel { app.registerExtension({ name: "ComfyUI.FL_VideoCombine", + setup() { + document.addEventListener("visibilitychange", () => { + if (document.hidden) pauseAllVideoCombinePreviews(); + }); + window.addEventListener("pagehide", pauseAllVideoCombinePreviews); + }, nodeCreated(node) { if (node.comfyClass !== "FL_VideoCombine") return;