황현동 블로그 개발, 인생, 유우머

251221 WebCodec API와 MP4Box.js 기술분석

Tags:

웹 브라우저에서 네이티브 수준의 미디어 처리가 가능해진 현재, WebCodec API와 MP4Box.js의 조합은 웹 기반 미디어 애플리케이션의 새로운 가능성을 열어주고 있습니다. 본 분석에서는 두 기술의 심층적인 특징과 실제 활용 방안을 살펴보겠습니다.

기술 개요와 배경

WebCodec API의 등장 배경

┌─────────── 웹 미디어 처리 기술의 진화 ───────────┐
│                                               │
│  2010년대 초반:                                │
│  ┌─────────────────────────────────────────┐   │
│  │ <video> 태그 + Media Source Extensions │   │
│  │ • 제한적인 컨테이너 지원                │   │
│  │ • 브라우저 의존적 코덱 지원              │   │
│  │ • 고수준 API만 제공                    │   │
│  └─────────────────────────────────────────┘   │
│                     ↓                         │
│  2010년대 후반:                                │
│  ┌─────────────────────────────────────────┐   │
│  │ WebAssembly + FFmpeg                   │   │
│  │ • 성능 향상하지만 여전히 제약            │   │
│  │ • 메모리 사용량 증가                    │   │
│  │ • 하드웨어 가속 불가                    │   │
│  └─────────────────────────────────────────┘   │
│                     ↓                         │
│  2020년대:                                     │
│  ┌─────────────────────────────────────────┐   │
│  │ WebCodec API                           │   │
│  │ • 네이티브 하드웨어 가속 지원            │   │
│  │ • 저수준 미디어 처리 API                │   │
│  │ • 직접적인 인코더/디코더 제어            │   │
│  └─────────────────────────────────────────┘   │
└───────────────────────────────────────────────┘

MP4Box.js의 역할

MP4Box.js는 GPAC 프로젝트의 JavaScript 포팅으로, MP4 컨테이너 형식의 완전한 파싱과 조작을 웹 환경에서 가능하게 합니다.

┌──────── MP4Box.js 핵심 기능 ────────┐
│                                    │
│  MP4 파일 구조 분석:                │
│  ┌────────────────────────────┐     │
│  │ • ftyp (File Type)         │     │
│  │ • moov (Movie Header)      │     │
│  │ • mdat (Media Data)        │     │
│  │ • 트랙별 메타데이터         │     │
│  └────────────────────────────┘     │
│                │                   │
│                ▼                   │
│  실시간 스트림 처리:                 │
│  ┌────────────────────────────┐     │
│  │ • Fragmented MP4 지원      │     │
│  │ • DASH/HLS 세그먼테이션    │     │
│  │ • 점진적 다운로드 지원      │     │
│  └────────────────────────────┘     │
│                │                   │
│                ▼                   │
│  WebCodec 연동:                    │
│  ┌────────────────────────────┐     │
│  │ • EncodedVideoChunk 생성   │     │
│  │ • 코덱 설정 정보 추출       │     │
│  │ • 타이밍 동기화            │     │
│  └────────────────────────────┘     │
└────────────────────────────────────┘

WebCodec API 심층 분석

VideoDecoder 상세 구현

interface VideoDecoderInit {
  output: VideoFrameOutputCallback;
  error: WebCodecsErrorCallback;
}

interface VideoDecoderConfig {
  codec: string;
  description?: AllowSharedBufferSource;
  codedWidth?: number;
  codedHeight?: number;
  displayAspectWidth?: number;
  displayAspectHeight?: number;
  colorSpace?: VideoColorSpaceInit;
  hardwareAcceleration?: HardwareAcceleration;
  optimizeForLatency?: boolean;
}

class AdvancedVideoDecoder {
  private decoder: VideoDecoder;
  private frameBuffer: VideoFrame[] = [];
  private frameCounter: number = 0;
  private decodingStats: DecodingStats;
  
  constructor(config: VideoDecoderConfig) {
    this.decodingStats = new DecodingStats();
    
    this.decoder = new VideoDecoder({
      output: (frame: VideoFrame) => this.handleDecodedFrame(frame),
      error: (error: Error) => this.handleDecodingError(error)
    });
    
    this.configureDecoder(config);
  }
  
  private async configureDecoder(config: VideoDecoderConfig): Promise<void> {
    // 브라우저 지원 확인
    const support = await VideoDecoder.isConfigSupported(config);
    
    if (!support.supported) {
      throw new Error(`Decoder config not supported: ${support.config}`);
    }
    
    // 하드웨어 가속 최적화
    const optimizedConfig = {
      ...config,
      hardwareAcceleration: 'prefer-hardware' as HardwareAcceleration,
      optimizeForLatency: true
    };
    
    this.decoder.configure(optimizedConfig);
    console.log('Decoder configured with hardware acceleration');
  }
  
  private handleDecodedFrame(frame: VideoFrame): void {
    this.frameCounter++;
    this.decodingStats.recordFrame(frame);
    
    // 프레임 버퍼 관리
    this.manageFrameBuffer(frame);
    
    // 렌더링 또는 후처리
    this.processFrame(frame);
  }
  
  private manageFrameBuffer(frame: VideoFrame): void {
    // 최대 버퍼 크기 제한
    const MAX_BUFFER_SIZE = 10;
    
    if (this.frameBuffer.length >= MAX_BUFFER_SIZE) {
      const oldFrame = this.frameBuffer.shift();
      if (oldFrame) {
        oldFrame.close(); // 메모리 해제
      }
    }
    
    this.frameBuffer.push(frame);
  }
  
  private processFrame(frame: VideoFrame): void {
    // Canvas에 프레임 렌더링
    const canvas = document.getElementById('videoCanvas') as HTMLCanvasElement;
    const ctx = canvas.getContext('2d');
    
    if (ctx && canvas) {
      canvas.width = frame.codedWidth;
      canvas.height = frame.codedHeight;
      
      // VideoFrame을 ImageBitmap으로 변환하여 그리기
      createImageBitmap(frame).then(bitmap => {
        ctx.drawImage(bitmap, 0, 0);
        bitmap.close();
        
        // 프레임 닫기 (메모리 관리)
        frame.close();
      });
    }
  }
  
