어느 날 제 미디어 처리 파이프라인이 일부 결과를 게시하지 못하고 멈췄습니다. 처음 본 오류는 거의 우스워 보일 정도로 작았습니다.
Invalid CFR packet duration: 5580 ticks, expected 5625
디코더가 죽은 것도 아니고 FFmpeg가 H.264 파일 생성에 실패한 것도 아니었습니다. 인코딩 후 검증기가 거부했습니다. 출력은 고정 프레임률여야 했는데, 한 패킷이 제가 설계한 시간 그리드에서 벗어나 있었기 때문입니다.
같은 작업을 다시 돌렸습니다. 또 5580. 다시 한 번 돌려도 5580. 나중에는 다른 소스도 같은 값으로 실패했습니다. 이 반복은 중요한 증거였습니다. 일시적인 네트워크 문제나 드문 경쟁 상태가 아니라 같은 입력과 알고리즘에서 재현되는 결정론적 위반이었습니다.
그 뒤 겉보기에는 비슷하지만 기술적으로는 다른 두 번째 오류를 찾았습니다. 24 fps이고 비디오 트랙 타임스케일이 90,000이라면 일반 샘플 지속시간은 정확히 3750 틱이어야 합니다. 그런데 검증기는 3751을 발견했습니다.
이 둘을 모두 “FFmpeg가 반올림해서 그렇다”라고 설명하면 핵심을 놓칩니다. 5580과5625의 차이는 45 틱, 정확히 0.5 ms입니다. 3751과3750은 단 1 틱, 약 11.1 마이크로초 차이입니다.
이 두 버그를 겪으면서 예전에는 “FPS”라는 한 단어 아래 섞어 생각했던 네 가지를 분리하게 됐습니다. 프레임률, 타임 베이스, PTS/DTS, 패킷 지속시간입니다.
CFR은 단순히 “16 fps”라고 표시되는 것이 아니다
지금 제가 생성된 파일을 CFR이라고 부를 때는 ffprobe가 16/1이나 24/1을 보여주는 것만으로 충분하지 않습니다.
이 파이프라인에서 CFR은 더 엄격한 규칙입니다. 표시 시각이 일정한 그리드 위에 있고, 일반적인 비디오 샘플 지속시간이 그 그리드의 한 칸과 일치해야 합니다.
16 fps라면 한 프레임은:
1 / 16 = 0.0625 s = 62.5 ms
90,000 틱/s 트랙에서는 같은 시간을 정수로 정확히 표현할 수 있습니다.
90000 / 16 = 5625 ticks
그래서 5625는 검증기에 임의로 넣은 상수가 아닙니다. 16 fps와90,000 ticks/s라는 출력 규칙 두 조건에서 직접 나온 값입니다.
이렇게 정확한 값을 요구할 수 있는 이유는 허용한 프레임률이 타임스케일을 나머지 없이 나누도록 설계했기 때문입니다. 목표 주기가 하나의 정수 지속시간으로 표현되지 않는다면 검증기는 올바른 정수 패턴을 검사해야지, 존재할 수 없는 단일 값을 강요하면 안 됩니다.
프레임률, 타임 베이스, MP4 타임스케일은 서로 다르다
- 프레임률은 화면에 프레임이 표시되는 간격을 뜻합니다. CFR 16이면 62.5 ms마다 한 프레임이 표시됩니다.
- FFmpeg 타임 베이스는 정수 타임스탬프 한 단위의 길이입니다. 예를 들어
1/90000초입니다. - MP4 트랙 타임스케일은 초당 몇 개의 시간 단위를 쓰는지 나타냅니다. 90,000이면 한 단위는
1/90000초입니다. - PTS는 화면을 언제 표시할지 나타냅니다.
- DTS는 인코딩된 패킷을 언제 디코딩해야 하는지 나타냅니다.
- 패킷 지속시간은 스트림 타임 베이스 기준으로 샘플이 차지하는 시간입니다.
B프레임이 있으면 PTS와 DTS가 다른 것은 정상일 수 있습니다. 따라서 타임스탬프를 고친다고 단순히 PTS = DTS로 만드는 것은 위험합니다. 공식 setts 문서도 B프레임이 있을 때 이 방식을 권장하지 않습니다.
제 검증기는 두 값을 같게 강제하지 않고, 디코딩 순서와 표시 순서의 관계가 유효한지 확인합니다.
90,000 틱/s를 선택한 이유
90,000이 모든 비디오에 정답인 숫자는 아닙니다. 제 파이프라인에서는 허용한 모든 프레임률이 정확한 정수 지속시간을 만든다는 점이 좋았습니다.
| 프레임률 | 90,000 틱/s에서 프레임 지속시간 |
|---|---|
| 10 fps | 9000 틱 |
| 12 fps | 7500 틱 |
| 15 fps | 6000 틱 |
| 16 fps | 5625 틱 |
| 18 fps | 5000 틱 |
| 20 fps | 4500 틱 |
| 24 fps | 3750 틱 |
| 25 fps | 3600 틱 |
| 30 fps | 3000 틱 |
밀리초 소스 타이밍과도 관계가 단순합니다.
1 ms = 90 ticks
FFmpeg MP4 멀티플렉서는 video_track_timescale을 제공하므로 이 그리드를 명시할 수 있습니다. 타임스케일을 지정했다고 타이밍이 저절로 맞는 것은 아닙니다. 대신 규칙을 정확한 정수 값으로 측정하고 검증할 수 있습니다.
5580이라는 숫자가 조사 방향을 보여줬다
첫 번째 실패를 다시 실제 시간으로 바꾸면 의미가 보입니다.
5580 / 90000 = 0.062 s = 62 ms
62 ms는 낯선 값이 아니었습니다. 제가 실제로 처리한 애니메이션 소스 중 하나는 3.063초 동안 49개의 표시된 프레임이 있었고 프레임 지연이 62 ms와 63 ms로 번갈아 나왔습니다.
49 / 3.063 ≈ 15.997 frames/s
62 ms + 63 ms = 125 ms
2 frames at 16 fps = 2 × 62.5 ms = 125 ms
소스 단계에서 62/63 ms는 이상하지 않습니다. 1 ms 단위 타이밍으로 16 fps를 표현할 때 매우 자연스러운 근사입니다.
하지만 CFR 16을 선택한 뒤에는 출력이 목표 그리드 위에 있어야 합니다. 일반 프레임 지속시간은 62.5 ms, 즉 5625 틱이어야 합니다.
그래서 5580은 소스의 62 ms 지속시간이 이미 CFR로 양자화되어 있어야 하는 단계까지 남아 있었을 가능성을 강하게 보여줬습니다.
여기서는 증거와 추론의 경계를 분명히 합니다. 저장된 로그로 확실히 말할 수 있는 것은 90 kHz에서 5580 틱이 정확히 62 ms라는 점과 실제 소스에 62/63 ms 지연이 있었다는 점입니다. 어느 함수에서 처음 이 값이 남았는지는 오류 한 줄만으로 증명할 수 없습니다.
1000 Hz 입력 클록 자체는 잘못이 아니었다
이 부분은 FFmpeg 7.1.5와 ffconcat으로 별도 재현했습니다. 다음 입력을 사용하면:
duration 0.010
option framerate 1000
패킷 타임스탬프가 의도한 밀리초 위치를 유지했습니다.
0 ms
10 ms
20 ms
30 ms
반대로 이 초기 단계에서 입력 레이트를 30으로 주면 같은 타이밍이 대략 0과 33.3 ms로 양자화됐습니다.
따라서 1000 Hz 입력 클록은 올바른 역할을 했습니다. 소스의 기준이 되는 지연을 1 ms 정밀도로 보존하기 위한 입력 타임스탬프 그리드였습니다. 최종 출력이 1000 fps라는 뜻이 아닙니다.
millisecond-accurate source delays
↓
authoritative source timeline
↓
choose target CFR
↓
explicitly quantize onto CFR grid
↓
preserve that grid through encoding and muxing
입력 정밀도가 높은 것이 문제가 아니라 소스 타이밍에서 목표 CFR로 넘어가는 경계를 명시하지 않는 것이 문제입니다.
CFR은 시간을 의도적으로 양자화한 결과다
62/63 ms 소스는 프레임 두 개씩 보면 16 fps에 자연스럽게 맞습니다. 둘 다 합쳐 125 ms입니다. 그렇다고 최종 파일에 62,63,62,63 ms 지속시간을 그대로 두고 “16 fps”라고 부를 수는 없습니다. 그것은 여전히 가변 지속시간입니다.
62 ms, 63 ms, 62 ms, 63 ms
↓
62.5 ms, 62.5 ms, 62.5 ms, 62.5 ms
FFmpeg의 fps 필터는 이 변환을 수행하기 좋은 지점입니다. 입력 타임스탬프와 선택한 반올림 정책을 기준으로 프레임을 제거하거나 반복해 목표 프레임률을 만듭니다.
이 단계에서 목표 그리드를 만들었다면 뒤쪽 단계가 독립적으로 프레임률 변환을 또 수행하게 두고 싶지 않습니다. 의도적인 양자화 경계는 하나여야 합니다.
세 번 재시도해도 결과가 바뀌지 않은 이유
FAIL: duration 5580, expected 5625
retry 1/3
FAIL: duration 5580, expected 5625
retry 2/3
FAIL: duration 5580, expected 5625
이건 비디오만의 교훈이 아닙니다. 재시도는 다음 시도에서 사라질 수 있는 실패에 적합합니다. 일시적인 네트워크 오류, 스토리지 문제, 리소스 압박, 의존성 장애 같은 경우입니다.
같은 입력과 같은 알고리즘이 같은 결정론적 규칙 위반을 만들면 재시도로 고쳐지지 않습니다.
- 일시적 실패 — 재시도가 도움이 될 수 있다.
- 잘못된 입력 — 별도 처리하거나 거부한다.
- 결정론적 불변 조건 실패 — 재시도를 멈추고 파이프라인을 진단한다.
반복된 5580은 세 번째였습니다.
그다음에는 3750 대신 3751이 나왔다
24 fps에서 기대값은 정확한 정수입니다.
90000 / 24 = 3750 ticks
그런데 실제 concat 출력 하나에:
3751 ticks
인 패킷이 있었습니다. 차이는:
1 / 90000 s ≈ 11.111 µs
뿐입니다. 시청자가 1 틱을 알아챌 가능성은 사실상 없습니다. 그래서 검증기를 ±1만큼 느슨하게 만들고 싶어집니다.
저는 그렇게 하지 않았습니다. 이 그리드에서 24 fps는 3750으로 정확히 표현됩니다. 3751은 표현 한계 때문에 피할 수 없는 오차가 아니라, 정확한 그리드의 불변 조건이 어딘가에서 사라졌다는 증거였습니다.
스트림 복사는 타임스탬프가 손대지 않았다는 뜻이 아니다
호환되는 H.264 세그먼트들은 두 번째 손실 인코딩을 피하기 위해 다음처럼 concat했습니다.
ffmpeg -f concat -safe 0 -i segments.ffconcat \
-c:v copy \
final.mp4
-c:v copy는 압축된 H.264 데이터를 디코딩 후 다시 인코딩하지 않는다는 뜻입니다. 멀티플렉싱 단계가 타이밍 작업을 전혀 하지 않는다는 뜻은 아닙니다.
Concat 디멀티플렉서 문서에는 각 파일의 지속시간을 이용해 다음 파일의 타임스탬프를 조정한다고 명시돼 있습니다. FFmpeg는 유리수 타임 베이스 사이에서 정수 타임스탬프를 재스케일하며, libavutil에는 반올림 모드를 명시하는 함수도 있습니다.
이 사실이 제 3751의 원인이 concat 하나뿐이었다고 증명하지는 않습니다. 다만 “비트스트림이 복사됐으니 패킷 타임스탬프도 바뀔 수 없다”는 개념적 모델은 틀렸습니다.
압축된 영상 데이터가 동일한 것과 타임스탬프 그리드가 동일한 것은 서로 다른 속성입니다.
PTS와 DTS를 한 줄 공식으로 고치면 안 된다
PTS = N * frame_duration
DTS = N * frame_duration
B프레임이 있는 H.264에서는 이 식이 잘못될 수 있습니다. 화면의 디코딩 순서와 표시 순서가 다를 수 있기 때문입니다.
목표는 더 제한적이어야 합니다. 기준이 되는 세그먼트 시간축을 이용해 알려진 표시 그리드와 패킷 지속시간을 복원하되 유효한 디코딩-순서 관계를 유지하는 것입니다.
그래서 맥락 없는 “마법 같은 setts 표현식”을 제공하지 않습니다. 올바른 표현식은 세그먼트 경계, 선택 비율, 최종 시간축을 만든 타이밍 메타데이터에 달려 있습니다.
setts를 사용한 이유
FFmpeg의 setts 비트스트림 필터는 비디오를 디코딩하거나 다시 인코딩하지 않고 패킷 PTS, DTS, 지속시간, 출력 타임 베이스를 바꿀 수 있습니다.
연결 후 정규화에 맞는 단계였습니다. H.264 압축 데이터는 그대로 두고 알려진 세그먼트 메타데이터에서 유도한 패킷 시간축을 다시 강제할 수 있기 때문입니다.
known segment timeline
+ known CFR
+ 90000-tick grid
↓
known valid packet positions and durations
↓
normalize packet timing
↓
validate again
이건 “지속시간이 3751이면 1을 뺀다”는 규칙과 다릅니다. 수정은 오늘의 오류 메시지가 아니라 타이밍 모델에서 나와야 합니다.
±1 틱 허용 오차를 거부한 이유
작은 허용 오차가 맞는 시스템도 많습니다. 선택한 정수 타임 베이스에서 표시 간격을 정확하게 표현할 수 없다면 검증기는 필요한 반올림 패턴을 모델링해야 합니다.
제 규칙은 다릅니다. 허용한 프레임률을 90000 / fps가 정수가 되도록 제한했습니다.
16 fps → 5625
24 fps → 3750
30 fps → 3000
예상 지속시간을 정확한 정수로 표현할 수 있는데도 일반적인 ±1을 허용하면, 이유를 모르는 불변 조건 위반을 정상 상태로 바꾸게 됩니다.
1 틱은 시각적으로 중요하지 않습니다. 설계한 규칙을 왜 잃었는지 모른 채 넘어가는 것이 중요합니다.
- 그리드가 본질적으로 여러 정수 지속시간을 번갈아 써야 한다면 올바른 패턴을 검증한다.
- 지속시간이 정확한 하나의 정수여야 한다면 그 값을 요구한다.
- 검증기를 통과시키기 위한 만능 해법으로 ±1을 쓰지 않는다.
패킷 단위로 CFR을 검증하는 방법
avg_frame_rate 같은 스트림 메타데이터는 유용한 요약이지만 이 규칙에는 충분하지 않습니다.
ffprobe -v error \
-select_streams v:0 \
-show_streams \
-show_packets \
-of json \
final.mp4
실제 스트림 타임 베이스와 각 패킷의 pts, dts, duration을 확인합니다.
expected = 90000 / 16 // 5625
for each normal video packet:
assert packet.duration == 5625
assert decode ordering is legal
assert PTS/DTS relationship is legal for the codec
assert presentation timeline matches planned frame count and duration
“일반 패킷”이라는 조건은 중요합니다. 편집 목록, 트리밍, 의도적으로 특별한 마지막 샘플이나 컨테이너 동작은 별도로 모델링해야 할 수 있습니다. 이 조건을 모든 MP4에 적용하는 보편 법칙으로 말하는 것은 아닙니다.
하지만 구조를 제가 통제하는 생성기라면 “대충 16 fps처럼 보인다”보다 엄격한 검증이 훨씬 유용합니다.
메타데이터 검증과 전체 디코딩은 서로 다른 질문에 답한다
타임스탬프가 완벽해도 H.264 스트림 전체가 디코딩된다는 보장은 없습니다. 반대로 정상적인 디코딩이 타이밍 규칙까지 맞다는 뜻도 아닙니다.
ffprobe / packet validation
→ structure, timestamps, durations, stream parameters
full decode
→ whether the complete compressed stream can actually be decoded
패킷 검사 후에도 다음을 실행합니다.
ffmpeg -v error -xerror -err_detect explode \
-i final.mp4 -f null -
두 단계가 모두 통과한 파일만 게시합니다. 인코더의 종료 코드가 0이라는 사실만으로는 더 이상 “완료”라고 보지 않습니다.
두 실패가 실제로 증명한 것
5580에 대해서는 같은 결정론적 값이 재시도마다 나타났다는 것, 90 kHz에서 5580이 정확히 62 ms라는 것, 실제 소스 타이밍에 62/63 ms 지연이 있었다는 것, CFR 16에는 62.5 ms 또는 5625 틱이 필요하다는 것, 그리고 1000 Hz 입력 실험이 밀리초 지연을 올바르게 보존했다는 것을 확인할 수 있습니다.
따라서 소스 지속시간이 이미 CFR이어야 할 단계까지 남았다는 설명에는 강한 근거가 있습니다. 다만 보관된 오류 한 줄만으로 정확한 함수를 특정할 수는 없습니다.
3751에 대해서는 실제 출력에서 24 fps가 요구하는 3750 대신 3751-틱 패킷을 확인했고, 파이프라인이 스트림 복사 concat을 사용했으며, 이후 연결 후 패킷 정규화에 setts가 사용됐습니다. FFmpeg 문서도 concat의 타임스탬프 조정과 패킷 단위 재작성을 설명합니다.
이 사실들은 정수 재스케일링이나 멀티플렉싱/concat 경계 반올림과 일치하지만, “concat은 항상 1 틱을 더한다”는 일반화를 정당화하지 않습니다.
지금 사용하는 작업 흐름
- 추정된 단일 FPS가 아니라 기준이 되는 소스 시간축을 복원한다.
- 원래 밀리초 지연을 충분히 정밀한 입력 클록으로 보존한다.
- 목표 CFR을 별도 단계에서 선택한다.
- 소스 시간축을 명시적으로 CFR 그리드에 양자화한다.
- 가능하면 허용 비율을 정확한 정수로 표현할 수 있는 트랙 타임스케일을 쓴다.
- 인코딩 이후의 단계가 독립적으로 프레임률 변환을 다시 하지 않게 한다.
- Concat 전 타임 베이스, 프레임 개수, PTS/DTS, 패킷 지속시간을 검증한다.
- 스트림 복사 concat 전에 코덱 설정과 타이밍 호환성을 검증한다.
- Concat 후에도 패킷 그리드를 다시 확인하며
-c:v copy를 타임스탬프 동일성의 증명으로 보지 않는다. - 정규화가 필요하면 알려진 시간축에서 유도해 패킷 수준에서 적용한다.
- 모든 패킷을 다시 검증한다.
- 최종 파일 전체를 디코딩한다.
- 모든 규칙을 통과한 뒤에만 원자적으로 게시한다.
남은 원칙: CFR은 정수 타이밍 규칙이다
예전에는 16 fps라는 숫자만으로 설명이 충분하다고 생각했습니다. 이제는 아닙니다.
time base = 1/90000
normal frame duration = 5625 ticks
presentation cadence = 62.5 ms
packet timeline follows that grid
PTS/DTS remain legal for H.264 reordering
5580은 소스의 오래된 밀리초 그리드를 드러냈기 때문에 중요했습니다. 3751은 오히려 거의 보이지 않는 차이라서 중요했습니다. 단 1 틱만으로도 시스템이 지키기로 한 불변 조건이 깨졌다는 사실을 알 수 있습니다.
지금의 규칙은 단순합니다. “CFR”이라는 라벨을 검증하지 말고, 그 라벨이 성립하게 만드는 타이밍 자체를 검증합니다.
주요 공식 문서
- FFmpeg: fps 필터 — 목표 프레임률 구성과 PTS 반올림 정책.
- FFmpeg: MOV/MP4 형식 —
video_track_timescale과 MP4 멀티플렉서 옵션. - FFmpeg: concat 디멀티플렉서 — 파일 지속시간과 연결 입력 사이 타임스탬프 조정.
- FFmpeg: setts 비트스트림 필터 — 패킷 PTS, DTS, 지속시간, 타임 베이스 재작성.
- ffprobe 문서 — 스트림과 패킷 검사.
- FFmpeg libavutil: 시간 계산과 타임스탬프 재스케일링 — 유리수 타임스탬프 재스케일링과 명시적 반올림 모드.