<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: liveavabot</title>
    <description>The latest articles on DEV Community by liveavabot (@liveavabot).</description>
    <link>https://dev.to/liveavabot</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3881261%2F7c3ae75d-32b0-4012-b30c-77efe6621a40.jpg</url>
      <title>DEV Community: liveavabot</title>
      <link>https://dev.to/liveavabot</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/liveavabot"/>
    <language>en</language>
    <item>
      <title>Converting iPhone HEVC Videos to Telegram Video Avatars With FFmpeg</title>
      <dc:creator>liveavabot</dc:creator>
      <pubDate>Wed, 19 Aug 2026 12:03:05 +0000</pubDate>
      <link>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-50ol</link>
      <guid>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-50ol</guid>
      <description>&lt;h2&gt;
  
  
  The problem nobody explains
&lt;/h2&gt;

&lt;p&gt;I recorded a short clip on my iPhone, opened Telegram, tapped my avatar, picked "Set video". Nothing happened. No error. No toast. The old avatar stayed. I tried again with a screen recording. Same silence.&lt;/p&gt;

&lt;p&gt;Turns out Telegram has a very narrow spec for video avatars, and the iPhone camera app violates almost every part of it by default. iPhone shoots HEVC (H.265) in .mov. Telegram wants H.264 in .mp4. iPhone shoots portrait 1080x1920. Telegram wants 800x800 square. iPhone records with audio. Telegram wants no audio track at all. And there's a 2 MB hard cap on the file.&lt;/p&gt;

&lt;p&gt;If any of these fail, Telegram just refuses without telling you why. I lost an hour before I read the raw Bot API docs and found the requirements buried in the &lt;code&gt;setUserProfilePhoto&lt;/code&gt; notes.&lt;/p&gt;

&lt;p&gt;I built a bot to fix this once and never think about it again. This post walks through what the spec actually is, how ffmpeg solves it, and the minimal aiogram 3 handler that ties it together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Telegram video avatar spec
&lt;/h2&gt;

&lt;p&gt;Straight from testing and the Bot API source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Container: mp4 with faststart (moov atom at the front)&lt;/li&gt;
&lt;li&gt;Video codec: H.264, profile Main or Baseline, pixel format yuv420p&lt;/li&gt;
&lt;li&gt;Resolution: exactly 800x800, square&lt;/li&gt;
&lt;li&gt;Duration: 3 to 10 seconds&lt;/li&gt;
&lt;li&gt;File size: 2 MB or less&lt;/li&gt;
&lt;li&gt;Audio: no audio stream. Not muted. Removed.&lt;/li&gt;
&lt;li&gt;Frame rate: 30 fps works, 60 is risky at this size cap&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two silent killers are HEVC and audio. If your file has an audio track, even a silent one, Telegram rejects the upload without any status message.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ffmpeg pipeline
&lt;/h2&gt;

&lt;p&gt;Two passes. First a &lt;code&gt;cropdetect&lt;/code&gt; to figure out what's actually inside the frame (portrait iPhone videos leave huge black bars if you center-crop naively). Then the real encode.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-hide_banner&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="nv"&gt;cropdetect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;24:16:0 &lt;span class="nt"&gt;-f&lt;/span&gt; null - 2&amp;gt;&amp;amp;1 | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'crop=\S+'&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints something like &lt;code&gt;crop=1080:1080:0:420&lt;/code&gt;. Feed it back into the real command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"crop=1080:1080:0:420,scale=800:800:flags=lanczos,format=yuv420p,setsar=1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-profile&lt;/span&gt;:v main &lt;span class="nt"&gt;-level&lt;/span&gt; 4.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-preset&lt;/span&gt; slow &lt;span class="nt"&gt;-crf&lt;/span&gt; 26 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-t 10&lt;/code&gt; clips duration to 10s.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;crop,scale&lt;/code&gt; in the same filter chain: crop to square first, scale to 800.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;format=yuv420p&lt;/code&gt; forces the pixel format Telegram wants.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-an&lt;/code&gt; strips audio.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-movflags +faststart&lt;/code&gt; puts the moov atom up front so Telegram can start reading before the download completes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-crf 26&lt;/code&gt; is the sweet spot for staying under 2 MB on a 10s clip. If the input is a busy scene, drop to 28.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also run &lt;code&gt;ffprobe&lt;/code&gt; after encoding to double-check the file is actually under 2 MB. If it's over, I re-encode with &lt;code&gt;-crf 30&lt;/code&gt; and warn the user that quality took a hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal aiogram 3 handler
&lt;/h2&gt;

&lt;p&gt;The bot accepts video, document (with video mime), or animation (mp4 GIF). Here's the routing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FSInputFile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@router.message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_media&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_size&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;File over 20 MB, please trim first.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in.bin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;convert_to_avatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Conversion failed. Try a shorter or simpler clip.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;video&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;FSInputFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ready. In Telegram, tap your avatar then Set Video.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;convert_to_avatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;crop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;detect_crop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;vf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;crop&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;,scale=800:800:flags=lanczos,format=yuv420p,setsar=1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_subprocess_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-vf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;libx264&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-profile:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-preset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-crf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;26&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-movflags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+faststart&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-an&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEVNULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEVNULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;st_size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;detect_crop&lt;/code&gt; runs the cropdetect pass and returns the last &lt;code&gt;crop=&lt;/code&gt; value. If none is found (already square input), it falls back to &lt;code&gt;crop=in_h:in_h:(in_w-in_h)/2:0&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I packaged this as &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;I put the whole pipeline behind a Telegram bot so people don't have to install ffmpeg or figure out flags. Send a video or GIF, get back a file ready to set as your avatar. Handles iPhone HEVC, portrait clips, over-long clips, oversized files. Link: &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260819" rel="noopener noreferrer"&gt;https://t.me/LiveAvaBot?start=devto_article_20260819&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The bot runs in Python with aiogram 3 on a small VPS, using ffmpeg 6 from the system package. No queue for now, just async subprocess with a semaphore capping parallel encodes at 3 so a burst doesn't OOM the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases and lessons
&lt;/h2&gt;

&lt;p&gt;A few things I got wrong the first time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I forgot &lt;code&gt;format=yuv420p&lt;/code&gt; at first. Some inputs (especially from cameras) come as yuv422p or yuv444p. H.264 Main profile at those pixel formats encodes fine but Telegram won't accept it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-preset slow&lt;/code&gt; matters. &lt;code&gt;-preset veryfast&lt;/code&gt; at the same CRF produces files roughly 30% larger, which pushes 10s clips over 2 MB.&lt;/li&gt;
&lt;li&gt;Animation stickers exported as mp4 sometimes have zero-duration streams that break &lt;code&gt;-t 10&lt;/code&gt;. I now clamp with a duration computed from ffprobe first.&lt;/li&gt;
&lt;li&gt;If the input has a weird SAR (sample aspect ratio), the scale to 800x800 stretches. &lt;code&gt;setsar=1&lt;/code&gt; before scale fixes it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Still on the todo list: 4K HDR clips need tone-mapping before H.264 encode or the colors look washed. I skip those for now and reply asking for a lower-res source.&lt;/p&gt;

&lt;p&gt;That's the whole thing. ffmpeg is doing the heavy lifting, the bot is a thin wrapper that hides the flags. If you're building anything similar, the cropdetect + explicit pixel format + &lt;code&gt;-an&lt;/code&gt; combo is the part I'd focus on first.&lt;/p&gt;

&lt;p&gt;Built by me, &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>ffmpeg</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Converting iPhone HEVC to Telegram Video Avatars with FFmpeg</title>
      <dc:creator>liveavabot</dc:creator>
      <pubDate>Mon, 17 Aug 2026 11:04:33 +0000</pubDate>
      <link>https://dev.to/liveavabot/converting-iphone-hevc-to-telegram-video-avatars-with-ffmpeg-3f7h</link>
      <guid>https://dev.to/liveavabot/converting-iphone-hevc-to-telegram-video-avatars-with-ffmpeg-3f7h</guid>
      <description>&lt;h2&gt;
  
  
  The problem: Telegram silently rejects your iPhone videos
&lt;/h2&gt;

&lt;p&gt;If you have tried uploading an iPhone video as your Telegram profile picture (the animated one), you know the deal. The upload goes through. The progress bar fills up. Then nothing. Your avatar stays as it was. No error, no toast, no explanation.&lt;/p&gt;

&lt;p&gt;The video looked fine in your camera roll. It plays in Photos. It plays in QuickTime. But Telegram treats it like it does not exist.&lt;/p&gt;

&lt;p&gt;The reason is HEVC. Since iOS 11, iPhones record video in HEVC (H.265) by default. Telegram's video avatar pipeline expects H.264. When the codec check fails, the server drops the file on the floor without telling the client.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260817" rel="noopener noreferrer"&gt;@LiveAvaBot&lt;/a&gt; to fix this. Send it any video or GIF, get back a valid Telegram video avatar. This is a walkthrough of what the format actually requires and how ffmpeg gets you there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Telegram video avatar spec actually requires
&lt;/h2&gt;

&lt;p&gt;Telegram does not publish this as a single page, but from bot testing and API docs, here is what a video avatar has to be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Container: MP4&lt;/li&gt;
&lt;li&gt;Video codec: H.264 (avc1), yuv420p pixel format&lt;/li&gt;
&lt;li&gt;Resolution: exactly 800x800, square&lt;/li&gt;
&lt;li&gt;Duration: 10 seconds maximum&lt;/li&gt;
&lt;li&gt;File size: 2 MB maximum&lt;/li&gt;
&lt;li&gt;Audio: none, must be stripped&lt;/li&gt;
&lt;li&gt;Faststart: moov atom at the front&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Miss any of these and the upload either fails silently (codec, resolution) or gets rejected with a size error (over 2 MB). The 2 MB cap is the tightest constraint. At 800x800 H.264 you have roughly 200 KB per second to work with for 10 seconds of video.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cropping to square without cutting off the subject
&lt;/h2&gt;

&lt;p&gt;Most phone videos are 9:16 or 16:9. You cannot just resize to 800x800, that stretches faces into pancakes. You need a center crop to a square, then scale down.&lt;/p&gt;

&lt;p&gt;FFmpeg has a &lt;code&gt;cropdetect&lt;/code&gt; filter that can find content bounds, but for phone video the whole frame is usually the content. The pragmatic approach is a centered square crop based on the shorter side.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"crop='min(iw,ih)':'min(iw,ih)':(iw-min(iw,ih))/2:(ih-min(iw,ih))/2,scale=800:800,format=yuv420p"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-profile&lt;/span&gt;:v high &lt;span class="nt"&gt;-level&lt;/span&gt; 4.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="nt"&gt;-crf&lt;/span&gt; 28 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking that down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;crop=min(iw,ih):min(iw,ih):...&lt;/code&gt; takes a centered square from the smallest dimension of the source.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scale=800:800&lt;/code&gt; downsamples to Telegram's exact resolution.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;format=yuv420p&lt;/code&gt; forces the pixel format Telegram expects. Without this you might end up with yuv420p10le on some HDR sources and the file will be rejected.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-c:v libx264&lt;/code&gt; re-encodes to H.264 regardless of input codec, which handles the HEVC case.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-crf 28&lt;/code&gt; targets decent quality that fits in 2 MB for most 10 second clips. Bump to 30 if you overshoot, drop to 24 if you have headroom.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-movflags +faststart&lt;/code&gt; puts the moov atom at the start so Telegram can begin decoding without a full download.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-an&lt;/code&gt; strips audio.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-t 10&lt;/code&gt; caps duration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you still bust the 2 MB budget, the next lever is a bitrate cap with &lt;code&gt;-b:v 1500k -maxrate 1500k -bufsize 3000k&lt;/code&gt; and a second pass. For 90% of iPhone clips the crf 28 single pass is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The aiogram 3 handler
&lt;/h2&gt;

