diff --git a/comfy_api/latest/_input/video_types.py b/comfy_api/latest/_input/video_types.py index e2e99521f..b700d44f5 100644 --- a/comfy_api/latest/_input/video_types.py +++ b/comfy_api/latest/_input/video_types.py @@ -29,11 +29,13 @@ class VideoInput(ABC): 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 f5af41973..14d663881 100644 --- a/comfy_api/latest/_input_impl/video_types.py +++ b/comfy_api/latest/_input_impl/video_types.py @@ -460,6 +460,7 @@ class VideoFromFile(VideoInput): 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 @@ class VideoFromFile(VideoInput): 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 @@ class VideoFromFile(VideoInput): 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 @@ class VideoFromFile(VideoInput): 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 @@ class VideoFromComponents(VideoInput): 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 @@ class VideoFromComponents(VideoInput): 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 3bfd00be4..45394ce4d 100644 --- a/comfy_extras/nodes_video.py +++ b/comfy_extras/nodes_video.py @@ -86,7 +86,31 @@ class SaveVideo(io.ComfyNode): 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 @@ class SaveVideo(io.ComfyNode): ) @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 @@ class SaveVideo(io.ComfyNode): 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 ae758bd40..dd95dc843 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",