DEV Community

liveavabot
liveavabot

Posted on

Fixing iPhone HEVC Videos for Telegram Video Avatars with ffmpeg

The Silent Failure

You shoot a clip on an iPhone, send it to Telegram, tap "Set as profile video" and nothing happens. No error message, no explanation. The avatar just doesn't update.

The cause is HEVC. Apple switched iPhones to H.265 recording by default starting with iOS 11. It saves storage and the quality is good. Telegram's profile video pipeline doesn't accept H.265. It receives the file, processes it, and silently discards it.

I ran into this while building a bot that converts videos for Telegram avatars. About half the test videos from iPhones were failing. I spent a day digging through the Telegram client source and testing edge cases until I understood the full spec.

What Telegram's Video Avatar Spec Actually Requires

Telegram doesn't publish this anywhere, but here's what I found through testing:

  • Codec: H.264 (AVC). Not H.265/HEVC, not VP9, not AV1.
  • Resolution: exactly 800x800 pixels, square crop.
  • Duration: 10 seconds max. It loops.
  • File size: under 2MB.
  • Pixel format: yuv420p. Not yuv422p, not yuv444p, not 10-bit.
  • Audio: none. An audio track causes silent rejection.

Every one of these is a silent failure mode. H.264 with yuv444p? Rejected. H.264 at 1080x800? Rejected. Correct format but with an audio track? Rejected. You get no feedback.

The ffmpeg Pipeline

The main challenge is squaring a non-square video without stretching it. I use cropdetect to find the content area first, then scale to 800x800.

# Step 1: detect crop bounds
ffmpeg -i input.mp4 -vf "cropdetect=24:16:0" -frames:v 1 -f null - 2>&1 | grep -oP "crop=\S+"
Enter fullscreen mode Exit fullscreen mode

That outputs something like crop=1080:1080:0:0. Then encode with those values:

ffmpeg -i input.mp4 \
  -vf "crop=1080:1080:0:0,scale=800:800,setsar=1,fps=30" \
  -c:v libx264 \
  -profile:v baseline \
  -level 3.0 \
  -pix_fmt yuv420p \
  -movflags +faststart \
  -an \
  -t 10 \
  -crf 28 \
  output.mp4
Enter fullscreen mode Exit fullscreen mode

The flags that matter:

  • -pix_fmt yuv420p: forces the pixel format Telegram requires. Don't skip this.
  • -profile:v baseline -level 3.0: maximally compatible H.264 profile.
  • -movflags +faststart: puts the MOOV atom at the front, required for streaming playback.
  • -an: strips all audio tracks.
  • -t 10: hard cap at 10 seconds.

For HEVC input, ffmpeg decodes it automatically during the input stage. No special flag needed on the input side. The -c:v libx264 on the output handles re-encoding regardless of source codec.

The Aiogram 3 Handler

I wrote the bot in Python with aiogram 3. The message handler accepts video, animation, or document types, downloads the file, runs ffmpeg, and returns the result.

@router.message(F.video | F.animation | F.document)
async def handle_video(message: Message, bot: Bot):
    if message.video:
        file_id = message.video.file_id
    elif message.animation:
        file_id = message.animation.file_id
    else:
        file_id = message.document.file_id

    file = await bot.get_file(file_id)
    if file.file_size and file.file_size > 50 * 1024 * 1024:
        await message.reply("File too large (50MB Bot API limit).")
        return

    with tempfile.TemporaryDirectory() as tmpdir:
        input_path = Path(tmpdir) / "input"
        output_path = Path(tmpdir) / "output.mp4"

        await bot.download_file(file.file_path, str(input_path))
        ok = await convert_to_avatar(input_path, output_path)

        if not ok:
            await message.reply("Conversion failed, try a different clip.")
            return

        if output_path.stat().st_size > 2 * 1024 * 1024:
            await message.reply("Output exceeds 2MB, try a shorter clip.")
            return

        await message.reply_video(
            FSInputFile(output_path),
            caption="Done. Set this as your Telegram profile video."
        )
Enter fullscreen mode Exit fullscreen mode

convert_to_avatar wraps the ffmpeg call in asyncio.create_subprocess_exec. Running it as a subprocess keeps the event loop unblocked during encodes, which typically take 2-4 seconds for a 10-second clip.

How I Packaged This as @liveavabot

The bot is live at https://t.me/LiveAvaBot?start=devto_article_20260918. Send it a video or GIF and it returns an 800x800 H.264 MP4 ready to set as a Telegram profile video.

Supported inputs:

  • iPhone HEVC videos (.mov, .mp4 with H.265)
  • GIFs (Telegram delivers these as animation type)
  • Vertical and horizontal videos (square-cropped, not stretched)
  • Documents (some clients forward videos as raw files rather than video messages)

The stack is Python, aiogram 3, ffmpeg, running on a small VPS. Current numbers: 409 total users, 4-5 new users per day.

Edge Cases That Bit Me

4K input ran out of memory. The VPS has 2GB RAM. Cropdetect on a 4K frame allocates enough to OOM the process. I added a pre-pass that checks resolution and downsamples anything above 1080p before the main encode.

Variable frame rate GIFs. Certain GIFs and Telegram animated stickers have irregular timing data that makes output duration unpredictable. Adding fps=30 to the filter chain normalizes this.

Output over 2MB. A 10-second 800x800 H.264 clip at CRF 28 typically lands around 1.2-1.8MB. High-motion clips push it over. I handle this with a second encode pass at CRF 35 if the first output exceeds 2MB.

Omitting -pix_fmt yuv420p on HDR input. HDR videos often use 10-bit yuv2020 color. Without the explicit pixel format flag, ffmpeg may preserve that, producing output Telegram silently rejects. The flag is not optional.

ffmpeg handles all the real work. I wrote the glue code and spent most of the time understanding the spec.

Built by me, @liveavabot on Telegram. If you've hit silent rejection with a different cause, I'd like to hear what fixed it.

Top comments (0)