&lt;p&gt;I built the bot on aiogram 3. Here is the trimmed video handler.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FSInputFile&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;FFMPEG_TEMPLATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{input}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-vf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crop=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;min(iw,ih)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;min(iw,ih)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;:(iw-min(iw,ih))/2:(ih-min(iw,ih))/2,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scale=800:800,format=yuv420p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;libx264&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-profile:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-preset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;veryfast&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-crf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;28&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-movflags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+faststart&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-an&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{output}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nd"&gt;@router.message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in.bin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;FFMPEG_TEMPLATE&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_subprocess_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEVNULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="n"&gt;size_mb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;st_size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;size_mb&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Output is &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;size_mb&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; MB, over the 2 MB cap. Try a shorter clip.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FSInputFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Upload this as your profile video.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I accept &lt;code&gt;document&lt;/code&gt; too because iOS sometimes sends MOV files as documents when the size is above a threshold.&lt;/li&gt;
&lt;li&gt;The reply is &lt;code&gt;reply_video&lt;/code&gt;, not &lt;code&gt;reply_document&lt;/code&gt;. Telegram will show the correct preview and the user can forward it straight to their profile settings.&lt;/li&gt;
&lt;li&gt;The 2 MB check is a client side courtesy. Telegram will reject bigger files, but the error the user sees is cleaner if I catch it first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Packaging it as &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;The full bot lives at &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260817" rel="noopener noreferrer"&gt;https://t.me/LiveAvaBot?start=devto_article_20260817&lt;/a&gt;. It runs on a small VPS, uses aiogram 3, and stores nothing except a per user conversion counter for rate limiting. The ffmpeg call is the same as above, plus a queue so a burst of uploads does not spawn 20 parallel encodes on a 2 vCPU box.&lt;/p&gt;

&lt;p&gt;Things I punted on for v1:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;4K HDR sources with wide color gamut. Right now they get tone mapped to SDR with mixed results.&lt;/li&gt;
&lt;li&gt;Vertical crop mode. Center square works for most clips but a face detector would beat it for portrait video.&lt;/li&gt;
&lt;li&gt;GIF audio. GIFs do not have audio, so &lt;code&gt;-an&lt;/code&gt; is a no op, but Telegram animation files can carry audio. It gets stripped anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;p&gt;The main takeaway from shipping this: ffmpeg is doing 95% of the work, I just wrote a wrapper. The tricky part was not the encoding, it was figuring out the exact spec Telegram wants. There is no error message when you get it wrong. The upload succeeds, the avatar does not change, and you go debug for an hour.&lt;/p&gt;

&lt;p&gt;If you are building anything that produces media for Telegram, test with &lt;code&gt;getUserProfilePhotos&lt;/code&gt; after the upload. If the new avatar is not there, your file was silently dropped, and you have a spec bug somewhere.&lt;/p&gt;

&lt;p&gt;Built by me, &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;: &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260817" rel="noopener noreferrer"&gt;https://t.me/LiveAvaBot?start=devto_article_20260817&lt;/a&gt;&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>ffmpeg</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I Built a Telegram Video Avatar Converter with FFmpeg</title>
      <dc:creator>liveavabot</dc:creator>
      <pubDate>Sat, 15 Aug 2026 10:02:25 +0000</pubDate>
      <link>https://dev.to/liveavabot/how-i-built-a-telegram-video-avatar-converter-with-ffmpeg-4jlh</link>
      <guid>https://dev.to/liveavabot/how-i-built-a-telegram-video-avatar-converter-with-ffmpeg-4jlh</guid>
      <description>&lt;h2&gt;
  
  
  The problem: iPhone videos silently fail as Telegram avatars
&lt;/h2&gt;

&lt;p&gt;Telegram lets you set a short video as your profile picture. It plays in a loop on your profile page. Neat feature. But if you record a clip on iPhone and try to upload it, Telegram either rejects the upload with no error or shows a spinner forever. No message. Just nothing.&lt;/p&gt;

&lt;p&gt;The reason: modern iPhones record in HEVC (H.265), and Telegram's video avatar endpoint only accepts H.264 in an MP4 container. The client does not warn you. The server just drops the upload on the floor.&lt;/p&gt;

&lt;p&gt;I hit this exact wall trying to set my own avatar with a clip from my phone. So I built a bot that takes any video or GIF, re-encodes it to Telegram's spec, and sends it back. I named it &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Telegram actually wants for a video avatar
&lt;/h2&gt;

&lt;p&gt;The Telegram spec for video profile pictures (also called animated avatars) is narrow and mostly undocumented in the Bot API reference. Here is what I pieced together from trial and mobile client source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Container: MP4 with the faststart flag (moov atom at the front)&lt;/li&gt;
&lt;li&gt;Video codec: H.264 (avc1), yuv420p pixel format&lt;/li&gt;
&lt;li&gt;Resolution: 800 by 800 pixels, square, no letterbox&lt;/li&gt;
&lt;li&gt;Duration: 3 to 10 seconds&lt;/li&gt;
&lt;li&gt;Frame rate: 30 fps or lower&lt;/li&gt;
&lt;li&gt;Audio: must be removed entirely, not just muted&lt;/li&gt;
&lt;li&gt;File size: under 2 MB, and this is the mean one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Miss any of these and the upload fails silently. The 2 MB cap is the hard part, because 10 seconds of decent-quality 800x800 H.264 usually blows past that if you use default settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Solving it with ffmpeg cropdetect and scale
&lt;/h2&gt;

&lt;p&gt;The cropping is the interesting bit. Most user videos are portrait or landscape, not square. If you naive-scale a 1080x1920 clip to 800x800, it looks stretched and awful. You need to crop to a centered square first, then scale down.&lt;/p&gt;

&lt;p&gt;ffmpeg has a &lt;code&gt;cropdetect&lt;/code&gt; filter that scans a video and tells you the largest non-black rectangle. In practice, for user avatars, I found a simpler expression works better: crop to the shorter side, centered. Here is the actual command the bot runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"crop='min(iw,ih)':'min(iw,ih)',scale=800:800,fps=30,format=yuv420p"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-profile&lt;/span&gt;:v baseline &lt;span class="nt"&gt;-level&lt;/span&gt; 3.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="nt"&gt;-crf&lt;/span&gt; 28 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-maxrate&lt;/span&gt; 1500k &lt;span class="nt"&gt;-bufsize&lt;/span&gt; 3000k &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breakdown:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-t 10&lt;/code&gt; clamps duration to 10 seconds&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;crop='min(iw,ih)':'min(iw,ih)'&lt;/code&gt; picks the shorter side and cuts a centered square&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scale=800:800&lt;/code&gt; resizes to Telegram's target&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fps=30&lt;/code&gt; caps frame rate at 30&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;format=yuv420p&lt;/code&gt; forces the pixel format Telegram needs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-profile:v baseline -level 3.1&lt;/code&gt; maximizes decoder compatibility on older devices&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-crf 28&lt;/code&gt; gives a decent quality-to-size tradeoff&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-maxrate 1500k&lt;/code&gt; keeps peak bitrate under control so the file stays small&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-movflags +faststart&lt;/code&gt; moves the moov atom to the front so it is streamable&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-an&lt;/code&gt; drops audio completely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;-crf 28 -maxrate 1500k&lt;/code&gt; combo is what I settled on after testing a couple hundred clips. Lower CRF means better quality but bigger files. 28 with a 1500k ceiling keeps most 10-second clips under 2 MB without looking terrible. If a clip still comes out too big (happens with high-motion content), the bot re-encodes with CRF 32 as a fallback pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The aiogram 3 handler
&lt;/h2&gt;

&lt;p&gt;Wrapping this in a Telegram bot is straightforward with aiogram 3. Here is the core handler that receives a video or animation and returns the converted file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FSInputFile&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_ffmpeg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-vf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crop=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;min(iw,ih)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;min(iw,ih)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,scale=800:800,fps=30,format=yuv420p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;libx264&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-profile:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;baseline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-preset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;veryfast&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-crf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;28&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-maxrate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1500k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-bufsize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-movflags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+faststart&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-an&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_subprocess_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEVNULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nd"&gt;@router.message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;file_obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;file_obj&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in.bin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_ffmpeg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;FSInputFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Open Settings, Edit profile, tap avatar to set.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I accept &lt;code&gt;F.document&lt;/code&gt; too, because iPhone users often send clips as documents when sharing from Files&lt;/li&gt;
&lt;li&gt;The temp directory auto-cleans on exit, so no leaked files build up on disk&lt;/li&gt;
&lt;li&gt;The ffmpeg stderr gets truncated to 500 chars if things blow up, keeps error logs sane&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Packaging it as &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;The full bot adds a few things around this core:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;File size check up front (Telegram caps user uploads at 20 MB for bots without the local API server)&lt;/li&gt;
&lt;li&gt;HEVC detection via &lt;code&gt;ffprobe&lt;/code&gt; before conversion, so I can log how often iPhone clips show up (spoiler: most of them)&lt;/li&gt;
&lt;li&gt;A rate limit per user, because someone will always try to hammer it&lt;/li&gt;
&lt;li&gt;Storage of user IDs in SQLite for a rough monthly-active count, no other tracking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deployed on a small VPS. ffmpeg is doing all the heavy lifting, the Python wrapper is maybe 400 lines. Cost per conversion is negligible, most clips encode in under 3 seconds on 2 vCPUs.&lt;/p&gt;

&lt;p&gt;Try it here: &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260815" rel="noopener noreferrer"&gt;https://t.me/LiveAvaBot?start=devto_article_20260815&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases and what is next
&lt;/h2&gt;

&lt;p&gt;Things that still trip the bot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;4K HDR videos take forever to encode and sometimes hit the 2 MB ceiling even at CRF 32. I need to add a resolution pre-check and downscale before the main pass.&lt;/li&gt;
&lt;li&gt;Very short clips (under 3 seconds) get rejected by Telegram because they violate the minimum duration. I should loop them automatically instead of erroring out.&lt;/li&gt;
&lt;li&gt;Some Android phones send WebM animations that trigger a weird cropdetect result on the very first frame. Worked around it with &lt;code&gt;-ss 0.5&lt;/code&gt; to skip the first half-second, but it is a hack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Telegram spec for animated profile pictures is not fully documented anywhere I could find. I dug most of it out of failed uploads and the mobile client. If you want to build something similar, budget a weekend just for figuring out what the server actually accepts.&lt;/p&gt;

