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

یک تیک که CFR من را شکست: چرا 5580 برابر 5625 نبود و 3751 برابر 3750 نشد

اعتبارسنج من بارها MP4 با نرخ 16 فریم‌برثانیه را رد کرد، چون مدت یک بسته 5580 تیک بود نه 5625؛ بعدتر هم بسته‌ای با 3751 تیک پیدا کرد، در حالی که 24 فریم‌برثانیه دقیقاً 3750 می‌خواست. این دو خطا باعث شدند زمان‌بندی منبع، کوانتیزه‌کردن CFR، مبنای زمان MP4، PTS/DTS، مالتی‌پلکس‌کردن و اعتبارسنجی در سطح بسته را از هم جدا کنم.

FFmpegH.264CFRبرچسب‌های زمانی ویدئوPTS و DTSاعتبارسنجی MP4

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

Invalid CFR packet duration: 5580 ticks, expected 5625

رمزگشا از کار نیفتاده بود. FFmpeg فایل H.264 را ساخته بود. این اعتبارسنج بود که پس از کدگذاری فایل را رد کرد، چون خروجی باید نرخ فریم ثابت می‌داشت اما مدت یکی از بسته‌ها روی شبکه زمانی طراحی‌شده قرار نگرفته بود.

کار را دوباره اجرا کردم؛ باز هم 5580. بار سوم هم همان. بعدتر منبع دیگری نیز با همین مقدار شکست خورد. این تکرار سرنخ مهمی بود: با خطای گذرای شبکه یا شرایط رقابتی نادر طرف نبودم. نقض قرارداد قطعی و تکرارپذیر بود.

بعداً خطای دومی پیدا کردم که ظاهراً شبیه اولی بود اما ماهیت متفاوتی داشت. در 24 fps و ترک با مقیاس زمانی برابر 90,000، مدت هر نمونه عادی باید دقیقاً 3750 تیک باشد. اعتبارسنج مقدار 3751 را دید.

این دو را نباید با جمله مبهم «FFmpeg گرد می‌کند» یکی کرد. اختلاف 5580 و5625 برابر 45 تیک یا دقیقاً 0.5 میلی‌ثانیه است. اختلاف 3751 و3750 فقط یک تیک، یعنی حدود 11.1 میکروثانیه است.

این دو باگ مجبورم کردند چهار مفهومی را که قبلاً زیر عنوان کلی «FPS» با هم قاطی می‌کردم از هم جدا کنم: نرخ فریم، مبنای زمان، PTS/DTS و مدت بسته.

CFR فقط برچسب «16 fps» نیست

وقتی امروز می‌گویم فایل تولیدشده CFR است، منظورم فقط این نیست که ffprobe عدد خوش‌ظاهر 16/1 یا 24/1 نشان می‌دهد.

در این خط پردازش، CFR یک قرارداد دقیق‌تر است: زمان‌های نمایش روی شبکه‌ای منظم می‌افتند و مدت نمونه عادی ویدئو یک گام از همان شبکه است.

در 16 fps مدت هر فریم:

1 / 16 = 0.0625 s = 62.5 ms

اگر ترک از 90,000 تیک در ثانیه استفاده کند، همان بازه عدد صحیح دقیقی می‌شود:

90000 / 16 = 5625 ticks

پس 5625 عدد دلخواه اعتبارسنج نبود. مستقیماً از دو انتخاب قرارداد خروجی می‌آمد: 16 fps و 90,000 ticks/s.

این سخت‌گیری فقط زمانی منطقی است که نرخ هدف مقیاس زمانی را دقیق تقسیم کند. اگر ریتم نمایش را نتوان با یک مدت صحیح نمایش داد، اعتبارسنج باید الگوی صحیحِ اعداد صحیح را بررسی کند، نه مقداری را که اصلاً قابل تحقق نیست.

نرخ فریم، مبنای زمان و مقیاس زمانی MP4 یکی نیستند

  • نرخ فریم ریتم نمایش را توصیف می‌کند؛ CFR 16 یعنی هر 62.5 ms یک فریم نمایش داده می‌شود.
  • مبنای زمان در FFmpeg مدت یک واحد صحیح برچسب زمانی است؛ مثلاً 1/90000 ثانیه.
  • مقیاس زمانی در ترک MP4 همان مفهوم را به‌صورت تعداد واحد در ثانیه بیان می‌کند؛ 90,000 یعنی هر واحد 1/90000 ثانیه.
  • PTS زمان نمایش تصویر را مشخص می‌کند.
  • DTS زمان لازم برای رمزگشایی بسته فشرده را مشخص می‌کند.
  • مدت بسته مدت نمونه را برحسب مبنای زمان جریان بیان می‌کند.

