The Silent Failure Nobody Warns You About
You take a nice short clip on your iPhone. You open Telegram, go to Settings, tap your avatar, choose "Set Video". The upload spins. Then... nothing. No error, no toast, just the old picture staring back at you.
I hit this three times before I realized what was happening. iPhone records video in HEVC (H.265) inside a .mov container. Telegram's video avatar accepts H.264 in MP4. So the app quietly drops your upload on the floor.
That was the seed for @liveavabot. Send it any clip, get back a file Telegram will actually accept.
What the Telegram Video Avatar Spec Actually Requires
The official rules are scattered across the Bot API docs and community reverse-engineering. Here is what I settled on after testing:
- Container: MP4 with
faststart(moov atom at the start). - Video codec: H.264, baseline or main profile, yuv420p pixel format.
- Resolution: square, 800x800 is the sweet spot. 640x640 works too.
- Duration: up to 10 seconds (Telegram trims silently past that).
- File size: under 2 MB. Above ~2.5 MB the client rejects it.
- Audio: none. Any audio track and the client treats it as a regular video.
- Framerate: 30 fps caps out cleanly. 60 fps sometimes glitches.
Miss any one of these and the upload fails without an error message. Not great.
The ffmpeg Pipeline
The whole conversion is three steps: detect a square crop, scale to 800x800, encode to H.264 with the right flags. All of it fits in one ffmpeg invocation.
First I run cropdetect to find a reasonable square region. Portrait videos need cropping to a square, not just scaling (else your face gets squished):
ffmpeg -hide_banner -i input.mov \
-vf "cropdetect=24:16:0" -t 3 -f null - 2>&1 \
| grep -oP 'crop=\S+' | tail -1
That returns something like crop=1080:1080:0:420. Feed that into the real pass, forcing 800x800, no audio, yuv420p, and faststart:
ffmpeg -y -i input.mov \
-vf "crop=1080:1080:0:420,scale=800:800:flags=lanczos,fps=30" \
-c:v libx264 -profile:v main -pix_fmt yuv420p \
-preset veryfast -crf 26 \
-movflags +faststart \
-an -t 10 \
output.mp4
The important flags nobody documents together:
-
-pix_fmt yuv420p: without this some Telegram clients render green frames. -
-movflags +faststart: puts the moov atom first so the client can preview before downloading the full file. -
-an: drops audio. Non-negotiable for the avatar spec. -
-crf 26: quality/size tradeoff. 26 keeps 10s under 2 MB for most content.
If the output still lands above 2 MB, I re-encode with -crf 30. Above 4 MB source, I do one warm-up pass at -crf 32 before falling back further.
Wiring It Into aiogram 3
The bot handler is small. Accept a video, animation, or document; run it through the pipeline; send back the MP4.
from aiogram import Router, F
from aiogram.types import Message, FSInputFile
import tempfile, pathlib
router = Router()
@router.message(F.video | F.animation | F.document)
async def handle_media(message: Message):
file = message.video or message.animation or message.document
if file.file_size and file.file_size > 20 * 1024 * 1024:
await message.reply("Too big. 20 MB max input.")
return
with tempfile.TemporaryDirectory() as tmp:
src = pathlib.Path(tmp) / "in.bin"
dst = pathlib.Path(tmp) / "out.mp4"
await message.bot.download(file.file_id, destination=src)
ok = await convert_to_avatar(src, dst)
if not ok:
await message.reply("Conversion failed. Send a shorter clip.")
return
await message.reply_video(
FSInputFile(dst),
caption="Upload as your Telegram avatar.",
)
The convert_to_avatar function is just an asyncio.create_subprocess_exec wrapper around the ffmpeg command above, with the cropdetect step first. Nothing fancy. ffmpeg is doing the heavy lifting, I just wrote the wrapper.
Packaging It as @liveavabot
Once the conversion worked reliably, I put a Telegram Stars paywall in front of it (25 stars per conversion, roughly 30 cents). The whole thing runs on one small VPS with a systemd unit, ffmpeg from the Debian repo, and aiogram 3 in a venv. No queue, no worker pool, no S3. When a message comes in, it converts on the box and sends the file back.
Traffic is small enough (a few hundred users, single-digit concurrent conversions) that this is fine. If it grows past that, adding a worker queue is a two-file change.
You can try it at https://t.me/LiveAvaBot?start=devto_article_20260813.
Edge Cases and What Is Next
A few things bit me during the build.
Videos rotated by EXIF: iPhone stores rotation as metadata, not in the pixel data. ffmpeg's -vf chain runs before rotation is applied. Fix by prepending transpose= based on ffprobe orientation, or use -noautorotate and handle it yourself.
Animated GIFs with palette artifacts: convert through the paletteuse filter first if colors look wrong, otherwise a straight -i input.gif into the same H.264 pipeline works.
HDR sources from newer iPhones: these have BT.2020 color space and look washed out after conversion. Tone-map with zscale=t=linear:npl=100,tonemap=hable.
Bots sending 4K: I hard-cap input at 20 MB, and if the resolution is over 1920x1920 I downscale before the crop pass to save CPU.
What is next: batch conversion (send 3 clips, get 3 back), custom crop selection (tap the region you want), and maybe a web version for people who do not want to talk to a bot.
Built by me. @liveavabot.
Top comments (0)