The problem: iPhone videos that Telegram refuses
I wanted to set a short clip as my Telegram video avatar. Recorded 8 seconds on my iPhone, opened the profile picture menu, hit upload. Telegram accepted the file, showed a spinner, then reverted to my old photo. No error message, just silence.
iPhones record video in HEVC (H.265) by default since iOS 11. Telegram's video avatar endpoint only accepts H.264 in an MP4 container. HEVC gets rejected server-side without a user-facing message. The upload "succeeds" but the profile never updates.
That's the pain that made me build @liveavabot.
What Telegram actually requires
The video avatar spec isn't in the public Bot API docs (video avatars are a client feature, not a bot feature), but the constraints are visible if you inspect the desktop client's upload path:
- Container: MP4 with faststart (moov atom at the beginning)
- Video codec: H.264, yuv420p pixel format, Baseline or Main profile
- Resolution: 800x800 square, exactly
- Duration: 10 seconds max
- File size: 2 MB max
- Audio: must be stripped
Miss any of these and the client either rejects the upload immediately or, worse, accepts it and silently discards it. HEVC lands in the second failure mode. So do yuv444p files from some Android editors, and any resolution that isn't exactly 800x800.
Cropdetect, scale, and format conversion in one pipe
FFmpeg handles all of this if you chain the right filters. Here's the actual command my bot runs:
ffmpeg -i input.mov \
-t 10 \
-vf "scale=800:800:force_original_aspect_ratio=increase,crop=800:800,format=yuv420p" \
-c:v libx264 \
-profile:v main \
-preset veryfast \
-crf 26 \
-movflags +faststart \
-an \
-y output.mp4
Breaking it down:
-
-t 10caps duration at 10 seconds. If the source is longer, the tail is dropped. - The
scalefilter withforce_original_aspect_ratio=increaseupscales so the smaller side fits 800. Thencrop=800:800slices out a centered square. This handles portrait, landscape, and already-square input uniformly. -
format=yuv420pforces the pixel format Telegram wants. -
-c:v libx264 -profile:v mainpicks H.264 with a widely compatible profile. -
-crf 26targets a quality level that usually keeps output under 2 MB for 10 seconds at 800x800. If it doesn't, I re-encode with-crf 30as a fallback. -
-movflags +faststartmoves the moov atom to the start of the file so Telegram can begin decoding without downloading the whole thing. -
-anstrips audio. Video avatars are muted anyway.
For source videos with letterboxing (someone screen-recorded a phone video on a laptop), I run cropdetect first:
ffmpeg -i input.mov -vf cropdetect=24:16:0 -f null - 2>&1 | \
grep -oP 'crop=\K[0-9:]+' | tail -1
That prints something like 640:640:0:0 which I feed back as a pre-crop before the scale pipeline.
The aiogram 3 handler
The bot side is small. Aiogram 3 handles file downloads, so the whole flow is: receive video, download, shell out to ffmpeg, upload the result. Here's the trimmed handler:
from aiogram import Router, F
from aiogram.types import Message, FSInputFile
import asyncio, tempfile, pathlib
router = Router()
@router.message(F.video | F.animation | F.document)
async def convert(msg: Message):
file = msg.video or msg.animation or msg.document
if file.file_size and file.file_size > 50 * 1024 * 1024:
await msg.answer("Source is over 50 MB, please trim it first.")
return
with tempfile.TemporaryDirectory() as tmp:
src = pathlib.Path(tmp) / "in.bin"
dst = pathlib.Path(tmp) / "out.mp4"
tg_file = await msg.bot.get_file(file.file_id)
await msg.bot.download_file(tg_file.file_path, destination=src)
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", str(src), "-t", "10",
"-vf", "scale=800:800:force_original_aspect_ratio=increase,"
"crop=800:800,format=yuv420p",
"-c:v", "libx264", "-profile:v", "main",
"-preset", "veryfast", "-crf", "26",
"-movflags", "+faststart", "-an", "-y", str(dst),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
rc = await proc.wait()
if rc != 0 or not dst.exists():
await msg.answer("Conversion failed. Try a different source.")
return
if dst.stat().st_size > 2 * 1024 * 1024:
await msg.answer("Output exceeded 2 MB. Retrying at lower quality.")
return
await msg.answer_video(
FSInputFile(dst),
caption="Ready. Long-press your avatar in Settings, "
"pick 'Set as Video', upload this file."
)
A few things worth noting:
- I accept
F.video | F.animation | F.documentbecause iPhone shares often arrive asdocumentwith mimevideo/quicktime, notvideo. Missing that check drops half the real traffic. - The 50 MB source cap matches Bot API's
getFilelimit for non-premium bots. Bigger sources need the local Bot API server. - The 2 MB output check is a safety net. In practice CRF 26 hits under 2 MB about 95% of the time for typical phone clips.
Packaging it as @liveavabot
I glued the handler above to a Postgres row per user (for stats and rate limits), a Redis queue for concurrent conversions, and a small aiogram middleware for logging. The whole thing runs on a cheap Hetzner VPS with ffmpeg from apt. No GPU, no cloud transcoder. 800x800 for 10 seconds is trivial CPU load, veryfast preset finishes it in under a second.
Try it: https://t.me/LiveAvaBot?start=devto_article_20260722. Send any video or GIF, it sends back a Telegram-compatible avatar file.
Edge cases I learned the hard way
-
Rotated iPhone videos. iPhones store portrait video as landscape with a rotation flag in metadata. FFmpeg 5+ honors it by default, 4.x needs
-noautorotatehandling. Check your distro version. -
Animated stickers as source. Telegram sends
.tgs(Lottie JSON), not video. Ignore those in the handler, they'd just error. -
GIFs with palette artifacts. Old GIFs upscale ugly. I added
-sws_flags lanczosto the scale filter, which sharpens the result a lot without slowing anything down. - Voice-over videos. People sometimes complain that audio was stripped. I added a note in the reply caption explaining Telegram video avatars are muted by spec.
What's next
Right now the bot is stateless per conversion. Next iteration adds a trim mode where users pick the 10-second window from a longer clip, using inline buttons and ffmpeg's -ss seek. After that, probably batch mode for multi-file uploads.
Built by me. @liveavabot lives at https://t.me/LiveAvaBot?start=devto_article_20260722 if you want to try it or fork the concept.
Top comments (0)