Sitelet https://github.com/appsmypass/truefps
Skip to content
appsmypassPublic

About

Tells you whether a recording is really constant frame rate. Reads the MP4/MOV sample tables directly, reconstructs every frame's true duration, and reports real average fps, VFR vs CFR, the longest stall, keyframe spacing and audio/video duration drift - the reason clips desync in editors. Zero dependencies, read-only.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

truefps

Is this recording really constant frame rate? Ask the file, not the label.

OBS says 60 fps. Explorer says 60 fps. Your editor still drifts out of sync forty minutes in. Both of those numbers are labels. An MP4 stores the real duration of every single frame in its sample tables, and no Windows UI ever shows you them.

truefps reads those tables and reports what they actually say.

  • No install, no dependencies, no ffprobe, no Python. One PowerShell file.
  • Read-only. Opens the file with full sharing, so you can check a clip while OBS is still recording it.
  • Tells you why it matters, not just the numbers.
.\truefps.ps1 "C:\Users\you\Videos\stream.mp4"

The thing it catches

This is a 1080p screen capture that reports itself as 60 fps everywhere in Windows. It is not 60 fps.

truefps 1.0.0  -  what the frames actually say

  FILE
    name        screen-capture-60fps.mp4
    size        723 B
    brand       isom (ISO base media)
    structure   ftyp mdat moov
    duration    24.217s   (movie timescale 1000)
    tracks      1

  VIDEO  (track 1, id 1)
    codec       avc1 (H.264)   1920x1080
    real rate   60.000000 fps
    exactly     60/1   (timescale 60000, frame delta 1000)
    standard    60
    average     54.590502 fps   (across the whole track)
    timing      VARIABLE   4 different frame durations, 1000 to 8000 ticks
    frames      1322 in 24.217s
    keyframes   23   every 57.5 frames on average, widest gap 60 (1.0 s)
    b-frames    no
    data        11.3 MB of samples, 3931 kbps
    long frames 122 frame(s) held 1.5x normal or more
                at 17.417s  133.3 ms  (8.0x normal)
                at 8.333s  83.3 ms  (5.0x normal)
                at 13.417s  33.3 ms  (2.0x normal)  x120

  WHAT THIS MEANS

    [!] Track 1 is variable frame rate
        Frames have 4 different durations. Only 90.8 percent of frames use
        the most common one. An editor that conforms this to a constant
        timeline has to guess, which is how a clip that looked fine drifts
        out of sync later. Re-encode to constant frame rate before editing.

    [!] Track 1 has 122 long frames
        A frame is held for at least 1.5 times the normal duration 122
        times. The longest is 133.3 ms at 17.417s. On a screen capture these
        are dropped frames, usually from the encoder or the disk falling
        behind.

The nominal rate is a real 60/1. The average is 54.59. The gap between those two numbers is the entire problem, and nothing in Windows shows it to you.

That sample is a crafted file, generated by makedemo.ps1, because every real recording on the machine this was built on turned out to be clean CFR. Every other capture in this README is a real file.


A real, healthy file

A 24 fps render. This is what "nothing to worry about" looks like.

truefps 1.0.0  -  what the frames actually say

  FILE
    name        my project.mp4
    size        37.3 MB
    brand       isom (ISO base media)
    structure   ftyp free mdat moov
    duration    1m 30.930s   (movie timescale 1000)
    tracks      2

  VIDEO  (track 1, id 1)
    codec       avc1 (H.264)   1920x1080
    real rate   24.000000 fps
    exactly     24/1   (timescale 12288, frame delta 512)
    standard    24 (cinema)
    timing      CONSTANT   every one of 2181 frames is 512 ticks
    frames      2181 in 1m 30.875s
    keyframes   48   every 45.4 frames on average, widest gap 104 (4.3 s)
    b-frames    yes   1741 composition offset runs (decode order is not display order)
    edit start  skips 512 ticks (41.7 ms) before the first shown frame
    data        35.9 MB of samples, 3313 kbps

  AUDIO  (track 2, id 2)
    codec       mp4a (AAC)
    real rate   43.066406 blocks/s
    exactly     11025/256   (timescale 44100, frame delta 1024)
    timing      CONSTANT   every one of 3916 frames is 1024 ticks
    frames      3916 in 1m 30.929s
    data        1.4 MB of samples, 128 kbps

  WHAT THIS MEANS

    [i] Track 1 starts 41.7 ms into the media
        The edit list tells a player to skip the first 512 ticks. Players
        honour it and some editors do not, which shifts this track against
        the others by that much.

    [i] Audio and video differ by 54 ms
        Video runs 1m 30.875s and audio runs 1m 30.929s, so audio is longer
        by 54 ms. A player hides this. An editor that stretches one track to
        match the other spreads the difference across the whole clip.

