Zurück zum Blog
13. August 2026Sergei Solod8 Min. Lesezeit

Der eine Tick, der mein CFR brach: Warum 5580 nicht 5625 und 3751 nicht 3750 waren

Mein Validator lehnte ein 16-fps-MP4 wiederholt wegen 5580 statt 5625 Ticks ab und fand später 3751 Ticks dort, wo 24 fps exakt 3750 verlangten. Die beiden Fehler zwangen mich, das Timing der Quelle, CFR-Quantisierung, MP4-Zeitbasis, PTS/DTS, Multiplexing und Paketvalidierung sauber zu trennen.

FFmpegH.264CFRVideo-ZeitstempelMP4Medienpipeline

Eines Tages verweigerte meine Medienpipeline die Veröffentlichung mehrerer Videos wegen einer Fehlermeldung, die zunächst beinahe lächerlich wirkte:

Ungültige CFR-Paketdauer: 5580 Ticks, erwartet wurden 5625

FFmpeg war nicht abgestürzt. Die H.264-Datei war erzeugt worden. Mein Validator lehnte sie erst danach ab, weil ein Video mit konstanter Bildrate einen Paketwert enthielt, der nicht auf dem von mir festgelegten Zeitraster lag.

Ich startete denselben Vorgang erneut: wieder 5580. Noch einmal: wieder 5580. Später traf ein anderer Ausgangsdatensatz denselben Fehler. Das war ein wichtiges Signal: kein zufälliger Netzfehler, sondern ein deterministischer Vertragsbruch.

Danach tauchte ein zweiter Fall auf. Bei 24 Bildern/s und einer Videospur mit 90 000 Zeiteinheiten pro Sekunde erwartete ich exakt 3750 Ticks. Ein Paket hatte 3751.

Diese Fälle darf man nicht mit „FFmpeg rundet eben“ zusammenfassen. Von 5580 zu 5625 fehlen 45 Ticks, genau 0,5 ms. Von 3751 zu 3750 ist es ein einziger Tick, ungefähr 11,1 Mikrosekunden.

Die Fehler zwangen mich, Begriffe sauber zu trennen, die ich vorher zu oft unter „FPS“ zusammengefasst hatte: Bildrate, Zeitbasis, PTS/DTS und Paketdauer.

CFR ist mehr als die Metadatenangabe „16 fps“

Für meinen Generator reicht es nicht, wenn ffprobe 16/1 meldet. CFR bedeutet hier ein regelmäßiges Präsentationsraster, auf dem jeder normale Sample-Schritt liegt.

1 / 16 = 0.0625 s = 62.5 ms
90000 / 16 = 5625 Ticks

5625 ist deshalb keine willkürliche Prüfkonstante. Der Wert folgt aus 16 Bildern/s und 90 000 Ticks/s.

Diese Strenge ist nur sinnvoll, weil meine erlaubten Bildraten die Zeitskala ganzzahlig teilen. Bei einer nicht exakt darstellbaren Rate müsste ein Validator das korrekte Muster unterschiedlicher Ganzzahldauern prüfen.

Bildrate, Zeitbasis und MP4-Zeitskala sind verschiedene Größen

  • Bildrate: Präsentationsrhythmus, bei 16 fps alle 62,5 ms.
  • Zeitbasis: Dauer eines Timestamp-Ticks, etwa 1/90000 s.
  • MP4-Zeitskala: Ticks pro Sekunde; 90 000 entspricht ebenfalls 1/90000 s pro Tick.
  • PTS: Zeitpunkt der Darstellung.
  • DTS: Zeitpunkt, zu dem das codierte Paket dekodiert werden muss.
  • Paketdauer: Dauer des Samples in der Zeitbasis des Streams.

Bei B-Frames dürfen PTS und DTS voneinander abweichen. Ein pauschales PTS = DTS wäre daher keine saubere Reparatur; auch die FFmpeg-Dokumentation zu setts warnt in diesem Zusammenhang vor B-Frames.

Warum ich 90 000 als Zeitskala gewählt habe

BildrateTicks pro Bild
109000
127500
156000
165625
185000
204500
243750
253600
303000

Alle Werte sind ganzzahlig. Zusätzlich gilt:

1 ms = 90 Ticks

Das passte gut zu meinen animierten Quellen mit Millisekunden-Verzögerungen. FFmpeg kann die video_track_timescale der MP4-Spur explizit setzen. Die Skala repariert das Timing nicht; sie macht einen präzisen Vertrag möglich.

5580 war selbst ein Diagnosehinweis

5580 / 90000 = 0.062 s = 62 ms

Ich hatte tatsächlich eine Quelle mit 49 angezeigten Bildern in 3,063 s und wechselnden Verzögerungen von 62 und 63 ms:

49 / 3.063 ≈ 15.997 fps
62 + 63 = 125 ms
2 × 62.5 ms = 125 ms

Auf einem Millisekundenraster ist 62/63 ms eine plausible Näherung an 16 fps. Nach der bewussten Umwandlung zu CFR 16 muss die Ausgabe jedoch bei 62,5 ms beziehungsweise 5625 Ticks liegen.

Darum deutete 5580 stark darauf hin, dass eine originale 62-ms-Dauer in einen Bereich gelangt war, der bereits das CFR-Raster enthalten sollte.

Mehr behaupte ich nicht: Der Logeintrag beweist Zahl und Zusammenhang, aber nicht die exakte Codezeile, die den Wert durchgelassen hat.

Die 1000-Hz-Eingangsuhr war nicht das Problem

Mit FFmpeg 7.1.5 und ffconcat testete ich:

duration 0.010
option framerate 1000

Daraus entstanden Zeitpunkte bei:

0 ms
10 ms
20 ms
30 ms

Mit 30 als frühem Eingaberaster wurde dagegen bereits auf ungefähr 33,3-ms-Schritte quantisiert.

1000 Hz bewahrte also korrekt die Millisekundeninformation der Quelle. Es bedeutete keineswegs 1000 fps in der Ausgabedatei.

präzise Quellverzögerungen
→ autoritative Quellzeitachse
→ Ziel-CFR wählen
→ explizit auf CFR-Raster quantisieren
→ dieses Raster bis zur fertigen MP4 bewahren

Die hohe Präzision am Eingang war nicht der Fehler. Der Fehler war, den Übergang vom bewahrten Quell-Timing zum quantisierten CFR-Raster nicht ausdrücklich zu machen.

CFR ist kontrollierte Zeitquantisierung

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

Der FFmpeg-Filter fps ist eine geeignete Stelle dafür: Er erzeugt die gewünschte Bildrate anhand der Eingabe-PTS und der gewählten Rundungsregel, indem Bilder ausgelassen oder wiederholt werden.

Nach dieser Stelle sollte eine weitere Schicht nicht erneut unabhängig entscheiden, wie das Zeitraster zu erzeugen ist.

Warum drei Wiederholungen nichts änderten

FAIL: 5580, erwartet 5625
Retry 1/3
FAIL: 5580, erwartet 5625
Retry 2/3
FAIL: 5580, erwartet 5625

Wiederholungen helfen bei vorübergehenden Ursachen. Sie beheben keinen deterministischen Vertragsbruch, der mit denselben Eingaben immer denselben ganzzahligen Wert erzeugt.

  • Temporärer Fehler: Ein erneuter Versuch kann sinnvoll sein.
  • Ungültige Eingabe: anders behandeln oder ablehnen.
  • Deterministischer Invariantenbruch: stoppen und untersuchen.

Dann kam 3751 statt 3750

90000 / 24 = 3750 Ticks

Ein echter zusammengefügter Output enthielt trotzdem 3751. Ein Tick entspricht:

1 / 90000 s ≈ 11.111 µs

Optisch ist das bedeutungslos. Für den Vertrag ist es interessant, weil 24 fps in dieser Skala exakt darstellbar sind. 3751 ist keine notwendige Näherung.

Stream Copy bedeutet nicht, dass Zeitstempel unverändert bleiben

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

-c:v copy verhindert eine erneute verlustbehaftete H.264-Codierung. Der Container muss die Paketzeitstempel trotzdem zu einer neuen Sequenz zusammensetzen.

Der concat-Demuxer verwendet laut Dokumentation die Dauer jeder Datei, um die Zeitstempel der folgenden Datei anzupassen. FFmpeg skaliert Zeitstempel außerdem zwischen rationalen Zeitbasen mit definierten Rundungsregeln.

Das beweist nicht, dass concat allein meinen einzelnen 3751-Tick verursacht hat. Es beweist aber, dass „Bitstream kopiert“ und „Timestamp-Raster identisch“ nicht dasselbe sind.

PTS und DTS lassen sich nicht mit einer hübschen Formel reparieren

PTS = N * Dauer
DTS = N * Dauer

Bei H.264 mit B-Frames kann das falsch sein, weil Dekodier- und Darstellungsreihenfolge verschieden sind.

Die Normalisierung muss daher aus einer bekannten Segmentzeitachse abgeleitet werden: Präsentationsraster und Paketdauern wiederherstellen, ohne eine zulässige Reorder-Struktur zu zerstören.

Deshalb gibt es von mir keine kontextfreie „magische“ setts-Formel.

Warum setts die richtige Ebene war

setts kann Paket-PTS, DTS, Dauer und Ausgangszeitbasis ändern, ohne das Video neu zu codieren.