  public decode(chunk: EncodedVideoChunk): void {
    if (this.decoder.state === 'configured') {
      this.decodingStats.recordChunk(chunk);
      this.decoder.decode(chunk);
    }
  }
  
  private handleDecodingError(error: Error): void {
    console.error('Decoding error:', error);
    this.decodingStats.recordError(error);
    
    // 에러 복구 시도
    this.attemptErrorRecovery();
  }
  
  private attemptErrorRecovery(): void {
    // 디코더 재설정 시도
    try {
      this.decoder.reset();
      console.log('Decoder reset successful');
    } catch (error) {
      console.error('Decoder recovery failed:', error);
    }
  }
  
  public getDecodingStats(): DecodingStats {
    return this.decodingStats;
  }
  
  public close(): void {
    // 모든 프레임 해제
    this.frameBuffer.forEach(frame => frame.close());
    this.frameBuffer = [];
    
    // 디코더 닫기
    this.decoder.close();
  }
}

// 디코딩 통계 클래스
class DecodingStats {
  private framesDecoded: number = 0;
  private chunksReceived: number = 0;
  private errors: Error[] = [];
  private startTime: number = Date.now();
  private frameTimestamps: number[] = [];
  
  recordFrame(frame: VideoFrame): void {
    this.framesDecoded++;
    this.frameTimestamps.push(frame.timestamp);
  }
  
  recordChunk(chunk: EncodedVideoChunk): void {
    this.chunksReceived++;
  }
  
  recordError(error: Error): void {
    this.errors.push(error);
  }
  
  getFrameRate(): number {
    if (this.frameTimestamps.length < 2) return 0;
    
    const duration = (Date.now() - this.startTime) / 1000;
    return this.framesDecoded / duration;
  }
  
  getDecodeLatency(): number {
    // 평균 디코딩 지연시간 계산
    if (this.frameTimestamps.length < 2) return 0;
    
    const intervals = [];
    for (let i = 1; i < this.frameTimestamps.length; i++) {
      intervals.push(this.frameTimestamps[i] - this.frameTimestamps[i-1]);
    }
    
    return intervals.reduce((a, b) => a + b, 0) / intervals.length;
  }
  
  getSummary(): object {
    return {
      framesDecoded: this.framesDecoded,
      chunksReceived: this.chunksReceived,
      errorCount: this.errors.length,
      frameRate: this.getFrameRate(),
      decodeLatency: this.getDecodeLatency()
    };
  }
}

VideoEncoder 고급 기능

class AdvancedVideoEncoder {
  private encoder: VideoEncoder;
  private encodingQueue: VideoFrame[] = [];
  private isProcessing: boolean = false;
  private encodingStats: EncodingStats;
  
  constructor(config: VideoEncoderConfig) {
    this.encodingStats = new EncodingStats();
    
    this.encoder = new VideoEncoder({
      output: (chunk, metadata) => this.handleEncodedChunk(chunk, metadata),
      error: (error) => this.handleEncodingError(error)
    });
    
    this.configureEncoder(config);
  }
  
  private async configureEncoder(config: VideoEncoderConfig): Promise<void> {
    // 동적 비트레이트 조정을 위한 확장 설정
    const adaptiveConfig = {
      ...config,
      bitrateMode: 'variable' as BitrateMode,
      latencyMode: 'realtime' as LatencyMode,
      
      // 프레임 품질 설정
      alpha: 'discard' as AlphaOption,
      scalabilityMode: 'L1T1' // 단일 레이어 인코딩
    };
    
    const support = await VideoEncoder.isConfigSupported(adaptiveConfig);
    if (!support.supported) {
      throw new Error('Encoder configuration not supported');
    }
    
    this.encoder.configure(adaptiveConfig);
  }
  
  public async encodeFrame(frame: VideoFrame, options?: VideoEncoderEncodeOptions): Promise<void> {
    // 인코딩 큐에 프레임 추가
    this.encodingQueue.push(frame);
    
    if (!this.isProcessing) {
      this.processEncodingQueue(options);
    }
  }
  
  private async processEncodingQueue(options?: VideoEncoderEncodeOptions): Promise<void> {
    this.isProcessing = true;
    
    while (this.encodingQueue.length > 0) {
      const frame = this.encodingQueue.shift();
      if (!frame) continue;
      
      try {
        // 적응적 키프레임 삽입
        const needsKeyframe = this.shouldInsertKeyframe(frame);
        const encodeOptions = {
          ...options,
          keyFrame: needsKeyframe
        };
        
        // 인코딩 시작 시간 기록
        const encodeStartTime = performance.now();
        
        this.encoder.encode(frame, encodeOptions);
        this.encodingStats.recordFrameStart(frame, encodeStartTime);
        
        // 프레임 해제
        frame.close();
        
      } catch (error) {
        console.error('Frame encoding failed:', error);
        frame.close();
      }
    }
    
    this.isProcessing = false;
  }
  
  private shouldInsertKeyframe(frame: VideoFrame): boolean {
    const stats = this.encodingStats.getSummary();
    
    // 조건부 키프레임 삽입
    const timeSinceLastKeyframe = frame.timestamp - stats.lastKeyframeTimestamp;
    const framesSinceLastKeyframe = stats.framesSinceLastKeyframe;
    
    // 2초마다 또는 30프레임마다 키프레임 삽입
    return timeSinceLastKeyframe > 2000000 || framesSinceLastKeyframe > 30;
  }
  
  private handleEncodedChunk(chunk: EncodedVideoChunk, metadata?: EncodedVideoChunkMetadata): void {
    this.encodingStats.recordChunk(chunk, metadata);
    
    // 청크를 스트리밍 또는 저장
    this.processEncodedChunk(chunk, metadata);
  }
  
  private processEncodedChunk(chunk: EncodedVideoChunk, metadata?: EncodedVideoChunkMetadata): void {
    // MP4Box를 사용하여 컨테이너에 패키징
    this.packageToMP4(chunk, metadata);
    
    // 또는 WebRTC를 통한 실시간 전송
    this.streamViaWebRTC(chunk);
  }
  
  private packageToMP4(chunk: EncodedVideoChunk, metadata?: EncodedVideoChunkMetadata): void {
    // MP4Box.js 통합 (다음 섹션에서 상세 설명)
    // 청크를 MP4 컨테이너에 추가하는 로직
  }
  
