Retour au blog
13 août 2026Sergei Solod10 min de lecture

Le tick qui a cassé mon CFR : pourquoi 5580 n’était pas 5625 et 3751 n’était pas 3750

Mon validateur refusait à répétition un MP4 à 16 images/s parce qu’un paquet durait 5580 ticks au lieu de 5625, puis il a détecté 3751 là où 24 images/s exigeaient exactement 3750. Ces deux erreurs m’ont obligé à séparer la chronologie source, la quantification CFR, la base de temps MP4, PTS/DTS, le multiplexage et la validation paquet par paquet.

FFmpegH.264CFRHorodatage vidéoMP4Traitement multimédia

Un jour, mon pipeline multimédia a cessé de publier certains fichiers à cause d'une erreur qui semblait presque dérisoire :

Durée de paquet CFR incorrecte : 5580 ticks, 5625 attendus

FFmpeg n'avait pas planté et le H.264 avait bien été produit. C'est mon validateur qui refusait le fichier après encodage : la vidéo devait avoir une cadence constante, mais un paquet ne tombait pas sur la grille temporelle prévue.

J'ai relancé le traitement : encore 5580. Une deuxième fois : toujours 5580. Un autre fichier source a ensuite produit la même valeur. Le problème était donc déterministe, pas un incident réseau passager.

Plus tard, j'ai rencontré un autre écart. À 24 images/s sur une piste à 90 000 unités par seconde, chaque durée normale devait valoir exactement 3750. Le validateur a trouvé 3751.

Ces deux cas ne racontent pas la même histoire. 5580 au lieu de 5625 représente 45 ticks, soit exactement 0,5 ms. 3751 au lieu de 3750 ne diffère que d'un tick, environ 11,1 microsecondes. Les résumer par « FFmpeg arrondit » aurait été trop facile.

Ils m'ont surtout obligé à distinguer ce que je mélangeais auparavant sous le mot FPS : cadence, base de temps, PTS/DTS et durée des paquets.

Le CFR n'est pas simplement une métadonnée « 16 fps »

Dans mon pipeline, voir 16/1 dans ffprobe ne suffit pas. Le CFR est un contrat : les instants de présentation suivent une grille régulière et la durée normale d'un échantillon correspond à un pas de cette grille.

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

5625 découle donc directement du couple 16 images/s et 90 000 ticks/s.

Cette exigence n'est valable que parce que j'ai choisi des cadences représentables exactement. Si une cadence exige un motif de plusieurs durées entières, le validateur doit vérifier ce motif plutôt que réclamer une constante impossible.

Cadence, base de temps et échelle de temps MP4 sont trois notions distinctes

  • Cadence : rythme d'affichage ; à 16 images/s, une image toutes les 62,5 ms.
  • Base de temps : durée d'un tick entier, par exemple 1/90000 s.
  • échelle de temps MP4 : nombre d'unités par seconde ; 90 000 signifie la même résolution temporelle.
  • PTS : moment de présentation.
  • DTS : moment où le paquet doit être décodé.
  • Durée de paquet : durée de l'échantillon dans la base de temps du flux.

Avec des images B, PTS et DTS peuvent légitimement être différents. Les forcer à être égaux n'est donc pas une correction générale ; la documentation de setts le signale explicitement.

Pourquoi 90 000 était une bonne grille pour mon cas

CadenceTicks par image
109000
127500
156000
165625
185000
204500
243750
253600
303000

Et :

1 ms = 90 ticks

C'était pratique pour des animations dont les délais d'origine étaient exprimés en millisecondes. FFmpeg permet de fixer video_track_timescale dans le muxer MP4. La grille ne garantit pas la correction ; elle rend les écarts mesurables.

5580 indiquait déjà où chercher

5580 / 90000 = 0.062 s = 62 ms

J'avais précisément un fichier réel de 49 images affichées en 3,063 s, avec des délais alternant entre 62 et 63 ms :

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

