Checked November 11, 2026. I build bamabound.app, which turns one photo of one person into a vertical clip of them holding a red I'M BAMA BOUND banner. The test clip in this post is synthetic (a red box swaying over a green field, with beeps), not a real person.
People ask some version of this a lot: "How do I make my Bama Bound video loop smoothly on my story / on a website / on a screen at a grad party?"
Players already repeat short videos. The problem is the moment where they do. A Bama Bound clip follows the reference timing of about eight seconds in a 9:16 frame, with the original soundtrack attached (FAQ). The last frame and the first frame were never meant to meet, so every repeat has a visible jump: the banner snaps from one side to the other, and the audio clicks.
This post fixes that with one ffmpeg graph and gives you a number to check the result instead of squinting.
The idea: crossfade the tail into the head
Split the clip into three parts:
| head (0..F) | mid (F..D-F) | tail (D-F..D) | Then build a new clip: mid, followed by tail with head fading in on top of it. The output ends on the head's last frame, which is exactly where mid begins. So when the player jumps back to the start, frame N flows into frame N+1 of the original motion. No snap.
The cost is length: the output is D - F seconds. With F = 0.5, an 8 s clip becomes 7.5 s.
Audio gets the same treatment with acrossfade, so the beat doesn't click at the seam.
The script
Full file: loop_clip.py (Python 3, numpy, ffmpeg/ffprobe on PATH).
def make_loop(src, dst, fade): w, h, fps, dur = probe(src) body = dur - fade v = (f"[0:v]split=3[a][b][c];" f"[a]trim=0:{fade},setpts=PTS-STARTPTS[head];" f"[b]trim={fade}:{body},setpts=PTS-STARTPTS[mid];" f"[c]trim={body}:{dur},setpts=PTS-STARTPTS[tail];" f"[head]format=yuva420p,fade=t=in:st=0:d={fade}:alpha=1[hf];" f"[tail][hf]overlay=shortest=1[seam];" f"[mid][seam]concat=n=2:v=1:a=0,format=yuv420p[v]") if has_audio(src): v += (f";[0:a]asplit=3[x][y][z];" f"[x]atrim=0:{fade},asetpts=PTS-STARTPTS[ah];" f"[y]atrim={fade}:{body},asetpts=PTS-STARTPTS[am];" f"[z]atrim={body}:{dur},asetpts=PTS-STARTPTS[at];" f"[at][ah]acrossfade=d={fade}:c1=tri:c2=tri[as];" f"[am][as]concat=n=2:v=0:a=1[aout]") # then: ffmpeg -i src -filter_complex v -map [v] -map [aout] ... dst Why not xfade?
My first version used xfade=transition=fade. It failed with:
The inputs needs to be a constant frame rate; current rate of 1/0 is invalid After trim + setpts, the branches lose their frame-rate metadata, and xfade refuses them. Adding fps= up front didn't fix it either. Fading the head's alpha channel in and overlaying it on the tail does the same blend and doesn't care about frame-rate metadata. If you control your input, xfade works too, but the alpha version has been less fussy for me.
Measuring the seam
"Looks smooth to me" isn't a test. The script grabs the last frame and the first frame as grayscale arrays and reports the mean absolute pixel difference (0 = identical, 255 = opposite). It also reports the difference between two neighbouring frames in the middle of the clip, because that's the honest target: a good seam should look like any other frame step, not like a freeze.
def seam(path): w, h, fps, dur = probe(path) last = frame(path, max(dur - 1.5 / fps, 0), w, h) first = frame(path, 0, w, h) return float(np.abs(last - first).mean()) Results on a test clip
I ran it on two synthetic 480x854, 30 fps, 8 s clips: one with heavy film grain on the grass, one with no grain.
| Clip | Seam before | Seam after (F=0.5 s) | Normal frame step | Length |
|---|---|---|---|---|
| grainy field | 7.5 | 5.2 | 5.1 | 8.00 → 7.50 s |
| clean field | 2.0 | 0.9 | 0.7 | 8.00 → 7.50 s |
With F = 1.0 on the grainy clip, the seam was 4.5 and the clip 7.0 s.
Two things to notice:
- The grainy "before" seam looks small (7.5) but the jump is real. Grain makes every frame differ by about 5, so the number only means something next to the normal frame step. That's why the script prints both.
- After the crossfade, the seam lands right at the normal frame step (5.2 vs 5.1, 0.9 vs 0.7). That's the goal: the loop point stops being special.
Picking the fade length
- 0.3 to 0.5 s is enough when the banner moves slowly at the start and end. You lose little length.
- 1 s helps when the start and end poses are very different, but you'll see a soft double image of the banner for that second. For text that has to stay readable, that ghosting is worse than a tiny jump.
- Don't go past about a quarter of the clip. You'd be cutting the part people came for.
Where a loop-ready file helps
- A TV or laptop at a grad party, where a media player repeats the clip all night.
- A website hero or a
<video loop muted playsinline>embed. - A story that auto-replays while people read the caption.
TikTok, Reels and Shorts loop on their own. A seamless file just stops the repeat from looking like an edit.
What this doesn't do
- It doesn't change the soundtrack's musical phrase. If the song is mid-word at 7.5 s, the crossfade blends two words. Listen once before posting.
- It re-encodes (
libx264 -crf 18). Keep your original download too. - It can't fix a clip that has a frozen or black lead-in from screen recording. Trim that first, then loop.
Prices, if you're making the clip itself
On the live pricing page today, one Bama Bound generation is $7.98 at 480p, $9.98 at 720p, $16.98 at 1080p and $24.98 at 4k, with tax added at checkout (pricing). There's no free tier and no subscription. Loop-ready output isn't a product feature, which is why this script exists.
And as always with this meme: it's a joke format, not an admission letter. The real Bama Bound is the University of Alabama's orientation program, which has nothing to do with it (about the meme).
