ffmpeg로 만든 영상이 yuv420p여야 하는데 yuvj420p로 나와 테스트가 깨졌다면, 먼저 입력 프레임이 풀 레인지인지 의심하면 된다. 내 경우에는 mjpeg 풀 레인지 입력이 그대로 풀 레인지 표시를 달고 나왔다. 기록을 읽기로는 인코더 인자에 -pix_fmt yuv420p가 이미 들어 있었는데도 그랬다.

이 글은 영상 공모전 출품작을 모션그래픽으로 다시 만드는 저장소에서 있었던 일을 다룬다. HTML 캔버스 장면을 Playwright로 한 프레임씩 캡처하고 ffmpeg로 H.264/AAC 영상으로 묶는 렌더러였다. 렌더 명세 테스트 7개를 먼저 쓰고, 구현 뒤 첫 실행에서 T5가 yuvj420p를 받아 실패했고, 인자 두 개를 더해 7개 모두 통과시켰다.

범위를 먼저 잘라 둔다. 이 글은 출력 픽셀 형식과 색 범위 표시 문제만 다룬다. 장면 디자인, 음악 합성, 재생 환경별 화면 비교는 다루지 않으며, 마지막 절에 확인하지 못한 항목을 따로 적었다.

렌더러가 하는 일과 명세를 먼저 쓴 이유

장면은 HTML 캔버스로 그렸고 120 BPM 그리드에 맞춰 시간을 배치했다. 120 BPM이면 한 박자가 0.5초이므로 장면 길이를 0.5초의 배수로 잡을 수 있다. 각 장면은 시간 값을 받아 그 시각의 화면을 그리는 seek 함수를 가져서, 같은 시각을 요청하면 같은 화면이 나오도록 했다.

렌더는 아래 순서로 흘러간다.

  1. 스토리보드 표에서 장면 11개와 초 수를 읽는다. 초는 5, 5, 6, 5, 6, 5, 5, 5, 5, 5, 5이고 합계가 57.0초다.
  2. headless Chromium이 프레임을 하나씩 캡처한다. 해상도는 1080x1920, 초당 30프레임이다.
  3. ffmpeg가 프레임을 H.264 영상으로, 직접 합성한 음악을 AAC로 인코딩해 dist/final.mp4를 만든다.
  4. ffprobe와 전체 디코드로 결과 파일의 형식을 검사한다.

이 작업은 하네스를 통해 코딩 에이전트에게 나눠 맡겼다. 나는 목표와 진행 방식을 정하고 남은 기록을 확인하는 쪽이었고, 테스트와 구현은 서로 다른 에이전트가 썼다. 그래서 이 글에서 "썼다"는 표현은 에이전트가 쓴 파일을 기록에서 확인했다는 뜻이다.

테스트를 먼저 쓰는 순서는 계획 단계에서 정해 둔 것이다. 영상은 눈으로 보기 전에 기계가 확인할 수 있는 약속이 필요했다. 그 약속이 길이 30-180초, 1080x1920, H.264 yuv420p와 AAC, 깨끗한 전체 디코드, 오디오 피크 -1.0 dBFS 이하였다.

테스트 T1-T7과 의도된 RED 0 pass 7 fail

테스트 파일은 파일 하나이고 138줄이 추가됐다. 구현 모듈을 import하지 않고 파일을 읽거나 프로세스를 띄워 결과만 본다. 그래서 구현이 없는 시점에도 파일이 오류 없이 실행되고, 실패는 단언 줄에서만 난다.

렌더 명세 테스트 T1-T7: 확인 내용과 구현 전 실패 위치
테스트확인하는 것구현 전 실패한 단언
T1장면 id와 초, 화면 문자열 목록이 스토리보드 행과 정확히 같다scenes.json 존재 여부
T2AI 제작 고지 문구가 정확하고 첫 장면과 마지막 장면에 있다scenes.json 존재 여부
T330fps, 1080x1920, 총 길이 30-180초, 장면 초가 0.5의 배수다scenes.json 존재 여부
T4bun run render가 종료 코드 0으로 끝난다종료 코드가 0이 아님
T5ffprobe 기준 h264, yuv420p, 1080x1920, 길이 오차 0.25초 이내, aac 스트림dist/final.mp4 존재 여부
T6전체 디코드에서 stderr가 비어 있다dist/final.mp4 존재 여부
T7volumedetect의 max_volume이 -1.0 dB 이하다dist/final.mp4 존재 여부

