Привет, друзья!
Продолжаю исследовать возможности по работе с медиа, предоставляемые современными браузерами, и в этой статье хочу рассказать вам о возможности захвата и записи медиаданных в процессе воспроизведения аудио и видеофайлов.
Мы разработаем простое приложение для сведения аудио и видео со следующим функционалом:
- пользователь выбирает одно видео и несколько аудио, хранящихся в его файловой системе;
- когда пользователь нажимает на кнопку для начала записи, запускается воспроизведение выбранных файлов, захватываются их медиапотоки;
- захваченные потоки объединяются в один и передаются для записи;
- в процессе записи пользователь может менять источник аудиоданных;
- пользователь может приостанавливать (например, для изменения источника аудиоданных) и продолжать запись;
- по окончанию записи генерируется видеофайл в формате
WebM
— превью сведенного контента и ссылка для его скачивания.
В качестве фреймворка для фронтенда я буду использовать React
, однако все функции по работе с медиа будут автономными (сигнатура этих функций будет framework agnostic), так что вы можете использовать любой другой фреймворк или ограничиться чистым JavaScript
.
О том, как разработать приложение для создания аудиозаметок, можно прочитать в этой статье, а о том, как разработать приложение для захвата и записи экрана — в этой.
Если вам это интересно, прошу под кат.
Если вы внимательно изучили функционал нашего будущего приложения, то могли заметить, что пользователь может выбрать только одно видео и имеет возможность менять только источник аудиоданных. Это связано с тем, что на сегодняшний день в процессе записи медиаданных возможна замена только аудиоисточника (без создания нового экземпляра MediaRecorder
).
В процессе разработки приложения мы будем опираться на следующие спецификации (черновики и рекомендацию):
- Media Capture from DOM Elements — захват медиапотока из DOM элементов;
- MediaStream Recording — запись медиаданных;
- Media Capture and Streams. MediaStream API — интерфейс для объединения медиапотоков;
- Web Audio API. MediaStreamAudioDestinationNode и MediaStreamAudioSourceNode — интерфейсы для создания динамического источника аудиоданных;
- File API. The Blob Interface and Binary Data и A URL for Blob and MediaSource reference — создание объекта
Blob
и ссылки на него.
Ссылки на соответствующие разделы MDN
будут приводиться по мере необходимости.
Создаем шаблон React-приложения
с помощью create-react-app
:
yarn create react-app capture-stream
# or
npx create-react-app capture-stream
Пока генерируется шаблон, поговорим об интерфейсах и методах, которые мы будем применять.
- Для захвата медиапотока в процессе воспроизведения аудио или видео, а также в процессе рендеринга
canvas
, используется методcaptureStream
. К сожалению, на сегодняшний день данный метод не поддерживается Safari, а в Firefox он поддерживается с префиксом (mozCaptureStream
):
const audio$ = document.querySelector('audio')
audio$.play()
const stream = audio$.captureStream()
- Для объединения потоков используется интерфейс
MediaStream
:
const audioStream = audio$.captureStream()
const videoStream = video$.captureStream()
const audioTracks = audioStream.getAudioTracks()
const videoTracks = videoStream.getVideoTracks()
const mediaStream = new MediaStream([...audioTracks, ...videoTracks])
- Для записи медиа данных используется интерфейс
MediaRecorder
:
const mediaChunks = []
const mediaRecorder = new MediaRecorder(mediaRecorder, {
mimeType: 'video/webm',
// другие настройки
})
// обработка полученных данных
mediaRecorder.ondataavailable = (e) => {
// части данных в виде `Blob`
mediaChunks.push(e.data)
}
// метод `start` принимает опциональный параметр `timeslice` - время в мс,
// по истечении которого возникает событие `dataavailable`
mediaRecorder.start(250)
// другие методы
mediaRecorder.stop()
mediaRecorder.pause()
mediaRecorder.resume()
// свойства
// неактивен
mediaRecorder.inactive
// идет запись
mediaRecorder.recording
// запись приостановлена
mediaRecorder.paused
- Для создания сведенного видеофайла мы будем использовать интерфейс
Blob
в сочетании с методомcreateObjectURL
:
const blob = new Blob(mediaChunks, {
type: 'video/webm',
// другие настройки
})
// формируем путь к файлу, хранящемуся в памяти,
// для передачи элементам `video` и `a`
const url = URL.createObjectURL(blob)
- Наконец, для создания динамического источника аудиоданных используется сочетание интерфейсов
AudioContext
,MediaStreamAudioDestinationNode
иMediaStreamAudioSourceNode
:
// создаем аудио контекст
const audioContext = new AudioContext()
// создаем передатчик аудио
const mediaStreamAudioDestinationNode = new MediaStreamAudioDestinationNode(
audioContext
)
// создаем источник аудио с помощью аудиопотока, захваченного из элемента `audio`
// контекст передатчика и источника должен быть одинаковым
const mediaStreamAudioSourceNode = new MediaStreamAudioSourceNode(audioContext, {
mediaStream: audioStream
})
// подключаем источник к передатчику
mediaStreamAudioSourceNode.connect(mediaStreamAudioDestinationNode)
// далее вместо `audioStream.getAudioTracks()` в `new MediaStream()` передается
mediaStreamAudioDestinationNode.stream.getAudioTracks()
Шаблон готов. Приступим к разработке приложения.
Структура директории src
будет следующей:
- assets - 2 аудиофайла и 1 видеофайл (можете использовать свои)
- components - компоненты
- AudioSelector.js - для выбора аудио
- VideoSelector.js - для выбора видео
- Recorder.js - для записи
- Result.js - для результата записи
- hook - хуки
- usePrevious.js - для сохранения значения предыдущего состояния
- utils - утилиты
- verifySupport.js - для проверки поддержки используемых технологий
- recording.js - для записи и формирования ее результата
- createStore.js - для создания хранилища состояния
- App.js
- App.scss
- index.js
- ...
Как видите, для стилизации приложения я пользовался Sass
:
yarn add -D sass
# or
npm i -D sass
Начнем с основного компонента приложения (App.js
).
Импортируем компоненты, утилиту для создания хранилища состояния и стили:
import { VideoSelector, AudioSelector, Recorder, Result } from 'components'
import { createStore } from 'utils/createStore'
import './App.scss'
Создаем хранилище и импортируем хуки:
const store = {
state: {
// выбранное пользователем аудио
audio: '',
// ... видео
video: '',
// результат записи
result: ''
},
setters: {
// соответствующие методы
setAudio: (_, audio) => ({ audio }),
setVideo: (_, video) => ({ video }),
setResult: (_, result) => ({ result })
}
}
export const [Provider, useStore, useSetters] = createStore(store)
С вашего позволения, я не буду останавливаться на утилите (почитать о ней можно здесь). Справедливости ради следует отметить, что в последнее время для создания хранилища состояния я все чаще прибегаю к помощи zustand
.
Создаем и экспортируем компонент:
function App() {
return (
<Provider>
<div className='container common'>
<VideoSelector />
<AudioSelector />
</div>
<Recorder />
<Result />
</Provider>
)
}
export default App
Компонент для выбора видео (components/VideoSelector.js
):
import { useState, useEffect, useRef } from 'react'
import { useSetters } from 'App'
export const VideoSelector = () => {
// состояние для пути к выбранному видео
const [fileUrl, setFileUrl] = useState()
// иммутабельная переменная для ссылки на элемент `video`
const videoRef = useRef()
// метод для сохранения ссылки на элемент `video`
const { setVideo } = useSetters()
// сохраняем ссылку на элемент `video` при наличии
// пути к выбранному файлу и самого элемента
useEffect(() => {
if (fileUrl && videoRef.current) {
setVideo(videoRef.current)
}
}, [fileUrl, setVideo])
// метод для выбора файла
const selectFile = (e) => {
if (e.target.files.length) {
const url = URL.createObjectURL(e.target.files[0])
setFileUrl(url)
}
}
// инпут для выбора файла
const Input = () => (
<div className='input video'>
<label htmlFor='file'>Choose video file</label>
<input type='file' id='file' accept='video/*' onChange={selectFile} />
</div>
)
// превью выбранного файла
const File = () => (
<div className='container video'>
<div className='item video'>
<video src={fileUrl} controls muted ref={videoRef} />
</div>
</div>
)
// условный рендеринг
return <div className='selector video'>{fileUrl ? <File /> : <Input />}</div>
}
Компонент для выбора аудио (components/AudioSelector.js
). Сигнатура данной функции немного сложнее предыдущей, поскольку пользователь может выбрать несколько файлов, но, в целом, все тоже самое:
import { useState, useEffect, useRef } from 'react'
import { useSetters } from 'App'
export const AudioSelector = () => {
const [fileUrls, setFileUrls] = useState()
const inputRef = useRef()
const { setAudio } = useSetters()
useEffect(() => {
// выбираем первый файл по списку после загрузки
if (inputRef.current) {
inputRef.current.click()
}
}, [fileUrls])
const selectFiles = (e) => {
if (e.target.files.length) {
const urls = [...e.target.files].map((f) => URL.createObjectURL(f))
setFileUrls(urls)
}
}
// метод для выбора элемента `audio`
const selectAudio = (e) => {
setAudio(e.target.nextElementSibling)
}
const Input = () => (
<div className='input audio'>
<label htmlFor='file'>Choose audio files</label>
<input
type='file'
id='file'
accept='audio/*'
multiple
onChange={selectFiles}
/>
</div>
)
const Files = () => (
<div className='container audio'>
<h2>Select audio</h2>
{fileUrls?.map((u, i) => (
<div key={i} className='item audio'>
<input
type='radio'
name='audio'
onChange={selectAudio}
ref={i === 0 ? inputRef : null}
/>
<audio src={u} controls />
</div>
))}
</div>
)
return (
<div className='selector audio'>{fileUrls ? <Files /> : <Input />}</div>
)
}
Кратко рассмотрим утилиту для определения поддержки используемых технологий (utils/verifySupport.js
):
// утилита возвращает массив неподдерживаемых "фич"
export default function verifySupport() {
const unsupportedFeatures = []
if (
!('captureStream' in HTMLAudioElement.prototype) &&
!('mozCaptureStream' in HTMLAudioElement.prototype)
) {
unsupportedFeatures.push('captureStream()')
}
;[
'MediaStream',
'MediaRecorder',
'Blob',
'AudioContext',
'MediaStreamAudioSourceNode',
'MediaStreamAudioDestinationNode'
].forEach((f) => {
if (!(f in window)) {
unsupportedFeatures.push(f)
}
})
return unsupportedFeatures
}
Переходим к самой интересной части.
Начнем с методов для записи и формирования ее результата (utils/recording.js
).
Импортируем утилиту для определения поддержки и создаем глобальные (в пределах модуля) переменные:
import verifySupport from './verifySupport'
let mediaChunks = []
let mediaRecorder
let audioContext
let mediaStreamAudioDestinationNode
let mediaStreamAudioSourceNode
Функция для начала записи:
// функция принимает элементы `audio` и `video`, а также `timeslice`
export const startRecording = ({ audio, video, timeslice = 250 }) => {
// проверяем поддержку
const unsupportedFeatures = verifySupport()
if (unsupportedFeatures.length)
return console.error(`${unsupportedFeatures.join(', ')} not supported`)
// захватываем потоки
const videoStream =
(video.captureStream && video.captureStream()) || video.mozCaptureStream()
const audioStream =
(audio.captureStream && audio.captureStream()) || audio.mozCaptureStream()
// см. выше
audioContext = new AudioContext()
mediaStreamAudioDestinationNode = new MediaStreamAudioDestinationNode(
audioContext
)
mediaStreamAudioSourceNode = new MediaStreamAudioSourceNode(audioContext, {
mediaStream: audioStream
})
mediaStreamAudioSourceNode.connect(mediaStreamAudioDestinationNode)
// объединяем потоки
const mediaStream = new MediaStream([
...videoStream.getVideoTracks(),
...mediaStreamAudioDestinationNode.stream.getAudioTracks()
])
// создаем экземпляр "записывателя" медиа,
// передавая ему объединенный поток и указывая тип данных
mediaRecorder = new MediaRecorder(mediaStream, { mimeType: 'video/webm' })
// обрабатываем запись данных
mediaRecorder.ondataavailable = (e) => {
mediaChunks.push(e.data)
}
// сообщаем о начале записи
console.log('*** Start recording')
// запускаем запись
mediaRecorder.start(timeslice)
}
Функция для остановки записи:
export const stopRecording = () => {
// если запись не запускалась, ничего не делаем
if (!mediaRecorder) return
// останавливаем запись
console.log('*** Stop recording')
mediaRecorder.stop()
// формируем результат - видео в формате `WebM`
const result = new Blob(mediaChunks, { type: 'video/webm' })
// очистка
mediaRecorder = null
mediaChunks = []
// возвращаем результат
return result
}
Функция замены источника аудиоданных:
// функция принимает элемент `audio`
export const replaceAudioInStream = (audio) => {
// захватываем поток
const audioStream = audio.captureStream()
// создаем новый источник аудио данных
const newMediaStreamAudioSourceNode = new MediaStreamAudioSourceNode(
audioContext,
{ mediaStream: audioStream }
)
// подключаем новый источник к старому передатчику
newMediaStreamAudioSourceNode.connect(mediaStreamAudioDestinationNode)
// отключаем старый источник
mediaStreamAudioSourceNode.disconnect()
// рокировка
mediaStreamAudioSourceNode = newMediaStreamAudioSourceNode
}
Наконец, функции для приостановки и продолжения записи:
export const pauseRecording = () => {
if (!mediaRecorder) return
console.log('*** Pause recording')
mediaRecorder.pause()
}
export const resumeRecording = () => {
if (!mediaRecorder) return
console.log('*** Resume recording')
mediaRecorder.resume()
}
Компонент для записи (components/Recorder.js
).
Импортируем хуки и утилиты:
import { useState } from 'react'
import { useSetters, useStore } from 'App'
import { usePrevious } from 'hooks/usePrevious'
import {
startRecording,
stopRecording,
pauseRecording,
resumeRecording,
replaceAudioInStream
} from 'utils/recording'
export const Recorder = () => {
// TODO
}
Извлекаем сеттер и части состояния из хранилища, сохраняем ссылку на элемент audio
и определяем локальное состояние для индикатора паузы и начала записи:
const { setResult } = useSetters()
const { audio, video } = useStore()
const previousAudio = usePrevious(audio)
const [paused, setPaused] = useState(false)
const [recordingStarted, setRecordingStarted] = useState(false)
Определяем метод для управления воспроизведением аудио и видео:
// функция принимает тип операции
const toggleAudioVideo = (action) => {
switch (action) {
// воспроизведение
case 'play': {
if (audio.paused) {
audio.play()
}
if (video.paused) {
video.play()
}
break
}
// пауза
case 'pause': {
if (!audio.paused) {
audio.pause()
}
if (!video.paused) {
video.pause()
}
break
}
// остановка
// HTMLAudioElement.prototype и HTMLVideoElement.prototype
// не предоставляют метода `stop`
case 'stop': {
// ставим воспроизведение на паузу
toggleAudioVideo('pause')
// обнуляем текущее время воспроизведения
audio.currentTime = 0
video.currentTime = 0
break
}
default:
return
}
}
Определяем метод для начала записи:
const start = () => {
// проверяем наличием элементов `audio` и `video` и то,
// что запись еще не запускалась
if (video && audio && !recordingStarted.current) {
// запускаем воспроизведение
toggleAudioVideo('play')
// передаем элементы утилите
startRecording({ audio, video })
// обновляем состояние
setRecordingStarted(true)
}
}
Определяем метод для приостановки/продолжения воспроизведения:
const pauseResume = () => {
if (!paused) {
toggleAudioVideo('pause')
pauseRecording()
} else {
toggleAudioVideo('play')
// если при продолжении воспроизведения элемент `audio`
// отличается от элемента, сохраненного в `previousAudio`,
// значит, необходимо заменить источник аудио данных
if (previousAudio !== audio) {
console.log('*** New audio')
replaceAudioInStream(audio)
}
resumeRecording()
}
setPaused(!paused)
}
Наконец, определяем метод для остановки записи:
const stop = () => {
toggleAudioVideo('stop')
const result = stopRecording()
setResult(result)
setRecordingStarted(false)
}
Ну и, конечно, разметка:
if (!audio || !video) return null
return (
<div className='container recorder'>
{!recordingStarted ? (
<button onClick={start} className='start'>
Start recording
</button>
) : (
<>
<button onClick={pauseResume} className={paused ? 'resume' : 'pause'}>
{paused ? 'Resume' : 'Pause'}
</button>
<button onClick={stop} className='stop'>
Stop
</button>
</>
)}
</div>
)
Последний компонент — результат записи (components/Result.js
):
import { useStore } from 'App'
export function Result() {
const { result } = useStore()
if (!result) return null
const url = URL.createObjectURL(result)
return (
<div className='container result'>
<video src={url} controls></video>
<a href={url} download={`${Date.now()}.webm`}>
Download
</a>
</div>
)
}
Думаю, тут все понятно.
Проверяем работоспособность нашего приложения.
Запускаем сервер для разработки с помощью yarn start
или npm start
:
Выбираем видео и аудиофайлы:
Нажимаем на кнопку Start recording
:
Начинается воспроизведение и запись данных.
Нажимаем Pause
, выбираем другой аудио файл и нажимаем Resume
:
Нажимаем Stop
:
Генерируется сведенный контент, появляется превью и ссылка для скачивания файла.
Все работает, как ожидается.
Пожалуй, это все, чем я хотел поделиться с вами в этой статье.
Благодарю за внимание и happy coding!