탭 하나에서 영상을 병렬로 디코딩하기
Video2Any에는 나머지 전부를 결정하는 제약이 하나 있습니다. 사용자의 영상이 그 사람 기기를 벗어나지 않는다는 것입니다. 파일을 올리지 않으니 대신 일해 줄 서버도 없고, 그래서 디코딩과 비교와 렌더링이 모두 브라우저 탭 안에서 일어납니다. 그 탭은 같은 시간에 화면도 계속 움직여야 합니다.
이 제약이 실제로 얼마나 드는 일인지, 그리고 사이트의 나머지 부분은 무엇으로 만들었는지 적어 둡니다.

이 페이지의 목차
디먹서는 직접 써야 한다
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)입니다. 절반만 쓰는 것은 탭이 화면도 돌려야 하기 때문이고, 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이고 캐시하지 않습니다. 다른 페이지의 공개 캐시는 그대로입니다.
왜 이렇게까지
서버를 두면 위의 대부분은 사라집니다. 대신 누군가 변환하는 모든 강의와 사내 회의와 진료 녹화가 저희 기계를 거친다는 뜻이 됩니다. 이 제품이 존재하는 이유가 바로 그러지 않는다는 데 있고요.
제약이 곧 제품입니다. 위에 적은 것은 그 제약을 지키려고 치르는 값입니다.