Why Your iPhone Video Gets Silently Rejected
Your iPhone records in HEVC (H.265). Telegram video avatars require H.264. When you upload an HEVC file, Telegram accepts it, shows no error, and quietly uses a static placeholder instead. No feedback at all.
I ran into this trying to set a looping avatar on a secondary account. The file uploaded fine, the preview thumbnail appeared, but the avatar never animated. After some digging I found the actual spec.
What Telegram Actually Requires
The Bot API and client enforce the same constraints:
- Codec: H.264 (yuv420p color space specifically)
- Resolution: 800x800 pixels
- Duration: 10 seconds max
- File size: 2MB max
- Audio: the stream must not exist, not just be muted
HEVC fails at the first constraint. Portrait video (1080x1920) fails the second unless you square-crop it. And a lot of "short" clips are still over 10MB at original quality, so naive trimming doesn't save you either.
The audio one is subtle. You'd think muting is enough. It isn't. Telegram checks for the presence of an audio stream, not whether it's silent. -c:a copy with a muted track still gets rejected.
The ffmpeg Pipeline
Three steps: detect and strip letterboxing, square-crop and scale to 800x800, re-encode to H.264 yuv420p.
# detect black bars (sample first 5 seconds, look for crop= in output)
ffmpeg -i input.mp4 -vf cropdetect=24:16:0 -t 5 -f null - 2>&1 | grep crop
# encode: crop to square, scale, H.264 baseline, strip audio
ffmpeg -i input.mp4 \
-vf "crop=iw:iw,scale=800:800:flags=lanczos" \
-c:v libx264 -profile:v baseline -level 3.0 \
-pix_fmt yuv420p \
-t 10 \
-an \
-movflags +faststart \
-b:v 400k -maxrate 500k -bufsize 1000k \
output.mp4
-an removes the audio stream entirely. Without it, Telegram rejects the file silently, same as HEVC. -movflags +faststart moves the moov atom before the mdat so Telegram can read the metadata without buffering the whole file first. The 400k bitrate cap keeps most 10-second clips under 2MB.
crop=iw:iw takes a square from the top of portrait video. For landscape it takes the left square. Not ideal for every shot but correct for 90% of cases. Run the cropdetect pass first if you suspect black bars from a widescreen source.
For HEVC specifically, ffmpeg handles the decode transparently with libx265 on the input side. You don't need to specify the input codec, ffmpeg detects it. The output codec is what matters.
Minimal aiogram 3 Handler
The bot accepts video files, GIFs (Telegram sends these as animation type), and generic documents. Here's the core handler.
from aiogram import Router, F
from aiogram.types import Message, BufferedInputFile
import asyncio, tempfile, os
router = Router()
@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message):
if message.video:
file_id = message.video.file_id
elif message.animation:
file_id = message.animation.file_id
else:
file_id = message.document.file_id
await message.reply("Converting...")
with tempfile.TemporaryDirectory() as tmpdir:
src = os.path.join(tmpdir, "input")
dst = os.path.join(tmpdir, "output.mp4")
file = await message.bot.get_file(file_id)
await message.bot.download_file(file.file_path, src)
ok = await convert_to_avatar(src, dst)
if not ok or not os.path.exists(dst):
await message.reply("Couldn't convert this. Try a clip under 10s.")
return
size = os.path.getsize(dst)
if size > 2 * 1024 * 1024:
await message.reply(f"Output is {size // 1024}KB, still over 2MB. Try a shorter clip.")
return
with open(dst, "rb") as f:
await message.reply_video(
BufferedInputFile(f.read(), filename="avatar.mp4"),
caption="Set this as your video avatar in Telegram profile settings."
)
convert_to_avatar runs the ffmpeg subprocess via asyncio.create_subprocess_exec and returns a bool. The whole chain is async so the bot doesn't block during conversion. On a 2-core VPS, a 10-second 4K HEVC clip takes 8-12 seconds to process.
Packaging It as a Bot
I wrapped this into @liveavabot: https://t.me/LiveAvaBot?start=devto_article_20261111. It's been running for a few months now. 519 users, with slow organic growth from people sharing it in Telegram groups.
The infra is a Hetzner CX22 (2 vCPU, 4GB RAM). I added an asyncio.Semaphore(3) to cap concurrent conversions and avoid OOM during bursts. Three simultaneous ffmpeg processes decoding 4K is already close to the RAM limit.
Temp files live in TemporaryDirectory contexts and get cleaned up after each reply. No files stored permanently. The only persistent state is a SQLite database for rate limiting, cooldowns, and usage stats.
The Bot API caps file downloads at 20MB without a local Bot API server. I surface this as a clear error message instead of letting the request silently time out.
Edge Cases Worth Knowing
GIFs arrive as animation type in aiogram. They're already MP4 internally (Telegram converts on upload) but sometimes have missing or inconsistent timestamps. Adding -vsync vfr to the ffmpeg command fixes the occasional muxing error on these inputs.
Audio removal is not optional, as covered above. I had a flag early on to pass audio through for people who wanted sound. Telegram rejected every one of those files without explanation. Removed the flag entirely.
4K input decodes slowly. The scale step handles any source resolution since ffmpeg resamples anyway, but a 4K 60fps HEVC source takes longer to process than 1080p 30fps. Users who send large files get a longer wait with no progress feedback, which feels broken. I want to add a "still working..." update after 10 seconds.
Portrait video from newer iPhones sometimes arrives with a rotation metadata tag rather than actually rotated pixels. ffmpeg respects the tag by default with -noautorotate off, so the output is usually correct. Worth testing if you're seeing sideways results.
What I want to add next: a preview frame before final delivery so users can confirm the crop region looks right. Cropdetect outputs coordinates, but most people want to see the frame, not the numbers.
Built by me: @liveavabot.
Top comments (0)