Telegram added video avatars back in 2020. You upload a short clip and it loops on your profile instead of a static photo. Fun feature. Except if you recorded that clip on an iPhone, there's a good chance Telegram does nothing with it. No error message. No rejection. The upload finishes and your profile still shows the old photo.
I hit this myself, then watched two friends hit the same wall. So I dug into why, and ended up shipping a bot around the fix. This is the write-up.
Why iPhone Videos Fail
Since iOS 11, iPhones record video in HEVC (H.265) by default. Better codec, roughly half the bitrate of H.264 at the same quality. The problem: Telegram's video avatar pipeline only accepts H.264. When it gets HEVC, the client doesn't transcode and doesn't warn you. It silently drops the upload.
The failure is invisible because regular video messages do get transcoded. Send the same .mov in a chat and it plays fine. Only the avatar path is strict, and that mismatch is exactly what makes people think their app is broken.
What the Spec Actually Requires
Telegram never published a formal document for video avatars, so this is reverse-engineered from what the official clients produce and what the API accepts:
- MP4 container, H.264 video
- yuv420p pixel format (10-bit HDR sources fail without conversion)
- square, up to 800x800
- 10 seconds maximum
- no audio track at all, not just muted
- under roughly 2MB to be reliable
Miss any of these and you get the same silent nothing. The audio rule is the sneaky one: a video with a muted audio track still counts as having audio.
The FFmpeg Pipeline
ffmpeg does all the real work. For a center crop to square, the whole conversion is one command:
ffmpeg -y -i input.mov -t 10 \
-vf "crop='min(iw,ih)':'min(iw,ih)',scale=800:800,format=yuv420p" \
-c:v libx264 -preset slow -crf 26 \
-movflags +faststart \
-an output.mp4
What each piece does:
-
crop='min(iw,ih)':'min(iw,ih)'takes the largest centered square, works for portrait and landscape -
scale=800:800downsizes to the avatar resolution -
format=yuv420pconverts 10-bit HDR footage to 8-bit; without it libx264 produces output Telegram rejects -
-anstrips the audio track completely -
-movflags +faststartmoves the moov atom to the front so the preview loads instantly -
-t 10hard-caps duration
For letterboxed input (screen recordings, clips with black bars) a center crop keeps the bars. cropdetect fixes that. Run a short analysis pass first:
ffmpeg -i input.mov -t 3 -vf cropdetect=24:16:0 -f null - 2>&1 \
| grep -o 'crop=[0-9:]*' | tail -1
It prints something like crop=1080:1080:0:420, which you drop into the filter chain of the encode pass in place of the min() expression.
The 2MB cap needs one more trick. CRF encoding doesn't target a file size, so I encode at crf 26 first, and if the output lands over 2MB I bump CRF by 3 and retry. Two retries cover every 10 second clip I've seen.
The aiogram 3 Handler
The bot side is small. Download the file, shell out to ffmpeg, send the result back:
import asyncio
import tempfile
from pathlib import Path
from aiogram import Bot, F, Router
from aiogram.types import FSInputFile, Message
router = Router()
SQUARE = "crop='min(iw,ih)':'min(iw,ih)',scale=800:800,format=yuv420p"
@router.message(F.video | F.animation)
async def convert(message: Message, bot: Bot):
src_file = message.video or message.animation
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "in.mp4"
dst = Path(tmp) / "out.mp4"
await bot.download(src_file, destination=src)
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-y", "-i", str(src), "-t", "10",
"-vf", SQUARE,
"-c:v", "libx264", "-preset", "slow", "-crf", "26",
"-movflags", "+faststart", "-an", str(dst),
)
await proc.wait()
await message.answer_video(
FSInputFile(dst),
caption="Done. Settings > Edit profile > set as video avatar",
)
Details that matter:
-
create_subprocess_execinstead ofsubprocess.run, so one heavy encode doesn't block the event loop for every other user -
F.animationcatches GIFs, which Telegram already stores as silent MP4s, so the same pipeline handles them for free - Bot API caps downloads at 20MB, so check
file_sizebefore downloading and tell the user instead of failing mid-encode
Packaging It as a Bot
I wrapped this into @LiveAvaBot. The production version adds the CRF retry loop for the 2MB cap, cropdetect for letterboxed sources, and a small queue so parallel encodes don't flatten the VPS. 207 people have used it so far, most of them arriving right after Telegram silently ate their upload. That's the whole funnel: the app fails without explanation, people go looking, some land on the bot.
The stack is boring on purpose. One VPS, aiogram 3, ffmpeg from apt, SQLite for state. A typical 1080p iPhone clip encodes in 2 to 6 seconds at preset slow.
Edge Cases and Lessons
HDR colors go flat. format=yuv420p makes HDR footage valid but skips tone mapping, so colors wash out slightly. Proper tone mapping with zscale needs an ffmpeg build with zimg and costs real encode time. For a 10 second avatar nobody has complained yet.
Rotation metadata. iPhones store orientation as metadata instead of rotating pixels. Modern ffmpeg autorotates by default. If your output comes out sideways, your ffmpeg is old.
Muted is not silent. Early on I copied the audio stream when users had muted it in the iOS editor. The track was still present and Telegram rejected the file. Always -an.
Portrait crops cut heads. A centered square crop of a portrait video chops foreheads. I shift the crop window up for portrait input and the complaints stopped.
Next on the list: proper HDR tone mapping and letting users pick which part of the frame survives the crop.
Disclosure: I built @liveavabot, and the code above is a trimmed version of what runs in production.
If you've hit other undocumented corners of Telegram's media pipeline, tell me in the comments. I collect these.
Top comments (0)