I built a tiny Telegram bot that does one boring thing: it takes any video or GIF you send it and spits back a file Telegram will actually accept as a video avatar. Sounds trivial. It wasn't.
The reason it wasn't trivial is that Telegram's video-avatar format is a narrow slice of MP4, and iPhone videos land outside that slice by default. If you drag a fresh iPhone clip into the desktop app's avatar picker, Telegram just says the file is not supported. No hint about codec, no hint about duration, no hint about resolution. So I read the spec, wrote an ffmpeg pipeline, wrapped it in aiogram 3, and shipped it as @liveavabot. This post is the build log.
The pain: iPhone HEVC silently fails
Since iOS 11 the default camera codec is HEVC (H.265) in an .mov container, yuv420p10le, variable frame rate. Telegram's clients decode HEVC fine for playback, but the avatar upload path is stricter than the general video path. It wants H.264, baseline or main profile, 8-bit yuv420p, square 800x800, no audio, up to 10 seconds, up to 2 MB, and moov atom at the front so the server can index it without downloading the whole file.
Send it a 4K HEVC portrait clip and it rejects the file with a generic error. Users assume the bot is broken. It isn't, Telegram is just picky and quiet about it.
So the bot's job is to normalize any input into that narrow slice.
What the Telegram spec actually requires
From the API docs and a lot of trial and error, a working video avatar looks like this:
- Container: MP4, moov atom at the start (
-movflags +faststart). - Video codec: H.264, profile main or baseline, level 3.1 or lower.
- Pixel format: yuv420p (8-bit, not 10-bit).
- Resolution: 800x800, square, center-cropped from the source.
- Frame rate: constant, 30 fps works reliably.
- Duration: up to 10 seconds, hard cap.
- Audio: none, must be stripped.
- File size: under 2 MB. This is the real constraint, everything else you can hit easily.
The 2 MB cap is what makes the encoding interesting. At 800x800 30fps for 10 seconds you have about 1.6 Mbps of budget. That is thin for anything with motion, so the bot has to be honest about bitrate and use a two-pass or CRF-with-cap strategy.
The ffmpeg pipeline
I settled on a two-step approach: first probe the source with cropdetect to find the actual content bounds (iPhone videos are often letterboxed or shot in portrait), then encode with a hard bitrate ceiling.
Here is the cropdetect probe. It samples a few frames and prints suggested crop rectangles:
ffmpeg -ss 0.5 -i input.mov -t 2 \
-vf cropdetect=24:16:0 \
-f null - 2>&1 | grep -oP 'crop=\S+' | tail -1
That gives you something like crop=1080:1080:0:420. The bot parses that and feeds it into the real encode:
ffmpeg -y -i input.mov \
-t 10 \
-vf "crop=1080:1080:0:420,scale=800:800:flags=lanczos,fps=30,format=yuv420p" \
-an \
-c:v libx264 -profile:v main -level 3.1 \
-preset veryfast -crf 26 \
-maxrate 1400k -bufsize 2800k \
-movflags +faststart \
output.mp4
A few things worth calling out:
-
-t 10before-iwould seek in the input, which is faster but can miss the intended clip start. Placing it after-icuts the output cleanly. -
format=yuv420pin the filter chain forces 8-bit even if the source is 10-bit HEVC. Without this, x264 will bail on some inputs. -
-anstrips audio. Telegram rejects avatars with an audio track even if it's silent. -
-crf 26with-maxrate 1400kis a compromise. CRF alone can blow the 2 MB cap on high-motion clips, so the maxrate is a safety net. If the output still lands over 2 MB, the bot re-encodes with-crf 30and a lower maxrate. -
-movflags +faststartmoves the moov atom to the front. Telegram's server needs this to accept the upload without a full download.
After this pipeline, a 47 MB 4K HEVC portrait clip from an iPhone 15 becomes a 1.7 MB square MP4 that Telegram accepts on the first try.
The aiogram 3 handler
The bot itself is small. aiogram 3 makes the routing clean. Here is the trimmed video handler:
import asyncio
import tempfile
from pathlib import Path
from aiogram import Router, F
from aiogram.types import Message, FSInputFile
router = Router()
@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message) -> None:
file = message.video or message.animation or message.document
if not file:
await message.reply("send a video or gif")
return
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "in.bin"
dst = Path(tmp) / "out.mp4"
await message.bot.download(file, destination=src)
crop = await detect_crop(src)
ok = await encode_avatar(src, dst, crop)
if not ok or dst.stat().st_size > 2 * 1024 * 1024:
await message.reply("could not fit under 2mb, try a shorter clip")
return
await message.reply_video(FSInputFile(dst), caption="upload this in settings as your video avatar")
async def detect_crop(src: Path) -> str:
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-ss", "0.5", "-i", str(src), "-t", "2",
"-vf", "cropdetect=24:16:0", "-f", "null", "-",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, err = await proc.communicate()
for line in reversed(err.decode(errors="ignore").splitlines()):
if "crop=" in line:
return line.split("crop=")[-1].split()[0]
return "in_w:in_h:0:0"
The encode_avatar function is a thin wrapper around the ffmpeg command above. If the first pass overshoots 2 MB, it retries with tighter parameters. Two retries is enough for 99% of inputs I have seen so far.
One gotcha: F.document catches files Telegram treats as documents rather than videos, which happens when the client uploads a raw .mov. Without that filter, iPhone shares from the Files app get ignored.
Packaging as @liveavabot
The bot lives at https://t.me/LiveAvaBot?start=devto_article_20260805. Send it any video, get back a Telegram-ready 800x800 MP4. It also handles GIFs (animation type) and raw documents. Hosting is a small VPS with ffmpeg installed from the distro repo. No queue, no worker pool, just async subprocesses. At 281 total users and a handful of conversions a day it does not need more.
The stack: aiogram 3 for the Telegram side, ffmpeg for the encoding, sqlite for logs and stats. Deployment is a systemd unit and a git pull. That is the whole thing.
Lessons and edge cases
A few things I learned the hard way:
- Portrait videos are the norm on mobile. Assume the input is 9:16 and center-crop to square, do not fit-with-black-bars. Users hate letterboxing on avatars.
- 10-bit HEVC is more common than you think. Always force
format=yuv420pin the filter chain, not just as an output pixel format. - The 2 MB cap is what fails, not codec or resolution. Build the retry loop first, the crop logic second.
- Telegram does not tell you why the avatar upload failed. If your file matches the spec and still fails, check
ffprobe output.mp4for extra streams (subtitles, data streams). Strip them with-map 0:v:0if needed. - 4K sources are slow to decode on tiny VPS boxes. A 20-second 4K HEVC clip took 14 seconds to process on a 2-vCPU machine. Users are patient for up to about 10 seconds, so I cap accepted input duration and warn early.
What is next: proper 4K handling with a two-pass encode, and a preview thumbnail so users see the crop before uploading. The crop is right most of the time, but for landscape videos with the subject on one side it picks the middle and cuts them out.
Built by me, @liveavabot: https://t.me/LiveAvaBot?start=devto_article_20260805. Feedback and edge-case clips welcome, that is how the crop heuristic gets better.
Top comments (0)