Привет, друзья!


Продолжаю исследовать возможности по работе с медиа, предоставляемые современными браузерами, и в этой статье хочу рассказать вам о возможности захвата и записи медиаданных в процессе воспроизведения аудио и видеофайлов.


Мы разработаем простое приложение для сведения аудио и видео со следующим функционалом:


  • пользователь выбирает одно видео и несколько аудио, хранящихся в его файловой системе;
  • когда пользователь нажимает на кнопку для начала записи, запускается воспроизведение выбранных файлов, захватываются их медиапотоки;
  • захваченные потоки объединяются в один и передаются для записи;
  • в процессе записи пользователь может менять источник аудиоданных;
  • пользователь может приостанавливать (например, для изменения источника аудиоданных) и продолжать запись;
  • по окончанию записи генерируется видеофайл в формате WebM — превью сведенного контента и ссылка для его скачивания.

В качестве фреймворка для фронтенда я буду использовать React, однако все функции по работе с медиа будут автономными (сигнатура этих функций будет framework agnostic), так что вы можете использовать любой другой фреймворк или ограничиться чистым JavaScript.


Песочница:

Репозиторий.


О том, как разработать приложение для создания аудиозаметок, можно прочитать в этой статье, а о том, как разработать приложение для захвата и записи экрана — в этой.


Если вам это интересно, прошу под кат.


Если вы внимательно изучили функционал нашего будущего приложения, то могли заметить, что пользователь может выбрать только одно видео и имеет возможность менять только источник аудиоданных. Это связано с тем, что на сегодняшний день в процессе записи медиаданных возможна замена только аудиоисточника (без создания нового экземпляра MediaRecorder).


В процессе разработки приложения мы будем опираться на следующие спецификации (черновики и рекомендацию):



Ссылки на соответствующие разделы MDN будут приводиться по мере необходимости.


Создаем шаблон React-приложения с помощью create-react-app:


yarn create react-app capture-stream
# or
npx create-react-app capture-stream

Пока генерируется шаблон, поговорим об интерфейсах и методах, которые мы будем применять.


  • Для захвата медиапотока в процессе воспроизведения аудио или видео, а также в процессе рендеринга canvas, используется метод captureStream:

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)


// создаем аудио контекст
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) ||
   !('captureStream' in HTMLVideoElement.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()
 const audioStream = audio.captureStream()

 // см. выше
 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!




Комментарии (3)


  1. Wesha
    21.01.2022 08:48

    Почему-то после нажатия Stop не предлагает скачать полученный файл, а возвращается к Start Recoding.


    1. aio350 Автор
      23.01.2022 12:21

      из песочницы


      1. Wesha
        23.01.2022 22:55

        Я ровно в той же песочнице. Видео и аудио играют, а сведённый файл после нажатия STOP не появляется.