Shader를 Remotion 영상 작업에 도입해보기

– ArtXTech

Remotion에서 WebGL 쉐이더 적용하다가 머리털 실종할 뻔

얼마 전에 Remotion으로 비디오에 쉐이더 이펙트를 적용하는 파이프라인을 만들어보자는 야심찬 계획을 세웠다. 목표는 간단했다.

1분짜리 영상 넣으면 자동으로 1800프레임 계산해서 적용하고, 재사용 가능한 이펙트들을 import 한 번으로 쓸 수 있게 만드는 거였다.

뭐가 그렇게 어렵겠나 싶었는데… 아, 또 시작됬다. 이렇게 앞으로 벌어질 고난 따위 일절 생각하고 일단 질러보는 내 습성이.

참고로 three.js가 아닌 twgl를 통해 glsl을 도입하였다. three.js로 쉐이더를 넣게 되면 카메라랑 기타 맵핑 설정까지 다 넣어야 할 거 같아 예상보다 더 복잡해질 것 같아서.

프로젝트 구조

VideoEditPlayground/
├── src/remotion/
│   ├── _Shared/
│   │   ├── Helper/
│   │   │   ├── videoFrameCount.ts     # 비디오 길이 계산
│   │   │   └── sequenceManager.ts     # 시퀀스 관리
│   │   ├── Component/
│   │   │   └── VideoShader.tsx        # WebGL 비디오 처리
│   │   └── Effect/
│   │       ├── AtkinsonDithering.tsx  # 쉐이더 이펙트들
│   │       ├── HalftoneShader.tsx
│   │       └── ...
│   └── _YT/MotionShader/
│       ├── videoImport.ts             # 비디오 경로 관리
│       ├── MoShaderShow.tsx           # 비디오 + 이펙트
│       ├── MoShaderSequence.tsx       # 시퀀스 관리
│       └── MoShaderMain.tsx           # Composition 정의

첫 번째 관문: 렌더링 성공 패턴 분석

처음엔 여러 방식으로 실험해봤다. 결과를 먼저 말하면:

오버레이 방식: 쉐이더를 비디오 위에 블렌딩  렌더링 성공
순수 쉐이더: 비디오 없이 쉐이더만 사용  렌더링 성공  
텍스처 처리: 비디오를 WebGL 텍스처로 처리  렌더링 실패 (검은 화면)

왜 텍스처 처리만 실패하는 거지? 프리뷰에서는 멀쩡하게 나오는데 렌더링하면 검은 화면만 뜬다. 이게 바로 내가 3일간 붙잡고 씨름한 챌린지였다.

두 번째 관문: 검은 화면과의 전쟁

🔴 문제 상황

// 이 코드가 문제였다
<AtkinsonDithering videoPath={videoPath} intensity={0.5}>
  {videoContent}
</AtkinsonDithering>

VideoShader 기반 이펙트들이 프리뷰에서는 정상인데 렌더링에서는 검은 화면. 처음엔 쉐이더 코드가 잘못된 줄 알았다. 몇 시간 동안 fragment shader를 뜯어고쳤는데 소용없었다.

그러다가 깨달았다. WebGL Context 설정 문제구나.

✅ 해결책

// 핵심은 이거였다
const gl = canvas.getContext('webgl', {
  preserveDrawingBuffer: true,  // 이게 핵심!
  premultipliedAlpha: false,
  alpha: true
});

preserveDrawingBuffer: true가 없으면 Remotion 렌더링 과정에서 WebGL 컨텍스트가 날아간다. 프리뷰는 실시간이라 괜찮은데, 렌더링은 프레임별로 캡처하니까 버퍼가 보존되지 않으면 검은 화면만 나온다는 거였다.

이런 걸 왜 문서에 크게 안 써놨을까? 아니면 내가 못 본 건가? 어쨌든 이거 하나 때문에 3일을 날렸다.

