Un giorno la mia pipeline multimediale ha smesso di pubblicare alcuni video per un errore che sembrava quasi assurdo:
Durata pacchetto CFR non valida: 5580 tick, attesi 5625
FFmpeg non era andato in crash e il file H.264 esisteva. A rifiutarlo era il mio validatore dopo l'encoding: il video doveva avere frequenza fotogrammi costante, ma un pacchetto non cadeva sulla griglia temporale prevista.
Ho riprovato: ancora 5580. Un altro tentativo: sempre 5580. Più tardi anche un'altra sorgente ha prodotto lo stesso valore. Non era un errore transitorio: la violazione era deterministica.
In seguito è apparso un secondo caso. A 24 fotogrammi/s con una traccia da 90 000 unità al secondo, mi aspettavo esattamente 3750 tick. Il validatore ha trovato 3751.
I due casi non vanno spiegati allo stesso modo. 5580 invece di 5625 significa 45 tick, cioè esattamente 0,5 ms. 3751 invece di 3750 è un solo tick, circa 11,1 microsecondi. Dire semplicemente «FFmpeg arrotonda» avrebbe nascosto il vero problema.
Questi errori mi hanno costretto a separare concetti che prima tendevo a riassumere sotto FPS: frequenza fotogrammi, base temporale, PTS/DTS e durata del pacchetto.
CFR non è soltanto la scritta «16 fps» nei metadati
Per la mia pipeline non basta che ffprobe mostri 16/1. CFR è un contratto: gli istanti di presentazione devono stare su una griglia regolare e ogni campione normale deve durare un passo di quella griglia.
1 / 16 = 0.0625 s = 62.5 ms
90000 / 16 = 5625 tick
5625 deriva quindi direttamente da 16 fps e 90 000 tick/s. Non è una costante arbitraria.
Posso pretendere l'uguaglianza esatta perché le frequenze consentite sono rappresentabili con durate intere. Con una cadenza non rappresentabile esattamente, il validatore dovrebbe controllare la sequenza di arrotondamenti corretta.
Frequenza, base temporale e scala temporale MP4 sono grandezze diverse
- Frequenza fotogrammi: ritmo di presentazione, a 16 fps un'immagine ogni 62,5 ms.
- Time base: durata di un'unità timestamp, per esempio
1/90000s. - Timescale della traccia MP4: unità per secondo; 90 000 implica la stessa risoluzione.
- PTS: quando mostrare l'immagine.
- DTS: quando decodificare il pacchetto.
- Durata del pacchetto: durata del campione nella base temporale del flusso.
Con i B-frame PTS e DTS possono differire legittimamente. Imporre PTS = DTS non è una correzione generale; la documentazione di setts avverte proprio di questo.
Perché ho scelto 90 000
| Frequenza | Tick per fotogramma |
|---|---|
| 10 | 9000 |
| 12 | 7500 |
| 15 | 6000 |
| 16 | 5625 |
| 18 | 5000 |
| 20 | 4500 |
| 24 | 3750 |
| 25 | 3600 |
| 30 | 3000 |
Inoltre:
1 ms = 90 tick
Era utile perché le animazioni sorgenti portavano ritardi in millisecondi. Il muxer MP4 di FFmpeg consente di impostare video_track_timescale. La scala temporale non rende corretto il tempo: rende misurabile il contratto.
Il valore 5580 indicava già la direzione
5580 / 90000 = 0.062 s = 62 ms
Avevo davvero una sorgente con 49 fotogrammi mostrati in 3,063 s e ritardi alternati di 62 e 63 ms:
49 / 3.063 ≈ 15.997 fps
62 + 63 = 125 ms
2 × 62.5 ms = 125 ms
Sulla griglia sorgente al millisecondo, 62/63 ms è una buona approssimazione di 16 fps. Dopo aver scelto CFR 16, però, ogni fotogramma normale deve durare 62,5 ms, cioè 5625 tick.
5580 era quindi un forte indizio che una durata sorgente di 62 ms fosse sopravvissuta fino a una fase che avrebbe già dovuto contenere la griglia CFR.
Non trasformo l'indizio in certezza: il log dimostra il valore, non la specifica riga di codice che l'ha lasciato passare.
Il clock di ingresso a 1000 Hz non era l'errore
Ho riprodotto questo passaggio con FFmpeg 7.1.5 e ffconcat:
duration 0.010
option framerate 1000
I timestamp dei pacchetti conservavano le posizioni previste:
0 ms
10 ms
20 ms
30 ms
I timestamp risultavano 0, 10, 20 e 30 ms. Usando 30 già in ingresso, il tempo veniva quantizzato a passi di circa 33,3 ms.
1000 Hz conservava correttamente il timing sorgente al millisecondo. Non significava produrre un video a 1000 fps.
ritardi sorgente precisi
→ timeline autorevole
→ scelta CFR
→ quantizzazione esplicita sulla griglia CFR
→ conservazione della griglia fino al MP4 finale
L’elevata precisione in ingresso non era il problema. Il problema era non rendere esplicito il passaggio dal timing sorgente conservato alla quantizzazione sulla griglia CFR.
CFR è quantizzazione controllata del tempo
62, 63, 62, 63 ms
↓
62.5, 62.5, 62.5, 62.5 ms
Il filtro fps di FFmpeg è un punto adatto: costruisce la frequenza richiesta in base ai PTS e alla regola di arrotondamento, duplicando o scartando fotogrammi.
Dopo quel passaggio voglio preservare la griglia, non farla ricostruire indipendentemente da un altro livello.
Perché tre nuovi tentativi non hanno risolto nulla
FAIL 5580, attesi 5625
retry 1/3
FAIL 5580, attesi 5625
retry 2/3
FAIL 5580, attesi 5625
Il nuovo tentativo è utile per cause transitorie. Non corregge una violazione deterministica prodotta dallo stesso algoritmo.
- Errore transitorio: nuovo tentativo possibile.
- Input non valido: altra gestione o rifiuto.
- Invariante deterministico violato: fermare e diagnosticare.
Poi è arrivato 3751 invece di 3750
90000 / 24 = 3750 tick
Un risultato concatenato reale conteneva invece 3751. La differenza è:
1 / 90000 s ≈ 11.111 µs
Visivamente irrilevante, ma tecnicamente interessante perché 24 fps è esattamente rappresentabile su quella griglia.
La copia del flusso non significa che i timestamp restino intatti
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy final.mp4
-c:v copy evita un secondo encode H.264, ma il contenitore deve comunque costruire la nuova sequenza temporale.
Il demuxer concat usa la durata di ogni file per aggiustare i timestamp del successivo. FFmpeg inoltre riscala valori interi fra basi temporali razionali con regole di arrotondamento.
Questo non prova che concat sia l'unica causa del mio 3751; dimostra però che identità del payload compresso e identità della griglia timestamp non sono la stessa cosa.
PTS e DTS non si sistemano con una formula elegante
PTS = N * durata
DTS = N * durata
Con B-frame può essere sbagliato: ordine di decodifica e ordine di presentazione possono differire.
La normalizzazione deve partire da una linea temporale nota e ripristinare la griglia di presentazione senza distruggere una relazione PTS/DTS valida. Per questo non pubblico una formula setts universale.
Perché setts era il livello giusto
setts può cambiare PTS, DTS, durata e base temporale dei pacchetti senza ricodificare il video.
timeline nota + CFR noto + griglia 90000
→ posizioni e durate attese
→ normalizzazione dei pacchetti
→ nuova validazione
È una correzione derivata dal modello, non «se trovi 3751, sottrai uno».
Perché non ho ammesso una tolleranza di ±1 tick
Quando una frequenza non è rappresentabile esattamente, bisogna accettare il corretto schema di arrotondamento. Il mio insieme era stato scelto proprio per evitarlo:
16 fps → 5625
24 fps → 3750
30 fps → 3000
Con un valore esatto disponibile, ±1 avrebbe soltanto legalizzato una violazione non spiegata.
- \n
- se la griglia richiede necessariamente un’alternanza di durate intere, validare il modello corretto; \n
- se la durata deve essere un unico intero esatto, richiedere proprio quel valore; \n
- non usare mai ±1 come scorciatoia universale per far passare un validatore in errore. \n
Come valido CFR a livello di pacchetto
avg_frame_rate e gli altri riepiloghi del flusso sono utili, ma non bastano per questo contratto: devo ispezionare i pacchetti reali.
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json final.mp4
Controllo base temporale effettiva, pts, dts e 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 list, trimming o altri casi intenzionali vanno modellati esplicitamente. È un contratto del mio generatore, non una legge universale per ogni MP4.
Validare i metadati e decodificare tutto sono due controlli diversi
ffprobe/pacchetti → struttura e timing
decode completo → integrità del bitstream
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
Pubblico solo dopo entrambi.
Un codice di uscita 0 dell’encoder, da solo, non significa più che il file sia finito.
Cosa hanno davvero dimostrato i due errori
5580: stesso valore in più nuovo tentativo; corrisponde esattamente a 62 ms a 90 kHz; le sorgenti reali avevano 62/63 ms; CFR 16 richiede 62,5 ms; l'esperimento a 1000 Hz preservava correttamente i millisecondi. È una forte evidenza di timing sorgente sopravvissuto troppo a lungo, non la prova della singola funzione colpevole.
3751: pacchetto reale con un tick extra, in un processo con concat in copia del flusso e successiva normalizzazione tramite setts. La documentazione conferma i meccanismi, ma non giustifica la frase «concat aggiunge sempre un tick».
Il flusso che uso adesso
- Ricostruire la linea temporale reale della sorgente.
- Conservare i ritardi originali con precisione sufficiente.
- Scegliere il CFR separatamente.
- Quantizzare esplicitamente sulla griglia scelta.
- Usare una scala temporale che rappresenti esattamente le frequenze consentite quando possibile.
- Non ripetere la conversione fps in fasi successive.
- Validare timing e pacchetti prima di concat.
- Verificare completa compatibilità prima dello stream copy.
- Ricontrollare la griglia dopo concat.
- Normalizzare dalla linea temporale nota, se necessario.
- Validare ancora.
- Decodificare tutto.
- Pubblicare atomicamente solo dopo il superamento del contratto.
La regola rimasta: CFR è un contratto temporale intero
time base = 1/90000
durata normale = 5625 tick
cadenza = 62.5 ms
griglia pacchetti conforme
PTS/DTS validi per il riordino H.264
5580 ha rivelato la vecchia griglia in millisecondi. 3751 è stato prezioso proprio perché quasi invisibile: un solo tick ha dimostrato che un invariante progettato per essere esatto non veniva più conservato.
Non valido più l'etichetta “CFR”. Valido il tempo da cui quell'etichetta deve derivare.