> ## Documentation Index
> Fetch the complete documentation index at: https://mintlify.com/BintzGavin/helios/llms.txt
> Use this file to discover all available pages before exploring further.

# Browser export with WebCodecs

> Export videos directly in the browser using the ClientSideExporter

The `ClientSideExporter` class enables video export directly in the browser using the WebCodecs API and MediaBunny library, without requiring a server or FFmpeg.

## Overview

Client-side export is useful for:

* Interactive video editors where users can preview and export
* Serverless applications that want to avoid backend rendering costs
* Progressive web apps that work offline
* Quick previews during development

<Note>
  Client-side export requires modern browsers with WebCodecs support (Chrome 94+, Edge 94+). It will not work in Firefox or Safari.
</Note>

## Basic usage

```typescript theme={null}
import { HeliosPlayer } from '@helios-project/player';

const player = new HeliosPlayer({
  src: './composition.html',
  container: document.getElementById('player-container')
});

const exporter = player.getExporter();

await exporter.export({
  format: 'mp4',
  onProgress: (progress) => {
    console.log(`Export: ${(progress * 100).toFixed(1)}%`);
  }
});
```

## Export options

<ParamField path="format" type="'mp4' | 'webm' | 'png' | 'jpeg'" default="'mp4'">
  Output format:

  * `'mp4'`: H.264 video in MP4 container
  * `'webm'`: VP9 video in WebM container
  * `'png'`: Single frame snapshot as PNG
  * `'jpeg'`: Single frame snapshot as JPEG
</ParamField>

<ParamField path="mode" type="'auto' | 'canvas' | 'dom'" default="'auto'">
  Capture mode:

  * `'auto'`: Try canvas first, fall back to DOM
  * `'canvas'`: Capture from HTMLCanvasElement
  * `'dom'`: Capture from DOM using screenshots
</ParamField>

<ParamField path="canvasSelector" type="string" default="'canvas'">
  CSS selector to find the canvas element in canvas mode
</ParamField>

<ParamField path="width" type="number">
  Override output width (defaults to composition width)
</ParamField>

<ParamField path="height" type="number">
  Override output height (defaults to composition height)
</ParamField>

<ParamField path="bitrate" type="number" default="5000000">
  Video bitrate in bits per second (default: 5 Mbps)
</ParamField>

<ParamField path="includeCaptions" type="boolean" default="true">
  Whether to burn captions into the video
</ParamField>

<ParamField path="captionStyle" type="object">
  Caption styling options:

  ```typescript theme={null}
  {
    color?: string;           // Text color (default: 'white')
    backgroundColor?: string; // Background color (default: 'rgba(0,0,0,0.7)')
    fontFamily?: string;      // Font family (default: 'sans-serif')
    scale?: number;           // Font scale (default: 0.05)
  }
  ```
</ParamField>

<ParamField path="filename" type="string" default="'video'">
  Output filename (without extension)
</ParamField>

<ParamField path="onProgress" type="(progress: number) => void" required>
  Progress callback receiving values from 0 to 1
</ParamField>

<ParamField path="signal" type="AbortSignal">
  AbortSignal to cancel the export
</ParamField>

## Export workflow

