모차르트 카탈로그 웹앱에는 "공연" 섹션이 있고, 실제 공연을 KOPIS에서 끌어와 채워 두었습니다. 이번엔 그 공연을 SNS에 올릴 세로 소개 영상으로 만들고 싶었습니다. 포스터·일시·장소·프로그램·출연진을 담고, 레퍼토리에 든 모차르트 작품을 배경음악으로 깔아, "모차르티아데 공연소식"이라는 하나의 브랜드로 감싸는 46초짜리 클립입니다.

결과보다 과정에 함정이 많았고, 마지막엔 "다음 공연은 명령 한 줄로"가 목표였습니다.

Revideo에서 Remotion으로

처음엔 Revideo로 시안 3종을 만들고, 포스터형과 프로그램 롤형을 조합한 시안까지 갔습니다. 방향은 잡혔지만, 이 프로젝트에는 이미 작품 쇼츠를 찍어내는 Remotion 파이프라인이 있었습니다. 다크+골드 톤, 세리프 조합, 블러 처리한 배경 자켓, 스프링 스태거 같은 디자인 시스템이 이미 코드로 존재하는데 별도 스택을 하나 더 이고 갈 이유가 없었습니다.

그래서 Remotion으로 재제작했습니다. TransitionSeries로 다섯 장면을 페이드로 잇는 구성입니다.

장면 길이 내용
Title 11.0s 로고 마스트헤드 + 포스터 카드 + 공연명
Info 9.0s 일시·장소·요금
Program 14.0s 작곡가 롤이 흐르다 모차르트에서 정지·점화
Artists 10.0s 출연진 2열 + 중앙 골드 세로선
Outro 5.0s 브랜드 로고 + 슬로건

시작 마스트헤드와 아웃트로가 브랜드로 클립을 감싸고, Program 장면에서 여러 작곡가 이름이 흐르다 모차르트에서 멈추며 은은히 밝아지는 게 클라이맥스입니다. 그리고 정리 작업 하나 — 예전 Revideo 흔적과 루트에 흩어져 있던 렌더 산출물을 걷어내고, 영상은 전부 remotion/out/ 아래로 모았습니다.

데이터 하나만 바꾸면 되는 구조

핵심 설계 원칙은 "장면 코드는 건드리지 않는다"였습니다. 공연별로 달라지는 값은 전부 concert-data.json 한 파일에 모았습니다.

{
  "posterFile": "concert/poster.jpg",
  "titleKr": "무지커 영 아티스트 콘서트 · 부천",
  "dateLine": "2026. 7. 19. (일) · 오후 5시",
  "venue": "부천아트센터 소공연장",
  "composers": ["Beethoven", "…", "Liszt", "MOZART"],
  "bgmWork": "Piano Sonata No.18 in D major, K.576",
  "performers": ["…"],
  "outputName": "20260719-무지커영아티스트콘서트-부천"
}

새 공연은 이 JSON과 에셋(포스터·음원)만 갈아끼우면 됩니다. composers 배열의 맨 끝을 항상 모차르트로 두는 규칙 하나로 Program 장면의 정지 위치가 결정되고, outputName은 최종 파일명이 됩니다(날짜를 앞세운 YYYYMMDD-… 형식).

오디오는 컴포지션에 넣지 않습니다. 무음으로 렌더한 뒤 ffmpeg로 후결합하는데, 비디오는 무손실 복사하고 페이드 인/아웃만 오디오에 겁니다. 이러면 톤을 바꿔도 영상은 재인코딩 없이 음원만 다시 붙일 수 있습니다.

함정 1 — 전환 순간 로고에 생기는 검정 사각

마스트헤드를 텍스트 워드마크에서 브랜드 로고 마크로 바꾸면서 문제가 생겼습니다. 로고 원본이 검정 배경 위 흰색 라인아트라, 처음엔 mixBlendMode: "screen"으로 검정을 날렸습니다. 정지 화면에서는 멀쩡했습니다.

그런데 다음 장면으로 넘어가는 전환 순간에 로고 배경이 검게 변했습니다.

// 검정 배경 webm + screen 블렌드 — 정지 화면은 OK, 전환 fade에서 검정 사각이 남는다
<OffthreadVideo src={staticFile("logo.webm")} style={{ mixBlendMode: "screen" }} />

원인은 스택 컨텍스트였습니다. screen 블렌드는 "뒤에 실제로 깔린 배경"과 실시간으로 섞여야 검정이 사라집니다. 그런데 장면 전환 fade가 상위 요소에 opacity < 1을 걸면, 그 요소가 독립된 합성 그룹으로 격리되면서 뒤 배경과의 블렌드가 끊깁니다. 그 순간 로고의 검정 배경이 그대로 드러난 것입니다.

해결은 블렌드를 버리고 진짜 알파를 쓰는 것이었습니다. 마침 같은 로고의 알파 투명 PNG가 있어서, 블렌드 없이 Img로 얹었습니다.

// 알파 PNG — 픽셀에 투명도가 박혀 있어 opacity/전환과 무관하게 유지된다
<Img src={staticFile("logo.png")} style={{ objectFit: "contain" }} />

알파는 합성 격리의 영향을 받지 않으니, 덤으로 진입 스프링 애니메이션까지 안전하게 되살릴 수 있었습니다. 블렌드 트릭은 배경이 단색인 곳(아웃트로)에서만 안전하고, 배경이 비치는 곳에서는 알파가 정답이었습니다.

함정 2 — node -p가 ffmpeg 인자를 깨뜨리다

