탭 안에서 영상을 병렬로 디코딩하기
Video2Any에는 나머지 전부를 결정하는 제약이 하나 있습니다. 영상이 사용자의 기기를 벗어나지 않는다는 것. 업로드가 없으니 일을 대신할 서버도 없고, 그래서 디코딩도 비교도 렌더링도 전부 UI까지 돌려야 하는 브라우저 탭 안에서 일어납니다.
그게 실제로 얼마나 드는 일인지, 그리고 사이트의 나머지는 무엇으로 만들었는지 적습니다.
디먹서는 직접 써야 한다
WebCodecs는 VideoDecoder를 줍니다. 인코딩된 청크를 프레임으로 바꾸는 물건입니다. 그런데 청크는 안 줍니다. 브라우저 자신은 MP4 파서를 갖고 있고 `<video>`가 그걸로 돌아가는데, 그걸 넘겨주는 API는 없습니다. 그래서 컨테이너를 직접 읽습니다. 박스를 훑고, moov를 찾고, 샘플 테이블을 읽고, EncodedVideoChunk를 하나씩 디코더에 밀어 넣습니다.
여기에 부수 효과가 하나 있습니다. 컨테이너를 직접 읽고 있으니, 5분을 날리기 전에 이 파일이 왜 안 열리는지 말해줄 수 있습니다. ProRes는 편집용 포맷이라 어떤 브라우저도 디코딩하지 못하는데, 그걸 아는 방법이 moov를 읽는 것이고 ProRes의 moov는 파일 앞이 아니라 뒤에 있는 경우가 많습니다. 저희 프로브가 양쪽 끝을 훑는 이유입니다.
두 단계인 건 메모리 때문
순진하게 하면 모든 프레임을 디코딩해서 비교합니다. 92분짜리 25fps 녹화는 138,000프레임이고, 1080p로 그걸 탭이 들고 있을 수는 없습니다.
그래서 두 번에 나눕니다. 스윕은 샘플 지점만 디코딩하고 각 샘플을 160×90으로 보관합니다. 영상 전체라도 픽셀은 수십 MB, 그러면서 슬라이드가 바뀐 건 알아볼 수 있는 크기죠. 검출기가 고르고 난 뒤에야 캡처가 원본 해상도 프레임을 가지러 갑니다. 고른 것만요. 첫 단계에는 샘플 900개 상한이 있고, 여기서 알아둘 만한 이상한 결론이 나옵니다. 29분짜리와 92분짜리가 비슷한 시간이 듭니다. 비용이 분이 아니라 샘플 단위이기 때문입니다.
청크마다 워커 하나, 디코더도 하나씩
영상을 시간으로 잘라 각 청크를 워커에 넘기고, 워커마다 자기 VideoDecoder를 갖습니다. 풀 크기는 min(4, 코어 수 / 2)입니다. 절반만 쓰는 건 탭이 UI도 돌려야 해서고, 4에서 막아둔 이유는 다음 절입니다.
각 청크는 자기 구간보다 조금 앞에서부터 훑습니다. 첫 프레임을 판단할 기준이 있어야 하니까요. 그다음 자기 구간 밖은 버립니다. 슬라이드는 마지막에 한꺼번에가 아니라 캡처가 하나씩 끝날 때마다 흘러나옵니다.
쪼개는 값
두 가지고, 여기가 재미있는 부분입니다. 각 청크가 자기 구간에서 임계값을 보정하니, 같은 영상을 넷으로 쪼갤 때와 여덟으로 쪼갤 때 결과가 달라집니다. 즉 **결과가 기기의 코어 수에 좌우됩니다**. 그리고 각 청크는 자기 안에서만 중복을 지우니, 회의가 계속 돌아가는 같은 화면은 등장한 청크 수만큼 남습니다. 29분 녹화에서 아홉 장 중 세 장이 그랬습니다.
중복은 고쳤습니다. 판정을 메인 스레드로 옮겨 슬라이드가 도착할 때마다 비교합니다. 보정은 못 고쳤습니다. 전역 버전을 만들어봤는데 결과가 더 나빠졌습니다. 같은 회의에서 ffmpeg가 진짜 컷을 일곱 개 세는데, 청크별 보정은 여섯 장, 전역 보정은 네 장. 그래서 풀은 넷 그대로입니다. 청크를 늘리면 25%쯤 빨라지지만 답이 달라지니까요.
다 읽기 전에는 한 장도 못 준다
임계값은 영상 전체의 프레임 차분 분포에서 나옵니다. 그래서 마지막 프레임을 샘플링하기 전에는 첫 장도 고를 수 없습니다. 92분 녹화면 스윕에 52초, 첫 슬라이드는 50초째에 나옵니다.
성능 문제가 아니고 병렬도를 아무리 올려도 없어지지 않습니다. 피드백 문제이고, 해법은 그냥 퍼센트 숫자만 있던 자리에 "영상을 읽는 중 · 45:46 / 92:08"이라고 적는 것이었습니다.
나머지 스택
API는 Cloudflare Workers와 Hono, 계정과 메타데이터는 D1, 정말 저장해야 하는 몇 가지는 R2. 프런트는 React 19에 TanStack Router, Tailwind v4, better-auth. 8개 언어에 각각 URL 접두사와 라우터 basepath가 있습니다.
사이트는 서버 렌더링이 아니라 **프리렌더된 SPA**입니다. 공개 페이지는 전부 배포 시점에 만들어진 정적 HTML 스냅샷이고, 그래서 마케팅 페이지는 캐시가 잘 먹고 빠르며 변환기 자체는 서버가 아예 필요 없습니다. 예외는 딱 하나입니다. `/app` 아래는 정적 셸이라 에디터가 계정에 대해 아무것도 모른 채 시작해서 물어봐야 했고, 답이 오기 전에 일을 시작한 것이 유료 구독자가 무료 요금제의 30분 제한을 맞은 원인이었습니다. 워커는 그 페이지를 내줄 때 이미 세션을 갖고 있으니, 요금제를 문서에 적어 넣어 첫 렌더에서 알게 했습니다. 이 응답은 private이고 캐시하지 않습니다. 다른 페이지의 공개 캐시는 그대로입니다.
왜 이렇게까지
서버를 두면 위의 대부분은 사라집니다. 대신 누군가 변환하는 모든 강의와 사내 회의와 진료 녹화가 저희 기계를 거친다는 뜻이 됩니다. 이 제품이 존재하는 이유가 바로 그러지 않는다는 데 있고요.
제약이 곧 기능입니다. 위에 적은 건 그 값입니다.