From 5cd615f92e21c8e5b5ef210acda0d831c6534f4f Mon Sep 17 00:00:00 2001 From: Sergilol Date: Mon, 12 Oct 2026 01:26:40 +0300 Subject: [PATCH 1/9] Fix Matroska B-frame stream-copy timestamps --- CHANGELOG.md | 10 ++ release-notes/v0.5.1.md | 23 ++++ tests/test_keyframes.py | 91 +++++++++++++++ vfr_fastcut.py | 80 ++++++++++--- vfr_keyframes.py | 248 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 436 insertions(+), 16 deletions(-) create mode 100644 release-notes/v0.5.1.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f7cfb8..778160a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to VFR FastCut are documented here. +## 0.5.1 + +### Critical stream-copy timing fix + +- Fixed severe frame-pacing corruption when remuxing some Matroska HEVC/H.264 recordings with reordered B-frames and non-monotonic/missing decode timestamps. +- Added a lightweight packet-timestamp probe before video export. Valid streams keep the existing fast path unchanged. +- Problematic streams now preserve original presentation timestamps (PTS) while FFmpeg's packet-level setts bitstream filter reconstructs a monotonic decode timeline (DTS), without re-encoding video. +- Applied the repair consistently to single-range, multi-range, audio-processing and final concat/remux paths so temporary Matroska parts cannot reintroduce duplicate PTS. +- Added regression tests for broken Matroska-style DTS, valid B-frame DTS, VFR-rate fallback and repair-filter generation. + ## 0.5.0 ### Keyframe-aware editing and preview diff --git a/release-notes/v0.5.1.md b/release-notes/v0.5.1.md new file mode 100644 index 0000000..b5a624d --- /dev/null +++ b/release-notes/v0.5.1.md @@ -0,0 +1,23 @@ +# VFR FastCut v0.5.1 + +v0.5.1 is a critical lossless-export timing hotfix. + +## Fixed + +Some Matroska HEVC/H.264 recordings with reordered B-frames can expose packet DTS that are missing or effectively copied from PTS. During a normal FFmpeg stream-copy remux, those non-monotonic decode timestamps may be clamped, causing several consecutive frames to receive the same presentation time. The file can still report 60 FPS while visibly playing closer to 20–30 FPS. + +VFR FastCut now detects this condition before export. When needed, it preserves the source PTS exactly and reconstructs only a monotonic DTS timeline with FFmpeg's packet-level setts bitstream filter. Video remains stream-copy and is not re-encoded. + +The repair is applied through single-range export, multi-range temporary parts, audio-processing paths and final concat/remux passes. + +## Regression coverage + +The timestamp analyzer is covered for: + +- the reproduced Matroska/B-frame DTS failure; +- valid B-frame streams with monotonic DTS; +- ordinary monotonic streams; +- implausible nominal-rate fallback; +- missing DTS and frame-rate fallback. + +Windows 10/11 x64 remains the tested release platform. diff --git a/tests/test_keyframes.py b/tests/test_keyframes.py index 913950e..20a588a 100644 --- a/tests/test_keyframes.py +++ b/tests/test_keyframes.py @@ -3,6 +3,8 @@ import unittest from vfr_keyframes import ( + TimestampRepairProbeError, + analyze_video_timestamps, resolve_preview_playback_position, snap_range_to_keyframes, snap_ranges_to_keyframes, @@ -85,5 +87,94 @@ def test_all_deleted_has_no_target(self): self.assertIsNone(target) +class TimestampRepairTests(unittest.TestCase): + def setUp(self): + self.stream_60 = { + "r_frame_rate": "60/1", + "avg_frame_rate": "60/1", + } + + @staticmethod + def _packets(pts_values, dts_values): + return [ + {"pts_time": str(pts), "dts_time": str(dts)} + for pts, dts in zip(pts_values, dts_values) + ] + + def test_matroska_style_dts_equal_pts_is_repaired(self): + pts = [0.000, 0.050, 0.017, 0.033, 0.100, 0.067, 0.083] + plan = analyze_video_timestamps( + self.stream_60, + self._packets(pts, pts), + ) + self.assertTrue(plan.required) + self.assertGreater(plan.dts_regressions, 0) + self.assertEqual(plan.frame_rate_expr, "60/1") + self.assertEqual(plan.guard_frames, 2) + self.assertEqual( + plan.filter_spec, + "setts=pts=PTS:dts=(N-2)/((60/1)*TB)", + ) + + def test_valid_b_frame_dts_are_left_untouched(self): + pts = [0.000, 0.050, 0.017, 0.033, 0.100, 0.067, 0.083] + dts = [-0.033, -0.017, 0.000, 0.017, 0.033, 0.050, 0.067] + plan = analyze_video_timestamps( + self.stream_60, + self._packets(pts, dts), + ) + self.assertFalse(plan.required) + self.assertEqual(plan.dts_regressions, 0) + self.assertGreater(plan.pts_regressions, 0) + self.assertEqual(plan.filter_spec, "") + + def test_monotonic_video_is_left_untouched(self): + pts = [0.000, 0.017, 0.033, 0.050] + plan = analyze_video_timestamps( + self.stream_60, + self._packets(pts, pts), + ) + self.assertFalse(plan.required) + + def test_absurd_nominal_rate_falls_back_to_average_rate(self): + stream = { + "r_frame_rate": "1000/1", + "avg_frame_rate": "60/1", + } + pts = [0.000, 0.050, 0.017, 0.033, 0.100, 0.067, 0.083] + plan = analyze_video_timestamps( + stream, + self._packets(pts, pts), + ) + self.assertTrue(plan.required) + self.assertEqual(plan.frame_rate_expr, "60/1") + + def test_missing_rate_uses_pts_span_fallback(self): + pts = [0.000, 0.050, 0.017, 0.033, 0.100, 0.067, 0.083] + plan = analyze_video_timestamps( + {}, + self._packets(pts, pts), + ) + self.assertTrue(plan.required) + self.assertGreater(plan.frame_rate, 50.0) + self.assertLess(plan.frame_rate, 70.0) + + def test_reordered_pts_without_dts_still_requests_repair(self): + packets = [ + {"pts_time": value, "dts_time": "N/A"} + for value in ("0.000", "0.050", "0.017", "0.033") + ] + plan = analyze_video_timestamps(self.stream_60, packets) + self.assertTrue(plan.required) + + def test_unresolvable_rate_raises_only_for_broken_timestamps(self): + packets = [ + {"pts_time": "0.0", "dts_time": "0.0"}, + {"pts_time": "-1.0", "dts_time": "-1.0"}, + ] + with self.assertRaises(TimestampRepairProbeError): + analyze_video_timestamps({}, packets) + + if __name__ == "__main__": unittest.main() diff --git a/vfr_fastcut.py b/vfr_fastcut.py index b32deb0..c586ef5 100644 --- a/vfr_fastcut.py +++ b/vfr_fastcut.py @@ -21,6 +21,9 @@ from vfr_keyframes import ( KeyframeScanCancelled, KeyframeScanError, + TimestampRepairProbeError, + VideoTimestampRepairPlan, + probe_video_timestamp_repair, scan_video_keyframes, resolve_preview_playback_position, snap_ranges_to_keyframes, @@ -59,7 +62,7 @@ ) APP_NAME = "VFR FastCut" -APP_VERSION = "0.5.0" +APP_VERSION = "0.5.1" SUPPORTED_VIDEO_SUFFIXES = frozenset({ ".mp4", @@ -2487,10 +2490,48 @@ def __init__( self._used_single_track_main_mix_copy = False self._process_lock = threading.Lock() self._active_process: Optional[subprocess.Popen] = None + self._video_timestamp_probe_done = False + self._video_timestamp_repair = VideoTimestampRepairPlan() def _t(self, key: str, **kwargs) -> str: return ui_text(self.language, key, **kwargs) + def _ensure_video_timestamp_repair(self) -> VideoTimestampRepairPlan: + if self._video_timestamp_probe_done or not self.export_video: + return self._video_timestamp_repair + + self._video_timestamp_probe_done = True + try: + self._video_timestamp_repair = probe_video_timestamp_repair( + self.ffprobe, + self.input_path, + ) + except TimestampRepairProbeError: + # Timestamp probing is a defensive compatibility layer. If a + # source cannot be inspected, preserve the established export path + # instead of turning a diagnostic failure into an export failure. + self._video_timestamp_repair = VideoTimestampRepairPlan() + return self._video_timestamp_repair + + def _video_timestamp_output_args( + self, + *, + force_non_negative: bool = False, + ) -> list[str]: + args: list[str] = [] + if self.export_video: + plan = self._ensure_video_timestamp_repair() + if plan.required and plan.filter_spec: + args += ["-bsf:v", plan.filter_spec] + # The generated decode timeline starts slightly before zero so + # reordered B-frames keep legal DTS <= PTS. Shift every stream + # together at mux time; presentation intervals stay unchanged. + force_non_negative = True + + if force_non_negative: + args += ["-avoid_negative_ts", "make_zero"] + return args + def _set_active_process(self, proc: Optional[subprocess.Popen]): with self._process_lock: self._active_process = proc @@ -2976,8 +3017,9 @@ def _build_primary_range_part( cmd += ["-map_metadata", "0"] cmd += codec_args - if start > 0.001: - cmd += ["-avoid_negative_ts", "make_zero"] + cmd += self._video_timestamp_output_args( + force_non_negative=start > 0.001, + ) cmd += [str(output)] self._run(cmd) @@ -3101,8 +3143,9 @@ def _build_single_track_main_mix_copy_part( ] cmd += self._main_mix_metadata_args() - if start > 0.001: - cmd += ["-avoid_negative_ts", "make_zero"] + cmd += self._video_timestamp_output_args( + force_non_negative=start > 0.001, + ) cmd += [str(output)] self._used_single_track_main_mix_copy = True @@ -3213,8 +3256,9 @@ def _build_processed_range_part( cmd += ["-t", f"{duration:.6f}", "-c", "copy"] if primary_input_index is not None: cmd += ["-map_metadata", str(primary_input_index)] - if primary_is_original and start > 0.001: - cmd += ["-avoid_negative_ts", "make_zero"] + cmd += self._video_timestamp_output_args( + force_non_negative=primary_is_original and start > 0.001, + ) cmd += [str(output)] self._run(cmd) return @@ -3304,8 +3348,9 @@ def _build_processed_range_part( if primary_input_index is not None: cmd += ["-map_metadata", str(primary_input_index)] cmd += self._main_mix_metadata_args() - if primary_is_original and start > 0.001: - cmd += ["-avoid_negative_ts", "make_zero"] + cmd += self._video_timestamp_output_args( + force_non_negative=primary_is_original and start > 0.001, + ) cmd += [str(output)] self._run(cmd) @@ -3390,6 +3435,7 @@ def _run_audio_processing_export( ] cmd += self._stream_map_args(original_input=False) cmd += ["-c", "copy"] + cmd += self._video_timestamp_output_args() cmd += self._main_mix_metadata_args() if staging.suffix.lower() == ".mp4": cmd += ["-movflags", "+faststart"] @@ -3478,8 +3524,9 @@ def _build_copy_command( "-c", "copy", ] - if start is not None and start > 0.001: - cmd += ["-avoid_negative_ts", "make_zero"] + cmd += self._video_timestamp_output_args( + force_non_negative=start is not None and start > 0.001, + ) if output.suffix.lower() == ".mp4": cmd += ["-movflags", "+faststart"] @@ -3496,6 +3543,10 @@ def run(self): if not self.export_video and not self.export_audio: raise RuntimeError(self._t("export_no_streams")) + if self.export_video: + self.signals.progress.emit(2, self._t("task_export_preparing")) + self._ensure_video_timestamp_repair() + out.parent.mkdir(parents=True, exist_ok=True) staging = self._make_staging_output(out) @@ -3638,9 +3689,11 @@ def run(self): cmd += [ "-map_metadata", "0", "-c", "copy", - "-avoid_negative_ts", "make_zero", - str(part), ] + cmd += self._video_timestamp_output_args( + force_non_negative=True, + ) + cmd += [str(part)] self._run(cmd) self._check_cancelled() @@ -3671,6 +3724,7 @@ def run(self): cmd += [ "-c", "copy", ] + cmd += self._video_timestamp_output_args() if staging.suffix.lower() == ".mp4": cmd += ["-movflags", "+faststart"] diff --git a/vfr_keyframes.py b/vfr_keyframes.py index 9857c03..5e2afd0 100644 --- a/vfr_keyframes.py +++ b/vfr_keyframes.py @@ -1,17 +1,22 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2026 Sergilol -"""Reusable keyframe analysis and lossless range snapping for VFR FastCut. +"""Reusable keyframe/timestamp analysis and lossless range helpers. -The module deliberately has no Qt dependency. Both the GUI preview layer and -LosslessExporter can use the same keyframe map and the same snapping rules. +The module deliberately has no Qt dependency. Both the GUI preview layer and +LosslessExporter can share keyframe maps, snapping rules and stream-copy +timestamp diagnostics. """ from __future__ import annotations import bisect +import json +import math import subprocess import threading +from dataclasses import dataclass +from fractions import Fraction from pathlib import Path from typing import Callable, Optional @@ -27,6 +32,243 @@ class KeyframeScanError(RuntimeError): ProgressCallback = Callable[[int], None] +class TimestampRepairProbeError(RuntimeError): + """Raised when ffprobe cannot inspect video packet timestamps.""" + + +@dataclass(frozen=True) +class VideoTimestampRepairPlan: + """Describe a packet-level DTS repair for stream-copy video. + + Some Matroska sources with reordered B-frames expose packet DTS values that + are effectively copied from PTS. Their DTS therefore move backwards in + decode order. A normal FFmpeg remux clamps those timestamps, which can give + multiple frames the same PTS and make nominal 60 fps footage play near + 20 fps. + + The repair keeps presentation timestamps untouched and synthesizes only a + monotonic decode timeline with FFmpeg's setts bitstream filter. + """ + + required: bool = False + frame_rate_expr: str = "" + frame_rate: float = 0.0 + guard_frames: int = 0 + pts_regressions: int = 0 + dts_regressions: int = 0 + + @property + def filter_spec(self) -> str: + if ( + not self.required + or not self.frame_rate_expr + or self.frame_rate <= 0.0 + ): + return "" + guard = max(1, int(self.guard_frames)) + return ( + "setts=pts=PTS:" + f"dts=(N-{guard})/(({self.frame_rate_expr})*TB)" + ) + + +def _timestamp_float(value) -> Optional[float]: + try: + result = float(value) + except (TypeError, ValueError): + return None + return result if math.isfinite(result) else None + + +def _parse_frame_rate(value) -> Optional[tuple[str, float]]: + if value in (None, "", "0/0", "N/A"): + return None + try: + fraction = Fraction(str(value)) + except (ValueError, ZeroDivisionError): + return None + if fraction <= 0: + return None + rate = float(fraction) + if not math.isfinite(rate) or rate < 0.1 or rate > 2000.0: + return None + return f"{fraction.numerator}/{fraction.denominator}", rate + + +def _choose_frame_rate( + stream: dict, + packet_pts: list[float], +) -> Optional[tuple[str, float]]: + nominal = _parse_frame_rate(stream.get("r_frame_rate")) + average = _parse_frame_rate(stream.get("avg_frame_rate")) + + # r_frame_rate can be an enormous time-base-derived value for some VFR + # files. Prefer it when plausible because it is a useful upper cadence for + # a synthetic decode timeline, but reject obvious outliers. + if nominal and average: + if nominal[1] > max(240.0, average[1] * 4.0): + return average + return nominal + if nominal: + return nominal + if average: + return average + + unique_pts = sorted(set(packet_pts)) + if len(unique_pts) >= 2: + span = unique_pts[-1] - unique_pts[0] + if span > 0.000001: + rate = (len(unique_pts) - 1) / span + if 0.1 <= rate <= 2000.0 and math.isfinite(rate): + fraction = Fraction(rate).limit_denominator(100000) + return ( + f"{fraction.numerator}/{fraction.denominator}", + float(fraction), + ) + return None + + +def analyze_video_timestamps( + stream: dict, + packets: list[dict], +) -> VideoTimestampRepairPlan: + """Detect non-monotonic decode timestamps and build a safe repair plan. + + A valid B-frame stream may have non-monotonic PTS in packet/decode order, + but DTS must remain monotonic. The problematic Matroska case seen in OBS + recordings reports DTS == PTS, so both move backwards. Repair is enabled + only when DTS regress, or when DTS are unavailable while PTS clearly show + frame reordering. + """ + pts_values: list[float] = [] + indexed_pts: list[tuple[int, float]] = [] + previous_pts: Optional[float] = None + previous_dts: Optional[float] = None + pts_regressions = 0 + dts_regressions = 0 + dts_count = 0 + + for index, packet in enumerate(packets): + pts = _timestamp_float(packet.get("pts_time")) + dts = _timestamp_float(packet.get("dts_time")) + + if pts is not None: + pts_values.append(pts) + indexed_pts.append((index, pts)) + if previous_pts is not None and pts < previous_pts - 0.000001: + pts_regressions += 1 + previous_pts = pts + + if dts is not None: + dts_count += 1 + if previous_dts is not None and dts < previous_dts - 0.000001: + dts_regressions += 1 + previous_dts = dts + + required = dts_regressions > 0 or ( + dts_count < 2 and pts_regressions > 0 + ) + if not required: + return VideoTimestampRepairPlan( + pts_regressions=pts_regressions, + dts_regressions=dts_regressions, + ) + + rate_info = _choose_frame_rate(stream, pts_values) + if rate_info is None or not indexed_pts: + raise TimestampRepairProbeError( + "Video DTS are non-monotonic, but frame rate could not be resolved" + ) + + frame_rate_expr, frame_rate = rate_info + first_pts = indexed_pts[0][1] + + # Estimate how far decode order can run ahead of presentation order. + # Round onto the nominal frame grid to absorb millisecond Matroska + # timestamp quantization (e.g. 16/17 ms for 60 fps). One extra frame is + # kept as safety headroom; at least two frames matches common B-frame GOPs. + max_decode_lead = 0 + for index, pts in indexed_pts: + presentation_index = int(round((pts - first_pts) * frame_rate)) + max_decode_lead = max(max_decode_lead, index - presentation_index) + + guard_frames = max(2, max_decode_lead + 1) + + return VideoTimestampRepairPlan( + required=True, + frame_rate_expr=frame_rate_expr, + frame_rate=frame_rate, + guard_frames=guard_frames, + pts_regressions=pts_regressions, + dts_regressions=dts_regressions, + ) + + +def probe_video_timestamp_repair( + ffprobe: str, + input_path: str, + *, + max_packets: int = 512, +) -> VideoTimestampRepairPlan: + """Inspect a short packet sample and return a stream-copy repair plan.""" + if not ffprobe: + raise TimestampRepairProbeError("ffprobe executable is not available") + + source = Path(input_path) + if not source.is_file(): + raise TimestampRepairProbeError(f"Input file not found: {input_path}") + + max_packets = max(32, min(4096, int(max_packets))) + cmd = [ + ffprobe, + "-v", "error", + "-select_streams", "v:0", + "-read_intervals", f"%+#{max_packets}", + "-show_streams", + "-show_packets", + "-show_entries", + "stream=r_frame_rate,avg_frame_rate:packet=pts_time,dts_time", + "-of", "json", + str(source), + ] + + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + try: + completed = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + creationflags=creationflags, + timeout=20.0, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise TimestampRepairProbeError(str(exc)) from exc + + if completed.returncode != 0: + details = (completed.stderr or "").strip()[-2000:] + raise TimestampRepairProbeError( + details or f"ffprobe error code {completed.returncode}" + ) + + try: + payload = json.loads(completed.stdout or "{}") + except json.JSONDecodeError as exc: + raise TimestampRepairProbeError( + "ffprobe returned invalid timestamp metadata" + ) from exc + + streams = payload.get("streams") or [] + packets = payload.get("packets") or [] + if not streams or not packets: + return VideoTimestampRepairPlan() + + return analyze_video_timestamps(streams[0], packets) + + def _terminate_and_reap(proc: subprocess.Popen, timeout: float = 1.0) -> None: """Best-effort child-process cleanup without leaking ffprobe processes.""" if proc.poll() is not None: From 5532fd5b280f08fa0c7b58fab7e9adcbd10175c7 Mon Sep 17 00:00:00 2001 From: Sergilol Date: Mon, 12 Oct 2026 01:27:55 +0300 Subject: [PATCH 2/9] Fix timestamp repair edge-case test --- tests/test_keyframes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_keyframes.py b/tests/test_keyframes.py index 20a588a..1aa48ff 100644 --- a/tests/test_keyframes.py +++ b/tests/test_keyframes.py @@ -170,7 +170,7 @@ def test_reordered_pts_without_dts_still_requests_repair(self): def test_unresolvable_rate_raises_only_for_broken_timestamps(self): packets = [ {"pts_time": "0.0", "dts_time": "0.0"}, - {"pts_time": "-1.0", "dts_time": "-1.0"}, + {"pts_time": "0.0", "dts_time": "-1.0"}, ] with self.assertRaises(TimestampRepairProbeError): analyze_video_timestamps({}, packets) From 9f85a4aca1ab407d48d6320a2061abf5d52c5dba Mon Sep 17 00:00:00 2001 From: Sergilol Date: Mon, 12 Oct 2026 01:28:49 +0300 Subject: [PATCH 3/9] Require setts bitstream filter in FFmpeg runtime --- tools/ffmpeg-minimal/build-msys2-ucrt64.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/ffmpeg-minimal/build-msys2-ucrt64.sh b/tools/ffmpeg-minimal/build-msys2-ucrt64.sh index 3b59218..6e25796 100755 --- a/tools/ffmpeg-minimal/build-msys2-ucrt64.sh +++ b/tools/ffmpeg-minimal/build-msys2-ucrt64.sh @@ -96,6 +96,7 @@ CONFIGURE_FLAGS=( "--enable-filter=amix" "--enable-filter=alimiter" "--enable-filter=adelay" + "--enable-bsf=setts" "--disable-hwaccels" "--disable-devices" "--disable-avdevice" From c2f62afdc5b83ee288ece5e82d92aed1545e6396 Mon Sep 17 00:00:00 2001 From: Sergilol Date: Mon, 12 Oct 2026 01:28:52 +0300 Subject: [PATCH 4/9] Verify setts support in FFmpeg runtime --- tools/ffmpeg-minimal/verify.ps1 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/ffmpeg-minimal/verify.ps1 b/tools/ffmpeg-minimal/verify.ps1 index ef6a0d5..a79109d 100644 --- a/tools/ffmpeg-minimal/verify.ps1 +++ b/tools/ffmpeg-minimal/verify.ps1 @@ -56,6 +56,11 @@ foreach ($required in @('volume', 'afade', 'aformat', 'amix', 'alimiter', 'adela } } +$bsfs = & $Ffmpeg -hide_banner -bsfs 2>&1 | Out-String +if ($bsfs -notmatch "(?m)^setts\s*$") { + throw "Required video timestamp bitstream filter missing: setts" +} + $muxers = & $Ffmpeg -hide_banner -muxers 2>&1 | Out-String foreach ($required in @('mp4', 'mov', 'matroska')) { if ($muxers -notmatch "(?m)^\s*E\s+[^\r\n]*\b$([regex]::Escape($required))\b") { From 9973ce18c0ee1296976706ea177e27c7650654ff Mon Sep 17 00:00:00 2001 From: Sergilol Date: Mon, 12 Oct 2026 01:29:05 +0300 Subject: [PATCH 5/9] Relax setts capability check formatting --- tools/ffmpeg-minimal/verify.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/ffmpeg-minimal/verify.ps1 b/tools/ffmpeg-minimal/verify.ps1 index a79109d..ff8c3c4 100644 --- a/tools/ffmpeg-minimal/verify.ps1 +++ b/tools/ffmpeg-minimal/verify.ps1 @@ -57,7 +57,7 @@ foreach ($required in @('volume', 'afade', 'aformat', 'amix', 'alimiter', 'adela } $bsfs = & $Ffmpeg -hide_banner -bsfs 2>&1 | Out-String -if ($bsfs -notmatch "(?m)^setts\s*$") { +if ($bsfs -notmatch "(?m)^\s*setts\s*$") { throw "Required video timestamp bitstream filter missing: setts" } From e9bc8f50c8d4de20edbc05541d36148ced5e1536 Mon Sep 17 00:00:00 2001 From: Sergilol Date: Mon, 12 Oct 2026 01:30:19 +0300 Subject: [PATCH 6/9] Track reordered PTS separately from broken source DTS --- vfr_keyframes.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/vfr_keyframes.py b/vfr_keyframes.py index 5e2afd0..9570065 100644 --- a/vfr_keyframes.py +++ b/vfr_keyframes.py @@ -54,13 +54,14 @@ class VideoTimestampRepairPlan: frame_rate_expr: str = "" frame_rate: float = 0.0 guard_frames: int = 0 + reordered_pts: bool = False pts_regressions: int = 0 dts_regressions: int = 0 @property def filter_spec(self) -> str: if ( - not self.required + not (self.required or self.reordered_pts) or not self.frame_rate_expr or self.frame_rate <= 0.0 ): @@ -165,10 +166,11 @@ def analyze_video_timestamps( dts_regressions += 1 previous_dts = dts + reordered_pts = pts_regressions > 0 required = dts_regressions > 0 or ( - dts_count < 2 and pts_regressions > 0 + dts_count < 2 and reordered_pts ) - if not required: + if not required and not reordered_pts: return VideoTimestampRepairPlan( pts_regressions=pts_regressions, dts_regressions=dts_regressions, @@ -177,7 +179,7 @@ def analyze_video_timestamps( rate_info = _choose_frame_rate(stream, pts_values) if rate_info is None or not indexed_pts: raise TimestampRepairProbeError( - "Video DTS are non-monotonic, but frame rate could not be resolved" + "Video packet reordering was detected, but frame rate could not be resolved" ) frame_rate_expr, frame_rate = rate_info @@ -199,6 +201,7 @@ def analyze_video_timestamps( frame_rate_expr=frame_rate_expr, frame_rate=frame_rate, guard_frames=guard_frames, + reordered_pts=reordered_pts, pts_regressions=pts_regressions, dts_regressions=dts_regressions, ) From dfb0d63ab646d4ec3d3a5d3cde463fbe19dff0b4 Mon Sep 17 00:00:00 2001 From: Sergilol Date: Mon, 12 Oct 2026 01:30:23 +0300 Subject: [PATCH 7/9] Repair DTS again when remuxing Matroska temp parts --- vfr_fastcut.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/vfr_fastcut.py b/vfr_fastcut.py index c586ef5..88a0367 100644 --- a/vfr_fastcut.py +++ b/vfr_fastcut.py @@ -2517,11 +2517,15 @@ def _video_timestamp_output_args( self, *, force_non_negative: bool = False, + matroska_input: bool = False, ) -> list[str]: args: list[str] = [] if self.export_video: plan = self._ensure_video_timestamp_repair() - if plan.required and plan.filter_spec: + needs_repair = plan.required or ( + matroska_input and plan.reordered_pts + ) + if needs_repair and plan.filter_spec: args += ["-bsf:v", plan.filter_spec] # The generated decode timeline starts slightly before zero so # reordered B-frames keep legal DTS <= PTS. Shift every stream @@ -3258,6 +3262,10 @@ def _build_processed_range_part( cmd += ["-map_metadata", str(primary_input_index)] cmd += self._video_timestamp_output_args( force_non_negative=primary_is_original and start > 0.001, + matroska_input=( + primary_input_index is not None + and not primary_is_original + ), ) cmd += [str(output)] self._run(cmd) @@ -3350,6 +3358,10 @@ def _build_processed_range_part( cmd += self._main_mix_metadata_args() cmd += self._video_timestamp_output_args( force_non_negative=primary_is_original and start > 0.001, + matroska_input=( + primary_input_index is not None + and not primary_is_original + ), ) cmd += [str(output)] self._run(cmd) @@ -3435,7 +3447,9 @@ def _run_audio_processing_export( ] cmd += self._stream_map_args(original_input=False) cmd += ["-c", "copy"] - cmd += self._video_timestamp_output_args() + cmd += self._video_timestamp_output_args( + matroska_input=True, + ) cmd += self._main_mix_metadata_args() if staging.suffix.lower() == ".mp4": cmd += ["-movflags", "+faststart"] @@ -3724,7 +3738,9 @@ def run(self): cmd += [ "-c", "copy", ] - cmd += self._video_timestamp_output_args() + cmd += self._video_timestamp_output_args( + matroska_input=True, + ) if staging.suffix.lower() == ".mp4": cmd += ["-movflags", "+faststart"] From f6de1f58c09a330f06ef3af882e5e220c062ba98 Mon Sep 17 00:00:00 2001 From: Sergilol Date: Mon, 12 Oct 2026 01:30:27 +0300 Subject: [PATCH 8/9] Cover valid reordered streams used through Matroska temp parts --- tests/test_keyframes.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_keyframes.py b/tests/test_keyframes.py index 1aa48ff..0a0c0d0 100644 --- a/tests/test_keyframes.py +++ b/tests/test_keyframes.py @@ -124,9 +124,13 @@ def test_valid_b_frame_dts_are_left_untouched(self): self._packets(pts, dts), ) self.assertFalse(plan.required) + self.assertTrue(plan.reordered_pts) self.assertEqual(plan.dts_regressions, 0) self.assertGreater(plan.pts_regressions, 0) - self.assertEqual(plan.filter_spec, "") + self.assertEqual( + plan.filter_spec, + "setts=pts=PTS:dts=(N-2)/((60/1)*TB)", + ) def test_monotonic_video_is_left_untouched(self): pts = [0.000, 0.017, 0.033, 0.050] @@ -135,6 +139,8 @@ def test_monotonic_video_is_left_untouched(self): self._packets(pts, pts), ) self.assertFalse(plan.required) + self.assertFalse(plan.reordered_pts) + self.assertEqual(plan.filter_spec, "") def test_absurd_nominal_rate_falls_back_to_average_rate(self): stream = { From ffb96d152f81c93baea45028ccacd7b8dc1a165d Mon Sep 17 00:00:00 2001 From: Sergilol Date: Mon, 12 Oct 2026 01:31:23 +0300 Subject: [PATCH 9/9] Preserve source DTS validity in timestamp repair plan --- vfr_keyframes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vfr_keyframes.py b/vfr_keyframes.py index 9570065..5e03ff9 100644 --- a/vfr_keyframes.py +++ b/vfr_keyframes.py @@ -197,7 +197,7 @@ def analyze_video_timestamps( guard_frames = max(2, max_decode_lead + 1) return VideoTimestampRepairPlan( - required=True, + required=required, frame_rate_expr=frame_rate_expr, frame_rate=frame_rate, guard_frames=guard_frames,