구현 전 실행 결과는 0 pass, 7 fail, expect() 호출 7회였다. 테스트 파일 단독 실행과 전체 스위트 실행 모두 종료 코드 1이었다. T1-T3은 장면 파일을 읽는 함수 안의 단언에서, T4는 종료 코드 단언에서, T5-T7은 결과 영상 존재 단언에서 실패했다.

이 실패를 의도된 RED로 센 이유는 기록에 남아 있다. import 오류, 테스트 발견 오류, 설정 오류, 건너뛴 테스트가 하나도 없었다. 7개가 모두 아직 없는 산출물 때문에 단언 줄에서 실패했고, 환경 문제로 실패한 것이 아니었다.

테스트를 짤 때 정해 둔 세부 규칙 몇 가지도 기록에 남아 있다. T1은 처음 계획에서 스토리보드 문자열이 장면 파일 어딘가에 나타나는지만 확인하는 수준이었다. 계획 검증이 이를 지적했고, 각 장면의 문자열 목록이 스토리보드와 정확히 같아야 하며 더하거나 빠진 항목도 실패하도록 바꿨다. T4는 이전 실행이 남긴 결과 영상을 먼저 지우고 렌더하므로, 낡은 파일 때문에 통과하는 일이 없다. T7은 음량 검출 결과가 -inf이거나 일치하는 항목이 없는 경우도 처리한다.

테스트는 다른 모델의 코드 검토를 받았다. 다섯 항목 점수가 9, 9, 8, 8, 9로 모두 7 이상이었고 막는 항목은 없었다. 이 점수는 테스트가 계획의 명세와 맞는지에 대한 판단일 뿐이고, 뒤에 나오는 yuvj420p 문제와는 관계가 없다.

의도된 RED 상태에서 로컬 develop 브랜치에 테스트를 병합했고, 병합 뒤 전체 스위트는 이 7개만 실패했다. 구현을 병합한 뒤에는 7개가 모두 통과해 이 예외가 끝났다. RED를 안은 채 병합하는 방식 자체는 의도된 RED를 CI 게이트에 기록하는 글에서 따로 다뤘다.

첫 GREEN에서 T5 하나만 실패했다

구현은 장면, 렌더 스크립트, 음악 스크립트, 설정 파일을 합쳐 파일 11개, 824줄 추가였다. 구현 뒤 처음 스위트를 돌린 결과는 6 pass, 1 fail이었다. 실패한 것은 T5였고, 기대값 yuv420p 자리에 받은 값이 yuvj420p였다.

expect(video.pix_fmt).toBe("yuv420p");
// 받은 값: "yuvj420p"  (테스트 파일 116번째 줄, mjpeg 풀 레인지 입력)

눈여겨볼 점은 나머지 여섯 개가 통과했다는 것이다. T4는 렌더가 종료 코드 0으로 끝났다는 뜻이고, T6은 전체 디코드가 깨끗했다는 뜻이며, T7은 음량 상한을 지켰다는 뜻이다. 영상은 만들어졌고 디코드도 깨끗했다. 그런데도 약속한 픽셀 형식만 달랐다.

렌더가 성공하고 디코드가 깨끗해도 출력 형식은 맞는다고 보장되지 않는다. 형식 약속은 형식을 직접 읽는 단언으로 따로 적어 두어야 잡힌다.

이 결함은 코드 검토 전에 GREEN 실행에서 발견됐다. 기록은 이를 제품 결함으로 분류했고 구현 저자에게 돌려보내 고쳤다. 검토가 잡은 것이 아니라 먼저 써 둔 테스트가 잡았다.