&lt;p&gt;Built by me, &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;. Feedback and PRs welcome.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>ffmpeg</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Converting iPhone HEVC Videos to Telegram Video Avatars With ffmpeg</title>
      <dc:creator>liveavabot</dc:creator>
      <pubDate>Thu, 13 Aug 2026 09:03:38 +0000</pubDate>
      <link>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-1n34</link>
      <guid>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-1n34</guid>
      <description>&lt;h2&gt;
  
  
  The Silent Failure Nobody Warns You About
&lt;/h2&gt;

&lt;p&gt;You take a nice short clip on your iPhone. You open Telegram, go to Settings, tap your avatar, choose "Set Video". The upload spins. Then... nothing. No error, no toast, just the old picture staring back at you.&lt;/p&gt;

&lt;p&gt;I hit this three times before I realized what was happening. iPhone records video in HEVC (H.265) inside a &lt;code&gt;.mov&lt;/code&gt; container. Telegram's video avatar accepts H.264 in MP4. So the app quietly drops your upload on the floor.&lt;/p&gt;

&lt;p&gt;That was the seed for &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;. Send it any clip, get back a file Telegram will actually accept.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Telegram Video Avatar Spec Actually Requires
&lt;/h2&gt;

&lt;p&gt;The official rules are scattered across the Bot API docs and community reverse-engineering. Here is what I settled on after testing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Container: MP4 with &lt;code&gt;faststart&lt;/code&gt; (moov atom at the start).&lt;/li&gt;
&lt;li&gt;Video codec: H.264, baseline or main profile, yuv420p pixel format.&lt;/li&gt;
&lt;li&gt;Resolution: square, 800x800 is the sweet spot. 640x640 works too.&lt;/li&gt;
&lt;li&gt;Duration: up to 10 seconds (Telegram trims silently past that).&lt;/li&gt;
&lt;li&gt;File size: under 2 MB. Above ~2.5 MB the client rejects it.&lt;/li&gt;
&lt;li&gt;Audio: none. Any audio track and the client treats it as a regular video.&lt;/li&gt;
&lt;li&gt;Framerate: 30 fps caps out cleanly. 60 fps sometimes glitches.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Miss any one of these and the upload fails without an error message. Not great.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ffmpeg Pipeline
&lt;/h2&gt;

&lt;p&gt;The whole conversion is three steps: detect a square crop, scale to 800x800, encode to H.264 with the right flags. All of it fits in one ffmpeg invocation.&lt;/p&gt;

&lt;p&gt;First I run cropdetect to find a reasonable square region. Portrait videos need cropping to a square, not just scaling (else your face gets squished):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-hide_banner&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"cropdetect=24:16:0"&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 3 &lt;span class="nt"&gt;-f&lt;/span&gt; null - 2&amp;gt;&amp;amp;1 &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'crop=\S+'&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returns something like &lt;code&gt;crop=1080:1080:0:420&lt;/code&gt;. Feed that into the real pass, forcing 800x800, no audio, yuv420p, and faststart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"crop=1080:1080:0:420,scale=800:800:flags=lanczos,fps=30"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-profile&lt;/span&gt;:v main &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="nt"&gt;-crf&lt;/span&gt; 26 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important flags nobody documents together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-pix_fmt yuv420p&lt;/code&gt;: without this some Telegram clients render green frames.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-movflags +faststart&lt;/code&gt;: puts the moov atom first so the client can preview before downloading the full file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-an&lt;/code&gt;: drops audio. Non-negotiable for the avatar spec.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-crf 26&lt;/code&gt;: quality/size tradeoff. 26 keeps 10s under 2 MB for most content.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the output still lands above 2 MB, I re-encode with &lt;code&gt;-crf 30&lt;/code&gt;. Above 4 MB source, I do one warm-up pass at &lt;code&gt;-crf 32&lt;/code&gt; before falling back further.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring It Into aiogram 3
&lt;/h2&gt;

&lt;p&gt;The bot handler is small. Accept a video, animation, or document; run it through the pipeline; send back the MP4.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FSInputFile&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@router.message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_media&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_size&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Too big. 20 MB max input.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in.bin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;convert_to_avatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Conversion failed. Send a shorter clip.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;FSInputFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Upload as your Telegram avatar.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;convert_to_avatar&lt;/code&gt; function is just an &lt;code&gt;asyncio.create_subprocess_exec&lt;/code&gt; wrapper around the ffmpeg command above, with the cropdetect step first. Nothing fancy. ffmpeg is doing the heavy lifting, I just wrote the wrapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging It as &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Once the conversion worked reliably, I put a Telegram Stars paywall in front of it (25 stars per conversion, roughly 30 cents). The whole thing runs on one small VPS with a systemd unit, ffmpeg from the Debian repo, and aiogram 3 in a venv. No queue, no worker pool, no S3. When a message comes in, it converts on the box and sends the file back.&lt;/p&gt;

&lt;p&gt;Traffic is small enough (a few hundred users, single-digit concurrent conversions) that this is fine. If it grows past that, adding a worker queue is a two-file change.&lt;/p&gt;

&lt;p&gt;You can try it at &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260813" rel="noopener noreferrer"&gt;https://t.me/LiveAvaBot?start=devto_article_20260813&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases and What Is Next
&lt;/h2&gt;

&lt;p&gt;A few things bit me during the build.&lt;/p&gt;

&lt;p&gt;Videos rotated by EXIF: iPhone stores rotation as metadata, not in the pixel data. ffmpeg's &lt;code&gt;-vf&lt;/code&gt; chain runs before rotation is applied. Fix by prepending &lt;code&gt;transpose=&lt;/code&gt; based on &lt;code&gt;ffprobe&lt;/code&gt; orientation, or use &lt;code&gt;-noautorotate&lt;/code&gt; and handle it yourself.&lt;/p&gt;

&lt;p&gt;Animated GIFs with palette artifacts: convert through the &lt;code&gt;paletteuse&lt;/code&gt; filter first if colors look wrong, otherwise a straight &lt;code&gt;-i input.gif&lt;/code&gt; into the same H.264 pipeline works.&lt;/p&gt;

&lt;p&gt;HDR sources from newer iPhones: these have BT.2020 color space and look washed out after conversion. Tone-map with &lt;code&gt;zscale=t=linear:npl=100,tonemap=hable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Bots sending 4K: I hard-cap input at 20 MB, and if the resolution is over 1920x1920 I downscale before the crop pass to save CPU.&lt;/p&gt;

&lt;p&gt;What is next: batch conversion (send 3 clips, get 3 back), custom crop selection (tap the region you want), and maybe a web version for people who do not want to talk to a bot.&lt;/p&gt;

&lt;p&gt;Built by me. &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>ffmpeg</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Converting iPhone HEVC Videos to Telegram Video Avatars with ffmpeg</title>
      <dc:creator>liveavabot</dc:creator>
      <pubDate>Tue, 11 Aug 2026 08:03:58 +0000</pubDate>
      <link>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-166j</link>
      <guid>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-166j</guid>
      <description>&lt;h2&gt;
  
  
  The problem: your iPhone video will not upload as a Telegram avatar
&lt;/h2&gt;

&lt;p&gt;I recorded a 4-second clip on my iPhone last month, tried to set it as my Telegram profile video, and got nothing. No error, no toast, just silence. The upload dialog closed and my avatar stayed the same static image.&lt;/p&gt;

&lt;p&gt;Turns out Telegram's video avatar endpoint quietly rejects anything it cannot decode. iPhones record in HEVC (H.265) by default since iOS 11, and Telegram's avatar pipeline only accepts H.264. There is no error message, no fallback, no "please convert this first". It just fails.&lt;/p&gt;

&lt;p&gt;I built a small bot to fix this exact problem. Send it any video or GIF, get back a file that Telegram will actually accept as your profile animation. This post is the build log.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Telegram's video avatar spec actually wants
&lt;/h2&gt;

&lt;p&gt;The requirements are scattered across the Bot API reference and a few forum threads. Here is the full list I had to satisfy, discovered through trial and error:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Codec&lt;/strong&gt;: H.264 (libx264). HEVC, VP9, AV1 all rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container&lt;/strong&gt;: MP4 with the faststart flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pixel format&lt;/strong&gt;: yuv420p. yuv420p10le or yuv444p will not work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: exactly 800x800, square crop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration&lt;/strong&gt;: 10 seconds or less.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size&lt;/strong&gt;: under 2 MB. This is the killer constraint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio&lt;/strong&gt;: must be stripped. Even a silent audio track can cause issues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frame rate&lt;/strong&gt;: I clamp to 30 fps to save bytes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 2 MB cap is the hardest part. A 10-second 800x800 H.264 clip at reasonable quality is closer to 3 or 4 MB with default settings. You need aggressive bitrate control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ffmpeg pipeline
&lt;/h2&gt;

&lt;p&gt;Here is the actual command the bot runs. Breakdown below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"scale=800:800:force_original_aspect_ratio=increase,crop=800:800"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-profile&lt;/span&gt;:v baseline &lt;span class="nt"&gt;-level&lt;/span&gt; 3.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-r&lt;/span&gt; 30 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-b&lt;/span&gt;:v 1200k &lt;span class="nt"&gt;-maxrate&lt;/span&gt; 1400k &lt;span class="nt"&gt;-bufsize&lt;/span&gt; 2000k &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart &lt;span class="se"&gt;\&lt;/span&gt;
  output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Walking through it:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scale=800:800:force_original_aspect_ratio=increase,crop=800:800&lt;/code&gt; scales the shorter dimension to 800 then center-crops to a square. This preserves aspect ratio without stretching faces. For sources with black letterbox bars, I run &lt;code&gt;cropdetect=24:16:0&lt;/code&gt; as a separate probe pass first to get the real crop values, then feed those into the encode.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-c:v libx264 -profile:v baseline -level 3.1&lt;/code&gt; picks H.264 with the most compatible profile. Baseline profile means no B-frames, which decodes faster on old Android clients.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-pix_fmt yuv420p&lt;/code&gt; is non-negotiable. iPhone HEVC often uses yuv420p10le (10-bit), and Telegram silently rejects the output even after codec conversion if you leave the pixel format alone. Force 8-bit.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-b:v 1200k -maxrate 1400k -bufsize 2000k&lt;/code&gt; is the bitrate ceiling that keeps a 10-second clip under 2 MB. I landed on these values after about 40 test uploads.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-an&lt;/code&gt; strips audio. &lt;code&gt;-movflags +faststart&lt;/code&gt; moves the moov atom to the front so Telegram can start decoding before the file finishes downloading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The aiogram 3 handler
&lt;/h2&gt;

&lt;p&gt;I use aiogram 3 (async, type-hinted) for the Telegram side. The handler receives a video or animation, downloads it, runs ffmpeg, and sends the result back. Here is the core:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FSInputFile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@router.message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video_note&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;convert_to_avatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;file_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_id&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;file_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_id&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video_note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;file_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video_note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_id&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;file_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_id&lt;/span&gt;

    &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdtemp&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="nb"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_subprocess_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-vf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scale=800:800:force_original_aspect_ratio=increase,crop=800:800&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;libx264&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-profile:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;baseline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-pix_fmt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yuv420p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-b:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1200k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-maxrate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1400k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-bufsize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2000k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-an&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-movflags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+faststart&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEVNULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg failed. Try a shorter or lower-res clip.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="n"&gt;size_mb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;st_size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;size_mb&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Output is &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;size_mb&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; MB, over the 2 MB limit. Trim first.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;FSInputFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Set this as your profile video: open Settings, tap the avatar, pick Video.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes on this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;F.video | F.animation | F.video_note | F.document&lt;/code&gt; catches every file type users actually send. Documents matter because Telegram Desktop uploads MP4s as documents by default, not videos.&lt;/p&gt;

