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

Kate

Administrator
Команда форума
Привет, друзья!


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


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


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


mrgqccgyflhdaohl1a8jbjbsdfu.png




Выбираем видео и аудиофайлы:


mqjfwu3mxcu0sivodl9ldr2kz0m.png




Нажимаем на кнопку Start recording:


rmsujbrvtfvlbnq8hi6i5tphul4.png




Начинается воспроизведение и запись данных.


Нажимаем Pause, выбираем другой аудио файл и нажимаем Resume:


ygdf5frw6wwgaxdsyb_gcj6r3im.png




Нажимаем Stop:


mt2jmszwbqdxhy6r0wyxkqy1qvy.png




Генерируется сведенный контент, появляется превью и ссылка для скачивания файла.


Все работает, как ожидается.


Пожалуй, это все, чем я хотел поделиться с вами в этой статье.


Благодарю за внимание и happy coding!

 
Сверху