بازگشت به بلاگ
۲۲ مرداد ۱۴۰۵Sergei Solod9 دقیقه مطالعه

چطور ویدیوهای H.264 را چند برابر کوچک‌تر کردم، بدون افت کیفیت محسوس

کدک همان H.264 باقی ماند، اما سیاست encode را با CRF 28، x264 veryslow، سقف رزولوشن در کلاس 720p و فقط فریم‌ریت مفید بازطراحی کردم. نتیجه فایل‌هایی بود که چند برابر کوچک‌تر شدند و در پخش عادی همچنان طبیعی به نظر می‌رسیدند. مهم‌تر از آن، یاد گرفتم encode را هزینه‌ای یک‌باره و bandwidth را هزینه‌ای تکرارشونده ببینم.

H.264FFmpegفشرده‌سازی ویدیوکارایی وبx264

نتیجه شبیه چیزی بود که معمولاً از مهاجرت به یک codec جدیدتر انتظار می‌رود: فایل‌های ویدیویی من چند برابر کوچک‌تر شدند، در حالی که پخش معمولی همچنان طبیعی به نظر می‌رسید و در اندازه‌های عادی نمایش افت کیفیت واضحی نمی‌دیدم.

اما به AV1، HEVC یا VP9 مهاجرت نکرده بودم. هنوز از H.264 داخل MP4 استفاده می‌کردم.

چیزی که عوض شد، همه چیز در اطراف codec بود. سیاست encode را برای یک workload مشخص از نو ساختم: کلیپ‌های کوتاه تصویری و انیمیشنی، مخاطبانی که بخش بزرگی از آن‌ها موبایل هستند، bandwidth به‌عنوان هزینه تکرارشونده اصلی، و تقریباً بی‌اهمیت بودن زمان یک encode آفلاین که فقط یک بار انجام می‌شود.

baseline نهایی عمداً برای playback محافظه‌کارانه و برای encoding پرهزینه بود: H.264 Main Profile @ Level 3.1، avc1، 8-bit yuv420p، CRF 28، x264 veryslow، سقف رزولوشن در کلاس 720p، فریم‌ریت مفید که معمولاً از 30 fps بالاتر نمی‌رود، تعداد محدود reference frame و B-frame، و faststart برای تحویل progressive MP4.

مهم‌ترین بهینه‌سازی یک flag در FFmpeg نبود

بزرگ‌ترین تغییر، نحوه نگاه من به هزینه بود.

Encoding یک بار اتفاق می‌افتد. Delivery هر بار که کسی فایل را درخواست می‌کند تکرار می‌شود.

برای ویدیوی real-time، مصرف CPU بسیار بیشتر برای صرفه‌جویی کمی در bitrate ممکن است معامله بدی باشد. فایل‌های من offline encode می‌شوند و بعد بارها سرو می‌شوند. در این مدل، صرفه‌جویی ده دقیقه در encode می‌تواند از نظر اقتصادی بی‌معنی باشد اگر encode سریع‌تر، هر درخواست آینده را بزرگ‌تر کند.

به همین دلیل -preset veryslow برای من منطقی است. حاضرم یک بار CPU بیشتری مصرف کنم اگر x264 بتواند با آن representation بهینه‌تری پیدا کند. مرورگر جست‌وجوی encoder را تکرار نمی‌کند؛ فقط bitstream نهایی را decode می‌کند.

قاعده برای من ساده شد: محاسبه را در مرحله‌ای خرج کن که یک بار انجام می‌شود و در مرحله‌ای که تکرار می‌شود روی byteها سخت‌گیر باش.

چرا به‌جای دنبال کردن جدیدترین codec با H.264 ماندم

ادعا نمی‌کنم H.264 کارآمدترین codec از نظر compression است. نیست. codecهای جدیدتر وقتی جذاب‌اند که سیستم delivery بتواند چند rendition نگه دارد و برای هر client بهترین مورد را انتخاب کند.

محدودیت من چیز دیگری بود: یک URL، یک فایل، یک codec و کمترین دردسر عملی playback برای مخاطبی که عمدتاً موبایلی است.

