DEV Community

liveavabot
liveavabot

Posted on

Why iPhone Videos Silently Fail as Telegram Avatars (FFmpeg Fix)

My girlfriend tried to set a video avatar on her Telegram profile last month. Fresh clip from her iPhone 15, under ten seconds, looked great. Telegram accepted the upload, spun for a moment, then did nothing. No error message, no broken thumbnail. Nothing.

I pulled the file onto my laptop and ran ffprobe. Codec: hevc. That was the whole bug. iPhones have recorded HEVC (H.265) by default since iOS 11, and Telegram's video avatar pipeline only takes H.264. Instead of saying so, it silently drops the file.

One ffmpeg command fixed her avatar. Then I figured other people hit this every day, so I wrapped the command in a Telegram bot. This post is the build log: the spec Telegram actually enforces, the ffmpeg pipeline, and the aiogram 3 code that glues it together.

What Telegram actually accepts

There is no single doc page listing the video avatar requirements, so I collected them through testing and scattered API notes:

  • Codec: H.264. HEVC, VP9 and AV1 get dropped without a word.
  • Pixel format: yuv420p. Ten-bit HEVC from newer iPhones decodes to yuv420p10le, which also fails.
  • Square frame. 800x800 is what the official clients upload.
  • Duration: 10 seconds max.
  • No audio track. Muting is not enough, the stream has to be gone.
  • Size: under roughly 2 MB uploads reliably.
  • moov atom at the front (faststart), so the preview renders before the file finishes downloading.

Miss any one of these and the feedback is identical: nothing happens.

The ffmpeg pipeline

Two passes. First cropdetect, because screen recordings and vertical videos carry black bars that waste the 2 MB budget:

ffmpeg -i input.mov -t 3 -vf "cropdetect=24:16:0" -f null - 2>&1 \
  | grep -o 'crop=[0-9:]*' | tail -1
Enter fullscreen mode Exit fullscreen mode

That prints something like crop=1080:1080:0:420. Feed it into the encode pass:

ffmpeg -y -i input.mov -t 9.5 \
  -vf "crop=1080:1080:0:420,scale=800:800,format=yuv420p" \
  -c:v libx264 -profile:v high -preset veryfast -crf 26 \
  -an -movflags +faststart \
  avatar.mp4
Enter fullscreen mode Exit fullscreen mode

Notes on the flags:

  • -t 9.5 trims just under the 10 second cap. Cutting at exactly 10.0 sometimes lands on 10.03 after keyframe alignment and gets refused.
  • format=yuv420p fixes the 10-bit iPhone case in the same filter chain.
  • -an removes the audio stream entirely.
  • -crf 26 lands a 9.5 second 800x800 clip around 1.5 MB. If the output is over 2 MB I re-run with crf 28, then 30. Three tries have covered every file so far.
  • +faststart moves the moov atom to the front.

ffmpeg is doing the heavy lifting here. I just wrote the wrapper.

The aiogram 3 handler

The minimal bot side. A router catches videos and GIFs, downloads them, shells out to ffmpeg, sends the result back:

import asyncio
import tempfile
from pathlib import Path

from aiogram import Bot, F, Router
from aiogram.types import FSInputFile, Message

router = Router()


@router.message(F.video | F.animation)
async def convert(message: Message, bot: Bot):
    media = message.video or message.animation
    if media.file_size and media.file_size > 50 * 1024 * 1024:
        await message.answer("Over 50 MB, send something smaller.")
        return

    with tempfile.TemporaryDirectory() as tmp:
        src = Path(tmp) / "src"
        dst = Path(tmp) / "avatar.mp4"
        await bot.download(media.file_id, destination=src)

        proc = await asyncio.create_subprocess_exec(
            "ffmpeg", "-y", "-i", str(src), "-t", "9.5",
            "-vf",
            "scale=800:800:force_original_aspect_ratio=increase,"
            "crop=800:800,format=yuv420p",
            "-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
            "-an", "-movflags", "+faststart", str(dst),
        )
        await proc.wait()

        if proc.returncode != 0 or not dst.exists():
            await message.answer("ffmpeg choked on that file, try another one.")
            return

        await message.answer_video(FSInputFile(dst), width=800, height=800)
Enter fullscreen mode Exit fullscreen mode

This lazy version skips cropdetect and uses force_original_aspect_ratio=increase plus a center crop. Good enough for most clips. The production bot runs the two-pass cropdetect flow and falls back to center crop when detection returns garbage.

One detail worth copying: asyncio.create_subprocess_exec instead of subprocess.run. The handler stays async, so one slow encode doesn't block updates from other users.

Turning it into @liveavabot

The wrapper became @LiveAvaBot. You send a video or GIF, it replies with a ready avatar file plus short instructions for setting it, since Telegram buries the video avatar option two menus deep.

Things I added on top of the core pipeline: a per-user queue so someone dumping ten videos doesn't starve everyone else, the crf retry loop for the 2 MB budget, and a duration probe before download, since there's no point pulling a 3 minute file to keep 9.5 seconds of it.

Current numbers, because build logs should have numbers: 234 users, 5 conversions in the last 24 hours. Not a rocket. But it fixes a real annoyance and runs mostly idle on a small VPS.

Disclosure: built by me. @liveavabot is my project and this article is part of building it in public.

Edge cases that bit me

  • Rotation metadata. iPhone clips are often stored sideways with a rotate tag. Modern ffmpeg autorotates, but one older distro build shipped sideways avatars until I noticed.
  • Odd dimensions. A GIF with a width like 479 makes libx264 refuse to encode, it wants even numbers. Scaling to 800x800 fixes that for free.
  • Alpha channels. Some webm files carry transparency and format=yuv420p flattens it to black. I now composite on white first.
  • Phantom audio. Some Android camera apps write an audio track with zero samples. -an kills it either way.

What's next

Hardware HEVC decode is the current experiment, since software decoding 4K60 iPhone footage is the slowest step in the pipeline. Animated WebP input is next on the list, people keep sending those.

If you've fought Telegram's other undocumented media specs (video sticker webm is a similar rabbit hole), I'd like to hear what you ran into.

Top comments (0)