DEV Community

liveavabot
liveavabot

Posted on

Fixing iPhone HEVC for Telegram Video Avatars with ffmpeg

The Problem

I wanted to set a short video clip as my Telegram profile avatar. Telegram lets you do that. Upload an MP4, it trims to 10 seconds, done.

Except it doesn't always work. Drag in a clip recorded on an iPhone and Telegram just silently does nothing. No error, no feedback. The avatar doesn't change.

The issue is HEVC (H.265). iPhones have been shooting HEVC by default since iOS 11. Telegram's video avatar processor only accepts H.264. It rejects HEVC files without telling you why.

So I built a bot to handle it: @LiveAvaBot. Send it any video or GIF, get back a ready-to-upload avatar file.

Here's what I learned building it.

What Telegram Actually Requires

The official docs are thin on details. After testing, here's the full spec that actually works:

  • Codec: H.264 (libx264)
  • Pixel format: yuv420p (some decoders reject other formats)
  • Resolution: 800x800 exactly
  • Max duration: 10 seconds
  • Max file size: 2 MB
  • Audio: not allowed (strip it or the upload fails)
  • Container: MP4 with faststart flag (moov atom at the front)

The resolution requirement is the tricky one. Most videos aren't square. You can't just scale to 800x800 and call it done, you'll stretch faces. You need to crop first, then scale.

Squaring with ffmpeg

The full encode pipeline I use:

ffmpeg -i input.mov \
  -vf "crop=ih:ih:(iw-ih)/2:0,scale=800:800,format=yuv420p" \
  -c:v libx264 \
  -preset slow \
  -crf 23 \
  -t 10 \
  -an \
  -movflags +faststart \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

What each part does:

crop=ih:ih:(iw-ih)/2:0 crops a centered square. ih is input height, so this takes a square with side equal to the shorter dimension, centered horizontally. For portrait video where width is shorter, I swap the crop expression.

scale=800:800 scales to the required resolution.

format=yuv420p forces the pixel format. Without this, libx264 sometimes picks yuv444p, which Telegram's decoder rejects silently.

-t 10 hard-stops at 10 seconds.

-an strips audio. Required by Telegram's avatar spec.

-movflags +faststart moves the moov atom to the front of the file. Telegram needs this.

Handling iPhone HEVC and Rotation

iPhones record HEVC in a .mov container with a rotation metadata tag. Without special handling, ffmpeg sometimes produces rotated output because it reads the display rotation from metadata and applies it differently depending on the codec.

The fix: read the rotation tag with ffprobe first, then build the filter graph explicitly.

import subprocess, json

def get_rotation(path: str) -> int:
    result = subprocess.run(
        [
            "ffprobe", "-v", "quiet",
            "-select_streams", "v:0",
            "-show_entries", "stream_tags=rotate",
            "-of", "json",
            path,
        ],
        capture_output=True, text=True,
    )
    data = json.loads(result.stdout)
    tags = data.get("streams", [{}])[0].get("tags", {})
    return int(tags.get("rotate", 0))


def build_vf(path: str) -> str:
    rot = get_rotation(path)
    transpose_map = {90: "transpose=1,", 180: "hflip,vflip,", 270: "transpose=2,"}
    pre = transpose_map.get(rot, "")
    return f"{pre}crop=ih:ih:(iw-ih)/2:0,scale=800:800,format=yuv420p"
Enter fullscreen mode Exit fullscreen mode

Pass -noautorotate to ffmpeg alongside the generated -vf string. This gives consistent output across iPhone portrait, landscape, and selfie-mode clips.

The aiogram 3 Handler

The bot is built with aiogram 3. The core handler:

from aiogram import Router, F, types
from aiogram.types import FSInputFile
import tempfile, os

router = Router()

@router.message(F.video | F.document | F.animation)
async def handle_media(message: types.Message) -> None:
    media = message.video or message.document or message.animation
    if media is None:
        return

    with tempfile.TemporaryDirectory() as tmp:
        src = os.path.join(tmp, "input")
        dst = os.path.join(tmp, "avatar.mp4")

        await message.bot.download(file=media.file_id, destination=src)

        ok = encode_avatar(src, dst)
        if not ok:
            await message.answer("Could not convert this file.")
            return

        size = os.path.getsize(dst)
        if size > 2 * 1024 * 1024:
            await message.answer(f"Output is {size // 1024} KB, over the 2 MB limit.")
            return

        await message.answer_video(
            FSInputFile(dst),
            caption="Save this, then set as your Telegram video avatar.",
        )
Enter fullscreen mode Exit fullscreen mode

F.document catches videos sent as files, which happens when you forward from a camera roll without compression. F.animation catches GIFs (Telegram stores them as MP4 internally). Both go through the same pipeline.

The 2 MB check after encoding is a safety valve. CRF 23 at 800x800 for 10 seconds is usually under 1 MB, but very noisy source footage can push past the limit.

Packaging as @liveavabot

The bot is live at https://t.me/LiveAvaBot?start=devto_article_20261014. Send it any video, GIF, or file. No account, no signup required.

Built with aiogram 3, ffmpeg 6, running on a small Hetzner VPS. Currently at 458 registered users. Conversion is synchronous for now (the ffmpeg call blocks the handler). That's fine at this scale.

One thing I didn't anticipate: people sending 4K footage. ffmpeg handles it, but download plus encode takes a few seconds. I added send_chat_action(action="upload_video") before the ffmpeg call so the bot shows "Uploading video..." while it processes. Without that, the bot looks unresponsive.

Edge Cases and What's Next

A few things that bit me:

GIFs with transparency. Some animated GIFs have transparent frames. yuv420p doesn't support transparency, so ffmpeg fills those areas with black. Usually fine, occasionally ugly.

Square input. If the source is already 1:1 aspect ratio, crop=ih:ih:(iw-ih)/2:0 is a no-op (zero pixels cropped). ffmpeg handles it without a special case.

Very short clips. A 0.3-second clip produces a valid output but barely moves as an avatar. I added a warning for clips under 2 seconds but still process them.

File size spikes. A 10-second clip of fast-moving screen content at CRF 23 can hit 3-4 MB. I drop CRF to 28 on retry if the first encode comes out over 2 MB. That usually brings it under.

What's next: a web version with drag-and-drop conversion. The bot is convenient on mobile, but desktop users sometimes prefer not to open Telegram for file conversion.

Built by me. @LiveAvaBot

Top comments (0)