다중 비디오 레이아웃의 늪: 혼돈 속에서 질서를 찾아서
다중 비디오 레이아웃의 늪: 혼돈 속에서 질서를 찾아서
단일 비디오도 힘들었는데 이제 여러 개를?
2025-06-30-surviving-from-remotion-video-framing-hell
이전 포스팅에서 OffthreadVideo와 Sequence로 비디오 렌더링 지옥에서 간신히 탈출했다고 생각했는데…사람의 욕심은 끝이 없다고, 비디오 두 개를 나란히 놓고, 중간에 트랜지션 효과도 넣고 싶어졌다.
아, 진짜 이럴 줄 알았다. 하나 제대로 돌아간다고 안심하면 안 되는 게 개발이지. 비디오 하나도 버퍼링과 싸우느라 진땀 뺐는데, 이제 Picture-in-Picture, Side-by-Side, Grid Layout까지?
그리고 이 모든 걸 스스로 자처하고 있는 자는 누구? 바로 나.
이전 아키텍처의 한계: 단일 비디오 세상
// 이전 방식: 하나의 비디오, 하나의 컴포넌트
export interface VideoSequenceConfig {
id: string;
src: string; // 🔥 하나의 소스만
from: number;
durationInFrames: number;
// ...
}
이전 시스템은 철저히 “하나의 비디오, 하나의 시퀀스” 철학이었다. 깔끔하고 단순했지만, 현실은 그보다 복잡했다.
새로운 요구사항들
| 레이아웃 | 설명 | 난이도 |
|---|---|---|
| Side-by-Side | 두 비디오를 좌우로 배치 | ⭐⭐ |
| Picture-in-Picture | 작은 비디오를 큰 비디오 위에 오버레이 | ⭐⭐⭐ |
| Grid 2x2 | 네 개 비디오를 격자로 배치 | ⭐⭐⭐⭐ |
| Split Screen | 화면을 분할해서 각각 다른 비디오 | ⭐⭐ |
| Dynamic Transitions | 비디오 간 부드러운 전환 효과 | ⭐⭐⭐⭐⭐ |
🎬 내가 원하는 것
┌─────────────┬─────────────┐
│ Video A │ Video B │ <- Side-by-Side
│ (Main) │ (Reaction) │
└─────────────┴─────────────┘
↓ (트랜지션)
┌─────────────────────────────┐
│ Video C │ <- 전체 화면
│ ┌─────────┐ │
│ │Video A │ │ <- PiP
│ │(Small) │ │
│ └─────────┘ │
└─────────────────────────────┘
해결책: 다중 비디오 레이아웃 시스템 구축
1. 레이아웃 타입 시스템 설계
export type VideoLayout =
| 'single' // 기존 단일 비디오
| 'sideBySide' // 좌우 분할
| 'pip' // Picture in Picture
| 'grid2x2' // 2x2 격자
| 'splitScreen' // 화면 분할
| 'overlay'; // 오버레이
export interface MultiVideoConfig {
layout: VideoLayout;
videos: {
id: string;
src: string;
position: {
x: number | string; // '0%' 또는 픽셀값
y: number | string;
width: number | string; // '50%' 또는 픽셀값
height: number | string;
};
zIndex?: number; // 레이어 순서
startFrame?: number; // 개별 시작 타이밍
duration?: number; // 개별 지속 시간
}[];
transition?: TransitionConfig; // 🔥 트랜지션 설정
}
기존의 단일 비디오 설정에서 다중 비디오 컨테이너 개념으로 확장했다. 각 비디오가 독립적인 포지셔닝과 타이밍을 가질 수 있게 된 거지.
2. MultiVideoPlayer: 레이아웃 매니저
export const MultiVideoPlayer: React.FC<{
config: MultiVideoConfig;
currentFrame: number;
}> = ({ config, currentFrame }) => {
const renderVideo = (video: MultiVideoConfig['videos'][0], index: number) => {
const { src, position, zIndex = 1, startFrame = 0, duration } = video;
// 🎯 개별 비디오의 활성 상태 확인
const isActive = currentFrame >= startFrame &&
(duration ? currentFrame < startFrame + duration : true);
if (!isActive) return null;
return (
<div
key={video.id}
style={{
position: 'absolute',
left: position.x, // '50%' 같은 상대값 지원
top: position.y,
width: position.width,
height: position.height,
zIndex,
}}
>
<OffthreadVideo
src={staticFile(src)}
style={{
width: '100%',
height: '100%',
objectFit: 'cover', // 🔥 비율 유지하며 채우기
}}
startFrom={Math.max(0, currentFrame - startFrame)}
/>
</div>
);
};
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
{config.videos.map(renderVideo)}
</div>
);
};
핵심은 상대적 포지셔닝 시스템이다. '50%' 같은 값을 그대로 CSS에 넘겨주면 반응형 레이아웃이 자동으로 구현된다.
3. 트랜지션 지옥: 부드러운 전환의 미학
export interface TransitionConfig {
type: 'swap' | 'flip' | 'slide' | 'fade' | 'custom';
duration: number; // 프레임 단위
timing: 'linear' | 'easeIn' | 'easeOut' | 'spring';
direction?: 'left' | 'right' | 'up' | 'down';
}
트랜지션이 진짜 어려웠다. 두 비디오 사이의 부드러운 전환을 만들려면 interpolate 함수를 정교하게 다뤄야 한다.
const VideoTransition: React.FC<{
fromVideo: React.ReactNode;
toVideo: React.ReactNode;
transition: TransitionConfig;
transitionStartFrame: number;
}> = ({ fromVideo, toVideo, transition, transitionStartFrame }) => {
const frame = useCurrentFrame();
const { type, duration, timing } = transition;
// 🎯 트랜지션 진행률 계산 (0.0 ~ 1.0)
const progress = interpolate(
frame,
[transitionStartFrame, transitionStartFrame + duration],
[0, 1],
{
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: timing === 'spring' ? Easing.elastic() :
timing === 'easeIn' ? Easing.in(Easing.quad) :
Easing.out(Easing.quad)
}
);
// 🔥 각 트랜지션 타입별 렌더링
switch (type) {
case 'flip':
return (
<div style={{
transform: `rotateY(${progress * 180}deg)`,
transformStyle: 'preserve-3d', // 3D 변환 활성화
}}>
<div style={{
backfaceVisibility: 'hidden',
transform: progress > 0.5 ? 'rotateY(180deg)' : 'rotateY(0deg)'
}}>
{progress <= 0.5 ? fromVideo : toVideo}
</div>
</div>
);
case 'slide':
const slideOffset = direction === 'left' ? -100 : 100;
return (
<>
<div style={{
transform: `translateX(${progress * slideOffset}%)`
}}>
{fromVideo}
</div>
<div style={{
transform: `translateX(${(progress - 1) * slideOffset}%)`
}}>
{toVideo}
</div>
</>
);
}
};
CSS 3D Transform의 마법
특히 flip 트랜지션에서 사용한 3D transform이 인상적이었다. preserve-3d와 backfaceVisibility: 'hidden'을 조합하면 진짜 카드 뒤집기 같은 효과가 나온다.
/* 3D 플립 효과의 핵심 */
transform: rotateY(180deg);
transformStyle: preserve-3d;
backfaceVisibility: hidden;
실제 적용: EP1에서의 다중 비디오 설정
// 다중 비디오 설정 예시
export const EP1Shorts1_MULTI_VIDEO_INSERTS: MediaInsertConfig[] = [
{
type: 'video',
startFrame: 800,
duration: 120,
multiVideo: {
layout: 'sideBySide',
videos: [
{
id: 'main-speaker',
src: 'assets/speaker-main.mp4',
position: { x: '0%', y: '0%', width: '70%', height: '100%' },
zIndex: 1
},
{
id: 'reaction-cam',
src: 'assets/reaction-small.mp4',
position: { x: '70%', y: '0%', width: '30%', height: '100%' },
zIndex: 2 // 🔥 반응 캠이 위에 오도록
}
],
transition: {
type: 'swap',
duration: 30, // 1초 (30fps 기준)
timing: 'easeInOut',
direction: 'left'
}
}
}
];
레이아웃별 성능 비교
| 레이아웃 | 메모리 사용량 | 렌더링 시간 | 복잡도 |
|---|---|---|---|
| Single | 650MB | 28초 | ⭐ |
| Side-by-Side | 1.1GB | 42초 | ⭐⭐ |
| PiP | 850MB | 35초 | ⭐⭐⭐ |
| Grid 2x2 | 1.8GB | 68초 | ⭐⭐⭐⭐ |
메모리 사용량이 비디오 개수에 거의 비례한다. 당연한 얘기지만, 실제로 보니 생각보다 크다.
기존 시스템과의 통합: 하위 호환성 유지
// MediaInsert에서 다중 비디오 지원 추가
export const MediaInsert: React.FC<{ config: MediaInsertConfig }> = ({ config }) => {
const frame = useCurrentFrame();
// 🔥 다중 비디오 모드 확인
if (config.multiVideo) {
return <MultiVideoPlayer config={config.multiVideo} currentFrame={frame} />;
}
// 기존 단일 미디어 로직은 그대로 유지
if (config.type === 'image') {
return <Img src={staticFile(config.src)} style={animatedStyle} />;
}
};
기존 VideoSequencePlayer와 새로운 MultiVideoPlayer가 공존할 수 있도록 설계했다. 단일 비디오는 여전히 Sequence 기반으로 최적화되고, 다중 비디오만 새로운 시스템을 사용한다.
고급 트랜지션: @remotion/transitions 활용
Remotion에서 공식 제공하는 트랜지션 패키지도 시도해봤다:
import { TransitionSeries, linearTiming } from '@remotion/transitions';
import { wipe } from '@remotion/transitions/wipe';
const AdvancedVideoTransition = () => {
return (
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={100}>
<MainVideo />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
timing={linearTiming({ durationInFrames: 30 })}
presentation={wipe({ direction: 'from-left' })} // 🔥 와이프 효과
/>
<TransitionSeries.Sequence durationInFrames={100}>
<SecondVideo />
</TransitionSeries.Sequence>
</TransitionSeries>
);
};
공식 패키지가 확실히 더 부드럽긴 하다. 하지만 우리 커스텀 시스템보다 유연성이 떨어져서 결국 하이브리드로 사용하게 됐다.
이전 대비 확장된 부분들
아키텍처 관점에서의 확장
| 이전 (단일 비디오) | 이번 (다중 비디오) | 발전 양상 |
|---|---|---|
| 1:1 매핑 (하나의 설정 → 하나의 비디오) | 1:N 매핑 (하나의 설정 → 여러 비디오) | 컨테이너 패턴 도입 |
| Sequential 처리 (순차적 재생) | Parallel 처리 (동시 재생) | 동시성 관리 |
| Fixed Layout (고정 배치) | Dynamic Layout (유연한 배치) | 레이아웃 시스템 |
| Static Timing (고정 타이밍) | Individual Timing (개별 타이밍) | 타이밍 복잡도 증가 |
기술적 복잡도의 확장
이전: Sequence → OffthreadVideo 이번: MultiVideoConfig → Layout Manager → Individual Videos + Transitions
단순한 선형 파이프라인에서 다차원 매트릭스 구조로 발전했다.
🧠 개념적 확장
- 공간 관리: 2D 레이아웃 시스템 도입
- 시간 관리: 개별 비디오의 독립적 타이밍
- 상태 관리: 여러 비디오의 동기화
- 시각 효과: 트랜지션과 블렌딩
아직 해결하지 못한 과제들
메모리 최적화 이슈
Grid 2x2: 1.8GB 메모리 사용 → 4K 비디오 4개 동시 로드시 8GB+ 예상
비디오가 늘어날수록 메모리 사용량이 선형적으로 증가한다. Lazy Loading이나 Virtual Scrolling 같은 최적화 기법이 필요할 것 같다.
동기화 문제
Video A: 프레임 100에서 시작 Video B: 프레임 105에서 시작 (5프레임 지연) → 립싱크 문제 발생 가능성
🎬 복잡한 트랜지션의 성능
3D 트랜지션이나 복잡한 이징 함수를 사용할 때 렌더링 성능이 급격히 떨어진다. GPU 가속을 더 적극적으로 활용해야 할 듯하다.
이번 확장에서 가장 인상적이었던 건? 단일 컴포넌트 설계에서 컨테이너 패턴으로의 전환이었다. 하나의 비디오를 잘 처리하는 것과 여러 비디오를 조화롭게 배치하는 것은 완전히 다른 문제였다.