![](https://habrastorage.org/webt/cm/yu/dk/cmyudkffaaxue4r760lyvmate6i.png)
Привет, друзья!
Продолжаю исследовать возможности по работе с медиа, предоставляемые современными браузерами, и в этой статье хочу рассказать вам о возможности захвата и записи медиаданных в процессе воспроизведения аудио и видеофайлов.
Мы разработаем простое приложение для сведения аудио и видео со следующим функционалом:
- пользователь выбирает одно видео и несколько аудио, хранящихся в его файловой системе;
- когда пользователь нажимает на кнопку для начала записи, запускается воспроизведение выбранных файлов, захватываются их медиапотоки;
- захваченные потоки объединяются в один и передаются для записи;
- в процессе записи пользователь может менять источник аудиоданных;
- пользователь может приостанавливать (например, для изменения источника аудиоданных) и продолжать запись;
- по окончанию записи генерируется видеофайл в формате
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!
ссылка на оригинал статьи https://habr.com/ru/articles/646831/
Добавить комментарий