DEV Community

liveavabot
liveavabot

Posted on

Converting iPhone HEVC Videos to Telegram Video Avatars With FFmpeg

The problem nobody explains

I recorded a short clip on my iPhone, opened Telegram, tapped my avatar, picked "Set video". Nothing happened. No error. No toast. The old avatar stayed. I tried again with a screen recording. Same silence.

Turns out Telegram has a very narrow spec for video avatars, and the iPhone camera app violates almost every part of it by default. iPhone shoots HEVC (H.265) in .mov. Telegram wants H.264 in .mp4. iPhone shoots portrait 1080x1920. Telegram wants 800x800 square. iPhone records with audio. Telegram wants no audio track at all. And there's a 2 MB hard cap on the file.

If any of these fail, Telegram just refuses without telling you why. I lost an hour before I read the raw Bot API docs and found the requirements buried in the setUserProfilePhoto notes.

I built a bot to fix this once and never think about it again. This post walks through what the spec actually is, how ffmpeg solves it, and the minimal aiogram 3 handler that ties it together.

The Telegram video avatar spec

Straight from testing and the Bot API source:

  • Container: mp4 with faststart (moov atom at the front)
  • Video codec: H.264, profile Main or Baseline, pixel format yuv420p
  • Resolution: exactly 800x800, square
  • Duration: 3 to 10 seconds
  • File size: 2 MB or less
  • Audio: no audio stream. Not muted. Removed.
  • Frame rate: 30 fps works, 60 is risky at this size cap

The two silent killers are HEVC and audio. If your file has an audio track, even a silent one, Telegram rejects the upload without any status message.

The ffmpeg pipeline

Two passes. First a cropdetect to figure out what's actually inside the frame (portrait iPhone videos leave huge black bars if you center-crop naively). Then the real encode.

ffmpeg -hide_banner -i input.mov \
  -vf cropdetect=24:16:0 -f null - 2>&1 | \
  grep -oP 'crop=\S+' | tail -n 1
Enter fullscreen mode Exit fullscreen mode

That prints something like crop=1080:1080:0:420. Feed it back into the real command:

ffmpeg -y -i input.mov \
  -t 10 \
  -vf "crop=1080:1080:0:420,scale=800:800:flags=lanczos,format=yuv420p,setsar=1" \
  -c:v libx264 -profile:v main -level 4.0 \
  -preset slow -crf 26 \
  -movflags +faststart \
  -an \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

Key flags:

  • -t 10 clips duration to 10s.
  • crop,scale in the same filter chain: crop to square first, scale to 800.
  • format=yuv420p forces the pixel format Telegram wants.
  • -an strips audio.
  • -movflags +faststart puts the moov atom up front so Telegram can start reading before the download completes.
  • -crf 26 is the sweet spot for staying under 2 MB on a 10s clip. If the input is a busy scene, drop to 28.

I also run ffprobe after encoding to double-check the file is actually under 2 MB. If it's over, I re-encode with -crf 30 and warn the user that quality took a hit.

Minimal aiogram 3 handler

The bot accepts video, document (with video mime), or animation (mp4 GIF). Here's the routing:

from aiogram import Router, F
from aiogram.types import Message, FSInputFile
from pathlib import Path
import asyncio, tempfile

router = Router()

@router.message(F.video | F.animation | F.document)
async def handle_media(msg: Message):
    file = msg.video or msg.animation or msg.document
    if file.file_size and file.file_size > 20 * 1024 * 1024:
        await msg.reply("File over 20 MB, please trim first.")
        return

    with tempfile.TemporaryDirectory() as tmp:
        src = Path(tmp) / "in.bin"
        dst = Path(tmp) / "out.mp4"
        await msg.bot.download(file, destination=src)

        ok = await convert_to_avatar(src, dst)
        if not ok:
            await msg.reply("Conversion failed. Try a shorter or simpler clip.")
            return

        await msg.reply_video(
            video=FSInputFile(dst),
            caption="Ready. In Telegram, tap your avatar then Set Video.",
        )

async def convert_to_avatar(src: Path, dst: Path) -> bool:
    crop = await detect_crop(src)
    vf = f"{crop},scale=800:800:flags=lanczos,format=yuv420p,setsar=1"
    proc = await asyncio.create_subprocess_exec(
        "ffmpeg", "-y", "-i", str(src),
        "-t", "10",
        "-vf", vf,
        "-c:v", "libx264", "-profile:v", "main", "-level", "4.0",
        "-preset", "slow", "-crf", "26",
        "-movflags", "+faststart",
        "-an",
        str(dst),
        stdout=asyncio.subprocess.DEVNULL,
        stderr=asyncio.subprocess.DEVNULL,
    )
    await proc.wait()
    return (
        proc.returncode == 0
        and dst.exists()
        and dst.stat().st_size < 2 * 1024 * 1024
    )
Enter fullscreen mode Exit fullscreen mode

detect_crop runs the cropdetect pass and returns the last crop= value. If none is found (already square input), it falls back to crop=in_h:in_h:(in_w-in_h)/2:0.

How I packaged this as @liveavabot

I put the whole pipeline behind a Telegram bot so people don't have to install ffmpeg or figure out flags. Send a video or GIF, get back a file ready to set as your avatar. Handles iPhone HEVC, portrait clips, over-long clips, oversized files. Link: https://t.me/LiveAvaBot?start=devto_article_20260819.

The bot runs in Python with aiogram 3 on a small VPS, using ffmpeg 6 from the system package. No queue for now, just async subprocess with a semaphore capping parallel encodes at 3 so a burst doesn't OOM the box.

Edge cases and lessons

A few things I got wrong the first time:

  • I forgot format=yuv420p at first. Some inputs (especially from cameras) come as yuv422p or yuv444p. H.264 Main profile at those pixel formats encodes fine but Telegram won't accept it.
  • -preset slow matters. -preset veryfast at the same CRF produces files roughly 30% larger, which pushes 10s clips over 2 MB.
  • Animation stickers exported as mp4 sometimes have zero-duration streams that break -t 10. I now clamp with a duration computed from ffprobe first.
  • If the input has a weird SAR (sample aspect ratio), the scale to 800x800 stretches. setsar=1 before scale fixes it.

Still on the todo list: 4K HDR clips need tone-mapping before H.264 encode or the colors look washed. I skip those for now and reply asking for a lower-res source.

That's the whole thing. ffmpeg is doing the heavy lifting, the bot is a thin wrapper that hides the flags. If you're building anything similar, the cropdetect + explicit pixel format + -an combo is the part I'd focus on first.

Built by me, @liveavabot.

Top comments (0)