블로그 목록
Media25분 읽기

Agora SEI 메타데이터 — 비디오에 박힌 레이아웃 정보의 바이너리 해부

합성 송출을 받는 시청자 앱에서 "지금 화면 어디에 누가 있는지"를 어떻게 알아낼까요? 별도 시그널링으로 보내면 비디오 프레임과 타이밍이 어긋나기 마련인데, 답은 비디오 스트림 안에 메타데이터를 박아 보내는 SEI에 있습니다. 이 글은 H.264와 H.265 표준의 SEI가 무엇인지, Agora Media Push가 레이아웃 JSON을 어떤 바이트 포맷으로 박아 주는지, 그리고 시청자 파서가 NAL 헤더부터 0xFF 누적 길이, JSON 파싱까지 어떻게 풀어내는지 차근차근 짚어 줍니다.

SEIH.264H.265NALAgora Media Push메타데이터Annex BAVCCRTMPFLVSwift
목차(40개 항목)
  1. 0. 핵심 명제 — SEI는 비디오와 함께 도착이 보장된 메타데이터 채널
  2. 1. SEI란 무엇인가 — H.264/H.265 표준의 일부
  3. 2. 왜 시그널링 대신 SEI를 쓰나
  4. 3. Agora SEI의 JSON 구조
    1. 최상위 필드
    2. `canvas` 필드
    3. `regions` 배열의 각 요소 (호스트별)
    4. 실제 페이로드 예시
  5. 4. SEI 바이너리 구조 — H.264 표준 + Agora 커스텀
    1. 바이트별 의미
    2. NAL 헤더 0x06 분해
    3. payload_type 100의 정확한 위치
    4. NAL 스트림 안에서의 위치
  6. 5. 0xFF 누적 길이 인코딩
    1. 인코딩 규칙
    2. 예시
    3. 파서 의사 코드
  7. 6. NAL 스트림 포맷 — Annex B vs AVCC
    1. Annex B 포맷 (raw H.264 스트림 / RTSP / TS)
    2. AVCC 포맷 (MP4 / FLV / RTMP)
  8. 7. 시청자 측 파싱 흐름
    1. Swift / iOS 파서 예시
    2. 주의사항 — `emulation_prevention_byte`
  9. 8. 활용 패턴
    1. 8-1. 클릭 가능 영역 매핑 (Agora의 표준 사용 케이스)
    2. 8-2. `app_data` 필드 활용 — 자유 메타데이터
    3. 8-3. 정확도 한계
  10. 9. SEI vs 다른 동기화 메커니즘 — 의사결정
  11. 10. FAQ
    1. Q1. SEI를 쓰면 시그널링 인프라 필요 없나?
    2. Q2. SEI가 디코딩에 영향 주나?
    3. Q3. SEI가 매 프레임마다 박히나?
    4. Q4. RTC 원본 스트림에도 SEI가 있나?
    5. Q5. payload_type 100을 쓰면 다른 디코더와 충돌하나?
    6. Q6. H.265(HEVC)에서도 같은 형식인가?
  12. 11. SA 체크리스트
  13. 12. 한 줄 결론
    1. 한 장 요약
  14. 관련 글
  15. 참고 자료

"Agora Media Push로 합성 송출 받는 시청자 앱에서, 화면에 누가 어디 있는지 어떻게 알아내요?", "RTM으로 보내려니 비디오 프레임과 타이밍이 어긋나는데요". 답은 비디오 스트림 안에 메타데이터를 박아 보내는 SEI (Supplemental Enhancement Information).

이 글은 H.264/H.265 표준의 SEI가 무엇인지, Agora Media Push가 어떤 JSON을 어떤 바이트 포맷으로 박아 주는지, 시청자 측 파서가 어떻게 풀어내야 하는지를 정리합니다.


0. 핵심 명제 — SEI는 비디오와 함께 도착이 보장된 메타데이터 채널

별도 시그널링(WebSocket/RTM)으로 메타데이터를 보내면 비디오 프레임과 타이밍이 어긋난다. SEI는 비디오 NAL 스트림 안에 박혀 있어 항상 같이 도착한다 — 디코딩에 영향 없이 메타데이터를 비디오에 동기로 실어 보내는 표준 메커니즘.

흔한 오해와 정정:

오해정확한 이해
SEI는 Agora 전용 기술H.264/H.265(및 MPEG-2 video)의 표준 기능. 단 모든 비디오 코덱이 지원하는 건 아님 — VP9/AV1 등에는 SEI가 없고 각각 WebM BlockAddID(ITU-T T.35) / Metadata OBU(metadata_itut_t35) 같은 별도 메타데이터 메커니즘을 씀
SEI를 쓰면 시그널링 불필요SEI는 레이아웃 정보, 시그널링은 업링크 데이터 — 둘은 다른 채널
RTMP 위에 SEI 못 박음RTMP/FLV는 AVCC 포맷이지만 SEI NAL은 동일하게 실림
SEI는 디코더 동작에 영향❌ Annex D — 디코더는 SEI를 무시해도 됨 (선택사항)

1. SEI란 무엇인가 — H.264/H.265 표준의 일부

SEI = Supplemental Enhancement Information. ITU-T H.264 / ISO/IEC 14496-10의 Annex D에 정의된 부가 정보 영역.

핵심 특성:

특성설명
표준성H.264/H.265 표준에 정의 — 모든 컴플라이언트 인코더/디코더가 알고 있는 형식
비강제성디코더는 SEI를 처리할 의무가 없음 — 무시해도 디코딩 정상
비디오 동기NAL 스트림 안에 슬라이스 NAL 직전에 위치 — 같은 프레임과 묶여 도착
페이로드 자유도payload_type별로 표준 의미가 정해져 있고, type 5 = user_data_unregistered가 자유 데이터용

Annex D의 의미: Annex D는 H.264 표준의 부록으로, 디코딩에 필수가 아닌 부가 정보 형식을 정의. SEI는 그 부록 안에 있어서 "선택적"입니다. 그래도 모든 메이저 디코더는 SEI를 파싱할 줄 알고, 일부는 자막/광고/HDR 메타데이터에 적극 활용.


2. 왜 시그널링 대신 SEI를 쓰나

라이브 송출에서 자주 부딪히는 문제:

[발신측]
   │
   ├─ 비디오 프레임 t=10.0s ──→ [네트워크] ──→ [시청자] (예: 12.3s 도착, 합성된 화면)
   │
   └─ 시그널링 메시지 ──────→ [네트워크] ──→ [시청자] (예: 12.1s 또는 12.7s 도착, 별도 채널)

별도 시그널링과 비디오는 서로 다른 네트워크 경로를 거치므로 타이밍이 맞지 않음. "이 프레임에 호스트 A가 좌상단에 있다"는 정보가 비디오보다 0.4초 늦게 도착하면, 시청자 UI가 잘못된 위치를 클릭 가능 영역으로 인식.

SEI 해결책: 메타데이터를 NAL 스트림 안에 박아서 같은 프레임과 같이 도착시킴.

[발신측]
   │
   └─ NAL 스트림: [SEI: 레이아웃] [Slice: 비디오] ──→ [네트워크] ──→ [시청자]
                                                                       └─ 동시 도착 보장

뉘앙스: 네트워크 패킷 재정렬이나 버퍼 동작에 따라 완벽한 동시성이 깨질 수 있지만, decode order에서 SEI는 슬라이스 직전에 위치하므로 디코더 레벨에서는 항상 묶여서 처리됩니다. 시그널링 별도 채널 대비 압도적으로 안전.


3. Agora SEI의 JSON 구조

Agora Media Push는 트랜스코딩된 H.264/H.265 스트림에 자동으로 SEI를 추가합니다 — 클라이언트 별도 설정 불필요.

최상위 필드

{
  "canvas":   { ... 캔버스 정보 ... },
  "regions":  [ ... 호스트별 레이아웃 ... ],
  "ver":      20190611,
  "ts":       1683195420123,
  "app_data": "사용자 정의"
}
필드설명
canvas전체 합성 화면(캔버스) 정보
regions캔버스 위에 배치된 각 호스트의 레이아웃 (배열)
verSEI 프로토콜 버전 (현재 20190611)
ts인코딩 시점의 타임스탬프 (ms)
app_data사용자 정의 추가 정보 (transcodingExtraInfo에 대응)

