The pain nobody mentions
I recorded a short clip on my iPhone, dragged it into the Telegram "set video avatar" dialog, and nothing happened. The app accepted the file, pretended to upload it, then quietly fell back to a still frame. No error message.
The problem turned out to be HEVC. iPhones record H.265 by default since iOS 11. Telegram's video avatar endpoint will not touch H.265. It expects H.264, square frame, under 10 seconds, under 2MB, no audio track. Nothing in the UI tells you this.
I lost two hours before I found the spec buried in a Telegram Core page. Then I spent a weekend building a bot that does the conversion in one tap. This post is the writeup.
What Telegram actually wants
The constraints for a Telegram video avatar are strict:
- Codec: H.264, yuv420p pixel format.
- Container: MP4 with
faststartflag. - Resolution: square. Telegram officially supports 640x640, but 800x800 works fine and looks sharper.
- Duration: 3 to 10 seconds.
- Size: under 2MB.
- Audio: must be removed entirely, not just muted.
If any one of these fails, Telegram silently reverts to the first frame as a static picture. No upload error. The video just does not play when someone taps your avatar.
Most phone-recorded clips fail on at least three of these: HEVC codec, non-square aspect ratio, audio present, size over 2MB.
FFmpeg does the heavy lifting
The conversion is a single ffmpeg pipeline. I run cropdetect first on a 2-second sample to pick a square crop centered on motion, then re-encode with a size target.
Here is the actual command the bot uses:
ffmpeg -y -i input.mov \\
-vf "crop=min(iw\\,ih):min(iw\\,ih),scale=800:800,fps=30,format=yuv420p" \\
-c:v libx264 -preset veryfast -crf 28 \\
-an \\
-movflags +faststart \\
-t 9.5 \\
output.mp4
Breaking that down:
-
crop=min(iw,ih):min(iw,ih)makes the output square by taking the shortest side. -
scale=800:800resizes to 800x800. -
fps=30caps the framerate so a 60fps iPhone clip does not blow up the file size. -
format=yuv420pis the pixel format Telegram wants. Without this, H.264 Main profile can still fail. -
-c:v libx264picks the right codec. HEVC input is transcoded here. -
-preset veryfast -crf 28is my size vs quality compromise. For a 10 second clip at 800x800, it lands around 1.5MB. -
-androps audio. Telegram requires this. -
-movflags +faststartmoves the moov atom to the front so Telegram can start playing before the whole file is downloaded. -
-t 9.5hard-caps duration to 9.5 seconds, under the 10s limit with a safety margin.
If the result is still over 2MB (long clips, lots of motion), I retry with -crf 32. Two retries at most, then I give up and tell the user to send a shorter clip.
The aiogram 3 handler
The bot runs on aiogram 3. The handler takes a video or document message, saves the file, runs ffmpeg, and sends the result back.
from aiogram import Router, F
from aiogram.types import Message, FSInputFile
from pathlib import Path
import asyncio
import tempfile
router = Router()
async def convert(src: Path, dst: Path, crf: int = 28) -> None:
cmd = [
"ffmpeg", "-y", "-i", str(src),
"-vf",
"crop=min(iw\\\\,ih):min(iw\\\\,ih),scale=800:800,"
"fps=30,format=yuv420p",
"-c:v", "libx264", "-preset", "veryfast", "-crf", str(crf),
"-an", "-movflags", "+faststart", "-t", "9.5",
str(dst),
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_, err = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(err.decode()[-500:])
@router.message(F.video | F.animation | F.document)
async def handle_video(msg: Message) -> None:
file = msg.video or msg.animation or msg.document
if file.file_size and file.file_size > 50 * 1024 * 1024:
await msg.reply("File is over 50MB, send a shorter clip.")
return
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "in"
dst = Path(tmp) / "out.mp4"
await msg.bot.download(file, destination=src)
for crf in (28, 32):
await convert(src, dst, crf=crf)
if dst.stat().st_size <= 2 * 1024 * 1024:
break
else:
await msg.reply("Could not get under 2MB. Try a shorter clip.")
return
await msg.answer_video(
FSInputFile(dst),
caption=(
"Done. Open Telegram settings, tap your avatar, "
"pick this video."
),
)
A few notes:
-
F.video | F.animation | F.documentcatches all three ways users send videos. GIFs arrive asanimation. Dragged files arrive asdocument. - The 50MB cap is bot-side. Telegram's bot API maxes at 20MB for direct downloads, 50MB with a workaround. For larger files I use the full file API.
- Running ffmpeg as a subprocess with
asyncio.create_subprocess_execkeeps the event loop responsive. One worker per message is fine for low traffic. For higher volumes I would put ffmpeg behind a work queue.
Packaging this as a bot
I ran this as a local script for a week, converting clips for myself. Then I turned it into a public bot: @LiveAvaBot.
Current numbers: 441 users, running on a single Hetzner box. ffmpeg eats CPU for about 2 seconds per clip, which is the main bottleneck. Storage cost is zero because files are deleted right after upload.
The bot is free and has no account step. Send a video, get a converted file back. If you want the result applied automatically, the Bot API cannot change your profile picture for you, so you still have to tap "Set" in Telegram settings yourself.
Edge cases and what is next
Things that broke in the first week:
- Portrait clips with text at the bottom got cropped weird. I added a cropdetect pass that biases toward the face region if one is detected. Still not perfect.
- Very dark clips at crf 28 looked grainy. I bumped the minimum to crf 26 for clips under 5 seconds.
- Telegram's
animationtype for GIFs sometimes has no audio stream, which made ffmpeg log scary warnings. The warnings are safe to ignore, but I had to parse stderr more carefully to catch real errors. - Some HEVC clips with 10-bit color (
yuv420p10le) needed an explicit-pix_fmt yuv420pon top of the vf filter chain. Without it, libx264 complained about input format.
What I want to add next: a scene-aware auto-trim that picks the most interesting 9.5-second window instead of just taking the first 9.5 seconds. ffmpeg has select='gt(scene,0.3)' for this. I just have not shipped it yet.
Built by me, @LiveAvaBot.
Top comments (0)