  private streamViaWebRTC(chunk: EncodedVideoChunk): void {
    // WebRTC를 통한 실시간 스트리밍
    // RTCPeerConnection을 통한 전송 로직
  }
  
  private handleEncodingError(error: Error): void {
    console.error('Encoding error:', error);
    this.encodingStats.recordError(error);
  }
  
  public async flush(): Promise<void> {
    await this.encoder.flush();
    this.encodingStats.recordFlush();
  }
  
  public close(): void {
    // 대기 중인 프레임들 정리
    this.encodingQueue.forEach(frame => frame.close());
    this.encodingQueue = [];
    
    this.encoder.close();
  }
  
  public getEncodingStats(): EncodingStats {
    return this.encodingStats;
  }
}

class EncodingStats {
  private framesEncoded: number = 0;
  private chunksGenerated: number = 0;
  private lastKeyframeTimestamp: number = 0;
  private framesSinceLastKeyframe: number = 0;
  private encodeTimes: number[] = [];
  private errors: Error[] = [];
  
  recordFrameStart(frame: VideoFrame, startTime: number): void {
    this.framesEncoded++;
    // 인코딩 시작 시간 저장 (완료 시 계산용)
  }
  
  recordChunk(chunk: EncodedVideoChunk, metadata?: EncodedVideoChunkMetadata): void {
    this.chunksGenerated++;
    
    if (chunk.type === 'key') {
      this.lastKeyframeTimestamp = chunk.timestamp;
      this.framesSinceLastKeyframe = 0;
    } else {
      this.framesSinceLastKeyframe++;
    }
  }
  
  recordError(error: Error): void {
    this.errors.push(error);
  }
  
  recordFlush(): void {
    console.log('Encoder flush completed');
  }
  
  getSummary(): object {
    return {
      framesEncoded: this.framesEncoded,
      chunksGenerated: this.chunksGenerated,
      lastKeyframeTimestamp: this.lastKeyframeTimestamp,
      framesSinceLastKeyframe: this.framesSinceLastKeyframe,
      errorCount: this.errors.length,
      averageEncodeTime: this.encodeTimes.reduce((a, b) => a + b, 0) / this.encodeTimes.length
    };
  }
}

MP4Box.js 고급 활용

실시간 스트림 파싱

class RealTimeMP4Parser {
  private mp4boxFile: any;
  private chunkBuffer: ArrayBuffer[] = [];
  private totalBytesReceived: number = 0;
  private onTrackReady?: (trackInfo: any) => void;
  private onSampleReady?: (sample: any) => void;
  
  constructor() {
    this.mp4boxFile = MP4Box.createFile();
    this.setupEventHandlers();
  }
  
  private setupEventHandlers(): void {
    // 파일 준비 완료 이벤트
    this.mp4boxFile.onReady = (info: any) => {
      console.log('MP4 파일 준비 완료:', info);
      this.handleFileReady(info);
    };
    
    // 샘플 데이터 추출 이벤트
    this.mp4boxFile.onSamples = (trackId: number, user: any, samples: any[]) => {
      console.log(`트랙 ${trackId}에서 ${samples.length}개 샘플 추출`);
      this.handleSamples(trackId, samples);
    };
    
    // 세그먼트 생성 이벤트 (DASH/HLS)
    this.mp4boxFile.onSegment = (id: number, user: any, buffer: ArrayBuffer) => {
      console.log(`세그먼트 ${id} 생성: ${buffer.byteLength} bytes`);
      this.handleSegment(id, buffer);
    };
    
    // 에러 처리
    this.mp4boxFile.onError = (error: any) => {
      console.error('MP4Box 에러:', error);
    };
  }
  
  public appendData(data: ArrayBuffer): void {
    // 오프셋 정보 추가 (MP4Box 요구사항)
    data.fileStart = this.totalBytesReceived;
    this.totalBytesReceived += data.byteLength;
    
    // MP4Box에 데이터 공급
    this.mp4boxFile.appendBuffer(data);
  }
  
  private handleFileReady(info: any): void {
    console.log('비디오 정보:', {
      duration: info.duration / info.timescale,
      tracks: info.tracks.length,
      fragmentedMp4: info.isFragmented
    });
    
    // 각 트랙 분석
    info.tracks.forEach((track: any, index: number) => {
      console.log(`트랙 ${index}:`, {
        id: track.id,
        type: track.type,
        codec: track.codec,
        language: track.language
      });
      
      if (track.type === 'video') {
        this.setupVideoTrack(track);
      } else if (track.type === 'audio') {
        this.setupAudioTrack(track);
      }
      
      // 트랙 준비 완료 콜백
      if (this.onTrackReady) {
        this.onTrackReady(track);
      }
    });
    
    // 샘플 추출 시작
    this.startExtraction(info.tracks);
  }
  
  private setupVideoTrack(track: any): VideoDecoderConfig {
    // WebCodec VideoDecoder 설정 생성
    const config: VideoDecoderConfig = {
      codec: track.codec,
      codedWidth: track.video.width,
      codedHeight: track.video.height
    };
    
    // AVCC (AVC Configuration) 데이터 처리
    if (track.codec.startsWith('avc1')) {
      config.description = this.extractAVCC(track);
    }
    
    return config;
  }
  
  private setupAudioTrack(track: any): AudioDecoderConfig {
    // WebCodec AudioDecoder 설정 생성
    const config: AudioDecoderConfig = {
      codec: track.codec,
      sampleRate: track.audio.sample_rate,
      numberOfChannels: track.audio.channel_count
    };
    
    // AAC AudioSpecificConfig 처리
    if (track.codec.startsWith('mp4a')) {
      config.description = this.extractAudioSpecificConfig(track);
    }
    
    return config;
  }
  
