v3.0.0

<bmx-video-editor>

A timeline video editor in the page, with nothing uploaded: bring in video, sound and pictures, lay them out on video and sound tracks, cut, trim, split, move, change speed and volume, fade, crop, place and turn pictures, add titles, and see it all in a frame-accurate preview at the frame size and rate chosen (16:9, 9:16 for phones, square...).

7 properties · 7 events · 20 methods · 7 parts

Example

Try it with your own files in confidence: they are opened by your browser, on your device. Nothing is uploaded, no copy reaches us or anyone else, and nothing is kept once you close this tab.

A fictional café's opening teaser, made in the page: two short clips recorded from an animation here, two pictures, a logo card, a music bed and titles, on two video tracks and a sound track, with transitions and captions. Play it, trim and move clips, split with S, change speed, volume, fades and transitions in the inspector, drag a picture or title on the preview, write captions - then Export video to make an MP4 or WebM file. Or add video, sound and pictures of your own.

Show markup
<div class="row" style="gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-block-end: 0.75rem">
  <button type="button" id="ex-ve-sample">Load the sample edit</button>
  <button type="button" id="ex-ve-new">New project</button>
  <button type="button" id="ex-ve-tall">New 9:16 project</button>
  <label><input type="checkbox" id="ex-ve-read" /> Read only</label>
</div>

<bmx-video-editor id="ex-ve" label="Sample video edit" style="--bmx-video-editor-height: 46rem"></bmx-video-editor>
<p class="note" id="ex-ve-out" role="status">A fictional café's opening teaser, made in the page: two short clips recorded from an animation here, two pictures, a logo card, a music bed and titles, on two video tracks and a sound track, with transitions and captions. Play it, trim and move clips, split with S, change speed, volume, fades and transitions in the inspector, drag a picture or title on the preview, write captions - then Export video to make an MP4 or WebM file. Or add video, sound and pictures of your own.</p>

