알고리즘 문제처럼 보였던 버그 여덟 개
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를 읽고 무료 요금제의 천장을 씌웠습니다. 다시 샘플링할 무렵엔 요금제 정보가 도착해 있었고요. 처음엔 기다리도록 고쳤고, 다음엔 기다릴 필요 자체를 없앴습니다. 페이지를 돌려줄 때 워커는 이미 세션을 쥐고 있으니, 요금제를 문서에 적어 넣으면 첫 렌더에서 압니다.
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의 장면 검출은 우리가 만드는 것도 아니고 그럴 필요도 없습니다. 독립적이기만 하면 됩니다. 그게 없었다면 보정 재작성을 그대로 배포했을 겁니다. 설계로는 더 나았으니까요. 그리고 일곱 장 중 세 장의 진짜 슬라이드를 조용히 잃었겠죠.