The Video That Won't Upload
You recorded a clip on your iPhone, tried to set it as your Telegram video avatar, and nothing happened. No error. Just a spinner. You trimmed it. Still nothing. You exported it again. Nothing.
The problem is HEVC. iPhones default to HEVC (H.265) since iOS 11, and Telegram's video avatar endpoint rejects H.265 silently. No feedback to the user. The video just doesn't apply.
I hit this myself when building a small tool. Then I read the actual spec.
What Telegram Actually Requires
Telegram's video avatar has strict requirements. Most aren't documented in one place:
- Codec: H.264 (libx264), not H.265/HEVC
- Container: MP4
- Resolution: exactly 800×800 pixels, square
- Duration: 10 seconds maximum
- File size: 2 MB maximum
-
Audio: none (strip with
-an) - Pixel format: yuv420p
-
Streaming: moov atom at front (
-movflags +faststart)
The audio requirement trips people up. Even a video with no audible sound will fail if the container has an empty audio stream. You have to explicitly drop it.
Converting With ffmpeg
ffmpeg handles all of this. The tricky part is the crop. Vertical phone videos need centering before squaring, and some have black borders from export.
A two-pass approach: first detect the actual content bounds, then encode to spec.
Step 1: detect crop bounds
ffmpeg -i input.mov -vf cropdetect=24:2:0 -f null - 2>&1 | grep crop
This outputs something like crop=1080:1080:0:120. The cropdetect filter finds where actual image content starts and ends.
Step 2: encode to Telegram spec
ffmpeg -i input.mov \
-vf "crop=1080:1080:0:120,scale=800:800:flags=lanczos,format=yuv420p" \
-c:v libx264 \
-crf 28 \
-preset fast \
-an \
-t 10 \
-movflags +faststart \
output.mp4
Breaking that down:
-
crop=1080:1080:0:120uses the bounds from step 1 (adjust per video) -
scale=800:800:flags=lanczosresizes to exactly 800×800 -
format=yuv420pforces the pixel format -
-c:v libx264sets the H.264 encoder -
-crf 28trades quality for size (23 is ffmpeg default, 28 gets most clips under 2 MB) -
-androps audio, no exceptions -
-t 10caps at 10 seconds -
-movflags +faststartmoves the moov atom to front for streaming
For most iPhone clips (1080×1920), you can skip cropdetect entirely and hardcode crop=1080:1080:0:420 for a center-square crop.
The Aiogram 3 Handler
The bot receives a video or GIF, runs ffmpeg, sends the result back. Here's the core handler:
import asyncio
import os
import tempfile
from aiogram import Router, F
from aiogram.types import Message, BufferedInputFile
router = Router()
FFMPEG_CMD = [
"ffmpeg", "-y", "-i", "{input}",
"-vf", "crop=ih:ih,scale=800:800:flags=lanczos,format=yuv420p",
"-c:v", "libx264", "-crf", "28", "-preset", "fast",
"-an", "-t", "10", "-movflags", "+faststart",
"{output}"
]
@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message):
file_id = (
message.video or message.animation or message.document
).file_id
await message.answer("Converting...")
with tempfile.TemporaryDirectory() as tmp:
input_path = os.path.join(tmp, "input")
output_path = os.path.join(tmp, "output.mp4")
file = await message.bot.get_file(file_id)
await message.bot.download_file(file.file_path, input_path)
cmd = [
part.replace("{input}", input_path).replace("{output}", output_path)
for part in FFMPEG_CMD
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0 or not os.path.exists(output_path):
await message.answer("Conversion failed. Is this a valid video?")
return
size = os.path.getsize(output_path)
if size > 2 * 1024 * 1024:
await message.answer(f"File still too large ({size // 1024} KB). Try a shorter clip.")
return
with open(output_path, "rb") as f:
data = f.read()
await message.answer_document(
BufferedInputFile(data, filename="avatar.mp4"),
caption="Ready. Set this as your Telegram video avatar in profile settings."
)
The crop=ih:ih filter takes a center square without a cropdetect pre-pass. Works for most phone videos. For wide landscape clips with black bars, add a cropdetect step first.
Packaging It as @liveavabot
I wrapped this into a production bot, currently at 398 users. The core logic is exactly the code above, plus:
- A queue so multiple simultaneous uploads don't fork a hundred ffmpeg processes
- Size checks before downloading (Telegram caps bot file downloads at 20 MB)
- GIF support (Telegram delivers GIFs as
animationtype, same handler path) - A
/startmessage with explicit instructions
The ffmpeg piece is straightforward. Most of the production work was handling people who send 200 MB screen recordings and videos that are already H.264 but still fail because of an empty audio stream.
Built by me: @LiveAvaBot.
Edge Cases and Lessons
HEVC with Dolby Vision: some recent iPhones tag clips with Dolby Vision HDR metadata. libx264 doesn't support it and ffmpeg fails on the HDR pass. Adding -vf "zscale=transfer=709:primaries=709:matrix=709,format=yuv420p" before the scale step converts colorspace first. Rare, but worth knowing.
GIFs use more temp disk than you expect: a 3-second GIF can decode to 150 MB of raw frames before re-encoding. ffmpeg handles it, but disk usage spikes. Keep temp dirs on SSD if running this at any scale.
4K input is slow: the scale step on 4K source takes 10-15 seconds. Most avatar clips are phone-format anyway, so I haven't optimized for it. The bot accepts them but warns about conversion time.
CRF 28 isn't always enough: for dense 10-second 1080p clips, CRF 28 occasionally produces a file just over 2 MB. The bot checks size after conversion and replies with an error. Dropping to -t 8 or bumping to CRF 32 usually fixes it.
The main lesson: read the spec before debugging. Telegram's silent HEVC rejection is intentional on their end. ffmpeg is doing the heavy lifting here. I just wrote the wrapper.
Top comments (0)