<Steps>
  <Step title="Pause playback">
    The exporter pauses the player to ensure consistent frame capture:

    ```typescript theme={null}
    this.controller.pause();
    ```
  </Step>

  <Step title="Determine capture mode">
    If mode is `'auto'`, the exporter attempts to capture from canvas first:

    ```typescript theme={null}
    const result = await this.controller.captureFrame(0, {
      selector: canvasSelector,
      mode: 'canvas'
    });

    if (result && result.frame) {
      effectiveMode = 'canvas';
    } else {
      effectiveMode = 'dom';
      console.log("Falling back to DOM mode");
    }
    ```
  </Step>

  <Step title="Initialize MediaBunny output">
    Create output with the selected format:

    ```typescript theme={null}
    const target = new BufferTarget();
    const outputFormat = format === 'webm' 
      ? new WebMOutputFormat() 
      : new Mp4OutputFormat();

    const output = new Output({ format: outputFormat, target });
    ```
  </Step>

  <Step title="Setup video track">
    Configure video encoding:

    ```typescript theme={null}
    const videoConfig = {
      codec: format === 'webm' ? 'vp9' : 'avc',
      bitrate: bitrate ?? 5_000_000
    };

    const videoSource = new VideoSampleSource(videoConfig);
    output.addVideoTrack(videoSource);
    ```
  </Step>

  <Step title="Setup audio track">
    If audio tracks exist, configure audio encoding:

    ```typescript theme={null}
    const audioTracks = await this.controller.getAudioTracks();

    if (audioTracks.length > 0) {
      const audioConfig = format === 'webm'
        ? { codec: 'opus' }
        : { codec: 'aac' };
      
      const audioSource = new AudioSampleSource(audioConfig);
      output.addAudioTrack(audioSource);
    }
    ```
  </Step>

  <Step title="Capture and encode frames">
    Loop through all frames in the composition:

    ```typescript theme={null}
    for (let i = 0; i < totalFrames; i++) {
      const frameIndex = startFrame + i;
      const result = await this.controller.captureFrame(frameIndex, {
        selector: canvasSelector,
        mode: effectiveMode,
        width: targetWidth,
        height: targetHeight
      });
      
      let finalFrame = result.frame;
      
      // Optionally burn captions
      if (includeCaptions && result.captions?.length > 0) {
        finalFrame = await this.drawCaptions(
          result.frame,
          result.captions,
          captionStyle
        );
        result.frame.close();
      }
      
      const keyFrame = i % (fps * 2) === 0;
      await videoSource.add(new VideoSample(finalFrame), { keyFrame });
      
      finalFrame.close();
      onProgress((i + 1) / totalFrames);
    }
    ```
  </Step>

  <Step title="Process audio">
    Mix all audio tracks into a single buffer:

    ```typescript theme={null}
    const durationInSeconds = totalFrames / fps;
    const audioBuffer = await mixAudio(
      audioTracks,
      durationInSeconds,
      48000,
      rangeStartInSeconds
    );

    // Convert to planar format for MediaBunny
    const c0 = audioBuffer.getChannelData(0);
    const c1 = audioBuffer.getChannelData(1);
    const planarData = new Float32Array(c0.length + c1.length);
    planarData.set(c0, 0);
    planarData.set(c1, c0.length);

    const sample = new AudioSample({
      format: 'f32-planar',
      sampleRate: 48000,
      numberOfChannels: 2,
      timestamp: 0,
      data: planarData
    });

    await audioSource.add(sample);
    ```
  </Step>

  <Step title="Finalize and download">
    Complete encoding and trigger browser download:

    ```typescript theme={null}
    await output.finalize();

    if (target.buffer) {
      const blob = new Blob([target.buffer], { 
        type: format === 'webm' ? 'video/webm' : 'video/mp4' 
      });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = `${filename}.${format}`;
      a.click();
      URL.revokeObjectURL(url);
    }
    ```
  </Step>
</Steps>

## Image snapshots

Export single frames as images:

```typescript theme={null}
await exporter.export({
  format: 'png',
  onProgress: (progress) => console.log(progress)
});
```

The snapshot captures the current frame and optionally burns captions:

```typescript theme={null}
const frameToCapture = state.currentFrame;
const result = await this.controller.captureFrame(frameToCapture, {
  selector: canvasSelector,
  mode: effectiveMode,
  width: targetWidth,
  height: targetHeight
});

let finalFrame = result.frame;

if (includeCaptions && result.captions?.length > 0) {
  finalFrame = await this.drawCaptions(
    result.frame,
    result.captions,
    captionStyle
  );
  result.frame.close();
}

// Convert VideoFrame to Blob
const canvas = new OffscreenCanvas(finalFrame.displayWidth, finalFrame.displayHeight);
const ctx = canvas.getContext('2d');
ctx.drawImage(finalFrame, 0, 0);
finalFrame.close();

const blob = await canvas.convertToBlob({ type: 'image/png' });
```

## Caption rendering

Captions are burned into the video by drawing them onto each frame:

```typescript theme={null}
private async drawCaptions(
  frame: VideoFrame,
  captions: CaptionCue[],
  style?: CaptionStyle
): Promise<VideoFrame> {
  const width = frame.displayWidth;
  const height = frame.displayHeight;
  
  const canvas = new OffscreenCanvas(width, height);
  const ctx = canvas.getContext('2d');
  
  // Draw original frame
  ctx.drawImage(frame, 0, 0);
  
  // Configure caption styling
  const scale = style?.scale ?? 0.05;
  const fontSize = Math.max(16, Math.round(height * scale));
  const padding = fontSize * 0.5;
  const lineHeight = fontSize * 1.2;
  const bottomMargin = height * 0.05;
  
  ctx.font = `${fontSize}px ${style?.fontFamily || 'sans-serif'}`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'top';
  
  let currentBottomY = height - bottomMargin;
  
  // Render captions from bottom to top
  [...captions].reverse().forEach(cue => {
    const lines = cue.text.split('\n');
    const cueHeight = lines.length * lineHeight + (padding * 2);
    
    // Measure text for background
    let maxLineWidth = 0;
    lines.forEach(line => {
      const m = ctx.measureText(line);
      if (m.width > maxLineWidth) maxLineWidth = m.width;
    });
    
    const bgWidth = maxLineWidth + (fontSize * 1.0);
    const bgTopY = currentBottomY - cueHeight;
    
    // Draw background
    ctx.fillStyle = style?.backgroundColor || 'rgba(0, 0, 0, 0.7)';
    ctx.fillRect((width / 2) - (bgWidth / 2), bgTopY, bgWidth, cueHeight);
    
    // Draw text with shadow
    ctx.shadowColor = 'black';
    ctx.shadowBlur = 2;
    ctx.shadowOffsetY = 1;
    ctx.fillStyle = style?.color || 'white';
    
    lines.forEach((line, i) => {
      const y = bgTopY + padding + (i * lineHeight);
      ctx.fillText(line, width / 2, y);
    });
    
    currentBottomY -= (cueHeight + 4);
  });
  
  return new VideoFrame(canvas, { timestamp: frame.timestamp });
}
```

