소리꽃 KeyBloom 21
오늘 한 일
- 영상 내보내기 속도를 대폭 개선 — 1080p가 30분 넘게 걸리던 것을 12분으로. 브라우저 인코더가 하드웨어를 못 써 소프트웨어로 돌던 것을, 번들 ffmpeg의 하드웨어 인코더로 바꿈
- 프레임을 워커 풀에서 여러 코어로 나눠 압축하도록 바꿔 전송 병목 해소
- 저장·불러오기 표시를 버튼 누르는 즉시 뜨게, 렌더 중엔 경과·남은 시간을 추정해 표시
- 라이브 공연창을 닫을 수 있게 함 — 버튼으로도, 공연창에서 ESC로도. 세컨드 모니터에서 공연창이 본체를 덮으면 본체 버튼이 안 보여 ESC가 유일한 탈출구
막힌 부분
영상 내보내기가 왜 이렇게 느린지 — 브라우저 인코더가 하드웨어를 못 쓰고 있었다
1080p 영상 하나 뽑는 데 30분이 넘게 걸렸다. 프레임을 한 장씩 렌더해 브라우저 내장 H.264 인코더(WebCodecs)에 넣는 구조인데, 어디가 느린지 프레임마다 렌더 시간과 인코더 대기 시간을 따로 재봤다.
렌더는 프레임당 2ms인데 인코더 대기가 107ms였다. 인코더가 소프트웨어로 돌고 있었다. 네이티브 앱을 감싸는 웹뷰(WebView2)가 GPU의 하드웨어 인코더를 안 잡고 소프트웨어로 떨어진 것이다. 하드웨어 가속을 명시로 요청하거나 실시간 모드로 바꿔도 소용없었다 — GPU엔 하드웨어 인코더가 있는데 웹뷰가 접근을 못 한다.
- 해결: 브라우저 인코더를 버리고, 앱에 이미 번들된 ffmpeg의 하드웨어 인코더(
h264_amf)로 인코딩. 명령줄로 재보니 프레임당 4ms — 25배 빠름
하드웨어는 빨라졌는데, 이번엔 프레임을 넘기는 게 병목
렌더는 웹뷰(브라우저 엔진), 인코더는 네이티브(ffmpeg)라 그 사이로 프레임을 넘겨야 한다. 한 프레임이 1080p면 raw 픽셀로 8MB. 이걸 웹뷰에서 네이티브로 넘기는 통로(invoke)로 보내니 프레임당 210ms가 걸렸다. 인코더(4ms)는 놀고 전송이 발목을 잡았다.
웹뷰의 통로가 대용량에서 초당 40MB 정도밖에 안 나온다. 혹시 통로 문제인가 싶어 다른 통로(로컬 웹소켓)도 실제로 만들어 재봤는데 비슷하게 느렸다 — 웹뷰에서 렌더러 밖으로 큰 데이터를 빼는 것 자체가 느린 것이라, 통로를 바꾸는 걸로는 안 됐다.
- 해결 방향: 프레임을 압축해 작게 만들어 넘긴다(어차피 최종이 H.264 손실 압축이라 중간 JPEG의 화질 손실은 눈에 안 띔)
압축을 메인 스레드에서 하면 병렬이 안 된다 — 워커 풀로
프레임을 JPEG로 압축하니 8MB에서 50KB(40배)로 줄어 전송은 8ms로 해결됐다. 그런데 압축(toBlob) 자체가 프레임당 108ms로 새 병목이 됐다. 브라우저는 이 압축을 메인 스레드의 짬 나는 시간에 조금씩 쪼개 실행해서, 여러 장을 한꺼번에 띄워도 병렬이 안 된다.
워커(백그라운드 스레드)의 압축(convertToBlob)은 그 워커 스레드에서 통으로 실행된다 → 워커 수만큼 CPU 코어로 병렬. 그래서 워커 풀을 만들었다. 메인은 프레임 픽셀을 워커로 소유권째 넘기고(복사 없음), 워커가 JPEG로 압축해 돌려주면, 프레임 순서를 지켜 작은 JPEG만 네이티브로 넘긴다.
// 워커 — 픽셀을 받아 OffscreenCanvas에서 JPEG로 압축(워커 스레드 통실행 = 코어 병렬)
scope.onmessage = async (e) => {
const { seq, width, height, buf } = e.data;
ctx.putImageData(new ImageData(new Uint8ClampedArray(buf), width, height), 0, 0);
const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.95 });
const bytes = new Uint8Array(await blob.arrayBuffer());
scope.postMessage({ seq, bytes }, [bytes.buffer]); // 돌려줄 때도 소유권 이전(무복사)
};
// 메인 — 프레임을 워커 풀에 라운드로빈으로. 8MB 버퍼는 소유권 이전이라 복사 없음
const img = ctx.getImageData(0, 0, w, h);
workers[submitted % W].postMessage(
{ seq: submitted, width: w, height: h, buf: img.data.buffer },
[img.data.buffer],
);
- 해결: 워커 풀(코어수−2, 최대 8개)로 JPEG를 병렬 압축, 순서 보존해 네이티브 ffmpeg에 전달. 1080p가 37분에서 12분, 4K도 뽑힘
- 남은 한계: 남은 병목은 메인 스레드 직렬 구간(렌더+픽셀 읽기 ~31ms)이라 워커를 더 늘려도 소용없어 8개로 고정
저장·불러오기 표시가 늦게 뜨고, 남은시간이 튀었다
저장·불러오기가 무거운 동기 작업이라, 버튼을 눌러도 “저장 중” 표시가 작업이 끝나갈 때쯤에야 떴다. 화면을 강제로 한 번 그리게 한 뒤(rAF, 브라우저의 다음 화면 갱신 타이밍을 두 번 기다림) 작업을 시작해 표시가 즉시 뜨게 했다.
렌더 남은시간 추정도 문제였다. 경과 시간 ÷ 진행률로 총 시간을 추정하는데, 진행이 1%일 때 계산하면 값이 30분+로 폭발해 엉뚱하게 떴다.
- 해결: 진행 5%가 넘어 초기 오버헤드가 희석된 뒤에만 남은시간을 띄우고, 그 전엔 “계산 중”으로
다음에 할 일
- 4K 저장 파일이 실제로 재생되는지·오디오 싱크 집 PC에서 확인
- Ut Video·자동 업데이터 첫 릴리스 — 로컬 서명 빌드 후 KeyBloom-releases에 업로드해 업데이트 작동시키기
- Mac 네이티브 앱 + ProRes 알파
- Creem 결제 Worker 배포 + 인앱 활성화(라이선스 키 입력) UI
- 첫 방문자용 샘플 데모 MIDI 자동 로드(자작곡 1곡 + 쇼팽 녹턴)
- 커스텀 파티클 모양 실루엣 재작업(투명 PNG 기준)
- 프로젝트 “다른 이름으로 저장”(Save As)
- (우선순위 낮음) 알파(투명) export도 워커 풀 PNG 병렬 + raw IPC로 최적화 — 지금은 메인 스레드 toBlob(PNG) + base64라 4K가 오래 걸림. 알파는 JPEG를 못 써 무손실 PNG라 MP4보단 무겁지만 병렬화로 단축 가능
- 비-나선 파티클 가로 퍼짐(폭) 슬라이더 검토
- 실시간 연주 데모 영상 — 손+화면 동시로 실제 연주임을 보이기