플레이헤드를 끌면 영상이 뚝뚝 끊기던 이유 — 키프레임과 '이어서 디코드하기'
캔버스 기반 영상 에디터를 만들고 있습니다. 사용자가 올린 영상, 배경 영상, 그리고 서버가 렌더링한 AI 아바타 영상(투명 배경 webm)까지 모두 캔버스 위에 그립니다.
재생은 멀쩡합니다. 문제는 멈춘 상태에서 타임라인 플레이헤드를 끌 때였습니다. 특히 아바타 영상에서 심했습니다. 화면이 부드럽게 따라오지 않고 뚝, 뚝, 몇 장만 바뀝니다. 손을 떼고 나서도 0.5~1초쯤 뒤에야 마지막 장면이 나옵니다.
1. 원인 — 영상 파일은 모든 장면을 그림으로 저장하지 않는다
영상은 1초에 수십 장의 그림(프레임)이 넘어가는 것입니다. 우리 아바타 영상은 12fps라서 57초짜리 영상이면 약 690장입니다. 이걸 전부 온전한 그림으로 저장하면 파일이 너무 커집니다.
그래서 영상 코덱은 이렇게 저장합니다.
- 가끔 한 장만 온전한 그림으로 저장합니다. 이게 키프레임입니다.
- 나머지는 "앞 그림에서 바뀐 부분"만 적어 둡니다. "입이 조금 열림", "눈 깜빡임" 같은 메모입니다.
키프레임 하나와 그 뒤에 이어지는 메모 묶음을 GOP(Group of Pictures)라고 부릅니다. 간격은 영상마다 다릅니다. 휴대폰으로 찍은 mp4는 보통 1~2초입니다. 그런데 실제 파일을 열어 보니 아바타 영상은 128프레임마다 키프레임이었습니다. 12fps니까 10.67초마다 한 장입니다. 아바타에서 유독 심했던 이유가 여기 있습니다.
$ ffprobe -v error -select_streams v -show_entries packet=pts_time,flags -of csv=p=0 out.webm
0.007000,K__, ← 키프레임
0.090000,___,
0.174000,___,
...
10.674000,K__, ← 다음 키프레임
21.340000,K__,
32.007000,K__,
그래서 중간 장면 하나를 보려면 처음부터 쌓아 올려야 한다
9초 장면을 보고 싶다고 해 봅시다.
- 9초짜리 메모만으로는 그림이 안 나옵니다. "입이 조금 열림"만 있지, 얼굴이 없으니까요.
- 0초 키프레임(온전한 그림)부터 시작해서
- 메모를 1장째, 2장째, …, 108장째까지 차례로 적용해야 9초 그림이 완성됩니다.
그림 한 장을 보려고 약 108장을 다시 만드는 셈입니다.
기존 코드는 드래그 요청마다 이걸 처음부터 반복했다
플레이헤드를 끌면 "9.00초 보여줘 → 9.04초 보여줘 → 9.08초 보여줘…" 요청이 프레임마다 들어옵니다.
에디터는 디코딩에 mediabunny를 씁니다. 정지 화면 한 장을 얻을 때 CanvasSink.getCanvas(timestamp)를 불렀는데, 이 API는 호출할 때마다 그 시점의 키프레임부터 디코드를 새로 시작합니다. 바로 전에 9.00초까지 만들어 둔 걸 버리고, 9.04초를 위해 0초부터 다시 109장을 쌓습니다.
// 기존: 요청마다 키프레임부터 새로 디코드
const frame = await stillSink.getCanvas(sourceTimeMs / 1000);
요청을 다 처리하면 밀리니까 "가장 최근 요청만 처리"하는 latest-wins 루프가 있긴 했습니다. 하지만 한 번 쌓는 데 수백 ms가 걸리니 그 사이 들어온 요청은 대부분 건너뜁니다. 그래서 화면이 몇 번만 바뀌는 끊김이 됩니다. 키프레임에서 멀수록, 즉 10초 직전으로 갈수록 더 심해집니다.
실제로 seek 한 번을 재 보면 키프레임에서의 거리에 따라 0.2초에서 4초까지 나왔습니다.
2. 고친 방법 — 쌓아 둔 걸 버리지 말고 이어서 쌓는다
생각해 보면 드래그는 대부분 한 방향으로 조금씩 움직입니다. 9.00초 다음은 9.04초고, 그다음은 9.08초입니다. 방금 9.00초까지 쌓아 뒀다면 9.04초는 한 장만 더 쌓으면 됩니다.
① 어디까지 쌓았는지 기억한다
mediabunny에는 getCanvas 말고 canvases(start)라는 API도 있습니다. 시작 시점부터 프레임을 차례대로 하나씩 꺼내 주는 비동기 이터레이터입니다. 이 이터레이터를 버리지 않고 들고 있으면, 그게 곧 "지금 어디까지 디코드했는지"를 기억하는 커서가 됩니다.
interface StillCursor {
iterator: AsyncGenerator<WrappedCanvas>; // 디코더가 이어서 꺼내 줄 수 있는 상태
position: WrappedCanvas; // 지금 화면에 보여 준 프레임
next: WrappedCanvas | null; // 한 장 미리 꺼냈는데 아직 안 쓴 프레임
}
② 앞으로 가는 요청이면 이어서 쌓는다
새 요청이 오면 이터레이터에서 목표 시점을 넘기 직전까지만 프레임을 꺼냅니다.
private async advanceCursor(cursor: StillCursor, sec: number) {
for (;;) {
const candidate = cursor.next ?? (await cursor.iterator.next()).value ?? null;
cursor.next = null;
if (!candidate) return cursor.position; // 영상 끝
if (candidate.timestamp > sec) { // 목표를 넘었다
cursor.next = candidate; // 버리지 말고 다음 요청 때 쓴다
return cursor.position;
}
cursor.position = candidate; // 한 장 전진
}
}
목표를 한 장 넘겨서 꺼낸 프레임은 버리지 않고 next에 넣어 둡니다. 다음 요청이 그보다 뒤라면 그대로 씁니다.
③ 이어갈 수 없을 때만 새로 시작한다
이어서 쌓는 게 항상 이득은 아닙니다. 두 가지 경우는 새로 여는 게 맞습니다.
- 뒤로 갈 때 (9초 → 8초): 메모는 앞으로만 적용할 수 있습니다. 거꾸로 되돌릴 방법이 없으니 키프레임부터 다시 쌓아야 합니다.
- 다음 키프레임을 넘을 때 (9초 → 15초): 10.67초에 새 온전한 그림이 있습니다. 9초에서 15초까지 72장을 이어 쌓는 것보다 10.67초에서 시작해 52장만 쌓는 게 빠릅니다.
두 번째 판정에는 "목표 시점 직전의 키프레임이 어디냐"를 알아야 합니다. 이건 프레임을 디코드하지 않고 컨테이너의 패킷 정보만 보면 알 수 있습니다. mediabunny의 EncodedPacketSink.getKeyPacket(t)가 정확히 이걸 해 줍니다.
private async stillFrameAt(handle: VideoHandle, sourceTimeMs: number) {
const sec = Math.max(0, sourceTimeMs / 1000);
const cursor = this.cursor;
if (cursor && sec >= cursor.position.timestamp) {
const key = await handle.packetSink.getKeyPacket(sec);
// 목표의 키프레임이 현재 위치 이하 = 같은 GOP 안에서 앞으로 가는 중
if (this.cursor === cursor && key && key.timestamp <= cursor.position.timestamp) {
return this.advanceCursor(cursor, sec);
}
}
// 뒤로 가거나 다음 키프레임을 넘었다 → 목표에서 새로 연다
this.closeCursor();
const iterator = handle.stillSink.canvases(sec);
const first = (await iterator.next()).value ?? null;
if (first) this.cursor = { iterator, position: first, next: null };
return first;
}
조건은 한 줄로 정리됩니다. 목표 ≥ 현재 위치이고, 목표의 키프레임 ≤ 현재 위치이면 이어 간다. 아니면 새로 연다.
④ 재생을 시작하거나 화면을 닫으면 정리한다
재생은 원래 쓰던 별도의 스트리밍 경로를 씁니다. 그래서 재생이 시작되면 스크럽용 커서는 닫아서 디코더를 놓아 줍니다. 컴포넌트가 사라질 때(dispose)도 마찬가지입니다. 이터레이터를 return()으로 닫지 않으면 디코더 자원이 남습니다.
왜 canvasesAtTimestamps는 안 썼나
mediabunny에는 여러 시점을 한 번에 넘기는 canvasesAtTimestamps도 있어서 처음엔 이게 정답처럼 보였습니다. 그런데 이 API는 키프레임이 바뀌거나 입력이 끝날 때까지 결과를 모아서 내보냅니다. 드래그처럼 시점이 하나씩 실시간으로 들어오는 상황에서는 현재 프레임을 바로 못 받고 붙잡혀 있게 됩니다. 그래서 순차 이터레이터를 직접 들고 있는 쪽을 택했습니다.
3. 측정 — 자동화 브라우저의 드래그는 믿을 수 없었다
처음엔 자동화 브라우저로 실제 플레이헤드를 끌어서 비교하려 했습니다. 그런데 CDP로 보낸 마우스 이동 이벤트 하나가 정확히 약 1초씩 걸렸습니다. 드래그 중 화면 갱신 자체가 초당 1번이 되니, 이전 코드와 새 코드가 똑같이 느려 보였습니다.
그래서 UI를 거치지 않고, 브라우저 안에서 실제 모듈을 그대로 import 해서 측정했습니다. Vite 개발 서버는 소스 파일을 ES 모듈로 서빙하니 페이지에서 import('/src/.../videoFeed.ts')가 그대로 됩니다.
- 드래그 재현: 16ms마다 목표 시점을 40ms씩 앞으로 요청
- 기존 방식:
getCanvas+ latest-wins 루프 (예전 코드와 같은 구조) - 새 방식: 고친
VideoFeed클래스 그대로 - 57초짜리 실제 아바타 영상, 케이스마다 2회
| 구간 | 방식 | 화면 바뀐 횟수 | 가장 오래 멈춘 시간 | 손 뗀 뒤 따라오는 시간 |
|---|---|---|---|---|
| 5.0→10.5s (2.4초 드래그) | 기존 | 4 | 544–652ms | 853–1013ms |
| 변경 | 66 (전 프레임) | 54–56ms | 0 | |
| 0→57s (24초 드래그) | 기존 | 87–98 | 805–1070ms | 368–480ms |
| 변경 | 687 (전 프레임) | 59–98ms | 0 |
5~10초 구간이 키프레임(0초)에서 가장 먼 곳이라 기존 방식이 가장 나쁩니다. 2.4초 동안 화면이 4번 바뀌었으니 거의 슬라이드쇼입니다. 새 방식은 같은 구간에서 12fps 영상의 모든 프레임인 66장을 다 보여 줬습니다.
전체 구간(0→57초)에서 기존 방식이 상대적으로 덜 나빠 보이는 건 10.67초마다 키프레임을 지나면서 일부 seek가 싸지기 때문입니다. 그래도 최대 1초씩 멈췄습니다. 새 방식은 키프레임을 넘을 때마다 새로 열지만, 키프레임 바로 뒤에서 시작하니 최대 98ms 정도만 튑니다.
4. 모든 영상에 적용된다
고친 곳은 아바타 전용 코드가 아니라, 에디터가 영상 오브젝트를 화면에 그릴 때 공통으로 쓰는 VideoFeed 클래스입니다. 업로드 영상·배경 영상·아바타 영상이 모두 이 클래스를 지나므로 수정도 전부에 적용됩니다.
효과 크기는 키프레임 간격에 비례합니다. 1~2초 간격인 일반 mp4는 원래 덜 끊겼으니 개선 폭도 작고, 화면 녹화처럼 간격이 긴 영상은 아바타와 비슷하게 좋아집니다. 위 측정은 간격이 가장 긴 아바타 영상으로만 했습니다.
5. 남은 것
- 뒤로 드래그는 그대로입니다. 앞에서 말한 대로 메모는 거꾸로 적용할 수 없으니 키프레임부터 다시 쌓습니다. 뒤로 스크럽까지 매끄럽게 하려면 최근 프레임 몇 장을 캐시해 두거나, 아예 키프레임 간격을 줄여야 합니다.
- 근본 처방은 인코더 쪽입니다. 128프레임 GOP는 파일 크기엔 유리하지만 편집기에서 탐색하기엔 깁니다. 인코더에 키프레임 간격 옵션(ffmpeg
-g)을 주면 디코드 거리 자체가 줄어듭니다. 대신 파일이 커지니 저장·전송 비용과 맞바꿔야 합니다. 이번엔 서버 쪽을 건드리지 않고 프론트에서 먼저 해결했습니다.
정리하면, 영상 탐색이 느릴 때는 "한 장 보여 주는 데 몇 장을 디코드하고 있나"를 먼저 세어 보면 됩니다. 이번 경우엔 그게 100장 남짓이었고, 이어서 쌓기만 해도 1장이 됐습니다.