Add crf option to save video node. (#15191)

This commit is contained in:
comfyanonymous
2026-07-31 21:27:48 -07:00
committed by GitHub
parent a1c421994c
commit 235b466a0c
4 changed files with 61 additions and 5 deletions

View File

@@ -29,11 +29,13 @@ class VideoInput(ABC):
codec: VideoCodec = VideoCodec.AUTO, codec: VideoCodec = VideoCodec.AUTO,
metadata: Optional[dict] = None, metadata: Optional[dict] = None,
bit_depth: int | None = None, bit_depth: int | None = None,
crf: float | None = None,
): ):
""" """
Abstract method to save the video input to a file. Abstract method to save the video input to a file.
bit_depth selects the encoded bit depth; None keeps the video's native depth. 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 pass

View File

@@ -460,6 +460,7 @@ class VideoFromFile(VideoInput):
codec: VideoCodec = VideoCodec.AUTO, codec: VideoCodec = VideoCodec.AUTO,
metadata: Optional[dict] = None, metadata: Optional[dict] = None,
bit_depth: int | None = None, bit_depth: int | None = None,
crf: float | None = None,
): ):
if isinstance(self.__file, io.BytesIO): if isinstance(self.__file, io.BytesIO):
self.__file.seek(0) # Reset the BytesIO object to the beginning self.__file.seek(0) # Reset the BytesIO object to the beginning
@@ -475,13 +476,15 @@ class VideoFromFile(VideoInput):
reuse_streams = False reuse_streams = False
if bit_depth is not None and video_encoding is not None and bit_depth != source_bit_depth: if bit_depth is not None and video_encoding is not None and bit_depth != source_bit_depth:
reuse_streams = False reuse_streams = False
if crf is not None:
reuse_streams = False
if self.__start_time or self.__duration: if self.__start_time or self.__duration:
reuse_streams = False reuse_streams = False
if not reuse_streams: if not reuse_streams:
if bit_depth is None: if bit_depth is None:
bit_depth = source_bit_depth 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 streams = container.streams
@@ -514,6 +517,7 @@ class VideoFromFile(VideoInput):
codec: VideoCodec, codec: VideoCodec,
metadata: dict | None, metadata: dict | None,
bit_depth: int, 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.""" """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) open_kwargs = mp4_output_open_kwargs(path, format, codec)
@@ -659,6 +663,8 @@ class VideoFromFile(VideoInput):
out_video.width = out_width out_video.width = out_width
out_video.height = out_height out_video.height = out_height
out_video.pix_fmt = pix_fmt 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 # source pts pass through (rebased to 0), so variable frame rate survives
out_video.codec_context.time_base = video_stream.time_base out_video.codec_context.time_base = video_stream.time_base
if audio_stream is not None: if audio_stream is not None:
@@ -827,6 +833,7 @@ class VideoFromComponents(VideoInput):
codec: VideoCodec = VideoCodec.AUTO, codec: VideoCodec = VideoCodec.AUTO,
metadata: Optional[dict] = None, metadata: Optional[dict] = None,
bit_depth: int | None = None, bit_depth: int | None = None,
crf: float | None = None,
): ):
"""Save the video to a file path or BytesIO buffer.""" """Save the video to a file path or BytesIO buffer."""
open_kwargs = mp4_output_open_kwargs(path, format, codec) 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.width = self.__components.images.shape[2]
video_stream.height = self.__components.images.shape[1] video_stream.height = self.__components.images.shape[1]
video_stream.pix_fmt = pix_fmt video_stream.pix_fmt = pix_fmt
if crf is not None:
video_stream.options = {"crf": str(crf)}
# Create an audio stream # Create an audio stream
audio_sample_rate = 1 audio_sample_rate = 1

View File

@@ -86,7 +86,31 @@ class SaveVideo(io.ComfyNode):
io.Video.Input("video", tooltip="The video to save."), 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.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("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], hidden=[io.Hidden.prompt, io.Hidden.extra_pnginfo],
is_output_node=True, is_output_node=True,
@@ -94,7 +118,9 @@ class SaveVideo(io.ComfyNode):
) )
@classmethod @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() width, height = video.get_dimensions()
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path( full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, filename_prefix,
@@ -115,8 +141,9 @@ class SaveVideo(io.ComfyNode):
video.save_to( video.save_to(
os.path.join(full_output_folder, file), os.path.join(full_output_folder, file),
format=Types.VideoContainer(format), format=Types.VideoContainer(format),
codec=codec, codec=codec_name,
metadata=saved_metadata metadata=saved_metadata,
crf=encoding.get("crf"),
) )
return io.NodeOutput(video, ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)])) return io.NodeOutput(video, ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)]))

View File

@@ -240,6 +240,24 @@ def test_duration_consistency(video_components):
assert duration == pytest.approx(manual_duration) 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( def create_transcode_source(
width=64, height=64, frames=30, fps=30, audio_streams=1, undecodable_audio=0, rotation=False, width=64, height=64, frames=30, fps=30, audio_streams=1, undecodable_audio=0, rotation=False,
container_format="mov", audio_codec="pcm_s16le", container_format="mov", audio_codec="pcm_s16le",