Skip to content

Media quality guide

Video Variable Frame Rate and Timestamp Review: Compare Actual PTS, Not Just the Header

Compare decoded video presentation timestamps with stream rate summaries to review candidate variable timing, gaps, duplicates, and discontinuities.

A video stream can advertise a familiar frame rate while its presentation timestamps contain pauses, bursts, duplicates, or discontinuities. Keep the stream time base, summary rates, and decoded frame PTS as separate observations. This is a bounded timing review method, not a playback guarantee, motion-quality score, or recommendation to change a source.

Updated

On this page

Name the time model before comparing rates

The source dates shown in this guide identify documentation checks on 22 October 2026, not original publication dates. Follow each citation for the decoding-software documentation being discussed.

Keep stream index, codec, declared time base, average frame rate, r_frame_rate when supplied, start time, duration, frame count, and controlled artifact reference as separate fields. A stream-level rate is a summary. It is not a substitute for the sequence of presentation timestamps, or PTS, that decoding software exposes for individual frames.

The PyAV, (opens in a new tab) documentation distinguishes average_rate, base_rate, and guessed_rate concepts on a video stream. Those names are useful clues that a field has a purpose and derivation; they are not interchangeable evidence of every frame presentation time. The PyAV, (opens in a new tab) documentation also separates PTS and DTS, so use decoded frame PTS for a question about when a frame should be shown and retain packet timing separately when decode order matters.

Name the time model before comparing rates (table)
FieldRecordDo not infer
time_baseInteger timestamp units and source stream referenceThat one unit is one displayed frame
avg_frame_rateSummary rate and whether it was suppliedThat every frame uses its reciprocal
r_frame_rateSummary rate when presentThat it is the editorial target
frame PTSBounded list or count plus first and last indexesThat a gap proves corruption
packet DTSSeparate decode-order observation when neededThat DTS is display timing
expected deltaExplicit local policy or unknownThat the header alone defines tolerance

Ask for selected stream and frame fields

FFprobe supports stream, frame, and packet inspection with selected entries and machine-readable output FFmpeg, (opens in a new tab). Request only fields needed for the worksheet. Keep a controlled identifier in the command, not a private filesystem path, footage title, packet payload, or decoding log.

Selected stream and frame timing

Ask for selected stream and frame fields code example, Plain text
python3 - "$MEDIA_REFERENCE" <<'PY'
import json
import os
import selectors
import subprocess
import sys
import time

MAX_BYTES = 200_000
TIMEOUT_SECONDS = min(30.0, max(0.01, float(os.environ.get("RIGHTMARK_TIMEOUT_SECONDS", "30"))))
command = [
    "ffprobe", "-v", "error", "-read_intervals", "%+30", "-select_streams", "v:0",
    "-show_entries", "stream=index,avg_frame_rate,r_frame_rate,time_base,start_time,duration:"
    "frame=best_effort_timestamp_time,pts_time,pkt_duration_time",
    "-show_streams", "-show_frames", "-of", "json", sys.argv[1],
]
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
selector = selectors.DefaultSelector()
selector.register(process.stdout, selectors.EVENT_READ)
data, reason = bytearray(), None
deadline = time.monotonic() + TIMEOUT_SECONDS
while process.poll() is None or selector.get_map():
    if time.monotonic() >= deadline:
        reason = "timeout"; process.kill(); break
    if not selector.select(deadline - time.monotonic()):
        reason = "timeout"; process.kill(); break
    chunk = process.stdout.read1(16_384)
    if not chunk: break
    data.extend(chunk)
    if len(data) > MAX_BYTES:
        reason = "output_limit_reached"; process.kill(); break
process.wait()
exit_status = process.returncode
if reason is not None:
    print(json.dumps({"status": "incomplete", "reason": reason, "exit_status": exit_status, "captured_bytes": len(data)}))
    raise SystemExit(125 if reason == "output_limit_reached" else 124)
if exit_status != 0:
    print(json.dumps({"status": "incomplete", "reason": "ffprobe_exit_status", "exit_status": exit_status}))
    raise SystemExit(exit_status)
try:
    payload = json.loads(data)
except json.JSONDecodeError:
    print(json.dumps({"status": "incomplete", "reason": "invalid_complete_json", "exit_status": exit_status}))
    raise SystemExit(65)
print(json.dumps({"status": "complete", "exit_status": exit_status, "payload": payload}, separators=(",", ":")))
PY

The -read_intervals %+30 request asks ffprobe for an approximately bounded 30-second read, but seeking is format- and decoding-software-dependent and is not a hard frame or byte cap. The wrapper bounds process time and captured output without truncating a JSON document silently. It emits status: complete only after a zero exit status and complete JSON parse; timeout, output-limit, nonzero-exit, and invalid-JSON cases emit status: incomplete with a reason and the ffprobe exit status. Do not add a raw-data dump to a public diagnostic. Preserve the command policy, tool version, selected stream, and evidence reference. This bounded check is not a whole-file completeness claim. The report can say how many frames were inspected and which stable issue classes occurred without copying images, audio, titles, or unbounded warnings.

Synthetic timing summary

Ask for selected stream and frame fields code example, JSON
{
  "stream": {"avg_frame_rate": "30/1", "r_frame_rate": "30/1", "time_base": "1/30"},
  "frames": [
    {"index": 0, "pts": 0, "pts_time": 0.0},
    {"index": 1, "pts": 1, "pts_time": 0.033333},
    {"index": 2, "pts": 3, "pts_time": 0.1}
  ]
}

