자전거 경로를 타기 전에 미리 볼 수 있으면 좋겠다고 생각한 적 있으신가요? Party Onbici의 모든 파티 경로에 대해 아름다운 상공 비행 영상을 생성하는 자동화 시스템을 구축하여, 라이더들에게 여정에서 기대할 수 있는 것의 시네마틱한 미리보기를 제공합니다.

도전 과제

사이클링 이벤트를 조직하거나 참여할 때 경로를 이해하는 것은 매우 중요합니다. 정적 지도는 유용하지만 실제로 경로를 달리는 경험을 전달하지는 못합니다. 라이드에 참여하기 전에 경로를 가상으로 “비행"할 수 있는 방법을 사용자들에게 제공하고 싶었습니다.

우리의 솔루션: 헤드리스 영상 렌더링

Puppeteer를 사용하여 헤드리스 Chrome 브라우저를 실행하고, 대화형 MapLibre GL 지도에서 경로를 렌더링하며, 가상 카메라가 경로를 따라 비행하는 동안 프레임을 캡처하는 Node.js 서비스를 구축했습니다. 작동 방식은 다음과 같습니다:

1. 경로 보간

자전거 경로에는 수천 개의 좌표 점이 있을 수 있습니다. 15초 동안 30FPS의 부드러운 영상을 만들려면 정확히 450프레임이 필요합니다. 거리 기반 보간을 사용하여 경로를 균등하게 샘플링합니다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Calculate cumulative distances using Haversine formula
const distances = [0];
for (let i = 1; i < coordinates.length; i++) {
  const dist = haversineDistance(coordinates[i-1], coordinates[i]);
  totalDistance += dist;
  distances.push(totalDistance);
}

// Sample at exactly totalFrames points
for (let frame = 0; frame < totalFrames; frame++) {
  const targetDist = (frame / (totalFrames - 1)) * totalDistance;
  // Interpolate position at this distance...
}

2. 부드러운 방위각을 활용한 카메라 애니메이션

단순히 이동 방향으로 카메라를 향하면 굽은 경로에서 끊김 현상이 발생합니다. 360도/0도 전환에 대한 특별 처리와 함께 12프레임에 걸친 이동 평균을 사용하여 카메라 방위각을 부드럽게 합니다:

1
2
3
4
5
6
7
// Use sin/cos components for circular averaging
for (let j = i - smoothWindow; j <= i + smoothWindow; j++) {
  const rad = rawBearings[j] * Math.PI / 180;
  sinSum += Math.sin(rad);
  cosSum += Math.cos(rad);
}
const avgBearing = Math.atan2(sinSum/count, cosSum/count) * 180 / Math.PI;

3. MapLibre GL 렌더링

경로는 아름다운 Stadia Maps 기본 레이어 위에 다음과 함께 렌더링됩니다:

  • 가시성을 위한 흰색 외곽선이 있는 강조된 경로 선
  • 애니메이션 카메라 위치 표시기
  • 출발(녹색) 및 도착(빨간색) 마커
  • 도시 지역을 위한 선택적 3D 건물 돌출

4. 프레임 캡처 및 영상 인코딩

Puppeteer가 각 프레임을 PNG 스크린샷으로 캡처한 다음, ffmpeg를 사용하여 VP9 코덱으로 WebM 영상으로 인코딩합니다:

1
2
3
ffmpeg -framerate 30 -i frame_%05d.png \
  -c:v libvpx-vp9 -crf 20 -b:v 0 \
  route.webm

기술 아키텍처

1
Django App → Celery Task → Node.js Renderer → S3 Storage
  1. Django가 영상 생성을 트리거: 파티가 생성되거나 업데이트될 때
  2. Celery가 작업을 대기열에 추가: 비동기 처리를 위해
  3. Node.js 렌더러: MapLibre GL과 함께 Puppeteer 실행
  4. ffmpeg가 인코딩: 캡처된 프레임을
  5. 영상 업로드: S3에, 파티 레코드 업데이트

성능 고려사항

해상도길이렌더링 시간파일 크기
1280x72015초60-90초2-4 MB
1920x108015초90-120초4-8 MB

프로덕션에서는 GPU 의존성을 피하기 위해 소프트웨어 렌더링(SwiftShader)이 적용된 Docker 컨테이너를 사용합니다:

1
2
--use-angle=swiftshader
--enable-unsafe-swiftshader

추가한 기능들

  • 사용자 정의 영상 크기 - 소셜 미디어 공유를 위한 정사각형 포맷
  • 로고 워터마크 - 조직의 로고로 영상 브랜딩
  • 카메라 기울기 및 줌 - 시야각과 고도 조정
  • 경로 선 스타일링 - 사용자 정의 색상 및 너비

Django 통합

애플리케이션 코드에서 영상 생성은 다음과 같이 간단합니다:

1
2
party = Party.objects.get(uid="...")
party.generate_video(width=1080, height=1080, duration=10)

렌더링이 완료되면 영상 URL이 자동으로 업데이트됩니다:

1
2
3
4
5
{% if party.video_url %}
<video controls>
  <source src="{{ party.video_full_url }}" type="video/webm">
</video>
{% endif %}

다음 단계

여러 개선 사항을 탐색하고 있습니다:

  • 실시간 미리보기 - 사용자가 경로를 그릴 때 라이브 렌더링
  • 다중 카메라 앵글 - 측면 뷰, 오버헤드 샷
  • 날씨 오버레이 - 경로를 따라 예보 조건 표시
  • 고도 프로필 - 영상에서 오르막과 내리막 시각화

경로 상공 비행 영상은 가장 인기 있는 기능 중 하나가 되었으며, 사이클리스트들이 어떤 라이드에 참여할지 정보에 기반한 결정을 내리는 데 도움을 주고 있습니다. 현대 웹 기술의 조합은 비용이 많이 드는 제작 작업이었을 것을 자동화된 온디맨드 서비스로 전환시켰습니다.


사용해 보시겠어요? 파티를 만들면 경로 영상이 자동으로 생성됩니다!