Skip to content

음수 delta의 범인: first_pts를 잘못 잡은 TS 분석기 ​

StreamLive 라이브 채널에 ID3 방식의 timed metadata를 심어서 광고 마커 삽입을 검증하던 중이었습니다. 요청한 시각과 세그먼트 안에 실제로 박힌 시각의 차이, 즉 delta를 계산해서 삽입 지연이 어느 정도인지 보는 작업이었는데, 계산 결과에서 이상한 값을 하나 발견했습니다. delta가 음수로 나온 것입니다.

처음에는 그럴 수도 있다고 생각했습니다. MPEG-TS에서는 metadata PID가 비디오 첫 프레임보다 아주 살짝 앞선 PTS를 가질 수 있고, 세그먼트 프리롤처럼 보이는 케이스도 있기 때문입니다. 그런데 실제로 나온 값은 그 정도 범위로 설명할 수 있는 수준이 아니었습니다.

text
rel_ms = -2500ms

2.5초 음수는 살짝 앞선 정도로 넘어갈 수 있는 오차가 아닙니다. 어딘가에서 기준점 자체를 잘못 잡고 있다는 뜻이었습니다.

[음수 delta 로그 캡처 첨부 필요]

처음 의심한 것 ​

가장 먼저 의심한 것은 PTS wraparound였습니다. PTS는 33bit 값이고 90kHz clock을 쓰기 때문에 언젠가는 값이 한 바퀴 돌아 처음으로 되돌아갈 수 있습니다. 하지만 테스트 구간이 짧았고, 특정 세그먼트에서 반복적으로 비슷한 음수가 나오는 것을 보면 wraparound일 가능성은 낮아 보였습니다.

다음으로는 ID3 PTS가 실제로 비디오보다 앞에 있는 경우인지 확인했습니다. TS 구조상 metadata가 비디오보다 약간 먼저 나오는 것 자체는 가능한 일입니다. 하지만 -2500ms라면 이야기가 다릅니다. 결국 남은 후보는 하나, 분석 스크립트 자체를 의심하는 것이었습니다.

문제의 코드 ​

분석 로직은 대략 이런 형태였습니다.

python
if pts is not None and contains_idr(payload):
    idrs.append(IDRFrame(pts_90khz=pts))
    if first_pts is None or pts < first_pts:
        first_pts = pts
elif pts is not None and first_pts is None:
    first_pts = pts

의도는 명확합니다. first_pts는 세그먼트의 첫 비디오 프레임 PTS여야 하고, 이 값이 있어야 ID3 위치를 아래처럼 계산할 수 있습니다.

text
id3_offset_ms = (id3_pts - first_pts) / 90

90으로 나누는 이유는 90kHz clock 기준에서 90 ticks가 1ms이기 때문입니다. 문제는 이 first_pts를 잡는 방식이 실제 TS packet이 처리되는 방식과 맞지 않았다는 데 있었습니다.

PES가 뭔데 ​

원인을 이해하려면 먼저 PES 구조를 짚고 가야 합니다. TS packet은 188바이트짜리 작은 조각이고, PES는 이 조각들을 모아서 만든 오디오, 비디오, 메타데이터 단위에 가깝습니다.

text
TS packet
TS packet
TS packet
  -> PES packet

여기서 중요한 점은, 비디오 한 세그먼트 안에 PES가 하나만 있는 게 아니라는 것입니다. 실제로는 여러 개가 순서대로 섞여 들어갑니다.

text
segment.ts
  -> video PES #1
  -> audio PES #1
  -> video PES #2
  -> metadata PES
  -> video PES #3
  -> ...

그래서 세그먼트의 첫 비디오 PTS를 잡으려면, 이 여러 PES 중에서 비디오 PID의 첫 번째 PES PTS를 정확히 골라내야 합니다.

진짜 문제 ​