  private extractAVCC(track: any): Uint8Array {
    // AVC Configuration Box에서 SPS/PPS 추출
    const avcC = track.avcC;
    if (!avcC) return new Uint8Array();
    
    // H.264 초기화 데이터 구성
    const spsArray = avcC.SPS;
    const ppsArray = avcC.PPS;
    
    // AVCC 형태로 패키징
    let totalLength = 7; // 기본 헤더 크기
    spsArray.forEach((sps: Uint8Array) => totalLength += 2 + sps.length);
    ppsArray.forEach((pps: Uint8Array) => totalLength += 2 + pps.length);
    
    const avccData = new Uint8Array(totalLength);
    let offset = 0;
    
    // AVCC 헤더 작성
    avccData[offset++] = 1; // configurationVersion
    avccData[offset++] = avcC.AVCProfileIndication;
    avccData[offset++] = avcC.profile_compatibility;
    avccData[offset++] = avcC.AVCLevelIndication;
    avccData[offset++] = 0xFF; // lengthSizeMinusOne
    avccData[offset++] = 0xE0 | spsArray.length; // SPS 개수
    
    // SPS 데이터 작성
    spsArray.forEach((sps: Uint8Array) => {
      avccData[offset++] = (sps.length >> 8) & 0xFF;
      avccData[offset++] = sps.length & 0xFF;
      avccData.set(sps, offset);
      offset += sps.length;
    });
    
    // PPS 개수
    avccData[offset++] = ppsArray.length;
    
    // PPS 데이터 작성
    ppsArray.forEach((pps: Uint8Array) => {
      avccData[offset++] = (pps.length >> 8) & 0xFF;
      avccData[offset++] = pps.length & 0xFF;
      avccData.set(pps, offset);
      offset += pps.length;
    });
    
    return avccData;
  }
  
  private extractAudioSpecificConfig(track: any): Uint8Array {
    // AAC AudioSpecificConfig 추출
    const audioConfig = track.audioConfig;
    if (!audioConfig) return new Uint8Array();
    
    // AudioSpecificConfig 구성
    const config = new Uint8Array(2);
    config[0] = (audioConfig.audioObjectType << 3) | (audioConfig.samplingFrequencyIndex >> 1);
    config[1] = ((audioConfig.samplingFrequencyIndex & 1) << 7) | (audioConfig.channelConfiguration << 3);
    
    return config;
  }
  
  private startExtraction(tracks: any[]): void {
    // 각 트랙에 대해 샘플 추출 설정
    tracks.forEach(track => {
      this.mp4boxFile.setExtractionOptions(track.id, this, {
        nbSamples: 100 // 100개 샘플씩 배치 처리
      });
    });
    
    // 추출 시작
    this.mp4boxFile.start();
  }
  
  private handleSamples(trackId: number, samples: any[]): void {
    samples.forEach(sample => {
      // EncodedVideoChunk 또는 EncodedAudioChunk 생성
      const chunk = this.createWebCodecChunk(sample);
      
      // 샘플 준비 완료 콜백
      if (this.onSampleReady) {
        this.onSampleReady({
          trackId,
          chunk,
          sample
        });
      }
    });
  }
  
  private createWebCodecChunk(sample: any): EncodedVideoChunk | EncodedAudioChunk {
    // 샘플 데이터를 WebCodec 청크로 변환
    const chunkData = {
      type: sample.is_sync ? 'key' as const : 'delta' as const,
      timestamp: sample.cts, // Composition Time Stamp
      duration: sample.duration,
      data: sample.data
    };
    
    // 비디오 또는 오디오 청크 생성
    if (sample.description && sample.description.type === 'video') {
      return new EncodedVideoChunk(chunkData);
    } else {
      return new EncodedAudioChunk(chunkData);
    }
  }
  
  private handleSegment(id: number, buffer: ArrayBuffer): void {
    // DASH/HLS 세그먼트 처리
    console.log(`세그먼트 ${id} 처리: ${buffer.byteLength} bytes`);
    
    // 세그먼트를 서버에 업로드하거나 CDN에 저장
    this.uploadSegment(id, buffer);
  }
  
  private async uploadSegment(id: number, buffer: ArrayBuffer): Promise<void> {
    try {
      const response = await fetch(`/api/segments/${id}`, {
        method: 'POST',
        headers: {
          'Content-Type': 'video/mp4'
        },
        body: buffer
      });
      
      if (response.ok) {
        console.log(`세그먼트 ${id} 업로드 완료`);
      }
    } catch (error) {
      console.error(`세그먼트 ${id} 업로드 실패:`, error);
    }
  }
  
  public setTrackReadyCallback(callback: (trackInfo: any) => void): void {
    this.onTrackReady = callback;
  }
  
  public setSampleReadyCallback(callback: (sample: any) => void): void {
    this.onSampleReady = callback;
  }
  
  public createDASHSegments(trackId: number, segmentDuration: number = 2000): void {
    // DASH 세그멘테이션 설정
    this.mp4boxFile.setSegmentOptions(trackId, this, {
      duration: segmentDuration, // 밀리초 단위
      rapAlignement: true // Random Access Point 정렬
    });
  }
  
  public flush(): void {
    // 버퍼링된 데이터 모두 처리
    this.mp4boxFile.flush();
  }
}

MP4 파일 생성 및 수정

class MP4FileGenerator {
  private mp4boxFile: any;
  private tracks: Map<number, any> = new Map();
  private nextTrackId: number = 1;
  
  constructor() {
    this.mp4boxFile = MP4Box.createFile();
  }
  
  public addVideoTrack(config: {
    width: number;
    height: number;
    codec: string;
    timescale?: number;
    framerate?: number;
  }): number {
    const trackId = this.nextTrackId++;
    
    // 비디오 트랙 추가
    const trackInfo = this.mp4boxFile.addTrack({
      id: trackId,
      type: 'video',
      width: config.width,
      height: config.height,
      timescale: config.timescale || 90000,
      duration: 0,
      codec: config.codec,
      avcDecoderConfigRecord: this.createAVCConfig(config)
    });
    
    this.tracks.set(trackId, {
      type: 'video',
      info: trackInfo,
      sampleCount: 0
    });
    
    return trackId;
  }
  
  public addAudioTrack(config: {
    sampleRate: number;
    channels: number;
    codec: string;
    timescale?: number;
  }): number {
    const trackId = this.nextTrackId++;
    
    // 오디오 트랙 추가
    const trackInfo = this.mp4boxFile.addTrack({
      id: trackId,
      type: 'audio',
      timescale: config.timescale || config.sampleRate,
      samplerate: config.sampleRate,
      channel_count: config.channels,
      hdlr: 'soun',
      name: 'audio track',
      codec: config.codec
    });
    
    this.tracks.set(trackId, {
      type: 'audio',
      info: trackInfo,
      sampleCount: 0
    });
    
    return trackId;
  }
  
