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"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 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.
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.
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
| 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.
.\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.
.\truefps.ps1 stream.mp4 -Save reading.json # on the capture machine
.\truefps.ps1 -Replay reading.json # anywhere, laterUseful 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.
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.
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)).
| 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.
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.
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 recordingsThe 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.
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.mp4Other 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
MIT. See LICENSE.