HDR 메타데이터는 트랜스코딩에서 살아남지 못한다
개요
HDR 콘텐츠를 다루다 보면 화면에 보이는 색보다 먼저 마주치는 게 몇 개의 태그입니다. color_primaries(어떤 원색 삼각형을 쓰는지), color_trc(밝기 값을 어떤 곡선으로 부호화했는지, PQ인지 HLG인지), matrix_coefficients(RGB와 YUV를 어떻게 변환하는지) 세 가지가 컨테이너와 비트스트림 양쪽에 박혀 있고, 여기에 HDR10이라면 MaxCLL/MaxFALL 같은 정적 메타데이터가 더 붙습니다.
문제는 이 태그들이 "화면에 그려지는 실제 색"과는 별개로 존재한다는 점입니다. 태그는 재생기에게 "이 픽셀 값을 이런 방식으로 해석해라"라고 알려주는 지시문일 뿐이고, 트랜스코딩 파이프라인이 이 지시문을 그대로 옮겨 적는지, 도중에 지워버리는지, 아니면 다른 값으로 덮어써버리는지는 인코더나 플랫폼마다 다르게 동작할 수 있습니다.
이 글은 BT.709(SDR) 소스를 BT.2020/PQ로 재태깅한 뒤, 그 재태깅된 파일을 다시 트랜스코딩했을 때 색공간 메타데이터가 어떻게 되는지 로컬 FFmpeg와 Tencent Cloud MPS 양쪽에서 실측합니다.
먼저 밝혀둘 게 있습니다. 이 테스트에 쓴 소스는 실제로 HDR로 촬영되거나 그레이딩된 영상이 아닙니다. 뒤에서 자세히 설명하겠지만, 진짜 HDR10 마스터가 없는 상태에서 "화질이 HDR처럼 보이는가"가 아니라 "메타데이터가 파이프라인을 통과하며 유지되는가"만 순수하게 떼어서 검증하는 실험입니다.
실무 시나리오
"HDR로 올렸는데 재생해보면 색이 이상하다"는 문의를 받았다고 가정하겠습니다. 소스는 BT.2020/PQ로 정확히 태깅된 HDR10 마스터였고, 트랜스코딩 요청도 10bit HEVC로 걸었는데, 결과물을 특정 플레이어에서 재생하면 색이 탁해 보이거나 과포화돼 보인다는 리포트입니다.
원인을 좁혀나가다 보면 크게 두 갈래로 나뉩니다. 하나는 인코더가 픽셀 값 자체는 그대로 두고 넘겼는데 컨테이너에 박히는 태그만 잘못 바뀐 경우입니다. 예를 들어 원본은 BT.2020/PQ인데 출력물에는 BT.709 태그가 찍히면, 실제 픽셀 데이터는 PQ 곡선으로 부호화된 값 그대로인데 재생기는 이걸 SDR/감마 곡선으로 해석해버립니다. 색이 탁하거나 뿌옇게 보이는 전형적인 증상이 여기서 나옵니다.
다른 하나는 트랜스코딩 단계에서 비트 심도(10bit → 8bit)가 의도치 않게 바뀌면서 밴딩이나 계조 손실이 생기는 경우인데, 이 경우 태그 자체는 안 바뀌었을 수도 있어서 더 헷갈립니다.
이런 문의에 답하려면 결국 트랜스코딩 파이프라인의 각 단계에서 태그가 요청한 대로 나오는지, 도중에 유지되는지, 아니면 조용히 리셋되는지를 ffprobe로 직접 찍어봐야 합니다. 이 글은 그 확인 절차를 그대로 재현합니다.
테스트 환경과 소스
- FFmpeg 8.0(Homebrew 빌드, libx264/libx265 포함, 둘 다 10bit 인코딩 지원), macOS, Apple Silicon
- 소스: 1920x1080, 24fps, 오디오 트랙 없음, 5.04초(121프레임)
소스의 색공간 메타데이터를 먼저 확인했습니다.
$ ffprobe -v error -show_entries stream=codec_name,pix_fmt,color_space,color_transfer,color_primaries,color_range,width,height \
-of default=noprint_wrappers=0 src.mp4
codec_name=h264
pix_fmt=yuv420p
color_range=tv
color_space=bt709
color_transfer=bt709
color_primaries=bt709
width=1920
height=1080정직하게 밝히자면, 이 소스는 BT.709(SDR) 8bit 영상입니다. 진짜 HDR10으로 그레이딩된 원본이 수중에 없어서, 이 소스의 컨테이너에 박힌 색공간 태그만 BT.2020/PQ로 바꿔 다시 태우는 방식으로 실험을 설계했습니다.
이렇게 만든 파일은 화면에 실제로 HDR 계조나 확장된 색역이 담기는 게 아니라 "BT.2020 원색과 PQ 곡선으로 부호화됐다"는 지시문만 붙어 있는 상태입니다. 이 글에서 확인하려는 건 정확히 그 지시문이 트랜스코딩을 거치며 어떻게 되는지이지, 재태깅으로 실제 HDR 화질을 만들어냈다는 뜻이 아닙니다. 이 전제를 깔고 아래 결과를 읽어주시기 바랍니다.
1. 재태깅의 함정: -color_primaries/-color_trc 미반영
BT.2020/PQ로 재태깅하려고 가장 먼저 시도한 커맨드는 FFmpeg 문서에 나오는 일반적인 방식이었습니다.
$ ffmpeg -y -i src.mp4 -an -c:v libx265 -preset ultrafast -frames:v 1 -pix_fmt yuv420p10le \
-color_primaries bt2020 -color_trc smpte2084 -colorspace bt2020nc t_pt.mp4
$ ffprobe -v error -show_entries stream=color_space,color_transfer,color_primaries \
-of default=noprint_wrappers=0 t_pt.mp4
color_space=bt2020nc
color_transfer=bt709
color_primaries=bt709-colorspace로 지정한 matrix coefficients(bt2020nc)는 정확히 반영됐는데, -color_primaries와 -color_trc는 무시되고 소스의 bt709 값이 그대로 남았습니다. 세 옵션을 개별로도 시도해보고 순서를 바꿔도 봤지만 결과는 같았습니다.
libx264로 인코더를 바꿔도 동일했습니다. 이 값들만 따로 놓고 보면 이 조합에서는 -color_primaries/-color_trc라는 전역 출력 옵션이 인코더 컨텍스트에 실제로 반영되지 않는다는 뜻입니다.
우회 방법은 인코더별 파라미터 문자열을 직접 쓰는 것이었습니다.
$ ffmpeg -y -i src.mp4 -an -c:v libx265 -preset ultrafast -frames:v 1 -pix_fmt yuv420p10le \
-x265-params "colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc" t_x265params.mp4
$ ffprobe -v error -show_entries stream=color_space,color_transfer,color_primaries \
-of default=noprint_wrappers=0 t_x265params.mp4
color_space=bt2020nc
color_transfer=smpte2084
color_primaries=bt2020-x265-params(libx264라면 -x264-params)로 colorprim/transfer/colormatrix를 직접 넘기니 세 값 모두 정확히 반영됐습니다. 이후 이 글의 모든 재태깅 작업은 이 방식으로 진행했습니다.
이 결과가 시사하는 바는 명확합니다. 트랜스코딩 파이프라인 어딘가에서 "분명히 -color_primaries bt2020을 줬는데 출력물이 bt709로 나온다"는 문제가 생긴다면, 인코더가 그 값을 실제로 못 받았을 가능성부터 ffprobe로 확인해야 한다는 것입니다. 커맨드에 옵션을 넣었다는 사실과 인코더가 그 옵션을 실제로 적용했다는 사실은 다른 문제입니다.
2. BT.2020/PQ 재태깅 10bit 마스터
-x264-params로 소스를 재태깅해서 이후 실험에 쓸 기준 파일을 만들었습니다.
$ ffmpeg -y -i src.mp4 -an -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p10le \
-x264-params "colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc" \
tagged_h264_10bit.mp4$ ffprobe -v error -show_entries stream=codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,color_range,bit_rate \
-of default=noprint_wrappers=0 tagged_h264_10bit.mp4
codec_name=h264
profile=High 10
pix_fmt=yuv420p10le
color_range=tv
color_space=bt2020nc
color_transfer=smpte2084
color_primaries=bt2020
bit_rate=6139855profile=High 10, pix_fmt=yuv420p10le에 원하는 세 태그(bt2020/smpte2084/bt2020nc)까지 정확히 찍혔습니다. 이 파일(tagged_h264_10bit.mp4)이 이후 재트랜스코딩 실험과 MPS 업로드에 쓴 기준 소스입니다.
다시 한번 강조하면, 이 파일의 실제 픽셀 데이터는 여전히 SDR 소스에서 나온 값이고 달라진 건 컨테이너 태그와 비트 심도뿐입니다.
3. 재트랜스코딩 후 태그 생존 여부
이제 이 파일을 아무 색공간 옵션도 주지 않고 다시 트랜스코딩해서, FFmpeg가 입력 스트림의 태그를 기본으로 출력까지 옮겨 적는지 확인했습니다.
3-1. 코덱 변경(H.264→H.265) 시 태그 유지
$ ffmpeg -y -i tagged_h264_10bit.mp4 -an -c:v libx265 -preset medium -crf 21 -pix_fmt yuv420p10le \
retranscode_hevc10_noflags.mp4
$ ffprobe -v error -show_entries stream=codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries \
-of default=noprint_wrappers=0 retranscode_hevc10_noflags.mp4
codec_name=hevc
profile=Main 10
pix_fmt=yuv420p10le
color_space=bt2020nc
color_transfer=smpte2084
color_primaries=bt2020색공간 옵션을 하나도 지정하지 않았는데도 bt2020/smpte2084/bt2020nc가 그대로 나왔습니다. FFmpeg는 디코딩한 프레임에 실려 있는 색공간 정보를 인코더 쪽 옵션이 명시적으로 덮어쓰지 않는 한 그대로 들고 가서 출력 스트림에 다시 씁니다. H.264에서 H.265로 코덱 자체가 바뀌었는데도 이 전달 과정은 문제없이 이어졌습니다.
3-2. 8bit 다운컨버전의 태그 잔존 위험
이번엔 실무에서 흔히 벌어질 수 있는 실수 상황을 재현했습니다. 다운스트림 프리셋이 8bit로 고정돼 있어서 -pix_fmt yuv420p만 지정하고 색공간 옵션은 역시 아무것도 주지 않은 경우입니다.
$ ffmpeg -y -i tagged_h264_10bit.mp4 -an -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p \
retranscode_8bit_noflags.mp4
$ ffprobe -v error -show_entries stream=codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries \
-of default=noprint_wrappers=0 retranscode_8bit_noflags.mp4
codec_name=h264
profile=High
pix_fmt=yuv420p
color_space=bt2020nc
color_transfer=smpte2084
color_primaries=bt2020비트 심도는 10bit에서 8bit로 떨어졌는데 bt2020/smpte2084/bt2020nc 태그는 그대로 살아남았습니다. 처음 봤을 때는 "태그가 잘 유지되네"로 읽힐 수 있지만, 실제로는 반대로 읽어야 하는 결과입니다.
PQ(smpte2084) 곡선은 애초에 절대 밝기를 최대 10,000nit까지 촘촘하게 표현하기 위해 설계된 전달 함수이고, 이걸 제대로 담으려면 최소 10bit 정밀도가 필요하다고 알려져 있습니다. 그런데 이 결과물은 8bit 버퍼에 PQ 태그를 얹은 상태입니다.
태그만 보고 파이프라인을 신뢰하는 재생기나 후속 처리기가 있다면, "이 스트림은 PQ로 부호화됐다"고 믿고 EOTF를 적용했다가 8bit 정밀도로 뭉개진 계조에서 더 심한 밴딩이나 색 오류를 만들어낼 수 있습니다. 비트 심도가 바뀌는 트랜스코딩 단계에서는 색공간 태그가 "그대로 유지됐는지"보다 "지금 비트 심도에서 여전히 유효한 태그인지"를 함께 확인해야 한다는 뜻입니다.
3-3. 명시적 덮어쓰기 대조군
FFmpeg가 태그를 자동으로 리셋하지 않는다는 걸 반대 방향에서도 확인했습니다. 이번엔 색공간 옵션을 명시적으로 bt709로 지정해서 재트랜스코딩했습니다.
$ ffmpeg -y -i tagged_h264_10bit.mp4 -an -c:v libx265 -preset medium -crf 21 -pix_fmt yuv420p10le \
-x265-params "colorprim=bt709:transfer=bt709:colormatrix=bt709" \
retranscode_forced_bt709.mp4
$ ffprobe -v error -show_entries stream=codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries \
-of default=noprint_wrappers=0 retranscode_forced_bt709.mp4
codec_name=hevc
profile=Main 10
pix_fmt=yuv420p10le
color_space=bt709
color_transfer=bt709
color_primaries=bt709이번엔 예상대로 bt709/bt709/bt709로 정확히 바뀌었습니다.
3-1, 3-2, 3-3을 종합하면 결론은 이렇습니다. 로컬 FFmpeg 파이프라인에서 색공간 태그는 코덱이 바뀌든 비트 심도가 바뀌든 사용자가 명시적으로 새 값을 주지 않는 한 입력에서 출력으로 그대로 전달됩니다. 태그가 원치 않게 바뀌는 사고가 난다면, 그건 파이프라인이 "저절로 리셋"해서가 아니라 어딘가에 있는 인코딩 프리셋이나 스크립트가 색공간 값을 암묵적으로 강제 지정하고 있을 가능성이 훨씬 큽니다.
4. Tencent Cloud MPS 트랜스코딩 결과
로컬에서 확인한 "기본값은 유지, 명시적으로 지정해야 변경"이라는 동작이 클라우드 트랜스코딩 서비스에서도 같은지 확인하려고, 앞서 만든 tagged_h264_10bit.mp4를 COS에 올리고 MPS의 ProcessMedia API로 트랜스코딩했습니다.
이 버킷은 다른 클라이언트 프로젝트 파일도 들어 있는 운영 버킷이라, 이번 테스트에서 만든 오브젝트는 전부 hdr-metadata-test/ 접두사 아래에만 두고 검증이 끝난 뒤 삭제했습니다.
MPS의 VideoTemplateInfo에는 출력 비트 심도를 직접 지정하는 BitDepth(8 또는 10, 기본값 8) 필드가 있지만, ColorPrimaries나 ColorTransfer 같은 색공간 태그를 출력에서 지정하는 입력 필드는 없습니다. 이 필드들은 MediaVideoStreamItem(미디어 메타정보를 읽어서 되돌려주는 응답 쪽 모델)에만 존재합니다.
즉 MPS의 RawParameter 경로에서는 "이 태그로 찍어달라"고 요청할 방법이 없고, 인코더가 입력을 어떻게 처리하느냐에 결과가 달려 있습니다.
4-1. MPS의 입력 파일 자체 판정: HdrType: hdr10
트랜스코딩 태스크를 걸면 DescribeTaskDetail 응답의 MetaData 필드에 MPS가 입력 파일을 분석한 결과가 함께 실려 옵니다.
"MetaData": {
"VideoStreamSet": [
{
"Codec": "h264",
"Width": 1920, "Height": 1080,
"ColorPrimaries": "", "ColorSpace": "", "ColorTransfer": "",
"HdrType": "hdr10"
}
]
}ColorPrimaries/ColorSpace/ColorTransfer 개별 값은 빈 문자열로 나왔지만 HdrType은 정확히 hdr10으로 잡혔습니다. MPS가 개별 색공간 필드를 그대로 되돌려주진 않아도, BT.2020 원색과 PQ 전달함수 조합을 내부적으로 인식해서 HDR10 콘텐츠로 분류하고 있다는 뜻입니다.
4-2. 트랜스코딩 후 태그 초기화
두 가지 트랜스코딩을 걸었습니다. 하나는 10bit를 유지한 채 코덱만 H.265로 바꾸는 경우(BitDepth: 10), 다른 하나는 BitDepth를 지정하지 않아 기본값인 8bit로 떨어지는 H.264 트랜스코딩입니다.
task = {
"OutputStorage": output_storage,
"OutputObjectPath": "hdr-metadata-test/out_A_h265_10bit/{Definition}.{format}",
"Definition": 0,
"RawParameter": {
"Container": "mp4",
"RemoveAudio": 1,
"VideoTemplate": {
"Codec": "h265",
"Fps": 24,
"Bitrate": 6000,
"ResolutionAdaptive": "close",
"Width": 1920,
"Height": 1080,
"BitDepth": 10,
},
},
}두 태스크 모두 SUCCESS로 끝났고, DescribeTaskDetail 응답의 Output.VideoStreamSet은 두 경우 모두 다음과 같았습니다.
"ColorPrimaries": "", "ColorSpace": "", "ColorTransfer": "", "HdrType": ""MPS가 스스로 보고하는 메타데이터부터 이미 입력의 hdr10 판정이 출력에서는 빈 값으로 바뀌어 있었습니다.
Output.Path가 가리키는 실제 경로(hdr-metadata-test/hdr-metadata-test/out_A_h265_10bit/0.mp4처럼 OutputObjectPath에 지정한 접두사 앞에 같은 접두사가 한 번 더 붙어 나왔습니다)로 파일을 내려받아 ffprobe로 직접 확인해서 이게 API 응답만의 문제가 아니라 실제 파일 태그 문제라는 것도 검증했습니다.
| 태스크 | 요청 Codec/BitDepth | 실제 pix_fmt | color_primaries | color_transfer | color_space |
|---|---|---|---|---|---|
| 입력(재태깅 원본) | h264, 10bit | yuv420p10le | bt2020 | smpte2084 | bt2020nc |
| A: H.265, BitDepth 10 | h265, 10bit | yuv420p10le | unknown | unknown | unknown |
| B: H.264, BitDepth 기본(8) | h264, 8bit | yuv420p | unknown | unknown | unknown |
A는 요청한 대로 profile=Main 10, pix_fmt=yuv420p10le로 비트 심도는 정확히 유지됐지만 색공간 세 태그는 전부 unknown(미지정)으로 리셋됐습니다. B는 BitDepth를 지정하지 않아 기본값대로 8bit(pix_fmt=yuv420p)로 떨어졌고, 색공간 태그 역시 unknown이었습니다.
로컬 FFmpeg에서는 옵션을 안 주면 입력 태그가 그대로 옮겨졌던 것과 반대로, MPS의 기본 트랜스코딩 경로는 비트 심도 유지 여부와 무관하게 색공간 태그를 매번 초기화하는 것으로 보입니다.
이건 현재 RawParameter 기반의 기본 트랜스코딩 기능으로는 지원하지 않는 옵션입니다. VideoTemplateInfo에 출력 색공간 태그를 지정하는 필드 자체가 없다는 걸 앞서 확인했으니, 트랜스코딩 후에도 원본의 BT.2020/PQ 태그를 그대로 유지해야 하는 요구사항이 있다면 이 기본 경로만으로는 해결되지 않습니다.
HEVC 10bit HDR10 MOV나 ProRes+MOV 같은 커스텀 규격도 지원 범위에 들어가 있는 걸 감안하면, 이런 태그 보존이 필요한 워크플로우는 담당 계정팀이나 기술지원팀에 문의해서 커스텀 트랜스코딩 파라미터나 별도 처리 경로가 있는지 확인하는 걸 권합니다.
테스트에 쓴 COS 오브젝트(hdr-metadata-test/ 아래 소스 파일과 두 트랜스코딩 결과물)는 검증이 끝난 뒤 전부 삭제했습니다.
정리
- 이 글의 모든 실험은 실제 HDR 그레이딩 소스가 아니라 BT.709 SDR 8bit 소스의 컨테이너 태그만 BT.2020/PQ로 재태깅한 결과입니다. 화질이 HDR이 된 게 아니라 "색공간 지시문이 파이프라인을 통과하며 유지되는지"만 떼어서 검증했다는 전제를 다시 한번 밝힙니다.
- 이번 FFmpeg 8.0(Homebrew 빌드) 환경에서는
-color_primaries/-color_trc라는 전역 출력 옵션이 libx264/libx265 양쪽에서 실제로 반영되지 않았고,-colorspace(matrix coefficients)만 반영됐습니다. 세 값을 모두 확실히 적용하려면-x264-params/-x265-params로colorprim/transfer/colormatrix를 직접 지정해야 했습니다. - 로컬 FFmpeg에서 재태깅된 10bit 파일을 색공간 옵션 없이 재트랜스코딩하면, 코덱을 H.264에서 H.265로 바꿔도 BT.2020/PQ 태그는 그대로 유지됐습니다. 비트 심도를 10bit에서 8bit로 낮춰도 태그 자체는 살아남았는데, 이건 오히려 위험한 상태입니다. PQ 곡선이 요구하는 정밀도를 더 이상 만족하지 못하는 8bit 버퍼에 PQ 태그가 그대로 얹혀 있기 때문입니다. 태그를 명시적으로 bt709로 덮어썼을 때만 값이 바뀌었습니다.
- Tencent Cloud MPS는 입력 파일을 분석해서
MetaData.VideoStreamSet.HdrType을hdr10으로 정확히 판정했지만,RawParameter기반 기본 트랜스코딩 결과물에서는BitDepth: 10으로 비트 심도를 유지한 경우에도ColorPrimaries/ColorTransfer/ColorSpace/HdrType이 모두 초기화됐습니다. 다운로드한 실제 파일을 ffprobe로 재확인해도 색공간 태그는unknown이었고, API 응답과 실제 파일 상태가 일치했습니다. VideoTemplateInfo에는 출력 색공간 태그를 직접 지정하는 필드가 없습니다. 원본의 BT.2020/PQ 태그를 트랜스코딩 후에도 유지해야 하는 요구사항이 있다면 기본RawParameter경로만으로는 부족하고, 커스텀 파라미터 지원 여부를 계정팀이나 기술지원팀에 확인하는 절차가 필요합니다.- "HDR로 올렸는데 색이 이상하다"는 문의를 받으면, 원본과 결과물 양쪽에서
color_primaries/color_transfer/color_space를 ffprobe로 직접 찍어 비교하는 게 첫 단계여야 합니다. 로컬 인코더와 클라우드 트랜스코딩 서비스가 태그를 다루는 기본 동작 자체가 다를 수 있다는 걸 이번 테스트로 확인했기 때문에, 어느 한쪽에서 통했던 가정을 다른 쪽에 그대로 적용하면 안 됩니다.