Add video combine progress and playback sync
This commit is contained in:
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user