bekannte Segmentzeitachse
+ bekannter CFR
+ 90000er Raster
→ gültige Paketpositionen kennen
→ Zeitfelder normalisieren
→ erneut validieren

Das ist ein modellbasiertes Verfahren und kein Sonderfall „3751 minus 1“.

Warum ich keine Toleranz von ±1 Tick eingebaut habe

Bei nicht exakt darstellbaren Raten sind Rundungsmuster unvermeidlich. Mein erlaubter Satz war absichtlich anders:

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

Wenn der Sollwert exakt darstellbar ist, würde ±1 nur einen unbekannten Fehler legalisieren. Ein Tick ist für das Auge unwichtig; der Verlust eines bewusst entworfenen Invarianten ist es nicht.

    \n
  • wenn das Raster zwangsläufig zwischen ganzzahligen Dauern wechselt, das korrekte Muster prüfen;
  • \n
  • wenn die Dauer genau eine ganze Zahl sein muss, genau diesen Wert verlangen;
  • \n
  • ±1 nie pauschal verwenden, nur damit ein fehlgeschlagener Validator grün wird.
  • \n
\n\n

So prüfe ich CFR auf Paketebene

avg_frame_rate und ähnliche Stream-Zusammenfassungen sind hilfreich, reichen für diesen Vertrag aber nicht aus; ich prüfe die tatsächlichen Pakete.

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

Ich prüfe die tatsächliche Zeitbasis sowie pts, dts und duration jedes relevanten Pakets.

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 the plan

Bewusste Sonderfälle wie Edit Lists oder Trimming müssen ausdrücklich modelliert werden. Diese Regel ist mein Generatorvertrag, kein universelles Gesetz für jede MP4-Datei.

Metadatenprüfung und vollständiges Dekodieren prüfen Verschiedenes

Saubere Timestamps beweisen nicht, dass der gesamte H.264-Bitstream dekodierbar ist. Umgekehrt kann eine Datei vollständig dekodieren und trotzdem mein Zeitraster verletzen.

ffprobe/Pakete → Struktur und Timing
vollständiger Decode → Integrität des komprimierten Streams
ffmpeg -v error -xerror -err_detect explode \
  -i final.mp4 -f null -

Ein Exit-Code 0 des Encoders bedeutet für mich allein nicht mehr, dass die Datei fertig ist.

Was die beiden Fehler tatsächlich belegen

5580: Der Wert wiederholte sich deterministisch; 5580 bei 90 kHz sind exakt 62 ms; reale Quelldaten enthielten 62/63 ms; CFR 16 verlangt 62,5 ms; und der 1000-Hz-Versuch bewahrte Millisekunden korrekt. Das stützt stark die Erklärung, dass Quellzeit zu weit in die CFR-Stufe gelangte, beweist aber nicht die exakte Codezeile.

3751: Ein echter Output enthielt den einen zusätzlichen Tick; der Prozess nutzte Stream-Copy-Concat und später setts zur Normalisierung. FFmpeg dokumentiert sowohl Timestamp-Anpassungen bei concat als auch paketweises Umschreiben durch setts. Das ist mit Rescaling/Rundung vereinbar, aber keine Aussage „concat addiert immer einen Tick“.

Mein heutiger Ablauf

  1. Quellzeitachse aus echten Dauern rekonstruieren.
  2. Millisekunden-Timing präzise bewahren.
  3. Ziel-CFR separat wählen.
  4. Explizit auf das CFR-Raster quantisieren.
  5. Eine passende ganzzahlige Track-Zeitskala verwenden.
  6. Spätere Schichten die Bildrate nicht erneut konvertieren lassen.
  7. Segmente vor concat auf Zeitbasis, PTS/DTS und Dauer prüfen.
  8. Vor Stream Copy vollständige Kompatibilität prüfen.
  9. Nach concat das Paketraster erneut prüfen.
  10. Falls nötig aus der bekannten Zeitachse normalisieren.
  11. Noch einmal validieren.
  12. Alles vollständig dekodieren.
  13. Erst danach atomar veröffentlichen.

Die Regel, die geblieben ist: CFR ist ein ganzzahliger Zeitvertrag

time base = 1/90000
normale Bilddauer = 5625 Ticks
Präsentationsrhythmus = 62.5 ms
Paketzeitachse folgt diesem Raster
PTS/DTS bleiben für H.264-Reordering zulässig

5580 verriet das alte Millisekundenraster. 3751 war gerade deshalb wertvoll, weil ein einzelner praktisch unsichtbarer Tick zeigte, dass ein exakt definierter Invariant nicht mehr erhalten blieb.

Ich validiere nicht mehr das Etikett „CFR“. Ich validiere die Zeit, aus der dieses Etikett folgen muss.

Primärdokumentation