canvas 필드

의미LiveTranscoding 대응
w캔버스 너비 (px)width
h캔버스 높이 (px)height
bgnd배경색 (RGB hex)backgroundColor

regions 배열의 각 요소 (호스트별)

의미TranscodingUser 대응
uid호스트 UIDuid
suid문자열 계정 (선택)
alpha투명도 [0.0 ~ 1.0]alpha
zorder레이어 순서 [0 ~ 100]zOrder
volume음량 dB [0 ~ 100]
x, y좌상단 기준 위치x, y
w, h비디오 프레임 크기 (px)width, height

실제 페이로드 예시

{
  "canvas": {
    "w": 1920,
    "h": 1080,
    "bgnd": "#000000"
  },
  "regions": [
    {
      "uid": 1001,
      "x": 0, "y": 0, "w": 960, "h": 540,
      "alpha": 1.0, "zorder": 1, "volume": 50
    },
    {
      "uid": 1002,
      "x": 960, "y": 0, "w": 960, "h": 540,
      "alpha": 1.0, "zorder": 1, "volume": 50
    },
    {
      "uid": 1003,
      "x": 0, "y": 540, "w": 1920, "h": 540,
      "alpha": 1.0, "zorder": 1, "volume": 50
    }
  ],
  "ver": 20190611,
  "ts": 1683195420123,
  "app_data": "{\"chapter\":\"intro\"}"
}

이 JSON 전체가 SEI 페이로드로 들어갑니다.


4. SEI 바이너리 구조 — H.264 표준 + Agora 커스텀

SEI의 첫 몇 바이트가 핵심입니다. 예시:

06 64 bd [...JSON content...]

바이트별 의미

바이트의미
06NAL 헤더 — forbidden_zero_bit(1) + nal_ref_idc(2) + nal_unit_type(5) = 0 00 00110 → SEI
64payload_type = 100 (Agora 커스텀 선택)
bdpayload_size = 189 바이트 (이 예시 한정 — 단일 바이트로 표현 가능한 길이)

NAL 헤더 0x06 분해

0x06 = 0000 0110
       │ ││ │└┴┴── nal_unit_type (5 bits) = 0b00110 = 6 → SEI
       │ └┴── nal_ref_idc (2 bits) = 0 (SEI는 비참조)
       └── forbidden_zero_bit = 0

NAL unit type 6 = SEI, 표준 정의. RFC 6184도 SEI NAL은 nal_ref_idc=0이어야 한다고 명시.

payload_type 100의 정확한 위치

⚠️ 흔한 오해 정정: payload_type 100은 H.264/HEVC 표준의 어떤 amendment에도 정의돼 있지 않은 미정의(reserved) 값입니다. 표준은 미정의 payloadType을 reserved_sei_message로 취급할 뿐이고, Agora가 이 미정의 값을 임의로 점유한 것. (Agora 공식 문서는 100을 명시적으로 "user-defined(여기서 100으로 정의)"라고 표기합니다.)

**표준적인 커스텀 데이터 메커니즘은 type 5 = user_data_unregistered**이며, 128-bit UUID 네임스페이싱이 따라옵니다 (벤더 충돌 방지). Agora는 표준 mechanism 대신 미정의 type을 직접 점유한 케이스.

실무 영향: 다른 인코더로 만든 H.264 스트림에 type 100 SEI가 있으면 그건 다른 의미일 수 있음. Agora 송출인지 확인 후 파싱.

payload_type표준 의미
0buffering_period
1pic_timing
5user_data_unregistered (UUID 네임스페이싱 — 표준 커스텀 데이터)
22post_filter_hint
100표준 미정의값 (Agora가 점유, 공식 문서상 user-defined)
136time_code

NAL 스트림 안에서의 위치

[Slice: 프레임 N-1] [SEI: payload] [Slice: 프레임 N] [Slice: 프레임 N+1]
                    ↑ 프레임 N과 묶여서 도착

SEI는 자기가 따라붙을 슬라이스의 직전에 위치 — 디코더가 슬라이스 처리 전에 SEI를 본다는 약속.


5. 0xFF 누적 길이 인코딩

payload_typepayload_size 모두 H.264 표준의 동일한 가변 길이 인코딩을 씁니다.

