JavaScript: захват медиапотока из DOM элементов

от автора

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

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

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

  • пользователь выбирает одно видео и несколько аудио, хранящихся в его файловой системе;
  • когда пользователь нажимает на кнопку для начала записи, запускается воспроизведение выбранных файлов, захватываются их медиапотоки;
  • захваченные потоки объединяются в один и передаются для записи;
  • в процессе записи пользователь может менять источник аудиоданных;
  • пользователь может приостанавливать (например, для изменения источника аудиоданных) и продолжать запись;
  • по окончанию записи генерируется видеофайл в формате 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. К сожалению, на сегодняшний день данный метод не поддерживается 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)

// создаем аудио контекст 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!


ссылка на оригинал статьи https://habr.com/ru/articles/646831/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *