Individual recording 결과를 참가자별 VOD나 하나의 composite 화면으로 후처리하는 FFmpeg 파이프라인을 설명합니다. M3U8과 TS를 점검하고 기록된 track의 시작 시각을 맞춘 뒤, audio-only·video-only 입력과 참가자 수를 검증해 레이아웃을 구성합니다. 입력값 제한, 경로 검증, 실패 복구를 포함한 자동화 기준도 함께 다룹니다.
Individual 녹화 결과를 여러 참가자가 함께 보이는 VOD로 만들려면 별도 합성 단계가 필요합니다.
Agora Cloud Recording의 Individual standard mode는 UID별 audio/video와 함께 직접 재생 가능한 merged M3U8을 생성합니다. 다만 여러 UID를 하나의 화면에 합친 composite VOD는 만들지 않으므로, 그런 결과가 필요할 때 FFmpeg 후처리를 추가합니다.
이 글은 UID별 HLS 결과를 MP4로 remux하고, timestamp를 맞춘 뒤 grid로 합성하는 절차와 적용 조건을 다룹니다.
전체 파이프라인 아키텍처
┌─ Cloud Storage (S3/GCS) ────────────────────────────────┐
│ Individual 녹화 파일들 │
│ ├── sid_ch__uid_s_1001__uid_e_audio.m3u8 │
│ ├── sid_ch__uid_s_1001__uid_e_video.m3u8 │
│ ├── sid_ch__uid_s_2002__uid_e_audio.m3u8 │
│ ├── sid_ch__uid_s_2002__uid_e_video.m3u8 │
│ └── ... (TS 세그먼트 수백 개) │
└──────────────────────┬───────────────────────────────────┘
│ ① 다운로드
▼
┌─ Post-Processing Server ────────────────────────────────┐
│ │
│ ② M3U8 → MP4 변환 (ffmpeg -c copy) │
│ ③ 오디오/비디오 합치기 (-map 0:a -map 1:v) │
│ ④ 타임스탬프 동기화 (-itsoffset) │
│ ⑤ 멀티유저 그리드 합성 (filter_complex) │
│ ⑥ 트랜스코딩 + 품질 제어 (CRF, 2-pass) │
│ │
└──────────────────────┬───────────────────────────────────┘
│ ⑦ 업로드
▼
┌─ CDN / VOD Service ─────────────────────────────────────┐
│ 최종 MP4 → 사용자에게 다시보기 서비스 │
└──────────────────────────────────────────────────────────┘
UID별 ② 작업은 병렬화할 수 있지만 ③은 ② 결과에 의존합니다. ④는 참가자별 timeline이 다를 때 필요하고, ⑤의 filter graph는 입력 수와 track 존재 여부에 맞게 구성해야 합니다.
Step 1: M3U8 → MP4 변환
가장 먼저 할 일은 HLS 포맷을 MP4로 바꾸는 것입니다.
# 가장 기본: M3U8 목록을 읽어서 하나의 MP4로 합침
ffmpeg -i sid_ch__uid_s_1001__uid_e_video.m3u8 -c copy user_1001_video.mp4
오디오 MP4 ──→ ┌──────────┐
│ │ -map 0:a (첫 번째 입력의 오디오)
│ FFmpeg │ -map 1:v (두 번째 입력의 비디오)
│ Muxer │──→ user_1001_combined.mp4
│ │
비디오 MP4 ──→ └──────────┘
Individual recording은 UID별 audio/video slice를 생성합니다. 오디오는 15초에 분할되고, 비디오는 slice가 15초에 도달한 뒤 I-frame이 나타나거나 codec·해상도가 변경되거나 stream이 중단되는 등의 조건에서 분할됩니다. streamMode=standard는 UID별 audio/video를 함께 재생할 수 있는 merged M3U8도 생성합니다.
Step 3: 타임스탬프 동기화
UID별 track 시작 시각이 다르면 동일한 0초 기준으로 합성할 수 없습니다.
User A: ────────────────────────────── (00:00부터 입장)
User B: ──────────────────────── (00:05부터 입장)
User C: ─────────────────── (00:10부터 입장)
그냥 합치면? → 모두 00:00부터 시작해서 5초, 10초 어긋남
Agora TS 파일명의 17자리 UTC 시각은 기록된 track slice의 시작 시각입니다. 참가자의 실제 channel join 시각과 항상 같다고 가정하지 말고, 합성 timeline을 맞추는 기준 중 하나로 사용합니다.
# 파일명에서 UTC 추출# sid_ch__uid_s_1001__uid_e_video_20190920125142485.ts# ^^^^^^^^^^^^^^^^^# UTC 시작 시간 (YYYYMMDDHHmmssSSS, UTC+0, 밀리초 포함, 17자리)# → 2019-09-20 12:51:42.485 UTC
Python으로 각 유저의 입장 시점을 계산하고, 가장 빠른 시점 대비 offset을 구합니다.
import re
from datetime import datetime, timedelta
defget_utc_from_m3u8(m3u8_path):
"""M3U8에서 첫 번째 TS 파일의 UTC 시작 시간 추출 (datetime 반환)"""withopen(m3u8_path, encoding='utf-8') as f:
for line in f:
if line.strip().endswith('.ts'):
# 파일명 끝 17자리: YYYYMMDDHHmmssSSS (밀리초 포함, UTC+0)match = re.search(r'_(\d{17})\.ts', line)
ifmatch:
s = match.group(1)
# 앞 14자리(초 단위)를 datetime으로 파싱 + 밀리초 보정
dt = datetime.strptime(s[:14], '%Y%m%d%H%M%S')
return dt + timedelta(milliseconds=int(s[14:]))
returnNone# 각 유저의 시작 시점 확인
users = {
'user_1001': get_utc_from_m3u8('sid_ch__uid_s_1001__uid_e_video.m3u8'),
'user_2002': get_utc_from_m3u8('sid_ch__uid_s_2002__uid_e_video.m3u8'),
'user_3003': get_utc_from_m3u8('sid_ch__uid_s_3003__uid_e_video.m3u8'),
}
# 가장 빠른 입장 시점 기준으로 offset 계산
earliest = min(users.values())
for uid, utc in users.items():
offset = (utc - earliest).total_seconds()
print(f"{uid}: offset = {offset}초")
# 출력 예:# user_1001: offset = 0초# user_2002: offset = 5초# user_3003: offset = 10초
계산된 offset을 -itsoffset 옵션으로 적용합니다.
# offset 적용하여 동기화
ffmpeg \
-i user_1001_combined.mp4 \
-itsoffset 5 -i user_2002_combined.mp4 \
-itsoffset 10 -i user_3003_combined.mp4 \
-filter_complex "..." \
synced_output.mp4
# -itsoffset 5: 두 번째 입력을 5초 뒤로 밀어서 시간 축 정렬
-itsoffset은 해당 입력 바로 앞에 위치해야 합니다. 그 뒤에 오는 -i에만 적용됩니다.
빈 구간 처리
User B의 기록된 track이 5초 뒤 시작하면 output의 앞 5초를 검은 화면과 무음으로 채우는 정책을 선택할 수 있습니다. -itsoffset은 timestamp만 이동하며 filler 자체를 생성하지는 않습니다.
# 무음 오디오 생성 (5초)
ffmpeg -f lavfi -i anullsrc=r=48000:cl=stereo -t 5 silence_5s.m4a
# 검은 화면 비디오 생성 (5초)
ffmpeg -f lavfi -i color=c=black:s=640x360:r=30 -t 5 -c:v libx264 black_5s.mp4
-f lavfi는 FFmpeg의 가상 소스 필터를 활성화합니다. anullsrc는 무음 오디오, color=c=black은 단색 비디오를 생성합니다. 실제 파일이 없어도 원하는 길이의 빈 미디어를 만들 수 있습니다.
이 두 명령은 filler 파일만 만듭니다. 원본 앞에 붙이려면 concat demuxer나 filter_complex로 원본과 명시적으로 연결해야 합니다. 단순히 파일을 생성하는 것만으로는 합성 결과에 반영되지 않습니다.
Step 4: 멀티유저 그리드 합성
동기화된 유저별 파일을 하나의 화면으로 합칩니다. FFmpeg의 filter_complex가 이 작업의 핵심입니다.
[입력]필터=파라미터[출력]
[0:v] → 입력 0번의 비디오 스트림 참조
scale=640:360 → 640x360으로 리사이즈
[v0] → 결과에 "v0"이라는 레이블 부여
[v0][v1]hstack[top] → v0과 v1을 수평으로 붙여서 "top"
[top][bottom]vstack[outv] → top과 bottom을 수직으로 붙여서 최종 비디오
[0:a][1:a][2:a][3:a]amix=inputs=4[outa] → 4개 오디오를 믹싱
후처리 VOD에는 CRF 기반 VBR을 사용할 수 있습니다. delivery 규격이 평균·최대 bitrate를 요구하면 해당 규격에 맞춘 rate control을 선택합니다.
해상도별 권장 설정
해상도만으로 적정 CRF나 bitrate를 고정할 수는 없습니다. 움직임, 노이즈, 프레임률, encoder preset에 따라 결과가 크게 달라집니다. CRF 23을 기준점으로 짧은 대표 구간을 인코딩한 뒤 VMAF 같은 객관 지표와 실제 재생 품질, 파일 크기를 함께 비교해 값을 정합니다.
1st pass: 영상 전체 분석
→ 장면별 복잡도 기록
→ ffmpeg2pass-0.log 생성
2nd pass: log 읽어서
→ 복잡한 장면에 비트레이트 집중
→ 단순 장면에서 비트레이트 절약
→ 동일 평균 비트레이트로 더 나은 품질
2-pass는 전체 입력을 한 번 더 처리하므로 1-pass보다 오래 걸립니다. 정확한 배수는 encoder 설정과 I/O에 따라 달라집니다.
Step 6: 자동화 파이프라인
아래 예시는 참가자 2명에게 모두 audio/video track이 있고, timeline 정렬을 이미 끝냈다는 전제의 최소 자동화 예시입니다. 참가자 수와 track 구성이 달라질 수 있는 서비스는 입력을 먼저 검사하고 filter graph를 동적으로 생성해야 합니다.
예시는 변환 실패, playlist 중복·누락, 빈 결과를 모두 작업 실패로 처리합니다. 로그를 버리고 일부 결과만 게시하면 참가자 누락을 정상 완료로 오인할 수 있기 때문입니다. audio-only나 video-only 참가자를 허용하려면 입력 검사 결과에 따라 무음 또는 검은 track을 만들고, 그 정책을 filter graph에 명시적으로 반영해야 합니다.
임시 경로는 검증되지 않은 session ID를 직접 붙이지 않고 mktemp로 만들며, trap으로 성공·실패 모두 정리합니다. 대용량 작업에서는 시작 전에 예상 용량과 free space를 검사하고 별도 작업 volume을 사용합니다.
병렬 처리
UID별 remux는 서로 독립적이므로 worker queue로 병렬화할 수 있습니다. 다만 storage bandwidth와 encoder CPU를 함께 측정해 동시 실행 수를 제한해야 합니다. 최종 합성은 모든 UID 결과가 검증된 뒤 한 작업으로 실행합니다.
비용 비교 시 확인할 항목
Agora의 Cloud Recording 가격표는 Individual과 Composite의 녹화 요금을 모드별로 다르게 책정하지 않습니다. 실제 총비용은 녹화 해상도·구독 분수, cloud storage, 후처리 compute, 전송량과 운영 비용을 함께 계산해야 합니다.
항목
Composite 녹화
Individual + 후처리
녹화 요금 기준
Agora 가격표의 해상도·분수 기준
동일한 가격표 기준
후처리 compute
결과를 그대로 쓰면 불필요
합성 VOD가 필요하면 추가
레이아웃
녹화 요청 시 결정
녹화 후 다시 구성 가능
게시 시점
저장 완료 후
후처리와 검증 완료 후
어느 쪽이 저렴한지는 세션 수만으로 결정할 수 없습니다. 대표 트래픽으로 양쪽의 월간 비용과 결과 요구사항을 비교해야 합니다.
핵심 요약
-c copy는 codec과 container가 호환될 때 사용: 재인코딩 손실은 없지만 timestamp나 codec 호환 문제가 있으면 실패하거나 재인코딩이 필요하다.
파일명의 UTC는 기록된 slice 시각: 실제 channel join 시각과 같다고 단정하지 말고 서비스 이벤트와 대조해 timeline을 맞춘다.
filter graph는 입력 계약에 맞춰 생성: 참가자 수와 audio/video track 존재 여부를 확인한 뒤 hstack, vstack, overlay, amix를 구성한다.
CRF 23은 측정 시작점: 대표 장면으로 품질과 크기를 확인해 조정한다.
2-pass는 목표 평균 bitrate가 정해졌을 때 검토: 1-pass보다 오래 걸리므로 게시 지연과 품질 이득을 함께 측정한다.
모드 선택은 요구사항과 총비용으로 판단: 녹화 요금만이 아니라 후처리, storage, 전송과 운영비를 포함한다.
시리즈를 마치며
6편에 걸쳐 Cloud Recording의 구조, 배포 방식, 녹화 모드, HLS 산출물과 FFmpeg 후처리를 살펴봤습니다. 실제 구현에서는 공식 문서의 현재 산출물 계약을 확인하고, 자신의 입력 조합으로 검증한 명령만 자동화해야 합니다.