개발 취재 노트

오디오 싱크 자동 보정이 자꾸 틀린다면 — 장면 컷 검출이 못 보는 '정지 화면'

AI 캐릭터들이 그날의 이슈로 티격태격하는 쇼츠 영상을 대본부터 렌더링까지 자동으로 만드는 파이프라인(personaverse)을 만들고 있었다. 배경으로 쓰는 원본 클립의 소리를 녹화 결과물에 자동으로 싱크 맞춰 입히는 기능이 있는데, 작은 플레이어 영역에 캐릭터 혼자 거의 움직이지 않고 서 있는 편에서는 소리가 매번 어긋났다.

결론

장면 전환(scene cut) 시점을 맞춰 오디오 오프셋을 자동 측정하는 파이프라인에서 값이 자꾸 이상하게 나온다면, 입력 영상이 저모션(정지 화면)에 가까운지부터 의심하라. 장면 컷 검출은 프레임 사이의 화면 변화량을 단서로 삼는데, 화면 대부분이 흰 배경이고 피사체가 작은 영역에서 거의 움직이지 않으면 변화 신호가 묻힌다. 이 경우 단순히 "민감도만 더 낮추면 되겠지"라고 접근하기 전에, 애초에 비교할 만한 컷 신호가 녹화본에 잡히는지부터 확인해야 한다.

컷 신호가 있는지는 FFmpeg의 장면 전환 검출 필터 scdet로 직접 확인할 수 있다. 다만 이건 이번 세션에서 실제로 돌린 명령이 아니라, 같은 상황을 재현하려는 독자를 위해 FFmpeg 공식 문서를 근거로 따로 조사해 붙인 일반 절차다.

ffmpeg -i input.mp4 -vf "scdet=threshold=10,metadata=print" -f null -

로그에 lavfi.scd.time이 한 번도 찍히지 않으면, 그 threshold에서는 컷이 0건이라는 뜻이다. FFmpeg 문서가 권장하는 threshold 범위는 8.0~14.0이지만, 이 사례의 영상으로 실제 threshold를 맞춰본 적은 없으므로 이 범위를 이 사건의 정답처럼 받아들이면 안 된다 — "컷이 안 잡히는 영상인지 먼저 확인하라"는 방향만 가져가면 된다.

코드가 이런 실패를 고정 폴백값으로 조용히 넘기면 더 위험하다. 폴백이 우연히 맞는 동안은 버그가 드러나지 않다가, 조건이 바뀌는 순간 어긋남이 보인다. 이 사례에서는 제목 카드가 없던 첫 렌더에서는 폴백 0.3초가 평소 지연값과 비슷해 넘어갔고, 제목 카드 2초가 추가된 뒤에는 폴백 2.82초와 실제 오프셋 2.57초가 0.25초 벌어져 소리가 늦게 들렸다.

해결은 자동측정 결과를 믿지 않고, 원본 클립과 최종 영상 각각에서 프레임 그리드를 만들어 내용이 뚜렷이 바뀌는 지점을 눈으로 찾은 뒤, 같은 장면이 양쪽에서 몇 초에 나타나는지 비교해 오프셋을 수동으로 구하는 것이었다. 프레임을 뽑는 구체적인 명령 역시 세션 기록엔 남아 있지 않지만, FFmpeg 표준 기능으로 같은 작업을 하면 이렇다.

ffmpeg -i input.mp4 -vf fps=2 frame_%05d.png

초당 2장씩 번호가 매겨진 프레임을 뽑으면, 파일 번호 n의 시간은 (n-1)/2초로 바로 계산된다. 여러 장을 한 이미지로 모아 훑어보고 싶으면 tile 필터로 그리드를 만들 수도 있다.

클립 17.1초가 최종 영상에서는 19.67초, 클립 51.2초는 53.8초에 나타나 두 지점 모두 오프셋이 2.57초로 일치했다.

오프셋이 확정되면 재녹화 없이 오디오만 다시 인코딩한다.

python video-engine/make.py --scene work/<slug>/scene.json --skip-record --clip-audio-delay 2.57

--skip-record를 쓰면 재녹화 없이 오디오만 다시 맞출 수 있다.

오프셋의 부호는 (최종 영상에서 장면이 나타난 시각) − (원본 클립에서 같은 장면이 나타난 시각)으로 구한다. 이 값이 양수면 오디오를 그만큼 늦게 틀면 되고, 이 사례의 두 지점은 모두 +2.57초로 양수였다. 값이 음수로 나온다면(오디오를 더 일찍 틀어야 하는 경우) 얘기가 다른데, video-engine/make.py의 --clip-audio-delay가 음수 값을 실제로 받는지는 이 저장소에 소스가 없어 확인하지 못했다. 음수가 필요한 경우를 만났다면 플래그에 음수를 그대로 넣기 전에 소스를 먼저 확인하는 편이 안전하다.