Sur la grille milliseconde de la source, 62/63 ms est une approximation naturelle de 16 images/s. Après le choix explicite du CFR 16, la sortie doit cependant utiliser 62,5 ms, donc 5625 ticks, pour chaque image normale.

5580 indiquait fortement qu'une durée source de 62 ms avait survécu jusqu'à une étape où le temps aurait déjà dû être quantifié.

Je n'en tire pas une causalité artificielle : le journal prouve la valeur et l'arithmétique, pas la ligne exacte de code responsable.

L'horloge d'entrée à 1000 Hz n'était pas l'erreur

J'ai reproduit ce point avec FFmpeg 7.1.5 et ffconcat. Avec :

duration 0.010
option framerate 1000

Les horodatages des paquets conservaient bien les positions prévues :

0 ms
10 ms
20 ms
30 ms

j'obtenais 0, 10, 20 et 30 ms. En utilisant 30 dès cette étape, le temps était déjà ramené à des pas d'environ 33,3 ms.

1000 Hz conservait donc correctement les délais d'origine à la milliseconde. Cela ne signifiait pas que la sortie devait avoir 1000 images/s.

délais source précis
→ timeline de référence
→ choix du CFR
→ quantification explicite sur la grille CFR
→ conservation de cette grille jusqu'au MP4

La haute précision en entrée n’était pas le problème. Le problème était de ne pas rendre explicite la transition entre le temps source conservé et sa quantification sur la grille CFR.

Le CFR est une quantification contrôlée du temps

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

Le filtre fps de FFmpeg est adapté à cette conversion : il construit la cadence demandée à partir des PTS, en répétant ou supprimant des images selon la règle d'arrondi.

Après cette frontière, je préfère conserver la grille au lieu de demander à une autre couche de refaire indépendamment la conversion.

Pourquoi trois relances n'ont rien changé

FAIL 5580, attendu 5625
retry 1/3
FAIL 5580, attendu 5625
retry 2/3
FAIL 5580, attendu 5625

Une relance est utile si la cause peut disparaître : réseau, stockage momentanément indisponible, ressource temporaire. Elle ne corrige pas une violation déterministe produite par le même algorithme.

  • Erreur transitoire : relance possible.
  • Entrée invalide : traitement alternatif ou rejet.
  • Invariant déterministe brisé : arrêter et diagnostiquer.

Puis j'ai trouvé 3751 au lieu de 3750

90000 / 24 = 3750 ticks

Un fichier concaténé réel contenait pourtant 3751. Un tick vaut environ :

1 / 90000 s ≈ 11.111 µs

Impossible à percevoir à l'œil, mais intéressant car 24 images/s est exactement représentable sur cette grille. Le tick supplémentaire signalait la perte d'un invariant, pas une limite mathématique.

La copie du flux ne signifie pas que les horodatages restent intacts

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

-c:v copy évite un deuxième encodage H.264. Le conteneur doit malgré tout reconstruire une séquence temporelle de paquets.

La documentation de concat précise que la durée de chaque fichier sert à ajuster les timestamps du suivant. FFmpeg effectue aussi des changements d'échelle entre bases de temps rationnelles avec des règles d'arrondi.

Cela ne prouve pas que concat était l'unique origine de mon 3751. Cela prouve que l'identité du bitstream compressé et l'identité de la grille temporelle sont deux propriétés différentes.

PTS et DTS ne se réparent pas avec une formule unique

PTS = N * durée
DTS = N * durée

Cette formule peut être fausse en présence d'images B, car l'ordre de décodage diffère de l'ordre de présentation.

Une normalisation correcte doit partir d’une chronologie connue, restaurer la grille de présentation et les durées, tout en préservant une relation PTS/DTS légale.

C'est pourquoi je ne donne pas une expression setts universelle et hors contexte.

Pourquoi setts était le bon niveau d'intervention

setts peut modifier PTS, DTS, la durée et la base de temps au niveau des paquets sans réencoder la vidéo.

