
AI를 사용하여 격투 영상에 임팩트 효과 추가하기
요약
Google Gemini와 FFmpeg를 활용하여 격투 영상의 타격 순간을 자동으로 감지하고 임팩트 효과를 추가하는 AI 파이프라인 구축 방법을 소개합니다. Go 언어와 육각형 아키텍처를 사용하여 확장 가능하고 테스트 가능한 비디오 처리 시스템을 구현했습니다.
핵심 포인트
- Google Gemini를 사용하여 영상 내 타격 지점 자동 탐지
- FFmpeg를 활용한 프레임 추출, 레이블링 및 효과 적용 자동화
- Go 언어와 육각형 아키텍처를 적용한 깔끔한 시스템 설계
- 수동 편집의 번거로움을 API 호출 한 번으로 해결하는 워크플로우
목표: 격투 영상(애니메이션, 복싱, UFC 등 무엇이든)을 가져와 누군가 타격을 입는 정확한 순간에 극적인 흑백 플래시 효과를 자동으로 추가하는 것입니다. TikTok의 애니메이션 편집 영상이나 격투 컴필레이션에서 볼 수 있는 바로 그 효과 말이죠. 펀치가 꽂히는 바로 그 찰나에 프레임이 고대비(high contrast)로 바뀌고 반전(inverted)되는 그런 효과입니다.
아이디어는 간단합니다. 영상을 업로드하면, AI가 타격 지점을 찾아내고, 효과를 적용한 뒤, 결과 영상을 돌려받는 방식입니다.
제가 이를 어떻게 구축했는지 소개합니다.
문제점 (The Problem)
비디오 편집기에서 이를 수동으로 작업하는 것은 매우 지루한 일입니다. 영상을 프레임 단위로 훑으며 모든 타격 순간을 찾아내고, 효과를 추가하고, 타이밍을 조절하고, 이 과정을 반복해야 합니다. 5번의 타격이 있는 30초짜리 클립 하나를 처리하는 데 15분이 걸릴 수도 있습니다. 영상이 길어질수록 상황은 더 악화됩니다.
저는 이 모든 과정을 처리할 수 있는 단 한 번의 API 호출을 원했습니다.
기술 스택 (The Stack)
- Go: HTTP 서버를 위한 Gin 사용
- FFmpeg: 모든 비디오 처리(프레임 추출, 레이블링, 그리드 생성, 효과 적용)를 위해 사용
- Google Gemini (genai SDK를 통해): 프레임을 분석하고 타격 순간을 찾기 위해 사용
- PostgreSQL: 데이터베이스 레이어를 위해 sqlc와 함께 사용
이 프로젝트는 육각형 아키텍처(hexagonal architecture)를 따릅니다. 포트(Ports), 어댑터(adapters), 도메인(domain), 서비스(services)로 구성되어 코드를 깔끔하고 테스트 가능하게 유지합니다.
1단계: 프레임 추출 및 레이블링 (Step 1: Extract and Label Frames)
비디오 업로드를 받은 후 파이프라인이 가장 먼저 수행하는 작업은 초당 10프레임(10 fps)의 속도로 프레임을 추출하는 것입니다. 각 프레임의 왼쪽 상단에는 식별 가능한 숫자가 삽입됩니다. 이 숫자는 나중에 AI가 특정 프레임을 참조하는 용도로 사용됩니다.
func ExtractAndLabelFrames(inputPath, outputDir string, sampleRate float64) (*ExtractedFrames, error) {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create output dir: %w", err)
...
여기서 사용된 drawtext 필터는 %{eif\:n+1\:d}를 사용하여 프레임 번호를 정수 (integer)로 입힙니다. 처음에는 %{expr\:n+1}을 사용했지만, 그러면 단순히 1이 아닌 1.000000과 같은 숫자가 나왔습니다. eif 변형은 이를 십진 정수로 포맷팅합니다.
10fps의 30초 영상의 경우, 이렇게 하면 300개의 레이블이 붙은 프레임이 생성됩니다.
2단계: 프레임을 그리드(Grid)로 배열하기
300개의 개별 이미지를 Gemini에 보내는 것은 비용이 많이 들고 속도가 느릴 것입니다. 대신, 저는 프레임을 5x4 그리드로 타일링 (tiling)합니다. 즉, 그리드 이미지 하나당 20개의 프레임이 들어갑니다. 이제 30초 분량의 영상은 300개의 개별 프레임 대신 약 15개의 그리드 이미지가 됩니다.
const (
gridCols = 5
gridRows = 4
...
각 그리드는 FFmpeg의 concat demuxer와 tile 필터를 사용하여 구축됩니다:
func createSingleGrid(framePaths []string, outputPath string) error {
// ffmpeg가 프레임을 시퀀스로 읽을 수 있도록 concat 리스트를 작성합니다
listPath := outputPath + ".txt"
...
마지막 배치의 프레임이 20개보다 적은 경우, tile 필터가 빈 셀을 검은색으로 채웁니다. 별도의 특별한 처리는 필요하지 않습니다.
3단계: Gemini가 임팩트를 찾도록 하기
그리드 이미지는 File API를 통해 Gemini로 업로드됩니다. 그런 다음 모델에게 무엇을 찾아야 하고 무엇을 무시해야 하는지 정확하게 알려주는 프롬프트 (prompt)를 보냅니다.
시스템 프롬프트 (system prompt)는 매우 구체적입니다:
핵심 액션 모먼트 (key action moment)는 두 가지 요소가 상당한 물리적 접촉을 일으킬 때입니다:
- 캐릭터의 움직임이 다른 캐릭터나 물체와 연결될 때
...
그리고 무엇을 건너뛰어야 하는지에 대해서도 똑같이 구체적입니다:
- 접촉이 없는 움직임 (달리기, 점프, 비행)
- 접촉이 발생하기 전의 준비 동작 또는 예비 동작 (wind-up)
- 캐릭터가 서 있거나, 포즈를 취하거나, 대화하거나, 반응하는 장면
...
모델은 다음과 같은 간단한 JSON 응답을 반환합니다:
{ "impacts": [12, 45, 78, 132] }
오직 프레임 번호만 포함합니다. 그 외의 것은 없습니다. 프롬프트는 명시적으로 가장 좋은 순간을 2개에서 5개 사이로만 선택하라고 지시합니다. 양보다 질이 중요합니다.
API에 과부하를 주는 것을 방지하기 위해, 그리드(grids)는 버퍼 채널 (buffered channel)과 함께 동시에 업로드됩니다:
const maxConcurrent = 5
sem := make(chan struct{}, maxConcurrent)
ch := make(chan result, len(files))
...
4단계: 효과 적용하기
이 부분이 흥미로운 지점입니다. 기존 방식은 비디오에서 10fps로 프레임을 다시 추출하고, 임팩트 프레임에 개별적으로 효과를 적용한 다음, 모든 것을 다시 하나로 꿰매는 방식이었습니다. 이 방식도 작동은 했지만, 원본 프레임 레이트 (framerate)가 무엇이든 관계없이 10fps로 비디오를 재구성하기 때문에 결과물이 끊겨 보였습니다 (choppy).
해결책: 프레임 추출 과정을 완전히 건너뜁니다. 임팩트 프레임 번호를 타임스탬프 (timestamps)로 변환하고, 원본 비디오에 직접 효과 필터 (effect filters)를 적용합니다. FFmpeg에는 시간에 따라 필터를 켜고 끌 수 있는 enable 파라미터가 있습니다.
func BuildImpactVideo(inputPath, outputDir string, impactFrames []int, sampleRate float64, meta *VideoMetadata) (string, error) {
// 프레임 번호를 시간 범위로 변환
type timeWindow struct {
...
enable='between(t,4.000,4.200)' 부분은 FFmpeg에게 4.0초에서 4.2초 사이에서만 해당 필터를 적용하도록 지시합니다. 비디오의 나머지 부분은 손상되지 않은 채 그대로 통과합니다. 이는 결과물이 원본 비디오의 네이티브 프레임 레이트 (native framerate)를 유지함을 의미합니다. 입력이 60fps라면 출력도 60fps입니다. 끊김 현상이 없습니다.
효과 자체는 세 가지 필터의 체인 (chain)으로 구성됩니다:
-
hue=s=0: 모든 색상을 제거합니다 (완전한 채도 저하 (desaturation)) -
eq=contrast=3.5:brightness=0.9: 대비 (contrast)를 대폭 높입니다 -
curves: 강렬한 느낌을 위해 커스텀 톤 커브 (tone curve)를 적용합니다
전체 파이프라인 (Full Pipeline)
모든 것을 종합하면, 핸들러(handler)는 단 한 번의 요청으로 모든 과정을 조율합니다:
func (h *Handler) ExtractFrames(c *gin.Context) {
// 1. 비디오 업로드 수신 (최대 1GB)
file, err := c.FormFile("video")
...
비디오를 업로드하면 다운로드 URL이 반환됩니다. 응답에는 어떤 프레임 번호가 감지되었는지와 Gemini API 호출 비용이 포함됩니다.
예시 출력 (Example Output)
전 (Before, 원본):
후 (After, 임팩트 효과 적용):
직면했던 문제들 (Things I Ran Into)
Windows에서의 FFmpeg 이스케이프 (escaping). 필터 문자열에 중첩된 작은따옴표(enable='...'와 curves=all='...' 모두 사용됨)가 포함되어 있습니다. Windows에서는 명령줄 인수 파싱(command line argument parsing) 과정에서 이 따옴표들이 손상되었습니다. 결국 필터를 파일로 작성한 뒤 -filter_script:v를 사용하여 로드하는 방식으로 해결했으며, 이를 통해 모든 이스케이프 문제를 우회할 수 있었습니다.
라벨 포맷팅 (Label formatting). FFmpeg의 drawtext 표현식인 %{expr\:n+1}은 1.000000과 같은 결과를 출력합니다. 정수 포맷팅을 위해서는 %{eif\:n+1\:d}를 사용해야 합니다.
그리드 렌더링 (Grid rendering). 처음에는 프레임을 그리드로 배치하기 위해 FFmpeg의 xstack 필터를 사용하려고 시도했습니다. 하지만 이는 신뢰할 수 없었습니다. concat 디먹서(demuxer)와 tile 필터를 사용하는 것이 훨씬 더 간단했습니다.
Gemini 콘텐츠 필터링 (Gemini content filtering). Google의 안전 필터(safety filters)는 모든 안전 설정을 꺼두더라도 인프라 수준에서 격투/전투 콘텐츠를 차단할 수 있습니다. 이는 API를 통해 재정의할 수 있는 것이 아니라 정책 수준의 문제입니다. 액션 콘텐츠를 다룬다면 알아둘 가치가 있습니다.
Thinking 설정 호환성 (Thinking config compatibility). gemini-2.5-pro는 ThinkingConfig를 지원하지 않습니다. 오직 프리뷰(preview) 변체 모델들만 지원합니다. 프리뷰 모델이 아닌 모델에 ThinkingLevel: ThinkingLevelHigh를 설정하면 400 에러가 발생합니다.
직접 시도해보기 (Try It Out)
전체 소스 코드는 GitHub에서 확인할 수 있습니다: github.com/umohsamuel/impact
로컬에서 실행하려면 Go, FFmpeg, 그리고 Google Gemini API 키가 필요합니다.
git clone https://github.com/umohsamuel/impact.git
cd impact
go mod download
...
그 다음, 엔드포인트(endpoint)를 호출하세요:
curl -X POST http://localhost:5000/api/v1/generate-impact-frames \
-F "video=@your-video.mp4"
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기