The Silent Failure Nobody Explains
I was setting up a Telegram video avatar and uploaded a recent iPhone clip. No error, no feedback, just the upload spinner disappeared and my profile photo stayed the same. Tried again. Same result.
It took about an hour to figure out. iPhone records HEVC (H.265) by default. Telegram's video avatar API silently rejects it. No error message, nothing.
What Telegram Actually Requires
The spec for video avatars is specific:
- Codec: H.264 (not HEVC/H.265, not VP9, not AV1)
- Resolution: 800x800 pixels, square
- Duration: 10 seconds maximum
- File size: 2 MB maximum
- Audio: must be stripped
- Pixel format: yuv420p
Most modern iPhone videos are HEVC at 1080x1920 or 4K. All of those properties need fixing before Telegram accepts the file.
How ffmpeg Handles It
The tricky part isn't the encode, it's the crop. A 9:16 portrait video fed directly into scale=800:800 produces a squished result. You need cropdetect first.
# Detect crop bounds from the first 5 seconds
crop=$(ffmpeg -i input.mov -vf cropdetect=24:16:0 -t 5 -f null - 2>&1 \
| grep -oP 'crop=\d+:\d+:\d+:\d+' | tail -1)
# Encode: crop, square scale, H.264, no audio
ffmpeg -i input.mov \
-vf "${crop},scale=800:800:force_original_aspect_ratio=decrease,pad=800:800:(ow-iw)/2:(oh-ih)/2" \
-c:v libx264 -profile:v baseline -level 3.0 \
-pix_fmt yuv420p \
-b:v 1500k \
-t 10 \
-an \
-movflags +faststart \
output.mp4
-an strips audio entirely. -movflags +faststart moves the moov atom to the front so Telegram can start reading before the full download finishes. Targeting 1500k bitrate keeps a 10-second clip under 2 MB (1500 * 10 / 8 = 1875 KB).
force_original_aspect_ratio=decrease plus pad scales the source down to fit inside 800x800, then pads with black. Letterboxing, but Telegram accepts it without complaints.
GIFs go through the same pipeline. The palette-to-yuv420p conversion happens automatically with libx264. No special flags needed.
The aiogram 3 Handler
I built the bot with aiogram 3. The handler downloads the file, runs ffmpeg as a subprocess, checks output size, and returns the result.
from aiogram import Router, F
from aiogram.types import Message, BufferedInputFile
import asyncio, tempfile, os, re
router = Router()
@router.message(F.video | F.document | F.animation)
async def handle_video(message: Message):
status = await message.answer("Converting...")
file_obj = message.video or message.animation or message.document
with tempfile.TemporaryDirectory() as tmpdir:
src = os.path.join(tmpdir, "input")
dst = os.path.join(tmpdir, "output.mp4")
await message.bot.download(file_obj, destination=src)
# detect crop
probe = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", src, "-vf", "cropdetect=24:16:0",
"-t", "5", "-f", "null", "-",
stderr=asyncio.subprocess.PIPE
)
_, stderr_bytes = await probe.communicate()
matches = re.findall(r"crop=\d+:\d+:\d+:\d+", stderr_bytes.decode())
crop = matches[-1] if matches else ""
vf = (
f"{crop},scale=800:800:force_original_aspect_ratio=decrease,"
"pad=800:800:(ow-iw)/2:(oh-ih)/2"
) if crop else "scale=800:800"
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-i", src, "-vf", vf,
"-c:v", "libx264", "-profile:v", "baseline",
"-pix_fmt", "yuv420p", "-t", "10", "-an",
"-movflags", "+faststart", "-b:v", "1500k",
dst, stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
size = os.path.getsize(dst) if os.path.exists(dst) else 0
if size == 0 or size > 2_000_000:
await status.edit_text("Conversion failed or output over 2 MB.")
return
with open(dst, "rb") as f:
data = f.read()
await status.edit_text("Done. Set this as your video avatar in profile settings.")
await message.answer_video(BufferedInputFile(data, filename="avatar.mp4"))
ffmpeg runs via asyncio.create_subprocess_exec so it doesn't block the event loop. The size check after encoding is important: a dense 10-second clip can still exceed 2 MB at 1500k. Better to tell the user than upload a file Telegram will silently reject.
Packaging It as @liveavabot
After building this for my own use, I turned it into a public bot. You send a video, GIF, or document to https://t.me/LiveAvaBot?start=devto_article_20260427 and it replies with a ready-to-use 800x800 H.264 MP4, no settings required.
It runs on a Hetzner VPS, Python 3.12, aiogram 3.x, system ffmpeg. Infrastructure cost is around 5 EUR/month. It handles iPhone HEVC clips, Android VP9 exports, GIFs from Giphy, and screen recordings without any manual flags from the user.
Edge Cases
A few things came up during testing:
Large GIFs time out on slow connections. I added a pre-download file size check and reject anything over 50 MB with a message.
Some sources produce no cropdetect output (already square content, or solid borders ffmpeg misses). The fallback is plain scale=800:800, which works for square video but distorts widescreen. I'm considering making letterbox-pad the default regardless of cropdetect.
The -t 10 cut happens at encode time, not download time. A 3-minute video gets fully downloaded before ffmpeg trims it. Fixing that means probing duration first and deciding whether to reject early.
4K sources look rough at 1500k on motion-heavy clips. No clean fix yet short of bumping bitrate and hoping the clip is short enough to stay under 2 MB.
Built by me, @liveavabot.
Top comments (0)