&lt;p&gt;I use &lt;code&gt;asyncio.create_subprocess_exec&lt;/code&gt; instead of &lt;code&gt;subprocess.run&lt;/code&gt; because the bot handles concurrent requests. Blocking on ffmpeg would freeze every other user's conversion.&lt;/p&gt;

&lt;p&gt;The 2 MB post-check is a safety net. My bitrate settings usually keep the file comfortably under, but a very high-motion 10-second clip can spike past. Rather than silently produce a file Telegram will reject, I return an explicit error and let the user trim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging it as &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;The bot is live at &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260811" rel="noopener noreferrer"&gt;@LiveAvaBot&lt;/a&gt; with 301 users so far. Behind the scenes it runs on a small VPS: one Python process, ffmpeg from apt, a SQLite database for user state and rate limits. Nothing fancy. The whole thing is under 2000 lines of Python.&lt;/p&gt;

&lt;p&gt;I added a few conveniences on top of the ffmpeg core:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HEVC pre-detection&lt;/strong&gt;: &lt;code&gt;ffprobe&lt;/code&gt; reads the codec first. If the input is already H.264 under 2 MB and 800x800, I skip re-encoding and just send it back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limit&lt;/strong&gt;: 5 conversions per user per hour. Prevents someone from hammering the queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GIF support&lt;/strong&gt;: &lt;code&gt;.gif&lt;/code&gt; inputs get treated as animations, output as MP4. Telegram avatars must be MP4, animated GIFs will not work as avatars directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Edge cases and what is next
&lt;/h2&gt;

&lt;p&gt;Things that still trip the bot up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4K vertical iPhone clips&lt;/strong&gt;: cropping to 800x800 loses too much face. I want to add a face-detection pass to center on the subject instead of the geometric center.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weird pixel formats&lt;/strong&gt;: some Android phones export in exotic YUV variants that ffmpeg auto-converts wrong. Forcing &lt;code&gt;-pix_fmt yuv420p&lt;/code&gt; early in the filter chain catches most of these.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent audio tracks&lt;/strong&gt;: &lt;code&gt;-an&lt;/code&gt; strips them, but I have seen odd cases where the video still fails until I re-mux with an explicit &lt;code&gt;-map 0:v:0&lt;/code&gt;. Not fully debugged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next up: a &lt;code&gt;/crop_square&lt;/code&gt; command that lets the user pick which part of a 16:9 video to keep, since automatic center-crop is not always right for portrait clips.&lt;/p&gt;

&lt;p&gt;Built by me, &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt; on Telegram.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>ffmpeg</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Converting iPhone HEVC Videos to Telegram Video Avatars with FFmpeg</title>
      <dc:creator>liveavabot</dc:creator>
      <pubDate>Sun, 09 Aug 2026 07:04:10 +0000</pubDate>
      <link>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-50k</link>
      <guid>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-50k</guid>
      <description>&lt;h2&gt;
  
  
  The problem: iPhone videos silently die as Telegram avatars
&lt;/h2&gt;

&lt;p&gt;Telegram lets you set a short video as your profile picture. It's a nice feature. But if you try to upload a video shot on an iPhone, it often just fails. No error, no explanation, the upload spinner runs and then nothing happens.&lt;/p&gt;

&lt;p&gt;I hit this myself last month. Shot a 4-second clip, tried to set it as my avatar, got nothing. Turned out the file was HEVC (H.265), which iPhones use by default since iOS 11. Telegram's video avatar endpoint only accepts H.264. It doesn't tell you that. It just silently rejects.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260809" rel="noopener noreferrer"&gt;@LiveAvaBot&lt;/a&gt;. Send it any video or GIF, get back a file that Telegram will actually accept as your profile picture. Under the hood it's a thin wrapper around ffmpeg with some aiogram glue. This post is the technical write-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Telegram's video avatar spec actually requires
&lt;/h2&gt;

&lt;p&gt;I dug through the Bot API docs and did a bunch of testing. The undocumented reality:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Codec: H.264 (yuv420p pixel format). HEVC gets rejected.&lt;/li&gt;
&lt;li&gt;Container: MP4 with faststart flag (moov atom at the front).&lt;/li&gt;
&lt;li&gt;Resolution: exactly 800x800 square. Non-square videos need cropping, not letterboxing.&lt;/li&gt;
&lt;li&gt;Duration: max 10 seconds. Anything longer gets truncated or rejected.&lt;/li&gt;
&lt;li&gt;File size: under 2MB. Bigger files fail silently.&lt;/li&gt;
&lt;li&gt;Audio: must be stripped. Even a silent audio track sometimes causes issues.&lt;/li&gt;
&lt;li&gt;Framerate: 30fps works, higher rates occasionally break.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 2MB cap is the interesting one. It means you can't just crank the bitrate. You have to actually think about the encoding.&lt;/p&gt;

&lt;h2&gt;
  
  
  FFmpeg: cropdetect, scale, encode
&lt;/h2&gt;

&lt;p&gt;The pipeline has two ffmpeg passes. First pass runs cropdetect to figure out where the actual content sits (some videos have black bars). Second pass does the real conversion.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="nv"&gt;cropdetect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;24:16:0 &lt;span class="nt"&gt;-f&lt;/span&gt; null - 2&amp;gt;&amp;amp;1 | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'crop=[0-9:]+'&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you something like &lt;code&gt;crop=1080:1080:0:420&lt;/code&gt;. Feed it back into the encode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"crop=1080:1080:0:420,scale=800:800:flags=lanczos,fps=30,format=yuv420p"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-profile&lt;/span&gt;:v high &lt;span class="nt"&gt;-level&lt;/span&gt; 4.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-b&lt;/span&gt;:v 1400k &lt;span class="nt"&gt;-maxrate&lt;/span&gt; 1600k &lt;span class="nt"&gt;-bufsize&lt;/span&gt; 3200k &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking down the flags that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-t 10&lt;/code&gt; caps duration at 10 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;crop=...&lt;/code&gt; uses the values from the detect pass.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scale=800:800:flags=lanczos&lt;/code&gt; gets us to the required square. Lanczos is sharper than default bicubic.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;format=yuv420p&lt;/code&gt; inside the filter graph, plus &lt;code&gt;-pix_fmt yuv420p&lt;/code&gt; on the output. Belt and suspenders. HEVC often uses yuv420p10le which Telegram hates.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-b:v 1400k&lt;/code&gt; targets the 2MB budget for 10 seconds. Adjust down for longer clips.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-movflags +faststart&lt;/code&gt; puts the moov atom at the start of the file. Telegram needs this for streaming.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-an&lt;/code&gt; drops audio.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bitrate math: 2MB is roughly 16 Mbit. Over 10 seconds that's a 1.6 Mbit/s ceiling. I aim for 1.4 Mbit/s target with a 1.6 Mbit/s cap, and the file lands around 1.7 to 1.8MB in practice. Enough headroom for the container overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The aiogram 3 handler
&lt;/h2&gt;

&lt;p&gt;Nothing fancy on the bot side. Download the incoming file, run the pipeline, send back the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FSInputFile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_ffmpeg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;detect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_subprocess_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-vf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cropdetect=24:16:0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;null&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEVNULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stderr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;detect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;crop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_crop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_w:in_h:0:0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;encode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_subprocess_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-vf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crop=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;crop&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;,scale=800:800:flags=lanczos,fps=30,format=yuv420p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;libx264&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-profile:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-pix_fmt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yuv420p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-b:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1400k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-maxrate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1600k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-bufsize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3200k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-movflags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+faststart&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-an&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@router.message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nb"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avatar.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_ffmpeg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;FSInputFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ready. Long-press your profile picture in Telegram to set it.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;parse_crop&lt;/code&gt; helper is five lines of regex on the ffmpeg stderr, I won't bore you with it.&lt;/p&gt;

&lt;p&gt;Points worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;F.video | F.animation | F.document&lt;/code&gt; catches all three ways Telegram delivers uploaded video content. GIFs come in as &lt;code&gt;animation&lt;/code&gt;. Compressed video is &lt;code&gt;video&lt;/code&gt;. Uncompressed (send-as-file) is &lt;code&gt;document&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Using &lt;code&gt;asyncio.create_subprocess_exec&lt;/code&gt; instead of &lt;code&gt;subprocess.run&lt;/code&gt; keeps the bot responsive under concurrent load. On my single-CPU VPS I can process 3 or 4 clips in parallel before ffmpeg starts thrashing.&lt;/li&gt;
&lt;li&gt;The temporary directory is nuked on exit. No leftover files.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Packaging it as a bot
&lt;/h2&gt;

&lt;p&gt;The actual bot runs on a small Hetzner box under systemd. aiogram 3 with long polling (no webhook, no reverse proxy, keeps ops simple). SQLite for the tiny amount of state I keep (user id, conversion count for basic analytics).&lt;/p&gt;

&lt;p&gt;The whole thing is maybe 300 lines of Python plus the ffmpeg invocation. If you want to try it, send a video to the bot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases and things that broke
&lt;/h2&gt;

