diff --git a/comfy_api/latest/_input_impl/video_types.py b/comfy_api/latest/_input_impl/video_types.py index 09db94198..ae60603c4 100644 --- a/comfy_api/latest/_input_impl/video_types.py +++ b/comfy_api/latest/_input_impl/video_types.py @@ -521,6 +521,12 @@ class VideoFromFile(VideoInput): start_time, duration = self.get_active_trim_window() start_pts = int(start_time / video_stream.time_base) end_pts = int((start_time + duration) / video_stream.time_base) if duration else None + stream_end_pts = None + if video_stream.duration is not None: + stream_end_pts = (video_stream.start_time or 0) + video_stream.duration + output_end_pts = end_pts + if stream_end_pts is not None and (output_end_pts is None or stream_end_pts < output_end_pts): + output_end_pts = stream_end_pts if start_pts != 0: container.seek(start_pts, stream=video_stream) @@ -686,14 +692,29 @@ class VideoFromFile(VideoInput): frame = frame.reformat(format=pix_fmt, src_color_range="JPEG", dst_color_range="MPEG") else: frame = frame.reformat(format=pix_fmt) + frame_output_end = None if frame.pts is not None: if video_pts_offset is None: video_pts_offset = frame.pts frame.pts -= video_pts_offset + if output_end_pts is not None: + frame_output_end = output_end_pts - video_pts_offset + if frame.pts + frame_duration > frame_output_end: + clamped_pts = frame_output_end - frame_duration + if clamped_pts >= 0 and (last_video_pts is None or clamped_pts > last_video_pts): + frame.pts = min(frame.pts, clamped_pts) + elif frame.pts < frame_output_end: + frame_duration = frame_output_end - frame.pts + else: + continue if frame.pts is None or (last_video_pts is not None and frame.pts <= last_video_pts): # broken sources emit missing/backward timestamps mid-stream, which the # muxer rejects; nudge them forward by one nominal frame interval frame.pts = 0 if last_video_pts is None else last_video_pts + pts_step + if frame_output_end is not None and frame.pts + frame_duration > frame_output_end: + if frame.pts >= frame_output_end: + continue + frame_duration = frame_output_end - frame.pts last_video_pts = frame.pts last_video_end = frame.pts + frame_duration video_frame_durations[frame.pts] = frame_duration diff --git a/tests-unit/comfy_api_test/video_types_test.py b/tests-unit/comfy_api_test/video_types_test.py index 3dc7055f6..49b53c6af 100644 --- a/tests-unit/comfy_api_test/video_types_test.py +++ b/tests-unit/comfy_api_test/video_types_test.py @@ -564,6 +564,78 @@ def test_save_to_transcode_sparse_video_keeps_true_duration(): assert trimmed["audio_seconds"] == pytest.approx(45.0, abs=0.1) +def test_save_to_transcode_clamps_final_pts_to_declared_stream_duration(): + """Some iPhone MOVs report a video stream duration that ends before the final + decoded frame's nominal duration. A transcode must not turn that trailing + timestamp quirk into an extra frame interval compared to the source/remux path.""" + fps = 30 + buffer = io.BytesIO() + with av.open(buffer, mode="w", format="mp4") as container: + video_stream = container.add_stream("mpeg4", rate=fps) + video_stream.width = video_stream.height = 64 + video_stream.pix_fmt = "yuv420p" + for i, pts in enumerate([*range(31), 32]): + frame = av.VideoFrame.from_ndarray( + torch.full((64, 64, 3), (i * 7) % 256, dtype=torch.uint8).numpy(), format="rgb24" + ).reformat(format="yuv420p") + frame.pts = pts + frame.time_base = Fraction(1, fps) + container.mux(video_stream.encode(frame)) + container.mux(video_stream.encode(None)) + + class _StreamProxy: + def __init__(self, stream, duration): + self._stream = stream + self.duration = duration + + def __getattr__(self, name): + return getattr(self._stream, name) + + class _StreamsProxy: + def __init__(self, video_stream): + self.video = [video_stream] + self.audio = [] + + class _PacketProxy: + def __init__(self, packet, stream): + self._packet = packet + self.stream = stream + + def __getattr__(self, name): + return getattr(self._packet, name) + + class _ContainerProxy: + def __init__(self, container, stream): + self._container = container + self._stream = stream + self.streams = _StreamsProxy(stream) + + def __getattr__(self, name): + return getattr(self._container, name) + + def demux(self, *streams): + for packet in self._container.demux(self._stream._stream): + yield _PacketProxy(packet, self._stream) + + buffer.seek(0) + output = io.BytesIO() + with av.open(buffer) as container: + real_stream = container.streams.video[0] + declared_duration = 32 * int(round((1 / fps) / real_stream.time_base)) + stream = _StreamProxy(real_stream, declared_duration) + VideoFromFile(buffer)._save_transcoded( + _ContainerProxy(container, stream), output, VideoContainer.MP4, VideoCodec.H264, None, 8 + ) + + output.seek(0) + with av.open(output) as container: + video_stream = container.streams.video[0] + frames = [f for p in container.demux(video_stream) for f in p.decode()] + assert len(frames) == 32 + assert float(video_stream.duration * video_stream.time_base) == pytest.approx(32 / fps, abs=0.01) + assert float(frames[-1].pts * frames[-1].time_base) == pytest.approx(31 / fps, abs=0.01) + + def test_save_to_transcode_irregular_vfr_keeps_span(): """B-frames reorder packets, and mp4 sample durations follow decode order: the dts timeline ends before the pts timeline, so an irregular-VFR source's tail holds fell