diff --git a/nodes/video/FL_VideoCombine.py b/nodes/video/FL_VideoCombine.py
index 70b1451..51d4d84 100644
--- a/nodes/video/FL_VideoCombine.py
+++ b/nodes/video/FL_VideoCombine.py
@@ -1,16 +1,20 @@
import json
+import math
import os
import secrets
import threading
from collections import OrderedDict
from fractions import Fraction
+import av
+import numpy as np
import torch
import torch.nn.functional as F
import folder_paths
from comfy.cli_args import args
-from comfy_api.latest import InputImpl, Types
+from comfy.model_management import InterruptProcessingException
+from comfy.utils import ProgressBar
DEFAULT_RENDER_SETTINGS = {
@@ -201,6 +205,76 @@ def _build_metadata(prompt, extra_pnginfo, enabled):
return metadata or None
+def _save_video(output_path, images, audio, frame_rate, bit_depth, crf, metadata):
+ frame_count = int(images.shape[0])
+ total_steps = frame_count + 1
+ progress = ProgressBar(total_steps)
+ is_10bit = bit_depth >= 10
+ pixel_format = "yuv420p10le" if is_10bit else "yuv420p"
+ frame_rate = Fraction(round(frame_rate * 1000), 1000)
+
+ try:
+ progress.update_absolute(0)
+ with av.open(
+ output_path,
+ mode="w",
+ format="mp4",
+ options={"movflags": "use_metadata_tags+faststart"},
+ ) as output:
+ if metadata is not None:
+ for key, value in metadata.items():
+ output.metadata[key] = json.dumps(value)
+
+ video_stream = output.add_stream("h264", rate=frame_rate)
+ video_stream.width = images.shape[2]
+ video_stream.height = images.shape[1]
+ video_stream.pix_fmt = pixel_format
+ video_stream.options = {"crf": str(crf)}
+
+ audio_stream = None
+ sample_rate = None
+ waveform = None
+ audio_layout = None
+ if audio is not None:
+ sample_rate = int(audio["sample_rate"])
+ sample_count = math.ceil((sample_rate / frame_rate) * frame_count)
+ waveform = audio["waveform"][0, :, :sample_count]
+ audio_layout = {1: "mono", 2: "stereo", 6: "5.1"}[waveform.shape[0]]
+ audio_stream = output.add_stream("aac", rate=sample_rate, layout=audio_layout)
+
+ for index, image in enumerate(images, start=1):
+ if is_10bit:
+ image = (image.float() * 65535).clamp(0, 65535).cpu().numpy().astype(np.uint16)
+ frame = av.VideoFrame.from_ndarray(image, format="rgb48le")
+ else:
+ image = (image * 255).clamp(0, 255).byte().cpu().numpy()
+ frame = av.VideoFrame.from_ndarray(image, format="rgb24")
+ frame = frame.reformat(format=pixel_format)
+ output.mux(video_stream.encode(frame))
+ progress.update_absolute(index)
+
+ output.mux(video_stream.encode(None))
+
+ if audio_stream is not None:
+ frame = av.AudioFrame.from_ndarray(
+ waveform.float().cpu().contiguous().numpy(),
+ format="fltp",
+ layout=audio_layout,
+ )
+ frame.sample_rate = sample_rate
+ frame.pts = 0
+ output.mux(audio_stream.encode(frame))
+ output.mux(audio_stream.encode(None))
+
+ progress.update_absolute(total_steps)
+ except (Exception, InterruptProcessingException):
+ try:
+ os.remove(output_path)
+ except FileNotFoundError:
+ pass
+ raise
+
+
class FL_VideoCombine:
@classmethod
def INPUT_TYPES(cls):
@@ -248,20 +322,14 @@ class FL_VideoCombine:
file = f"{filename}_{counter:05}_.mp4"
output_path = os.path.join(full_output_folder, file)
- video = InputImpl.VideoFromComponents(
- Types.VideoComponents(
- images=images,
- audio=prepared_audio,
- frame_rate=Fraction(round(settings["frame_rate"] * 1000), 1000),
- ),
- bit_depth=settings["bit_depth"],
- )
- video.save_to(
+ _save_video(
output_path,
- format=Types.VideoContainer.MP4,
- codec=Types.VideoCodec.H264,
- metadata=_build_metadata(prompt, extra_pnginfo, settings["save_metadata"]),
- crf=settings["crf"],
+ images,
+ prepared_audio,
+ settings["frame_rate"],
+ settings["bit_depth"],
+ settings["crf"],
+ _build_metadata(prompt, extra_pnginfo, settings["save_metadata"]),
)
frame_count = int(images.shape[0])
diff --git a/pyproject.toml b/pyproject.toml
index 6af8228..61bbfb0 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.25.0"
+version = "2.26.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 fc89011..58f177d 100644
--- a/tests/test_video_combine.py
+++ b/tests/test_video_combine.py
@@ -189,7 +189,7 @@ class VideoCombineAudioTests(unittest.TestCase):
class VideoCombineExecutionTests(unittest.TestCase):
- def test_execution_builds_native_video_and_returns_preview(self):
+ def test_execution_encodes_video_and_returns_preview(self):
settings = video_combine.DEFAULT_RENDER_SETTINGS.copy()
settings.update({
"filename_prefix": "clips/test",
@@ -204,7 +204,6 @@ class VideoCombineExecutionTests(unittest.TestCase):
"waveform": torch.ones((1, 2, 48000)),
"sample_rate": 48000,
}
- native_video = mock.Mock()
with (
mock.patch.object(video_combine.folder_paths, "get_temp_directory", return_value="D:\\temp"),
@@ -213,7 +212,7 @@ class VideoCombineExecutionTests(unittest.TestCase):
"get_save_image_path",
return_value=("D:\\temp\\clips", "test", 7, "clips", "clips/test"),
) as save_path,
- mock.patch.object(video_combine.InputImpl, "VideoFromComponents", return_value=native_video) as create_video,
+ mock.patch.object(video_combine, "_save_video") as save_video,
mock.patch.object(video_combine.args, "disable_metadata", False),
):
result = video_combine.FL_VideoCombine().combine_video(
@@ -225,24 +224,22 @@ class VideoCombineExecutionTests(unittest.TestCase):
)
save_path.assert_called_once_with("clips/test", "D:\\temp", 8, 6)
- components = create_video.call_args.args[0]
- self.assertEqual(components.images.shape, (3, 6, 8, 3))
- self.assertEqual(float(components.frame_rate), 12.0)
- self.assertEqual(components.audio["sample_rate"], 48000)
- torch.testing.assert_close(components.audio["waveform"], audio["waveform"] * (10 ** (-6 / 20)))
- self.assertEqual(create_video.call_args.kwargs["bit_depth"], 10)
-
output_path = os.path.join("D:\\temp\\clips", "test_00007_.mp4")
- native_video.save_to.assert_called_once_with(
- output_path,
- format=video_combine.Types.VideoContainer.MP4,
- codec=video_combine.Types.VideoCodec.H264,
- metadata={
+ save_video.assert_called_once()
+ save_args = save_video.call_args.args
+ self.assertEqual(save_args[0], output_path)
+ self.assertEqual(save_args[1].shape, (3, 6, 8, 3))
+ self.assertEqual(save_args[2]["sample_rate"], 48000)
+ torch.testing.assert_close(save_args[2]["waveform"], audio["waveform"] * (10 ** (-6 / 20)))
+ self.assertEqual(save_args[3:6], (12.0, 10, 23))
+ self.assertEqual(
+ save_args[6],
+ {
"workflow": {"nodes": []},
"prompt": {"1": {"class_type": "Test"}},
},
- crf=23,
)
+
self.assertEqual(result["result"], (output_path,))
preview = result["ui"]["fl_video_combine"][0]
self.assertEqual(preview["filename"], "test_00007_.mp4")
@@ -253,6 +250,66 @@ class VideoCombineExecutionTests(unittest.TestCase):
self.assertEqual((preview["encoded_width"], preview["encoded_height"]), (8, 6))
self.assertTrue(preview["has_audio"])
+ def test_encoder_reports_frame_progress_and_writes_audio_video(self):
+ images = torch.linspace(0, 1, 3 * 16 * 16 * 3).reshape(3, 16, 16, 3)
+ audio = {
+ "waveform": torch.zeros((1, 2, 12000)),
+ "sample_rate": 48000,
+ }
+ progress = mock.Mock()
+
+ with tempfile.TemporaryDirectory() as output_directory:
+ output_path = os.path.join(output_directory, "progress.mp4")
+ with mock.patch.object(video_combine, "ProgressBar", return_value=progress) as progress_bar:
+ video_combine._save_video(
+ output_path,
+ images,
+ audio,
+ 12,
+ 8,
+ 23,
+ {"workflow": {"nodes": []}},
+ )
+
+ progress_bar.assert_called_once_with(4)
+ self.assertEqual(
+ progress.update_absolute.call_args_list,
+ [mock.call(0), mock.call(1), mock.call(2), mock.call(3), mock.call(4)],
+ )
+ self.assertTrue(os.path.isfile(output_path))
+ with video_combine.av.open(output_path) as container:
+ self.assertEqual(len(container.streams.video), 1)
+ self.assertEqual(len(container.streams.audio), 1)
+ self.assertEqual(container.streams.video[0].width, 16)
+ self.assertEqual(container.streams.video[0].height, 16)
+ self.assertEqual(json.loads(container.metadata["workflow"]), {"nodes": []})
+
+ def test_encoder_removes_partial_output_when_progress_is_cancelled(self):
+ images = torch.zeros((2, 16, 16, 3))
+ progress = mock.Mock()
+ progress.update_absolute.side_effect = [None, video_combine.InterruptProcessingException()]
+
+ with tempfile.TemporaryDirectory() as output_directory:
+ output_path = os.path.join(output_directory, "cancelled.mp4")
+ with (
+ mock.patch.object(video_combine, "ProgressBar", return_value=progress),
+ self.assertRaises(video_combine.InterruptProcessingException),
+ ):
+ video_combine._save_video(output_path, images, None, 12, 8, 19, None)
+
+ self.assertFalse(os.path.exists(output_path))
+
+ def test_encoder_supports_ten_bit_video(self):
+ images = torch.linspace(0, 1, 2 * 16 * 16 * 3).reshape(2, 16, 16, 3)
+
+ with tempfile.TemporaryDirectory() as output_directory:
+ output_path = os.path.join(output_directory, "ten-bit.mp4")
+ with mock.patch.object(video_combine, "ProgressBar"):
+ video_combine._save_video(output_path, images, None, 12, 10, 23, None)
+
+ with video_combine.av.open(output_path) as container:
+ self.assertEqual(container.streams.video[0].codec_context.format.name, "yuv420p10le")
+
def test_custom_directory_overrides_default_destination_and_uses_token_preview(self):
with tempfile.TemporaryDirectory() as output_directory:
settings = video_combine.DEFAULT_RENDER_SETTINGS.copy()
@@ -262,7 +319,6 @@ class VideoCombineExecutionTests(unittest.TestCase):
"save_output": False,
})
images = torch.ones((2, 4, 6, 3))
- native_video = mock.Mock()
output_path = os.path.join(output_directory, "custom_00003_.mp4")
with (
@@ -273,7 +329,7 @@ class VideoCombineExecutionTests(unittest.TestCase):
"get_save_image_path",
return_value=(output_directory, "custom", 3, "", "custom"),
) as save_path,
- mock.patch.object(video_combine.InputImpl, "VideoFromComponents", return_value=native_video),
+ mock.patch.object(video_combine, "_save_video"),
mock.patch.object(video_combine, "register_preview_file", return_value="preview-token") as register_preview,
):
result = video_combine.FL_VideoCombine().combine_video(images, json.dumps(settings))
diff --git a/web/nodes/video/FL_VideoCombine.js b/web/nodes/video/FL_VideoCombine.js
index cdd0bd8..3a37a6f 100644
--- a/web/nodes/video/FL_VideoCombine.js
+++ b/web/nodes/video/FL_VideoCombine.js
@@ -128,6 +128,7 @@ const STYLES = `
.flvc-toggle[data-enabled="true"] .flvc-toggle-value { color: #86efac; }
.flvc-more-button,
.flvc-icon-button,
+ .flvc-sync-button,
.flvc-menu-close,
.flvc-reset-button {
background: var(--flvc-control);
@@ -145,6 +146,7 @@ const STYLES = `
}
.flvc-more-button:hover,
.flvc-icon-button:hover,
+ .flvc-sync-button:hover,
.flvc-menu-close:hover,
.flvc-reset-button:hover {
border-color: var(--flvc-accent);
@@ -155,6 +157,7 @@ const STYLES = `
}
.flvc-more-button:focus-visible,
.flvc-icon-button:focus-visible,
+ .flvc-sync-button:focus-visible,
.flvc-menu-close:focus-visible,
.flvc-reset-button:focus-visible {
outline: 2px solid var(--flvc-accent);
@@ -237,6 +240,14 @@ const STYLES = `
height: 25px;
padding: 0;
}
+ .flvc-sync-button {
+ border-radius: 4px;
+ flex: 0 0 38px;
+ font-size: 9px;
+ font-weight: 700;
+ height: 25px;
+ padding: 0 5px;
+ }
.flvc-time {
color: #e4e4e7;
flex: 0 0 82px;
@@ -399,6 +410,25 @@ 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.
+ }
+ }
+ for (const video of videos) {
+ video.play().catch(() => {});
+ }
+}
+
class VideoCombinePanel {
constructor(node, settingsWidget, container) {
this.node = node;
@@ -500,6 +530,7 @@ class VideoCombinePanel {
80%
+
@@ -549,6 +580,7 @@ class VideoCombinePanel {
this.previewMuteButton = this.container.querySelector('[data-role="preview-mute"]');
this.previewVolume = this.container.querySelector('[data-role="preview-volume"]');
this.previewVolumeValue = this.container.querySelector('[data-role="preview-volume-value"]');
+ this.syncButton = this.container.querySelector('[data-role="sync"]');
this.time = this.container.querySelector('[data-role="time"]');
this.audioToggle = this.container.querySelector('[data-role="audio-toggle"]');
this.audioToggleValue = this.container.querySelector('[data-role="audio-toggle-value"]');
@@ -640,6 +672,7 @@ class VideoCombinePanel {
this.node.properties.previewVolume = Number(this.previewVolume.value) / 100;
this.applyPreviewAudio();
});
+ this.syncButton.addEventListener("click", syncVideoCombinePreviews);
}
setMenuOpen(open) {
@@ -794,6 +827,9 @@ class VideoCombinePanel {
this.video.pause();
this.video.removeAttribute("src");
this.video.load();
+ if (this.node._flVideoCombinePanel === this) {
+ delete this.node._flVideoCombinePanel;
+ }
this.container.replaceChildren();
}
}
@@ -822,6 +858,7 @@ app.registerExtension({
requestAnimationFrame(() => enforceMinimumNodeSize(node));
const panel = new VideoCombinePanel(node, settingsWidget, container);
+ node._flVideoCombinePanel = panel;
const originalOnExecuted = node.onExecuted;
node.onExecuted = function (message) {