스크린샷과 에셋10 분 읽기

제 애니메이션은 잘 돌고 있었습니다 — 볼 방법이 없었을 뿐입니다

requestAnimationFrame 은 탭 가시성에 묶여 있습니다. 스크린샷 도구는 탭을 활성화하지 않습니다. 그래서 캔버스 연출을 만들어놓고 그게 예쁜지 아닌지를 판정할 방법이 사라졌습니다.

#canvas#verification#gotchas#reality-check#tools
왼쪽: 캡처 도구가 탭을 활성화하지 않아 requestAnimationFrame 이 스로틀되고 캔버스가 한 프레임도 안 그려져 검수가 불가능하다. 오른쪽: drawFrame(ctx, scene, t) 순수 함수와 t 를 쿼리로 주입하는 경로를 두면 한 프레임을 결정적으로 그려 캡처할 수 있다.
연출을 보려면 연출을 멈출 수 있어야 합니다.

캔버스로 6초짜리 세로 영상 연출을 만들고 있었습니다. 지도가 깔리고, 걸어서 딴 영역이 차오르고, 숫자가 올라가고, 마지막에 카드가 뜹니다.

코드를 다 쓰고 브라우저 자동화로 페이지를 열어 스크린샷을 찍었습니다. 빈 화면이었습니다.

에러는 없었습니다. 요소도 다 있었습니다. 캔버스 엘리먼트는 DOM 에 정확한 크기로 있었습니다. 그냥 아무것도 안 그려져 있었습니다.

스로틀은 버그가 아니라 규격입니다

원인은 한 줄로 끝납니다. requestAnimationFrame탭 가시성에 묶인 API 입니다. 탭이 백그라운드면 브라우저가 콜백을 스로틀하거나 아예 안 부릅니다. 배터리를 아끼려고 그렇게 설계된 겁니다.

그리고 자동화 스크린샷 도구는 탭을 포그라운드로 만들지 않습니다. 페이지를 열고, 렌더 트리를 받아 이미지로 굽습니다. 사람이 그 탭을 보고 있지 않습니다.

두 사실이 만나면 이렇게 됩니다. 연출은 멀쩡히 구현돼 있는데 rAF 가 한 번도 안 돌고, 첫 프레임조차 안 그려지고, 캡처에는 빈 캔버스가 찍힙니다. 코드는 정상인데 판정이 불가능한 상태 입니다.

이게 일반 버그와 다른 점이 있습니다. 보통은 "안 되는 것"을 고치면 됩니다. 여기서는 되는지 안 되는지를 볼 수단 자체가 없습니다. 디자인 판정 — 색이 맞나, 타이밍이 늘어지나, 텍스트가 안전영역을 넘나 — 을 할 근거가 0 이 됩니다.

당신이라면 어떻게 하시겠습니까

선택지가 몇 개 있습니다.

사람이 직접 봅니다. 됩니다. 대신 프레임을 바꿀 때마다 사람이 필요하고, 두 플랫폼을 눈으로 비교해야 하고, 6초 안의 특정 순간을 잡으려면 계속 되감아야 합니다.

탭을 강제로 활성화합니다. 도구에 따라 됩니다. 다만 캡처 파이프라인이 창 관리에 의존하게 되고, CI 나 헤드리스에서는 다시 깨집니다.

rAF 를 타이머로 바꿉니다. 스로틀은 피하지만 실제 재생 품질이 나빠집니다. 검수 편의를 위해 제품을 나쁘게 만드는 교환입니다.

저는 넷째를 골랐습니다.

시각을 밖에서 주입한다

연출을 순수 함수 로 뽑았습니다.

export function drawFrame(ctx: Ctx2D, scene: ReelScene, t: number): void

t 는 초 단위 시각입니다. 이 함수는 시계를 안 봅니다. 상태도 안 들고 있습니다. t 를 주면 그 순간의 화면을 그립니다. 재생은 rAF 가 t 를 흘려보내는 얇은 껍데기일 뿐입니다.

그 다음 공유 페이지에 쿼리 하나를 열었습니다.

// ?t=<초> 는 한 프레임에서 정지합니다 — 검수용 경로입니다.
const freezeAt = t !== undefined && Number.isFinite(Number(t)) ? Number(t) : undefined;

