Um dia, meu pipeline de mídia parou de publicar parte dos vídeos por causa de um erro que parecia quase insignificante:
Duração de pacote CFR inválida: 5580 ticks, esperado 5625
O FFmpeg não havia travado e o H.264 tinha sido criado. Quem recusava o arquivo era meu validador, depois da codificação: o resultado deveria ter taxa de quadros constante, mas um pacote não estava na grade temporal que eu havia definido.
Rodei novamente. Deu 5580 de novo. Outra tentativa: 5580. Mais tarde, outro arquivo de origem chegou ao mesmo valor. Isso mostrava que não era uma falha transitória; o rompimento do contrato era determinístico.
Depois surgiu um segundo caso. A 24 quadros/s, com uma trilha de 90 000 unidades por segundo, eu esperava exatamente 3750 ticks. O validador encontrou 3751.
Os dois casos não são equivalentes. 5580 em vez de 5625 significa 45 ticks, exatamente 0,5 ms. 3751 em vez de 3750 é um único tick, cerca de 11,1 microssegundos. Dizer apenas “o FFmpeg arredondou” teria escondido a diferença.
Esses erros me obrigaram a separar conceitos que antes eu resumiria rápido demais como FPS: taxa de quadros, base de tempo, PTS/DTS e duração do pacote.
CFR não é só o rótulo “16 fps”
No meu gerador, não basta ffprobe mostrar 16/1. CFR é um contrato: os instantes de apresentação ficam em uma grade regular e cada amostra normal dura um passo dessa grade.
1 / 16 = 0.0625 s = 62.5 ms
90000 / 16 = 5625 ticks
5625 vem diretamente de 16 fps e 90 000 ticks/s. Não é uma constante inventada pelo validador.
Essa igualdade exata só é correta porque escolhi frequências representáveis por durações inteiras. Se uma cadência exigir alternância de inteiros, o validador precisa verificar o padrão correto, não exigir um único valor impossível.
Taxa de quadros, base de tempo e escala de tempo do MP4 são coisas diferentes
- Taxa de quadros: ritmo de apresentação; a 16 fps, um quadro a cada 62,5 ms.
- Base de tempo: duração de uma unidade inteira de timestamp, como
1/90000s. - Timescale da trilha MP4: unidades por segundo; 90 000 representa a mesma resolução.
- PTS: quando a imagem deve ser apresentada.
- DTS: quando o pacote deve ser decodificado.
- Duração do pacote: duração da amostra na base de tempo do fluxo.
Com B-frames, PTS e DTS podem ser diferentes corretamente. Por isso simplesmente impor PTS = DTS não é uma correção geral; a documentação de setts também alerta para isso.
Por que escolhi 90 000
| Taxa | Ticks por quadro |
|---|---|
| 10 | 9000 |
| 12 | 7500 |
| 15 | 6000 |
| 16 | 5625 |
| 18 | 5000 |
| 20 | 4500 |
| 24 | 3750 |
| 25 | 3600 |
| 30 | 3000 |
E ainda:
1 ms = 90 ticks
Isso combinava bem com animações cuja temporização vinha em milissegundos. O muxer MP4 do FFmpeg permite definir video_track_timescale. A escala não corrige o tempo sozinha; ela torna o contrato observável.
O número 5580 já indicava a direção
5580 / 90000 = 0.062 s = 62 ms
Eu tinha um arquivo real com 49 quadros exibidos em 3,063 s e atrasos alternando entre 62 e 63 ms:
49 / 3.063 ≈ 15.997 fps
62 + 63 = 125 ms
2 × 62.5 ms = 125 ms
Na grade de milissegundos da origem, 62/63 ms é uma aproximação natural de 16 fps. Depois de escolher CFR 16, porém, cada quadro normal precisa durar 62,5 ms, ou 5625 ticks.
Por isso 5580 era uma evidência forte de que uma duração de origem de 62 ms havia sobrevivido até uma etapa que já deveria conter o tempo quantizado para CFR.
Não transformo essa evidência em certeza inventada: o log prova o valor e a relação matemática, mas não identifica sozinho a função exata responsável.
O relógio de entrada de 1000 Hz não era o erro
Reproduzi essa parte com FFmpeg 7.1.5 e ffconcat:
duration 0.010
option framerate 1000
As marcas de tempo dos pacotes preservavam as posições previstas:
0 ms
10 ms
20 ms
30 ms
Os timestamps ficaram em 0, 10, 20 e 30 ms. Usando 30 já nessa fase, o tempo era quantizado para passos de aproximadamente 33,3 ms.
Logo, 1000 Hz preservava corretamente os atrasos em resolução de 1 ms. Isso não significava produzir vídeo a 1000 fps.
atrasos precisos da origem
→ linha temporal de referência
→ escolher CFR
→ quantizar explicitamente para a grade CFR
→ preservar essa grade até o MP4 final
A alta precisão na entrada não era o problema. O problema era não tornar explícita a transição entre preservar o tempo da origem e quantizá-lo para a grelha CFR.
CFR é quantização controlada do tempo
62, 63, 62, 63 ms
↓
62.5, 62.5, 62.5, 62.5 ms
O filtro fps do FFmpeg é um ponto adequado para essa conversão: ele constrói a taxa solicitada com base nos PTS e na regra de arredondamento, repetindo ou descartando quadros.
Depois desse ponto quero preservar a grade, não deixar outra camada refazer independentemente a conversão.
Por que três novas tentativas não mudaram nada
FAIL 5580, esperado 5625
retry 1/3
FAIL 5580, esperado 5625
retry 2/3
FAIL 5580, esperado 5625
Uma nova tentativa ajuda quando a causa pode desaparecer entre as execuções. Não corrige uma violação determinística gerada pelo mesmo algoritmo.
- Falha transitória: nova tentativa pode funcionar.
- Entrada inválida: tratar por outra rota ou rejeitar.
- Invariante determinístico quebrado: parar e diagnosticar.
Depois apareceu 3751 em vez de 3750
90000 / 24 = 3750 ticks
Um resultado realmente concatenado continha 3751. Um tick vale:
1 / 90000 s ≈ 11.111 µs
Visualmente irrelevante, mas tecnicamente informativo porque 24 fps é representado exatamente por 3750 nessa escala.
A cópia do fluxo não significa marcas de tempo intocadas
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy final.mp4
-c:v copy evita recodificar o H.264. O contêiner ainda precisa montar os pacotes e suas marcas temporais em uma nova sequência.
A documentação do concat diz que a duração de cada arquivo é usada para ajustar os timestamps do seguinte. O FFmpeg também redimensiona valores inteiros entre bases de tempo racionais usando regras explícitas de arredondamento.
Isso não prova que concat foi a única causa possível do meu 3751. Prova que identidade do payload comprimido e identidade da grade temporal são propriedades diferentes.
PTS e DTS não podem ser corrigidos com uma fórmula bonita
PTS = N * duração
DTS = N * duração
Com B-frames isso pode estar errado, pois ordem de decodificação e apresentação podem divergir.
A normalização precisa nascer de uma linha temporal conhecida e restaurar a grade de apresentação sem destruir uma relação PTS/DTS válida. Por isso não publico uma expressão universal de setts.
Por que setts era a camada correta
setts pode alterar PTS, DTS, duração e base de tempo de pacotes sem recodificar o vídeo.
linha temporal conhecida + CFR conhecido + grade 90000
→ posições e durações esperadas
→ normalizar pacotes
→ validar novamente
A correção vem do modelo, não de uma regra “se aparecer 3751, subtraia um”.
Por que não aceitei tolerância de ±1 tick
Se uma taxa não for representável exatamente, um padrão de arredondamento pode ser necessário. Minhas taxas foram escolhidas justamente para serem exatas:
16 fps → 5625
24 fps → 3750
30 fps → 3000
Quando o valor exato existe, ±1 apenas transforma uma violação desconhecida em estado permitido.
- \n
- se a grade exigir necessariamente durações inteiras alternadas, validar o padrão correto; \n
- se a duração tiver de ser um único inteiro exato, exigir exatamente esse valor; \n
- nunca usar ±1 como atalho universal apenas para fazer um validador com falha passar. \n
Como valido CFR no nível de pacote
\navg_frame_rate e metadados semelhantes do fluxo são resumos úteis, mas não bastam para este contrato.
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json final.mp4
Verifico a base de tempo real e, para cada pacote, 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 lists, trimming e outras exceções intencionais precisam ser modeladas explicitamente. É um contrato do meu gerador, não uma lei universal do MP4.
Validar metadados e fazer decodificação completa respondem perguntas diferentes
ffprobe/pacotes → estrutura e tempo
decode completo → integridade do fluxo comprimido
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
Só publico depois das duas verificações.
Um código de saída 0 do codificador, por si só, já não significa para mim que o ficheiro esteja pronto.
O que os dois erros realmente provaram
5580: repetição determinística; exatamente 62 ms a 90 kHz; atrasos reais de 62/63 ms; CFR 16 exige 62,5 ms; e o experimento a 1000 Hz preservava milissegundos corretamente. Isso sustenta fortemente a hipótese de tempo de origem sobrevivendo além do ponto correto, mas não identifica sozinho a linha de código culpada.
3751: pacote real com um tick extra, processo usando concat com stream copy e posterior normalização via setts. A documentação confirma os mecanismos sem justificar a afirmação “concat sempre adiciona um tick”.
Meu processo atual
- Reconstruir a linha temporal real da origem.
- Preservar atrasos com precisão suficiente.
- Escolher CFR separadamente.
- Quantizar explicitamente para essa grade.
- Usar escala de tempo que represente exatamente as taxas permitidas quando possível.
- Evitar uma segunda conversão independente de fps.
- Validar timing e pacotes antes do concat.
- Verificar compatibilidade completa antes do stream copy.
- Revalidar a grade depois do concat.
- Normalizar a partir da linha temporal conhecida se necessário.
- Validar novamente.
- Decodificar todo o arquivo.
- Publicar atomicamente só após aprovação.
A regra que ficou: CFR é um contrato temporal inteiro
time base = 1/90000
duração normal = 5625 ticks
cadência = 62.5 ms
grade de pacotes preservada
PTS/DTS válidos para reordenação H.264
5580 revelou a antiga grade de milissegundos. 3751 foi valioso justamente por ser quase invisível: um único tick mostrou que um invariante projetado para ser exato tinha deixado de ser preservado.
Eu não valido mais o rótulo “CFR”. Valido o tempo do qual esse rótulo deveria ser consequência.