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>