D
P
0
← All articles Baca dalam Bahasa Indonesia

CSS Layout, Overflow & Cascade

Video Loop Still Hitches Even Though First and Last Frame Are Identical? The Seam Breaks at Encode, Not in the Source

· · 7 min read
Video Loop Still Hitches Even Though First and Last Frame Are Identical? The Seam Breaks at Encode, Not in the Source

A fix that makes the number worse rarely arrives this cleanly. The source loop I was handed measured 1.79 RGB of average difference between its first and last frame. After I re-encoded it with the exact flags meant to repair looping, that difference climbed to 3.01.

The asset came from the animator as a square 1080x1080 VP9 WebM at 24fps, still carrying an Opus audio track, roughly 1.26 MB, and the client's request at that point was to raise the quality. In the following round the client reported two problems, and the one this post is about is the first: the loop was not smooth.

Measuring the seam before touching anything

Instead of guessing, I decoded the first and last frame out of the source file and computed the average RGB difference between them with Python PIL. The whole stack for this job stayed small: ffmpeg, H.264, and PIL.

from PIL import Image, ImageChops, ImageStat
 
first = Image.open("first.png").convert("RGB")
last = Image.open("last.png").convert("RGB")
 
diff = ImageChops.difference(first, last)
print(sum(ImageStat.Stat(diff).mean) / 3)   # 1.79

It came out at 1.79. For two frames that are supposed to join, that is effectively identical. So the raw file from the animator was not the problem, the source was already seamless, and whatever broke happened after those frames left the source.

My suspicion at the time, and it stayed a suspicion right to the end, was that the hitch came from codec metadata and from where the GOP boundary landed, not from the frame contents. If the frame at the loop boundary happens not to be a keyframe, the decoder has to reconstruct it from earlier references at the exact moment playback jumps back to frame zero.

Round one: pinning the GOP to the clip length

If the guess is the GOP boundary, the fix is to force the loop boundary to always land on a keyframe. The clip was 145 frames long, so I pinned the GOP to 145 as well, forced constant frame rate, and stripped the metadata.

-g 145 -keyint_min 145 -r 24 -map_metadata -1

-g 145 and -keyint_min 145 make the GOP length equal to the clip length in frames, so the loop boundary lands on a keyframe. -r 24 forces CFR. -map_metadata -1 drops metadata that could make a decoder hesitate. Those 145 frames at 24fps work out to 145 divided by 24, about six seconds.

In the same round I also restored the display ratio to 3:4 by cropping the square source:

crop=810:1080:135:0

810 against 1080 is exactly 3:4, and the offset of 135 comes from 1080 minus 810 divided by two, so the crop really is centered.

To keep quality from sliding after the crop, I upscaled before encoding. The plan started at 1.5x, but 1.5 times 810 is 1215, and that odd number gets rejected because x264 with yuv420p needs even dimensions. So I used 2x instead, to 1620x2160 through lanczos, both sides even. A small lesson I have checked first ever since: pick a scale ratio whose output is even, not one that merely looks reasonable and leaves you with an odd side.

The encode parameters themselves did not change from the previous round. H.264 Main at CRF 18 for the MP4, VP9 at CRF 28 for the WebM, -an to drop audio, and +faststart. That produced a 2.9 MB MP4 and a 1.8 MB WebM.

The client came back, and the numbers agreed with them

After all of that, the client reported again: the loop was still not seamless, even though the first and last frames were identical, and the background still read as a rectangle.

This time I measured the encoded file instead of the source. Average difference between first and last frame in the output was 3.01, against a source baseline of 1.79. Measured, the client's observation turned out to be correct. The seam that had been tight really did get looser, and what loosened it was my own work.

The excess over baseline is 3.01 minus 1.79, so 1.22 RGB introduced purely by encoding. The explanation that made most sense to me: the re-encode itself introduces codec quantization variance. Pinning the GOP answered the keyframe question, but it never answered how faithfully two identical frames get reproduced at two different positions inside one GOP.

Patching the seam before the encode, not after

If the encoder is what shifts the values, then repairing the seam after encoding is hopeless. What I could do instead was hand it material whose seam was already guaranteed identical before the encoder touched it.

That means splitting the stream in filter_complex, dropping the source's last frame, and appending the source's own first frame as the new last one.

[0:v]crop=...,split[m][f];
[m]trim=end_frame=144[main];
[f]trim=end_frame=1[first];
[main][first]concat=n=2:v=1[seam]

The idea is plain. The source has 145 frames, trim=end_frame=144 leaves 144 of them, then one first frame gets appended so the count returns to 145. The difference is that the last frame is now a byte-identical copy of the first, not some other frame that merely resembles it.

With that patch in place, the post-encode average dropped to 2.01. Against the 1.79 source baseline, the leftover is 2.01 minus 1.79, so 0.22 RGB. From 1.22 down to 0.22, and the remainder genuinely does not go away.

Why the number never reaches zero

My reading of that leftover 0.22 goes like this: H.264 is still lossy, so it introduces minor variance even when the input frames are identical, because a different GOP position produces different motion vectors. A pixel-level seam remains, but across most of the frame it sits below the visible threshold.

For an exact zero, as far as I know there are only two roads, and I turned both down. The first is lossless with -crf 0, which costs roughly ten times the file size, and that is indefensible for an asset loaded on a product page. The second is a ping-pong loop, where the animation reverses in the second half so the seam is smooth by construction, but that produces visibly different motion from what the animator designed.

So the compromise I settled on is explicit: accept an average difference of about 2 RGB, with a background that is flat. The background was a separate thread running in the same round. The rectangle complaint turned out not to be about element size but about a subtle gradient in the encoded video, with the bottom-left corner and bottom-middle measuring 2 to 6 RGB brighter than the rest. I flattened the backdrop with a chroma-key at similarity 0.02 overlaid onto a solid color source, and verified the exact values at several points after encoding.

The files from that round landed at 2.08 MB for the MP4, 1.4 MB for the WebM, and 66 KB for the poster. The MP4 shrank from 2.9 MB, a difference of 0.82 MB, because a truly flat background compresses better than a graded one. The seam patch built from split and concat stayed in place for the round after that.

The number I compared against the wrong thing

There is a tail to this story that I find more useful than the whole GOP exercise above. Months later, on the next asset swap, I measured the loop seam with PSNR rather than a PIL diff. It read 28.94 dB across the last-frame-to-first-frame join, while ordinary frame-to-frame pairs read 52.3 dB. The gap was wide, and I briefly flagged it as a blocker.

That was the wrong comparison. The old asset already running on the site and already accepted by the client measured 27.53 dB at its own seam. Which makes the new one 28.94 minus 27.53, so 1.41 dB better than what had been shipping without complaint. I nearly blocked a release because I compared two things that were never comparable. The 52.3 dB figure is measured between frames whose motion is continuous, while 28.94 dB is measured right at the cut. The gap between them is an artefact of the comparison, not a defect in the asset.

What I took away