  private createAVCConfig(config: any): Uint8Array {
    // 간단한 AVC 설정 레코드 생성
    // 실제 구현에서는 SPS/PPS 파라미터가 필요
    const avcConfig = new Uint8Array(7);
    avcConfig[0] = 1; // configurationVersion
    avcConfig[1] = 0x64; // AVCProfileIndication (High Profile)
    avcConfig[2] = 0x00; // profile_compatibility
    avcConfig[3] = 0x1f; // AVCLevelIndication (Level 3.1)
    avcConfig[4] = 0xff; // lengthSizeMinusOne
    avcConfig[5] = 0xe0; // SPS 개수 (0)
    avcConfig[6] = 0x00; // PPS 개수 (0)
    
    return avcConfig;
  }
  
  public addSample(trackId: number, chunk: EncodedVideoChunk | EncodedAudioChunk): void {
    const track = this.tracks.get(trackId);
    if (!track) {
      throw new Error(`트랙 ${trackId}를 찾을 수 없습니다`);
    }
    
    // 청크 데이터를 ArrayBuffer로 복사
    const sampleData = new ArrayBuffer(chunk.byteLength);
    chunk.copyTo(sampleData);
    
    // MP4Box 샘플 형태로 변환
    const sample = {
      data: sampleData,
      size: chunk.byteLength,
      duration: chunk.duration || 3000, // 기본값
      dts: chunk.timestamp,
      cts: chunk.timestamp,
      is_sync: chunk.type === 'key'
    };
    
    // 샘플 추가
    this.mp4boxFile.addSample(trackId, sample.data, sample);
    
    track.sampleCount++;
  }
  
  public finalize(): ArrayBuffer {
    // MP4 파일 완성
    const mp4ArrayBuffer = this.mp4boxFile.getBuffer();
    
    console.log('MP4 파일 생성 완료:', {
      size: mp4ArrayBuffer.byteLength,
      tracks: this.tracks.size,
      samples: Array.from(this.tracks.values()).reduce((sum, track) => sum + track.sampleCount, 0)
    });
    
    return mp4ArrayBuffer;
  }
  
  public save(filename: string = 'generated-video.mp4'): void {
    const mp4Buffer = this.finalize();
    
    // Blob 생성 후 다운로드
    const blob = new Blob([mp4Buffer], { type: 'video/mp4' });
    const url = URL.createObjectURL(blob);
    
    const link = document.createElement('a');
    link.href = url;
    link.download = filename;
    link.click();
    
    // 메모리 정리
    URL.revokeObjectURL(url);
  }
}

통합 활용 사례

실시간 비디오 편집기

class WebVideoEditor {
  private decoder: AdvancedVideoDecoder;
  private encoder: AdvancedVideoEncoder;
  private mp4Parser: RealTimeMP4Parser;
  private fileGenerator: MP4FileGenerator;
  private canvas: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  
  constructor(canvasId: string) {
    this.canvas = document.getElementById(canvasId) as HTMLCanvasElement;
    this.ctx = this.canvas.getContext('2d')!;
    
    this.initializeComponents();
  }
  
  private initializeComponents(): void {
    // MP4 파서 설정
    this.mp4Parser = new RealTimeMP4Parser();
    this.mp4Parser.setTrackReadyCallback((track) => this.handleTrackReady(track));
    this.mp4Parser.setSampleReadyCallback((sample) => this.handleSampleReady(sample));
    
    // 파일 생성기 설정
    this.fileGenerator = new MP4FileGenerator();
  }
  
  private handleTrackReady(track: any): void {
    if (track.type === 'video') {
      // 디코더 설정
      const decoderConfig: VideoDecoderConfig = {
        codec: track.codec,
        codedWidth: track.video.width,
        codedHeight: track.video.height
      };
      
      this.decoder = new AdvancedVideoDecoder(decoderConfig);
      
      // 인코더 설정 (편집된 비디오용)
      const encoderConfig: VideoEncoderConfig = {
        codec: 'avc1.42E01E', // H.264 Baseline
        width: track.video.width,
        height: track.video.height,
        bitrate: 2000000, // 2Mbps
        framerate: 30
      };
      
      this.encoder = new AdvancedVideoEncoder(encoderConfig);
      
      // 출력 트랙 생성
      this.fileGenerator.addVideoTrack({
        width: track.video.width,
        height: track.video.height,
        codec: 'avc1.42E01E',
        framerate: 30
      });
    }
  }
  
  private handleSampleReady(sampleData: any): void {
    if (this.decoder) {
      // 디코딩
      this.decoder.decode(sampleData.chunk);
    }
  }
  
  public async loadVideo(file: File): Promise<void> {
    const arrayBuffer = await file.arrayBuffer();
    this.mp4Parser.appendData(arrayBuffer);
  }
  
  public applyFilter(filterType: string): void {
    // 필터 적용 로직
    switch (filterType) {
      case 'sepia':
        this.applySepia();
        break;
      case 'blur':
        this.applyBlur();
        break;
      case 'sharpen':
        this.applySharpen();
        break;
    }
  }
  
  private applySepia(): void {
    const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
    const data = imageData.data;
    
    for (let i = 0; i < data.length; i += 4) {
      const r = data[i];
      const g = data[i + 1];
      const b = data[i + 2];
      
      data[i] = Math.min(255, (r * 0.393) + (g * 0.769) + (b * 0.189));
      data[i + 1] = Math.min(255, (r * 0.349) + (g * 0.686) + (b * 0.168));
      data[i + 2] = Math.min(255, (r * 0.272) + (g * 0.534) + (b * 0.131));
    }
    
    this.ctx.putImageData(imageData, 0, 0);
  }
  
  private applyBlur(): void {
    // Canvas blur 필터 적용
    this.ctx.filter = 'blur(2px)';
    
    // 현재 캔버스 내용을 복사하여 블러 적용
    const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
    this.ctx.putImageData(imageData, 0, 0);
    
    // 필터 리셋
    this.ctx.filter = 'none';
  }
  
