알고리즘 탓처럼 보였던 버그 여덟 개
Video2Any는 브라우저에서 프레임을 디코딩해 직전 프레임과 비교하고, 충분히 달라진 프레임만 남기는 방식으로 슬라이드를 찾습니다. 결과가 이상할 때 가장 먼저 의심받는 것이 이 비교입니다. 그런데 원인이 거기 있는 경우는 드뭅니다.
저희가 일주일 동안 쫓아다닌 여덟 가지를 정리했습니다. 검출기 자체가 원인이었던 것은 두 개뿐입니다. 나머지는 지우지 못한 가드, 숫자가 맞지 않는 테스트 스크립트, 가상 스크롤 목록, API 호출과의 경쟁 상태, 메모리가 한계에 닿은 경우, 그리고 에러 로그였습니다. 마지막 로그는 에러에서 쓸모 있는 부분만 빼놓고 기록하고 있었습니다.

여덟 가지
1. 바꿔도 아무 일도 없던 컨트롤
짧은 영상에서 Dense를 골라도 세 장만 나오고 Default와 똑같다는 제보가 들어왔습니다. 저희는 임계값 수식을 한참 들여다본 뒤에야 가장 기본적인 것을 확인했습니다. 그 설정이 검출기까지 가기는 하는가.
가지 않았습니다. 첫 스윕은 파일마다 한 번만 돌도록 ref가 막고 있습니다. 밀도를 바꾸면 콜백은 새로 만들어지지만, 이 가드가 이펙트를 그 자리에서 되돌려보냈고 실제로 다시 돌릴 수 있는 것은 상관없는 알림에 달린 버튼 하나뿐이었습니다. 그래서 밀도를 바꾼 뒤에 보이던 것은 이전 결과였고, 타임스탬프까지 같았습니다. 고친 뒤에는 같은 영상에서 Sparse, Default, Dense가 각각 3장, 6장, 13장을 냅니다. 그전에는 셋 다 6장이었습니다.
2. 테스트 스크립트가 두 번 다 다른 숫자를 냈다
이상한 결과를 설명하려고 저희는 Node에서 파이프라인을 오프라인으로 다시 만들고 같은 파일을 넣었습니다. 그런데 나온 숫자가 앱과 어긋났고, 두 번 다 어긋난 방향이 반대였습니다. 처음에는 스크립트가 1장, 앱이 6장이었고, 고치고 나니 스크립트가 15장, 앱이 6장이었습니다.
원인은 두 가지였습니다. 스크립트는 ffmpeg로 그레이스케일 프레임을 뽑았지만, 브라우저가 엔진에 넘기는 것은 canvas의 RGBA입니다. 축소 필터 차이만으로 보정된 임계값이 0.618에서 0.650으로 움직입니다. 게다가 스크립트는 활동 마스크를 쓸지 결정하는 함수를 건너뛰었기 때문에, 앱이 지나가는 경로를 지나지 않았습니다. 알고리즘만 재현하고 파이프라인은 재현하지 않은 스크립트는 틀린 답을 아주 당당하게 내놓습니다.
3. DOM에는 화면에 보이는 것만 있다
32분짜리 영상에서 30분 이후 슬라이드가 나오는지 확인하는 테스트가 있었습니다. 이 테스트는 썸네일 타임스탬프를 전부 읽고 마지막을 27:00으로 보고했습니다. 분석이 중간에 끊겼을 때와 똑같은 모습이었습니다.
그 그리드는 가상 스크롤이라 요소로 존재하는 것은 보이는 행뿐입니다. 맨 아래까지 스크롤한 다음 다시 읽으니 진짜 마지막은 32:00이었습니다. 이 단언문이 재고 있던 것은 데이터가 아니라 뷰포트였습니다.
4. 워커마다 따로 중복을 지웠다
긴 영상은 여러 청크로 나눠 병렬로 스윕합니다. 각 워커는 자기 구간 안에서만 반복되는 장면을 걸러냅니다. 발표자가 앞 슬라이드로 돌아가거나 회의 화면이 같은 구도로 돌아오는 경우입니다. 워커들은 서로의 결과를 보지 못합니다.
그래서 반복해서 등장하는 장면은 등장한 청크 수만큼 살아남았습니다. 29분짜리 회의 녹화에서는 아홉 장 중 세 장이 중복이었습니다. 더 곤란한 것은 이러면 최종 장수가 청크 수에, 다시 말해 사용자 기기의 코어 수에 좌우된다는 점입니다. 저희는 중복 판정을 메인 스레드로 옮겨 슬라이드가 도착할 때마다 비교하도록 했습니다. 중복된 한 장이 화면에 떴다가 사라지는 일도 없습니다.
5. 더 옳은 설계가 결과는 더 나빴다
청크마다 자기 구간에서 임계값을 보정하는 것은 누가 봐도 앞뒤가 맞지 않습니다. 영상은 하나이고 분포도 하나이니 임계값도 하나여야 합니다. 그래서 그대로 바꿨습니다. 워커가 측정한 비율을 보내면 메인 스레드가 모아서 한 번만 보정하고 답을 돌려줍니다.
4청크와 8청크의 차이는 세 장에서 한 장으로 줄었습니다. 그리고 결과는 나빠졌습니다. 같은 29분 회의에서 ffmpeg 자체 장면 검출은 진짜 컷을 일곱 개 셉니다. 청크별 보정에 위의 중복 제거를 더하면 여섯 장, 전역 보정은 네 장입니다. 비율을 모으면 분포가 넓어지고, median + 2·MAD가 올라가고, 진짜 전환이 떨어져 나갑니다. 이 변경은 되돌렸습니다. 설계가 더 반듯해도 지금 돌아가는 것한테 질 수 있습니다. 그리고 그것을 가려내려면 내 코드 바깥에 맞춰볼 기준이 있어야 합니다.
6. 답이 아니오여서가 아니라, 아직 묻지 않아서
유료 구독자가 긴 녹화를 올렸는데 앞 30분만 분석됐습니다. 다시 샘플링을 누르면 전체가 나왔고, 그래서 검출 문제처럼 보였습니다.
에디터는 상대가 누구인지 알기 전에 일을 시작합니다. 권한 플래그의 초기값은 false입니다. 다만 이 false는 권한이 없다는 뜻이 아니라, 아직 답이 오지 않았다는 뜻입니다. 길이 제한은 그 false를 읽고 무료 요금제의 상한을 씌웠습니다. 다시 샘플링을 누를 무렵에는 요금제 정보가 이미 도착해 있었습니다. 저희는 처음에 그것을 기다리도록 고쳤고, 다음에는 기다릴 필요 자체를 없앴습니다. 워커는 페이지를 돌려줄 때 이미 세션을 쥐고 있으므로, 요금제를 문서에 적어 넣으면 첫 렌더에서 알 수 있습니다.
7. 모든 에러의 이름이 “Error”였다
음성 인식은 성공보다 실패가 많았습니다. 5대 6입니다. 그리고 그 전부가 “Error”라는 문자열로 기록됐습니다. 실패한 추출 19건도 마찬가지였습니다.
코드가 기록한 것은 err.name이고, 사람이 손으로 만든 Error는 전부 이 값이 “Error”입니다. 정작 정보가 들어 있는 message는 아무 데도 남지 않았습니다. message를 기록하고 나서야 실패의 원인이 드러났습니다. decodeAudioData는 오디오 컨텍스트의 샘플레이트에 맞춰 리샘플링합니다. 그래서 하드웨어의 48 kHz 스테레오로 55분짜리 강의를 디코딩하면 다른 작업에 들어가기 전에 부동소수점 샘플만 약 630MB가 잡힙니다. 두 시간이면 1.4 GB입니다. 처음부터 16 kHz 컨텍스트에서 디코딩하면 모델이 받는 오디오는 그대로이고, 메모리는 6분의 1 정도로 줄어듭니다.
8. 빠른데도 멈춘 것처럼 느껴진다
92분짜리 회의 녹화는 52초면 스윕이 끝납니다. 첫 슬라이드는 그중 50초째에 나옵니다.
느린 구간은 없습니다. 임계값을 프레임 차분 분포 전체에서 보정하기 때문에, 영상을 끝까지 읽기 전에는 한 장도 고를 수 없습니다. 그리고 그 50초 동안 화면에 있는 것은 퍼센트 숫자 하나뿐이었습니다. 옆에 아무것도 없이 숫자만 올라가면 사용자는 프로그램이 멈췄다고 받아들입니다. 지금은 어느 단계가 도는 중인지, 영상을 어디까지 읽었는지 함께 표시합니다. 같은 52초를 두고 더는 불만이 들어오지 않습니다.
일주일 전의 나에게 한마디 한다면
코드를 추리하기 전에 입력이 그 코드까지 갔는지부터 확인할 것. 이번 여덟 개 중 절반은 설정이 도착하지 않았거나, 답이 아직 오지 않았거나, 테스트가 엉뚱한 것을 재고 있었습니다.
그리고 내 파이프라인 바깥에 맞춰볼 기준을 하나 둘 것. ffmpeg의 장면 검출은 저희가 만드는 것도 아니고 그럴 필요도 없습니다. 저희 구현과 무관하기만 하면 됩니다. 그게 없었다면 보정 재작성을 그대로 배포했을 것입니다. 설계로는 더 나았으니까요. 그리고 진짜 슬라이드 일곱 장 중 세 장을 모르는 사이에 잃었을 것입니다.