DEV Community

liveavabot
liveavabot

Posted on

Converting iPhone HEVC Videos Into Telegram Video Avatars

The Problem Nobody Documents

Telegram video avatars loop silently on your profile when they work. But try uploading a video shot on an iPhone and Telegram silently ignores it. No error, nothing.

The culprit is HEVC (H.265). iPhone has defaulted to recording in HEVC since iOS 11. Telegram's video avatar endpoint only accepts H.264 with yuv420p colorspace, 800x800 resolution, no audio track, under 10 seconds, under 2 MB. Send anything else and the server accepts the upload but treats it as a regular file, not a video profile.

I ran into this while building @liveavabot. Users kept sending videos and asking why the bot wasn't working. Most of them were on iPhones.

What Telegram Actually Requires

The official TDLib docs are sparse, but testing against the actual endpoint reveals the constraints:

  • Codec: H.264 (libx264). Not H.265, VP9, or AV1.
  • Colorspace: yuv420p. Not yuv420p10le, not yuv444p.
  • Resolution: exactly 800x800 pixels.
  • Duration: 10 seconds maximum.
  • File size: 2 MB maximum.
  • Audio: none. Any audio track causes silent rejection.
  • Container: .mp4 with the faststart flag set.

The faststart flag trips people up. Without it, the moov atom sits at the end of the file. Telegram's server reads sequentially and gives up before it reaches the video metadata.

The ffmpeg Pipeline

Two passes: first detect crop bounds to remove letterboxing, then encode to spec.

Step 1: Detect crop bounds

ffmpeg -i input.mov -vf cropdetect=24:2:0 -t 5 -f null - 2>&1 | grep "crop="
# typical output: crop=1080:1080:0:0
Enter fullscreen mode Exit fullscreen mode

This runs 5 seconds through cropdetect. The output gives width, height, x offset, y offset of the content area. iPhone portrait video is usually already square, but landscape videos from other sources often have black bars baked in.

Step 2: Encode to spec

ffmpeg -i input.mov \
  -vf "crop=1080:1080:0:0,scale=800:800:force_original_aspect_ratio=decrease,pad=800:800:(ow-iw)/2:(oh-ih)/2,setsar=1" \
  -t 10 \
  -c:v libx264 -crf 28 -preset fast \
  -pix_fmt yuv420p \
  -an \
  -movflags +faststart \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

scale with force_original_aspect_ratio=decrease shrinks without stretching non-square content. pad centers the result in an 800x800 canvas with black fill if needed. setsar=1 resets the sample aspect ratio so downstream players don't miscalculate dimensions.

-crf 28 with -preset fast gives a reasonable size/quality tradeoff. For clips close to the 10-second limit you may need -crf 34 to stay under 2 MB.

-pix_fmt yuv420p explicitly forces 8-bit 4:2:0. iPhone ProRes and some HEVC recordings use 10-bit colorspace. libx264 can encode 10-bit, but Telegram's parser rejects yuv420p10le.

The aiogram 3 Handler

Here's a minimal version. The real bot adds a two-pass crop detection step and a job queue, but this covers the core conversion logic:

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

router = Router()

async def to_avatar(in_path: str, out_path: str, crf: int = 28) -> bool:
    cmd = [
        "ffmpeg", "-y", "-i", in_path,
        "-vf", (
            "scale=800:800:force_original_aspect_ratio=decrease,"
            "pad=800:800:(ow-iw)/2:(oh-ih)/2,setsar=1"
        ),
        "-t", "10",
        "-c:v", "libx264", "-crf", str(crf), "-preset", "fast",
        "-pix_fmt", "yuv420p", "-an",
        "-movflags", "+faststart",
        out_path,
    ]
    proc = await asyncio.create_subprocess_exec(
        *cmd, stderr=asyncio.subprocess.PIPE
    )
    await proc.communicate()
    return proc.returncode == 0 and os.path.getsize(out_path) <= 2 * 1024 * 1024

@router.message(F.video | F.document | F.animation)
async def handle_video(message: Message):
    status = await message.answer("Converting...")
    media = message.video or message.document or message.animation
    file = await message.bot.get_file(media.file_id)

    with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
        in_path = tmp.name
    out_path = in_path.replace(".mp4", "_avatar.mp4")

    try:
        await message.bot.download_file(file.file_path, in_path)
        ok = await to_avatar(in_path, out_path)
        if not ok:
            ok = await to_avatar(in_path, out_path, crf=34)
        if ok:
            await message.answer_video(FSInputFile(out_path))
            await status.delete()
        else:
            await status.edit_text("Conversion failed or output too large.")
    finally:
        for p in (in_path, out_path):
            try: os.unlink(p)
            except FileNotFoundError: pass
Enter fullscreen mode Exit fullscreen mode

F.animation handles GIFs. Telegram stores animated GIFs as MPEG4 internally, but if a GIF arrives as a file attachment rather than a native GIF send, it comes through as a Document type. Filtering on both document and animation covers most cases.

How @liveavabot Packages This

A few extra layers on top of the core conversion:

Queuing. ffmpeg is CPU-heavy. I use asyncio.Semaphore(3) to cap simultaneous conversions. Requests beyond that limit queue and wait.

Cooldown. A 60-second per-user cooldown prevents queue exhaustion. The bot runs on a single shared VPS.

Size retry. The handler above shows the crf fallback. The real implementation also tries tighter crop parameters before giving up on files that remain over 2 MB after crf 34.

Try it: https://t.me/LiveAvaBot?start=devto_article_20260929. Send any video or GIF and it returns an 800x800 H.264 file ready to set as a Telegram video avatar.

What I Learned

The biggest surprise was yuv420p10le. I spent an afternoon on a user report where output looked correct locally but Telegram rejected it. The video came from a ProRes recording. ffprobe showed pix_fmt=yuv420p in the output, but -pix_fmt yuv420p was silently falling back on some libx264 builds. Adding -vf format=yuv420p before the scale filter made the behavior consistent.

The crop detection pass was not in the original design. I added it after a batch of 16:9 landscape videos arrived with black bars baked into the sides. Without cropping first, the 800x800 output had black borders inside the frame.

GIFs are a minor ongoing edge case. Some large GIFs converted server-side by Telegram arrive as documents with mime_type=video/mp4 but without the animation flag properly set. The handler above covers most of these. The rest I log and review manually.

The bot has 433 users as of this writing. The most common use case, by far, is iPhone users who couldn't figure out why Telegram kept silently rejecting their video.


Built by me: @liveavabot

Top comments (0)