Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions comfy_api/latest/_input/video_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 17 additions & 4 deletions comfy_api/latest/_input_impl/video_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -460,6 +464,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
Expand All @@ -475,13 +480,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

Expand Down Expand Up @@ -514,6 +521,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)
Expand Down Expand Up @@ -659,6 +667,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:
Expand Down Expand Up @@ -827,6 +837,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)
Expand All @@ -847,6 +858,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
Expand Down
35 changes: 31 additions & 4 deletions comfy_extras/nodes_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,41 @@ 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,
outputs=[io.Video.Output("video")],
)

@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,
Expand All @@ -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)]))
Expand Down
5 changes: 3 additions & 2 deletions tests-unit/comfy_api_test/input_impl_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,17 @@ 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():
"""Test basic kwargs for file path: mode and movflags."""
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():
Expand Down
30 changes: 30 additions & 0 deletions tests-unit/comfy_api_test/video_types_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,36 @@ 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 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",
Expand Down
Loading