For one and the same WebM file, ffprobe reported two numbers that cannot both be true. The first was r_frame_rate=36/1. The second was a duration of 5.047 seconds. The file holds 242 frames, and at 36 frames per second, 242 frames take 242 divided by 36, about 6.72 seconds. I recognized that 6.72, because it was the duration of the previous version, before I sped this loop up for the second time.
If you trust r_frame_rate, the conclusion is that Chrome, which picks the WebM, plays this loop slower than Safari does. That conclusion is wrong, because the MP4 and the WebM carry the same timestamps. The 36 is a stale header, not the real tempo, and what drives playback is the timestamp on each packet.
I have written separately about an HEVC delivery that only played in Safari and about the judder that comes from lowering the frame rate. This piece is about something else: speeding up a loop that was already live, twice in one day with two different methods, and one ffprobe field that got left behind by the second method.
The tempo the client was used to
The loop is a short product video in a section of a Shopify theme. Its fourth version, v4, was made from a file the client sent directly, and that file was not a clean render from an animator: HEVC with AAC audio, 608x960, 241 frames at 24fps, 10.04 seconds long. I encoded it into two files without audio, an H.264 Main level 4.0 MP4 at CRF 18 with +faststart weighing 2.14 MB, and a VP9 WebM at CRF 28 weighing 1.47 MB. The file names matched the previous assets, so the section template was not touched at all. v4 went to production on 29 August 2026.
The client then asked for the loop to be sped up a little. The cause of that request could be measured. The old asset the client had been watching since June ran 6.00 seconds, while v4 ran 10.04 seconds. Dividing 10.04 by 6.00 gives about 1.67, so one pass of v4 took 67% longer than the tempo the client had been using as a reference.
Round one: raise the frame rate, keep every frame
For v5 I chose a 1.5x speed-up, and the method was to raise the frame rate rather than drop frames. Here is a simplified version of the command, showing only the part that sets the tempo:
# simplified: the other encode flags are not shown
ffmpeg -i <in> -vf "setpts=PTS/1.5" -r 36 <encode-flags> <out>The setpts=PTS/1.5 filter divides every timestamp by 1.5, so the same frames finish playing sooner. The 36 in -r is 24 times 1.5, an output frame rate dense enough to hold every frame in the shorter running time. All 241 source frames survived, becoming 242 through the constant frame rate adjustment, the result ran 6.72 seconds, and the motion came out smoother rather than choppy.
The alternative I rejected was the same setpts without raising the frame rate. With the output held at 24fps, that approach decimates frames. The arithmetic goes like this. A duration of 10.04 seconds divided by 1.5 is about 6.69 seconds, and 6.69 seconds at 24fps only has room for about 161 frames. Of the 241 source frames, roughly 80, about a third, would have to go.
v5 was re-encoded from the archived HEVC source, not from the v4 output, so there would be no second generation of compression. The commit landed at 08:16 in the morning and was pushed to the repo and to the live theme with --only, limited to those two asset files. The production check afterwards found MP4 and WebM checksums identical to the local copies, 36fps, 6.72 seconds, and no audio stream.
The HEVC source that vanished between rounds
When the v5 request came in, the client's file was already gone from the Downloads folder and the previous session's scratchpad had been cleaned. The only remaining copy of the source sat in assets/backupassets/. The rule I wrote down that day was simple: every raw file from the client gets archived into that folder as soon as it arrives, and Downloads or a scratchpad is never something to rely on.
Before the next round, that copy disappeared too. The 608x960 HEVC file, 7.9 MB, was no longer in the archive folder, and searching Downloads, the Trash, and the entire home directory turned up nothing. My notes only guess that it was deleted by hand. What is certain is that it cannot be restored from git, because the theme's .gitignore excludes the archive folder:
## Local scratch / backups (not part of deployed theme)
backup/
assets/backupassets/The consequence splits in two. Changes that are pure retiming are still safe, because they can be made from the shipped asset without losing anything. Anything that needs a re-encode from the original pixels, such as a new crop, a new scale, a new chroma key, or a higher resolution, is no longer possible without asking the client to send the file again. The action I recorded was to ask the client to resend the file and archive it, while considering keeping client masters outside the git-ignored backupassets/, or at least somewhere that does not get swept easily.
Round two: shift the timestamps, leave the frames alone
After v5, the client asked for the loop to be faster again. The v6 commit message records this as the second speed request, because to the client 6.72 seconds still felt slow. I chose 5.05 seconds, which is twice the v4 tempo and a touch quicker than the 6.00-second old asset.
Since the original source was gone, v6 was built from the shipped v5 asset, and this time there was no encode at all:
ffmpeg -itsscale 0.75 -i <in> -c copy <out>-itsscale is an input option that multiplies the input timestamps by the given factor, and -c copy copies the packets as they are, with no decode and no encode. A factor of 0.75 compresses every timestamp to three quarters of its value, so the 36fps loop from v5 now runs at 36 divided by 0.75, which is 48fps.
Because the encoded frames are reused untouched, there is no extra generation of compression, and the background that sits flat at a single value of 250 (#FAFAFA), which I had been checking since v5 in both the MP4 and the WebM, is guaranteed not to shift. I confirmed it with hashes: frame 0 and frame 120 are byte-identical to the previous asset. The file sizes barely moved either. According to git show --stat, the MP4 went from 1,804,768 to 1,804,776 bytes, 8 bytes more, and the WebM from 1,444,065 to 1,444,054 bytes, 11 bytes less.
v6 was committed at 17:51 the same day, then pushed to the repo and the live theme. The rule I set from this round: for a pure speed change, always use this method and never re-encode.
A header still advertising 36fps
The trap showed up right after the retiming, and I nearly missed it. ffprobe reported r_frame_rate=36/1 for the v6 WebM even though its duration was already 5.047 seconds. Those are the two numbers from the top of this piece, and now their origin is clear. The 36 is the v5 tempo. -itsscale shifts the timestamp of every packet, but the WebM header still advertises the 36fps from before the retiming.
On this page, that stale number misleads in a specific direction. The production check back at v4 found the homepage referencing theme assets and loading no MP4 from Shopify's CDN, which I read as the section's video setting being empty, so the template branch for the bundled assets is the one that runs. In that branch, the WebM source is listed before the MP4 inside the video element. Chrome picks the WebM, so anyone reading only r_frame_rate would conclude that Chrome plays this loop slower than Safari.
The real tempo lives in the packet timestamps. I read them with -show_entries packet=pts_time, and the result was the same for both files: the MP4 and the WebM each hold 242 packets ending at 5.02 seconds, an effective 48fps, which matches the 36 divided by 0.75 from earlier. The commands look roughly like this, in a condensed version with neutral file names:
# the header value, which was stale in this case
ffprobe -v error -select_streams v:0 \
-show_entries stream=r_frame_rate -of csv=p=0 loop.webm
# packet timestamps: how many, then the largest
ffprobe -v error -select_streams v:0 \
-show_entries packet=pts_time -of csv=p=0 loop.webm | wc -l
ffprobe -v error -select_streams v:0 \
-show_entries packet=pts_time -of csv=p=0 loop.webm | sort -n | tail -n 1The sort -n on the last line is there for a reason. ffprobe prints packets in the order they are stored in the file, which is decode order. In H.264 with B-frames, decode order differs from display order, so the last line is not necessarily the largest timestamp.
The v6 commit message sums it up more tightly than I can:
- both containers verified to carry the same timing: 242 packets with the
last at 5.02s, so mp4 and webm play at the same speed. The webm header
still advertises 36fps but that value is stale, the packet timestamps
are what browsers followI did not change the stale header itself. The way out of this trap lies in how you check, by reading pts_time instead of r_frame_rate. The production check for v6 was file-based as well: MP4 and WebM checksums identical to local, both 242 frames ending at 5.02 seconds, the frame 0 background flat at 250, and the poster attribute still in place. I did not record a side-by-side playback observation in Chrome and Safari, so the statement that both play at the same speed rests on identical packet timestamps, not on watching them run.
If you change tempo with -itsscale and -c copy, treat the r_frame_rate of the resulting WebM as a leftover from the previous file until proven otherwise. Proof that the frames did not change comes from hashes, and proof of the tempo comes from the pts_time of both files.