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

چطور Animated WebP، GIF و APNG را به H.264 MP4 کوچک و مطمئن برای مرورگر تبدیل می‌کنم

Pipeline تولید من displayed canvas state و source timing واقعی را بازسازی می‌کند، برای هر source CFR جداگانه انتخاب می‌کند، upscale نمی‌کند، segmentهای H.264 مستقل می‌سازد، بدون lossy encode دوم concat می‌کند و قبل از انتشار exact packet timeline را validate می‌کند.

H.264FFmpegMP4Animated WebPGIFAPNGفشرده‌سازی ویدئوپردازش رسانه

این pipeline را برای بازی با codecها نساختم. مسئله عملی‌تر بود: Animated WebP، GIF و APNG در عمل اغلب نقش ویدئوی کوتاه و بی‌صدا را داشتند، اما برای فایلی که بارها از وب دانلود می‌شود لزوماً ارزان‌ترین representation نبودند.

محتوای من معمولاً چند ثانیه طول دارد، چند ده displayed frame دارد و بین frameهای متوالی مقدار زیادی temporal redundancy دیده می‌شود. Encoding فقط یک بار انجام می‌شود، ولی delivery می‌تواند بارها تکرار شود؛ بنابراین اندازه خروجی و playback قابل پیش‌بینی برایم مهم‌تر از سرعت encode است.

«animation را به MP4 تبدیل کن» ساده به نظر می‌رسد. نسخه production آن ساده نیست. Animated image الزاماً مجموعه‌ای از full-size frameها با FPS تمیز نیست. ممکن است partial rectangle، blend/disposal rule، alpha، delay نامنظم، frame با duration صفر و metadata زمان‌بندی داشته باشد که ابزارهای عمومی آن را با یک FPS گمراه‌کننده خلاصه کنند.

در نهایت pipeline من به این زنجیره تبدیل شد:

animated WebP / GIF / APNG
        ↓
decode + reconstruct displayed canvas states
        ↓
recover source timeline
        ↓
select per-source CFR
        ↓
resize / pad without upscaling
        ↓
encode one H.264 segment
        ↓
repeat independently for every source
        ↓
concat with stream copy
        ↓
normalize final packet timeline
        ↓
ffprobe + packet checks + full decode
        ↓
atomic publish

Codec فقط یک بخش است. بخش سخت این است که هنگام عوض کردن representation، معنی واقعی animation حفظ شود.

قاعده اول: displayed frame را استخراج می‌کنم، نه rectangle ذخیره‌شده را

خطرناک‌ترین ساده‌سازی این است که فرض کنیم هر frame داخل animated image یک تصویر کامل برای جایگزینی canvas قبلی است.

Animated WebP برای frame موقعیت، اندازه rectangle، blend mode و disposal mode دارد. APNG offset، dimensions، dispose operation و blend operation دارد. GIF هم در Graphic Control Extension رفتار disposal را مشخص می‌کند.

پس stored frame ممکن است فقط patch کوچکی باشد که به canvas قبلی وابسته است. اگر همین patch را full frame فرض کنم و encode کنم، animation کوچک‌تر نمی‌شود؛ animation اشتباه می‌شود.

مرز extraction من بنابراین displayed canvas state است: تصویری کامل که decoder درست بعد از اعمال disposal قبلی و blending فعلی واقعاً به کاربر نشان می‌دهد.

اگر این مرحله اشتباه باشد، هیچ CRF یا preset بعدی نمی‌تواند pixels اشتباه را تعمیر کند.

Timing هر frame داده source است، نه FPS حدس‌زده‌شده

اشتباه بعدی اعتماد کردن به FPS بود که یک probe عمومی گزارش می‌کرد.

  • Animated WebP duration هر frame را با واحد 1 ms ذخیره می‌کند.
  • GIF delay را با واحد صدم ثانیه نگه می‌دارد.
  • APNG برای delay هر frame numerator و denominator دارد؛ denominator صفر طبق PNG specification برابر 100 در نظر گرفته می‌شود.

همین delayها timeline هستند. Average FPS فقط summary است.