  private applySharpen(): void {
    // 샤프닝 컨볼루션 필터 구현
    const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
    const data = imageData.data;
    const width = this.canvas.width;
    const height = this.canvas.height;
    
    // 샤프닝 커널
    const kernel = [
      0, -1, 0,
      -1, 5, -1,
      0, -1, 0
    ];
    
    const output = new Uint8ClampedArray(data);
    
    for (let y = 1; y < height - 1; y++) {
      for (let x = 1; x < width - 1; x++) {
        for (let c = 0; c < 3; c++) { // RGB 채널만
          let sum = 0;
          
          for (let ky = -1; ky <= 1; ky++) {
            for (let kx = -1; kx <= 1; kx++) {
              const pixel = ((y + ky) * width + (x + kx)) * 4 + c;
              const kernelValue = kernel[(ky + 1) * 3 + (kx + 1)];
              sum += data[pixel] * kernelValue;
            }
          }
          
          output[(y * width + x) * 4 + c] = Math.max(0, Math.min(255, sum));
        }
      }
    }
    
    const outputImageData = new ImageData(output, width, height);
    this.ctx.putImageData(outputImageData, 0, 0);
  }
  
  public async exportVideo(): Promise<void> {
    // 편집된 프레임들을 인코딩하여 새 비디오 생성
    
    // Canvas에서 VideoFrame 생성
    const frame = new VideoFrame(this.canvas, {
      timestamp: performance.now() * 1000
    });
    
    // 인코딩
    await this.encoder.encodeFrame(frame);
    
    // MP4 파일로 저장
    this.fileGenerator.save('edited-video.mp4');
  }
  
  public getProcessingStats(): object {
    return {
      decoder: this.decoder?.getDecodingStats().getSummary(),
      encoder: this.encoder?.getEncodingStats().getSummary()
    };
  }
}

// 사용 예시
const videoEditor = new WebVideoEditor('editorCanvas');

// 비디오 파일 로드
document.getElementById('fileInput')?.addEventListener('change', async (event) => {
  const file = (event.target as HTMLInputElement).files?.[0];
  if (file) {
    await videoEditor.loadVideo(file);
  }
});

// 필터 적용
document.getElementById('sepiaBtn')?.addEventListener('click', () => {
  videoEditor.applyFilter('sepia');
});

// 비디오 내보내기
document.getElementById('exportBtn')?.addEventListener('click', () => {
  videoEditor.exportVideo();
});

라이브 스트리밍 시스템

class LiveStreamingSystem {
  private mediaStream: MediaStream | null = null;
  private encoder: AdvancedVideoEncoder;
  private websocket: WebSocket;
  private isStreaming: boolean = false;
  private frameCount: number = 0;
  
  constructor(websocketUrl: string) {
    this.websocket = new WebSocket(websocketUrl);
    this.setupWebSocket();
  }
  
  private setupWebSocket(): void {
    this.websocket.onopen = () => {
      console.log('WebSocket 연결됨');
    };
    
    this.websocket.onmessage = (event) => {
      const message = JSON.parse(event.data);
      this.handleServerMessage(message);
    };
    
    this.websocket.onclose = () => {
      console.log('WebSocket 연결 종료');
      this.stopStreaming();
    };
  }
  
  private handleServerMessage(message: any): void {
    switch (message.type) {
      case 'bitrate_change':
        this.adjustBitrate(message.bitrate);
        break;
      case 'keyframe_request':
        this.requestKeyframe();
        break;
    }
  }
  
  public async startStreaming(constraints: MediaStreamConstraints): Promise<void> {
    try {
      // 미디어 스트림 획득
      this.mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
      
      // 인코더 설정
      const video = constraints.video as MediaTrackConstraints;
      const encoderConfig: VideoEncoderConfig = {
        codec: 'avc1.42E01E',
        width: video.width as number || 1280,
        height: video.height as number || 720,
        bitrate: 2000000,
        framerate: 30,
        latencyMode: 'realtime',
        bitrateMode: 'variable'
      };
      
      this.encoder = new AdvancedVideoEncoder(encoderConfig);
      
      // 비디오 캡처 시작
      this.startVideoCapture();
      
      this.isStreaming = true;
      console.log('라이브 스트리밍 시작');
      
    } catch (error) {
      console.error('스트리밍 시작 실패:', error);
    }
  }
  
  private startVideoCapture(): void {
    if (!this.mediaStream) return;
    
    const video = document.createElement('video');
    video.srcObject = this.mediaStream;
    video.play();
    
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d')!;
    
    video.addEventListener('loadedmetadata', () => {
      canvas.width = video.videoWidth;
      canvas.height = video.videoHeight;
      
      this.captureFrames(video, canvas, ctx);
    });
  }
  
  private captureFrames(video: HTMLVideoElement, canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void {
    if (!this.isStreaming) return;
    
    // 비디오 프레임을 캔버스에 그리기
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
    
    // VideoFrame 생성
    const timestamp = performance.now() * 1000;
    const frame = new VideoFrame(canvas, { timestamp });
    
    // 키프레임 주기 결정 (매 30프레임마다)
    const isKeyframe = this.frameCount % 30 === 0;
    
    // 인코딩
    this.encoder.encodeFrame(frame, { keyFrame: isKeyframe })
      .then(() => {
        frame.close();
        this.frameCount++;
      })
      .catch(error => {
        console.error('프레임 인코딩 실패:', error);
        frame.close();
      });
    
    // 다음 프레임 스케줄링 (30fps)
    setTimeout(() => this.captureFrames(video, canvas, ctx), 1000 / 30);
  }
  
  private streamEncodedChunk(chunk: EncodedVideoChunk, metadata?: EncodedVideoChunkMetadata): void {
    if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
      return;
    }
    
    // 청크 데이터를 ArrayBuffer로 복사
    const chunkData = new ArrayBuffer(chunk.byteLength);
    chunk.copyTo(chunkData);
    
    // 메타데이터와 함께 전송
    const message = {
      type: 'video_chunk',
      timestamp: chunk.timestamp,
      duration: chunk.duration,
      keyframe: chunk.type === 'key',
      data: Array.from(new Uint8Array(chunkData)) // JSON 직렬화를 위해 배열로 변환
    };
    
    this.websocket.send(JSON.stringify(message));
  }
  