상황

이 파이프라인은 HTML로 만든 장면을 헤드리스 크로미움(Playwright)으로 녹화해 영상 원본(raw webm)을 만들고, 거기에 배경 클립의 원본 오디오를 일정 시간만큼 지연시켜 입힌다. 이때 "얼마나 지연시킬지"(리드타임·오프셋)는 사람이 재지 않고, 원본 클립과 녹화 결과물 각각에서 장면이 바뀌는 시점을 찾아 서로 맞추는 방식으로 자동 측정한다.

문제가 난 영상은 53초짜리 편집본 클립으로, 리브 라이브 발언, 유영우의 장난스러운 노래, 실제로 노래를 잘하는 반전 구간을 이어 붙인 쇼츠였다. 최종 쇼츠는 캐릭터가 작은 플레이어를 소개하는 canvas-white 포맷으로 렌더됐다.

증상

원인

처음 의심한 건 제목 카드의 페이드 효과를 장면 컷으로 잘못 인식했다는 가설이었다. 화면이 어두워졌다 밝아지는 페이드가 장면 전환처럼 보일 수 있다고 보고 매칭 로직을 고쳤지만, 고친 뒤에도 여전히 폴백값(2.82)이 나왔다.

매칭 로직 탓이 아니라는 게 확인되자, 녹화 원본(raw webm) 자체를 프레임 단위로 직접 확인했다. 그랬더니 컷 자체가 잡히지 않았다. 이 영상은 캐릭터가 서 있는 플레이어 영역이 화면에서 작은 비중을 차지하고, 나머지는 흰 배경에 캐릭터도 거의 움직이지 않아서 프레임 사이 장면 변화 신호가 묻혔다.

즉 매칭 로직의 버그가 아니라 입력 영상 자체에 장면 검출 알고리즘이 반응할 화면 변화가 부족했던 것이 진짜 원인이었다. 장면 컷 검출이 실패하면 코드는 고정 폴백값으로 넘어가는데, 첫 렌더(제목 카드 없음)에서는 폴백값(0.3초)이 우연히 실제값과 비슷해 문제가 가려졌고, 제목 카드가 추가되며 필요한 오프셋이 커지자 폴백값(2.82초)과 실제값(2.57초)이 벌어지면서 비로소 어긋남이 드러났다.

해결

자동측정 결과를 포기하고, 원본 클립과 최종 영상 각각에서 프레임 그리드를 만들어 같은 장면이 바뀌는 지점을 눈으로 짚었다. 세션 기록에는 "프레임 단위로 직접 확인했다"고만 남아 있고 어떤 명령을 썼는지는 적혀 있지 않은데, FFmpeg 표준 기능으로 같은 걸 하려면 fps 필터로 일정 간격 프레임을 뽑고(ffmpeg -i input.mp4 -vf fps=2 frame_%05d.png), 파일 번호에 간격(초)을 곱해 각 프레임의 시간을 계산하면 된다. 여러 프레임을 한 이미지에 모아 훑어보려면 tile 필터로 그리드를 만든다.

그 다음 같은 장면이 양쪽 영상에서 몇 초에 나타나는지 비교했다. 클립 17.1초가 최종 영상에서는 19.67초, 클립 51.2초는 53.8초 — 두 지점 모두 오프셋이 2.57초로 정확히 일치해 신뢰할 수 있었다.

python video-engine/make.py --scene work/<slug>/scene.json --skip-record --clip-audio-delay 2.57

이 값으로 오디오만 재인코딩해 적용했다. 오프셋 부호는 최종 시각에서 클립 시각을 뺀 값이고, 이번 두 지점 모두 양수(+2.57초)였다 — 오디오를 그만큼 늦게 틀면 되는 경우다. 음수가 나왔을 때 --clip-audio-delay가 그 값을 그대로 받는지는 확인되지 않았다.

세션 결론은 "작은 플레이어 + 저모션 캐릭터 솔로 샷 포맷은 자동측정이 잘 안 되니, 앞으로 이 포맷 편들은 렌더 후 오프셋을 수동으로 재서 맞추는 흐름으로 간다"로 정리됐다.

덤 — 폴백값이 우연히 맞으면 버그가 숨는다

이번 폴백도 처음엔 0.3초가 실제값과 비슷해 아무도 자동측정 실패를 눈치채지 못했고, 제목 카드로 필요 오프셋이 커지며 폴백(2.82)과 실제값(2.57) 차이가 벌어지고 나서야 들통났다.

취재 후기 — 폴백값이 운 좋게 맞았던 것뿐이었다