세 번째 관문: 프레임 범위 지옥

검은 화면 문제를 해결했더니 이번엔 이런 에러가 떴다:

Error: The "durationInFrames" of the <Composition /> was evaluated to be 600, 
but frame range 0-602 is not inbetween 0-599

뭐야 이게? 600프레임이라고 해놓고 602프레임까지 렌더링하려고 한다니.

원인을 찾아보니 Composition 생성 시점과 비디오 길이 계산 완료 시점의 타이밍 불일치 때문이었다. Remotion이 Composition을 만들 때는 임시로 600프레임이라고 설정해놨는데, 실제 비디오를 분석해보니 602프레임이었던 거다.

해결 방법: 사전 Duration 계산

// Main 컴포넌트에서 미리 계산하고 시작
useEffect(() => {
  const calculateInitialDuration = async () => {
    const metadata = await getVideoDurationInFrames(CURRENT_VIDEO, 30);
    setTotalDuration(metadata.durationInFrames);
  };
  calculateInitialDuration();
}, []);

// Duration 계산 완료 후에만 Composition 렌더
if (isCalculating || totalDuration === null) {
  return null;
}

미리 계산해서 정확한 프레임 수를 알아낸 다음에 Composition을 생성하는 방식으로 바꿨다. 이제 602프레임이면 처음부터 602프레임으로 설정된다.

네 번째 관문: 타이밍 동기화

WebGL로 비디오를 텍스처로 처리할 때는 비디오 로딩 완료를 기다려야 한다. 안 그러면 텍스처가 준비되기 전에 쉐이더가 실행되어버린다.

// delayRender/continueRender 패턴 필수
const VideoShader = ({ videoPath, fragmentShader }) => {
  const [handle] = useState(() => delayRender());
  
  const onVideoLoad = () => {
    // 비디오 로딩 완료 후 렌더링 계속
    continueRender(handle);
  };
  
  return (
    <OffthreadVideo 
      src={videoPath} 
      onLoad={onVideoLoad}
    />
  );
};

이것도 처음엔 몰라서 간헐적으로 텍스처가 검은색으로 나오는 문제가 있었다. 비동기 처리의 중요성을 다시 한번 깨달았다.

최종 아키텍처: 계층적 데이터 흐름

결국 이런 구조로 정착했다:

MoShaderMain.tsx (Composition 정의)
├── MoShaderSequence.tsx (시퀀스 관리) 
    ├── MoShaderShow.tsx (비디오 + 이펙트)
        ├── OffthreadVideo (비디오 로딩)
        └── AtkinsonDithering (쉐이더 이펙트)
            └── VideoShader.tsx (WebGL 처리)

데이터는 이렇게 흐른다:

  1. videoImport.ts에서 비디오 경로 관리
  2. MoShaderShow.tsx에서 비디오 길이 계산 (602프레임)
  3. onDurationCalculated로 상위 컴포넌트에 전달
  4. MoShaderSequence.tsxMoShaderMain.tsx에서 동일한 프레임 수 사용

이렇게 하니까 프레임 불일치 문제도 해결되고, 재사용도 쉬워졌다.

📊 데이터 흐름 다이어그램

