Op een dag stopte mijn mediapijplijn met het publiceren van een deel van de video's vanwege een fout die bijna onbeduidend leek:
Ongeldige CFR-pakketduur: 5580 ticks, verwacht 5625
FFmpeg was niet gecrasht en het H.264-bestand bestond gewoon. Mijn validator weigerde het bestand pas na het encoderen: de uitvoer moest een constante beeldsnelheid hebben, maar één pakket lag niet op het tijdrooster dat ik zelf had vastgelegd.
Ik startte dezelfde taak opnieuw. Weer 5580. Nog een keer: opnieuw 5580. Later gaf een andere bron hetzelfde getal. Dat was waardevol bewijs: geen tijdelijke netwerkstoring, maar een deterministische schending van het contract.
Daarna kwam een tweede geval. Bij 24 beelden/s en een videotrack met 90 000 tijdseenheden per seconde verwachtte ik exact 3750 ticks. De validator vond 3751.
Die twee fouten zijn niet hetzelfde. 5580 in plaats van 5625 is een verschil van 45 ticks, exact 0,5 ms. 3751 in plaats van 3750 is één tick, ongeveer 11,1 microseconde. Alles afdoen als “FFmpeg rondt af” zou de interessante informatie verbergen.
De fouten dwongen me begrippen uit elkaar te halen die ik te vaak onder FPS had samengevat: beeldsnelheid, tijdbasis, PTS/DTS en pakketduur.
CFR is niet alleen een label met “16 fps”
Voor mijn generator is het niet genoeg dat ffprobe 16/1 toont. CFR is een contract: presentatiemomenten liggen op een regelmatig rooster en de normale duur van elk videosample is precies één stap van dat rooster.
1 / 16 = 0.0625 s = 62.5 ms
90000 / 16 = 5625 ticks
5625 komt dus rechtstreeks uit twee ontwerpkeuzes: 16 beelden/s en 90 000 ticks/s.
Zo'n exacte eis is alleen correct omdat ik beeldsnelheden koos die zonder breuk in deze schaal passen. Als een snelheid niet exact als één gehele duur kan worden weergegeven, moet de validator het juiste patroon van gehele duren controleren.
Beeldsnelheid, tijdbasis en MP4-tijdschaal zijn verschillende grootheden
- Beeldsnelheid: presentatieritme; bij 16 fps één beeld per 62,5 ms.
- Tijdbasis: duur van één integer timestamp-eenheid, bijvoorbeeld
1/90000s. - MP4-tijdschaal: eenheden per seconde; 90 000 betekent dezelfde resolutie.
- PTS: wanneer een beeld wordt gepresenteerd.
- DTS: wanneer het gecodeerde pakket moet worden gedecodeerd.
- Pakketduur: duur van het sample in de tijdbasis van de stream.
Met B-frames mogen PTS en DTS terecht verschillen. Daarom is PTS = DTS geen algemene reparatie; de officiële documentatie van setts waarschuwt hier ook voor.
Waarom ik 90 000 als tijdschaal koos
| Snelheid | Ticks per beeld |
|---|---|
| 10 | 9000 |
| 12 | 7500 |
| 15 | 6000 |
| 16 | 5625 |
| 18 | 5000 |
| 20 | 4500 |
| 24 | 3750 |
| 25 | 3600 |
| 30 | 3000 |
En bovendien:
1 ms = 90 ticks
Dat paste goed bij bronanimaties met vertragingen in milliseconden. FFmpeg laat de video_track_timescale van een MP4 expliciet instellen. De schaal maakt timing niet automatisch correct; hij maakt het contract meetbaar.
Het getal 5580 vertelde al waar ik moest zoeken
5580 / 90000 = 0.062 s = 62 ms
Ik had werkelijk een bron met 49 getoonde beelden in 3,063 s, waarbij de vertragingen 62 en 63 ms afwisselden:
49 / 3.063 ≈ 15.997 fps
62 + 63 = 125 ms
2 × 62.5 ms = 125 ms
Op een milliseconderooster is 62/63 ms een logische benadering van 16 fps. Maar zodra CFR 16 is gekozen, hoort ieder normaal beeld 62,5 ms of 5625 ticks te duren.
Daarom was 5580 een sterke aanwijzing dat een bronduur van 62 ms nog leefde in een fase waar de tijd al naar het CFR-rooster gekwantiseerd had moeten zijn.
Meer claim ik niet: het log bewijst de waarde en de rekenkundige relatie, maar niet welke precieze functie de waarde als eerste heeft laten doorstromen.
De 1000-Hz-ingangsklok was niet de fout
Ik reproduceerde dit met FFmpeg 7.1.5 en ffconcat:
duration 0.010
option framerate 1000
De pakkettijdstempels behielden de bedoelde posities:
0 ms
10 ms
20 ms
30 ms
De timestamps bleven 0, 10, 20 en 30 ms. Met 30 als vroeg ingangsrooster werd dezelfde tijd meteen naar ongeveer 33,3-ms-stappen gekwantiseerd.
1000 Hz deed dus precies wat ik nodig had: bronvertragingen op 1-ms-niveau bewaren. Het betekende niet dat het uiteindelijke bestand 1000 fps moest zijn.
nauwkeurige bronvertragingen
→ gezaghebbende brontijdlijn
→ doel-CFR kiezen
→ expliciet naar CFR-rooster kwantiseren
→ dat rooster tot de finale MP4 bewaren
De hoge precisie aan de invoerkant was niet het probleem. Het probleem was dat de overgang van behouden brontiming naar kwantisatie op het CFR-raster niet expliciet was.
CFR is gecontroleerde tijdkwantisering
62, 63, 62, 63 ms
↓
62.5, 62.5, 62.5, 62.5 ms
Het FFmpeg-filter fps is een logische plek voor die overgang: het bouwt de gevraagde snelheid op basis van PTS en afrondingsregels door beelden te herhalen of weg te laten.
Na die grens wil ik het rooster behouden, niet nog een laag onafhankelijk laten beslissen hoe de frame-rateconversie moet gebeuren.
Waarom drie nieuwe pogingen niets veranderden
FAIL 5580, verwacht 5625
retry 1/3
FAIL 5580, verwacht 5625
retry 2/3
FAIL 5580, verwacht 5625
Retries helpen bij oorzaken die tussen pogingen kunnen verdwijnen. Ze repareren geen deterministische invariantenschending van hetzelfde algoritme op dezelfde invoer.
- Tijdelijke fout: nieuwe poging kan helpen.
- Ongeldige invoer: anders verwerken of afwijzen.
- Deterministische contractfout: stoppen en diagnosticeren.
Daarna vond ik 3751 in plaats van 3750
90000 / 24 = 3750 ticks
Een echt samengevoegd bestand bevatte toch 3751. Eén tick is:
1 / 90000 s ≈ 11.111 µs
Visueel onbelangrijk, maar technisch relevant omdat 24 fps exact op dit rooster past. 3751 was dus geen noodzakelijke benadering.
Een streamkopie betekent niet dat tijdstempels onaangeraakt blijven
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy final.mp4
-c:v copy voorkomt een tweede H.264-encode. De container moet nog steeds pakketten en timestamps tot één nieuwe tijdlijn samenvoegen.
De concat-documentatie zegt expliciet dat de duur van elk bestand wordt gebruikt om de timestamps van het volgende aan te passen. FFmpeg schaalt daarnaast gehele timestamps tussen rationale tijdbases met expliciete afrondingsregels.
Dat bewijst niet dat concat de enige mogelijke oorzaak van mijn 3751 was. Het bewijst wel dat identiteit van gecomprimeerde data en identiteit van het timestamp-rooster niet hetzelfde zijn.
PTS en DTS kun je niet met één mooie formule repareren
PTS = N * duur
DTS = N * duur
Met B-frames kan dit fout zijn, omdat decodeervolgorde en presentatievolgorde verschillen.
Normalisatie moet daarom uit een bekende tijdlijn voortkomen en het presentatierooster herstellen zonder een geldige PTS/DTS-reorderstructuur te vernietigen. Daarom publiceer ik geen universele setts-formule.
Waarom setts het juiste niveau was
setts kan PTS, DTS, duur en tijdbasis van pakketten aanpassen zonder de video opnieuw te coderen.
bekende segmenttijdlijn + bekende CFR + rooster 90000
→ verwachte pakketposities en duren
→ pakket-timing normaliseren
→ opnieuw valideren
Dat is fundamenteel anders dan “als je 3751 ziet, trek één af”.
Waarom ik geen tolerantie van ±1 tick toeliet
Bij een snelheid die niet exact representeerbaar is, is een gecontroleerd afrondingspatroon noodzakelijk. Mijn toegestane waarden waren juist gekozen om dat te vermijden:
16 fps → 5625
24 fps → 3750
30 fps → 3000
Als de exacte waarde bestaat, maakt ±1 een onverklaarde afwijking stilletjes geldig. Eén tick is voor de kijker niets; het verlies van een bewust ontworpen invariant is voor de pipeline wél informatie.
- \n
- als het raster noodzakelijk afwisselende gehele duurtijden vereist, het juiste patroon valideren; \n
- als de duur exact één geheel getal moet zijn, precies dat getal eisen; \n
- ±1 nooit als algemene truc gebruiken om een falende validator groen te krijgen. \n
Hoe ik CFR op pakketniveau valideer
\navg_frame_rate en vergelijkbare streammetadata zijn nuttige samenvattingen, maar voor dit contract zijn ze niet voldoende.
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json final.mp4
Ik controleer de werkelijke tijdbasis en per pakket pts, dts en 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 lists, trimming of andere bewuste uitzonderingen moeten expliciet gemodelleerd worden. Dit is mijn generatorcontract, geen universele MP4-wet.
Metadata controleren en volledig decoderen beantwoorden verschillende vragen
ffprobe/pakketten → structuur en timing
volledige decode → integriteit van de gecomprimeerde stream
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
Ik publiceer pas nadat beide controles slagen.
Exitcode 0 van de encoder betekent voor mij op zichzelf niet meer dat het bestand klaar is.
Wat de twee fouten werkelijk bewezen
5580: dezelfde waarde na meerdere retries; exact 62 ms bij 90 kHz; echte bronvertragingen van 62/63 ms; CFR 16 vereist 62,5 ms; en het 1000-Hz-experiment bewaarde milliseconden correct. Dat ondersteunt sterk de verklaring dat bronduur te ver in de CFR-fase terechtkwam, maar identificeert niet vanzelf de exacte foutregel.
3751: een echt pakket met één tick te veel, in een proces met stream-copy-concat en daarna setts-normalisatie. De FFmpeg-documentatie bevestigt de relevante mechanismen zonder te bewijzen dat “concat altijd één tick toevoegt”.
Mijn huidige werkwijze
- De echte brontijdlijn reconstrueren.
- Bronvertragingen met voldoende nauwkeurigheid bewaren.
- Doel-CFR apart kiezen.
- Expliciet naar dat rooster kwantiseren.
- Een tijdschaal gebruiken waarop toegestane snelheden exact passen waar mogelijk.
- Latere lagen geen tweede frame-rateconversie laten uitvoeren.
- Timing en pakketten vóór concat valideren.
- Volledige compatibiliteit vóór stream copy controleren.
- Na concat opnieuw het pakket-rooster inspecteren.
- Zo nodig vanuit de bekende tijdlijn normaliseren.
- Opnieuw valideren.
- Het hele bestand decoderen.
- Pas daarna atomair publiceren.
De regel die bleef: CFR is een geheelgetallig tijdcontract
time base = 1/90000
normale duur = 5625 ticks
presentatieritme = 62.5 ms
pakket-tijdlijn volgt dit rooster
PTS/DTS blijven geldig voor H.264-reordering
5580 onthulde het oude milliseconderooster. 3751 was juist nuttig omdat één vrijwel onzichtbare tick liet zien dat een exact ontworpen invariant niet langer werd behouden.
Ik valideer niet meer het label “CFR”. Ik valideer de tijd waaruit dat label moet volgen.