&lt;p&gt;A few things that cost me hours of debugging:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Portrait videos with bars.&lt;/strong&gt; The cropdetect pass helps a lot, but sometimes it catches actual content as "black" if the video opens on a dark scene. Adding &lt;code&gt;cropdetect=24:16:0,select='gt(t\,1)'&lt;/code&gt; skips the first second before detecting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live Photos.&lt;/strong&gt; These are a HEIC still plus a short MOV. iPhone sends the MOV part when you share. Usually fine, but the MOV is often only 1.5 seconds and shows up jittery. I don't do anything special, just document it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slow-mo footage.&lt;/strong&gt; iPhone slow-mo is 240fps internally but plays back at variable rate via metadata. ffmpeg respects the playback rate, so the output looks smooth. Was a pleasant surprise, not something I had to fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audio track presence.&lt;/strong&gt; Even with &lt;code&gt;-an&lt;/code&gt;, some source files with weird audio containers cause the mux to complain. Adding &lt;code&gt;-map 0:v:0&lt;/code&gt; explicitly before &lt;code&gt;-c:v&lt;/code&gt; forces video-only mapping and shuts that up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Currently handling around 300 users with a couple of conversions per day. Things on my list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Batch mode: send multiple videos, get a zip back.&lt;/li&gt;
&lt;li&gt;4K source handling. Right now anything over 1080p gets awkward memory usage during the crop pass.&lt;/li&gt;
&lt;li&gt;Optional audio on avatars for Telegram Premium users (spec allows it, I just haven't wired it up).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to build something similar, ffmpeg is doing 95% of the work. I just wrote the wrapper and the retry logic for when an encode fails.&lt;/p&gt;

&lt;p&gt;Built by me. Live at &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260809" rel="noopener noreferrer"&gt;@LiveAvaBot&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>ffmpeg</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>LiveAvaBot Developer API: Embed Telegram Video Avatar Conversion in Your App</title>
      <dc:creator>liveavabot</dc:creator>
      <pubDate>Fri, 07 Aug 2026 06:02:08 +0000</pubDate>
      <link>https://dev.to/liveavabot/liveavabot-developer-api-embed-telegram-video-avatar-conversion-in-your-app-36gh</link>
      <guid>https://dev.to/liveavabot/liveavabot-developer-api-embed-telegram-video-avatar-conversion-in-your-app-36gh</guid>
      <description>&lt;p&gt;I built &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt; last year to fix a stupid problem: iPhone videos silently fail as Telegram video avatars. The bot has processed conversions for 290 users so far, and a few of them asked the same question: "can I hit this from my own code?"&lt;/p&gt;

&lt;p&gt;So I shipped a REST API. This post walks through the Telegram spec that makes video avatars weird, the ffmpeg pipeline that solves it, and how to call the new endpoint from your own app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why video avatars are harder than they look
&lt;/h2&gt;

&lt;p&gt;Telegram's video avatar format is picky. The spec, if you dig into the Bot API docs and the desktop client source, boils down to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Codec: H.264 (yuv420p pixel format), audio track removed&lt;/li&gt;
&lt;li&gt;Resolution: 800x800, square, center-cropped&lt;/li&gt;
&lt;li&gt;Duration: 3 to 10 seconds&lt;/li&gt;
&lt;li&gt;File size: under 2 MB&lt;/li&gt;
&lt;li&gt;Container: MP4 with faststart flag&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Miss any of these and Telegram either rejects the upload with a generic error or, worse, accepts it and displays a black square. iPhone videos are HEVC (H.265) by default since iOS 11, and Telegram's avatar upload does not transcode. It just refuses.&lt;/p&gt;

&lt;p&gt;Most users never figure out why their video "doesn't work." They try three times, give up, use a still photo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ffmpeg pipeline
&lt;/h2&gt;

&lt;p&gt;Here is the actual command the bot runs. It does cropdetect to find the tightest square crop, then re-encodes to spec:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"crop='min(iw,ih)':'min(iw,ih)',scale=800:800,format=yuv420p"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-profile&lt;/span&gt;:v high &lt;span class="nt"&gt;-level&lt;/span&gt; 4.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-preset&lt;/span&gt; medium &lt;span class="nt"&gt;-crf&lt;/span&gt; 23 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-y&lt;/span&gt; output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;crop='min(iw,ih)':'min(iw,ih)'&lt;/code&gt; grabs a centered square from portrait or landscape source. No math, ffmpeg figures it out.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;format=yuv420p&lt;/code&gt; is the pixel format Telegram actually renders. Skip it and iOS devices show nothing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-an&lt;/code&gt; strips audio. Telegram ignores it anyway, but including it counts toward the 2 MB budget.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-movflags +faststart&lt;/code&gt; moves the moov atom to the front. Without it, Telegram sometimes chokes on large files even when they fit the size limit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-t 10&lt;/code&gt; hard-caps duration at 10 seconds. If the source is shorter, ffmpeg just uses what's there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CRF 23 usually keeps a 10-second 800x800 clip under 2 MB. If it goes over, the bot re-runs with CRF 28 as a fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  The aiogram 3 handler
&lt;/h2&gt;

&lt;p&gt;The Telegram-side code is boring on purpose. Grab the file, hand it to the converter, send the result back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FSInputFile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@router.message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;
    &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tmp/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.mov&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tmp/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_avatar.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;converting...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_subprocess_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-vf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crop=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;min(iw,ih)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;min(iw,ih)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,scale=800:800,format=yuv420p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;libx264&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-crf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;23&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-movflags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+faststart&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-an&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FSInputFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;missing_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;missing_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production the converter runs in a worker with a queue, size checks, HEVC detection, and a fallback CRF pass. But this snippet is what the core actually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The REST API
&lt;/h2&gt;

&lt;p&gt;A few devs asked to embed this in their own apps: profile-picture flows in a client, a wedding-video service, a Discord-to-Telegram bridge. So I exposed the same pipeline over HTTP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Endpoint
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /api/v1/convert
Authorization: Bearer &amp;lt;token&amp;gt;
Content-Type: multipart/form-data

file: &amp;lt;video&amp;gt;
preset: lofi|bw|vignette|blur|none  (optional, default: none)
trim_start: 0.0  (optional)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Response
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"output_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"duration_s"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;8.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"size_bytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1847291&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"codec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"h264"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;preset&lt;/code&gt; parameter applies an ffmpeg filter chain on top of the base conversion. &lt;code&gt;lofi&lt;/code&gt; adds grain and a slight desaturation, &lt;code&gt;bw&lt;/code&gt; is a straight greyscale, &lt;code&gt;vignette&lt;/code&gt; is the ffmpeg vignette filter with defaults, &lt;code&gt;blur&lt;/code&gt; is a soft gaussian on the edges. &lt;code&gt;trim_start&lt;/code&gt; shifts the source window if the good bit is not at second zero.&lt;/p&gt;

&lt;p&gt;Output URLs are signed and expire after 1 hour. Downloading the file is your job; the bot does not keep it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Getting a token
&lt;/h3&gt;

&lt;p&gt;Send &lt;code&gt;/api&lt;/code&gt; to &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt; and it DMs you a token. Rate limits are generous during beta (60 requests per hour per token), let me know if you hit them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quick curl example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.liveava.bot/api/v1/convert &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@my_video.mov"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"preset=lofi"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lessons and edge cases
&lt;/h2&gt;

&lt;p&gt;A few things I learned shipping this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HEVC detection has to happen before you trust anything. &lt;code&gt;ffprobe -show_streams&lt;/code&gt; is your friend. Some "MP4" files from Android are actually HEVC in an MP4 container.&lt;/li&gt;
&lt;li&gt;The 2 MB limit is not always exactly 2 MB. Telegram seems to accept up to ~2.1 MB in practice, but I keep the target under 1.9 MB to be safe.&lt;/li&gt;
&lt;li&gt;4K source video is still rough. The crop+scale pass on a 4K 60fps clip takes 15-20 seconds on the current worker. I have not optimised this yet.&lt;/li&gt;
&lt;li&gt;The API's biggest surprise: about half the traffic is from people testing whether their video will work before they even open Telegram. That was not the use case I designed for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Built by me, &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt; is at &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260807" rel="noopener noreferrer"&gt;https://t.me/LiveAvaBot?start=devto_article_20260807&lt;/a&gt;. API docs and token via &lt;code&gt;/api&lt;/code&gt; in the bot. Feedback welcome, especially from anyone building on top of it.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>ffmpeg</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Converting iPhone HEVC Videos to Telegram Video Avatars With FFmpeg and Aiogram 3</title>
      <dc:creator>liveavabot</dc:creator>
      <pubDate>Wed, 05 Aug 2026 05:02:59 +0000</pubDate>
      <link>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-and-aiogram-3-3eea</link>
      <guid>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-and-aiogram-3-3eea</guid>
      <description>&lt;p&gt;I built a tiny Telegram bot that does one boring thing: it takes any video or GIF you send it and spits back a file Telegram will actually accept as a video avatar. Sounds trivial. It wasn't.&lt;/p&gt;

&lt;p&gt;The reason it wasn't trivial is that Telegram's video-avatar format is a narrow slice of MP4, and iPhone videos land outside that slice by default. If you drag a fresh iPhone clip into the desktop app's avatar picker, Telegram just says the file is not supported. No hint about codec, no hint about duration, no hint about resolution. So I read the spec, wrote an ffmpeg pipeline, wrapped it in aiogram 3, and shipped it as &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;. This post is the build log.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pain: iPhone HEVC silently fails
&lt;/h2&gt;

&lt;p&gt;Since iOS 11 the default camera codec is HEVC (H.265) in an .mov container, yuv420p10le, variable frame rate. Telegram's clients decode HEVC fine for playback, but the avatar upload path is stricter than the general video path. It wants H.264, baseline or main profile, 8-bit yuv420p, square 800x800, no audio, up to 10 seconds, up to 2 MB, and moov atom at the front so the server can index it without downloading the whole file.&lt;/p&gt;

&lt;p&gt;Send it a 4K HEVC portrait clip and it rejects the file with a generic error. Users assume the bot is broken. It isn't, Telegram is just picky and quiet about it.&lt;/p&gt;

&lt;p&gt;So the bot's job is to normalize any input into that narrow slice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Telegram spec actually requires
&lt;/h2&gt;

&lt;p&gt;From the API docs and a lot of trial and error, a working video avatar looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Container: MP4, moov atom at the start (&lt;code&gt;-movflags +faststart&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Video codec: H.264, profile main or baseline, level 3.1 or lower.&lt;/li&gt;
&lt;li&gt;Pixel format: yuv420p (8-bit, not 10-bit).&lt;/li&gt;
&lt;li&gt;Resolution: 800x800, square, center-cropped from the source.&lt;/li&gt;
&lt;li&gt;Frame rate: constant, 30 fps works reliably.&lt;/li&gt;
&lt;li&gt;Duration: up to 10 seconds, hard cap.&lt;/li&gt;
&lt;li&gt;Audio: none, must be stripped.&lt;/li&gt;
&lt;li&gt;File size: under 2 MB. This is the real constraint, everything else you can hit easily.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 2 MB cap is what makes the encoding interesting. At 800x800 30fps for 10 seconds you have about 1.6 Mbps of budget. That is thin for anything with motion, so the bot has to be honest about bitrate and use a two-pass or CRF-with-cap strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ffmpeg pipeline
&lt;/h2&gt;

&lt;p&gt;I settled on a two-step approach: first probe the source with cropdetect to find the actual content bounds (iPhone videos are often letterboxed or shot in portrait), then encode with a hard bitrate ceiling.&lt;/p&gt;

&lt;p&gt;Here is the cropdetect probe. It samples a few frames and prints suggested crop rectangles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-ss&lt;/span&gt; 0.5 &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="nt"&gt;-t&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="nv"&gt;cropdetect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;24:16:0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; null - 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'crop=\S+'&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you something like &lt;code&gt;crop=1080:1080:0:420&lt;/code&gt;. The bot parses that and feeds it into the real encode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"crop=1080:1080:0:420,scale=800:800:flags=lanczos,fps=30,format=yuv420p"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-profile&lt;/span&gt;:v main &lt;span class="nt"&gt;-level&lt;/span&gt; 3.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="nt"&gt;-crf&lt;/span&gt; 26 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-maxrate&lt;/span&gt; 1400k &lt;span class="nt"&gt;-bufsize&lt;/span&gt; 2800k &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart &lt;span class="se"&gt;\&lt;/span&gt;
  output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-t 10&lt;/code&gt; before &lt;code&gt;-i&lt;/code&gt; would seek in the input, which is faster but can miss the intended clip start. Placing it after &lt;code&gt;-i&lt;/code&gt; cuts the output cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;format=yuv420p&lt;/code&gt; in the filter chain forces 8-bit even if the source is 10-bit HEVC. Without this, x264 will bail on some inputs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-an&lt;/code&gt; strips audio. Telegram rejects avatars with an audio track even if it's silent.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-crf 26&lt;/code&gt; with &lt;code&gt;-maxrate 1400k&lt;/code&gt; is a compromise. CRF alone can blow the 2 MB cap on high-motion clips, so the maxrate is a safety net. If the output still lands over 2 MB, the bot re-encodes with &lt;code&gt;-crf 30&lt;/code&gt; and a lower maxrate.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-movflags +faststart&lt;/code&gt; moves the moov atom to the front. Telegram's server needs this to accept the upload without a full download.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After this pipeline, a 47 MB 4K HEVC portrait clip from an iPhone 15 becomes a 1.7 MB square MP4 that Telegram accepts on the first try.&lt;/p&gt;

&lt;h2&gt;
  
  
  The aiogram 3 handler
&lt;/h2&gt;

&lt;p&gt;The bot itself is small. aiogram 3 makes the routing clean. Here is the trimmed video handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FSInputFile&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="nd"&gt;@router.message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nb"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send a video or gif&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in.bin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;crop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;detect_crop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;encode_avatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;st_size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;could not fit under 2mb, try a shorter clip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FSInputFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upload this in settings as your video avatar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detect_crop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_subprocess_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-ss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-vf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cropdetect=24:16:0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;null&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEVNULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crop=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crop=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_w:in_h:0:0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;encode_avatar&lt;/code&gt; function is a thin wrapper around the ffmpeg command above. If the first pass overshoots 2 MB, it retries with tighter parameters. Two retries is enough for 99% of inputs I have seen so far.&lt;/p&gt;

&lt;p&gt;One gotcha: &lt;code&gt;F.document&lt;/code&gt; catches files Telegram treats as documents rather than videos, which happens when the client uploads a raw .mov. Without that filter, iPhone shares from the Files app get ignored.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging as &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;The bot lives at &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260805" rel="noopener noreferrer"&gt;https://t.me/LiveAvaBot?start=devto_article_20260805&lt;/a&gt;. Send it any video, get back a Telegram-ready 800x800 MP4. It also handles GIFs (animation type) and raw documents. Hosting is a small VPS with ffmpeg installed from the distro repo. No queue, no worker pool, just async subprocesses. At 281 total users and a handful of conversions a day it does not need more.&lt;/p&gt;

&lt;p&gt;The stack: aiogram 3 for the Telegram side, ffmpeg for the encoding, sqlite for logs and stats. Deployment is a systemd unit and a git pull. That is the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons and edge cases
&lt;/h2&gt;

&lt;p&gt;A few things I learned the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Portrait videos are the norm on mobile. Assume the input is 9:16 and center-crop to square, do not fit-with-black-bars. Users hate letterboxing on avatars.&lt;/li&gt;
&lt;li&gt;10-bit HEVC is more common than you think. Always force &lt;code&gt;format=yuv420p&lt;/code&gt; in the filter chain, not just as an output pixel format.&lt;/li&gt;
&lt;li&gt;The 2 MB cap is what fails, not codec or resolution. Build the retry loop first, the crop logic second.&lt;/li&gt;
&lt;li&gt;Telegram does not tell you why the avatar upload failed. If your file matches the spec and still fails, check &lt;code&gt;ffprobe output.mp4&lt;/code&gt; for extra streams (subtitles, data streams). Strip them with &lt;code&gt;-map 0:v:0&lt;/code&gt; if needed.&lt;/li&gt;
&lt;li&gt;4K sources are slow to decode on tiny VPS boxes. A 20-second 4K HEVC clip took 14 seconds to process on a 2-vCPU machine. Users are patient for up to about 10 seconds, so I cap accepted input duration and warn early.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is next: proper 4K handling with a two-pass encode, and a preview thumbnail so users see the crop before uploading. The crop is right most of the time, but for landscape videos with the subject on one side it picks the middle and cuts them out.&lt;/p&gt;

&lt;p&gt;Built by me, &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;: &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260805" rel="noopener noreferrer"&gt;https://t.me/LiveAvaBot?start=devto_article_20260805&lt;/a&gt;. Feedback and edge-case clips welcome, that is how the crop heuristic gets better.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>ffmpeg</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Three Aiogram 3 Patterns From Building a Telegram Video Bot</title>
      <dc:creator>liveavabot</dc:creator>
      <pubDate>Mon, 03 Aug 2026 04:03:24 +0000</pubDate>
      <link>https://dev.to/liveavabot/three-aiogram-3-patterns-from-building-a-telegram-video-bot-333g</link>
      <guid>https://dev.to/liveavabot/three-aiogram-3-patterns-from-building-a-telegram-video-bot-333g</guid>
      <description>&lt;h2&gt;
  
  
  The problem: iPhone videos silently fail as Telegram avatars
&lt;/h2&gt;

&lt;p&gt;Telegram has a feature where your profile picture can be a short looping video instead of a still image. Cool feature. Except when you upload an iPhone video, Telegram either rejects it silently or shows a generic error. Nothing tells you why.&lt;/p&gt;

&lt;p&gt;The reason: iPhones since 2016 record video in HEVC (H.265) by default. Telegram's video avatar endpoint accepts H.264 only, and the file has strict constraints most people never see documented in one place.&lt;/p&gt;

&lt;p&gt;I got tired of manually converting videos in an ffmpeg terminal every time a friend asked how to set a video avatar. So I built &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;: forward any video or GIF, get back a Telegram-compatible avatar file, tap "set as profile photo", done.&lt;/p&gt;

&lt;p&gt;This post covers three aiogram 3 patterns that made the bot maintainable as it grew from a 200-line script to a service handling a few hundred users a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Telegram actually requires
&lt;/h2&gt;

&lt;p&gt;Before any code, the spec you need to hit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Codec: H.264 (libx264)&lt;/li&gt;
&lt;li&gt;Pixel format: yuv420p&lt;/li&gt;
&lt;li&gt;Container: mp4 with faststart flag&lt;/li&gt;
&lt;li&gt;Resolution: 800x800 (square)&lt;/li&gt;
&lt;li&gt;Duration: 10 seconds or less&lt;/li&gt;
&lt;li&gt;File size: 2 MB or less&lt;/li&gt;
&lt;li&gt;Audio: none (strip the audio track entirely)&lt;/li&gt;
&lt;li&gt;Frame rate: 30 fps is safe, 60 works if size allows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Miss any of these and Telegram gives you an unhelpful error or accepts the file but refuses to display it as an avatar. The 2 MB ceiling is the killer. It forces you to think about bitrate carefully on every encode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: FSMContext plus inline keyboards for multi-step flows
&lt;/h2&gt;

&lt;p&gt;The first version of the bot was one handler: send video, get result. Then I wanted presets ("Lofi", "B&amp;amp;W", "Vignette"). That meant asking the user which filter to apply after they sent the video. FSMContext turned out to be the cleanest way.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.fsm.state&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StatesGroup&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.fsm.context&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FSMContext&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CallbackQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;InlineKeyboardMarkup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;InlineKeyboardButton&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConvertFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StatesGroup&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;waiting_preset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;State&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;preset_kb&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;InlineKeyboardMarkup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;InlineKeyboardMarkup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inline_keyboard&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;
        &lt;span class="nc"&gt;InlineKeyboardButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Lofi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;callback_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preset:lofi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;InlineKeyboardButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B&amp;amp;W&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;callback_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preset:bw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;InlineKeyboardButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Vignette&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;callback_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preset:vignette&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;InlineKeyboardButton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Blur&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;callback_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preset:blur&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]])&lt;/span&gt;