graph TD
    A[videoImport.ts<br/>DANCE_VIDEO_PATH] --> B[MoShaderShow.tsx]
    B --> |video path| C[getVideoDurationInFrames]
    C --> |602 frames| D[onDurationCalculated]
    D --> E[MoShaderSequence.tsx]
    E --> |602 frames| F[Sequence durationInFrames]
    E --> |602 frames| G[MoShaderMain.tsx]
    G --> |602 frames| H[Composition durationInFrames]
    
    B --> I[VideoShader.tsx]
    I --> |WebGL Context| J[preserveDrawingBuffer: true]
    I --> |Video Loading| K[delayRender/continueRender]
    
    L[AtkinsonDithering.tsx] --> I
    M[_Shared/Effect/*] --> I
    
    style C fill:#e1f5fe
    style I fill:#f3e5f5
    style J fill:#ffebee
    style K fill:#ffebee

최종 완성된 컴포넌트 관계도 (ASCII)

┌─────────────────────────────────────────────────────────────┐
                    MoShaderMain.tsx                         
  ┌─────────────────────────────────────────────────────┐    
                 MoShaderSequence.tsx                      
    ┌─────────────────────────────────────────────┐        
                MoShaderShow.tsx                         
                                                         
      ┌─────────────────────────────────────┐            
               OffthreadVideo                          
            (dance.mov loading)                        
      └─────────────────────────────────────┘            
                                                        
                                                        
      ┌─────────────────────────────────────┐            
             AtkinsonDithering                          
          (VideoShader wrapper)                        
                                                       
        ┌─────────────────────────────┐                
              VideoShader.tsx                        
            WebGL Context                           
            preserveDrawingBuffer                   
            delayRender/continue                    
        └─────────────────────────────┘                
      └─────────────────────────────────────┘            
    └─────────────────────────────────────────────┘        
                                                          
          onDurationCalculated(602)                        
                                                          
              Sequence durationInFrames=602                
  └─────────────────────────────────────────────────────┘    
                                                             
          onDurationCalculated(602)                           
                                                             
              Composition durationInFrames=602                
└─────────────────────────────────────────────────────────────┘

핵심 교훈들

1. WebGL Context이란 복병

preserveDrawingBuffer: true 없으면 렌더링에서 깨진다. 프리뷰에서 멀쩡해도 믿지 마라.

2. 비동기 처리는 생각보다 복잡

비디오 로딩, Duration 계산, WebGL 텍스처 준비… 이 모든 게 비동기다. delayRender/continueRender 패턴을 제대로 써야 한다.

3. 타이밍 이슈는 조건부 렌더링으로 해결

계산 완료 전에 컴포넌트 렌더링하지 마라. isCalculating 같은 상태로 제어해야 한다.

4. 재사용 가능한 구조가 디버깅도 쉽게 만든다

_Shared/Effect/ 폴더에 공용 이펙트들 모아두니까 문제 생겼을 때 어디서 고쳐야 할지 명확하다.

앞으로의 계획

지금은 AtkinsonDithering 하나만 만들어뒀는데, 앞으로 HalftoneShader, PixelSort, Glitch 이펙트들도 추가할 예정이다. 구조가 잡혀있으니까 이제는 쉐이더만 만들면 된다.

마무리

3일간 검은 화면과 싸우면서 진짜 포기하고 싶었다. 하지만 결국 해결하고 나니까 WebGL, Remotion, 비동기 처리에 대해 엄청 많이 배웠다.

특히 preserveDrawingBuffer 같은 건 문서만 봐서는 절대 모를 수 있는 디테일이었다. 실제로 건들며 뜯어봐야 아는 거다.

이제 20초짜리 댄스 영상에 쉐이더 이펙트 입혀서 내가 생각하는 효과를 입힌 영상을 만들 수 있다. 자동화된 파이프라인도 완성됐고.

다음엔 또 어떤 챌린지가 날 기다리고 있을지 기대..는 딱히 되진 않네.


다른 리모션 포스팅을 보고 싶다면

2025-01-16-implementing-canvas-animation-to-remotion-kr
2025-03-18-making-my-own-video-editor-in-progress
KR-no-more-adobe-starting-remotion-kr
KR-remotion-gsap
KR-making-chart-data-journalism-storytelling-motion-setup-with-remotion
KR-figma-to-jitter-and-remotion
KR-converting-bodymovin-animation-to-remotion-template
KR-how-i-make-a-youtube-series-with-remotion-dynamic-srt-script-setup-exp-likejennie
KR-how-i-made-podcast-interview-with-ai-virtual-idol
KR-thepain-of-animation-debugging