لم أبنِ هذه الـ pipeline لأنني أردت تجربة codec جديد. المشكلة كانت أبسط وأكثر عملية: ملفات Animated WebP وGIF وAPNG كانت تؤدي عمليًا وظيفة فيديو قصير صامت، لكنها لم تكن دائمًا أفضل تمثيل للتوزيع المتكرر على الويب.
معظم المواد التي أتعامل معها قصيرة، عادة بضع ثوانٍ وعشرات الإطارات المعروضة، وفيها قدر كبير من التشابه بين لحظة وأخرى. وقت الـ encoding لا يهمني كثيرًا مقارنة بحجم الملف الذي سيُنقل لاحقًا مرات كثيرة، خصوصًا على أجهزة محمولة.
الجملة السهلة هي: «حوّل الصورة المتحركة إلى MP4». أما النسخة الصحيحة للإنتاج فأصعب بكثير. الصورة المتحركة قد لا تكون سلسلة من صور كاملة بالحجم نفسه وبـ FPS ثابت. قد تحتوي على مستطيلات جزئية، وقواعد blend/disposal، وشفافية، وتأخيرات غير منتظمة، وframes بزمن صفر، كما أن أدوات الفحص العامة قد تعرض FPS لا يمثل الـ timeline الحقيقي.
لهذا أصبحت العملية عندي سلسلة من قواعد صحة واضحة:
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 each source
↓
concat with stream copy
↓
normalize final packet timeline
↓
ffprobe + packet checks + full decode
↓
atomic publish
الـ codec ليس الجزء الأصعب. الجزء الأصعب هو الحفاظ على معنى الحركة أثناء تغيير طريقة تمثيلها.
القاعدة الأولى: أستخرج ما عُرض فعليًا، لا المستطيلات المخزنة
أخطر تبسيط هو افتراض أن كل frame داخل الصورة المتحركة صورة كاملة تستبدل الشاشة السابقة.
Animated WebP يسمح لكل frame بموقع وحجم مستطيل وblend mode وdisposal mode. APNG يملك offsets وأبعادًا وعمليات disposal وblend. وGIF يملك Graphic Control Extension يحدد، من بين أشياء أخرى، ماذا يحدث للمنطقة بعد عرض frame.
هذا يعني أن frame المخزن قد يكون مجرد patch صغير يعتمد على محتوى canvas السابق. إذا أخذت هذه الـ patches وشفرتها كما لو كانت صورًا كاملة، فأنا لا أحصل على نسخة أصغر من الحركة، بل أحصل على حركة خاطئة.
لذلك حدّ الاستخراج عندي هو displayed canvas state: الصورة الكاملة التي كان سيعرضها decoder صحيح بعد تنفيذ disposal للـ frame السابق ثم blending للـ frame الحالي.
هذه ليست تفصيلة decoding صغيرة. إنها أول invariant في كل الـ pipeline. إذا أصبحت pixels خاطئة في هذه المرحلة جزءًا من H.264 فلن يستطيع أي CRF أو preset إصلاحها لاحقًا.
توقيت الإطارات هو بيانات المصدر، وليس رقم FPS نتخيله
الخطأ الثاني كان الثقة العمياء في FPS تعرضه أداة probe عامة.
- Animated WebP يخزن مدة كل frame بوحدات 1 ms.
- GIF يخزن delay بأجزاء من مئة من الثانية.
- APNG يخزن delay numerator وdenominator لكل frame؛ وإذا كان denominator صفرًا فالمواصفة تعامل القيمة كأنها 100.
هذه التأخيرات هي الـ timeline الحقيقي. الـ FPS المتوسط مجرد تلخيص، وأحيانًا يكون مضللًا.
في حالة حقيقية كان المصدر 1264×720 ويحتوي 49 displayed frames. التأخيرات كانت تتناوب بين 62 و63 ms، والمدة الكلية 3.063 ثانية. هذا يعادل cadence قريبًا جدًا من 16 fps لأن frame عند 16 fps مدته 62.5 ms.
إحدى أدوات probe أعطت 25 fps.
لو اعتمدت هذا الرقم وحولت الملف مباشرة إلى 25 أو 30 fps لكنت غيّرت timing أو أضفت frames مكررة بلا فائدة. المصدر نفسه كان يملك الحقيقة: frame durations.
أصلح timing غير المفيد قبل تحويله إلى CFR
بعض القيم لا تصلح كتعليمات playback حرفية. WebP يسمح بمدة صفر، والقيم الصغيرة جدًا قد تختلف طريقة التعامل معها بين implementations. GIF قد يملك zero delay، وAPNG يملك أيضًا معنى «اعرض التالي بأسرع ما يمكن» عندما يكون numerator صفرًا.
لذلك أحتاج policy واضحة. أحتفظ بالتوقيت بدقة milliseconds، وأستخدم حدًا أدنى قدره 10 ms للـ frame، و100 ms كـ fallback فقط عندما لا توجد مدة مفيدة أصلًا.
هذه الأرقام ليست قانونًا عالميًا. المهم أن normalization يحدث قبل اختيار FPS، وأن timing الصحيح لا يُستبدل بـ default مريح.
لماذا لا أحول كل شيء إلى 30 fps
بعد استخراج displayed states ومددها، أحتاج تمثيلها على video timeline. أسهل حل هو 30 fps للجميع، لكنه هدر بالنسبة لمحتوى يتحرك فعليًا 12 أو 16 أو 20 مرة في الثانية.
H.264 سيضغط duplicate frames جيدًا، لكن وجود بيانات زمنية زائدة يظل عبئًا لا أحتاجه.
أختار CFR مستقلًا لكل source من هذه المجموعة:
10, 12, 15, 16, 18, 20, 24, 25, 30 fps
الخوارزمية تبدأ من الـ source timeline وتختار أقل candidate يمثل transitions بشكل نظيف. إذا لم ينجح شيء أقل، فالـ fallback هو 30 fps.
في مثال 62/63 ms، اختيار 16 fps طبيعي جدًا: 62.5 ms لكل frame.
الملف النهائي لا يحتاج FPS عالميًا واحدًا
كل animation يُشفّر كـ segment مستقل. واحد قد يكون 12 fps، والآخر 16، وآخر 24 أو 30. أنا لا أفرض cadence واحدًا على المجموعة كلها فقط لتسهيل concat.
النتيجة النهائية هي سلسلة من segments ذات CFR محلي. من منظور الملف الكامل يمكن أن يتغير packet duration عند الحدود، وهذا مقبول لأن الـ timeline النهائي هو المرجع.
الشرط هو أن تكون كل هذه المعدلات ممثلة بدقة ومن دون rounding عشوائي.
لماذا أستخدم time base قدره 90,000 Hz
اخترت video/movie timescale يساوي 90,000 لأن كل FPS مسموح به يقسمه إلى عدد صحيح من ticks لكل frame:
| FPS | ticks/frame |
|---|---|
| 10 | 9000 |
| 12 | 7500 |
| 15 | 6000 |
| 16 | 5625 |
| 18 | 5000 |
| 20 | 4500 |
| 24 | 3750 |
| 25 | 3600 |
| 30 | 3000 |
هذا يجعل كل frame تقع على grid رياضي واضح، وهو مهم جدًا لاحقًا عند concat والتحقق من packets.
Geometry: أصغّر عند الحاجة ولا أعمل upscale أبدًا
بعد حل timing، أحل geometry. الحدود عندي تقريبًا:
- landscape: حتى 1280×720
- portrait: حتى 720×1280
- mixed / square: حتى 960×960
إذا كان المصدر أصغر، أبقيه أصغر. لا يوجد معنى لتحويل 900×600 إلى 1280×720: لا تظهر تفاصيل جديدة، لكن encoder يرى pixels أكثر.
كما لا أعمل stretch لتغيير aspect ratio. عند الحاجة أستخدم scaling متناسبًا ثم padding. وعندما يكون المصدر ذا alpha، يتم flatten على background واضح لأن H.264/yuv420p في هذا الـ pipeline ليس قناة transparency.
لماذا يمكن أن يكون H.264 MP4 أصغر بكثير لهذه المواد
GIF وAPNG وAnimated WebP ليست formats غبية؛ هي نفسها تستطيع استخدام partial updates وdisposal لتجنب تخزين canvas كامل دائمًا. لذلك لا أقول إن MP4 أصغر في كل حالة.
لكن H.264 مصمم أساسًا لاستغلال temporal redundancy بين الصور المتتابعة. عندما تبقى الخلفية ثابتة ويتغير جزء صغير من الرسم، inter prediction وreference pictures وP/B frames تناسب هذا النوع من البيانات جدًا.
Apple توصي حاليًا باستخدام H.264 MP4 للفيديو الثابت على الويب بدل animated GIF، وتذكر في توثيق Safari أن GIF يمكن أن يستهلك حتى 12× bandwidth وحوالي 2× energy مقارنة modern video codec. هذا الرقم مثال من Apple وليس benchmark مني.
أما Animated WebP فيحتاج حذرًا أكبر. إذا كان WebP الأصلي lossy، فإن H.264 يصبح lossy generation ثانية. قد يظل الحجم أقل بكثير عندما يكون temporal redundancy عاليًا، لكن لا يجوز افتراض الجودة؛ يجب قياسها ومراجعتها.
H.264 profile عندي محافظ عمدًا
| الإعداد | القيمة |
|---|---|
| Container | MP4 |
| Codec | H.264 / libx264 |
| Sample entry | avc1 |
| Profile / level | Main @ 3.1 |
| Pixel format | 8-bit yuv420p |
| Rate control | CRF 28 |
| Preset | veryslow |
| Tune | animation |
| Reference frames | 4 |
| Maximum B-frames | 5 |
| Open GOP | off |
| B-pyramid | normal |
| Maximum GOP | حوالي 5 ثوانٍ |
| VBV | 4M maxrate / 8M bufsize |
| Color | BT.709, limited range |
| Track/movie timescale | 90,000 |
| Audio/subtitles/data | none |
Main@3.1 ليس الحد الأعلى للأجهزة الحديثة. هو envelope محافظ اخترته لملف واحد واسع التوافق. Android يطلب Main Profile decoder من Android 6.0 فما بعد، ويعرض 1280×720@30 fps كإعداد H.264 HD موصى به. Apple توصي أيضًا بـ H.264 MP4 للفيديو الثابت على الويب.
أدفع CPU مرة واحدة كي أوفر bandwidth لاحقًا
-preset veryslow
هذه الملفات تُشفّر مرة وتُرسل مرات كثيرة، لذلك لا أرى سببًا لتوفير CPU في مرحلة offline encoding إذا كان x264 يستطيع مقابل ذلك إيجاد representation أكفأ.
هذا لا يعني جعل decoder معقدًا؛ decoder complexity محكومة منفصلًا بالـ profile والlevel والrefs والB-frame structure.
CRF 28 أيضًا baseline لمحتوى illustrated وbandwidth-first عندي، وليس رقمًا عامًا لكل أنواع الفيديو.
كل animation يصبح H.264 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
قبل هذا الأمر تكون frame sequence المبنية على الـ timeline وgeometry النهائية جاهزة. قيمة GOP تكون تقريبًا selectedFps * 5.
concat يعمل فقط لأن contract بين segments صارم
FFmpeg concat demuxer يتوقع streams متوافقة ويستخدم duration كل file لتحديد timestamps لما بعده. لذلك لا أعالج incompatibility عند concat؛ يجب رفض segment غير المطابق قبل ذلك.
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy \
-an -sn -dn \
-movflags +faststart \
final.mp4
-c:v copy مهم لأنه يمنع H.264 lossy encode ثانية بعد أن تم بالفعل تشفير كل source.
stream copy لا يعني أن timestamps ستصبح مثالية تلقائيًا
هذا خطأ تعلمته من validator فعلي.
90000 / 24 = 3750 ticks
في output مدمج حقيقي ظهر packet مدته 3751 ticks بدل 3750. بصريًا كان من السهل ألا تلاحظ شيئًا، لكن invariant الذي صممته للـ exact 90-kHz grid كان مكسورًا.
الدرس: الحفاظ على compressed H.264 payload لا يضمن أن muxed timestamps بعد تحريك حدود segments ستقع تلقائيًا على grid المطلوب.
لذلك concat النهائي يستخدم الآن setts bitstream filter لتطبيع PTS وDTS وduration إلى timeline معروف مع استمرار -c:v copy. لا أقدم expression واحدًا عامًا لأن التعبير الصحيح يعتمد على segment timeline المعروف.
التحقق جزء من encoder، وليس خطوة تجميلية بعده
FFmpeg exit code 0 لا يكفيني. أفحص stream metadata، profile/level، pixel format، dimensions، time base، frame/packet counts، packet durations، PTS/DTS، final duration، ثم أفك تشفير الملف كاملًا.
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 -
النشر atomic ولا يحدث إلا بعد نجاح كل هذه الاختبارات.
الأخطاء التي تمنعها هذه الـ pipeline
- تشفير raw subframes بدل displayed canvas.
- الثقة في FPS مخمن.
- إجبار كل شيء على 30 fps.
- upscale لمصادر صغيرة.
- تشويه aspect ratio.
- إعادة lossy encode بعد concat.
- اعتبار
-c copyدليلًا على صحة timing. - فحص header فقط من دون full decode.
أشياء اخترت عمدًا ألا أحافظ عليها
الـ alpha يتم flatten. الـ irregular VFR يتحول إلى source-specific CFR approximation. الصور الأكبر من envelope تُصغّر. lossy WebP يمر بجيل lossy إضافي. الصوت خارج نطاق هذه المسألة.
هذه ليست آثارًا جانبية مخفية؛ إنها trade-offs صريحة يجب قبولها قبل استخدام هذه الطريقة.
متى لا أستخدم هذه الطريقة
لن أستخدمها مباشرة إذا كانت transparency مطلوبة، أو إذا كان irregular timing نفسه يحمل معنى لا يجوز تقريبه، أو إذا كنت أُنشئ archival master، أو إذا كانت المنظومة تملك أصلًا adaptive multi-codec video delivery.
ولو كان Animated WebP محسّنًا وصغيرًا جدًا أصلًا، فسأقارن الحجم والجودة بدل أن أفترض أن MP4 سيفوز.
الـ pipeline العملية التي أستخدمها الآن
- قراءة animation metadata الحقيقية.
- إعادة بناء كل visible canvas state باستخدام blend/disposal.
- تنظيف delays وبناء timeline بالـ milliseconds.
- اختيار CFR لكل source من 10/12/15/16/18/20/24/25/30.
- حساب resize/pad بلا upscale وflatten للـ alpha.
- تشفير H.264 Main@3.1 / yuv420p / avc1 باستخدام
veryslowوCRF 28. - استخدام 90-kHz time base.
- التحقق من كل segment.
- concat باستخدام
-c:v copy. - تطبيع final timeline إلى exact grid.
- packet inspection وfull decode.
- النشر بعد النجاح فقط.
الخلاصة الأهم: الـ asset الحقيقي هو الـ timeline
Animated WebP وGIF وAPNG ليست «مجموعة صور» فقط. هي سلسلة من canvas states مرتبطة بالزمن.
عندما أتعامل مع هذه الـ timeline كمصدر الحقيقة، تصبح القرارات الأخرى أوضح: أخفض cadence من دون اختراع motion، وأخفض resolution من دون اختراع pixels، وأسمح لكل source بأن يملك CFR الخاص به، وأجمع segments من دون lossy encode جديدة.
H.264 يساعدني على استغلال التشابه الزمني، وMP4 يجعل delivery بسيطة. لكن إذا كسرتُ compositing أو timing قبل الوصول إلى encoder فلن ينقذني أي codec.
الـ codec هو الجزء السهل نسبيًا. العمل الهندسي الحقيقي هو الحفاظ على معنى الحركة أثناء تغيير representation.