인코딩 규칙

length = 0;
byte = read_byte();
while (byte == 0xFF) {
    length += 255;
    byte = read_byte();
}
length += byte;

0xFF가 나오면 255씩 누적, 0xFF가 아닌 마지막 바이트의 값을 더해 종료.

예시

길이인코딩분해
234EA234 < 255 → 단일 바이트
572FF FF 3E2 × 255 + 62 = 572
922FF FF FF 9D3 × 255 + 157 = 922
1500FF FF FF FF FF E15 × 255 + 225 = 1500

파서 의사 코드

def read_variable_length(buf, pos):
    length = 0
    while buf[pos] == 0xFF:
        length += 255
        pos += 1
    length += buf[pos]
    pos += 1
    return length, pos

# SEI 시작 위치(NAL 헤더 다음)에서:
payload_type, pos = read_variable_length(buf, pos)
payload_size, pos = read_variable_length(buf, pos)
payload = buf[pos:pos + payload_size]

이 로직 하나면 모든 H.264/H.265 SEI를 파싱할 수 있습니다.


6. NAL 스트림 포맷 — Annex B vs AVCC

같은 SEI NAL이라도 어떤 컨테이너에 담기느냐에 따라 framing이 다릅니다.

Annex B 포맷 (raw H.264 스트림 / RTSP / TS)

[start code: 00 00 00 01] [NAL header: 06] [payload_type] [payload_size] [payload]
  • 4바이트 start code(00 00 00 01) 또는 3바이트(00 00 01)로 NAL 경계 표시
  • byte stream을 스캔해서 start code 찾으면 NAL 시작

AVCC 포맷 (MP4 / FLV / RTMP)

[length: 4 bytes big-endian] [NAL header: 06] [payload_type] [payload_size] [payload]
  • start code 없음 — 대신 4바이트 length prefix로 NAL 크기 명시
  • length만큼 읽고 다음 NAL로 점프

자주 틀리는 포인트: "RTMP에 SEI 박는다" → RTMP는 AVCC 포맷입니다. NAL 페이로드(06 64 bd ...)는 동일하지만 앞에 붙는 게 start code가 아니라 length prefix. 파서를 짤 때 framing을 정확히 알아야 NAL 경계를 찾음.

컨테이너포맷NAL 경계 표시
RTSP, MPEG-TSAnnex Bstart code (00 00 00 01)
MP4, MOVAVCC4바이트 length prefix
RTMP, FLVAVCC4바이트 length prefix
WebRTC RTPstart code 안에 RTP fragmentationstart code (depacketize 후)

7. 시청자 측 파싱 흐름

전체 파서 흐름을 한 장에 정리.

[RTMP 도착]
   ↓
FLV 디먹스 → AVCC 포맷 NAL 단위 추출
   ↓
[NAL: 06 64 bd ... ]
   ↓
첫 바이트 0x06 → SEI NAL
   ↓
payload_type 읽기 (0xFF 누적)
   ↓
payload_type == 100? (Agora 커스텀)
   ↓ Yes
payload_size 읽기 (0xFF 누적)
   ↓
payload bytes (= JSON 문자열)
   ↓
JSON.parse
   ↓
canvas / regions / ver / ts / app_data 추출
   ↓
UI 업데이트 (예: 클릭 가능 영역 매핑)

Swift / iOS 파서 예시

func parseAgoraSEI(nalUnit: Data) -> AgoraSEI? {
    var pos = 0
    
    // ① NAL header
    guard nalUnit[pos] == 0x06 else { return nil }  // SEI NAL?
    pos += 1
    
    // ② payload_type (0xFF accumulation)
    var payloadType = 0
    while nalUnit[pos] == 0xFF {
        payloadType += 255
        pos += 1
    }
    payloadType += Int(nalUnit[pos])
    pos += 1
    
    guard payloadType == 100 else { return nil }  // Agora 커스텀
    
    // ③ payload_size (0xFF accumulation)
    var payloadSize = 0
    while nalUnit[pos] == 0xFF {
        payloadSize += 255
        pos += 1
    }
    payloadSize += Int(nalUnit[pos])
    pos += 1
    
    // ④ payload (JSON)
    let payload = nalUnit.subdata(in: pos..<(pos + payloadSize))
    
    // ⑤ JSON 파싱
    return try? JSONDecoder().decode(AgoraSEI.self, from: payload)
}

