The bug that took me a weekend
I recorded a 6-second clip on my iPhone, tried to set it as a Telegram video avatar, and got nothing. No error, no toast, no progress bar. Just the old avatar sitting there. I tried again. Same silence. I opened the file in QuickTime, it played fine. I checked the size, 1.4 MB, well under any limit I could imagine. So why did Telegram just swallow the request without a word?
Turns out iPhone records in HEVC (H.265) by default since iOS 11, and Telegram's video avatar endpoint only accepts H.264. The client uploads the file, the server rejects it silently, the UI shows nothing. If you have never dug into this, you assume your bot is broken or the network is flaky. It is neither. The codec is wrong.
What Telegram actually wants for a video avatar
The Bot API docs on this are thin, but the effective spec (confirmed by about forty test uploads) is:
- Codec: H.264 (libx264), yuv420p pixel format
- Container: MP4 with faststart
- Resolution: 800x800, square, exact
- Duration: 1 to 10 seconds
- Frame rate: 25 to 30 fps is safest
- Audio: must be absent (not just muted, absent)
- File size: under about 2 MB in practice
Miss any one of these and the avatar update just fails quietly. No error is returned from the API in most client paths.
The ffmpeg pipeline that actually works
The tricky part is not the codec swap, that is one flag. The tricky part is the crop. Most iPhone clips are portrait (1080x1920) or landscape (1920x1080), and a naive scale=800:800 squashes them into a distorted square. What you want is: detect the interesting region, center-crop to a square, then scale.
I run cropdetect on a probe pass, then apply the crop and scale in a single encode pass.
# probe pass, 2 seconds of input is enough
ffmpeg -ss 0 -t 2 -i input.mov -vf cropdetect=24:16:0 -f null - 2>&1 \
| grep -oE 'crop=[0-9:]+' | tail -1
# yields something like: crop=1080:1080:0:420
# encode pass
ffmpeg -y -i input.mov \
-vf "crop=1080:1080:0:420,scale=800:800,format=yuv420p" \
-c:v libx264 -profile:v baseline -level 3.1 \
-preset veryfast -crf 26 \
-movflags +faststart \
-an \
-t 10 \
output.mp4
Notes on the flags:
-
format=yuv420pinside the filter chain forces the pixel format before encode. Some libx264 builds pick yuv444p from HEVC sources and Telegram rejects that. -
-anstrips audio. If you leave-c:a copyby mistake you get an AAC track and Telegram rejects the whole file. -
+faststartmoves the moov atom to the front so the file streams. Without it Telegram sometimes times out on the fetch. -
-t 10hard-caps duration. A 15-second input becomes the first 10 seconds. -
-preset veryfast -crf 26gives me files under 2 MB for almost all 10-second clips.
The aiogram 3 handler
I use aiogram 3 with the router pattern. The handler accepts video, video_note, animation (GIF), and document (for people who forward as file). It downloads to a temp file, runs the pipeline in a subprocess, uploads the result.
from aiogram import Router, F
from aiogram.types import Message, FSInputFile
from aiogram.enums import ContentType
import asyncio, os, uuid
router = Router()
MEDIA = F.content_type.in_({
ContentType.VIDEO,
ContentType.VIDEO_NOTE,
ContentType.ANIMATION,
ContentType.DOCUMENT,
})
@router.message(MEDIA)
async def on_media(msg: Message):
file = msg.video or msg.video_note or msg.animation or msg.document
if not file:
return
src = f"/tmp/{uuid.uuid4()}_in"
dst = f"/tmp/{uuid.uuid4()}_out.mp4"
await msg.bot.download(file, destination=src)
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-y", "-i", src,
"-vf", "scale=800:800:force_original_aspect_ratio=increase,crop=800:800,format=yuv420p",
"-c:v", "libx264", "-profile:v", "baseline", "-level", "3.1",
"-preset", "veryfast", "-crf", "26",
"-movflags", "+faststart",
"-an", "-t", "10",
dst,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
if proc.returncode != 0:
await msg.answer("ffmpeg failed, try a shorter clip")
return
if os.path.getsize(dst) > 2 * 1024 * 1024:
await msg.answer("output too big, try a shorter clip")
return
await msg.answer_video(FSInputFile(dst), caption="use this as your profile video")
os.remove(src); os.remove(dst)
I skipped cropdetect in this inline snippet and leaned on ffmpeg's built-in scale-and-crop combo (force_original_aspect_ratio=increase then center-crop). It works for about 90% of clips. The full bot uses the two-pass cropdetect version for anything with letterboxing.
How this became @liveavabot
I wrapped the pipeline as a public Telegram bot: @LiveAvaBot. Send it any video or GIF, get back a file you can set as your Telegram video avatar. No signup, no watermark. Users so far: 382. Runs on a small Hetzner box with ffmpeg 6.1 and Python 3.11.
Bot state (rate limits, per-user cooldowns, error logs) lives in SQLite. I did not reach for Redis at this scale. If traffic ever gets serious I will swap it out, but 382 users generate about 10 conversions a day, well within what a single asyncio worker handles.
Edge cases and what is next
Things that broke along the way:
-
10-bit HEVC from iPhone Pro cameras: libx264 handles it if you have a recent ffmpeg. Older builds throw
Unsupported pixel format. Forceformat=yuv420pin the filter chain. - Portrait videos with subtitles baked in at the bottom: cropdetect includes them, the resulting square looks off. I added a heuristic to prefer top-anchored crops for portrait input.
-
Live Photos exported as .mov: they include a still image track that ffmpeg treats as a second video stream.
-map 0:v:0fixes it. - GIFs with transparent backgrounds: I flatten to black. Telegram avatars have no alpha channel.
Next up: batch mode (drop five clips, get the best one back based on a simple sharpness score) and a mini WebApp preview so users can pick their favorite crop before it lands on their profile.
Built by me, @LiveAvaBot. If you hit an edge case, DM me, I read every message.
Top comments (0)