이제 /t/<id>?t=2.4 를 열면 2.4초 시점 화면 한 장 이 그려지고 멈춥니다. rAF 가 안 돌아도 상관없습니다. 애초에 안 씁니다.

이걸로 자동화 캡처가 되살아났습니다. 그리고 부수 효과가 더 컸습니다.

  • 결정적 비교 — 같은 t 는 항상 같은 그림입니다. 팔레트를 바꾸고 전후를 나란히 놓을 수 있습니다.
  • 플랫폼 간 대조 — iOS 와 안드로이드의 같은 t 를 픽셀로 비교합니다. 눈으로는 못 잡는 차이가 잡힙니다.
  • 썸네일 — "가장 예쁜 순간"이 몇 초인지 알면 그 프레임이 곧 공유 카드 이미지입니다.
  • 회귀 테스트 — 특정 t 에서 텍스트가 안전영역을 안 넘는지 단정할 수 있습니다.

들인 비용은 함수 시그니처 하나와 쿼리 파싱 두 줄입니다. drawFrame(ctx, scene, t) 로 이미 잘라놨다면 정지 경로는 정말 몇 줄입니다.

순서가 핵심입니다

이걸 나중에 붙이면 비쌉니다. 연출 코드가 rAF 콜백 안에서 자기 시각을 들고 있으면 — elapsed += delta 같은 게 클로저에 숨어 있으면 — 순수 함수로 뽑는 일이 리팩터링이 됩니다.

처음부터 시각을 인자로 받으면 공짜입니다. 그래서 이건 디버깅 요령이 아니라 설계 규칙에 가깝습니다: 캔버스 연출을 만들 때는 한 프레임만 그리는 경로를 같은 커밋에 만든다.

같은 화면을 놓고 콘솔은 조용했고, 화면은 비어 있었습니다 를 쓴 적이 있습니다. 그때 저는 rAF 스로틀을 용의선상에 올렸다가 "그것만으로는 첫 프레임조차 안 그려지는 걸 설명 못 한다"며 내렸습니다. 그 판단은 그 사건에서는 맞았습니다 — 진짜 원인은 하이드레이션이 통째로 안 된 것이었습니다. 다만 rAF 스로틀도 동시에 참 이었고, 하이드레이션을 고친 뒤에 그게 남아 있었습니다. 원인 하나를 찾았다고 다른 하나가 사라지지 않습니다.

자가진단 3가지

  1. 연출 코드가 시각을 스스로 셉니까, 받습니까? elapsed += delta 가 클로저 안에 있으면 그 연출은 사람 눈으로만 검수할 수 있습니다.
  2. 애니메이션 한 프레임을 URL 하나로 재현할 수 있습니까? 없다면 회귀 테스트도, 플랫폼 대조도, 썸네일 자동 선정도 전부 못 합니다.
  3. 캡처 도구가 탭을 포그라운드로 만든다고 가정하고 있습니까? 대부분 아닙니다. 빈 캔버스가 찍혔다면 코드보다 이걸 먼저 의심하세요.

솔직한 부분

저는 이걸 설계로 넣은 게 아니라 막혀서 넣었습니다. 연출을 다 만들고, 캡처가 빈 화면을 주고, 한참 코드를 의심한 다음에야 "볼 방법이 없다"는 게 문제라는 걸 알았습니다.

그리고 이 경로가 모든 걸 해결해주지도 않았습니다. 링크 미리보기에 쓰는 OG 카드는 캔버스를 못 돌립니다 — 이미지 생성기 안에는 브라우저 캔버스가 없습니다. 그래서 거기는 같은 카메라 계산을 재사용해 영역을 SVG path 로 다시 그렸습니다. 정지 프레임을 그대로 갖다 쓸 수 있을 줄 알았는데 아니었고, 대신 모양이 어긋나지 않도록 카메라를 공유하는 쪽으로 풀었습니다. 미리보기가 영상과 다른 모양을 보여주면 없느니만 못하니까요.

지금 당신 화면에서 움직이는 것 중에, 멈춰서 볼 수 없는 것 이 있습니까?

관련 글