یک WebP واقعی 1264×720 بود و 49 displayed frame داشت. delayها بین 62 و 63 ms جابه‌جا می‌شدند و duration کل 3.063 s بود. این عملاً cadence نزدیک 16 fps است، چون هر frame در 16 fps برابر 62.5 ms است.

یک probe برای همان فایل 25 fps گزارش کرد.

اگر به آن عدد اعتماد می‌کردم و 25 یا 30 fps encode می‌کردم، timing source را عوض می‌کردم یا duplicate frame غیرضروری می‌ساختم. حقیقت در frame durationهای source بود.

قبل از CFR، timing نامعتبر را normalize می‌کنم

Zero delay و delayهای بسیار کوچک در همه implementationها playback یکسانی ندارند. WebP duration صفر را اجازه می‌دهد، GIF zero delay دارد و APNG هم می‌تواند numerator صفر داشته باشد.

برای production policy مشخص لازم است. Timeline را با دقت millisecond نگه می‌دارم، minimum frame delay را 10 ms می‌گذارم و فقط وقتی timing مفیدی وجود ندارد از fallback برابر 100 ms استفاده می‌کنم.

این دو عدد قانون جهانی نیستند. اصل مهم این است که timing معتبر حفظ شود و normalization قبل از frame-rate quantization انجام شود.

چرا همه animationها را 30 fps نمی‌کنم

بعد از داشتن displayed states و durationها باید timeline تصویر را روی timeline ویدئو نگاشت کنم. 30 fps برای همه ساده است، اما برای animationی که واقعاً 12 یا 16 بار در ثانیه تغییر می‌کند waste است.

H.264 duplicate frame را خوب compress می‌کند، ولی اطلاعات زمانی اضافه همچنان بی‌فایده است.

برای هر source به‌طور مستقل از این candidateها CFR انتخاب می‌کنم:

10, 12, 15, 16, 18, 20, 24, 25, 30 fps

Selector پایین‌ترین candidate را انتخاب می‌کند که تغییرات timeline را تمیز نمایش دهد. اگر هیچ گزینه پایین‌تر مناسب نباشد، fallback برابر 30 fps است.

در مثال 62/63 ms، 16 fps انتخاب طبیعی است چون frame duration آن 62.5 ms است.

Final MP4 لازم نیست یک CFR جهانی داشته باشد

هر animation جداگانه encode می‌شود. یکی می‌تواند 12 fps باشد، دیگری 16 و یکی 24 یا 30. من cadence یک source را فقط برای یکسان کردن collection تغییر نمی‌دهم.

هر segment داخل خودش CFR است، اما final timeline می‌تواند در مرز segmentها packet duration متفاوت داشته باشد. این رفتار عمدی است.

چرا time base را 90,000 Hz گذاشتم

90,000 برای همه FPSهای مجاز من integer ticks/frame تولید می‌کند:

FPSticks/frame
109000
127500
156000
165625
185000
204500
243750
253600
303000

این grid دقیق بعداً برای concat و packet validation بسیار ارزشمند است.

Geometry: downscale می‌کنم، ولی هرگز upscale نمی‌کنم

سقف‌های خروجی من تقریباً این‌ها هستند:

  • landscape: تا 1280×720
  • portrait: تا 720×1280
  • mixed/square: حدود 960×960

اگر source کوچک‌تر باشد، همان کوچک می‌ماند. تبدیل 900×600 به 1280×720 جز اضافه کردن pixel برای encoder، detail تازه‌ای نمی‌سازد.

Aspect ratio را stretch نمی‌کنم. در صورت نیاز proportional scaling و padding انجام می‌دهم. Alpha هم برای H.264/yuv420p روی background مشخص flatten می‌شود.

چرا H.264 MP4 برای این workload اغلب خیلی کوچک‌تر می‌شود

GIF، APNG و Animated WebP خودشان هم partial update و disposal دارند، پس «MP4 همیشه کوچک‌تر است» ادعای درستی نیست.