  private adjustBitrate(newBitrate: number): void {
    // 동적 비트레이트 조정
    console.log(`비트레이트 조정: ${newBitrate}bps`);
    
    // 새 인코더 설정으로 재구성 (실제로는 더 효율적인 방법 필요)
    // 현재 WebCodec API는 실시간 비트레이트 조정이 제한적
  }
  
  private requestKeyframe(): void {
    console.log('키프레임 요청 받음');
    // 다음 프레임을 키프레임으로 강제 설정
    this.frameCount = 0;
  }
  
  public stopStreaming(): void {
    this.isStreaming = false;
    
    if (this.mediaStream) {
      this.mediaStream.getTracks().forEach(track => track.stop());
      this.mediaStream = null;
    }
    
    if (this.encoder) {
      this.encoder.close();
    }
    
    console.log('라이브 스트리밍 중지');
  }
  
  public getStreamingStats(): object {
    return {
      frameCount: this.frameCount,
      isStreaming: this.isStreaming,
      encoderStats: this.encoder?.getEncodingStats().getSummary(),
      websocketState: this.websocket?.readyState
    };
  }
}

// 사용 예시
const liveStreaming = new LiveStreamingSystem('ws://localhost:8080/stream');

// 스트리밍 시작
document.getElementById('startStream')?.addEventListener('click', () => {
  liveStreaming.startStreaming({
    video: {
      width: 1280,
      height: 720,
      frameRate: 30
    },
    audio: false // 비디오만 스트리밍
  });
});

// 스트리밍 중지
document.getElementById('stopStream')?.addEventListener('click', () => {
  liveStreaming.stopStreaming();
});

성능 최적화 및 모니터링

메모리 관리 최적화

class MemoryOptimizer {
  private framePool: VideoFrame[] = [];
  private chunkPool: (EncodedVideoChunk | EncodedAudioChunk)[] = [];
  private maxPoolSize: number = 20;
  private memoryStats: MemoryStats;
  
  constructor() {
    this.memoryStats = new MemoryStats();
    this.startMemoryMonitoring();
  }
  
  public getFrame(): VideoFrame | null {
    if (this.framePool.length > 0) {
      const frame = this.framePool.pop()!;
      this.memoryStats.recordFrameReuse();
      return frame;
    }
    return null;
  }
  
  public releaseFrame(frame: VideoFrame): void {
    if (this.framePool.length < this.maxPoolSize) {
      this.framePool.push(frame);
      this.memoryStats.recordFramePooled();
    } else {
      frame.close();
      this.memoryStats.recordFrameDiscarded();
    }
  }
  
  public optimizeChunkUsage(chunk: EncodedVideoChunk | EncodedAudioChunk): void {
    // 청크 데이터 즉시 처리 후 해제
    this.processChunkImmediately(chunk);
  }
  
  private processChunkImmediately(chunk: EncodedVideoChunk | EncodedAudioChunk): void {
    // 청크를 즉시 처리하여 메모리 점유 시간 최소화
    const data = new ArrayBuffer(chunk.byteLength);
    chunk.copyTo(data);
    
    // 처리 로직
    this.handleChunkData(data, {
      type: chunk.type,
      timestamp: chunk.timestamp,
      duration: chunk.duration
    });
  }
  
  private handleChunkData(data: ArrayBuffer, metadata: any): void {
    // 실제 청크 데이터 처리 로직
    console.log('청크 처리:', metadata);
  }
  
  private startMemoryMonitoring(): void {
    setInterval(() => {
      this.memoryStats.updateStats();
      this.checkMemoryPressure();
    }, 5000); // 5초마다 모니터링
  }
  
  private checkMemoryPressure(): void {
    const stats = this.memoryStats.getCurrentStats();
    
    if (stats.usedJSHeapSize > 100 * 1024 * 1024) { // 100MB 초과
      console.warn('메모리 사용량 높음, 정리 수행');
      this.forceCleanup();
    }
  }
  
  private forceCleanup(): void {
    // 강제 정리
    this.framePool.forEach(frame => frame.close());
    this.framePool = [];
    
    this.chunkPool = [];
    
    // 가비지 컬렉션 유도
    if (window.gc) {
      window.gc();
    }
    
    this.memoryStats.recordCleanup();
  }
  
  public getMemoryReport(): object {
    return this.memoryStats.getReport();
  }
}

class MemoryStats {
  private frameReused: number = 0;
  private framePooled: number = 0;
  private frameDiscarded: number = 0;
  private cleanupCount: number = 0;
  private heapStats: any = {};
  
  recordFrameReuse(): void {
    this.frameReused++;
  }
  
  recordFramePooled(): void {
    this.framePooled++;
  }
  
  recordFrameDiscarded(): void {
    this.frameDiscarded++;
  }
  
  recordCleanup(): void {
    this.cleanupCount++;
  }
  
  updateStats(): void {
    if ('memory' in performance) {
      this.heapStats = {
        usedJSHeapSize: (performance as any).memory.usedJSHeapSize,
        totalJSHeapSize: (performance as any).memory.totalJSHeapSize,
        jsHeapSizeLimit: (performance as any).memory.jsHeapSizeLimit
      };
    }
  }
  
  getCurrentStats(): any {
    return this.heapStats;
  }
  
  getReport(): object {
    return {
      frames: {
        reused: this.frameReused,
        pooled: this.framePooled,
        discarded: this.frameDiscarded,
        reuseRate: this.frameReused / (this.frameReused + this.frameDiscarded)
      },
      memory: this.heapStats,
      cleanups: this.cleanupCount
    };
  }
}

성능 벤치마킹

class PerformanceBenchmark {
  private metrics: Map<string, number[]> = new Map();
  private startTimes: Map<string, number> = new Map();
  
  startMeasure(operationName: string): void {
    this.startTimes.set(operationName, performance.now());
  }
  
  endMeasure(operationName: string): number {
    const startTime = this.startTimes.get(operationName);
    if (!startTime) {
      throw new Error(`측정이 시작되지 않음: ${operationName}`);
    }
    
    const duration = performance.now() - startTime;
    
    if (!this.metrics.has(operationName)) {
      this.metrics.set(operationName, []);
    }
    this.metrics.get(operationName)!.push(duration);
    
    this.startTimes.delete(operationName);
    return duration;
  }
  