Even a clean file has two things worth knowing that no player will tell you: the video starts 41.7 ms late, and the audio track is 54 ms longer than the video track.


Phone footage

  VIDEO  (track 1, id 1)
    codec       avc1 (H.264)   1920x1080
    real rate   30.000000 fps
    exactly     30/1   (timescale 15360, frame delta 512)
    standard    30
    timing      CONSTANT   every one of 1775 frames is 512 ticks
    frames      1775 in 59.167s
    keyframes   8   every 221.9 frames on average, widest gap 250 (8.3 s)
    b-frames    yes   1730 composition offset runs (decode order is not display order)
    edit start  skips 1024 ticks (66.7 ms) before the first shown frame
    data        32.1 MB of samples, 4547 kbps

  WHAT THIS MEANS

    [i] Track 1 keyframes are 8.3 s apart at the widest
        There are 8 keyframes across 1775 frames. A cut that is not on a
        keyframe forces a re-encode of everything back to the previous one,
        so trimming this file will be slower and lossier than it looks.

Eight keyframes in a minute. That is why scrubbing this clip feels like glue and why a "lossless trim" on it is neither.


Usage

truefps.ps1 <file.mp4> [options]

  -Json            machine readable output, nothing else on stdout
  -NoColor         plain text, for logs and pipes
  -Top <n>         how many long frames to list (default 5)
  -Save <file>     save this reading to a file
  -Replay <file>   render a saved reading instead of a media file
  -Version         print the version and exit
  -Help            the built in help

Exit codes

Code Meaning
0 read fine, nothing to flag (info notes may still print)
1 read fine, something worth knowing (a [!] finding)
2 wrong arguments, or the file is not there
3 the file could not be parsed as MP4 or MOV

Only [!] warnings set exit 1. [i] notes do not, so you can gate a script on "is this clip actually broken" without it tripping on every edit list.

JSON

.\truefps.ps1 stream.mp4 -Json | ConvertFrom-Json
{
  "tool": "truefps",
  "version": "1.0.0",
  "file": "LoadingScreen.mp4",
  "bytes": 39161627,
  "brand": "isom",
  "fragmented": false,
  "movieTimescale": 1000,
  "movieSeconds": 90.93,
  "tracks": [
    {"index": 1, "trackId": 1, "kind": "video", "handler": "vide", "codec": "avc1", "width": 1920, "height": 1080, "timescale": 12288, "duration": 1116672, "seconds": 90.875, "language": "und", "samples": 2181, "timing": "constant", "fps": 24, "fpsNum": 24, "fpsDen": 1, "averageFps": 24, "distinctDeltas": 1, "minDelta": 512, "maxDelta": 512, "keyframes": 48, "keyframeMaxGap": 104, "allKeyframes": false, "longFrames": 0, "longestFrameMs": 0, "firstLongFrameIndex": -1, "bFrames": true, "editStartTicks": 512, "editRate": 1, "bytes": 37638537, "bitrate": 3313433.793673, "errors": []},
    {"index": 2, "trackId": 2, "kind": "audio", "handler": "soun", "codec": "mp4a", "width": 0, "height": 0, "timescale": 44100, "duration": 4009984, "seconds": 90.929342, "language": "und", "samples": 3916, "timing": "constant", "fps": 43.066406, "fpsNum": 11025, "fpsDen": 256, "averageFps": 43.066406, "distinctDeltas": 1, "minDelta": 1024, "maxDelta": 1024, "keyframes": 3916, "keyframeMaxGap": 1, "allKeyframes": true, "longFrames": 0, "longestFrameMs": 0, "firstLongFrameIndex": -1, "bFrames": false, "editStartTicks": 0, "editRate": 1, "bytes": 1454885, "bitrate": 128001.365591, "errors": []}
  ],
  "findings": [
    {"level": "info", "code": "edit-offset", "title": "Track 1 starts 41.7 ms into the media", "detail": "The edit list tells a player to skip the first 512 ticks. Players honour it and some editors do not, which shifts this track against the others by that much."},
    {"level": "info", "code": "av-drift", "title": "Audio and video differ by 54 ms", "detail": "Video runs 1m 30.875s and audio runs 1m 30.929s, so audio is longer by 54 ms. A player hides this. An editor that stretches one track to match the other spreads the difference across the whole clip."}
  ]
}