با B-فریم‌ها، اختلاف PTS و DTS کاملاً قانونی است. بنابراین «اصلاح برچسب زمانی» با PTS = DTS می‌تواند خرابکاری کند؛ مستندات رسمی setts هم هشدار می‌دهد که چنین کاری هنگام وجود B-فریم توصیه نمی‌شود.

اعتبارسنج من به‌جای برابرکردن این دو، رابطه قانونی بین ترتیب رمزگشایی و نمایش را بررسی می‌کند.

چرا مقیاس زمانی برابر 90,000 را انتخاب کردم

90,000 عدد جادویی جهانی نیست. مزیتش برای من این بود که تمام نرخ‌های مجاز به مدت صحیح تبدیل می‌شدند:

نرخمدت فریم در 90,000 تیک/s
10 fps9000 تیک
12 fps7500 تیک
15 fps6000 تیک
16 fps5625 تیک
18 fps5000 تیک
20 fps4500 تیک
24 fps3750 تیک
25 fps3600 تیک
30 fps3000 تیک

همچنین رابطه ساده‌ای با زمان‌بندی منبع برحسب میلی‌ثانیه دارد:

1 ms = 90 ticks

مالتی‌پلکسر MP4 در FFmpeg گزینه video_track_timescale دارد، بنابراین می‌توانم این شبکه را صریحاً درخواست کنم. شبکه به‌تنهایی زمان‌بندی را درست نمی‌کند؛ فقط قرارداد را قابل اندازه‌گیری می‌کند.

عدد 5580 به من گفت کجا را نگاه کنم

اگر خطای اول را دوباره به زمان تبدیل کنیم، معنی‌دار می‌شود:

5580 / 90000 = 0.062 s = 62 ms

این مقدار تصادفی نبود. یک منبع متحرک واقعی داشتم با 49 فریم نمایشی در 3.063 ثانیه که تأخیر فریم‌هایش بین 62 و 63 ms جابه‌جا می‌شد:

49 / 3.063 ≈ 15.997 frames/s

62 ms + 63 ms = 125 ms
2 frames at 16 fps = 2 × 62.5 ms = 125 ms

در سطح منبع، توالی 62/63 ms تقریب بسیار منطقیِ 16 fps روی شبکه میلی‌ثانیه‌ای است.

اما پس از انتخاب CFR 16، خروجی باید روی شبکه هدف زندگی کند: 62.5 ms یا 5625 تیک برای هر فریم عادی.

بنابراین 5580 سرنخ قوی‌ای بود که مدت 62 ms از منبع تا مرحله‌ای زنده مانده که باید پیش از آن زمان‌بندی به CFR کوانتیزه شده باشد.

مرز شواهد را عمداً روشن نگه می‌دارم: گزارش ثابت می‌کند 5580 در 90 kHz دقیقاً 62 ms است و منبع واقعی هم تأخیرهای 62/63 ms داشت. اما همان خط گزارش ثابت نمی‌کند کدام تابع دقیقاً اجازه عبور آن مقدار را داده است. این یک استنباط قوی درباره سازوکار است، نه مجوزی برای ساختن علت خیالی.

ورودی 1000 Hz اشتباه نبود

این بخش را جداگانه با FFmpeg 7.1.5 و ffconcat بازتولید کردم. با:

duration 0.010
option framerate 1000

timestampهای بسته موقعیت‌های موردنظر در مقیاس میلی‌ثانیه را حفظ کردند:

0 ms
10 ms
20 ms
30 ms

اگر در همان مرحلهٔ اولیه نرخ ورودی 30 می‌گذاشتم، همان زمان‌بندی تقریباً به 0 و 33.3 ms کوانتیزه می‌شد.

پس ورودی 1000 Hz کار درست را انجام می‌داد: تأخیرهای معتبر منبع را با دقت 1 ms نگه می‌داشت. این هیچ معنایی درباره خروجی 1000 fps نداشت.

مرز معماری باید صریح باشد:

millisecond-accurate source delays
        ↓
authoritative source timeline
        ↓
choose target CFR
        ↓
explicitly quantize onto CFR grid
        ↓
preserve that grid through encoding and muxing