struct AgoraSEI: Codable {
    let canvas: Canvas
    let regions: [Region]
    let ver: Int
    let ts: Int64
    let app_data: String?
}

struct Canvas: Codable {
    let w: Int
    let h: Int
    let bgnd: String
}

struct Region: Codable {
    let uid: Int
    let x: Int
    let y: Int
    let w: Int
    let h: Int
    let alpha: Float
    let zorder: Int
    let volume: Int
}

주의사항 — emulation_prevention_byte

H.264 NAL 페이로드에서 00 00 00이 우연히 등장하면 인코더가 00 00 03 00으로 escape합니다 (start code 충돌 방지). 파싱 시 0x03을 제거해야 원본 페이로드가 됩니다.

def strip_emulation_prevention(buf):
    out = bytearray()
    i = 0
    while i < len(buf):
        if i + 2 < len(buf) and buf[i] == 0x00 and buf[i+1] == 0x00 and buf[i+2] == 0x03:
            out.append(0x00)
            out.append(0x00)
            i += 3
        else:
            out.append(buf[i])
            i += 1
    return bytes(out)

긴 SEI(JSON 길이 100바이트 이상)에서는 이 escape가 거의 항상 등장합니다. 파서가 빠뜨리면 JSON 파싱이 실패함.


8. 활용 패턴

8-1. 클릭 가능 영역 매핑 (Agora의 표준 사용 케이스)

시청자 화면(합성된 1920x1080):
┌─────────────────────────────────────┐
│  [호스트 A: uid=1001]               │
│  (0,0) ~ (960,540)                  │
│ ┌──────────┐                        │
│ │ A 화면   │ [호스트 B: uid=1002]   │
│ └──────────┘ (960,0) ~ (1920,540)   │
│                                     │
│  [호스트 C: uid=1003]               │
│  (0,540) ~ (1920,1080)              │
└─────────────────────────────────────┘

시청자가 좌측 상단을 탭 → SEI의 regions 배열 탐색 →
matching region.uid = 1001 → "호스트 A 클릭" 이벤트 발화

8-2. app_data 필드 활용 — 자유 메타데이터

app_data는 고객이 자유롭게 쓸 수 있는 필드. 예:

활용app_data 예시
자막 큐{"caption":"안녕하세요","start":1683195420123}
광고 트리거{"ad":"midroll","duration":30}
채팅 동기화{"chat_seq":4521}
챕터 마커{"chapter":"intro"}

비디오 프레임과 정확히 동기되어야 하는 모든 메타데이터의 후보지.

8-3. 정확도 한계

  • SEI는 트랜스코딩된 합성 스트림(Media Push)에만 들어감 — 원본 RTC 스트림에는 없음
  • 시청자 디바이스의 SEI 파싱이 실패해도 비디오 재생은 정상 — 폴백 안전
  • payload는 매 프레임에 들어가는 게 아님 — 보통 키프레임이나 변경 시점에만 (Agora 구현 [NEEDS VERIFICATION])

9. SEI vs 다른 동기화 메커니즘 — 의사결정

메커니즘동기 정확도페이로드 크기도착 보장용도
SEI프레임 단위 동기작음(~수KB)비디오와 항상 같이레이아웃, 자막, 광고 큐
별도 RTM/WebSocketms 단위 어긋남자유별도 보장채팅, 업링크 데이터
RTP timestamp 기반 외부 채널잘 맞으면 ms자유클럭 동기 필요A/V 외부 큐
트랜스코드 워터마크영구픽셀비디오 프레임 자체DRM, 트래킹

결론:

  • 비디오와 정확히 동기되어야 하는 메타데이터 → SEI
  • 자유 형식, 양방향, 빈번한 메시지 → RTM/WebSocket
  • 둘은 보완 관계, 대체재 아님

10. FAQ

Q1. SEI를 쓰면 시그널링 인프라 필요 없나?

❌ 아님. SEI에 들어가는 건 다운링크 레이아웃 정보. 업링크(채팅, 호스트 명령 등)는 별도 시그널링이 여전히 필요. app_data 필드만 시그널링과 일부 겹침 — 둘은 대체재가 아니라 보완재.