## Example: Interactive export UI

```typescript theme={null}
import { HeliosPlayer } from '@helios-project/player';

const player = new HeliosPlayer({
  src: './composition.html',
  container: document.getElementById('player-container')
});

const exporter = player.getExporter();

// Export button
document.getElementById('export-btn').addEventListener('click', async () => {
  const format = document.getElementById('format-select').value;
  const progressBar = document.getElementById('progress-bar');
  const statusText = document.getElementById('status-text');
  
  const controller = new AbortController();
  
  // Cancel button
  const cancelBtn = document.getElementById('cancel-btn');
  cancelBtn.addEventListener('click', () => controller.abort());
  
  try {
    statusText.textContent = 'Exporting...';
    
    await exporter.export({
      format,
      bitrate: 8_000_000,
      includeCaptions: true,
      captionStyle: {
        color: '#ffffff',
        backgroundColor: 'rgba(0, 0, 0, 0.8)',
        scale: 0.04
      },
      filename: `export-${Date.now()}`,
      onProgress: (progress) => {
        progressBar.value = progress;
        statusText.textContent = `Exporting: ${(progress * 100).toFixed(0)}%`;
      },
      signal: controller.signal
    });
    
    statusText.textContent = 'Export complete!';
  } catch (error) {
    if (error.message === 'Export aborted') {
      statusText.textContent = 'Export cancelled';
    } else {
      statusText.textContent = `Export failed: ${error.message}`;
    }
  }
});
```

## Performance considerations

### Frame capture overhead

Capturing frames in the browser is slower than server-side rendering:

* Canvas mode: \~10-30ms per frame
* DOM mode: \~50-100ms per frame

For a 60 FPS, 10-second composition:

* Canvas: \~6-18 seconds
* DOM: \~30-60 seconds

### Memory usage

VideoFrames consume memory until closed:

```typescript theme={null}
// Always close frames after use
const frame = await captureFrame(i);
try {
  await processFrame(frame);
} finally {
  frame.close();  // Free memory
}
```

### Browser limitations

<Warning>
  Large exports may fail due to browser memory limits. Keep compositions under 60 seconds at 1080p.
</Warning>

For longer videos, use server-side rendering with the `Renderer` class.

## Codec support

Different browsers support different codecs:

| Format | Codec | Chrome | Edge | Firefox | Safari |
| ------ | ----- | ------ | ---- | ------- | ------ |
| MP4    | H.264 | Yes    | Yes  | No\*    | No\*   |
| WebM   | VP9   | Yes    | Yes  | No\*    | No     |

\* Firefox and Safari do not support WebCodecs API

Check codec support:

```typescript theme={null}
if (typeof VideoEncoder === 'undefined') {
  console.error('WebCodecs not supported');
  return;
}

const config = { codec: 'avc1.42001E', width: 1920, height: 1080 };
const support = await VideoEncoder.isConfigSupported(config);

if (!support.supported) {
  console.error('H.264 encoding not supported');
}
```

## Audio mixing

The exporter uses Web Audio API to mix multiple audio tracks:

```typescript theme={null}
export async function mixAudio(
  tracks: AudioTrack[],
  duration: number,
  sampleRate: number,
  offset: number
): Promise<AudioBuffer> {
  const audioContext = new OfflineAudioContext(2, duration * sampleRate, sampleRate);
  
  for (const track of tracks) {
    const source = audioContext.createBufferSource();
    source.buffer = track.buffer;
    
    // Apply volume
    const gainNode = audioContext.createGain();
    gainNode.gain.value = track.volume ?? 1.0;
    
    source.connect(gainNode);
    gainNode.connect(audioContext.destination);
    
    // Schedule playback
    const startTime = (track.offset ?? 0) - offset;
    source.start(Math.max(0, startTime));
  }
  
  return await audioContext.startRendering();
}
```