수정 전 결과로 만든 미리보기 영상은 별도로 보관됐다. 다만 이 미리보기를 내가 직접 열어 색을 비교한 기록은 없다. 수정 전후 화면의 색 차이에 대해서는 말할 근거가 없다.

원인: 풀 레인지 입력이 yuvj420p로 나왔다

기록이 적은 원인은 한 줄이다. 입력이 풀 레인지여서 ffmpeg가 yuvj420p로 인코딩했다는 것이다. ffmpeg 필터 문서의 scale 항목은 이 범위를 이렇게 정의하며, 아래 표와 그림이 그 값과 이번 출력의 위치를 보여 준다.

ffmpeg 공식 문서가 설명하는 색 범위 값
값문서의 설명8비트 휘도 기준
jpeg, full, pc풀 레인지0-255
mpeg, limited, tv"MPEG" 레인지16-235
두 열을 나란히 비교한 그림이다. 왼쪽 풀 레인지 열에는 위에서부터 8비트 휘도 0-255인 jpeg·full·pc 값, 풀 레인지인 mjpeg 입력, 범위가 pc인 yuvj420p 출력이 놓여 있고, yuvj420p 출력이 강조되어 있다. 오른쪽 제한 레인지 열에는 16-235인 MPEG 레인지 mpeg·limited·tv 값과, 지정했지만 결과로 나오지 않은 -pix_fmt yuv420p가 놓여 있다.
-pix_fmt yuv420p를 지정해도 출력이 yuvj420p로 나온 원인은 입력의 풀 레인지에 있다.

같은 문서는 in_range와 out_range를 입력과 출력의 YCbCr 샘플 범위를 정하는 옵션으로 설명한다. 자동 감지된 값을 덮어쓸 수 있고, 지정하지 않으면 범위가 픽셀 형식에 따라 정해진다고 적혀 있다. yuvj420p라는 이름을 설명하는 문장은 그 절에서 찾지 못했다.

이름의 앞뒤를 맞춰 보면 j가 JPEG 범위를 뜻한다고 읽는 것이 자연스럽다. 이는 문서가 직접 말한 것이 아니라 값 이름이 jpeg와 full을 같은 범위로 묶는 점에서 나온 내 해석이다. Playwright 문서는 스크린샷 형식으로 png가 기본이고 jpeg도 고를 수 있다고 적는다.

그렇다면 mjpeg 입력이 JPEG 형식 프레임에서 왔을 가능성은 있다. 다만 렌더 스크립트의 캡처 형식 설정은 이번에 열어 보지 않았다. 그래서 "입력이 mjpeg 풀 레인지였다"는 기록의 문장은 사실로 쓰고, 그 입력이 JPEG 스크린샷에서 왔는지는 추정으로 남긴다.

같은 증상이 한 번의 합성 입력으로도 재현되는지 이 Mac에서 따로 확인했다. ffmpeg 9.0.2에서 합성 테스트 영상(testsrc2) 30프레임을 mjpeg로 만들고, 그것을 받아 H.264로 인코딩했다. 실제 렌더러의 프레임이 아니라 합성 입력이라는 점을 감안해야 한다.

$ ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,pix_fmt,color_range -of default=nw=1 in.mjpeg
codec_name=mjpeg
pix_fmt=yuvj420p
color_range=pc

# A: -pix_fmt yuv420p만 지정해 인코딩
pix_fmt=yuvj420p
color_range=pc

# B: scale 필터와 -color_range tv를 더해 인코딩
pix_fmt=yuv420p
color_range=tv

A에서 -pix_fmt yuv420p를 지정했는데도 결과가 yuvj420p, 범위 pc였다. 기록의 증상과 같은 모습이다. 기록은 수정 뒤에도 -pix_fmt yuv420p가 그대로라고 적고 있어서, 수정 전에도 이 인자가 있었다고 읽었다.

ffmpeg 문서는 -pix_fmt를 고를 수 없으면 경고를 내고 인코더가 지원하는 가장 가까운 형식을 고른다고 적는다. 이 재현에서 왜 yuvj420p가 선택됐는지, ffmpeg 내부 경로는 확인하지 못했다. 관찰한 것은 이 입력과 인자 조합에서 결과가 yuvj420p였다는 사실까지다.