Q2. SEI가 디코딩에 영향 주나?

❌ 영향 없음. Annex D 정의상 디코더는 SEI를 무시해도 됨. 잘못된 SEI 페이로드가 와도 비디오 재생 정상.

Q3. SEI가 매 프레임마다 박히나?

[NEEDS VERIFICATION] Agora 구현에서 SEI는 보통 키프레임에 박히거나 레이아웃 변경 시점에 박힙니다. 매 프레임은 아님. 시청자 측 파서는 SEI 미수신 시 이전 값 유지.

Q4. RTC 원본 스트림에도 SEI가 있나?

❌ Agora SEI는 Media Push의 트랜스코딩된 합성 스트림에만 박힘. 원본 RTC P2P 스트림에는 없음. 합성 스트림을 RTMP로 받는 시청자만 활용 가능.

Q5. payload_type 100을 쓰면 다른 디코더와 충돌하나?

이론상 가능. 다른 인코더가 같은 type 100을 다른 의미로 사용 중이면 충돌. 표준 권장은 type 5 (user_data_unregistered) + UUID 네임스페이싱이지만, Agora는 type 100 직접 점유 — 실무에서 큰 이슈는 없으나 다른 SEI 파서가 type 100을 잘못 해석할 가능성은 있음.

Q6. H.265(HEVC)에서도 같은 형식인가?

H.265 SEI도 같은 Annex D 모델 + 0xFF 누적 길이 인코딩 사용. NAL header 형식은 다름(2바이트). payload_type/payload_size 인코딩은 동일.


11. SA 체크리스트

□ 1. 송출 시나리오가 Agora Media Push인지 확인 (RTC 원본은 SEI 없음)
□ 2. 트랜스코딩 옵션이 활성화되어 있는지 (raw passthrough면 SEI 없음)
□ 3. 시청자 측 파서가 AVCC vs Annex B 포맷을 정확히 이해
□ 4. NAL header 0x06 → SEI 식별
□ 5. payload_type 100 (0x64) 확인 후 Agora SEI로 분기
□ 6. 0xFF 누적 길이 인코딩 정확히 처리
□ 7. emulation_prevention_byte (00 00 03) 제거
□ 8. JSON 파싱 후 canvas / regions / app_data 활용
□ 9. SEI 미수신 시 폴백 동작 정의 (이전 값 유지 or 기본 레이아웃)
□ 10. (옵션) RTM/WebSocket으로 업링크 채널 별도 구축

12. 한 줄 결론

SEI는 H.264/H.265 표준의 Annex D — 비디오와 함께 도착이 보장된 메타데이터 채널. Agora Media Push는 payload_type 100에 합성 레이아웃 JSON을 자동으로 박아주며, 시청자 파서는 NAL header → 0xFF 누적 길이 → JSON 파싱의 3단계로 풀어낸다. 시그널링과는 보완 관계, 대체재 아님.

한 장 요약

┌──────────────────────────────────────────────────────┐
│  Agora Media Push                                    │
│       │                                              │
│       ▼ (자동)                                       │
│  H.264 NAL 스트림에 SEI 삽입                         │
│       │                                              │
│       ▼                                              │
│  [06 64 bd ... JSON ...]                             │
│   │   │  │  └─ canvas, regions, app_data, ts, ver   │
│   │   │  └──── payload_size (0xFF 누적)             │
│   │   └─────── payload_type 100 (Agora 커스텀)      │
│   └─────────── NAL type 6 (SEI)                     │
│       │                                              │
│       ▼                                              │
│  RTMP/AVCC 또는 RTSP/Annex B로 송출                  │
│       │                                              │
│       ▼                                              │
│  시청자 클라이언트 파서                               │
│  ① NAL 디먹스 → ② SEI 식별 → ③ 길이 파싱            │
│  ④ emulation_prevention 제거 → ⑤ JSON 파싱          │
│       │                                              │
│       ▼                                              │
│  UI 활용 (클릭 영역, 자막 큐, 광고 트리거 등)        │
└──────────────────────────────────────────────────────┘

관련 글


참고 자료

© 2026 Frank Kim. All rights reserved.