دقت بالای ورودی مشکل نیست؛ مشکل مبهم‌گذاشتن تبدیل منبع زمان‌بندی به CFR است.

CFR یعنی کوانتیزه‌کردن کنترل‌شده زمان

منبع 62/63 ms در جفت‌فریم‌ها به‌طور طبیعی با 16 fps جور می‌شود؛ هر دو 125 ms را پوشش می‌دهند. اما فایل نهایی نمی‌تواند مدت‌های 62، 63، 62، 63 ms را نگه دارد و فقط برچسب «16 fps» بزند؛ این مدت‌ها همچنان متغیرند.

62 ms, 63 ms, 62 ms, 63 ms
              ↓
62.5 ms, 62.5 ms, 62.5 ms, 62.5 ms

فیلتر fps در FFmpeg محل معقولی برای این تبدیل است. با توجه به timestampهای ورودی و سیاست گردکردن انتخاب‌شده، فریم‌ها را حذف یا تکرار می‌کند تا نرخ هدف ساخته شود.

بعد از آن دیگر نمی‌خواهم مرحله‌ای دیگر مستقلاً دوباره تبدیل نرخ فریم انجام دهد. باید یک مرز آگاهانهٔ کوانتیزه‌کردن وجود داشته باشد، نه چند لایه با تصمیم‌های مستقل دربارهٔ گردکردن.

چرا سه تلاش مجدد هیچ تغییری نداد

FAIL: duration 5580, expected 5625
retry 1/3
FAIL: duration 5580, expected 5625
retry 2/3
FAIL: duration 5580, expected 5625

این درس فقط مربوط به ویدئو نیست. تلاش مجدد برای خرابی‌هایی مفید است که ممکن است در اجرای بعد ناپدید شوند: شبکه، ذخیره‌سازی موقت، فشار منابع یا وابستگی از دسترس خارج.

اما تلاش مجدد نقض قطعی قرارداد را که با همان ورودی و همان الگوریتم تولید می‌شود تعمیر نمی‌کند.

  • خطای گذرا — تلاش مجدد ممکن است مفید باشد؛
  • ورودی نامعتبر — آن را جدا یا رد کنید؛
  • نقض قطعی ناوردای زمانی — تلاش مجدد را متوقف کنید و خط پردازش را بررسی کنید.

5580 تکرارشونده در گروه سوم بود.

بعد 3751 به‌جای 3750 ظاهر شد

در 24 fps مقدار مورد انتظار دقیق است:

90000 / 24 = 3750 ticks

با این حال یک خروجی واقعی پس از concat حاوی بسته با:

3751 ticks

بود. اختلاف فقط:

1 / 90000 s ≈ 11.111 µs

هیچ بیننده‌ای یک تیک را حس نمی‌کند. وسوسه ساده این بود که اعتبارسنج را روی ±1 شل کنم.

این کار را نکردم. در 24 fps روی این شبکه، 3750 دقیقاً قابل نمایش است؛ بنابراین 3751 یک مصالحهٔ اجتناب‌ناپذیر در نمایش زمان نبود، بلکه نشانه‌ای بود که ناوردای دقیق شبکه در جایی از بین رفته است.

جریان کپی یعنی داده فشرده دوباره کدگذاری نمی‌شود، نه اینکه برچسب زمانی دست‌نخورده می‌ماند

segmentهای سازگار H.264 را بدون یک کدگذاری اتلافیِ دوم با هم ترکیب می‌کردم:

ffmpeg -f concat -safe 0 -i segments.ffconcat \
  -c:v copy \
  final.mp4

-c:v copy می‌گوید داده فشرده H.264 رمزگشایی و کدگذاری مجدد نشود. نمی‌گوید مالتی‌پلکسر هیچ کار زمانی انجام نمی‌دهد.

مستندات concat صریحاً می‌گوید مدت هر فایل برای تنظیم timestampهای فایل بعدی استفاده می‌شود. FFmpeg همچنین timestampهای صحیح را میان rational time baseهای مختلف rescale می‌کند و libavutil برای این کار توابعی با حالت‌های گردکردن صریح دارد.

این واقعیت ثابت نمی‌کند concat تنها علت 3751 من بوده است؛ اما نشان می‌دهد مدل ذهنی «بیت‌استریم کپی شد، پس برچسب زمانی نمی‌تواند تغییر کند» غلط است.

یکسان‌بودن تصویر فشرده و یکسان‌بودن شبکهٔ timestamp دو ویژگی جدا هستند.

