빌드 로그: 사진 한 장을 약 13초짜리 레이싱 밈 비디오로 만드는 과정 (Cloudflare Workers 활용)
요약
본 글은 사진 한 장을 13초짜리 레이싱 밈 비디오로 변환하는 웹 서비스를 구축한 과정을 다룹니다. Cloudflare Workers와 D1, R2를 활용하여 전체 파이프라인을 구현했으며, 결제 및 작업 관리의 복잡성을 해결하는 데 초점을 맞추었습니다.
핵심 포인트
- Cloudflare Worker 내에서 모든 기능을 통합적으로 실행했습니다.
- D1과 R2를 사용하여 사용자 계정, 크레딧, 미디어 저장을 처리합니다.
- 결제 전 사진 검증을 통해 비용 낭비를 방지하는 것이 핵심입니다.
요즘 짧은 영상 앱에서 특정 레이싱 클립이 유행하고 있습니다. 깊게 웅크린 사람이 터무니없는 속도로 고속도로를 질주하며 뒤쫓는 추격 장면이 나오는 것입니다. 사람들은 자신들의 얼굴을 이 장면에 넣고 싶어 했습니다. 저는 정확히 한 가지 기능만 하는 작은 사이트를 만들었습니다. 사용자가 사진 한 장을 업로드하면, 약 4분 후에 원본 배경 음악이 포함된 약 13초짜리 레이싱 밈 비디오를 얻게 됩니다.
이 게시물은 빌드 로그입니다. 파이프라인, 구현하기보다 어려웠던 부분들, 그리고 재실행 비용을 초래했던 실수들을 다룹니다. 벤치마크는 없으며, 데이터 포인트가 하나뿐일 때는 그렇게 명시합니다.
고지 사항: 제가 이것을 만들었습니다. MemeMotion은 비공식적이며 TikTok, 제작자 또는 Crazy Frog 권리 보유자와 관련이 없습니다. 모션 레퍼런스는 @wdmowski의 원본 게시물에서 가져왔습니다. 원본 클립의 권리는 그 제작자에게 있습니다.
제품의 형태 (The shape of the product)
코드를 작성하기 전에 스스로 설정한 제약 조건들:
- 하나의 템플릿, 하나의 버튼. 프롬프트 박스는 없습니다. 사용자는 사진을 선택하고 비용을 지불하며, 나머지 모든 것은 서버에서 고정됩니다.
- 구독이 아닌 비디오당 결제. 크레딧 하나 = 비디오 한 개. 패키지는 $7.99 / 1개, $14.99 / 2개, $27.99 / 4개로, 일회성으로 지불합니다.
- 생성 전에 Google Sign-in을 통해 작업(jobs), 크레딧, 다운로드 권한이 계정에 귀속되도록 합니다.
- 짧은 보존 기간. 완성된 비디오(및 원본 사진)는 작업 완료 후 7일 뒤에 삭제됩니다.
- 작동하지 않는 것에 대해서는 절대 비용을 청구하지 않습니다. 돈이 움직이기 전에 사진을 확인하고, 생성 실패 시 자동으로 환불합니다.
파이프라인 (The pipeline)
모든 것이 하나의 Cloudflare Worker 내에서 실행됩니다: 사용자, 작업 및 크레딧 원장(ledger)을 위해 D1을 사용하고; 사진, 레퍼런스 미디어 및 출력을 위해 R2를 사용하며; 작업을 위한 큐 프로듀서/컨슈머와 스윕 및 정리(cleanup)를 위한 두 개의 cron 트리거를 사용합니다.
단계 1: 청구 전에 사진 확인하기
모델은 명확한 얼굴 하나를 원합니다. 단체 사진이나 프로필 사진을 보내면 여전히 요금이 부과되고 결과물도 좋지 않습니다. 따라서 크레딧이나 예산이 사용되기 전에 가장 먼저 실행되는 것은 저렴한 검사입니다. 즉, 대략 정면의 얼굴이 정확히 하나여야 합니다.
Workers는 face-landmark 모델을 인프로세스(in-process)로 실행할 수 없기 때문에, 이 기능만을 위해 새로운 공급업체를 원하지 않았고, 그래서 이 검사는 이미 사진을 보내는 것과 동일한 제공업체의 작은 비전 모델을 사용합니다. 코드의 주석에 명확하게 나와 있습니다:
/**
* 사전 충전 사진 검사: 대략 정면의 얼굴이 정확히 하나여야 합니다.
* ...
...
실제 작업에서 중요했던 두 가지 세부 사항이 있었습니다:
- 아주 작은 얼굴 박스는 보통 포스터나 배경에 있는 얼굴들이므로, 프레임 대비 1.5% 미만의 박스는 무시됩니다.
- 검사 서비스 자체에 오류가 발생하면 요청은 **안전하게 실패(fails closed)**합니다. 즉, 503 에러만 반환되고 요금은 부과되지 않습니다. 조용히 통과시키면 실패할 가능성이 높은 작업에 대해 비용을 청구하게 됩니다.
2단계: 레퍼런스 클립이 제품이다
이것은 비디오-투-비디오(video-to-video) 작업입니다. 고정된 움직임의 레퍼런스와 사용자의 사진이 결합됩니다. 레퍼런스를 제대로 확보하는 것이 가장 많은 노력이 필요한 부분이었습니다.
레퍼런스는 R2에 비공개로 저장되며, 단명한 서명 링크(short-lived signed link)를 통해 모델에 전달됩니다. 이 레퍼런스는 사이트에서 공개적인 <video> 소스로 절대 사용되지 않으며, 페이지에는 가상의 인물로 만든 결과물만 표시됩니다. 해당 페이지는 원본 게시물을 출처로 명시합니다.
저는 프로토타이핑 과정에서 여러 모델을 거쳤고(코드에는 세 가지 공급업체에 대한 스위치가 여전히 있습니다), 기본값은 이제 레퍼런스의 카메라 움직임, 타이밍, 포즈를 유지하면서 사진 속 인물로 교체하는 리캐스트 스타일의 모델입니다. 이 모델은 출력 초당으로 요금이 부과되며, 출력 길이는 레퍼런스 길이와 같고, 이는 프로젝트에서 가장 유용한 버그로 이어집니다.
정체성을 무너뜨린 근접 촬영(close-pass shot)
처음 생성된 결과물들은 클립의 대부분 동안은 적절해 보였지만, 마지막 1~2초에 이르러 얼굴이 업로드된 사진에서 벗어나 템플릿 영상 속 남성 쪽으로 다시 표류했습니다.
원인(문제의 발생 이유)은 레퍼런스 영상의 끝부분에 있었다: 카메라가 빠르게 근접 촬영하면서 심한 모션 블러가 걸린 장면이었다. 이 샷에서는 모델이 새로운 얼굴을 고정할 만한 선명한 부분이 거의 없었기 때문에, 참조 영상에서 볼 수 있는 인물에게 의존하게 되었다.
수정 작업에는 유료 재실행(paid re-runs)이 필요하지 않았다. 해당 샷을 제거하기 위해 레퍼런스를 자르고(trim) 프롬프트를 강화하여 사진 속 인물이 카메라의 근접 촬영을 포함한 '모든 샷'에서 라이더로 유지되도록 했다.
부수적인 효과가 있었다: 청구 금액이 출력 길이에 따라 결정되기 때문에, 레퍼런스를 짧게 자르자 모든 클립의 비용도 저렴해졌다. 또한 이 때문에 사이트에서 15초 대신 "약 13초"라고 표시하는 것이다.
단계 3: "모델이 다른 길이로 반환하는 것"에 대한 방지책
파이프라인은 출력 길이가 레퍼런스 길이와 같다고 가정하기 때문에, 실행기(runner)가 이를 확인한다. 허용 오차 범위는 의도적으로 작게 설정되어 있다:
export function outputDurationMatches(inputSeconds: number, outputSeconds: number): boolean {
if (!Number.isFinite(inputSeconds) || !Number.isFinite(outputSeconds)) return false;
if (inputSeconds <= 0 || outputSeconds <= 0) return false;
...
동일한 모듈은 필요할 때 MP4 타이밍 테이블(stts)을 상수 프레임 속도로 재작성하며, "작은 인코더 드리프트를 허용하고 짧아지거나 길어진 클립을 거부하는" 테스트가 있다. 1초가 부족한 클립은 다음 단계에서 음악 싱크를 깨뜨릴 수 있으므로, 여기서 잡아내는 것이 더 좋다.
단계 4: 오디오 제거 후 자체 오디오 추가
어떤 오디오가 돌아오든 전송되어서는 안 된다. 레퍼런스는 준비될 때 오디오가 제거되었지만, 그것만으로는 신뢰할 수 없기 때문에, 결과물에서 오디오 트랙을 제거하고 고아된 샘플 바이트를 0으로 만드는 작은 MP4 박스 워커(box walker)가 있다:
/**
* Drop audio tracks from a non-fragmented MP4 and zero the orphaned sample bytes.
* Reference-clip audio must not ship with the result. If the file is not an MP4
...
음악은 사이트를 위해 생성된 오리지널 백킹 트랙이며, 기존 노래가 아닙니다. R2에 비공개로 저장되어 호스팅되는 ffmpeg 병합 엔드포인트와 합쳐집니다. 모든 필터 작업을 제거한 요령은 다음과 같습니다: 트랙을 13.0초 길이로 자르고 0.5초 페이드아웃을 적용하여 12.8초에서 끝내는데, 이는 클립 길이와 정확히 일치합니다. 병합 과정에서 음악이 페이드 도중에 잘리는 일은 없습니다.
Muxing(병합)은 최선의 노력을 기울인 결과입니다. 실패하더라도 작업은 무음 버전으로 성공합니다. 사용자는 둘 다 받습니다: 기본적으로는 음악 버전이고, 일부 플랫폼에서는 앱 내에 사운드를 추가하도록 요구하기 때문에 무음 복사본도 받게 됩니다.
작업 페이지입니다. 스크린샷을 위해 업로드된 사진은 흐릿하게 처리되었습니다.
5단계: 결제, 대기열 및 환불
이것은 유료 생성기를 만들 사람이라면 누구나 가장 먼저 설계해야 할 부분입니다.
사용자당 하나의 활성 작업. D1의 활성 작업에 대한 고유 인덱스가 이를 강제합니다. 만약 사용자가 작업 중인 동안 다른 비디오를 위해 결제하면, 이는 waiting_slot으로 청구되고 보류된 다음 (대기열 소비자 후크) 또는 cron 스윕에 의해 시작됩니다. 만약 절대 시작될 수 없다면, 크레딧은 한 번 환불됩니다.
사이트 전체 일일 지출 상한선. 생성 비용은 출력 초당 실제 돈이 들기 때문에, 제공업체 지출에 대한 일일 상한선과 사용자별 일일 제한이 있습니다. 상한선에 도달하면 새로운 작업은 크레딧 청구 및 예산 예약 없이 waiting_budget으로 보류되며, 예산이 확보되면 cron이 가장 오래된 순서대로 보류된 작업을 승격시킵니다. 지출이 임계값을 초과할 때마다 관리자에게 알림이 전송됩니다.
실패는 맹목적으로 재시도되지 않고 분류됩니다.
제공자(provider)로부터의 검열 거부(moderation rejection)는 최종적입니다: 재제출되지 않으며, 크레딧이 환불되고 사용자에게 단순 메시지가 전송됩니다.
다른 실패 건은 최대 한 번 자동 재실행되며, 제공자가 이를 중복 처리하지 못하도록 새로운 Idempotency Key가 사용됩니다. 그런 다음 환불됩니다.
환불은 작업(job)별로 중복 제거되므로, 재시도 폭주(retry storm)가 두 번 환불할 수는 없습니다.
절대 실수로 두 번 제출하지 마세요. 제출 시 네트워크 오류가 발생했다고 해서 작업이 생성되지 않은 것은 아닙니다. 두 번째 제출을 하기 전에 러너(runner)는 해당 작업에 대한 기존 요청을 조회하며, 실패한 상태 확인은 절대 제출을 유발하지 않습니다.
6단계: 전송, 공유 및 7일 규칙
작업이 완료되면 사용자에게 '준비됨(ready)' 이메일(또는 '실패함(failed)' 이메일) 하나가 도착하고 비디오는 계정에 저장됩니다.
인사말의 이름은 흐릿하게 처리되었습니다.
공유 패널은 비디오가 아닌 사이트로 연결됩니다. 비디오는 사용자가 직접 파일을 게시할 때까지 비공개로 유지되며, 모바일에서는 TikTok/Instagram이 다운로드 대체 기능을 가진 네이티브 공유 시트를 사용합니다.

보존 기간은 정책 텍스트가 아닌 코드로 강제됩니다: 작업이 완료되면 expires_at = now + 7 days가 설정되고, 정리(cleanup) 작업이 R2에서 출력물과 원본 사진 모두를 삭제합니다. 실패했거나 완료되지 않은 작업은 생성 후 7일 후에 정리됩니다.
제가 다르게 할 것들
- 프롬프트 튜닝 전에 레퍼런스(reference)를 장면별로 확인하세요. 가장 큰 품질 개선점은 레퍼런스에서 한 장면을 제거한 것인데, 비용이 들지 않았습니다.
- 출력 길이를 처음부터 서버 상수(server constant)로 만드세요. 제 카피는 오랫동안 '15초'라고 되어 있어서, 레퍼런스가 변경될 때마다 모든 곳에서 수정해야 했습니다.
- 행복한 경로(happy path)보다 환불 경로를 먼저 작성하세요. 주차(Parking), 한 번의 재실행(rerun), 그리고 작업별 중복 제거(per-job dedupe)는 패치(patches)로 처리하는 것보다 상태 기계(state machine)로 생각하는 것이 훨씬 이해하기 쉬웠습니다.
- 단일 사실 출처를 유지하세요. 가격은 Stripe와 UI가 모두 읽는 하나의 JSON 카탈로그에 존재하므로, 카피와 결제 과정이 어긋나지 않게 할 수 있습니다.
제가 실제로 자신 있게 말할 수 있는 수치들
- 출력: 약 13초 (레퍼런스는 12.80초입니다).
- 완료 시간: 약 4분. 이는 평균이 아닌, 첫 번째 라이브 작업(live job)을 기준으로 한 단일 데이터 포인트입니다.
- 보존 기간: 7일
이상입니다. 만약 비디오-투-비디오 모델에서 아이덴티티 드리프트(identity drift)와 싸워본 경험이 있다면, 댓글로 어떤 방법이 효과가 있었는지 듣고 싶습니다.
직접 사용해보고 싶다면: MemeMotion. MemeMotion은 비공식적이며 TikTok, 제작자 또는 Crazy Frog 권리 보유자와 제휴하지 않습니다. 모션 레퍼런스는 @wdmowski의 원본 게시물에서 가져왔습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기