&lt;span class="nd"&gt;@router.message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;got_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FSMContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;file_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;file_id&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;file_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ConvertFlow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;waiting_preset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pick a preset:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reply_markup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;preset_kb&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="nd"&gt;@router.callback_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preset:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;picked_preset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CallbackQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FSMContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;preset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;preset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;do_conversion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I learned the hard way.&lt;/p&gt;

&lt;p&gt;Keep the state small. Store only the file_id (Telegram gives you a persistent handle) plus a couple of user choices. Do not stash raw bytes in state. Redis or MemoryStorage will hate you.&lt;/p&gt;

&lt;p&gt;Always set a timeout. Users abandon flows. I added a background task that clears any state older than 15 minutes.&lt;/p&gt;

&lt;p&gt;Also worth noting: callback_data is capped at 64 bytes and must be a string. For anything more complex than a fixed enum, use aiogram's CallbackData factory. It gives you typed payloads with validation. Cleaner than parsing colon-separated strings once you have five or more actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: A semaphore to keep ffmpeg from eating the VPS
&lt;/h2&gt;

&lt;p&gt;ffmpeg is CPU-bound and hungry. Four concurrent invocations on a 2-core VPS will spike load average past 8 and the bot stops responding to pings. I learned this when a small burst of users made the systemd service look dead to my monitoring.&lt;/p&gt;

&lt;p&gt;The fix is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="n"&gt;_SEM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_ffmpeg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;_SEM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_subprocess_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stderr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stderr&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tune the semaphore value to your host. On a 4-core box I use 4. On a 2-core VPS I use 2. Users see a small queue delay under load but the bot stays responsive on the aiogram event loop, which is what matters. Sending a "typing" chat action covers the wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: The actual ffmpeg pipeline
&lt;/h2&gt;

&lt;p&gt;Here is the command the bot runs. It uses cropdetect first to find the largest content region, then re-encodes to Telegram's spec:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# step 1: find the crop box from a 3-second sample&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"cropdetect=24:16:0,metadata=mode=print"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; null &lt;span class="nt"&gt;-t&lt;/span&gt; 3 - 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep &lt;/span&gt;&lt;span class="nv"&gt;crop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;

&lt;span class="c"&gt;# step 2: encode using the detected crop&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"crop=1080:1080:420:0,scale=800:800,fps=30,format=yuv420p"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="nt"&gt;-crf&lt;/span&gt; 26 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why each flag matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-t 10&lt;/code&gt; hard-caps duration before encoding, avoids extra work&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;crop=...&lt;/code&gt; uses cropdetect output, so a 16:9 iPhone video becomes a centered square instead of a squashed rectangle&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scale=800:800&lt;/code&gt; matches Telegram avatar dimensions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fps=30&lt;/code&gt; normalizes frame rate, some sources have variable fps that breaks looping&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;format=yuv420p&lt;/code&gt; is the pixel format Apple hardware decoders expect&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-crf 26&lt;/code&gt; is a quality setting that usually lands under 2 MB for 10 seconds&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-movflags +faststart&lt;/code&gt; moves the moov atom to the front, needed for streaming&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-an&lt;/code&gt; strips audio, required by the avatar spec&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the output is still over 2 MB, the bot re-runs with &lt;code&gt;-crf 28&lt;/code&gt;, then &lt;code&gt;-crf 30&lt;/code&gt;. Three attempts, then it gives up and tells the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons and edge cases
&lt;/h2&gt;