برای این مسئله، H.264 داخل MP4 هنوز baseline بسیار امنی است. Apple در حال حاضر به توسعه‌دهندگان وب توصیه می‌کند برای ویدیوی static در Safari از فایل‌های MP4 با H.264 استفاده کنند. مستندات فعلی Android نیز H.264 در MP4 را فهرست می‌کند و از Android 6.0 به بعد decoder برای Main Profile را الزامی می‌داند؛ توصیه playback آن همچنین 1280×720 در 30 fps را به‌عنوان یک پیکربندی HD برای H.264 ذکر می‌کند. جزئیات در فرمت‌های رسانه‌ای پشتیبانی‌شده Android آمده است.

این به معنی محدود بودن دستگاه‌های جدید به Main Profile یا Level 3.1 نیست. راهنمای HLS اپل، برای مثال، معمولاً High Profile را به Main یا Baseline ترجیح می‌دهد. من Main@3.1 را انتخاب کردم چون برای یک MP4 static واحد عمداً decoder envelope محافظه‌کارانه‌ای می‌خواستم، نه چون Apple آن را الزام کرده است.

دیگر pixelهایی را encode نکردم که لازم نبود وجود داشته باشند

Resolution یکی از بزرگ‌ترین اهرم‌ها بود. سقف من تقریباً 1280×720 برای landscape، 720×1280 برای portrait و حدود 960×960 برای محتوای مربعی یا mixed-orientation شد.

قاعده مهم‌تر این است: فقط برای رسیدن به سقف، upscale نکن.

اگر source برابر 900×600 است، تبدیل آن به 1280×720 جزئیات تازه‌ای ایجاد نمی‌کند. فقط sampleهای بیشتری می‌سازد که encoder باید آن‌ها را توصیف کند. یک source با 1920×1080 می‌تواند به کلاس 720p کاهش یابد، اما 900×600 می‌تواند تقریباً همان 900×600 باقی بماند. سقف یک حداکثر است، نه target.

ساده به نظر می‌رسد، اما حذف pixelهای اضافی می‌تواند از بسیاری از tweakهای مبهم encoder مهم‌تر باشد.

دیگر برای frameهایی که source واقعاً نداشت هزینه ندادم

Frame rate نیز یک multiplier است. اگر یک animation تقریباً 16 حالت بصری مفید در هر ثانیه دارد، ذخیره آن با 30 یا 60 fps به‌طور خودکار حرکت را بهتر نمی‌کند. اغلب فقط sampleهای زمانی تکراری یا ساخته‌شده بیشتری ایجاد می‌کند که باز هم باید encode شوند.

سیاست من حفظ cadence مفید source و ماندن معمولاً روی 30 fps یا کمتر است. برای چنین محتوایی 12، 15، 16، 18، 20، 24، 25 یا 30 fps همگی می‌توانند منطقی باشند، اگر واقعاً source را توصیف کنند.

در خروجی generated نیز CFR تمیز را ترجیح می‌دهم. VFR ذاتاً خراب نیست؛ CFR فقط timestamps، تعداد frameها، بررسی duration، seeking و validation بعدی را در pipeline من ساده‌تر می‌کند.

اصل کلی از هر عدد FPS مهم‌تر است: برای اطلاعات زمانی‌ای که در source وجود ندارد bandwidth پرداخت نکن.

CRF 28 انتخاب workload است، نه عدد جادویی

نمی‌خواستم همه کلیپ‌ها به یک target bitrate ثابت هل داده شوند. یک تصویر تقریباً ثابت و یک صحنه با حرکت پیچیده برای قابل‌قبول بودن به تعداد bit یکسان نیاز ندارند.

بنابراین از حالت CRF در x264 استفاده می‌کنم و برای این workload تصویری و bandwidth-first حدود -crf 28 را انتخاب کردم. FFmpeg، CRF در libx264 را rate control با کیفیت ثابت مستند می‌کند؛ به مستندات codecهای FFmpeg مراجعه کنید.

CRF 28 عمداً aggressive است. آن را کورکورانه برای film grain، فیلم دوربین noisy، متن خیلی ریز روی صفحه یا workloadی که fidelity از bandwidth مهم‌تر است کپی نمی‌کنم.