음원을 결합하는 셸 스크립트는 데이터 파일에서 값 몇 개를 읽어 ffmpeg에 넘깁니다. 이게 어느 순간 정체불명의 종료 코드로 실패하기 시작했습니다. 추적해 보니 시작 지점 값이 이렇게 들어가 있었습니다.

-ss '\e[33m0\e[39m'

node -p가 결과를 util.inspect로 포매팅하면서 숫자 0에 ANSI 색상 코드를 입혀 버린 것이었습니다. 셸이 파이프로 값을 받는데도 환경에 따라 색상이 강제되면 이런 일이 생깁니다. 그 오염된 문자열이 그대로 ffmpeg의 -ss 인자로 들어가 깨졌습니다.

고치는 법은 포매팅을 아예 거치지 않는 것이었습니다.

# node -p 는 util.inspect 로 ANSI 색상을 입힐 수 있어 인자가 깨진다
# node -e 로 순수 문자열만 출력한다
val() { node -e "const d=require('./$DATA');process.stdout.write(String(($1) ?? ''))"; }
AUDIO_START=$(val 'd.audioStart || 0')

셸 변수로 받아 다른 명령의 인자로 넘길 값이라면, 포매팅되는 node -p보다 원시 문자열을 찍는 node -e가 안전합니다. 결합 스크립트 끝에는 최종본이 정상 생성됐을 때만 무음 중간본을 지우는 정리도 넣어, 산출물 폴더에는 최종본만 남게 했습니다.

지금 재생 중인 곡, 하단에 고정하기

배경음악이 흐르는데 무슨 곡인지 화면에 없는 게 아쉬웠습니다. 그래서 하단에 "지금 재생 중" 정보를 고정 노출하기로 했습니다. NOW PLAYING 라벨과 작곡가·작품명, 그리고 재생 애니메이션까지.

두 가지를 정했습니다. 첫째, 이 바는 장면이 바뀌어도 계속 보여야 하니 각 장면 안이 아니라 컴포지션 최상단에 오버레이로 얹습니다. 장면 전환 fade의 영향을 받지 않고 지속됩니다. 둘째, 아웃트로가 시작되기 직전에는 페이드아웃시켜 브랜드 마무리를 방해하지 않게 했습니다.

재생 애니메이션인 이퀄라이저에는 결이 하나 있습니다. Remotion은 프레임을 결정론적으로 렌더하기 때문에, 매 프레임 다시 그려지는 Math.random은 쓰면 안 됩니다(막대가 프레임마다 튀어 깜빡입니다). 대신 프레임 값을 넣은 사인파로 구동해야 합니다.

처음엔 단일 사인파였는데 움직임이 규칙적이라 티가 났고, "너무 정신없다"는 피드백도 받았습니다. 그래서 두 가지를 손봤습니다. 막대를 바닥에 고정해 윗부분만 오르내리게 하고(중앙 확장은 산만합니다), 주기 비율이 무리수에 가까운 두 사인파를 합성해 좀처럼 반복되지 않는 불규칙한 움직임을 만들었습니다.

// 무리수 비율의 두 사인파 합성 → 잘 반복되지 않는(불규칙) 움직임
// 막대는 바닥 고정(alignSelf: flex-end), 윗부분만 움직인다
const s1 = Math.sin(frame * speed + phase);
const s2 = Math.sin(frame * speed * 1.73 + phase * 2.1);
const level = 0.5 + 0.34 * s1 + 0.16 * s2; // 0~1

막대마다 속도와 위상을 벌려 서로 어긋나게 하면, "그냥 재생되고 있다" 정도의 절제된 존재감이 나옵니다.

제목만으로 부르기

여기까지 오면 다음 공연이 문제입니다. 조회 스크립트는 공연 ID로만 동작했는데, ID를 매번 찾아 넣는 건 번거롭습니다. 그래서 제목으로도 부를 수 있게 했습니다.

ID 조회는 유니크 키라 간단하지만, 제목은 유니크가 아닙니다. 부분일치로 후보를 모으고, 여러 건이면 가장 최근 공연을 택하되 후보 목록을 보여줘 다른 공연은 ID로 오버라이드하게 했습니다.

const matches = await prisma.concert.findMany({
  where: { title: { contains: titleQuery, mode: "insensitive" } },
  orderBy: { startDate: "desc" },
  select: { id: true, title: true, startDate: true },
});

조회는 별도의 중개 서버 없이 DB에 직접 붙습니다. 공연 정보는 작품·악장을 다루는 기존 조회 창구의 범위 밖이라, 이 스크립트가 유일한 출처입니다.

스킬로 남기기

마지막은 이 모든 흐름을 재사용 가능한 형태로 남기는 것이었습니다. 조회 → 데이터 작성 → 음원 추출 → 렌더 → 프레임 검증 → 음원 결합의 여섯 단계와, 위에서 밟은 함정들(알파 로고, 결정론적 이퀄라이저, 중간본 정리)을 문서로 굳혀 하나의 스킬로 만들었습니다. 작품 쇼츠 스킬도 이름을 정리해 역할이 드러나게 했습니다.

이제 다음 공연은 이렇게 시작됩니다.

공연소식 영상 만들어줘. 제목 "무지커 영 아티스트"

영상 제작이 코드에서 명령 한 줄로 내려온 셈입니다. 시안을 고르던 일이 데이터 파일 한 장과 문장 하나로 수렴하기까지, 정작 시간을 먹은 건 화면에 잠깐 스치는 검정 사각과 색상 코드 몇 바이트였습니다. 영상 작업의 함정은 늘 그 잠깐에 숨어 있었습니다.