Запись и обработка видеоданных на стороне клиента традиционно опиралась на MediaRecorder API. Несмотря на простоту использования, MediaRecorder функционирует как «черный ящик»: разработчик лишен доступа к отдельным закодированным пакетам, ключевым кадрам (GOP / Keyframes), квантованию цвета и прямому управлению аппаратным кодеком.
С принятием стандарта WebCodecs API веб-платформа получила возможности детерминированного покадрового кодирования с контролем битрейта, параметров сжатия (I-frames, P-frames) и аппаратного ускорения (hardwareAcceleration: 'prefer-hardware').
┌─────────────────────────────────────────────────────────────────────────────┐
│ CAPTURE & COMPOSITION LAYER (React 19) │
│ │
│ [ WebCam Stream ] ──┐ │
│ ├──► [ Canvas 2D / WebGL Compositor (60 FPS Loop) ] │
│ [ Screen Display ] ─┘ - Pip Camera Layout │
│ - Watermarks, Overlays, Alpha Blending │
└──────────────────────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────┴──────────────────────┐
│ │
▼ (Path A: High-Level) ▼ (Path B: Low-Level)
┌───────────────────────────────────────────┐ ┌───────────────────────────────┐
│ MEDIARECORDER API │ │ WEBCODECS API │
│ │ │ │
│ canvas.captureStream(30 FPS) │ │ new VideoFrame(canvas) │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ MediaRecorder (Opaque Black Box) │ │ VideoEncoder (GPU Hardware) │
│ - Container: video/webm;codecs=vp9,opus │ │ - Codec: avc1.42001f / vp8 │
│ - timeslice: 1000ms │ │ - keyFrameInterval: 60 │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ Blob([chunk1, chunk2, ...]) │ │ EncodedVideoChunk │
│ (Issue: Missing Duration Metadata) │ │ │ │
│ │ │ │ ▼ │
│ │ │ │ Fast Muxer (fMP4 / WebM) │
└───────────┼───────────────────────────────┘ └───────────┼───────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLIENT-SIDE EXPORT & DOWNLOAD │
│ │
│ URL.createObjectURL(blob) ──► Instant File Download (Zero Server Costs!) │
└─────────────────────────────────────────────────────────────────────────────┘При композиции нескольких потоков (например, видео с веб-камеры спикера поверх экрана презентации) связующим звеном выступает элемент <canvas>. Метод canvas.captureStream(fps) генерирует на лету виртуальный MediaStream, который объединяет графику холста с аудиодорожками через Web Audio API.
Ментальная модель клиентской записи строится на конвейере «Композиция — Аппаратное кодирование — Мультиплексирование (Muxing)»:
requestAnimationFrame смешивает пиксели дисплея, круглое видео веб-камеры и векторные водяные знаки на поверхности <canvas>.VideoFrame моментально блокирует буферы графического драйвера и подвешивает вкладку.MediaRecorder выдает полуфабрикатный WebM, в котором отсутствует заголовок общей продолжительности (Duration: Infinity). Использование WebCodecs совместно с легковесными JS-мультиплексорами (например, mp4-muxer) позволяет на лету собирать стандартизированный файл MP4 (fMP4) с корректными временными метками для видеоредакторов.Параллельное получение getDisplayMedia (экран) и getUserMedia (веб-камера и микрофон).
Отрисовка захваченных кадров на Canvas с частотой 30/60 FPS с наложением водяных знаков и плавающего круга камеры.
Объединение системного звука и микрофона через GainNode в единый результирующий MediaStreamDestination.
Потоковая передача кадров в MediaRecorder (timeslice 1000 мс) или VideoEncoder (WebCodecs GPU pipeline).
Агрегация чанков в бинарный Blob, генерация ObjectURL и моментальное скачивание через скрытый <a>.
Команда разрабатывает инструмент покадрового монтажа видео в браузере. Разработчик выбрал MediaRecorder API для экспорта видео. В процессе тестирования выяснилось, что таймлайн видео невозможно прокручивать назад без подтормаживаний, а размер файла не поддается точному контролю. Почему для этой задачи необходим WebCodecs API?
Запись экрана с демонстрацией слайдов и плавающим круглым окном докладчика, динамическим зумом на курсоре мыши и наложением фирменных плашек компании.
Монтажные комплексы (аналоги Descript, Runway, CapCut Web): обрезка дорожек, наложение субтитров, нарезка фрагментов и экспорт в MP4 с точностью до отдельного кадра без загрузки исходников в облако.
Экспорт динамических аналитических графиков и 3D-моделей в видеоролики Full HD/4K для отправки стейкхолдерам прямо из браузера пользователя.
Запись коротких видеообращений клиентов с чтением кодовых фраз, наложением серверного таймштампа и водяного знака сессии в реальном времени для защиты от Deepfake-атак.
Для сведения экрана, камеры и водяного знака создается виртуальный холст с кадровым циклом requestAnimationFrame. Для получения видеопотока используется метод canvas.captureStream(30):
export class VideoCanvasCompositor {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private screenVideo: HTMLVideoElement | null = null;
private cameraVideo: HTMLVideoElement | null = null;
private isRunning: boolean = false;
private animFrameId: number | null = null;
constructor(width: number = 1920, height: number = 1080) {
this.canvas = document.createElement('canvas');
this.canvas.width = width;
this.canvas.height = height;
const context = this.canvas.getContext('2d', { alpha: false });
if (!context) throw new Error('Не удалось получить 2D контекст');
this.ctx = context;
}
public setSources(screenStream: MediaStream, cameraStream?: MediaStream) {
this.screenVideo = document.createElement('video');
this.screenVideo.srcObject = screenStream;
this.screenVideo.muted = true;
this.screenVideo.play();
if (cameraStream) {
this.cameraVideo = document.createElement('video');
this.cameraVideo.srcObject = cameraStream;
this.cameraVideo.muted = true;
this.cameraVideo.play();
}
}
public start() {
this.isRunning = true;
const render = () => {
if (!this.isRunning) return;
this.drawFrame();
this.animFrameId = requestAnimationFrame(render);
};
render();
}
private drawFrame() {
const { width, height } = this.canvas;
// 1. Отрисовка основного экрана презентации
if (this.screenVideo && this.screenVideo.readyState >= 2) {
this.ctx.drawImage(this.screenVideo, 0, 0, width, height);
} else {
this.ctx.fillStyle = '#0a0a14';
this.ctx.fillRect(0, 0, width, height);
}
// 2. Отрисовка круглой веб-камеры докладчика в правом нижнем углу
if (this.cameraVideo && this.cameraVideo.readyState >= 2) {
const radius = 120;
const x = width - radius - 40;
const y = height - radius - 40;
this.ctx.save();
this.ctx.beginPath();
this.ctx.arc(x, y, radius, 0, Math.PI * 2);
this.ctx.closePath();
this.ctx.clip();
// Отрисовка видео с центрированием
this.ctx.drawImage(
this.cameraVideo,
x - radius,
y - radius,
radius * 2,
radius * 2
);
// Обводка рамки камеры
this.ctx.restore();
this.ctx.beginPath();
this.ctx.arc(x, y, radius, 0, Math.PI * 2);
this.ctx.strokeStyle = '#00F0FF';
this.ctx.lineWidth = 4;
this.ctx.stroke();
}
// 3. Наложение полупрозрачного водяного знака
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
this.ctx.font = 'bold 20px monospace';
this.ctx.fillText('REC • WPSKILLS STUDIO', 40, 50);
}
public getStream(fps: number = 30): MediaStream {
return this.canvas.captureStream(fps);
}
public stop() {
this.isRunning = false;
if (this.animFrameId !== null) {
cancelAnimationFrame(this.animFrameId);
}
if (this.screenVideo) {
this.screenVideo.pause();
this.screenVideo.srcObject = null;
}
if (this.cameraVideo) {
this.cameraVideo.pause();
this.cameraVideo.srcObject = null;
}
}
}'use client';
import { useState, useRef, useCallback } from 'react';
import { VideoCanvasCompositor } from '../services/VideoCanvasCompositor';
export interface RecordingState {
isRecording: boolean;
isPaused: boolean;
recordedBlob: Blob | null;
recordedUrl: string | null;
recordingDurationSeconds: number;
error: string | null;
}
export function useMediaRecording() {
const [state, setState] = useState<RecordingState>({
isRecording: false,
isPaused: false,
recordedBlob: null,
recordedUrl: null,
recordingDurationSeconds: 0,
error: null,
});
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const compositorRef = useRef<VideoCanvasCompositor | null>(null);
const timerIntervalRef = useRef<number | null>(null);
// Запуск процесса записи
const startRecording = useCallback(async () => {
try {
// 1. Захват экрана и системного звука
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { width: 1920, height: 1080, frameRate: 30 },
audio: true,
});
// 2. Захват камеры и микрофона
const cameraStream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480 },
audio: { echoCancellation: true, noiseSuppression: true },
});
// 3. Запуск композитора холста
const compositor = new VideoCanvasCompositor(1920, 1080);
compositor.setSources(screenStream, cameraStream);
compositor.start();
compositorRef.current = compositor;
const composedVideoStream = compositor.getStream(30);
// 4. Микширование звука экрана и микрофона через Web Audio API
const audioCtx = new AudioContext();
const destination = audioCtx.createMediaStreamDestination();
if (screenStream.getAudioTracks().length > 0) {
const screenAudioSource = audioCtx.createMediaStreamSource(screenStream);
screenAudioSource.connect(destination);
}
if (cameraStream.getAudioTracks().length > 0) {
const micAudioSource = audioCtx.createMediaStreamSource(cameraStream);
micAudioSource.connect(destination);
}
// 5. Создание итогового объединенного стрима
const finalStream = new MediaStream([
...composedVideoStream.getVideoTracks(),
...destination.stream.getAudioTracks(),
]);
// 6. Инициализация MediaRecorder с выбором лучшего кодека
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
? 'video/webm;codecs=vp9,opus'
: 'video/webm';
const recorder = new MediaRecorder(finalStream, {
mimeType,
videoBitsPerSecond: 4_000_000, // 4 Mbps для высокой четкости текста
});
chunksRef.current = [];
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onstop = () => {
const completeBlob = new Blob(chunksRef.current, { type: mimeType });
const blobUrl = URL.createObjectURL(completeBlob);
setState((prev) => ({
...prev,
isRecording: false,
isPaused: false,
recordedBlob: completeBlob,
recordedUrl: blobUrl,
}));
// Остановка треков
screenStream.getTracks().forEach((t) => t.stop());
cameraStream.getTracks().forEach((t) => t.stop());
if (compositorRef.current) {
compositorRef.current.stop();
}
};
// Запуск записи с чанками по 1000 мс (предотвращает потерю при сбое)
recorder.start(1000);
mediaRecorderRef.current = recorder;
// Таймер длительности
let seconds = 0;
timerIntervalRef.current = window.setInterval(() => {
seconds++;
setState((prev) => ({ ...prev, recordingDurationSeconds: seconds }));
}, 1000);
setState((prev) => ({
...prev,
isRecording: true,
recordedBlob: null,
recordedUrl: null,
recordingDurationSeconds: 0,
error: null,
}));
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Ошибка запуска записи';
setState((prev) => ({ ...prev, error: msg }));
}
}, []);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
if (timerIntervalRef.current !== null) {
clearInterval(timerIntervalRef.current);
timerIntervalRef.current = null;
}
}, []);
const downloadRecording = useCallback((filename: string = 'recording.webm') => {
if (!state.recordedUrl) return;
const a = document.createElement('a');
a.href = state.recordedUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}, [state.recordedUrl]);
return {
...state,
startRecording,
stopRecording,
downloadRecording,
};
}Для задач, требующих покадрового экспорта без рассинхронизации, используется связка VideoEncoder и VideoFrame:
// Демонстрация аппаратного пайплайна WebCodecs
export async function encodeCanvasWithWebCodecs(
canvas: HTMLCanvasElement,
totalFrames: number,
fps: number = 30
) {
const init: VideoEncoderInit = {
output: (chunk, metadata) => {
// Каждый чанк EncodedVideoChunk содержит сжатые NAL-юниты
console.log('Encoded chunk size:', chunk.byteLength, 'Type:', chunk.type);
},
error: (e) => console.error('WebCodecs encoder error:', e),
};
const encoder = new VideoEncoder(init);
// Конфигурация H.264 Baseline Profile с аппаратным ускорением GPU
encoder.configure({
codec: 'avc1.42001f',
width: canvas.width,
height: canvas.height,
bitrate: 3_000_000,
framerate: fps,
hardwareAcceleration: 'prefer-hardware',
avc: { format: 'annexb' },
});
const frameDurationUs = Math.round(1_000_000 / fps); // микросекунды
for (let i = 0; i < totalFrames; i++) {
// 1. Создание легковесного кадра из холста
const frame = new VideoFrame(canvas, {
timestamp: i * frameDurationUs,
duration: frameDurationUs,
});
// 2. Отправка кадра в аппаратный кодек GPU
const isKeyframe = i % (fps * 2) === 0; // I-кадр каждые 2 секунды
encoder.encode(frame, { keyFrame: isKeyframe });
// 3. КРИТИЧЕСКИ ВАЖНО: немедленное освобождение VRAM GPU!
frame.close();
}
// Ожидание завершения буферизированных кадров
await encoder.flush();
encoder.close();
}При покадровом рендеринге 60-секундного ролика через WebCodecs вкладка браузера внезапно аварийно завершает работу с кодом 'Out of Memory' или 'GPU Process Crashed'. При этом код корректно вызывал encoder.encode(frame). В чем причина падения?
Каждый экземпляр new VideoFrame(...) резервирует системный буфер в видеопамяти видеокарты. Полагаться на сборщик мусора V8 нельзя: он не видит размер внешней видеопамяти, что приводит к краху вкладки.
Решение: Всегда вызывайте frame.close() в блоке finally сразу после отправки в encoder.encode(frame).
Файлы WebM, сгенерированные MediaRecorder в Chromium, не содержат в заголовке длительности ролика (seek metadata), поскольку при потоковой записи итоговое время неизвестно. Плееры не могут перематывать такое видео.
Решение: Либо используйте библиотеки восстановления метаданных (например, ts-ebml или fix-webm-duration), либо переходите на WebCodecs с прямым мультиплексированием в MP4 через mp4-muxer.
Если requestAnimationFrame на холсте проседает со 60 FPS до 15 FPS из-за тяжелых расчетов, видеотрек начинает отставать от непрерывно бегущего аудиотрека микрофона.
Решение: Выносите тяжелую отрисовку в OffscreenCanvas внутри Web Worker или используйте постоянные временные метки (timestamps) в WebCodecs.
Накопление чанков в массиве chunksRef.current.push(event.data) при часовой записи 4K-видео приводит к выделению нескольких гигабайт в оперативной памяти вкладки и краху по OOM.
Решение: Используйте File System Access API (showSaveFilePicker и FileSystemWritableFileStream) для потокового сброса чанков прямо на жесткий диск по мере их поступления.
VideoEncoder, VideoFrame), обеспечивая точный покадровый экспорт.<canvas> и метод captureStream().VideoFrame.close() для предотвращения критических утечек физической видеопамяти GPU.MediaStreamAudioDestinationNode.