The Silent Failure Nobody Documents
I was helping a friend set a video avatar on Telegram. She recorded a 5-second clip on her iPhone, sent it to the bot, and nothing happened. No error. Just silence.
After about an hour of debugging I found the problem: her iPhone records in HEVC (H.265) by default since iOS 11, and Telegram's video avatar pipeline rejects H.265 without any user-facing message. The file passes the upload step, appears to process, then gets quietly dropped.
That's what pushed me to build @liveavabot.
What Telegram Video Avatars Actually Require
Telegram's documentation on this is sparse. I pieced together the spec from their API docs, bot API behavior, and a lot of trial and error:
- Codec: H.264 (AVC), not H.265/HEVC
- Pixel format: yuv420p (not yuv420p10le, not yuvj420p)
- Resolution: exactly 800x800
- Duration: 10 seconds maximum
- File size: 2MB maximum
- Audio: none (audio tracks cause a "Bad Request: VIDEO_NOTE_AUDIO" error)
The resolution requirement is the tricky part. Most videos aren't square. You can't just scale to 800x800 without distorting the content. You need to crop to a square first, then scale up.
The ffmpeg Pipeline
The approach is two steps: detect the optimal square crop region, then encode to spec.
ffmpeg has a cropdetect filter that finds the largest non-black region in a video. I use it in a no-output probe pass to compute the crop rectangle, then apply it in the encode pass:
# Step 1: detect the crop rectangle (scans first 10 seconds)
ffmpeg -i input.mov -vf "cropdetect=24:2:0" -t 10 -f null - 2>&1 | grep "crop="
# Sample output: ... crop=1080:1080:0:420 ...
# Step 2: encode to Telegram spec (portrait 1080x1920 example)
ffmpeg -i input.mov \
-vf "crop=1080:1080:0:420,scale=800:800:flags=lanczos,format=yuv420p" \
-c:v libx264 -preset fast -crf 28 \
-an -t 10 -movflags +faststart \
output.mp4
Key flags in the encode pass:
-
crop=1080:1080:0:420cuts to a 1080x1080 square starting at x=0, y=420 -
scale=800:800:flags=lanczosupscales to 800x800 with Lanczos resampling -
format=yuv420pforces the exact pixel format Telegram expects (not 10-bit, not JPEG chroma) -
-anstrips the audio track -
-movflags +faststartmoves the MP4 moov atom to the front, required for streaming
In my bot I compute the crop in Python using ffprobe rather than parsing cropdetect output. For a portrait 1080x1920 video: side = min(1080, 1920) = 1080, x = 0, y = (1920 - 1080) // 2 = 420. Cropdetect is useful for videos with letterboxing where you want to remove black bars too.
The aiogram 3 Handler
The bot is built on aiogram 3.x. Here's the core handler, simplified:
import asyncio, os, tempfile
from aiogram import Bot, Router, F
from aiogram.types import Message, BufferedInputFile
import ffmpeg # pip install ffmpeg-python
router = Router()
def build_avatar(src: str, dst: str) -> None:
probe = ffmpeg.probe(src)
vs = next(s for s in probe["streams"] if s["codec_type"] == "video")
w, h = int(vs["width"]), int(vs["height"])
side = min(w, h)
x = (w - side) // 2
y = (h - side) // 2
(
ffmpeg
.input(src, ss=0, t=10)
.video
.filter("crop", side, side, x, y)
.filter("scale", 800, 800, flags="lanczos")
.filter("format", "yuv420p")
.output(
dst,
vcodec="libx264",
preset="fast",
crf=28,
an=None,
movflags="+faststart",
)
.overwrite_output()
.run(quiet=True)
)
@router.message(F.video | F.animation | F.document)
async def handle_video(msg: Message, bot: Bot) -> None:
file_id = (msg.video or msg.animation or msg.document).file_id
with tempfile.TemporaryDirectory() as tmp:
src = os.path.join(tmp, "in.mp4")
dst = os.path.join(tmp, "out.mp4")
await bot.download(file_id, destination=src)
await asyncio.to_thread(build_avatar, src, dst)
size = os.path.getsize(dst)
if size > 2 * 1024 * 1024:
await msg.reply("Still over 2MB after encoding. Try a shorter clip.")
return
data = open(dst, "rb").read()
await msg.reply_video_note(
BufferedInputFile(data, filename="avatar.mp4")
)
A few things worth noting:
-
asyncio.to_threadkeeps ffmpeg off the event loop so the bot stays responsive under concurrent requests. - The handler matches
F.video | F.animation | F.documentbecause iPhone.movfiles forwarded as documents arrive with typeDocument, notVideo. Checking onlyF.videosilently drops a common case. -
reply_video_notesends the result as a round video, which Telegram accepts as a video avatar source. - The 2MB check after encoding catches edge cases. CRF 28 handles clips under 10 seconds well, but a fast-moving scene at high resolution can still push past the limit.
Packaging It as @liveavabot
The handler above is the core. The deployed bot adds a few things:
Duration check before encoding. I probe the source and warn if it's over 30 seconds. Encoding a 5-minute video to discover it still exceeds 2MB wastes server CPU and user patience.
Per-user rate limiting. The bot runs on a single VPS with 2 vCPUs. ffmpeg is CPU-bound. One user queuing 10 videos in parallel blocks everyone else.
Stars-based paid tier for users who convert more than 3 videos per day. The aiogram invoice flow handles this cleanly, no external payment library needed.
The bot is live at https://t.me/LiveAvaBot?start=devto_article_20260916. Currently at 403 registered users.
Edge Cases I Didn't Expect
yuv420p10le rejections. Some screen recordings and GoPro footage comes in at 10-bit color depth. ffmpeg's format=yuv420p filter handles the downconversion, but you have to explicitly add it to the filter chain. I forgot it once and Telegram returned a cryptic "wrong dimensions" error that took 20 minutes to diagnose.
4K source videos. CRF 28 with the fast preset doesn't reliably keep 4K footage under 2MB for 10 seconds. A two-pass encode with a target bitrate would fix it but adds latency. For now I document the limitation. ffmpeg is doing the heavy lifting; I just wrote the wrapper.
GIF loops. Animated GIFs convert cleanly but they loop. A 2-second GIF becomes a 2-second looping video avatar. Users expect this, so it's not a bug.
HEVC audio rejection. iPhone HEVC videos almost always have an AAC audio track. Without -an, the bot API returns "Bad Request: VIDEO_NOTE_AUDIO" with no further context. That error message would be a lot more useful if Telegram documented it anywhere.
Built by me. Bot: https://t.me/LiveAvaBot?start=devto_article_20260916. Drop a comment if your video is still failing after this pipeline.
Top comments (0)