동영상 썸네일을 canvas 캡처 없이 video 태그의 media fragment 로 만들기
S3 presigned URL 로 redirect 되는 구조에서는 canvas 로 동영상 프레임을 캡처할 수 없습니다. video 의 src 끝에 #t=0.1 을 붙여 첫 프레임을 그대로 보여 주는 방법입니다.
파일 관리 웹앱의 그리드 카드에 동영상 첫 프레임 썸네일을 띄워야 했습니다. 정석으로 알려진 방법은 숨긴 <video> 에서 프레임을 canvas.drawImage() 로 캡처해 dataURL 로 캐싱하는 것입니다.
그런데 이 방법이 우리 구조에서는 막혔습니다.
canvas 캡처가 막히는 이유
파일 서빙이 "API 서버가 S3 presigned URL 로 307 redirect" 하는 구조였습니다. <video> 가 redirect 를 따라가 S3 에서 콘텐츠를 받는 순간 cross-origin 미디어가 되고, drawImage() 뒤에 toDataURL() 을 호출하면 canvas taint 로 SecurityError 가 납니다.
그럼 crossOrigin="anonymous" 를 붙이면 될까요? taint 는 풀리지만, 이번에는 S3 버킷에 CORS 설정이 없으면 로드 자체가 실패합니다. 버킷 CORS 는 인프라에 달린 문제라 코드만으로 보장할 수 없습니다.
결국 canvas 방식은 동일 출처에서만 동작하고, 운영 환경(S3, CDN)에서는 폴백으로만 떨어지는 코드가 됩니다.
캡처를 포기하고 media fragment 쓰기
<video> 요소 자체를 썸네일로 렌더하기로 했습니다. 방법은 media fragment(#t=0.1) 하나였습니다.
src 끝에 #t=0.1 을 붙이면 브라우저가 0.1초 지점으로 시킹해서 첫 프레임을 페인트합니다. preload="metadata" 와 같이 쓰면 포스터 이미지 없이도 정지 화면이 보입니다.
fragment 는 클라이언트에서만 해석되니 307 redirect 를 거쳐도 유지됩니다. presigned URL 이든 CDN 이든 똑같이 동작하고, canvas 를 안 쓰니 taint 와 CORS 문제도 처음부터 없습니다.
function VideoThumb({ src, onFallback }) {
const ref = useRef(null);
const setRef = useCallback((node) => {
if (!node && ref.current) {
ref.current.removeAttribute("src"); // 디코더/네트워크 해제
ref.current.load();
}
ref.current = node;
}, []);
return (
<video
ref={setRef}
src={`${src}#t=0.1`} // 첫 프레임 페인트, redirect 후에도 유지
muted
playsInline
preload="metadata"
tabIndex={-1}
onError={onFallback}
style={{ width: "100%", height: "100%", objectFit: "cover", pointerEvents: "none" }}
/>
);
}빼먹으면 티 나는 디테일
muted playsInline preload="metadata"로 모바일 자동재생 정책을 피하고, 메타데이터와 첫 프레임 정도만 받습니다. 전체 다운로드가 아닙니다.pointer-events: none과tabIndex={-1}로 썸네일이 카드의 클릭, 더블클릭, 드래그 이벤트를 가로채지 않게 합니다.onError에서 카테고리 아이콘으로 폴백합니다. avi, mkv 처럼 브라우저가 디코딩 못 하는 코덱은 자연스럽게 아이콘이 됩니다.
리소스 해제는 useEffect cleanup 대신 callback ref 에서 합니다. deps 기반 cleanup 은 파일 prop 이 바뀔 때 새로 커밋된 src 를 지워 버리는 타이밍 버그가 있습니다. ref 가 detach 되는 순간(node === null)에 removeAttribute('src') 후 load() 로 디코더를 내려놓는 쪽이 정확합니다.
트레이드오프
카드마다 <video> 요소가 살아 있으니 수백 개짜리 그리드에서는 브라우저의 미디어 플레이어 인스턴스 한도(Chrome 데스크톱 기준 대략 75개)에 닿을 수 있습니다. 대형 그리드라면 IntersectionObserver 로 lazy-mount 를 붙이는 게 다음 단계입니다.
캡처만이 답은 아니라는 걸 알게 된 김에, 다음 목록 화면부터는 이 방식을 먼저 써 보려고 합니다.
참고