PTS و DTS را با یک فرمول زیبا نمی‌شود تعمیر کرد

یک اصلاح وسوسه‌انگیز این است:

PTS = N * frame_duration
DTS = N * frame_duration

با H.264 و B-فریم‌ها ممکن است اشتباه باشد، چون ترتیب رمزگشایی می‌تواند با ترتیب نمایش فرق کند.

هدف درست محدودتر است: از timeline معتبر segment برای بازگرداندن شبکهٔ نمایش و مدت بسته استفاده کنیم، در حالی که رابطهٔ قانونی ترتیب رمزگشایی حفظ شود.

به همین دلیل یک عبارت جادویی و بی‌زمینه برای setts منتشر نمی‌کنم. عبارت درست به مرز segmentها، نرخ انتخاب‌شده و metadata زمانی‌ای وابسته است که timeline نهایی را ساخته است.

چرا setts را استفاده کردم

فیلتر bitstream به نام setts در FFmpeg می‌تواند PTS، DTS، duration و output time base بسته را بدون رمزگشایی و دوباره کدگذاری تغییر دهد.

برای normalization بعد از concat دقیقاً همان لایه‌ای بود که می‌خواستم: دادهٔ فشردهٔ H.264 را نگه دار، اما packet timeline را بر اساس metadata معتبر segmentها دوباره اعمال کن.

known segment timeline
+ known CFR
+ 90000-tick grid
        ↓
known valid packet positions and durations
        ↓
normalize packet timing
        ↓
validate again

این با «اگر مدت شد 3751 یکی کم کن» فرق اساسی دارد. اصلاح باید از مدل زمان‌بندی نتیجه شود، نه از پیام خطای امروز.

چرا تلرانس برابر ±1 را نپذیرفتم

در بسیاری سیستم‌ها تلرانس کوچک درست است. اگر ریتم نمایش روی مبنای زمان صحیح دقیق قابل نمایش نباشد، اعتبارسنج باید الگوی گردکردن ضروری را بشناسد.

اما قرارداد من طوری طراحی شده بود که 90000 / fps عدد صحیح باشد:

16 fps → 5625
24 fps → 3750
30 fps → 3000

وقتی duration دقیقاً قابل نمایش است، تلرانس عمومی ±1 فقط یک invariant شکسته و توضیح‌داده‌نشده را به حالت پذیرفته‌شده تبدیل می‌کند.

یک تیک از نظر تصویر مهم نیست. از دست رفتن قراردادی که دلیلش را نمی‌دانم مهم است.

  • اگر شبکهٔ زمانی ذاتاً نیاز به تناوب چند مدت صحیح دارد، همان pattern درست را اعتبارسنجی کنید؛
  • اگر مدت باید یک عدد صحیح دقیق باشد، همان عدد صحیح را بخواهید؛
  • ±1 را به دکمه عمومی سبزکردن اعتبارسنج تبدیل نکنید.

اعتبارسنجی CFR در سطح بسته

avg_frame_rate و فراداده مشابه خلاصه‌های مفیدی هستند، اما برای این قرارداد کافی نیستند.

ffprobe -v error \
  -select_streams v:0 \
  -show_streams \
  -show_packets \
  -of json \
  final.mp4

مبنای زمان واقعی جریان و pts، dts و duration هر بسته را نگاه می‌کنم.

expected = 90000 / 16   // 5625

for each normal video packet:
    assert packet.duration == 5625

assert decode ordering is legal
assert PTS/DTS relationship is legal for the codec
assert presentation timeline matches planned frame count and duration

عبارت «بستهٔ عادی» مهم است. edit list، trimming، یک terminal sample عمداً خاص یا رفتار دیگری از container ممکن است به modeling صریح نیاز داشته باشد. این قانون جهانی MP4 نیست.

اما برای generatorی که ساختارش در کنترل من است، اعتبارسنجی سخت‌گیرانه بسیار مفیدتر از «تقریباً شبیه 16 fps است» محسوب می‌شود.

اعتبارسنجی فراداده و رمزگشایی کامل دو سؤال متفاوت را جواب می‌دهند

برچسب زمانی عالی تضمین نمی‌کند کل H.264 رمزگشایی شود؛ همان‌طور که رمزگشایی موفق تضمین نمی‌کند قرارداد زمان‌بندی رعایت شده باشد.

ffprobe / packet validation
→ structure, timestamps, durations, stream parameters