<script type="module">
  await customElements.whenDefined('bmx-video-editor');
  const el = document.getElementById('ex-ve');
  const out = document.getElementById('ex-ve-out');

  const toFile = (blob, name) => new File([blob], name, { type: blob.type });
  const png = canvas => new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
  const canvasOf = (w, h) => Object.assign(document.createElement('canvas'), { width: w, height: h });

  // Pictures, drawn here so the sample needs no files.
  async function sunrise() {
    const c = canvasOf(1600, 900);
    const g = c.getContext('2d');
    const sky = g.createLinearGradient(0, 0, 0, 900);
    sky.addColorStop(0, '#1e3a5f');
    sky.addColorStop(0.55, '#f59e0b');
    sky.addColorStop(1, '#fde68a');
    g.fillStyle = sky;
    g.fillRect(0, 0, 1600, 900);
    g.fillStyle = '#fff7d6';
    g.beginPath();
    g.arc(800, 640, 150, 0, Math.PI * 2);
    g.fill();
    g.fillStyle = '#0f766e';
    g.fillRect(0, 700, 1600, 200);
    for (let i = 0; i < 12; i++) {
      g.fillStyle = `rgba(255,255,255,${0.12 + (i % 3) * 0.05})`;
      g.fillRect(80 + i * 130, 740 + (i % 2) * 30, 90, 6);
    }
    return toFile(await png(c), 'Harbour at dawn.png');
  }

  async function counter() {
    const c = canvasOf(1600, 1000);
    const g = c.getContext('2d');
    g.fillStyle = '#f5efe6';
    g.fillRect(0, 0, 1600, 1000);
    g.fillStyle = '#b45309';
    g.fillRect(0, 640, 1600, 360);
    g.fillStyle = '#c2410c';
    for (let x = 0; x < 1600; x += 80) g.fillRect(x + 8, 120, 64, 64);
    g.fillStyle = '#334155';
    for (const x of [380, 800, 1220]) {
      g.fillRect(x - 4, 0, 8, 300);
      g.beginPath();
      g.moveTo(x - 90, 380);
      g.lineTo(x + 90, 380);
      g.lineTo(x + 40, 300);
      g.lineTo(x - 40, 300);
      g.fill();
    }
    return toFile(await png(c), 'Counter and pendants.png');
  }

  async function card() {
    const c = canvasOf(1920, 1080);
    const g = c.getContext('2d');
    g.fillStyle = '#13293d';
    g.fillRect(0, 0, 1920, 1080);
    g.strokeStyle = '#f59e0b';
    g.lineWidth = 14;
    g.beginPath();
    g.arc(960, 470, 150, 0, Math.PI * 2);
    g.stroke();
    g.fillStyle = '#f8fafc';
    g.font = '700 96px Georgia, serif';
    g.textAlign = 'center';
    g.fillText('Harbour Street', 960, 760);
    g.font = '400 48px system-ui, sans-serif';
    g.fillText('Opening in spring', 960, 840);
    return toFile(await png(c), 'End card.png');
  }

  // A music bed: four chords with a soft fade, as a 16-bit WAV.
  function musicBed(seconds = 16) {
    const rate = 48000;
    const n = rate * seconds;
    const data = new Int16Array(n);
    const chords = [
      [220, 277.18, 329.63],
      [196, 246.94, 293.66],
      [174.61, 220, 261.63],
      [196, 246.94, 329.63],
    ];
    for (let i = 0; i < n; i++) {
      const t = i / rate;
      const chord = chords[Math.floor(t / (seconds / 4)) % 4];
      let v = 0;
      for (const f of chord) v += Math.sin(2 * Math.PI * f * t) * 0.18 + Math.sin(4 * Math.PI * f * t) * 0.04;
      const beat = 0.6 + 0.4 * Math.exp(-((t * 2) % 1) * 6);
      data[i] = Math.max(-1, Math.min(1, v * beat)) * 0x6fff;
    }
    const bytes = new ArrayBuffer(44 + n * 2);
    const v = new DataView(bytes);
    const s = (o, t) => [...t].forEach((ch, i) => v.setUint8(o + i, ch.charCodeAt(0)));
    s(0, 'RIFF');
    v.setUint32(4, 36 + n * 2, true);
    s(8, 'WAVEfmt ');
    v.setUint32(16, 16, true);
    v.setUint16(20, 1, true);
    v.setUint16(22, 1, true);
    v.setUint32(24, rate, true);
    v.setUint32(28, rate * 2, true);
    v.setUint16(32, 2, true);
    v.setUint16(34, 16, true);
    s(36, 'data');
    v.setUint32(40, n * 2, true);
    new Int16Array(bytes, 44).set(data);
    return new File([bytes], 'Music bed.wav', { type: 'audio/wav' });
  }

  // Two short clips recorded from animations drawn here (the browser's own recorder, four seconds, both at once).
  async function recordClips() {
    if (typeof MediaRecorder === 'undefined' || !HTMLCanvasElement.prototype.captureStream) return [];
    const make = (name, paint) =>
      new Promise(resolve => {
        const c = canvasOf(1280, 720);
        const g = c.getContext('2d');
        const stream = c.captureStream(30);
        const type = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', 'video/mp4'].find(t => MediaRecorder.isTypeSupported(t));
        if (!type) return resolve(null);
        const rec = new MediaRecorder(stream, { mimeType: type, videoBitsPerSecond: 2_500_000 });
        const parts = [];
        rec.ondataavailable = e => e.data.size && parts.push(e.data);
        rec.onstop = () => resolve(new File(parts, name, { type: type.split(';')[0] }));
        const t0 = performance.now();
        const draw = () => {
          const t = (performance.now() - t0) / 1000;
          paint(g, t);
          if (t < 4) requestAnimationFrame(draw);
          else rec.stop();
        };
        rec.start(250);
        draw();
      });
    const street = make('Street.webm', (g, t) => {
      g.fillStyle = '#93c5fd';
      g.fillRect(0, 0, 1280, 720);
      g.fillStyle = '#e2e8f0';
      g.fillRect(0, 520, 1280, 200);
      for (let i = 0; i < 9; i++) {
        const x = ((i * 190 - t * 160) % 1710 + 1710) % 1710 - 220;
        g.fillStyle = ['#c2410c', '#0f766e', '#334155', '#b45309'][i % 4];
        g.fillRect(x, 220 - (i % 3) * 40, 170, 300 + (i % 3) * 40);
        g.fillStyle = '#fef3c7';
        for (let w = 0; w < 6; w++) g.fillRect(x + 20 + (w % 2) * 80, 250 + Math.floor(w / 2) * 70 - (i % 3) * 40, 50, 40);
      }
      g.fillStyle = '#1e293b';
      g.fillRect(((t * 300) % 1600) - 200, 560, 180, 70);
    });
    const pour = make('Pour-over.webm', (g, t) => {
      g.fillStyle = '#f5efe6';
      g.fillRect(0, 0, 1280, 720);
      g.fillStyle = '#e7d8c3';
      g.fillRect(0, 520, 1280, 200);
      g.fillStyle = '#ffffff';
      g.fillRect(500, 380, 280, 160);
      const level = Math.min(1, t / 3.6);
      g.fillStyle = '#7c2d12';
      g.fillRect(510, 530 - 140 * level, 260, 140 * level);
      g.strokeStyle = '#7c2d12';
      g.lineWidth = 10;
      g.beginPath();
      g.moveTo(640, 120);
      g.lineTo(640 + Math.sin(t * 6) * 6, 530 - 140 * level);
      g.stroke();
      g.fillStyle = '#334155';
      g.fillRect(560, 60, 160, 70);
    });
    return (await Promise.all([street, pour])).filter(Boolean);
  }

  const loadSample = async () => {
    out.textContent = 'Making the sample clips…';
    await el.newProject(1920, 1080, 30);
    const [videos, pictures] = await Promise.all([recordClips(), Promise.all([sunrise(), counter(), card()])]);
    const ids = await el.addFiles([...videos, ...pictures, musicBed()]);
    const p = await el.getProject();
    const byName = name => p.media.find(m => m.name === name)?.id;
    const v1 = p.tracks.find(t => t.kind === 'video').id;
    const a1 = p.tracks.find(t => t.kind === 'audio').id;
    const clips = [];
    let at = 0;
    const put = (name, length, extra = {}) => {
      const id = byName(name);
      if (!id) return;
      const m = p.media.find(x => x.id === id);
      const kind = m.kind === 'image' ? 'image' : 'video';
      const len = kind === 'video' ? Math.min(length, m.duration || length) : length;
      clips.push({ id: `c${clips.length + 1}`, track: v1, kind, media: id, start: at, duration: len, in: 0, speed: 1, ...extra });
      at += len;
    };
    put('Street.webm', 3.9);
    put('Harbour at dawn.png', 3, { fit: 'cover', scale: 1.08, transition: { type: 'crossfade', duration: 1 } });
    put('Pour-over.webm', 3.9, { speed: 1, transition: { type: 'slide-left', duration: 0.8 } });
    put('Counter and pendants.png', 3, { fit: 'cover', transition: { type: 'wipe-left', duration: 0.8 } });
    put('End card.png', 3, { fadeOut: 1, transition: { type: 'dip', duration: 1 } });
    const end = at;
    const music = byName('Music bed.wav');
    el.project = {
      ...p,
      title: 'Harbour Street café - teaser',
      tracks: [{ id: 'v2', kind: 'video', name: 'Titles' }, ...p.tracks],
      clips: [
        ...clips,
        { id: 't1', track: 'v2', kind: 'title', start: 0.3, duration: 3, in: 0, speed: 1, fadeIn: 0.4, fadeOut: 0.4, title: { text: 'Harbour Street café', size: 0.09, bold: true, place: 'middle', color: '#ffffff', animate: 'rise' } },
        { id: 't2', track: 'v2', kind: 'title', start: 7.2, duration: 3.4, in: 0, speed: 1, title: { text: 'Lumen & Loom · design in progress', size: 0.05, place: 'bottom', background: '#13293d', color: '#fde68a', animate: 'type' } },
        ...(music ? [{ id: 'm1', track: a1, kind: 'audio', media: music, start: 0, duration: end, in: 0, speed: 1, volume: 0.8, fadeIn: 1, fadeOut: 2 }] : []),
      ],
      captions: [
        { id: 'cc1', start: 0.5, end: 3.2, text: 'Morning on Harbour Street.' },
        { id: 'cc2', start: 4.2, end: 6.6, text: 'The light comes up over the water.' },
        { id: 'cc3', start: 7.4, end: 10.4, text: 'Every cup is poured by hand.' },
        { id: 'cc4', start: 13.9, end: 16.5, text: 'Opening this spring.' },
      ],
      captionStyle: { size: 0.045, color: '#ffffff', background: '#000000', place: 'bottom', show: true },
      markers: [
        { id: 'k1', time: 3.9, label: 'Pictures' },
        { id: 'k2', time: end - 3, label: 'End card' },
      ],
    };
    out.textContent = `Sample edit ready: ${ids.length} files, ${Math.round(end)} seconds. Press Space to play.`;
  };

  document.getElementById('ex-ve-sample').addEventListener('click', loadSample);
  document.getElementById('ex-ve-new').addEventListener('click', () => el.newProject(1920, 1080, 30));
  document.getElementById('ex-ve-tall').addEventListener('click', () => el.newProject(1080, 1920, 30));
  document.getElementById('ex-ve-read').addEventListener('change', e => (el.readonly = e.target.checked));
  el.addEventListener('bmxMediaAdd', e => (out.textContent = `Added ${e.detail.name}.`));
  el.addEventListener('bmxPlayState', e => (out.textContent = e.detail.playing ? 'Playing.' : `Paused at ${e.detail.time.toFixed(2)} s.`));
  el.addEventListener('bmxSave', e => (out.textContent = `Saved ${e.detail.name} (${Math.round(e.detail.data.size / 1024)} KB).`));
  el.addEventListener('bmxExport', e => (out.textContent = `Exported ${e.detail.name}: ${e.detail.width} × ${e.detail.height}, ${e.detail.duration.toFixed(1)} s, ${(e.detail.data.size / 1048576).toFixed(1)} MB.`));
  el.addEventListener('bmxError', e => (out.textContent = e.detail.message));
  await loadSample();
</script>

WHAT IT DOES

  • Media: any video, sound or picture the browser can play, added by button, drop or addFiles; a filmstrip and a waveform are read from each. A project names its files and asks for them again when reopened.
  • Timeline: video tracks (the top one in front) and sound tracks; clips moved and trimmed with the pointer or the keyboard, snapping to edits, split at the playhead, deleted with or without closing the gap, copied, pasted and duplicated; tracks hidden, muted, locked and reordered; markers; zoom.
  • Clips: speed (a quarter to four times), volume to 200%, fades, the picture's fit, position, size, turn, opacity, crop and flips; a video clip's sound detached to a sound track to edit on its own.
  • Titles: text with its font, size, colour, an optional band behind it, bold, italic, alignment, and place (top, middle, lower third or free); coming in at once, fading, rising or typed out.
  • Transitions: cross-fade, dip, wipes, slides and zoom into any clip, centred on the cut (the sound crosses with them).
  • Captions: written on a captions lane or imported from WebVTT or SRT, shown over the picture in their own style, burnt into an export or saved as .vtt or .srt.
  • Preview: plays the edit with its sound in sync; frame steps, edit to edit, J/K/L shuttle; a still of any frame saved as a picture; the selected picture or title moved, sized and turned with handles on the frame.
  • Export: an MP4 (H.264 and AAC where the browser has them, otherwise VP9 or AV1 with Opus) or WebM file, at the project's size or smaller, with or without sound and captions, made frame-exact with the browser's own encoders and a progress bar that can stop it.
  • Undo and redo; the project as JSON in and out.

Properties

PropertyAttributeTypeDefaultDescription
compactBelow compact-below number 860 Below this width (pixels) the media list and inspector share a tabbed panel under the preview.
label label string — The element's accessible name.
noFiles no-files boolean false Hide the buttons that open and save files (the page does that itself).
project project BmxVideoProject | Record<string, unknown> | string — The project (tracks, clips, media by name), or its JSON. Files are added with addFiles. Mutable: edits replace it.
readonly readonly boolean false Look and play only: no edits.
snap snap boolean true Snap moves and trims to edits, markers and the playhead.
strings strings Partial<BmxVideoEditorStrings> | string — Replacements for any of the wording (an object, or its JSON).

Events

EventDetailDescription
bmxChange BmxVideoChangeDetail Fired after every change to the project.
bmxError { message: string; name?: string | undefined; } Fired when a file cannot be read.
bmxExport BmxVideoExportDetail Fired when an export from the dialog is made, before it downloads. Cancelable: cancel to keep the file yourself.
bmxMediaAdd BmxVideoMediaDetail Fired when a file has been read and added to the media.
bmxPlayState BmxVideoPlayDetail Fired when playing starts or stops.
bmxSave BmxVideoSaveDetail Fired before a project or a frame is downloaded. Cancelable: cancel to keep the file yourself.
bmxSelectionChange BmxVideoSelectionDetail Fired when the selected clips change.