timeline connue
+ CFR connu
+ grille 90000
→ positions et durées attendues
→ normalisation des paquets
→ nouvelle validation

La correction vient du modèle temporel, pas d'une règle « si 3751, soustraire 1 ».

Pourquoi je n'ai pas ajouté une tolérance de ±1 tick

Une tolérance est nécessaire si la cadence n'est pas exactement représentable. Ici, j'avais choisi des valeurs qui le sont :

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

Autoriser ±1 aurait simplement transformé une violation inexpliquée en état accepté. Le tick n'est pas dangereux visuellement ; perdre silencieusement le contrat l'est davantage pour le pipeline.

    \n
  • si la grille impose une alternance de durées entières, valider le bon motif ;
  • \n
  • si la durée doit être un entier exact, exiger exactement cet entier ;
  • \n
  • ne jamais utiliser ±1 comme moyen universel de faire passer un validateur en échec.
  • \n
\n\n

Comment je valide le CFR paquet par paquet

avg_frame_rate et les autres résumés du flux sont utiles, mais ils ne suffisent pas pour ce contrat ; je dois examiner les paquets réels.

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

Je vérifie la base de temps effective, puis pts, dts et duration.

expected = 90000 / 16 // 5625
for each normal video packet:
    assert packet.duration == 5625

assert decode order is legal
assert PTS/DTS relationship is legal
assert frame count and duration match the plan

Des edit lists, du trimming ou d'autres cas intentionnels doivent être modélisés explicitement. Ce n'est pas une loi universelle du MP4, mais un contrat strict pour un générateur contrôlé.

Valider les métadonnées et décoder entièrement ne répondent pas à la même question

ffprobe/paquets → structure et temps
full decode      → intégrité du flux compressé
ffmpeg -v error -xerror -err_detect explode \
  -i final.mp4 -f null -

Je ne publie qu'après les deux contrôles.

Un code de sortie 0 de l’encodeur ne signifie plus, à lui seul, que le fichier est terminé.

Ce que les deux erreurs ont réellement démontré

5580 : valeur répétée de façon déterministe, exactement équivalente à 62 ms à 90 kHz, correspondant aux délais 62/63 ms d’une source réelle, alors que CFR 16 exige 62,5 ms. L’expérience à 1000 Hz confirmait la conservation des millisecondes. Cela étaye fortement l’hypothèse d’une chronologie source ayant subsisté trop loin, sans permettre d’identifier à elle seule la fonction responsable.

3751 : paquet réel avec un tick de trop, dans un processus utilisant concat par copie de flux puis une normalisation avec setts. La documentation confirme les mécanismes de réajustement et de réécriture des horodatages, sans permettre d’affirmer que concat ajoute toujours un tick.

Mon processus aujourd'hui

  1. Reconstruire la chronologie réelle.
  2. Préserver les délais de la source avec une horloge d’entrée suffisamment précise.
  3. Choisir le CFR séparément.
  4. Quantifier explicitement sur cette grille.
  5. Utiliser une échelle de temps qui représente exactement les cadences autorisées quand c'est possible.
  6. Éviter une deuxième conversion indépendante de cadence.
  7. Valider les paquets avant concat.
  8. Vérifier la compatibilité complète avant la copie de flux.
  9. Revalider après concat.
  10. Normaliser à partir de la chronologie connue si nécessaire.
  11. Revalider.
  12. Décoder entièrement.
  13. Publier atomiquement seulement après succès.

La règle que j'ai gardée : le CFR est un contrat temporel entier

time base = 1/90000
durée normale = 5625 ticks
cadence = 62.5 ms
grille des paquets conforme
PTS/DTS légaux avec le réordonnancement H.264

5580 a révélé l'ancienne grille en millisecondes. 3751 m'a appris qu'un seul tick, même invisible, peut montrer qu'un invariant exact a cessé d'être respecté.

Je ne valide plus l'étiquette « CFR ». Je valide le temps dont cette étiquette doit découler.

Documentation principale