  async benchmarkDecoding(): Promise<BenchmarkResult> {
    const testConfig: VideoDecoderConfig = {
      codec: 'avc1.42E01E',
      codedWidth: 1920,
      codedHeight: 1080
    };
    
    this.startMeasure('decoder_init');
    const decoder = new AdvancedVideoDecoder(testConfig);
    this.endMeasure('decoder_init');
    
    // 테스트 청크 생성 (실제로는 실제 비디오 데이터 필요)
    const testChunks = this.generateTestChunks();
    
    this.startMeasure('decoding_batch');
    
    for (const chunk of testChunks) {
      this.startMeasure('single_decode');
      decoder.decode(chunk);
      this.endMeasure('single_decode');
    }
    
    await decoder.flush();
    this.endMeasure('decoding_batch');
    
    decoder.close();
    
    return this.calculateResults('decoding');
  }
  
  async benchmarkEncoding(): Promise<BenchmarkResult> {
    const testConfig: VideoEncoderConfig = {
      codec: 'avc1.42E01E',
      width: 1920,
      height: 1080,
      bitrate: 5000000,
      framerate: 30
    };
    
    this.startMeasure('encoder_init');
    const encoder = new AdvancedVideoEncoder(testConfig);
    this.endMeasure('encoder_init');
    
    // 테스트 프레임 생성
    const testFrames = this.generateTestFrames();
    
    this.startMeasure('encoding_batch');
    
    for (const frame of testFrames) {
      this.startMeasure('single_encode');
      await encoder.encodeFrame(frame);
      this.endMeasure('single_encode');
    }
    
    await encoder.flush();
    this.endMeasure('encoding_batch');
    
    encoder.close();
    
    return this.calculateResults('encoding');
  }
  
  private generateTestChunks(): EncodedVideoChunk[] {
    // 테스트용 청크 생성
    const chunks: EncodedVideoChunk[] = [];
    
    for (let i = 0; i < 100; i++) {
      // 가짜 H.264 데이터 (실제 테스트에는 유효한 데이터 필요)
      const fakeData = new Uint8Array(1000);
      fakeData.fill(i % 256);
      
      chunks.push(new EncodedVideoChunk({
        type: i === 0 ? 'key' : 'delta',
        timestamp: i * 33333, // 30fps
        duration: 33333,
        data: fakeData
      }));
    }
    
    return chunks;
  }
  
  private generateTestFrames(): VideoFrame[] {
    const frames: VideoFrame[] = [];
    
    // 테스트용 캔버스 생성
    const canvas = document.createElement('canvas');
    canvas.width = 1920;
    canvas.height = 1080;
    const ctx = canvas.getContext('2d')!;
    
    for (let i = 0; i < 100; i++) {
      // 간단한 테스트 패턴 그리기
      ctx.fillStyle = `hsl(${i * 3.6}, 50%, 50%)`;
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      
      frames.push(new VideoFrame(canvas, {
        timestamp: i * 33333,
        duration: 33333
      }));
    }
    
    return frames;
  }
  
  private calculateResults(operation: string): BenchmarkResult {
    const initTimes = this.metrics.get(`${operation}_init`) || [];
    const singleTimes = this.metrics.get(`single_${operation.slice(0, -3)}`) || [];
    const batchTimes = this.metrics.get(`${operation}_batch`) || [];
    
    return {
      operation,
      initTime: {
        average: this.calculateAverage(initTimes),
        min: Math.min(...initTimes),
        max: Math.max(...initTimes)
      },
      singleOperationTime: {
        average: this.calculateAverage(singleTimes),
        min: Math.min(...singleTimes),
        max: Math.max(...singleTimes),
        p95: this.calculatePercentile(singleTimes, 0.95)
      },
      batchTime: {
        total: batchTimes[0] || 0,
        throughput: singleTimes.length / ((batchTimes[0] || 1) / 1000) // ops/sec
      }
    };
  }
  
  private calculateAverage(values: number[]): number {
    return values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
  }
  
  private calculatePercentile(values: number[], percentile: number): number {
    const sorted = values.slice().sort((a, b) => a - b);
    const index = Math.ceil(sorted.length * percentile) - 1;
    return sorted[index] || 0;
  }
  
  public async runFullBenchmark(): Promise<{decoding: BenchmarkResult, encoding: BenchmarkResult}> {
    console.log('벤치마크 시작...');
    
    const decodingResults = await this.benchmarkDecoding();
    const encodingResults = await this.benchmarkEncoding();
    
    console.log('벤치마크 완료');
    
    return {
      decoding: decodingResults,
      encoding: encodingResults
    };
  }
}

interface BenchmarkResult {
  operation: string;
  initTime: {
    average: number;
    min: number;
    max: number;
  };
  singleOperationTime: {
    average: number;
    min: number;
    max: number;
    p95: number;
  };
  batchTime: {
    total: number;
    throughput: number;
  };
}

// 벤치마크 실행
const benchmark = new PerformanceBenchmark();
benchmark.runFullBenchmark().then(results => {
  console.log('벤치마크 결과:', results);
});

결론

WebCodec API와 MP4Box.js의 결합은 웹 플랫폼에서 전문가급 미디어 처리를 가능하게 만들었습니다:

핵심 장점

  1. 하드웨어 가속: 네이티브 수준의 인코딩/디코딩 성능
  2. 저수준 제어: 정밀한 미디어 처리 및 최적화 가능
  3. 실시간 처리: 라이브 스트리밍과 실시간 편집 지원
  4. 표준 호환성: MP4, WebM 등 표준 컨테이너 형식 완벽 지원

실무 적용 분야

  • 비디오 편집 도구: 브라우저 기반 전문 편집 소프트웨어
  • 라이브 스트리밍: 실시간 방송 및 화상회의 시스템
  • 미디어 변환: 클라우드 기반 트랜스코딩 서비스
  • 게임 스트리밍: 고품질 게임 화면 전송

웹 기술의 지속적인 발전과 함께, 이러한 API들은 미디어 처리의 새로운 표준으로 자리잡을 것입니다. 하드웨어 가속과 표준화된 인터페이스를 통해 웹이 미디어 생산과 소비의 핵심 플랫폼으로 성장하고 있습니다.