Pewnego dnia mój potok przetwarzania multimediów przestał publikować część filmów z powodu błędu, który na pierwszy rzut oka wyglądał niemal absurdalnie:
Nieprawidłowy czas trwania pakietu CFR: 5580 ticków, oczekiwano 5625
FFmpeg nie uległ awarii, a plik H.264 powstał. Odrzucał go dopiero mój walidator: wynik miał mieć stałą liczbę klatek na sekundę, ale czas trwania jednego pakietu nie leżał na zaprojektowanej przeze mnie siatce czasu.
Uruchomiłem zadanie ponownie. Znowu 5580. Jeszcze raz: znów 5580. Później inny plik źródłowy dał ten sam wynik. To była cenna wskazówka — nie chwilowy problem sieciowy, lecz deterministyczne naruszenie kontraktu.
Potem pojawił się drugi przypadek. Przy 24 klatkach/s i skali 90 000 jednostek na sekundę oczekiwałem dokładnie 3750 ticków. Walidator znalazł 3751.
Nie należy wrzucać tych dwóch błędów do worka „FFmpeg zaokrąglił”. 5580 zamiast 5625 to różnica 45 ticków, dokładnie 0,5 ms. 3751 zamiast 3750 to jeden tick, około 11,1 mikrosekundy.
Te dwa przypadki zmusiły mnie do rozdzielenia pojęć, które wcześniej zbyt łatwo mieściłem pod skrótem FPS: częstotliwość klatek, podstawa czasu, PTS/DTS i czas trwania pakietu.
CFR to nie tylko napis „16 fps” w metadanych
W moim generatorze nie wystarcza, że ffprobe pokaże 16/1. CFR oznacza regularną siatkę prezentacji, na której każdy normalny krok ma dokładnie znany czas.
1 / 16 = 0.0625 s = 62.5 ms
90000 / 16 = 5625 ticków
5625 wynika bezpośrednio z 16 klatek/s i 90 000 ticków/s. Nie jest arbitralnym progiem walidatora.
Taka dokładność ma sens dlatego, że dozwolone przeze mnie częstotliwości dzielą skalę bez reszty. Gdyby częstotliwość wymagała naprzemiennych całkowitych czasów, walidator musiałby sprawdzać właśnie ten prawidłowy wzorzec.
Częstotliwość klatek, podstawa czasu i skala czasu MP4 to różne rzeczy
- Częstotliwość klatek: rytm prezentacji; przy 16 fps jedna klatka co 62,5 ms.
- Podstawa czasu: długość jednej jednostki znacznika czasu, np.
1/90000s. - Timescale ścieżki MP4: liczba jednostek na sekundę; 90 000 oznacza tę samą rozdzielczość.
- PTS: czas prezentacji obrazu.
- DTS: czas dekodowania pakietu.
- Czas trwania pakietu: długość próbki w podstawie czasu strumienia.
Przy klatkach B PTS i DTS mogą poprawnie się różnić. Dlatego wymuszenie PTS = DTS nie jest uniwersalną naprawą; dokumentacja setts również to zaznacza.
Dlaczego wybrałem skalę 90 000
| FPS | Ticki na klatkę |
|---|---|
| 10 | 9000 |
| 12 | 7500 |
| 15 | 6000 |
| 16 | 5625 |
| 18 | 5000 |
| 20 | 4500 |
| 24 | 3750 |
| 25 | 3600 |
| 30 | 3000 |
Dodatkowo:
1 ms = 90 ticków
To wygodne dla animacji źródłowych z opóźnieniami w milisekundach. FFmpeg pozwala ustawić video_track_timescale w muxerze MP4. Sama skala niczego nie naprawia — sprawia, że kontrakt staje się mierzalny.
Liczba 5580 sama podpowiadała kierunek
5580 / 90000 = 0.062 s = 62 ms
Miałem rzeczywiste źródło z 49 wyświetlanymi klatkami w 3,063 s i opóźnieniami naprzemiennie 62 i 63 ms:
49 / 3.063 ≈ 15.997 fps
62 + 63 = 125 ms
2 × 62.5 ms = 125 ms
Na milisekundowej siatce źródła 62/63 ms rozsądnie przybliża 16 fps. Po wyborze CFR 16 normalna klatka powinna jednak mieć 62,5 ms, czyli 5625 ticków.
5580 mocno sugerowało, że źródłowe 62 ms przetrwało do etapu, w którym czas powinien być już skwantowany do CFR.
Nie udaję większej pewności: log dowodzi liczby i związku arytmetycznego, ale nie wskazuje samodzielnie dokładnej funkcji odpowiedzialnej za błąd.
Zegar wejściowy 1000 Hz nie był błędem
Odtworzyłem ten fragment na FFmpeg 7.1.5 i ffconcat:
duration 0.010
option framerate 1000
Znaczniki czasu pakietów zachowywały zamierzone pozycje:
0 ms
10 ms
20 ms
30 ms
Znaczniki czasu trafiały w 0, 10, 20 i 30 ms. Przy 30 już na wejściu czas kwantyzował się do kroków około 33,3 ms.
1000 Hz prawidłowo zachowywało milisekundowe opóźnienia źródła. Nie oznaczało 1000 fps w wyjściowym filmie.
dokładne opóźnienia źródła
→ autorytatywna oś czasu
→ wybór CFR
→ jawna kwantyzacja do siatki CFR
→ zachowanie tej siatki do finalnego MP4
Wysoka precyzja na wejściu nie była problemem. Problemem było to, że przejście od zachowanego czasu źródłowego do kwantyzacji na siatkę CFR nie było jawne.
CFR jest kontrolowaną kwantyzacją czasu
62, 63, 62, 63 ms
↓
62.5, 62.5, 62.5, 62.5 ms
Filtr fps FFmpeg jest właściwym miejscem na taką zmianę: buduje żądaną częstotliwość na podstawie PTS i reguły zaokrąglania, powtarzając lub usuwając klatki.
Po tym punkcie kolejne warstwy powinny zachowywać siatkę, a nie ponownie i niezależnie przeliczać częstotliwość.
Dlaczego trzy ponowienia niczego nie zmieniły
FAIL 5580, oczekiwano 5625
retry 1/3
FAIL 5580, oczekiwano 5625
retry 2/3
FAIL 5580, oczekiwano 5625
Ponowienie ma sens, jeśli przyczyna może zniknąć pomiędzy próbami. Nie naprawi deterministycznego naruszenia generowanego przez ten sam algorytm.
- Błąd przejściowy: warto ponowić.
- Złe dane wejściowe: obsłużyć inaczej lub odrzucić.
- Deterministyczne złamanie inwariantu: zatrzymać i diagnozować.
Później pojawiło się 3751 zamiast 3750
90000 / 24 = 3750 ticków
Jeden rzeczywisty wynik po concat zawierał jednak 3751. Jeden tick to:
1 / 90000 s ≈ 11.111 µs
Dla oka bez znaczenia, ale dla kontraktu istotne: 24 fps daje tu dokładnie 3750, więc 3751 nie jest wymuszonym przybliżeniem.
Kopiowanie strumienia nie oznacza niezmiennych znaczników czasu
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy final.mp4
-c:v copy zachowuje skompresowane dane H.264 bez ponownego kodowania. Muxer nadal musi złożyć pakiety w nową oś czasu.
Dokumentacja concat mówi, że czas trwania pliku służy do przesunięcia timestampów kolejnego. FFmpeg przelicza też liczby całkowite między racjonalnymi podstawami czasu z określonym zaokrąglaniem.
Nie dowodzi to, że concat był jedyną możliwą przyczyną mojego 3751. Dowodzi natomiast, że identyczny bitstream i identyczna siatka czasu to dwie różne własności.
PTS i DTS nie dają się naprawić jedną ładną formułą
PTS = N * duration
DTS = N * duration
Przy B-frame może to być błędne, bo kolejność dekodowania różni się od prezentacji.
Normalizacja musi wynikać ze znanej osi czasu i przywracać siatkę prezentacji bez niszczenia legalnej relacji PTS/DTS. Dlatego nie podaję uniwersalnego wyrażenia setts.
Dlaczego setts było właściwym poziomem
setts potrafi zmienić PTS, DTS, czas trwania i podstawę czasu pakietów bez ponownego kodowania obrazu.
znana oś segmentów + znany CFR + siatka 90000
→ znane oczekiwane pozycje i czasy
→ normalizacja pakietów
→ ponowna walidacja
Naprawa wynika z modelu, a nie z reguły „3751 minus jeden”.
Dlaczego nie dodałem tolerancji ±1 tick
Dla częstotliwości niewyrażalnej dokładnie tolerancja lub wzorzec zaokrągleń jest konieczny. Ja celowo wybrałem zestaw dokładny:
16 fps → 5625
24 fps → 3750
30 fps → 3000
Jeśli oczekiwana wartość jest dokładna, ±1 po prostu legalizuje nieznane naruszenie.
- \n
- jeśli siatka z konieczności wymaga naprzemiennych całkowitych czasów trwania, walidować właściwy wzorzec; \n
- jeśli czas trwania ma być dokładnie jedną liczbą całkowitą, wymagać właśnie tej wartości; \n
- nie używać ±1 jako uniwersalnego sposobu na zamianę czerwonego wyniku walidatora na zielony. \n
Jak waliduję CFR na poziomie pakietów
avg_frame_rate i podobne podsumowania strumienia są przydatne, ale dla tego kontraktu niewystarczające; sprawdzam rzeczywiste pakiety.
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json final.mp4
Sprawdzam faktyczną podstawę czasu oraz pts, dts i duration.
expected = 90000 / 16 // 5625
for each normal video packet:
assert packet.duration == 5625
assert legal decode order
assert legal PTS/DTS relationship
assert frame count and duration match
Edit listy, trimming i celowe wyjątki trzeba modelować jawnie. To kontrakt mojego generatora, nie uniwersalne prawo MP4.
Walidacja metadanych i pełne dekodowanie odpowiadają na inne pytania
ffprobe/pakiety → struktura i czas
pełny decode → integralność skompresowanego strumienia
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
Publikuję dopiero po obu testach.
Kod wyjścia 0 enkodera sam w sobie nie oznacza już dla mnie, że plik jest gotowy.
Co naprawdę udowodniły oba błędy
5580: deterministyczne powtórzenia, dokładnie 62 ms przy 90 kHz, realne opóźnienia 62/63 ms, wymagane 62,5 ms dla CFR 16 oraz eksperyment 1000 Hz poprawnie zachowujący milisekundy. To silnie wspiera hipotezę o zbyt daleko przeniesionym timing'u źródłowym, ale nie wskazuje automatycznie konkretnej linii kodu.
3751: realny dodatkowy tick, proces z concat w kopiowanie strumienia i późniejszą normalizacją setts. Dokumentacja potwierdza mechanizmy, lecz nie pozwala twierdzić, że „concat zawsze dodaje jeden tick”.
Mój obecny proces
- Odtworzyć prawdziwą oś czasu źródła.
- Zachować opóźnienia z odpowiednią precyzją.
- Osobno wybrać CFR.
- Jawnie skwantować do jego siatki.
- Stosować skala czasu, na którym dozwolone częstotliwości są dokładne, gdy to możliwe.
- Nie wykonywać drugiej niezależnej konwersji fps.
- Walidować timing pakietów przed concat.
- Sprawdzić pełną zgodność przed kopiowanie strumienia.
- Po concat znów skontrolować siatkę.
- W razie potrzeby normalizować ze znanej osi czasu.
- Walidować ponownie.
- W pełni zdekodować plik.
- Publikować atomowo dopiero po sukcesie.
Zasada, która została: CFR to całkowitoliczbowy kontrakt czasu
time base = 1/90000
normal duration = 5625 ticków
cadence = 62.5 ms
pakiety leżą na tej siatce
PTS/DTS pozostają legalne dla reorder H.264
5580 ujawniło starą siatkę milisekundową. 3751 było cenne właśnie dlatego, że jeden praktycznie niewidoczny tick pokazał utratę inwariantu zaprojektowanego jako dokładny.
Nie waliduję już etykiety „CFR”. Waliduję czas, z którego ta etykieta ma wynikać.