With -Json nothing else reaches stdout, so it pipes straight into ConvertFrom-Json.

Save and replay

.\truefps.ps1 stream.mp4 -Save reading.json     # on the capture machine
.\truefps.ps1 -Replay reading.json              # anywhere, later

Useful for a bug report: send the reading, not the 40 GB file. A replay renders byte-identically to the live read it came from, and the test suite asserts exactly that.


What it reads

Frame timing in an MP4 lives in the sample tables inside moov, and that is all truefps parses. It never decodes a frame.

Box What it gives
ftyp brand, and whether this is really an MP4
mvhd movie timescale and duration
tkhd track id, display width and height
mdhd the media timescale that every delta below is counted in, plus the packed language tag
hdlr video or audio
stsd codec fourcc
stts the frame durations - the whole point
stss which frames are keyframes
ctts composition offsets, so B-frames can be spotted
stsz sample sizes, for real bitrate
elst the edit list, for the start offset and the playback rate

stts is run-length encoded: a perfectly constant 60 fps track is a single entry saying "1322 samples of 1000 ticks each". A VFR track is dozens or thousands of entries. Counting the distinct deltas is what separates the two, and it is the number no UI exposes.

The rate is reported as an exact fraction

29.97 is not a frame rate, it is a rounding of 30000/1001. Timescale and delta give the exact rational, so the report prints both the decimal and the fraction it came from, and names it when it recognises one (23.976 (NTSC film), 24 (cinema), 59.94 (NTSC)).


What it flags

Finding Level Why you care
variable frame rate [!] editors must guess a constant timeline; this is the classic "drifts out of sync" cause
long frames [!] frames held 1.5x normal or more - dropped frames on a capture
sparse keyframes [i] a cut off a keyframe forces a re-encode back to the previous one
edit list offset [i] some editors ignore elst and shift the track
audio/video length mismatch [i] the drift an editor smears across the clip
unusual or non standard rate [i] a rate no common pipeline expects
fragmented file [i] timing lives in moof boxes, so the moov tables are incomplete
parse errors per track [!] a damaged table, reported per track rather than aborting

A malformed track does not abort the run. Each track carries its own error list, so one bad table still lets you see the rest of the file.


Not supported

MKV and WebM. Per-frame timing in Matroska lives in the cluster data spread through the whole file, so answering this question there means reading the entire file rather than a few kilobytes of header. truefps refuses them with a clear message instead of guessing.

Fragmented MP4 (moof) is detected and flagged: the moov sample tables in those files are deliberately empty, so the numbers come from the fragments and are reported as incomplete rather than wrong.


How this is tested

Two suites and a mutation gate. All three must be green.

