DEV Community

liveavabot
liveavabot

Posted on

How I Converted iPhone HEVC Videos Into Telegram Video Avatars

The pain nobody mentions

I recorded a short clip on my iPhone, dragged it into the Telegram "set video avatar" dialog, and nothing happened. The app accepted the file, pretended to upload it, then quietly fell back to a still frame. No error message.

The problem turned out to be HEVC. iPhones record H.265 by default since iOS 11. Telegram's video avatar endpoint will not touch H.265. It expects H.264, square frame, under 10 seconds, under 2MB, no audio track. Nothing in the UI tells you this.

I lost two hours before I found the spec buried in a Telegram Core page. Then I spent a weekend building a bot that does the conversion in one tap. This post is the writeup.

What Telegram actually wants

The constraints for a Telegram video avatar are strict:

  • Codec: H.264, yuv420p pixel format.
  • Container: MP4 with faststart flag.
  • Resolution: square. Telegram officially supports 640x640, but 800x800 works fine and looks sharper.
  • Duration: 3 to 10 seconds.
  • Size: under 2MB.
  • Audio: must be removed entirely, not just muted.

If any one of these fails, Telegram silently reverts to the first frame as a static picture. No upload error. The video just does not play when someone taps your avatar.

Most phone-recorded clips fail on at least three of these: HEVC codec, non-square aspect ratio, audio present, size over 2MB.

FFmpeg does the heavy lifting

The conversion is a single ffmpeg pipeline. I run cropdetect first on a 2-second sample to pick a square crop centered on motion, then re-encode with a size target.

Here is the actual command the bot uses:

ffmpeg -y -i input.mov \\
  -vf "crop=min(iw\\,ih):min(iw\\,ih),scale=800:800,fps=30,format=yuv420p" \\
  -c:v libx264 -preset veryfast -crf 28 \\
  -an \\
  -movflags +faststart \\
  -t 9.5 \\
  output.mp4
Enter fullscreen mode Exit fullscreen mode

Breaking that down:

  • crop=min(iw,ih):min(iw,ih) makes the output square by taking the shortest side.
  • scale=800:800 resizes to 800x800.
  • fps=30 caps the framerate so a 60fps iPhone clip does not blow up the file size.
  • format=yuv420p is the pixel format Telegram wants. Without this, H.264 Main profile can still fail.
  • -c:v libx264 picks the right codec. HEVC input is transcoded here.
  • -preset veryfast -crf 28 is my size vs quality compromise. For a 10 second clip at 800x800, it lands around 1.5MB.
  • -an drops audio. Telegram requires this.
  • -movflags +faststart moves the moov atom to the front so Telegram can start playing before the whole file is downloaded.
  • -t 9.5 hard-caps duration to 9.5 seconds, under the 10s limit with a safety margin.

If the result is still over 2MB (long clips, lots of motion), I retry with -crf 32. Two retries at most, then I give up and tell the user to send a shorter clip.

The aiogram 3 handler

The bot runs on aiogram 3. The handler takes a video or document message, saves the file, runs ffmpeg, and sends the result back.

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

router = Router()

async def convert(src: Path, dst: Path, crf: int = 28) -> None:
    cmd = [
        "ffmpeg", "-y", "-i", str(src),
        "-vf",
        "crop=min(iw\\\\,ih):min(iw\\\\,ih),scale=800:800,"
        "fps=30,format=yuv420p",
        "-c:v", "libx264", "-preset", "veryfast", "-crf", str(crf),
        "-an", "-movflags", "+faststart", "-t", "9.5",
        str(dst),
    ]
    proc = await asyncio.create_subprocess_exec(
        *cmd,
        stdout=asyncio.subprocess.DEVNULL,
        stderr=asyncio.subprocess.PIPE,
    )
    _, err = await proc.communicate()
    if proc.returncode != 0:
        raise RuntimeError(err.decode()[-500:])

@router.message(F.video | F.animation | F.document)
async def handle_video(msg: Message) -> None:
    file = msg.video or msg.animation or msg.document
    if file.file_size and file.file_size > 50 * 1024 * 1024:
        await msg.reply("File is over 50MB, send a shorter clip.")
        return

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

        for crf in (28, 32):
            await convert(src, dst, crf=crf)
            if dst.stat().st_size <= 2 * 1024 * 1024:
                break
        else:
            await msg.reply("Could not get under 2MB. Try a shorter clip.")
            return

        await msg.answer_video(
            FSInputFile(dst),
            caption=(
                "Done. Open Telegram settings, tap your avatar, "
                "pick this video."
            ),
        )
Enter fullscreen mode Exit fullscreen mode

A few notes:

  • F.video | F.animation | F.document catches all three ways users send videos. GIFs arrive as animation. Dragged files arrive as document.
  • The 50MB cap is bot-side. Telegram's bot API maxes at 20MB for direct downloads, 50MB with a workaround. For larger files I use the full file API.
  • Running ffmpeg as a subprocess with asyncio.create_subprocess_exec keeps the event loop responsive. One worker per message is fine for low traffic. For higher volumes I would put ffmpeg behind a work queue.

Packaging this as a bot

I ran this as a local script for a week, converting clips for myself. Then I turned it into a public bot: @LiveAvaBot.

Current numbers: 441 users, running on a single Hetzner box. ffmpeg eats CPU for about 2 seconds per clip, which is the main bottleneck. Storage cost is zero because files are deleted right after upload.

The bot is free and has no account step. Send a video, get a converted file back. If you want the result applied automatically, the Bot API cannot change your profile picture for you, so you still have to tap "Set" in Telegram settings yourself.

Edge cases and what is next

Things that broke in the first week:

  • Portrait clips with text at the bottom got cropped weird. I added a cropdetect pass that biases toward the face region if one is detected. Still not perfect.
  • Very dark clips at crf 28 looked grainy. I bumped the minimum to crf 26 for clips under 5 seconds.
  • Telegram's animation type for GIFs sometimes has no audio stream, which made ffmpeg log scary warnings. The warnings are safe to ignore, but I had to parse stderr more carefully to catch real errors.
  • Some HEVC clips with 10-bit color (yuv420p10le) needed an explicit -pix_fmt yuv420p on top of the vf filter chain. Without it, libx264 complained about input format.

What I want to add next: a scene-aware auto-trim that picks the most interesting 9.5-second window instead of just taking the first 9.5 seconds. ffmpeg has select='gt(scene,0.3)' for this. I just have not shipped it yet.

Built by me, @LiveAvaBot.

Top comments (0)