&lt;p&gt;A few things the tutorials never mention:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Animated GIFs come in as &lt;code&gt;message.animation&lt;/code&gt;, not &lt;code&gt;message.video&lt;/code&gt;. Handle both, or you drop half the "convert my meme" requests.&lt;/li&gt;
&lt;li&gt;Portrait iPhone videos have rotation metadata. ffmpeg respects it if you do nothing, but if you use raw &lt;code&gt;-map&lt;/code&gt;, you can end up sideways. Test with a portrait video before shipping.&lt;/li&gt;
&lt;li&gt;Some videos have the moov atom at the end. They will not stream. &lt;code&gt;-movflags +faststart&lt;/code&gt; fixes it on the encode side.&lt;/li&gt;
&lt;li&gt;Users forward videos that Telegram itself recompressed twice already. Quality is already gone by the time you get them. Nothing you can do about it, just document it.&lt;/li&gt;
&lt;li&gt;File size targets are cliffs. 2.00 MB works, 2.01 MB does not. Always encode with a safety margin.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bot runs on a small VPS handling a few hundred users a day. Total ffmpeg time per conversion is 3 to 8 seconds. aiogram 3's async model keeps interaction snappy while workers grind in the background.&lt;/p&gt;

&lt;p&gt;If you want to try it: &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260803" rel="noopener noreferrer"&gt;t.me/LiveAvaBot&lt;/a&gt;. Send any video or GIF, get a Telegram-ready avatar back.&lt;/p&gt;

&lt;p&gt;Built by me, &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>telegram</category>
      <category>ffmpeg</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why Your iPhone Video Fails as a Telegram Avatar (and How to Fix It)</title>
      <dc:creator>liveavabot</dc:creator>
      <pubDate>Sat, 01 Aug 2026 03:04:46 +0000</pubDate>
      <link>https://dev.to/liveavabot/why-your-iphone-video-fails-as-a-telegram-avatar-and-how-to-fix-it-4lma</link>
      <guid>https://dev.to/liveavabot/why-your-iphone-video-fails-as-a-telegram-avatar-and-how-to-fix-it-4lma</guid>
      <description>&lt;h2&gt;
  
  
  The silent failure
&lt;/h2&gt;

&lt;p&gt;I tried to set a video as my Telegram profile picture. Recorded a 6-second clip on my iPhone 15, opened Telegram Desktop, hit "Set as video". Progress bar filled. Nothing happened. No error, no warning, just... nothing. My profile still had the old static photo.&lt;/p&gt;

&lt;p&gt;Turned out the iPhone recorded in HEVC (H.265), and Telegram's video avatar endpoint accepts H.264 only. It doesn't tell you that. It just accepts the upload and drops it on the floor.&lt;/p&gt;

&lt;p&gt;That's the bug I wanted to fix, and it became &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260801" rel="noopener noreferrer"&gt;@LiveAvaBot&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Telegram actually wants
&lt;/h2&gt;

&lt;p&gt;The official spec for video profile pictures isn't documented in one place. I pieced it together from the Bot API docs, the Telegram Desktop source, and trial and error:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Codec&lt;/strong&gt;: H.264, yuv420p pixel format&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container&lt;/strong&gt;: MP4 with faststart&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution&lt;/strong&gt;: 800x800, square, cropped from center&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration&lt;/strong&gt;: up to 10 seconds (longer gets rejected)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File size&lt;/strong&gt;: under 2 MB (soft cap, sometimes 3 MB works)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio&lt;/strong&gt;: must be stripped (any audio stream causes silent rejection on some clients)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frame rate&lt;/strong&gt;: 30 fps max (60 fps works but adds size)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Miss any of these and the client silently fails. No 400 response, no toast, nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ffmpeg pipeline
&lt;/h2&gt;

&lt;p&gt;The core transform is a two-pass ffmpeg call. First, cropdetect on a sample of the video to find where the actual content is, then a scale+crop+encode pass to produce the square 800x800 output.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pass 1: detect crop rectangle&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-ss&lt;/span&gt; 0 &lt;span class="nt"&gt;-t&lt;/span&gt; 3 &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"cropdetect=24:16:0"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; null - 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"crop=[0-9:]*"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;

&lt;span class="c"&gt;# Pass 2: crop + scale + encode&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"crop=1080:1080:420:540,scale=800:800,fps=30,format=yuv420p"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="nt"&gt;-crf&lt;/span&gt; 26 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class="se"&gt;\&lt;/span&gt;
  output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-an&lt;/code&gt; strips audio. The &lt;code&gt;format=yuv420p&lt;/code&gt; in the filter chain and &lt;code&gt;-pix_fmt yuv420p&lt;/code&gt; at the codec level both matter. Some clients care about the container-level pixel format flag, others read from the codec stream.&lt;/p&gt;

&lt;p&gt;Tuning &lt;code&gt;-crf&lt;/code&gt; was the tricky part. At &lt;code&gt;crf=23&lt;/code&gt; (ffmpeg default), a 10-second 800x800 clip often ended up around 3.5 MB, over the Telegram limit. At &lt;code&gt;crf=28&lt;/code&gt;, quality got mushy in low-light scenes. &lt;code&gt;crf=26&lt;/code&gt; with &lt;code&gt;-preset veryfast&lt;/code&gt; was the sweet spot for a 2 MB budget.&lt;/p&gt;

&lt;p&gt;For clips that still bust the size limit, I do a second encode pass with &lt;code&gt;crf=30&lt;/code&gt; and 24 fps as a fallback. That covers about 95% of uploads without going to a full two-pass ABR encode, which would double the CPU cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into aiogram 3
&lt;/h2&gt;

&lt;p&gt;The bot logic is standard aiogram 3. Receive a video, download it, run the pipeline, send back as an input file. Here's the handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FSInputFile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@router.message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_size&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;File too big. Max 20 MB.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

        &lt;span class="n"&gt;tg_file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tg_file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Converting...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_pipeline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CalledProcessError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;edit_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;FSInputFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ready. Long-press your profile photo, tap Edit, choose Set as Avatar.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;asyncio.to_thread&lt;/code&gt; is important. ffmpeg is CPU-bound and blocks. Running it in a thread keeps the event loop responsive so other users don't stall behind a single long encode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging it as &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Deploy target: a small Hetzner box with systemd. Ffmpeg from apt, Python 3.11 in a venv, aiogram 3.4. No Docker, no k8s, one unit file and a token.&lt;/p&gt;

&lt;p&gt;Currently at 265 users, most from a single Reddit thread on r/Telegram. Conversion rate on video uploads sits around 80%. The 20% that fail are usually 4K HDR clips where the encode busts the size limit even at &lt;code&gt;crf=30&lt;/code&gt;. Still an open TODO.&lt;/p&gt;

&lt;p&gt;Try it here: &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260801" rel="noopener noreferrer"&gt;https://t.me/LiveAvaBot?start=devto_article_20260801&lt;/a&gt;. Send a video, get a Telegram-ready MP4 back in about 8 seconds for a typical 6-second iPhone clip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases I hit along the way
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HDR to SDR&lt;/strong&gt;: iPhone 15 records in HDR HLG. Telegram doesn't render HDR, colors come out washed. Added &lt;code&gt;zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p&lt;/code&gt; to the filter chain. Ugly, but works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portrait vs landscape&lt;/strong&gt;: cropdetect picks up letterboxing on portrait video shot in landscape orientation. I bypass it for aspect ratios more square than 1:1.2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GIFs from Twitter&lt;/strong&gt;: they come in as MP4 with no audio stream already. The &lt;code&gt;-an&lt;/code&gt; flag is a no-op there but doesn't hurt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotation metadata&lt;/strong&gt;: MOVs with &lt;code&gt;rotate=90&lt;/code&gt; in metadata need &lt;code&gt;-vf&lt;/code&gt; filters applied after auto-rotation. &lt;code&gt;-noautorotate&lt;/code&gt; and then a manual &lt;code&gt;transpose&lt;/code&gt; filter fixed a batch of clips that were coming out sideways.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Better fallback for oversized 4K clips, probably a two-pass ABR encode gated on file size. A &lt;code&gt;/gif&lt;/code&gt; mode that outputs animated MP4 stickers. And figuring out why some Android clients still reject the output even though it passes every spec check I know about.&lt;/p&gt;

&lt;p&gt;Built by me, &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260801" rel="noopener noreferrer"&gt;@LiveAvaBot&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>ffmpeg</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Converting iPhone HEVC Videos to Telegram Video Avatars with FFmpeg</title>
      <dc:creator>liveavabot</dc:creator>
      <pubDate>Thu, 30 Jul 2026 03:02:55 +0000</pubDate>
      <link>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-a87</link>
      <guid>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-a87</guid>
      <description>&lt;h2&gt;
  
  
  The bug that made me write this
&lt;/h2&gt;

&lt;p&gt;I tried setting a short clip from my iPhone as my Telegram profile video. Telegram accepted the upload, showed a spinner, then quietly reverted to my old static picture. No error message. No hint. Just the old avatar again.&lt;/p&gt;

&lt;p&gt;The clip was H.265 (HEVC), which iPhones record by default. Telegram's video avatar endpoint accepts H.264 only. The upload technically worked, the server just refused to use the file, and there was no user-visible reason.&lt;/p&gt;

&lt;p&gt;I wrote a bot that takes any video or GIF and produces a file Telegram will actually accept as a video avatar. This post is the ffmpeg pipeline and the aiogram 3 handler that runs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Telegram's video avatar format actually is
&lt;/h2&gt;

&lt;p&gt;Telegram doesn't document this well, but reverse engineering it gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Container: MP4&lt;/li&gt;
&lt;li&gt;Video codec: H.264, yuv420p, high profile is fine&lt;/li&gt;
&lt;li&gt;Audio: must be removed entirely&lt;/li&gt;
&lt;li&gt;Resolution: 800x800 square, exact&lt;/li&gt;
&lt;li&gt;Duration: 10 seconds max (I clamp to 9.9 to be safe)&lt;/li&gt;
&lt;li&gt;File size: 2 MB max&lt;/li&gt;
&lt;li&gt;Frame rate: 30 fps works, higher gets rejected sometimes&lt;/li&gt;
&lt;li&gt;moov atom at the front (faststart)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those are wrong, the upload either silently fails or the avatar shows as a still frame from second zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ffmpeg pipeline
&lt;/h2&gt;

&lt;p&gt;The tricky part is turning a rectangular phone video into an 800x800 square without ugly letterboxing. I run cropdetect on a probe pass, then crop and scale in the encode pass.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-ss&lt;/span&gt; 00:00:02 &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="nt"&gt;-vframes&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="nv"&gt;cropdetect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;24:16:0 &lt;span class="nt"&gt;-f&lt;/span&gt; null - 2&amp;gt;&amp;amp;1 | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'crop=[0-9:]*'&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="c"&gt;# example output: crop=1080:1080:0:420&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the actual encode with the detected crop values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; 9.9 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"crop=1080:1080:0:420,scale=800:800:flags=lanczos,format=yuv420p,fps=30"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="nt"&gt;-crf&lt;/span&gt; 23 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-profile&lt;/span&gt;:v high &lt;span class="nt"&gt;-level&lt;/span&gt; 4.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-pix_fmt&lt;/span&gt; yuv420p &lt;span class="se"&gt;\&lt;/span&gt;
  output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-an&lt;/code&gt; drops audio. Telegram rejects avatars with any audio track.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-vf format=yuv420p&lt;/code&gt; plus the explicit &lt;code&gt;-pix_fmt yuv420p&lt;/code&gt; are belt and suspenders. Some sources are yuv444p and Telegram won't touch that.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-movflags +faststart&lt;/code&gt; moves the moov atom to the start of the file so the server can begin streaming without downloading the whole thing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-t 9.9&lt;/code&gt; clamps duration. I found 10.0 flat sometimes gets rejected by rounding.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-crf 23&lt;/code&gt; keeps files under 2 MB for most 9 second clips. If the output goes over, I re-encode with &lt;code&gt;-crf 28&lt;/code&gt; and warn the user about quality.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For GIFs I skip cropdetect and center-crop to a square, because GIFs are usually already framed for the subject.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aiogram 3 handler
