From 719b4e259c3ffd56b84ea2912bc0937e62874a1d Mon Sep 17 00:00:00 2001 From: ausboss <102309304+ausboss@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:22:31 -0500 Subject: [PATCH] Let a connected video encode without a frames connection --- js/docs/AUSBOSS_NODES_SaveVideo.md | 3 +- nodes/node_save_video.py | 18 +++++++--- tests/test_video_save_helpers.py | 57 ++++++++++++++++++++++++------ 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/js/docs/AUSBOSS_NODES_SaveVideo.md b/js/docs/AUSBOSS_NODES_SaveVideo.md index 65ad7e3..6fdaa19 100644 --- a/js/docs/AUSBOSS_NODES_SaveVideo.md +++ b/js/docs/AUSBOSS_NODES_SaveVideo.md @@ -12,7 +12,8 @@ exactly one saved-video surface per node. ## Controls - **frames**: BHWC frame batch to encode. Odd dimensions are cropped down one - pixel to the even sizes H.264 requires. + pixel to the even sizes H.264 requires. Optional only because a connected + `video` brings its own frames — connect one or the other. - **fps**: Playback frame rate. Wire Load Video's `fps` output to keep the source timing, including fractional rates like 29.97. - **filename_prefix**: Path under the output folder; subfolders are created diff --git a/nodes/node_save_video.py b/nodes/node_save_video.py index 02b46f3..c93a5c2 100644 --- a/nodes/node_save_video.py +++ b/nodes/node_save_video.py @@ -35,10 +35,6 @@ class AusBossSaveVideo: def INPUT_TYPES(cls): return { "required": { - "frames": ( - "IMAGE", - {"tooltip": "BHWC frame batch to encode."}, - ), "fps": ( "FLOAT", { @@ -71,7 +67,15 @@ class AusBossSaveVideo: }, ), }, + # frames leads the optional group so the socket order the frontend + # draws — frames, audio, video — is the order saved workflows + # already link against. It is optional only because a connected + # video carries its own frames; one of the two must be present. "optional": { + "frames": ( + "IMAGE", + {"tooltip": "BHWC frame batch to encode. Not needed when a video is connected."}, + ), "audio": ( "AUDIO", {"tooltip": "Optional track muxed into the file, e.g. Load Video's audio output."}, @@ -96,7 +100,7 @@ class AusBossSaveVideo: FUNCTION = "save" async def save( - self, frames, fps, filename_prefix, crf, audio=None, video=None, prompt=None, extra_pnginfo=None + self, fps, filename_prefix, crf, frames=None, audio=None, video=None, prompt=None, extra_pnginfo=None ): if folder_paths is None: raise RuntimeError("Save Video requires ComfyUI's folder_paths at runtime.") @@ -111,6 +115,10 @@ class AusBossSaveVideo: fps, notice = resolve_encode_fps(float(fps), video_fps) if notice: print(notice) + elif frames is None: + raise ValueError( + "Save Video: connect either a frames batch or a video to encode." + ) full_output_folder, filename, counter, subfolder, filename_prefix = ( folder_paths.get_save_image_path( filename_prefix, diff --git a/tests/test_video_save_helpers.py b/tests/test_video_save_helpers.py index 9d49c5b..a002c2b 100644 --- a/tests/test_video_save_helpers.py +++ b/tests/test_video_save_helpers.py @@ -191,7 +191,7 @@ class SaveVideoHelperTests(unittest.TestCase): patch.object(node_save_video, "encode_video", return_value=(576, 1024, 188)), ): result = run_node(node_save_video.AusBossSaveVideo().save( - gradient_batch(1, 32, 32), 24.0, "AusBoss/video", 19 + frames=gradient_batch(1, 32, 32), fps=24.0, filename_prefix="AusBoss/video", crf=19 )) self.assertEqual(result["ui"]["images"], [{ @@ -262,7 +262,8 @@ class CoreVideoInputTests(unittest.TestCase): ) as encode, ): result = run_node(node_save_video.AusBossSaveVideo().save( - gradient_batch(1, 8, 8), 16.0, "AusBoss/video", 19, video=connected + frames=gradient_batch(1, 8, 8), fps=16.0, filename_prefix="AusBoss/video", crf=19, + video=connected, )) _path, encoded_frames, encoded_fps, encoded_audio, _crf, _metadata = encode.call_args.args @@ -279,18 +280,53 @@ class CoreVideoInputTests(unittest.TestCase): node_save_video, "encode_video", return_value=(8, 8, 2) ) as encode, ): - run_node(node_save_video.AusBossSaveVideo().save(images, 12.0, "AusBoss/video", 19)) + run_node(node_save_video.AusBossSaveVideo().save( + frames=images, fps=12.0, filename_prefix="AusBoss/video", crf=19 + )) _path, encoded_frames, encoded_fps, _audio, _crf, _metadata = encode.call_args.args self.assertIs(encoded_frames, images) self.assertAlmostEqual(encoded_fps, 12.0) - def test_the_video_input_is_optional_and_declared_after_audio(self): - optional = node_save_video.AusBossSaveVideo.INPUT_TYPES()["optional"] - self.assertEqual(list(optional), ["audio", "video"]) - self.assertEqual(optional["video"][0], "VIDEO") - required = node_save_video.AusBossSaveVideo.INPUT_TYPES()["required"] - self.assertEqual(list(required), ["frames", "fps", "filename_prefix", "crf"]) + def test_the_link_slot_order_saved_workflows_rely_on_is_unchanged(self): + # Saved workflows address inputs by slot index, and the frontend numbers + # them by walking required then optional and keeping the link types. A + # connected video carries its own frames, so frames leads the optional + # group rather than sitting in required — the slot order must not move. + spec = node_save_video.AusBossSaveVideo.INPUT_TYPES() + widget_types = {"INT", "FLOAT", "STRING", "BOOLEAN"} + sockets = [ + name + for group in ("required", "optional") + for name, definition in spec.get(group, {}).items() + if isinstance(definition[0], str) and definition[0] not in widget_types + ] + self.assertEqual(sockets, ["frames", "audio", "video"]) + self.assertEqual(spec["optional"]["frames"][0], "IMAGE") + self.assertEqual(spec["optional"]["video"][0], "VIDEO") + self.assertEqual(list(spec["required"]), ["fps", "filename_prefix", "crf"]) + + def test_a_video_alone_encodes_without_a_frames_connection(self): + images = gradient_batch(4, 16, 16) + connected = FakeCoreVideo(images, None, Fraction(12, 1)) + with ( + patch.object(node_save_video, "folder_paths", FakeFolderPaths), + patch.object(node_save_video, "encode_video", return_value=(16, 16, 4)) as encode, + ): + run_node(node_save_video.AusBossSaveVideo().save( + fps=16.0, filename_prefix="AusBoss/video", crf=19, video=connected + )) + + _path, encoded_frames, encoded_fps, _audio, _crf, _metadata = encode.call_args.args + self.assertIs(encoded_frames, images) + self.assertAlmostEqual(encoded_fps, 12.0) + + def test_neither_frames_nor_video_reports_what_to_connect(self): + with patch.object(node_save_video, "folder_paths", FakeFolderPaths): + with self.assertRaisesRegex(ValueError, "frames batch or a video"): + run_node(node_save_video.AusBossSaveVideo().save( + fps=16.0, filename_prefix="AusBoss/video", crf=19 + )) class AsyncSaveTests(unittest.TestCase): @@ -323,7 +359,8 @@ class AsyncSaveTests(unittest.TestCase): patch.object(node_save_video, "encode_video", slow_encode), ): await node_save_video.AusBossSaveVideo().save( - gradient_batch(1, 32, 32), 24.0, "AusBoss/video", 19 + frames=gradient_batch(1, 32, 32), fps=24.0, + filename_prefix="AusBoss/video", crf=19, ) finally: beat.cancel()