수정: 레인지를 변환하고 표시까지 맞춘다

수정은 렌더 스크립트 한 파일의 ffmpeg 인자에만 들어갔다. 스크립트의 해시가 바뀌었고 다른 파일은 그대로였으며, 테스트는 건드리지 않았다. 더한 인자는 필터 하나와 옵션 하나다.

-vf scale=in_range=full:out_range=tv,format=yuv420p
-color_range tv
# 기존 -pix_fmt yuv420p 는 그대로 둔다
더한 인자와 각각의 역할
인자문서의 설명이 수정에서의 역할
scale=in_range=full입력 샘플 범위를 풀 레인지(0-255)로 지정입력이 풀 레인지임을 명시한다
out_range=tv출력 샘플 범위를 MPEG 레인지(16-235)로 지정값을 제한 레인지로 변환한다
format=yuv420p(필터 그래프의 형식 지정)필터 출력이 yuv420p임을 고정한다
-color_range tv입력으로 쓰면 디코더에 주는 힌트이고, 값은 tv/mpeg/limited와 pc/jpeg/full인코더 출력에 제한 레인지 표시를 단다

세 번째 행의 설명은 공식 문서에서 가져온 문장이 아니라 필터 그래프에서 형식을 지정하는 일반적인 쓰임을 적은 것이다. 이번에 읽은 문서 구간에서 format 필터를 직접 설명한 문장은 확인하지 않았다. 기록은 수정의 효과를 "풀 레인지 입력을 제한 레인지로 변환하고 출력 레인지도 tv로 표시해 pix_fmt이 yuv420p가 된다"고만 적는다.

여섯 단계가 차례로 이어지는 흐름도다. 0-255 범위의 풀 레인지 입력이 들어오면 scale 필터의 in_range=full이 입력을 풀 레인지로 명시하고, out_range=tv가 값을 16-235 제한 레인지로 변환한다. 이어 format=yuv420p가 필터 출력 형식을 yuv420p로 고정하고, -color_range tv 옵션이 인코더 출력에 제한 레인지 표시를 단다. 마지막에는 pix_fmt이 yuv420p이고 color_range가 tv인 출력이 나온다. 강조된 단계는 값을 실제로 제한 레인지로 바꾸는 out_range=tv 변환이다.
표시만 tv로 바꾸지 않고 값도 제한 레인지로 변환해야 값과 표시가 일치하는 yuv420p 출력이 된다는 것이 이 수정의 핵심이다.

위 그림은 필터와 옵션이 맡는 일을 순서대로 나눠 그린 것이다. 결과는 두 차례 실행으로 확인됐다. 수정 전 GREEN 1회차는 6 pass, 1 fail이었고 수정 뒤 2회차는 7 pass, 0 fail이었다. 구현을 병합한 후보에서 돌린 전체 스위트도 종료 코드 0이고 7 pass, 0 fail이었다.

위 재현의 B 조합도 같은 방향이었다. 합성 입력에 같은 필터와 옵션을 적용하자 pix_fmt=yuv420p, color_range=tv가 나왔다. 이번 재현은 실제 렌더러를 다시 돌린 것이 아니므로 기록의 GREEN을 대체하지 않고 방향만 보강한다.

왜 변환까지 했는지도 적어 둔다. 표시만 tv로 바꾸고 값은 풀 레인지로 두면, 값과 표시가 어긋난 영상이 될 수 있다. 그런 어긋남이 화면 대비를 바꿀 수 있다는 것은 일반적인 우려이고, 이번 기록에서 화면으로 확인한 것은 아니다.

확인하지 못한 것