&lt;/h2&gt;

&lt;p&gt;I run aiogram 3 with the Router pattern. The handler grabs the file, hands it to an async ffmpeg subprocess, then sends back the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FSInputFile&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9.9&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-an&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-vf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cropdetect=24:16:0,scale=800:800:flags=lanczos,format=yuv420p,fps=30&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;libx264&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-preset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;veryfast&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-crf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;23&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-profile:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-movflags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+faststart&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-pix_fmt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yuv420p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_subprocess_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stderr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;

&lt;span class="nd"&gt;@router.message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nb"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

        &lt;span class="n"&gt;tg_file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tg_file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;RuntimeError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="n"&gt;size_mb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;st_size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;size_mb&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Output is &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;size_mb&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; MB, over the 2 MB Telegram limit. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Try a shorter clip.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answer_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FSInputFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the core. The production version has more error handling, rate limiting, and metrics, but the pipeline is 40 lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging this as &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;I put the pipeline behind a bot. Send it any video or GIF, get back an MP4 you can set as your Telegram video avatar. It handles iPhone HEVC, WhatsApp exports, Instagram reels saves, TikTok downloads, and other common sources.&lt;/p&gt;

&lt;p&gt;Stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;aiogram 3 on Python 3.11&lt;/li&gt;
&lt;li&gt;ffmpeg 6.x from apt&lt;/li&gt;
&lt;li&gt;systemd unit, no docker&lt;/li&gt;
&lt;li&gt;SQLite for user counts and rate limiting&lt;/li&gt;
&lt;li&gt;Hetzner CX22, 2 GB RAM is enough for concurrent ffmpeg jobs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cost so far: 4 EUR per month for the VPS. ffmpeg does the actual work, I just wrote the wrapper and the Telegram plumbing.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260730" rel="noopener noreferrer"&gt;https://t.me/LiveAvaBot?start=devto_article_20260730&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases I hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;iPhone Live Photos: the .mov wrapper contains a still HEIC plus a 3 second video. ffmpeg picks the right stream automatically, but the aspect is often 4:3 not 16:9, so cropdetect matters.&lt;/li&gt;
&lt;li&gt;Animated WebPs sent as stickers: Telegram sends these as documents with mime &lt;code&gt;image/webp&lt;/code&gt;. ffmpeg handles them, but you need &lt;code&gt;-r 30&lt;/code&gt; on input or the output fps is broken.&lt;/li&gt;
&lt;li&gt;Videos with rotation metadata: shoot vertical on iPhone, ffmpeg auto-rotates on read. Works fine.&lt;/li&gt;
&lt;li&gt;Very short GIFs (under 1 second): I loop them to 3 seconds so the avatar isn't a blink. Use &lt;code&gt;-stream_loop 3 -t 3&lt;/code&gt; before &lt;code&gt;-i&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Files over 20 MB: Telegram's bot API caps downloads at 20 MB. I catch that and tell the user to trim before sending.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Small backlog:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auto face-detect crop instead of center-crop (dlib or mediapipe)&lt;/li&gt;
&lt;li&gt;Batch mode for multiple videos in one message&lt;/li&gt;
&lt;li&gt;Preset selection (portrait, landscape, action)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core problem, HEVC to H.264 with the right specs, is solved. The rest is polish.&lt;/p&gt;

&lt;p&gt;Built by me, &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt; on Telegram.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>ffmpeg</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Converting iPhone HEVC Videos to Telegram Video Avatars With ffmpeg</title>
      <dc:creator>liveavabot</dc:creator>
      <pubDate>Tue, 28 Jul 2026 02:03:13 +0000</pubDate>
      <link>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-5d99</link>
      <guid>https://dev.to/liveavabot/converting-iphone-hevc-videos-to-telegram-video-avatars-with-ffmpeg-5d99</guid>
      <description>&lt;p&gt;I hit this bug last month. Uploaded a clip from my iPhone as a video avatar in Telegram. It processed, the UI said done, and then nothing happened. My avatar was still the old one. No error, no message, just silence.&lt;/p&gt;

&lt;p&gt;Turns out Telegram silently rejects videos that don't match its exact video-avatar spec. And iPhone videos never match it, because Apple encodes in HEVC by default.&lt;/p&gt;

&lt;p&gt;I got annoyed. I wrote a bot. Now it lives at &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260728" rel="noopener noreferrer"&gt;t.me/LiveAvaBot&lt;/a&gt; and I want to walk through what the spec actually requires and how ffmpeg handles it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Telegram Actually Wants
&lt;/h2&gt;

&lt;p&gt;The video-avatar format is stricter than most people realize. Here's what I reverse-engineered by trial and error:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Codec: H.264 (avc1). HEVC (hvc1) is rejected.&lt;/li&gt;
&lt;li&gt;Resolution: exactly 800x800 square. Not 720, not 1080.&lt;/li&gt;
&lt;li&gt;Pixel format: yuv420p. yuv420p10le fails silently.&lt;/li&gt;
&lt;li&gt;Duration: 10 seconds or less. 10.1 seconds gets truncated with weird artifacts.&lt;/li&gt;
&lt;li&gt;File size: 2 MB or less. Over that, Telegram compresses it aggressively and quality drops.&lt;/li&gt;
&lt;li&gt;Audio: must be stripped. Audio tracks cause silent rejection on some clients.&lt;/li&gt;
&lt;li&gt;Container: mp4 with faststart flag (moov atom at the start).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is documented well. The Bot API accepts sendVideo happily, but for the personal profile video avatar the constraints tighten dramatically and there's no error surface. Your upload just quietly does nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Encode Pipeline
&lt;/h2&gt;

&lt;p&gt;Most source videos aren't square. A phone clip is 1080x1920 portrait, a screen recording might be 1920x1080 landscape. You need to pick a 1:1 window from the interesting part of the frame.&lt;/p&gt;

&lt;p&gt;I use ffmpeg's cropdetect filter to find where the actual content is (skipping black bars) and then center-crop a square from that. Two passes, first detect, then encode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pass 1: detect crop rectangle&lt;/span&gt;
&lt;span class="nv"&gt;CROP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="nv"&gt;cropdetect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;24:16:0 &lt;span class="nt"&gt;-f&lt;/span&gt; null - 2&amp;gt;&amp;amp;1 &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'crop=[0-9:]+'&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Pass 2: encode with detected crop, scaled to 800x800&lt;/span&gt;
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-loglevel&lt;/span&gt; error &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-i&lt;/span&gt; input.mov &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-t&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CROP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,scale=800:800:flags=lanczos,format=yuv420p,fps=30"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="nt"&gt;-crf&lt;/span&gt; 23 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-profile&lt;/span&gt;:v high &lt;span class="nt"&gt;-level&lt;/span&gt; 4.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; mp4 output.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking down the filters in pass 2:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;crop=... cuts the detected rectangle from the source.&lt;/li&gt;
&lt;li&gt;scale=800:800:flags=lanczos downscales to the target with a decent kernel.&lt;/li&gt;
&lt;li&gt;format=yuv420p forces 8-bit 4:2:0 (kills HEVC 10-bit HDR).&lt;/li&gt;
&lt;li&gt;fps=30 normalizes the frame rate. iPhone shoots 60, Telegram gets confused.&lt;/li&gt;
&lt;li&gt;-an strips audio.&lt;/li&gt;
&lt;li&gt;-t 10 clips to 10 seconds.&lt;/li&gt;
&lt;li&gt;-movflags +faststart puts the moov atom at the front so Telegram can start playing before download completes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CRF 23 with the veryfast preset lands most videos under 2 MB. If a clip goes over, I re-encode at CRF 28. Anything still over 2 MB gets rejected with a friendly message to the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  The aiogram 3 Handler
&lt;/h2&gt;

&lt;p&gt;Here's the minimal handler that ties it together. aiogram 3 with async/await, the whole path from receiving the video to responding with the converted file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FSInputFile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aiogram.enums&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ChatAction&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;convert_to_avatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_subprocess_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-loglevel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-vf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;crop=in_h:in_h:(in_w-in_h)/2:0,scale=800:800:flags=lanczos,format=yuv420p,fps=30&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-an&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;libx264&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-preset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;veryfast&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-crf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;23&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-profile:v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-movflags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+faststart&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="nd"&gt;@router.message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;video&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;animation&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_size&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;file_size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;File too big. 50 MB cap.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_chat_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChatAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UPLOAD_VIDEO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TemporaryDirectory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;td&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;td&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in.bin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;dst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;td&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out.mp4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;convert_to_avatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;RuntimeError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;st_size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Output over 2 MB, try a shorter clip.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reply_video&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FSInputFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole flow. Download, convert, check size, send back. In production I added a queue, per-user rate limiting, and better error messages, but this is the core.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging It as a Bot
&lt;/h2&gt;

&lt;p&gt;I wrapped this into a live bot: &lt;a href="https://t.me/LiveAvaBot?start=devto_article_20260728" rel="noopener noreferrer"&gt;@LiveAvaBot&lt;/a&gt;. Users send any video or GIF, and get back a file they can drag into Telegram's video-avatar picker. It handles HEVC, portrait, landscape, GIFs, MOV, MP4, and weirder containers like webm.&lt;/p&gt;

&lt;p&gt;The whole thing runs on a small VPS. ffmpeg does the heavy lifting, I just wrote the wrapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases and What's Next
&lt;/h2&gt;

&lt;p&gt;A few things I learned the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rotation metadata:&lt;/strong&gt; iPhone videos have a rotation flag in the container. Newer ffmpeg respects it by default, but on older builds the output comes out sideways. I added -noautorotate and handle rotation manually via transpose when the metadata says so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Variable frame rate:&lt;/strong&gt; Screen recordings often have VFR. Forcing fps=30 fixed frame-drift artifacts even without audio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HDR to SDR:&lt;/strong&gt; iPhone Pro shoots Dolby Vision. Just forcing yuv420p tone-maps poorly. I added a zscale plus tonemap chain when the source is HDR. It's ugly but works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4K sources:&lt;/strong&gt; Currently I downscale in one pass. Not great quality-wise. Next version will do a two-pass with proper sharpening.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next on my list is a Telegram Stars payment for a batch mode (convert 10 clips at once). Free tier keeps the single-clip flow.&lt;/p&gt;

&lt;p&gt;Built by me, &lt;a class="mentioned-user" href="https://dev.to/liveavabot"&gt;@liveavabot&lt;/a&gt;. Feedback welcome, especially edge cases where my ffmpeg pipeline breaks.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>ffmpeg</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
