From 235b466a0cb26d47c24f2ab66d1a8c5e70b21070 Mon Sep 17 00:00:00 2001 From: comfyanonymous <121283862+comfyanonymous@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:27:48 -0700 Subject: [PATCH 1/2] Add crf option to save video node. (#15191) --- comfy_api/latest/_input/video_types.py | 2 ++ comfy_api/latest/_input_impl/video_types.py | 11 +++++- comfy_extras/nodes_video.py | 35 ++++++++++++++++--- tests-unit/comfy_api_test/video_types_test.py | 18 ++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/comfy_api/latest/_input/video_types.py b/comfy_api/latest/_input/video_types.py index e2e99521fa4..b700d44f55b 100644 --- a/comfy_api/latest/_input/video_types.py +++ b/comfy_api/latest/_input/video_types.py @@ -29,11 +29,13 @@ def save_to( codec: VideoCodec = VideoCodec.AUTO, metadata: Optional[dict] = None, bit_depth: int | None = None, + crf: float | None = None, ): """ Abstract method to save the video input to a file. bit_depth selects the encoded bit depth; None keeps the video's native depth. + crf selects the H.264 constant rate factor; None uses the encoder default. """ pass diff --git a/comfy_api/latest/_input_impl/video_types.py b/comfy_api/latest/_input_impl/video_types.py index f5af41973d6..14d66388110 100644 --- a/comfy_api/latest/_input_impl/video_types.py +++ b/comfy_api/latest/_input_impl/video_types.py @@ -460,6 +460,7 @@ def save_to( codec: VideoCodec = VideoCodec.AUTO, metadata: Optional[dict] = None, bit_depth: int | None = None, + crf: float | None = None, ): if isinstance(self.__file, io.BytesIO): self.__file.seek(0) # Reset the BytesIO object to the beginning @@ -475,13 +476,15 @@ def save_to( reuse_streams = False if bit_depth is not None and video_encoding is not None and bit_depth != source_bit_depth: reuse_streams = False + if crf is not None: + reuse_streams = False if self.__start_time or self.__duration: reuse_streams = False if not reuse_streams: if bit_depth is None: bit_depth = source_bit_depth - return self._save_transcoded(container, path, format=format, codec=codec, metadata=metadata, bit_depth=bit_depth) + return self._save_transcoded(container, path, format=format, codec=codec, metadata=metadata, bit_depth=bit_depth, crf=crf) streams = container.streams @@ -514,6 +517,7 @@ def _save_transcoded( codec: VideoCodec, metadata: dict | None, bit_depth: int, + crf: float | None = None, ): """Re-encode to H.264/AAC one frame at a time; peak memory does not scale with video length.""" open_kwargs = mp4_output_open_kwargs(path, format, codec) @@ -659,6 +663,8 @@ def drain_audio(final=False): out_video.width = out_width out_video.height = out_height out_video.pix_fmt = pix_fmt + if crf is not None: + out_video.options = {"crf": str(crf)} # source pts pass through (rebased to 0), so variable frame rate survives out_video.codec_context.time_base = video_stream.time_base if audio_stream is not None: @@ -827,6 +833,7 @@ def save_to( codec: VideoCodec = VideoCodec.AUTO, metadata: Optional[dict] = None, bit_depth: int | None = None, + crf: float | None = None, ): """Save the video to a file path or BytesIO buffer.""" open_kwargs = mp4_output_open_kwargs(path, format, codec) @@ -847,6 +854,8 @@ def save_to( video_stream.width = self.__components.images.shape[2] video_stream.height = self.__components.images.shape[1] video_stream.pix_fmt = pix_fmt + if crf is not None: + video_stream.options = {"crf": str(crf)} # Create an audio stream audio_sample_rate = 1 diff --git a/comfy_extras/nodes_video.py b/comfy_extras/nodes_video.py index 3bfd00be4f5..45394ce4df0 100644 --- a/comfy_extras/nodes_video.py +++ b/comfy_extras/nodes_video.py @@ -86,7 +86,31 @@ def define_schema(cls): io.Video.Input("video", tooltip="The video to save."), io.String.Input("filename_prefix", default="video/ComfyUI", tooltip="The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes."), io.Combo.Input("format", options=Types.VideoContainer.as_input(), default="auto", tooltip="The format to save the video as."), - io.Combo.Input("codec", options=Types.VideoCodec.as_input(), default="auto", tooltip="The codec to use for the video."), + io.DynamicCombo.Input( + "codec", + options=[ + io.DynamicCombo.Option("auto", []), + io.DynamicCombo.Option( + "h264", + [ + io.DynamicCombo.Input( + "encoding", + display_name="encoding mode", + options=[ + io.DynamicCombo.Option("auto", []), + io.DynamicCombo.Option( + "re-encode", + [io.Float.Input("crf", default=23.0, min=0.0, max=51.0, step=1.0, tooltip="Lower values produce higher quality and larger files.")], + ), + ], + optional=True, + tooltip="Automatic preserves compatible H.264 streams. Re-encode applies a custom CRF.", + ), + ], + ), + ], + tooltip="The codec to use for the video.", + ), ], hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo], is_output_node=True, @@ -94,7 +118,9 @@ def define_schema(cls): ) @classmethod - def execute(cls, video: Input.Video, filename_prefix, format: str, codec) -> io.NodeOutput: + def execute(cls, video: Input.Video, filename_prefix, format: str, codec: io.DynamicCombo.Type) -> io.NodeOutput: + codec_name = codec["codec"] + encoding = codec.get("encoding") or {} width, height = video.get_dimensions() full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path( filename_prefix, @@ -115,8 +141,9 @@ def execute(cls, video: Input.Video, filename_prefix, format: str, codec) -> io. video.save_to( os.path.join(full_output_folder, file), format=Types.VideoContainer(format), - codec=codec, - metadata=saved_metadata + codec=codec_name, + metadata=saved_metadata, + crf=encoding.get("crf"), ) return io.NodeOutput(video, ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)])) diff --git a/tests-unit/comfy_api_test/video_types_test.py b/tests-unit/comfy_api_test/video_types_test.py index ae758bd4000..dd95dc843ec 100644 --- a/tests-unit/comfy_api_test/video_types_test.py +++ b/tests-unit/comfy_api_test/video_types_test.py @@ -240,6 +240,24 @@ def test_duration_consistency(video_components): assert duration == pytest.approx(manual_duration) +def test_save_to_h264_crf_controls_quality(tmp_path): + generator = torch.Generator().manual_seed(7) + components = VideoComponents( + images=torch.rand(12, 64, 64, 3, generator=generator), + frame_rate=Fraction(30), + ) + high_quality = str(tmp_path / "high_quality.mp4") + low_quality = str(tmp_path / "low_quality.mp4") + transcoded = str(tmp_path / "transcoded.mp4") + + VideoFromComponents(components).save_to(high_quality, codec=VideoCodec.H264, crf=0) + VideoFromComponents(components).save_to(low_quality, codec=VideoCodec.H264, crf=51) + assert os.path.getsize(high_quality) > os.path.getsize(low_quality) + + VideoFromFile(high_quality).save_to(transcoded, codec=VideoCodec.H264, crf=51) + assert os.path.getsize(transcoded) < os.path.getsize(high_quality) + + def create_transcode_source( width=64, height=64, frames=30, fps=30, audio_streams=1, undecodable_audio=0, rotation=False, container_format="mov", audio_codec="pcm_s16le", From 2881e6161081439b1c3fb3b6c1f51b3d272da710 Mon Sep 17 00:00:00 2001 From: comfyanonymous <121283862+comfyanonymous@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:21:28 -0700 Subject: [PATCH 2/2] Store mp4 metadata at the beginning of the file when possible. (#15195) --- comfy_api/latest/_input_impl/video_types.py | 10 +++++++--- tests-unit/comfy_api_test/input_impl_test.py | 5 +++-- tests-unit/comfy_api_test/video_types_test.py | 12 ++++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/comfy_api/latest/_input_impl/video_types.py b/comfy_api/latest/_input_impl/video_types.py index 14d66388110..cf4119250a4 100644 --- a/comfy_api/latest/_input_impl/video_types.py +++ b/comfy_api/latest/_input_impl/video_types.py @@ -36,13 +36,15 @@ def get_open_write_kwargs( dest: str | io.BytesIO, container_format: str, to_format: str | None ) -> dict: """Get kwargs for writing a `VideoFromFile` to a file/stream with `av.open`""" + is_write_to_buffer = isinstance(dest, io.BytesIO) + is_mp4_file = not is_write_to_buffer and os.path.splitext(dest)[1].lower() == ".mp4" + movflags = "use_metadata_tags+faststart" if is_mp4_file else "use_metadata_tags" open_kwargs = { "mode": "w", # If isobmff, preserve custom metadata tags (workflow, prompt, extra_pnginfo) - "options": {"movflags": "use_metadata_tags"}, + "options": {"movflags": movflags}, } - is_write_to_buffer = isinstance(dest, io.BytesIO) if is_write_to_buffer: # Set output format explicitly, since it cannot be inferred from file extension if to_format == VideoContainer.AUTO: @@ -103,7 +105,9 @@ def mp4_output_open_kwargs(path: str | io.BytesIO, format: VideoContainer, codec raise ValueError("Only MP4 format is supported for now") if codec != VideoCodec.AUTO and codec != VideoCodec.H264: raise ValueError("Only H264 codec is supported for now") - open_kwargs = {"mode": "w", "options": {"movflags": "use_metadata_tags"}} + # FFmpeg's faststart pass reopens the output by filename, so it cannot be used with file-like objects. + movflags = "use_metadata_tags+faststart" if isinstance(path, (str, os.PathLike)) else "use_metadata_tags" + open_kwargs = {"mode": "w", "options": {"movflags": movflags}} if isinstance(format, VideoContainer) and format != VideoContainer.AUTO: open_kwargs["format"] = format.value elif isinstance(path, io.BytesIO): diff --git a/tests-unit/comfy_api_test/input_impl_test.py b/tests-unit/comfy_api_test/input_impl_test.py index 5fc21a9a743..f1924f16326 100644 --- a/tests-unit/comfy_api_test/input_impl_test.py +++ b/tests-unit/comfy_api_test/input_impl_test.py @@ -36,6 +36,7 @@ def test_get_open_write_kwargs_filepath_no_format(): kwargs_specific = get_open_write_kwargs("output.avi", "mp4", "avi") fail_msg = "Format should not be set for file paths (Specific)" assert "format" not in kwargs_specific, fail_msg + assert kwargs_specific["options"]["movflags"] == "use_metadata_tags" def test_get_open_write_kwargs_base_options_mode(): @@ -43,9 +44,9 @@ def test_get_open_write_kwargs_base_options_mode(): kwargs = get_open_write_kwargs("output.mp4", "mp4", VideoContainer.AUTO) assert kwargs["mode"] == "w", "mode should be set to write" - fail_msg = "movflags should be set to preserve custom metadata tags" + fail_msg = "movflags should preserve custom metadata tags and enable faststart for MP4 files" assert "movflags" in kwargs["options"], fail_msg - assert kwargs["options"]["movflags"] == "use_metadata_tags", fail_msg + assert kwargs["options"]["movflags"] == "use_metadata_tags+faststart", fail_msg def test_get_open_write_kwargs_bytesio_auto_format(): diff --git a/tests-unit/comfy_api_test/video_types_test.py b/tests-unit/comfy_api_test/video_types_test.py index dd95dc843ec..f688d3ecaf1 100644 --- a/tests-unit/comfy_api_test/video_types_test.py +++ b/tests-unit/comfy_api_test/video_types_test.py @@ -258,6 +258,18 @@ def test_save_to_h264_crf_controls_quality(tmp_path): assert os.path.getsize(transcoded) < os.path.getsize(high_quality) +def test_save_to_mp4_writes_metadata_before_media(video_components, tmp_path): + encoded = tmp_path / "encoded.mp4" + remuxed = tmp_path / "remuxed.mp4" + + VideoFromComponents(video_components).save_to(str(encoded), metadata={"prompt": {"test": "value"}}) + VideoFromFile(str(encoded)).save_to(str(remuxed), metadata={"prompt": {"test": "value"}}) + + for path in (encoded, remuxed): + data = path.read_bytes() + assert data.index(b"moov") < data.index(b"mdat") + + def create_transcode_source( width=64, height=64, frames=30, fps=30, audio_streams=1, undecodable_audio=0, rotation=False, container_format="mov", audio_codec="pcm_s16le",