liveavabotThe Bug I Didn't Expect I was testing my own bot and sent it a video shot on an iPhone....
I was testing my own bot and sent it a video shot on an iPhone. Nothing happened. The bot accepted the file, ran ffmpeg, and returned what looked like a valid MP4. But Telegram silently rejected it as a video avatar. No error message, just silence.
Spent an hour before I realized: iPhone records in HEVC (H.265) by default. Telegram's video avatar spec requires H.264. The container is .mp4, everything looks fine, but the codec inside is wrong. Telegram doesn't tell you this. It just ignores the file.
That's the whole problem. What Telegram actually requires, and how ffmpeg solves it.
Telegram's video profile photo spec is tighter than you'd expect:
faststart flag (moov atom first, or in-app preview breaks).Four of those are non-obvious. HEVC is the silent killer for iPhone users. yuv420p matters because libx264 can output yuv444p when you're not explicit. faststart matters for smooth in-app preview. Stripping audio is required, not optional cleanup.
The naive approach:
ffmpeg -i input.mp4 -vf "scale=800:800" -c:v libx264 output.mp4
This fails in several ways. The video might not be square (letterbox bars, portrait crop, arbitrary aspect ratio). It doesn't strip audio. It doesn't enforce yuv420p. It doesn't cap duration. File size can still exceed 2 MB.
Here's the two-pass pipeline I use in production. First, detect the actual content bounds:
ffmpeg -i input.mp4 \
-vf "cropdetect=limit=24:round=2:reset=0" \
-f null - 2>&1 | grep cropdetect | tail -1
Parse the crop= value from that output, then use it in the encode pass:
ffmpeg -i input.mp4 \
-t 10 \
-vf "crop=<detected>,scale=800:800:force_original_aspect_ratio=disable,fps=30" \
-c:v libx264 \
-pix_fmt yuv420p \
-crf 28 \
-preset fast \
-an \
-movflags +faststart \
output.mp4
Key flags:
-t 10: hard cap at 10 secondscrop=<detected>: removes letterbox and pillarbox bars from the first passscale=800:800:force_original_aspect_ratio=disable: stretches to exact 800x800-pix_fmt yuv420p: explicit, never rely on encoder default-crf 28: quality-based encoding, keeps output under 2 MB for most 10s clips-an: strips all audio tracks-movflags +faststart: moov atom firstFor clips that still exceed 2 MB at CRF 28 (high-motion 10s content), a second pass targeting -b:v 1400k reliably fits within 2 MB.
The handler downloads the file, runs the two-pass pipeline, and replies with the result.
import asyncio
import tempfile
import os
from pathlib import Path
from aiogram import Router, F
from aiogram.types import Message, BufferedInputFile
router = Router()
async def run_ffmpeg(input_path: str, output_path: str) -> bool:
detect_cmd = [
"ffmpeg", "-i", input_path,
"-vf", "cropdetect=limit=24:round=2:reset=0",
"-f", "null", "-",
]
proc = await asyncio.create_subprocess_exec(
*detect_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
crop_filter = ""
for line in stderr.decode().splitlines():
if "cropdetect" in line and "crop=" in line:
crop_val = line.split("crop=")[-1].split()[0]
crop_filter = f"crop={crop_val},"
vf = f"{crop_filter}scale=800:800:force_original_aspect_ratio=disable,fps=30"
encode_cmd = [
"ffmpeg", "-i", input_path,
"-t", "10",
"-vf", vf,
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-crf", "28",
"-preset", "fast",
"-an",
"-movflags", "+faststart",
"-y", output_path,
]
proc = await asyncio.create_subprocess_exec(
*encode_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.communicate()
size = Path(output_path).stat().st_size if Path(output_path).exists() else 0
return proc.returncode == 0 and size > 10_000 and size <= 2 * 1024 * 1024
@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message) -> None:
file = message.video or message.animation or message.document
if not file:
return
await message.reply("Processing...")
with tempfile.TemporaryDirectory() as tmpdir:
input_path = os.path.join(tmpdir, "input.mp4")
output_path = os.path.join(tmpdir, "output.mp4")
await message.bot.download(file, destination=input_path)
ok = await run_ffmpeg(input_path, output_path)
if not ok:
await message.reply("Couldn't convert this file. Try a shorter or smaller clip.")
return
with open(output_path, "rb") as f:
data = f.read()
await message.reply_video(
video=BufferedInputFile(data, filename="avatar.mp4"),
caption="Set this as your video avatar in Telegram profile settings.",
)
Real production version adds download size checks before the temp file is written, per-user queue limits, and a bitrate retry pass if the first encode exceeds 2 MB. But this core runs.
The bot is live at https://t.me/LiveAvaBot?start=devto_article_20260923. Send it any video or GIF, get back a ready-to-use 800x800 H.264 MP4. No app, no upload form, just a bot message.
424 users so far, 5 new in the last 24 hours. It's a small tool. VPS cost is under $5/month.
The reason I built it: people keep running into silent HEVC rejection with no explanation from Telegram. Settings > Camera > Formats > High Efficiency is on by default on most iPhones. Most users have no idea their video is H.265. The bot handles it transparently.
Built by me, https://t.me/LiveAvaBot?start=devto_article_20260923.
GIF files from Telegram are already MP4. When Telegram sends an "animation", it has already converted it to a silent MP4 internally. The handler treats it the same as video, which works.
Some vertical phone videos have no letterbox bars. cropdetect returns the full frame. scale=800:800 handles it fine.
Very short clips produce empty output. ffmpeg exits 0 but writes a 4 KB stub. Checking size > 10_000 before sending catches this.
libx264 preset tradeoffs. fast is the right balance. ultrafast produces noticeably larger files at the same CRF. slow rarely buys enough quality to matter for a 10s avatar clip.
Next thing I want to add: a manual crop selector so users can pick the frame window rather than relying on cropdetect. Not built yet. Auto-crop handles around 95% of cases well enough.