Add video combine progress and playback sync

This commit is contained in:
Fillip
2026-08-06 10:54:52 -07:00
parent a8cf834179
commit 6f4326dc5e
4 changed files with 194 additions and 33 deletions
+82 -14
View File
@@ -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])
+1 -1
View File
@@ -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"]
+74 -18
View File
@@ -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))
+37
View File
@@ -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 {
<button class="flvc-icon-button" data-role="preview-mute" type="button" title="Mute preview">🔇</button>
<input class="flvc-preview-volume" data-role="preview-volume" aria-label="Preview volume" type="range" min="0" max="100" step="1">
<span class="flvc-preview-value" data-role="preview-volume-value">80%</span>
<button class="flvc-sync-button" data-role="sync" type="button" title="Restart all FL Video Combine previews together">Sync</button>
</div>
</div>
@@ -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) {