The values are invented for this worksheet. The synthetic header says 30 frames per second, while the PTS jump from 1 to 3 in a 1/30 time base. That is a candidate interval finding, not proof of a corrupted file or a required edit. Keep frame indexes and calculation policy so another reviewer can reproduce the observation.

Compute bounded PTS deltas

Convert PTS values using the stream time base, then compare adjacent deltas with an explicit local expectation. The expectation can come from a capture contract, a nominal rate selected for this review, or an unknown state. Do not infer variable timing from one header field or label every difference as a defect.

Bounded timestamp arithmetic

Compute bounded PTS deltas code example, Python
from fractions import Fraction

def review_pts(pts_values, time_base: Fraction, expected_delta=None,
               tolerance=0.0005, max_frames=10_000):
    if time_base is None or time_base <= 0:
        return {
            "inspected_frames": 0,
            "deltas": [],
            "issues": [{"kind": "time_base_unavailable"}],
        }
    times = []
    truncated = False
    for index, pts in enumerate(pts_values):
        if index >= max_frames:
            truncated = True
            break
        times.append(None if pts is None else float(Fraction(pts) * time_base))
    deltas, issues, previous = [], [], None
    for index, current in enumerate(times):
        if current is None:
            issues.append({"index": index, "kind": "missing_pts"})
            previous = None
            continue
        if previous is not None:
            delta = current - previous
            deltas.append(delta)
            if delta <= 0:
                issues.append({"index": index, "kind": "non_monotonic_or_duplicate_pts"})
            elif expected_delta is not None and abs(delta - expected_delta) > tolerance:
                issues.append({"index": index, "kind": "interval_differs_from_policy"})
        previous = current
    if truncated:
        issues.append({"kind": "frame_limit_reached"})
    return {"inspected_frames": len(times), "deltas": deltas, "issues": issues}

synthetic_pts = [0, 1, 2, 4, 5]
print(review_pts(synthetic_pts, Fraction(1, 30), Fraction(1, 30)))

The function is bounded by max_frames and emits issue classes without timestamps tied to a private recording. An absent, zero, or negative time base returns time_base_unavailable before PTS arithmetic, rather than turning an invalid stream contract into a timing result. The synthetic list contains one two-tick gap under a 1/30 policy. A production worksheet can retain numeric times in an access-controlled evidence store while publishing only counts, ranges, and stable classes. Missing PTS stays distinct from zero or repeated PTS.

A deliberate edit, a segment boundary, a decoding implementation choice, or a source time-base change can create the same observation. Record discontinuity markers or edit-list context instead of rewriting frame times in place. If a constant-rate derivative is required, create a new artifact reference and preserve the original timing evidence.

Use a timing worksheet and disposition table

Record one row per stream or bounded frame window: artifact reference, stream index, time base, summary rate fields, first and last inspected frame, PTS count, missing PTS count, non-monotonic count, positive delta summary, expected-delta policy, tolerance, truncation state, evidence reference, owner decision, and unresolved questions.

Use a timing worksheet and disposition table (table)
ObservationSafe interpretationNext action
Rate summaries are presentThe container exposes rate declarationsCompare them with decoded PTS; do not choose one automatically
PTS deltas match policyNo timing variation was observed in the windowPreserve window and policy; do not generalize
PTS deltas varyCandidate variable timing or a discontinuityInspect segment boundaries, time base, and source notes
PTS repeats or decreasesDuplicate or non-monotonic presentation timingHold for decoding-software and edit-list review; do not sort silently
PTS is absent for framesTiming evidence is incompletePreserve indexes and distinguish missing from zero
Frame limit reachedReview coverage was intentionally boundedMark the remainder uninspected
PTS checks passSelected timing checks have evidenceContinue separate playback, provenance, privacy, rights, and accuracy review

Keep “not inspected,” “not available,” and “not observed under this policy” distinct. A short window can miss a transient event, and a timing summary cannot answer whether motion is visually acceptable or editorially intended.

Keep diagnostics safe and the next step qualified

A public report can contain a controlled artifact identifier, stream index, numeric counts, time-base notation, bounded frame indexes, issue classes, and tool versions. It should not contain private paths, footage labels, decoded images, audio, or unbounded decoding logs. A timestamp can disclose an event sequence even without a filename, so share only the precision needed for the approved review.

Pair this check with the timestamp and timezone guide and data provenance log. Those pages own general timestamp semantics and source, rights, and processing evidence; this guide owns bounded video timing observations.

After the owner has a supported non-health scope, authority to describe the footage, and a metadata or synthetic timing packet, the qualified appraisal request path may be a next preparation step. It does not validate frame timing, re-encode video, approve a derivative, produce a quote, match a buyer, issue a legal opinion, or grant a license. Keep raw footage outside this guide and within the owner-approved handling boundary.

Takeaways

  • Treat avg_frame_rate, r_frame_rate, and time_base as distinct stream observations.
  • Compare decoded frame PTS deltas to an explicit local policy instead of relying on a header rate.
  • Keep missing, duplicate, non-monotonic, and unusually large deltas as separate findings.
  • Bound frame inspection and publish counts or ranges, not raw footage or private labels.
  • Preserve original timing evidence when a later workflow creates a re-timed derivative.

Sources

  1. FFmpeg: ffprobe Documentation (opens in a new tab)
  2. PyAV: Video API (opens in a new tab)
  3. PyAV: Time (opens in a new tab)

Use the appraisal path after the source and bounded video timing packet are organized.

It does not validate frame timing, approve a derivative, create a quote, match a buyer, or grant a license.