박도은
우리 쇼츠 엔진 있잖아, 배경 클립 소리 자동으로 맞춰 입히는 기능. 이번 편만 자꾸 소리가 밀려.
정바다
또 리드타임 계산식이 틀렸나 보다. 전에도 raw 길이에서 재생길이를 빼는 공식이 끝쪽 여유시간까지 먹어서 1초나 밀렸었잖아.
정하늘
그건 이미 장면 컷 매칭으로 바꿔서 고친 거잖아. 이번에도 그 공식이 문제라는 근거가 뭐야?
정바다
음... 딱히 없어. 그냥 또 계산이 어긋난 줄 알았지.
박도은
일단 로그부터 보자. 어, 이번 건 자동측정이 아니라 폴백 0.3초로 잡혔다고 찍혀 있어.
정하늘
폴백이면 자동측정이 아예 실패했다는 거네. 근데 왜 이전 렌더에서는 아무도 몰랐지?
박도현
그야 폴백값이 우연히 비슷했으니까. 0.3초는 원래 평소 지연값이랑 크게 안 다르잖아.
정바다
아, 그럼 이번엔 앞에 제목 카드 2초를 붙이면서 필요한 오프셋이 커졌고, 폴백(2.82)이랑 실제값 차이가 벌어지면서 들킨 거네.
박도은
근데 왜 측정 자체가 실패하는지가 먼저 아니야? 폴백이 왜 뜨는지부터 봐야지.
정바다
내 생각엔 제목 카드 페이드 효과를 장면 컷으로 착각한 거 같아. 화면이 어두워졌다 밝아지는 게 컷처럼 보일 수 있잖아.
정하늘
그럴듯하네. 매칭 로직 고치고 다시 측정해 보자.

매칭 로직을 손봐 페이드를 장면 컷으로 세지 않도록 고친다.

박도현
그래서 다시 쟀더니 어떻게 됐어? 여전히 폴백이면 페이드 가설은 아닌 거잖아.
박도은
여전히 2.82초 폴백이야. 로직 고친 게 전혀 안 먹혔어.
박도현
그럼 매칭 탓하기 전에 물어보자, raw 자체에 컷이 있기는 한 거야? 확인했어?
박도은
아니, 안 봤어. 지금 raw.webm 바로 열어볼게.

녹화 원본을 프레임 단위로 넘겨가며 직접 확인한다.

정바다
어때, 뭐 보여?
박도은
컷이 아예 안 잡혀. 화면 대부분이 흰 배경이고 캐릭터도 거의 안 움직여.
정하늘
그거네, 진짜 원인. 장면 컷 검출은 프레임마다 화면이 얼마나 바뀌었는지 보는데, 바뀔 게 없으면 신호 자체가 약한 거잖아.
박도은
잠깐, "장면 컷 검출"이 정확히 뭘 보는 건데? 우리 눈으로 보는 거랑 뭐가 달라?
정바다
원본 클립이랑 녹화 결과물 각각에서 화면이 확 바뀌는 지점을 찾아서, 그 지점끼리 맞춰 지연 시간을 구하는 거야. 바뀌는 지점이 없으면 맞출 기준 자체가 없어.
박도현
그럼 이 포맷은 애초에 자동측정이랑 안 맞는 영상이네. 작은 플레이어에 캐릭터 혼자 서 있는 솔로 샷이잖아.
정하늘
그럼 자동은 포기하고 직접 재자. 프레임 그리드로 클립이랑 최종본에서 같은 장면 시간을 비교하면 되잖아.
박도은
쟀어. 클립 17.1초가 최종에서 19.67초, 51.2초가 53.8초. 둘 다 오프셋 2.57초로 똑같이 나와.
정바다
두 지점이 똑같이 나오면 믿을 만하네. 적용했던 2.82보다 0.25초 적은 거고.
박도은
--clip-audio-delay 2.57로 오디오만 다시 넣었어. 재녹화 안 해도 되니까 빨라.
박도현
근데 이거 고친 거야, 아니면 이번 한 편만 땜빵한 거야? 다음에 비슷한 포맷 또 나오면 똑같이 폴백 걸릴 텐데.
정하늘
맞아, 이 캔버스-화이트 포맷은 자동측정이 구조적으로 잘 안 되는 거니까, 앞으로 이 포맷 편들은 렌더 후에 수동으로 재는 걸로 정하자.
정바다
그럼 내 첫 가설(페이드 오인)도 아주 틀린 건 아니었네. 페이드도 문제긴 했잖아.
박도현
반은 맞았어도 고친 뒤에 폴백이 또 뜬 순간 버렸어야지. 증상이 같다고 원인이 하나는 아니야.

마지막으로 정리한다.

정하늘
폴백값이 운 좋게 맞아떨어지면, 측정이 실패했다는 사실조차 아무도 모른다는 거. 그게 제일 무서운 거네.