Suite What it is Result
selftest.ps1 39 groups of hermetic tests against MP4 files built byte by byte in %TEMP% - exact box layouts, v0 and v1 headers, truncated files, empty tables, unsorted keyframes, 64-bit boxes, negative edit times, boxes smaller than their own header, children overrunning their parent, runs describing zero frames, paths with spaces 285 passed, 0 failed
realcheck.ps1 every real .mp4 and .mov on the machine, cross-checked against Windows' own Shell property parser for duration, dimensions and frame rate 194 passed, 0 failed in 101 s
mutate.ps1 89 mutations injected into the tool one at a time: 84 deliberate bugs that a suite must catch, plus 5 tripwires that must survive 89/89 correct, 3 rounds
.\selftest.ps1      # hermetic, no media files needed
.\realcheck.ps1     # uses your own recordings

The mutation harness is the part that matters. It is easy to write a test suite that passes; it is hard to write one that notices. So 84 realistic bugs get injected into truefps.ps1 one at a time - a -gt flipped to -ge, a big-endian read taken at the wrong offset, an mdhd v1 timescale read from the v0 position, a percentage divided by the wrong total - and the suites have to catch every single one. Three consecutive clean rounds.

Five of those 89 mutants are tripwires: changes that genuinely alter nothing observable - a reworded comment, -gt 0 rewritten as -ne 0 on a value that cannot be negative, [int] swapped for its own alias [int32]. If a suite "catches" one of those, the suite is keying on noise rather than behaviour, and the harness fails itself for lying.

The first run of this gate paid for itself immediately. It found five mutations that neither suite noticed: a dead helper that nothing called, a 16-bit read whose only consumer was a language tag that was parsed and then thrown away, a signed 32-bit read that no fixture ever fed a negative number, a 64-bit read that every fixture kept under 2^32, and a bounds check whose removal turned a clear "truncated" message into a raw .NET index exception that no test was reading closely enough to notice. The dead code is gone, the language tag and edit rate are now reported, every header box degrades to a named per-track error instead of aborting the whole report, and selftest.ps1 gained a group - deliberately run first, so a broken primitive fails in seconds rather than minutes - that feeds each of those readers the one input that can tell a correct implementation from a broken one.

A second census, run once the suites had grown, found nineteen more. Three of them were a category worth naming: a value the tool computed but never printed. The index of the first long frame and the "every frame is a keyframe" flag were both derived correctly and then discarded, so no possible assertion could tell a right answer from a wrong one. They are now firstLongFrameIndex and allKeyframes in the JSON. Two more survivors were not test gaps at all: one was a line that nothing could ever reach, which was deleted, and one was a bounds check already guaranteed by the check above it, which became a tripwire instead of pretending to be a bug.

And one of the new fixtures - a time table whose run describes zero frames - found a real bug rather than a missing test. The histogram that counts distinct frame durations was counting those empty runs, while the loop directly below it already skipped them, so a perfectly constant file could report itself as CONSTANT apart from 0 frame(s). That is fixed, and group [0b], forty assertions on guards and boundaries, exists so it stays fixed.


Requirements

Windows PowerShell 5.1, which is already on every Windows 10 and 11 machine. Nothing else. No modules, no ffmpeg, no admin rights.

If PowerShell blocks the script:

powershell -ExecutionPolicy Bypass -File .\truefps.ps1 video.mp4

See also

Other small Windows tools for the same kind of problem:

  • obs-4k60-recorder - getting a clean 4K60 capture out of OBS in the first place
  • framecheck - frame pacing while you play
  • truehz - what refresh rate your monitor is really running
  • codecmap - which codecs your GPU can encode and decode in hardware
  • audiodrift - audio clock drift between devices

License

MIT. See LICENSE.

About

Tells you whether a recording is really constant frame rate. Reads the MP4/MOV sample tables directly, reconstructs every frame's true duration, and reports real average fps, VFR vs CFR, the longest stall, keyframe spacing and audio/video duration drift - the reason clips desync in editors. Zero dependencies, read-only.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages