RVE LogoReact Video EditorDOCS
RVE SDK/Quick Start/Setup Captions

Setup Captions

Add AI-powered caption generation to your video editor using OpenAI Whisper via a server-side proxy

RVE supports AI-powered caption generation through a transcription adaptor. The built-in Whisper adaptor works through a proxy pattern — your server holds the OpenAI API key and calls the Whisper API, the editor calls your local route.

Why a proxy?

The editor runs in the browser. If you called the OpenAI API directly from the client, your API key would be exposed in network requests for anyone to steal. By routing through your own server, the key stays secret and never leaves your backend.

How it works

Editor UI sends POST /api/ai/captions to your server, which calls api.openai.com (Whisper)

Unlike sounds and images, caption generation is not enabled by default. You need to:

  1. Create a server-side proxy route that calls OpenAI Whisper
  2. Pass the createWhisperTranscriptionAdaptor() to the editor

Step 1: Get an OpenAI API key

Sign up at platform.openai.com and create an API key. Add it to your environment:

OPENAI_API_KEY=sk-your-api-key-here

Step 2: Create the caption proxy route

Create app/api/ai/captions/route.ts:

import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const apiKey = process.env.OPENAI_API_KEY;
  if (!apiKey) {
    return NextResponse.json(
      { error: 'OpenAI API key not configured' },
      { status: 500 }
    );
  }

  // The adaptor sends FormData when the video is a local blob,
  // or JSON when it's a remote URL.
  let mediaBlob: Blob;
  let language: string | undefined;

  const contentType = request.headers.get('content-type') || '';

  if (contentType.includes('multipart/form-data')) {
    // Local file uploaded from browser
    const formData = await request.formData();
    const file = formData.get('file') as File | null;
    if (!file) {
      return NextResponse.json({ error: 'No file provided' }, { status: 400 });
    }
    mediaBlob = file;
    language = (formData.get('language') as string) || undefined;
  } else {
    // Remote URL — download server-side
    const body = await request.json();
    if (!body.videoSrc) {
      return NextResponse.json(
        { error: 'videoSrc is required' },
        { status: 400 }
      );
    }
    language = body.language;

    const mediaResponse = await fetch(body.videoSrc);
    if (!mediaResponse.ok) {
      return NextResponse.json(
        { error: `Failed to fetch media: ${mediaResponse.status}` },
        { status: 502 }
      );
    }
    mediaBlob = await mediaResponse.blob();
  }

  // Send to OpenAI Whisper
  const whisperForm = new FormData();
  whisperForm.append('file', mediaBlob, 'audio.mp4');
  whisperForm.append('model', 'whisper-1');
  whisperForm.append('response_format', 'verbose_json');
  whisperForm.append('timestamp_granularities[]', 'word');
  whisperForm.append('timestamp_granularities[]', 'segment');
  if (language) {
    whisperForm.append('language', language);
  }

  const whisperResponse = await fetch(
    'https://api.openai.com/v1/audio/transcriptions',
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${apiKey}` },
      body: whisperForm,
    }
  );

  if (!whisperResponse.ok) {
    const error = await whisperResponse.text();
    return NextResponse.json(
      { error: `Whisper API error: ${whisperResponse.status} - ${error}` },
      { status: whisperResponse.status }
    );
  }

  const result = await whisperResponse.json();

  // Transform Whisper response into RVE formats
  const captions = transformWhisperResponse(result);

  // Build transcript for caching (generate once, reuse on subsequent clicks)
  const transcript = {
    language: language || result.language,
    createdAt: new Date().toISOString(),
    segments: (result.segments || []).map((seg: any) => ({
      text: seg.text.trim(),
      startMs: Math.round(seg.start * 1000),
      endMs: Math.round(seg.end * 1000),
      confidence: 1,
      words: (result.words || [])
        .filter((w: any) => w.start >= seg.start && w.end <= seg.end)
        .map((w: any) => ({
          word: w.word.trim(),
          startMs: Math.round(w.start * 1000),
          endMs: Math.round(w.end * 1000),
          confidence: 1,
        })),
    })),
  };

  return NextResponse.json({ captions, transcript });
}

function transformWhisperResponse(result: any) {
  const words = result.words || [];
  if (words.length === 0) return [];

  // Group words into sentences (split on punctuation or every ~8 words)
  const captions: any[] = [];
  let currentWords: any[] = [];

  for (const word of words) {
    currentWords.push({
      word: word.word.trim(),
      startMs: Math.round(word.start * 1000),
      endMs: Math.round(word.end * 1000),
      confidence: 1,
    });

    const text = word.word.trim();
    const isEndOfSentence = /[.!?]$/.test(text);
    const isTooLong = currentWords.length >= 8;

    if (isEndOfSentence || isTooLong) {
      captions.push({
        text: currentWords.map((w) => w.word).join(' '),
        startMs: currentWords[0].startMs,
        endMs: currentWords[currentWords.length - 1].endMs,
        timestampMs: null,
        confidence: 1,
        words: currentWords,
      });
      currentWords = [];
    }
  }

  // Push remaining words
  if (currentWords.length > 0) {
    captions.push({
      text: currentWords.map((w) => w.word).join(' '),
      startMs: currentWords[0].startMs,
      endMs: currentWords[currentWords.length - 1].endMs,
      timestampMs: null,
      confidence: 1,
      words: currentWords,
    });
  }

  return captions;
}

Step 3: Pass the adaptor to the editor

import { createWhisperTranscriptionAdaptor } from '@reactvideoeditor/react-video-editor/adaptors/whisper-transcription-adaptor';

<ReactVideoEditor
  adaptors={{
    transcription: createWhisperTranscriptionAdaptor(),
  }}
/>

That's it. Select a video or an audio item on the timeline, open the AI panel, and click Generate Captions. The editor calls your proxy route, which calls Whisper. The captions appear on the timeline with word-level timing.

Custom endpoint

If your proxy route lives at a different path:

<ReactVideoEditor
  adaptors={{
    transcription: createWhisperTranscriptionAdaptor({
      endpoint: '/my-api/transcribe',
    }),
  }}
/>

Custom transcription provider

You can use any transcription service (Deepgram, AssemblyAI, Rev, etc.) by implementing the TranscriptionAdaptor interface:

import type { TranscriptionAdaptor } from '@reactvideoeditor/react-video-editor/types';

const myAdaptor: TranscriptionAdaptor = {
  name: 'deepgram',
  displayName: 'Deepgram',
  async transcribe({ videoSrc, language, durationSeconds }) {
    const response = await fetch('/api/my-transcription', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ videoSrc, language, durationSeconds }),
    });
    const data = await response.json();
    return { captions: data.captions, transcript: data.transcript };
  },
};

<ReactVideoEditor
  adaptors={{
    transcription: myAdaptor,
  }}
/>

Your server route must return { captions: Caption[], transcript?: Transcript }. Including transcript enables caching — the editor stores it on the video so subsequent caption generation is instant (no re-transcription). Each caption has:

{
  text: string;        // The caption text
  startMs: number;     // Start time in milliseconds
  endMs: number;       // End time in milliseconds
  timestampMs: number | null;
  confidence: number | null;  // 0-1, or null if unknown
  words: Array<{       // Word-level timing
    word: string;
    startMs: number;
    endMs: number;
    confidence: number;
  }>;
}

Captions with no transcription adaptor

The Captions panel can make captions without a transcription adaptor. If no caption item is selected, the panel shows the Add captions section:

  • Generate: Type or paste a script, then click Generate. Each sentence becomes one timed caption. The editor times the words at 160 words per minute, with a 500 ms gap between sentences.
  • Import .srt: Select an .srt file. The editor validates the file and makes one caption for each SRT cue. It spreads the word timing across each cue.

You can edit the text and the timing of each caption later in the Edit tab.

Caption styles

Select a caption item to open the caption panel. The Styles tab has a template picker and controls for typography, colors, and animation.

Templates

The template picker has these tabs:

  • Popular
  • Subtle
  • Bold
  • Playful
  • Elegant
  • Creative

New captions use the Clean template (id cleanRise). The Minimal template is removed. Captions that used Minimal keep their saved styles.

Animation modes

The Animation section sets styles.animationMode. If animationMode is not set, the editor uses 'word-highlight'.

ModeName in the editorEffect
'word-highlight'Word HighlightAll words show. The current word has a highlight.
'word-accumulate'Word AccumulateWords appear one at a time and stay.
'word-only'Single WordOnly the current word shows.
'sentence-fade'Sentence FadeThe full sentence fades in and out.
'word-pop'Word PopWords bounce in.
'word-slide'Word SlideWords slide in from the sides.
'word-typewriter'TypewriterA typewriter effect.
'word-wave'Word WaveWords appear in a wave pattern.
'word-scatter'ScatterWords show at random positions.
'word-stack'Word StackWords accumulate at scattered positions.
'word-rotate'Word RotateWords turn into view.
'karaoke'KaraokeWords fill with color, as in karaoke.
'word-blur'Word BlurWords blur in and out.
'word-random-boxes'Random BoxesWords pop in, in boxes with random colors.
'word-group-fade'Group FadeWords fade in, in groups.
'word-stack-color'Color StackA vertical stack with a color highlight.
'word-vertical-stack'Vertical StackOne word on each line. The lines scroll.
'word-bounce'BounceEach word pops in with a tilt and a bounce.
'word-spotlight'SpotlightThe line pops in. The spoken word grows and changes color.
'word-rise'RiseWords rise into position from a soft blur.
'word-emphasis'EmphasisThe key word is large. The other words are small.

'word-bounce', 'word-spotlight', 'word-rise', and 'word-emphasis' are new modes.

New style fields

CaptionStyles has these new optional fields:

FieldTypeDescription
paintOrderstringUse 'stroke fill' to keep a thick WebkitTextStroke behind the letter fill.
filterstringA CSS filter on the caption. Use drop-shadow for a hard shadow on outlined text. The browser renderer draws textShadow from the fill only, so a thick outline covers it. drop-shadow includes the outline.
wordColorsstring[]Colors that cycle across the words of a caption. The first color is the most frequent.
wordTiltnumberThe largest tilt in degrees for 'word-bounce'. Set 0 for no tilt.
emphasisScalenumberFor 'word-emphasis': the size of the key word, as a multiple of the font size.
emphasisLayout'center' | 'stagger'For 'word-emphasis': 'stagger' puts the words before the key word on the left and the words after it on the right.