Skip to content

Commit 43eeb06

Browse files
Harden video save compatibility and regressions
1 parent 6ce57f3 commit 43eeb06

5 files changed

Lines changed: 125 additions & 39 deletions

File tree

comfy_api/latest/_input_impl/video_types.py

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,7 @@ def get_open_write_kwargs(
7171
is_write_to_buffer = isinstance(dest, io.BytesIO)
7272
open_kwargs = {"mode": "w"}
7373

74-
if is_write_to_buffer:
75-
# Set output format explicitly, since it cannot be inferred from file extension
74+
if is_write_to_buffer or to_format != VideoContainer.AUTO:
7675
if to_format == VideoContainer.AUTO:
7776
to_format = container_format.lower()
7877
elif isinstance(to_format, VideoContainer):
@@ -81,7 +80,7 @@ def get_open_write_kwargs(
8180
to_format = to_format.lower()
8281
open_kwargs["format"] = container_to_output_format(to_format)
8382

84-
output_format = open_kwargs["format"] if is_write_to_buffer else os.path.splitext(dest)[1].lower().lstrip(".")
83+
output_format = open_kwargs["format"] if "format" in open_kwargs else os.path.splitext(dest)[1].lower().lstrip(".")
8584
if output_format in ("mov", "mp4"):
8685
# Preserve custom metadata tags (workflow, prompt, extra_pnginfo) in isobmff.
8786
movflags = "use_metadata_tags" if is_write_to_buffer else "use_metadata_tags+faststart"
@@ -617,10 +616,8 @@ def _save_remuxed(
617616
) -> bool:
618617
streams = container.streams
619618
with av.open(path, **open_kwargs) as output_container:
620-
# Add metadata before writing any streams
621619
write_output_metadata(container, output_container, metadata)
622620

623-
# Add streams to the new container. Streams with no codec context cannot be used as an output template.
624621
stream_map = {}
625622
for stream in streams:
626623
if isinstance(stream, (av.VideoStream, av.AudioStream, SubtitleStream)):
@@ -647,7 +644,6 @@ def _save_remuxed(
647644
return False
648645
stream_map[stream] = out_stream
649646

650-
# Write packets to the new container
651647
for packet in container.demux():
652648
if packet.stream in stream_map and packet.dts is not None:
653649
packet.stream = stream_map[packet.stream]
@@ -683,6 +679,7 @@ def _save_transcoded(
683679
audio_stream = last_decodable_audio_stream(container)
684680
source_color_space = video_stream_color_space(video_stream)
685681
preserve_source_color = source_color_space is not None
682+
normalize_color_range = video_stream.color_range == ColorRange.JPEG and source_color_space not in HDR_COLOR_TRANSFERS
686683
if color_space in HDR_COLOR_TRANSFERS or source_color_space in HDR_COLOR_TRANSFERS:
687684
bit_depth = max(bit_depth, 10)
688685
pix_fmt = "yuv420p10le" if bit_depth >= 10 else "yuv420p"
@@ -712,15 +709,10 @@ def _save_transcoded(
712709
if duration:
713710
duration_cap = math.ceil(duration * sample_rate)
714711

715-
# Subtitles are remuxed untouched: there is no subtitle encoder binding, so a stream the
716-
# output container cannot store as-is is dropped with a warning naming it, exactly like
717-
# the remux path does. Streams FFmpeg has no decoder for cannot template a new stream.
718712
subtitle_streams = [s for s in container.streams.subtitles if s.codec_context is not None]
719713
streams = [video_stream] if audio_stream is None else [video_stream, audio_stream]
720714
streams += subtitle_streams
721715
subtitle_map = {}
722-
# Subtitle packets that arrive before the first kept video frame: the output is not open
723-
# yet and the pts rebase offset is not known, so they wait here rather than being lost.
724716
pending_subtitles = []
725717
pts_step = max(1, int(round((1 / rate) / video_stream.time_base)))
726718
video_done = False
@@ -780,15 +772,12 @@ def drain_audio(final=False):
780772
return cap
781773

782774
def mux_subtitle(packet):
783-
"""Remux one subtitle packet, rebased onto the trimmed timeline the video was rebased to."""
784775
out_stream = subtitle_map.get(packet.stream)
785776
if out_stream is None or packet.dts is None or packet.pts is None or packet.time_base is None:
786777
return
787778
start = float(packet.pts * packet.time_base)
788779
if start < start_time or (duration and start >= start_time + duration):
789780
return
790-
# the video's own rebase offset, so subtitles stay in sync with it rather than
791-
# with the requested start (a seek lands on the preceding keyframe)
792781
offset_ticks = video_pts_offset if video_pts_offset is not None else start_pts
793782
shift = int(round(float(offset_ticks * video_stream.time_base) / packet.time_base))
794783
packet.pts -= shift
@@ -799,7 +788,6 @@ def mux_subtitle(packet):
799788
output.mux(packet)
800789

801790
def flush_subtitles():
802-
# only once the first video frame fixed the rebase offset
803791
if output is None or not pending_subtitles or last_video_pts is None:
804792
return
805793
while pending_subtitles:
@@ -853,6 +841,8 @@ def flush_subtitles():
853841
out_video.options = video_encoder_options(output_codec, crf)
854842
if preserve_source_color:
855843
copy_color_properties(video_stream, out_video.codec_context)
844+
if normalize_color_range:
845+
out_video.codec_context.color_range = ColorRange.MPEG
856846
elif color_space is not None:
857847
set_video_color_properties(out_video.codec_context, color_space)
858848
# source pts pass through (rebased to 0), so variable frame rate survives
@@ -897,13 +887,14 @@ def flush_subtitles():
897887
rotation_filter = (g_src, g_sink)
898888
rotation_filter[0].push(frame)
899889
frame = rotation_filter[1].pull()
900-
if frame.color_range == ColorRange.JPEG and not preserve_source_color:
901-
# compress full-range sources (yuvj/MJPEG) to limited range
890+
if frame.color_range == ColorRange.JPEG and normalize_color_range:
902891
frame = frame.reformat(format=pix_fmt, src_color_range="JPEG", dst_color_range="MPEG")
903892
else:
904893
frame = frame.reformat(format=pix_fmt)
905894
if preserve_source_color:
906895
copy_color_properties(video_stream, frame)
896+
if normalize_color_range:
897+
frame.color_range = ColorRange.MPEG
907898
elif color_space is not None:
908899
set_video_color_properties(frame, color_space)
909900
frame_output_end = None

comfy_extras/nodes_video.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -167,14 +167,16 @@ def define_schema(cls):
167167
)
168168

169169
@classmethod
170-
def execute(cls, video: Input.Video, filename_prefix, format: io.DynamicCombo.Type | str, codec: io.DynamicCombo.Type | None = None) -> io.NodeOutput:
170+
def execute(cls, video: Input.Video, filename_prefix, format: io.DynamicCombo.Type | str, codec: io.DynamicCombo.Type | str | None = None) -> io.NodeOutput:
171171
if isinstance(format, dict):
172172
format_name = format["format"]
173173
codec = format.get("codec") or codec
174174
else:
175175
format_name = format
176176
if codec is None:
177177
codec = {"codec": "auto"}
178+
elif isinstance(codec, str):
179+
codec = {"codec": codec}
178180
codec_name = codec["codec"]
179181
encoding = codec.get("encoding") or {}
180182
color_space = encoding.get("color_space")
@@ -197,14 +199,15 @@ def execute(cls, video: Input.Video, filename_prefix, format: io.DynamicCombo.Ty
197199
if len(metadata) > 0:
198200
saved_metadata = metadata
199201
file = f"{filename}_{counter:05}_.{Types.VideoContainer.get_extension(format_name)}"
200-
video.save_to(
201-
os.path.join(full_output_folder, file),
202-
format=Types.VideoContainer(format_name),
203-
codec=Types.VideoCodec(codec_name),
204-
metadata=saved_metadata,
205-
crf=encoding.get("crf"),
206-
color_space=color_space,
207-
)
202+
save_options = {
203+
"format": Types.VideoContainer(format_name),
204+
"codec": Types.VideoCodec(codec_name),
205+
"metadata": saved_metadata,
206+
"crf": encoding.get("crf"),
207+
}
208+
if color_space is not None:
209+
save_options["color_space"] = color_space
210+
video.save_to(os.path.join(full_output_folder, file), **save_options)
208211

209212
return io.NodeOutput(video, ui=ui.PreviewVideo([ui.SavedResult(file, subfolder, io.FolderType.output)]))
210213

tests-unit/comfy_api_test/input_impl_test.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,12 @@ def test_container_to_output_format_single():
2929
assert container_to_output_format("mp4") == "mp4"
3030

3131

32-
def test_get_open_write_kwargs_filepath_no_format():
33-
"""Test that 'format' kwarg is NOT set when dest is a file path."""
32+
def test_get_open_write_kwargs_filepath_format():
3433
kwargs_auto = get_open_write_kwargs("output.mp4", "mp4", VideoContainer.AUTO)
35-
assert "format" not in kwargs_auto, "Format should not be set for file paths (AUTO)"
34+
assert "format" not in kwargs_auto
3635

3736
kwargs_specific = get_open_write_kwargs("output.avi", "mp4", "avi")
38-
fail_msg = "Format should not be set for file paths (Specific)"
39-
assert "format" not in kwargs_specific, fail_msg
37+
assert kwargs_specific["format"] == "avi"
4038
assert "options" not in kwargs_specific
4139

4240

tests-unit/comfy_api_test/video_types_test.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,48 @@ def create_hdr_av1_video(path, transfer, color_range):
355355
container.mux(stream.encode(None))
356356

357357

358+
def create_full_range_srgb_video(path):
359+
images = np.random.default_rng(29).integers(0, 256, (3, 64, 64, 3), dtype=np.uint8)
360+
with av.open(path, mode="w") as container:
361+
stream = container.add_stream("mjpeg", rate=30)
362+
stream.width = 64
363+
stream.height = 64
364+
stream.pix_fmt = "yuvj420p"
365+
stream.color_primaries = ColorPrimaries.BT709
366+
stream.color_trc = ColorTrc.BT709
367+
stream.colorspace = 1
368+
stream.color_range = ColorRange.JPEG
369+
for image in images:
370+
frame = av.VideoFrame.from_ndarray(image, format="rgb24").reformat(format="yuvj420p")
371+
frame.color_primaries = ColorPrimaries.BT709
372+
frame.color_trc = ColorTrc.BT709
373+
frame.colorspace = 1
374+
frame.color_range = ColorRange.JPEG
375+
container.mux(stream.encode(frame))
376+
container.mux(stream.encode(None))
377+
378+
379+
def test_save_loaded_srgb_converts_full_range_to_limited(tmp_path):
380+
source = str(tmp_path / "source.mov")
381+
output = str(tmp_path / "output.mp4")
382+
create_full_range_srgb_video(source)
383+
with av.open(source) as container:
384+
source_stream = container.streams.video[0]
385+
source_color = (source_stream.color_primaries, source_stream.color_trc)
386+
387+
VideoFromFile(source).save_to(
388+
output,
389+
format=VideoContainer.MP4,
390+
codec=VideoCodec.H264,
391+
crf=0,
392+
)
393+
394+
with av.open(output) as container:
395+
stream = container.streams.video[0]
396+
assert (stream.color_primaries, stream.color_trc) == source_color
397+
assert stream.color_range == ColorRange.MPEG
398+
399+
358400
def test_save_to_av1_crf_controls_quality(tmp_path):
359401
generator = torch.Generator().manual_seed(11)
360402
components = VideoComponents(
@@ -824,6 +866,16 @@ def test_save_to_auto_still_remuxes_a_compatible_codec(tmp_path):
824866
assert decoded_counts(container)[0] == 6
825867

826868

869+
def test_save_to_explicit_format_overrides_destination_suffix(simple_video_file, tmp_path):
870+
destination = str(tmp_path / "saved.mkv")
871+
872+
VideoFromFile(simple_video_file).save_to(destination, format=VideoContainer.MP4)
873+
874+
with av.open(destination) as container:
875+
assert "mp4" in container.format.name.split(",")
876+
assert decoded_counts(container)[0] == 3
877+
878+
827879
def test_save_to_buffer_transcodes_codec_the_container_cannot_store(tmp_path):
828880
source = create_matroska_source(
829881
tmp_path, video_codec="mpeg4", audio_codec="pcm_u8", container_format="mov"
@@ -1348,13 +1400,11 @@ def test_save_to_transcode_bakes_rotation():
13481400

13491401

13501402
def mov_text_payload(text: str) -> bytes:
1351-
"""A mov_text sample is a 16-bit big-endian length followed by the UTF-8 text."""
13521403
encoded = text.encode("utf-8")
13531404
return len(encoded).to_bytes(2, "big") + encoded
13541405

13551406

13561407
def create_subtitled_source(subtitle_codec="mov_text", container_format="mp4", frames=90, fps=30):
1357-
"""mpeg4 video (so save_to must transcode) alongside a subtitle track carrying three cues."""
13581408
buffer = io.BytesIO()
13591409
with av.open(buffer, mode="w", format=container_format) as container:
13601410
video_stream = container.add_stream("mpeg4", rate=fps)
@@ -1380,7 +1430,6 @@ def create_subtitled_source(subtitle_codec="mov_text", container_format="mp4", f
13801430

13811431

13821432
def subtitle_cues(buffer):
1383-
"""(seconds, text) for every non-empty cue; the mp4 muxer pads gaps with 2-byte empties."""
13841433
buffer.seek(0)
13851434
with av.open(buffer) as container:
13861435
if not container.streams.subtitles:
@@ -1393,7 +1442,6 @@ def subtitle_cues(buffer):
13931442

13941443

13951444
def test_save_to_transcode_keeps_subtitles_the_container_can_store():
1396-
"""Transcoding video must not silently drop a subtitle track the output can hold."""
13971445
output = io.BytesIO()
13981446
VideoFromFile(create_subtitled_source()).save_to(
13991447
output, format=VideoContainer.MP4, codec=VideoCodec.H264
@@ -1407,7 +1455,6 @@ def test_save_to_transcode_keeps_subtitles_the_container_can_store():
14071455

14081456

14091457
def test_save_to_transcode_trims_subtitles_with_the_video():
1410-
"""Kept cues rebase onto the trimmed timeline; cues outside the window are dropped."""
14111458
output = io.BytesIO()
14121459
VideoFromFile(create_subtitled_source(), start_time=1, duration=1).save_to(
14131460
output, format=VideoContainer.MP4, codec=VideoCodec.H264
@@ -1417,8 +1464,6 @@ def test_save_to_transcode_trims_subtitles_with_the_video():
14171464

14181465

14191466
def test_save_to_transcode_drops_unstorable_subtitles_with_a_warning(caplog):
1420-
"""There is no subtitle encoder binding, so subrip cannot become mov_text: drop it, but
1421-
name the stream instead of letting the track vanish silently."""
14221467
output = io.BytesIO()
14231468
with caplog.at_level(logging.WARNING):
14241469
VideoFromFile(create_subtitled_source(subtitle_codec="subrip", container_format="matroska")).save_to(
@@ -1437,7 +1482,6 @@ def test_save_to_transcode_drops_unstorable_subtitles_with_a_warning(caplog):
14371482

14381483

14391484
def test_save_to_remux_fallback_keeps_subtitles():
1440-
"""The audio-triggered fallback into the transcode path must keep subtitles too."""
14411485
buffer = io.BytesIO()
14421486
with av.open(buffer, mode="w", format="mov") as container:
14431487
video_stream = container.add_stream("mpeg4", rate=30)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import Mock
3+
4+
import pytest
5+
6+
from comfy_api.latest import Types
7+
from comfy_extras import nodes_video
8+
9+
10+
@pytest.fixture
11+
def save_video(monkeypatch, tmp_path):
12+
monkeypatch.setattr(nodes_video.folder_paths, "get_output_directory", lambda: str(tmp_path))
13+
monkeypatch.setattr(
14+
nodes_video.folder_paths,
15+
"get_save_image_path",
16+
lambda *args: (str(tmp_path), "output", 1, "", args[0]),
17+
)
18+
monkeypatch.setattr(nodes_video.SaveVideo, "hidden", SimpleNamespace(prompt=None, extra_pnginfo=None), raising=False)
19+
video = Mock()
20+
video.get_dimensions.return_value = (64, 64)
21+
return video
22+
23+
24+
def test_save_video_accepts_legacy_codec_string(save_video):
25+
nodes_video.SaveVideo.execute(save_video, "video", "mp4", "h264")
26+
27+
kwargs = save_video.save_to.call_args.kwargs
28+
assert kwargs["format"] == Types.VideoContainer.MP4
29+
assert kwargs["codec"] == Types.VideoCodec.H264
30+
assert "color_space" not in kwargs
31+
32+
33+
def test_save_video_forwards_nested_encoding_options(save_video):
34+
nodes_video.SaveVideo.execute(
35+
save_video,
36+
"video",
37+
{
38+
"format": "webm",
39+
"codec": {
40+
"codec": "av1",
41+
"encoding": {"crf": 30, "color_space": "HDR"},
42+
},
43+
},
44+
)
45+
46+
kwargs = save_video.save_to.call_args.kwargs
47+
assert kwargs["format"] == Types.VideoContainer.WEBM
48+
assert kwargs["codec"] == Types.VideoCodec.AV1
49+
assert kwargs["crf"] == 30
50+
assert kwargs["color_space"] == "HDR"

0 commit comments

Comments
 (0)