اما H.264 برای temporal compression ویدئو ساخته شده است. وقتی background تقریباً ثابت است و بخش کوچکی حرکت می‌کند، inter prediction، reference pictures و P/B frames دقیقاً از همین redundancy استفاده می‌کنند.

Apple برای static web video استفاده از H.264 MP4 را توصیه می‌کند و می‌گوید به‌جای animated GIF از MP4 استفاده شود. در documentation Safari آمده GIF می‌تواند تا 12× bandwidth و حدود 2× energy نسبت به modern video codec مصرف کند. این 12× benchmark من نیست؛ مثال خود Apple است.

برای Animated WebP احتیاط بیشتری لازم است. اگر WebP source lossy باشد، H.264 یک lossy generation دوم ایجاد می‌کند. ممکن است با temporal redundancy زیاد فایل بسیار کوچک‌تر شود، اما quality باید validate شود.

H.264 profile عمداً محافظه‌کارانه است

SettingValue
ContainerMP4
CodecH.264 / libx264
Sample entryavc1
Profile / levelMain @ 3.1
Pixel format8-bit yuv420p
Rate controlCRF 28
Presetveryslow
Tuneanimation
Reference frames4
Maximum B-frames5
Open GOPoff
B-pyramidnormal
Maximum GOPحدود 5 s
VBV4M maxrate / 8M bufsize
ColorBT.709, limited range
Track/movie timescale90,000
Audio/subtitles/datanone

Main@3.1 سقف توان دستگاه‌های جدید نیست؛ baseline محافظه‌کارانه من برای یک فایل سازگار است. Android از Android 6.0 به بعد Main Profile decoder را الزامی کرده و 1280×720@30 fps را به‌عنوان H.264 HD recommendation نشان می‌دهد. Apple نیز برای static web video H.264 MP4 را توصیه می‌کند.

CPU را یک بار خرج می‌کنم تا bandwidth را بارها ذخیره کنم

-preset veryslow

Encoding offline فقط یک بار انجام می‌شود، delivery بارها. بنابراین دادن CPU بیشتر به x264 برای پیدا کردن representation بهتر منطقی است. Decoder complexity جداگانه با profile، level، refs و B-frame structure محدود شده است.

CRF 28 هم baseline workload illustrated و bandwidth-first من است، نه عددی جهانی برای همه ویدئوها.

هر animation یک segment مستقل می‌شود

ffmpeg -framerate "$SELECTED_FPS" -i frame-%06d.png \
  -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 "$GOP_FRAMES" \
  -maxrate:v 4M \
  -bufsize:v 8M \
  -x264-params "stitchable=1:open-gop=0:b-pyramid=normal:nal-hrd=none" \
  -color_range tv \
  -color_primaries bt709 \
  -color_trc bt709 \
  -colorspace bt709 \
  -fps_mode passthrough \
  -map_metadata -1 \
  -map_chapters -1 \
  -an -sn -dn \
  -video_track_timescale 90000 \
  -movie_timescale 90000 \
  -t "$EXPECTED_DURATION" \
  segment.mp4

قبل از این command، timeline-aware frame sequence و geometry نهایی آماده شده‌اند. GOP تقریباً selectedFps * 5 است.

Concat فقط با contract سخت بین segmentها امن است

FFmpeg concat demuxer به streams سازگار نیاز دارد و duration هر file را برای timestamp file بعدی استفاده می‌کند. بنابراین mismatch را هنگام concat حل نمی‌کنم؛ segment ناسازگار قبل از آن رد می‌شود.

ffmpeg -f concat -safe 0 -i segments.ffconcat \
  -c:v copy \
  -an -sn -dn \
  -movflags +faststart \
  final.mp4

-c:v copy باعث می‌شود بعد از encode مستقل sourceها، یک H.264 lossy encode دوم اتفاق نیفتد.

Stream copy به معنی timestamp بی‌نقص نیست

90000 / 24 = 3750 ticks

Validator در یک output واقعی یک packet با 3751 ticks پیدا کرد، در حالی که 24 fps باید دقیقاً 3750 باشد. از نظر بصری ممکن بود چیزی دیده نشود، اما exact-grid invariant شکسته بود.

