Skip to content
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions release-notes/v0.5.1.md
Original file line number Diff line number Diff line change
@@ -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.
97 changes: 97 additions & 0 deletions tests/test_keyframes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -85,5 +87,100 @@ 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.assertTrue(plan.reordered_pts)
self.assertEqual(plan.dts_regressions, 0)
self.assertGreater(plan.pts_regressions, 0)
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]
plan = analyze_video_timestamps(
self.stream_60,
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 = {
"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": "0.0", "dts_time": "-1.0"},
]
with self.assertRaises(TimestampRepairProbeError):
analyze_video_timestamps({}, packets)


if __name__ == "__main__":
unittest.main()
1 change: 1 addition & 0 deletions tools/ffmpeg-minimal/build-msys2-ucrt64.sh
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ CONFIGURE_FLAGS=(
"--enable-filter=amix"
"--enable-filter=alimiter"
"--enable-filter=adelay"
"--enable-bsf=setts"
"--disable-hwaccels"
"--disable-devices"
"--disable-avdevice"
Expand Down
5 changes: 5 additions & 0 deletions tools/ffmpeg-minimal/verify.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)^\s*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") {
Expand Down
96 changes: 83 additions & 13 deletions vfr_fastcut.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -59,7 +62,7 @@
)

APP_NAME = "VFR FastCut"
APP_VERSION = "0.5.0"
APP_VERSION = "0.5.1"

SUPPORTED_VIDEO_SUFFIXES = frozenset({
".mp4",
Expand Down Expand Up @@ -2487,10 +2490,52 @@ 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,
matroska_input: bool = False,
) -> list[str]:
args: list[str] = []
if self.export_video:
plan = self._ensure_video_timestamp_repair()
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
# 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
Expand Down Expand Up @@ -2976,8 +3021,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)

Expand Down Expand Up @@ -3101,8 +3147,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
Expand Down Expand Up @@ -3213,8 +3260,13 @@ 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,
matroska_input=(
primary_input_index is not None
and not primary_is_original
),
)
cmd += [str(output)]
self._run(cmd)
return
Expand Down Expand Up @@ -3304,8 +3356,13 @@ 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,
matroska_input=(
primary_input_index is not None
and not primary_is_original
),
)
cmd += [str(output)]
self._run(cmd)

Expand Down Expand Up @@ -3390,6 +3447,9 @@ def _run_audio_processing_export(
]
cmd += self._stream_map_args(original_input=False)
cmd += ["-c", "copy"]
cmd += self._video_timestamp_output_args(
matroska_input=True,
)
cmd += self._main_mix_metadata_args()
if staging.suffix.lower() == ".mp4":
cmd += ["-movflags", "+faststart"]
Expand Down Expand Up @@ -3478,8 +3538,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"]
Expand All @@ -3496,6 +3557,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)

Expand Down Expand Up @@ -3638,9 +3703,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()
Expand Down Expand Up @@ -3671,6 +3738,9 @@ def run(self):
cmd += [
"-c", "copy",
]
cmd += self._video_timestamp_output_args(
matroska_input=True,
)

if staging.suffix.lower() == ".mp4":
cmd += ["-movflags", "+faststart"]
Expand Down
Loading
Loading