Methods

MethodSignatureDescription
addFiles addFiles(files: FileList | readonly Blob[]) => Promise<string[]> Add video, sound or picture files. A file whose name (and size) matches media the project already names supplies it; others become new media. Returns the media ids.
addTitle addTitle(text?: string, look?: Partial<BmxVideoTitle>, duration?: number) => Promise<string> Add a title at the playhead on the top video track. Returns its clip id.
cancelExport cancelExport() => Promise<void> Stop an export in progress.
downloadProject downloadProject(name?: string) => Promise<void> Save the project as a .json download (unless bmxSave is cancelled).
exportFormats exportFormats() => Promise<BmxVideoExportFormat[]> The export formats this browser can make at the project's size, most widely played first.
exportVideo exportVideo(options?: BmxVideoExportOptions) => Promise<Blob> Make the edit into a video file and return it (nothing is downloaded): MP4 or WebM, a picture codec, a size, a quality, with or without sound and burnt-in captions. Rejects with an AbortError when stopped.
getCaptions getCaptions(format?: "vtt" | "srt" | "json" | "txt") => Promise<string> The captions as a WebVTT (default), SRT, JSON or plain-text file's contents.
getProject getProject() => Promise<BmxVideoProject> The project (treat as read-only; change it with setProject or the project property).
getTime getTime() => Promise<number> The playhead, in seconds.
importCaptions importCaptions(file: Blob | string) => Promise<number> Replace the captions with a WebVTT, SRT or JSON caption file's (undoable). Returns how many were read.
newProject newProject(width?: number, height?: number, fps?: number) => Promise<void> Start a new, empty project at a frame size and rate.
pause pause() => Promise<void>
play play() => Promise<void>
redo redo() => Promise<void>
seek seek(time: number) => Promise<void> Move the playhead (seconds).
select select(ids?: string[]) => Promise<void> Select clips by id (none clears).
setProject setProject(project: BmxVideoProject | Record<string, unknown> | string) => Promise<void> Replace the project (undoable).
snapshot snapshot(time?: number, type?: string) => Promise<Blob> A frame of the edit as a picture (PNG unless a JPEG or WebP type is asked), at the project's frame size.
split split() => Promise<void> Split at the playhead: the selected clips, or every unlocked track.
undo undo() => Promise<void>

CSS shadow parts

PartDescription
captions The captions list.
export The export dialog.
inspector The clip and project settings.
media The media list.
preview The preview and its transport.
timeline The tracks.
toolbar The toolbar.

CSS custom properties

PropertyDescription
--bmx-video-editor-caption Captions on the timeline.
--bmx-video-editor-fade Fades and their handles.
--bmx-video-editor-grid Ruler ticks.
--bmx-video-editor-handle The handles on the preview.
--bmx-video-editor-head-width The track heads' width.
--bmx-video-editor-height The editor's height.
--bmx-video-editor-marker Markers.
--bmx-video-editor-playhead The playhead.
--bmx-video-editor-radius The element's corner radius.
--bmx-video-editor-ruler-text Ruler labels.
--bmx-video-editor-side-width The media list's and the inspector's width.
--bmx-video-editor-stage Behind the preview.
--bmx-video-editor-timeline-height The timeline's height.
--bmx-video-editor-title Title clips.
--bmx-video-editor-transition Transitions on the timeline.
--bmx-video-editor-wave Waveforms.