분석 코드를 다시 보니, PES payload를 PID별로 모으는 과정에서 마지막 PES만 남기는 구조로 짜여 있었습니다. 대략 이런 흐름입니다.

text
TS를 읽는다
  -> 앞에 나온 video PES를 저장한다
  -> 다음 video PES가 오면 기존 것을 덮어쓴다
  -> 결국 마지막 PES만 남는다

그러면 first_pts라고 믿고 쓰는 값이 실제로는 세그먼트의 첫 비디오 PTS가 아니라, 뒤쪽에 있는 비디오 PTS가 되어버립니다. 예를 들어 4초짜리 세그먼트에서 값이 이렇다고 하겠습니다.

text
segment first video PTS = 100000
ID3 PTS                 = 120000
segment last video PTS  = 460000

정상적인 계산이라면 결과는 이렇습니다.

text
(120000 - 100000) / 90 = 222ms

그런데 first_pts를 마지막 쪽 PTS로 잘못 잡으면 계산은 이렇게 뒤집힙니다.

text
(120000 - 460000) / 90 = -3777ms

이제 값이 설명됩니다. ID3가 이상했던 게 아니라, 기준점 자체가 뒤로 밀려 있었던 것입니다.

수정 방향 ​

근본적으로는 PES assembly 로직을 제대로 고쳐야 합니다. 하지만 이번 목적이 세그먼트의 첫 비디오 PTS를 찾는 것 하나였다면 더 단순한 방법이 있습니다. TS packet을 순회하면서 video PID의 PUSI 패킷에서 PES header PTS를 바로 읽고, 그중 가장 작은 값을 first_pts로 쓰는 방식입니다.

python
first_video_pts = None

for packet in ts_packets:
    if packet.pid != video_pid:
        continue

    if not packet.payload_unit_start_indicator:
        continue

    pts = read_pes_pts(packet.payload)
    if pts is None:
        continue

    if first_video_pts is None or pts < first_video_pts:
        first_video_pts = pts

이렇게 하면 마지막 PES만 남는 기존 구조에 영향받지 않고 값을 구할 수 있습니다.

[수정 전/후 delta 비교 캡처 첨부 필요]

검증 방식 ​

수정한 뒤에는 같은 segment로 다시 계산해서 확인했습니다. 확인해야 하는 값은 단순합니다.

text
segment PDT
first video PTS
ID3 PTS
ID3 offset
actual ID3 time
delta

여기서 음수 값이 사라졌는지만 보면 충분하지 않습니다. 실제로 ID3가 세그먼트 안의 어느 위치에 있는지도 함께 확인해야 합니다.

text
SEG 시작 = EXT-X-PROGRAM-DATE-TIME
ID3 위치 = SEG 시작 + (ID3 PTS - first video PTS) / 90000

이렇게 계산된 위치가 세그먼트 duration 안에 들어온다면 일단 타당한 값이라고 볼 수 있습니다.

정리 ​

이번 문제는 StreamLive가 내보내는 스트림 자체의 문제가 아니었습니다. 분석기가 세그먼트의 첫 비디오 PTS를 잘못 잡고 있었을 뿐입니다. 특히 MPEG-TS처럼 작은 packet을 모아 PES를 해석하는 구조에서는, 마지막으로 본 값을 첫 값으로 착각하기가 쉽습니다.

ID3 delta가 음수로 크게 튀면 먼저 세 가지를 의심해볼 만합니다.

  1. PTS wraparound
  2. metadata PTS가 실제로 비디오보다 앞선 케이스
  3. 분석 코드가 first_pts를 잘못 잡은 케이스

이번에는 3번이었습니다. 이런 버그가 특히 위험한 이유는, 스트림은 멀쩡한데 도구가 틀린 값을 보여주면 사람은 오히려 멀쩡한 시스템 쪽을 의심하게 되기 때문입니다.

基于 VitePress 构建 · 部署于腾讯云 EdgeOne Pages