full decode
→ whether the complete compressed stream can actually be decoded

بعد از بررسی بسته هنوز این را اجرا می‌کنم:

ffmpeg -v error -xerror -err_detect explode \
  -i final.mp4 -f null -

فایل فقط وقتی قابل انتشار است که هر دو مرحله با موفقیت بگذرند. کد خروج صفرِ انکودر دیگر به‌تنهایی برای من به معنی «تمام شد» نیست.

این دو خطا واقعاً چه چیزی را ثابت کردند

برای 5580 می‌توانم تأیید کنم که همان مقدار قطعی در تلاش مجددها تکرار شد؛ 5580 در 90 kHz دقیقاً 62 ms است؛ منبع واقعی تأخیرهای 62/63 ms داشت؛ CFR 16 به 62.5 ms یا 5625 تیک نیاز دارد؛ و آزمایش ورودی 1000 Hz تأخیر میلی‌ثانیه‌ای را درست حفظ کرد.

این موارد، نشت duration منبع به مرحله‌ای را که باید قبلاً CFR شده باشد به توضیحی قوی تبدیل می‌کنند. اما گزارش بایگانی‌شده به‌تنهایی تابع دقیق مقصر را ثابت نمی‌کند.

برای 3751 تأیید می‌کنم که خروجی واقعی یک بستهٔ 3751-tick داشت، جایی که 24 fps مقدار 3750 می‌خواست؛ خط پردازش از stream-copy concat استفاده می‌کرد؛ normalization بسته‌ها با setts بعد از concat بخشی از راه‌حل شد؛ و مستندات FFmpeg هم timestamp adjustment در concat و هم packet-level timestamp rewriting را توضیح می‌دهد.

این شواهد با integer rescaling یا گردکردن در مرزهای multiplexing/concat سازگارند، اما ادعای «concat همیشه یک تیک اضافه می‌کند» را ثابت نمی‌کنند.

روالی که حالا استفاده می‌کنم

  1. خط زمانی معتبر منبع را بازسازی می‌کنم و به یک FPS حدسی اعتماد نمی‌کنم.
  2. تأخیر میلی‌ثانیه‌ای اصلی را روی input clockی با دقت کافی حفظ می‌کنم.
  3. CFR هدف را جداگانه انتخاب می‌کنم.
  4. خط زمانی منبع را صریحاً روی شبکه زمانی آن CFR کوانتیزه می‌کنم.
  5. در صورت امکان track timescaleای انتخاب می‌کنم که نرخ‌های مجاز را دقیق نمایش دهد.
  6. کدگذاری را طوری انجام می‌دهم که مراحل بعدی فریم-نرخ تبدیل مستقلی اجرا نکنند.
  7. پیش از concat، time base، frame count، PTS/DTS و packet duration را اعتبارسنجی می‌کنم.
  8. پیش از کپی مستقیم جریان concat سازگاری کُدک و زمان‌بندی را بررسی می‌کنم.
  9. پس از concat شبکه بسته را دوباره بررسی می‌کنم؛ -c:v copy را گواه ثابت بودن برچسب زمانی نمی‌دانم.
  10. اگر normalization لازم شد، آن را از timeline معتبر استخراج و در packet level اعمال می‌کنم.
  11. همهٔ بسته‌ها را دوباره اعتبارسنجی می‌کنم.
  12. فایل نهایی را کامل رمزگشایی می‌کنم.
  13. فقط پس از عبور قرارداد، انتشار اتمی انجام می‌شود.

قاعده‌ای که برایم ماند: CFR یک قرارداد زمان‌بندی عدد صحیح است

قبلاً 16 fps را تقریباً بدیهی می‌دانستم. دیگر نه.

time base = 1/90000
normal frame duration = 5625 ticks
presentation cadence = 62.5 ms
packet timeline follows that grid
PTS/DTS remain legal for H.264 reordering

5580 مهم بود چون شبکه زمانی قدیمی میلی‌ثانیه‌ای را لو داد. 3751 دقیقاً به این دلیل مهم بود که تقریباً نامرئی بود: فقط یک تیک کافی بود تا نشان دهد سیستم ناوردای زمانی طراحی‌شده را دیگر حفظ نمی‌کند.

قاعدهٔ من اکنون ساده است: برچسب «CFR» را اعتبارسنجی نکن؛ timingای را اعتبارسنجی کن که باید آن برچسب از آن نتیجه شود.

مستندات اصلی