다중 비디오 레이아웃의 늪: 혼돈 속에서 질서를 찾아서

다중 비디오 레이아웃의 늪: 혼돈 속에서 질서를 찾아서

단일 비디오도 힘들었는데 이제 여러 개를?

2025-06-30-surviving-from-remotion-video-framing-hell

이전 포스팅에서 OffthreadVideo와 Sequence로 비디오 렌더링 지옥에서 간신히 탈출했다고 생각했는데…사람의 욕심은 끝이 없다고, 비디오 두 개를 나란히 놓고, 중간에 트랜지션 효과도 넣고 싶어졌다.

아, 진짜 이럴 줄 알았다. 하나 제대로 돌아간다고 안심하면 안 되는 게 개발이지. 비디오 하나도 버퍼링과 싸우느라 진땀 뺐는데, 이제 Picture-in-PictureSide-by-SideGrid 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

단순한 선형 파이프라인에서 다차원 매트릭스 구조로 발전했다.

🧠 개념적 확장

  1. 공간 관리: 2D 레이아웃 시스템 도입
  2. 시간 관리: 개별 비디오의 독립적 타이밍
  3. 상태 관리: 여러 비디오의 동기화
  4. 시각 효과: 트랜지션과 블렌딩

아직 해결하지 못한 과제들

메모리 최적화 이슈

Grid 2x2: 1.8GB 메모리 사용 → 4K 비디오 4개 동시 로드시 8GB+ 예상

비디오가 늘어날수록 메모리 사용량이 선형적으로 증가한다. Lazy Loading이나 Virtual Scrolling 같은 최적화 기법이 필요할 것 같다.

동기화 문제

Video A: 프레임 100에서 시작 Video B: 프레임 105에서 시작 (5프레임 지연) → 립싱크 문제 발생 가능성

🎬 복잡한 트랜지션의 성능

3D 트랜지션이나 복잡한 이징 함수를 사용할 때 렌더링 성능이 급격히 떨어진다. GPU 가속을 더 적극적으로 활용해야 할 듯하다.


이번 확장에서 가장 인상적이었던 건? 단일 컴포넌트 설계에서 컨테이너 패턴으로의 전환이었다. 하나의 비디오를 잘 처리하는 것과 여러 비디오를 조화롭게 배치하는 것은 완전히 다른 문제였다.