기록과 재현이 닿지 않는 부분을 결론 전에 분리해 둔다.

  • 수정 전후 영상의 화면 색을 사람이 비교한 기록이 이 단계에는 없다. 시각 인수는 계획상 이후 단계였고 이 기록 범위 밖이다.
  • 렌더 스크립트와 구현 소스를 이번에 열어 보지 않았다. 캡처 형식(png나 jpeg)과 원래 ffmpeg 인자 전체는 확인하지 못했다.
  • -pix_fmt yuv420p가 있는데 왜 yuvj420p가 됐는지 ffmpeg 내부 동작은 확인하지 못했다. 관찰은 입력과 인자 조합별 결과까지다.
  • 재생 환경별(브라우저, 모바일 플레이어, 편집기) 호환 차이는 확인하지 않았다.
  • T5는 pix_fmt를 단언하지만, 기록상 color_range를 직접 단언한다는 내용은 없다.

다음에 쓸 점검

아래는 이번 일에서 나온 권장 절차이고, 이 순서로 이미 실행해 성공했다는 뜻이 아니다.

  1. 풀 레인지 입력(JPEG 계열 프레임 등)을 H.264로 묶을 때는 -pix_fmt만 믿지 말고 입력 범위를 필터로 명시한다.
  2. 결과 파일의 pix_fmt와 color_range를 둘 다 읽는다. ffprobe 문서에 따르면 -select_streams는 지정한 스트림만 고르고, -show_entries는 보여 줄 항목 목록을 정한다.
  3. 이 두 값을 테스트의 단언으로 만든다. 이번 일에서 렌더 성공, 깨끗한 디코드, 음량 상한은 형식이 어긋난 영상도 통과시켰다.
  4. 수정 후에는 테스트를 바꾸지 않고 같은 단언이 통과하는지 본다. 이번에는 테스트 파일이 그대로인 채 7개가 통과했다.
ffprobe -v error -select_streams v:0 \
  -show_entries stream=codec_name,pix_fmt,color_range \
  -of default=nw=1 dist/final.mp4

이 명령은 위 재현에서 입력 파일과 결과 파일에 실제로 써서 값을 읽은 것과 같은 형태다. 옵션 이름은 ffprobe 문서의 설명과 맞는지 확인했다. 렌더러의 실제 dist/final.mp4에 이 명령을 돌린 결과는 이 글에 없다.

비슷하게 한 단계의 통과가 다음 단계의 결함을 가리지 않도록 검증 단계를 나누는 이야기는 작성자와 검증자를 나누는 교차 검증 글에도 있다. 이번 글은 그 가운데 "테스트가 구현보다 먼저 있었기에 검토 없이도 결함이 먼저 드러났다"는 경우 하나만 다룬다. 더 많은 실전 기록은 프로그래밍 카테고리에 모아 둔다.

자주 묻는 질문

yuvj420p와 yuv420p는 무엇이 다른가요?

ffmpeg 문서가 정의하는 색 범위가 다르다. 풀 레인지(jpeg, full, pc)는 8비트 휘도 0-255이고, 제한 레인지(mpeg, limited, tv)는 16-235다. yuvj420p가 풀 레인지 쪽 형식이라는 점은 이름과 이번 재현의 color_range=pc 출력에서 읽은 내 해석이다.

-pix_fmt yuv420p만 쓰면 안 되나요?

이번 기록에서는 부족했다. 기록상 수정 뒤에도 이 인자가 그대로라 수정 전에도 있었다고 읽었는데, 결과가 yuvj420p였고 합성 입력 재현에서도 같았다. scale 필터로 범위를 변환하고 -color_range tv를 더하자 yuv420p가 나왔다. 내부 원인은 확인하지 못했다.

테스트를 구현보다 먼저 쓰면 무엇이 달라지나요?

이번에는 구현 전에 7개가 의도된 대로 실패했고, 구현 뒤 첫 실행에서 형식 결함 하나가 단언으로 드러났다. 다만 이 한 사례가 일반적인 효과를 입증하지는 않는다.

수정 뒤 모든 플레이어에서 색이 맞게 나오나요?

알 수 없다. 이 기록은 ffprobe 수준의 형식 단언과 디코드 검사까지만 확인했고, 재생 환경별 화면 비교는 하지 않았다.

참고한 자료