همچنین score ادراکی جهانی ندارم که ثابت کند CRF 28 transparent است. چیزی که می‌توانم درباره workload خودم بگویم محدودتر است: فایل‌ها به‌طور چشمگیری کوچک‌تر شدند و در playback عادی برای من همچنان طبیعی به نظر می‌رسیدند. این یک مشاهده عملی است، نه ادعای visually lossless بودن CRF 28.

veryslow برای encoder گران است، نه لزوماً برای decoder

preset من -preset veryslow است. preset کندتر فرصت بیشتری به x264 می‌دهد تا prediction و coding کارآمدتری پیدا کند. هزینه‌اش CPU و زمان encoding است.

تفاوت مهم این است که تلاش encoder و پیچیدگی decoder یک چیز نیستند.

می‌توانم x264 را وادار کنم بسیار سخت کار کند و در عین حال stream نهایی را جداگانه محدود کنم. قرارداد خروجی محافظه‌کارانه من این است:

H.264 Main Profile
Level 3.1
8-bit yuv420p
avc1
refs = 4
B-frames = 5
B-pyramid = normal
open GOP = disabled

FFmpeg، CRF، preset، tune، محدودیت profile، reference frame و B-frame را جداگانه ارائه می‌کند. من هم دقیقاً همین‌طور به آن‌ها نگاه می‌کنم: بگذار encoder عمیق جست‌وجو کند، اما سمت playback معمولی و قابل‌پیش‌بینی بماند.

GOP وVBV حفاظ هستند، نه کنترل اصلی کیفیت

برای این کلیپ‌های کوتاه progressive، maximum GOP من حدود پنج ثانیه است: تقریباً -g 150 در 30 fps، -g 120 در 24 fps یا -g 80 در 16 fps.

این انتخاب workload است، نه قانون جهانی. Adaptive streaming محدودیت‌های متفاوتی دارد؛ راهنمای HLS اپل، مثلاً، IDR هر دو ثانیه را توصیه می‌کند. من این قانون HLS را کورکورانه برای MP4های static و کوتاه progressive کپی نمی‌کنم.

همچنین تقریباً از این‌ها استفاده می‌کنم:

-maxrate:v 4M
-bufsize:v 8M

این اعداد سقفی برای جلوگیری از spikeهای غیرعادی bitrate هستند. معنایشان «همه چیز را با 4 Mbps encode کن» نیست. CRF همچنان allocation عادی bitrate را کنترل می‌کند، بنابراین کلیپ‌های ساده آزادند بسیار کوچک شوند.

خود container MP4 را هم عمداً ساده نگه داشتم

صریحاً از avc1 استفاده می‌کنم. مستندات فعلی HLS اپل sample formatهایی مثل avc1 را به avc3 ترجیح می‌دهد. این دلیل کوچک شدن فایل‌ها نبود، اما با هدف تولید خروجی متعارف H.264-in-MP4 هماهنگ است.

همچنین -movflags +faststart را استفاده می‌کنم. مستندات format در FFmpeg می‌گوید faststart، index مربوط به moov را به ابتدای MP4 منتقل می‌کند. الزامات HTTP streaming در Android نیز برای MPEG-4 می‌گوید moov باید بعد از ftyp و قبل از mdat باشد.

ftyp
moov
mdat

faststart compression را بهتر نمی‌کند. فقط progressive playback روی HTTP را راحت‌تر می‌کند.

برای خروجی SDR معمولی از 8-bit yuv420p استفاده می‌کنم و BT.709 با limited/video range را signal می‌کنم. اگر کلیپ صدا ندارد، audio track بی‌مصرف نمی‌سازم. برای این محتوای تصویری از -tune animation هم استفاده می‌کنم؛ آن را انتخابی مخصوص content می‌دانم، نه بخشی از compatibility contract عمومی.

پروفایل اصلی FFmpeg

برای یک source تصویری با 30 fps، بخش اصلی command تقریباً این‌طور است:

ffmpeg -i input \
  -c:v libx264 \
  -preset veryslow \
  -tune animation \
  -crf 28 \
  -profile:v main \
  -level:v 3.1 \
  -pix_fmt yuv420p \
  -tag:v avc1 \
  -refs 4 \
  -bf 5 \
  -g 150 \
  -maxrate:v 4M \
  -bufsize:v 8M \
  -x264-params "open-gop=0:b-pyramid=normal:nal-hrd=none" \
  -color_range tv \
  -color_primaries bt709 \
  -color_trc bt709 \
  -colorspace bt709 \
  -an \
  -movflags +faststart \
  output.mp4

عمداً scaling وframe-rate را در این مثال hard-code نکرده‌ام. source با 900×600 نباید فقط چون سقف 1280×720 است بزرگ شود و animation با frame rate طبیعی پایین نباید فقط چون مثال -g 150 دارد به 30 fps مجبور شود.

این command پیاده‌سازی policy است، نه خود policy.

چرا فایل‌ها چند برابر کوچک‌تر شدند

هیچ magic flagای وجود نداشت.

کاهش حجم از روی هم گذاشتن چند تصمیم آمد که هر کدام نوع متفاوتی از اتلاف را حذف کردند: pixelهای اضافی، frameهای اضافی، تفکر fixed-bitrate، تنظیمات encoder ارزان، keyframeهای بیش از حد و streamهایی که لازم نداشتم.

به همین دلیل جمله «این فایل H.264 است» اطلاعات کمی درباره حجم آن می‌دهد. دو encode H.264 از source یکسان می‌توانند به‌شدت متفاوت باشند، چون نام codec درباره resolution، frame rate، rate control، preset، ساختار GOP، profile یا آماده‌سازی source چیزی نمی‌گوید.

در مورد من، تغییر همین تصمیم‌های اطراف codec از تعویض codec مهم‌تر بود.

این نتیجه چه چیزی را ثابت نمی‌کند

هر setting را در یک آزمایش کنترل‌شده جدا نکردم، بنابراین صادقانه نمی‌توانم درصد دقیقی از صرفه‌جویی را جداگانه به veryslow، CRF 28، کاهش resolution یا کاهش frame rate نسبت دهم.

همچنین نمی‌توانم بگویم هر خروجی CRF 28 از نظر ادراکی transparent است. «افت کیفیت واضحی ندیدم» مشاهده من برای این workload تصویری در اندازه‌های عادی نمایش است، نه تضمین علمی برای هر ویدیو.

و ادعا نمی‌کنم یک فایل H.264 معماری درست برای هر وب‌سایت است. چند rendition، adaptive streaming، HDR، 4K و codec negotiation توازن را تغییر می‌دهند.

نتیجه محدودتر و کاربردی‌تر است: برای کتابخانه‌ای bandwidth-first و mobile-heavy از کلیپ‌های کوتاه تصویری و انیمیشنی، جایی که زمان encoding ارزان و playback قابل‌پیش‌بینی مهم است، این profile فایل‌های من را چند برابر کوچک‌تر کرد و در پخش عادی همچنان طبیعی به نظر می‌رسید.

قاعده‌ای که حالا استفاده می‌کنم

قبلاً به video optimization بیشتر به‌عنوان مسئله تنظیمات encoder نگاه می‌کردم. حالا آن را مسئله lifetime cost می‌بینم.

Encoder شاید یک بار اجرا شود. Byteها ممکن است هزاران یا میلیون‌ها بار از شبکه عبور کنند.

این تعریف «گران» را عوض می‌کند.

خوشحالم یک بار CPU بیشتری مصرف کنم. خیلی کمتر حاضر هستم در هر درخواست آینده pixelهای حاصل از upscale، frameهایی بدون حرکت مفید یا bitrate اضافه‌ای را بفرستم که محتوا به آن نیاز ندارد.

Codec عمداً معمولی باقی ماند: H.264 داخل MP4. بهینه‌سازی در اطراف آن اتفاق افتاد.

برای این workload، درس از هر flag منفرد FFmpeg روشن‌تر است: هزینه‌ای را بهینه کن که بارها می‌پردازی، نه هزینه‌ای را که فقط یک بار می‌پردازی.