Let a connected video encode without a frames connection

This commit is contained in:
ausboss
2026-08-13 20:22:31 -05:00
parent 9a234bcbaf
commit 719b4e259c
3 changed files with 62 additions and 16 deletions
+2 -1
View File
@@ -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
+13 -5
View File
@@ -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,
+47 -10
View File
@@ -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()