پس copy شدن compressed H.264 payload، correctness timestamp را ثابت نمی‌کند.

Final concat اکنون از setts bitstream filter استفاده می‌کند تا PTS، DTS و duration روی grid شناخته‌شده normalize شوند، در حالی که -c:v copy حفظ می‌شود. Expression دقیق از timeline segmentها تولید می‌شود و recipe عمومی نیست.

Validation بخشی از encoder است

Exit code صفر از FFmpeg کافی نیست. Stream metadata، profile/level، pixel format، dimensions، time base، frame/packet count، exact durationها، PTS/DTS و final duration را بررسی می‌کنم و بعد کل فایل را decode می‌کنم.

ffprobe -v error \
  -select_streams v:0 \
  -show_streams \
  -show_packets \
  -of json \
  final.mp4
ffmpeg -v error -xerror -err_detect explode -i final.mp4 -f null -

فقط بعد از عبور همه checks، atomic publish انجام می‌شود.

این pipeline چه اشتباه‌هایی را متوقف می‌کند

  • Encode کردن raw subframe به‌جای visible canvas.
  • اعتماد به FPS حدس‌زده‌شده.
  • تبدیل همه sourceها به 30 fps.
  • Upscale کردن source کوچک.
  • Stretch کردن aspect ratio.
  • Lossy encode دوباره بعد از concat.
  • فرض اینکه -c copy timing را ثابت می‌کند.
  • فقط header check بدون full decode.

چیزهایی که عمداً preserve نمی‌کنم

Alpha flatten می‌شود. VFR نامنظم به source-specific CFR approximation تبدیل می‌شود. Resolution بزرگ downscale می‌شود. Lossy WebP یک lossy generation اضافه می‌گیرد. Audio خارج از scope است.

این‌ها trade-offهای صریح هستند، نه محدودیت‌هایی که پنهان کرده باشم.

چه زمانی این روش را استفاده نمی‌کنم

اگر transparency باید حفظ شود، اگر exact irregular timing معنای ضروری داشته باشد، اگر خروجی archival master باشد، یا اگر سیستم از قبل adaptive multi-codec delivery دارد، این pipeline single-file را مستقیم انتخاب نمی‌کنم.

برای Animated WebP خیلی کوچک و بهینه هم ابتدا benchmark می‌کنم؛ فرض نمی‌کنم MP4 حتماً کوچک‌تر است.

Pipeline عملی فعلی

  1. خواندن animation metadata واقعی.
  2. بازسازی visible canvas states با blend/disposal.
  3. Normalize کردن delay و ساخت timeline میلی‌ثانیه‌ای.
  4. انتخاب CFR هر source از 10/12/15/16/18/20/24/25/30.
  5. No upscale، proportional resize/pad و alpha flatten.
  6. H.264 Main@3.1 / yuv420p / avc1 با veryslow و CRF 28.
  7. 90-kHz time base.
  8. Validation هر segment.
  9. Concat با -c:v copy.
  10. Normalize کردن final packet timeline روی exact grid.
  11. Packet inspection + full decode.
  12. Publish فقط بعد از pass کامل.

نتیجه مهم‌تر: asset واقعی timeline است

Animated WebP، GIF و APNG فقط «چند تصویر» نیستند. آن‌ها sequence زمانی از canvas stateها هستند.

وقتی timeline را source of truth بگیرم، بقیه design روشن می‌شود: cadence را بدون ساختن motion کم می‌کنم، resolution را بدون ساختن pixel کم می‌کنم، هر source CFR خودش را دارد و concat به encode lossy دوباره نیاز ندارد.

H.264 temporal redundancy را بهتر استفاده می‌کند و MP4 delivery را ساده می‌کند، اما اگر compositing یا timing در مرحله extraction خراب شود، هیچ codec بعدی آن را نجات نمی‌دهد.

Codec بخش نسبتاً ساده است. مهندسی واقعی، حفظ معنی animation هنگام تغییر representation است.

مراجع اولیه اصلی