JavaScript: разрабатываем приложение для записи экрана

image-loader.svg


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

Хочу поделиться с вами решением интересной задачи: записать экран компьютера пользователя.

Общие требования к реализации:


  • запись должна состоять из видео и аудио
  • у пользователя должна быть возможность просмотра и скачивания записи
  • данные должны передаваться и сохраняться на сервере
  • запись, сохраняемая на сервере, должна быть приличного качества, но весить мало

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

→ Исходный код проекта

Скриншот:


image-loader.svg

К слову, здесь можно почитать о том, как разработать приложение для записи звука.

Основные технологии, которые мы будем использовать при разработке приложения:


  • Express.js — Node.js-фреймворк для разработки веб-серверов
  • React.js — JavaScript-фремворк для разработки пользовательских интерфейсов
  • Socket.io — библиотека для разработки realtime-приложений с помощью веб-сокетов
  • FFmpeg — инструмент для работы с видео и аудио

Здесь вы найдете шпаргалку по Express API, а здесь — руководство по работе с Socket.io.

Вместо React вы можете использовать любой другой фреймворк или ванильный JS. Если хотите, можете использовать TypeScript.


Подготовка и настройка проекта

mkdir screen-record && cd !$

yarn init -yp

yarn add concurrently


  • создаем директорию для проекта и переходим в нее
  • инициализируем Node.js-проект
  • устанавливаем concurrently

Определяем команды для запуска серверов в package.json:

"scripts": {
 "server": "yarn --cwd server dev",
 "client": "yarn --cwd client start",
 "start": "concurrently \"yarn server\" \"yarn client\""
}
mkdir server && cd !$

yarn init -yp

yarn add express socket.io @ffmpeg-installer/ffmpeg fluent-ffmpeg

yarn add -D nodemon


  • создаем директорию для сервера и переходим в нее
  • инициализируем Node.js-проект
  • устанавливаем основные зависимости и зависимость для разработки (про ffmpeg мы поговорим в разделе, посвященном разработке сервера)

Определяем тип кода сервера и команды для запуска сервера в package.json:

"type": "module",
"scripts": {
 "start": "node index.js",
 "dev": "nodemon index.js"
},
cd .. && yarn create react-app client


  • возвращаемся в корневую директорию и создаем шаблон react-приложения в директории client
cd client

yarn add socket.io-client react-loader-spinner

yarn add -D sass


  • переходим в директорию client
  • устанавливаем основные зависимости и зависимость для разработки:

Я также удалил из шаблона все лишнее (инструменты для тестирования, web-vitals и т.д.).

Единственное, что нужно добавить в package.json — это адрес сервера для проксирования запросов:

"proxy": "http://localhost:4000",

На этом подготовка и настройка проекта завершены.


Клиент

Весь код клиента содержится в файле scr/App.js.

import { useEffect, useRef, useState } from 'react'
import Loader from 'react-loader-spinner'
import io from 'socket.io-client'
import './App.scss'

Импортируем хуки, индикатор загрузки, клиента socket.io и стили.

const SERVER_URI = 'http://localhost:4000'

let mediaRecorder = null
let dataChunks = []

Создаем переменные:


  • для адреса сервера
  • экземпляра MediaRecorder. MediaRecorder — это интерфейс, предоставляемый MediaStream Recording API, для записи медиа
  • частей записанных данных
function App() {
 // TODO
}

export default App
const username = useRef(`User_${Date.now().toString().slice(-4)}`)
const socketRef = useRef(io(SERVER_URI))
const videoRef = useRef()
const linkRef = useRef()


  • генерируем случайное имя пользователя (например, User_1234) — в реальном приложении имя пользователя, скорее всего, будет извлекаться из объекта user, содержащегося в контексте, например, const { user } = useAuthContext(); const { username } = user
  • вызов io(url, options) возвращает уникальный сокет клиента, используемый для передачи и получения данных от сервера. Нам достаточно указать адрес сервера. С полным списком настроек можно ознакомиться здесь
  • нам также нужны ссылки на DOM-элементы video и a для предоставления пользователю возможности просмотра записи и ее скачивания

Мы используем хук useRef для сохранения состояний между рендерингами.

const [screenStream, setScreenStream] = useState()
const [voiceStream, setVoiceStream] = useState()
const [recording, setRecording] = useState(false)
const [loading, setLoading] = useState(true)


  • screenStream — поток видео захваченного экрана
  • voiceStream — поток аудио из микрофона
  • recording — индикатор состояния записи
  • loading — индикатор состояния загрузки

Первое, что нам нужно сделать на клиенте — это уведомить сервер о подключении нового пользователя, сообщив ему имя пользователя:

useEffect(() => {
 socketRef.current.emit('user:connected', username.current)
}, [])

Для отправки событий используется метод socket.emit(type, data), где type — строка, обозначающая тип события, а data — данные. Данными могут быть как примитивы, так и объекты. Для обработки событий используется метод socket.on(type, callback), где type — тип события, а callback — функция обработки, принимающая данные, отправленные с помощью socket.emit.

Далее нам необходимо захватить экран (получить поток видеоданных):

useEffect(() => {
 ;(async () => {
   // проверяем поддержку
   if (navigator.mediaDevices.getDisplayMedia) {
     try {
       // получаем поток
       const _screenStream = await navigator.mediaDevices.getDisplayMedia({
         video: true
       })
       // обновляем состояние
       setScreenStream(_screenStream)
     } catch (e) {
       console.error('*** getDisplayMedia', e)
       setLoading(false)
     }
   } else {
     console.warn('*** getDisplayMedia not supported')
     setLoading(false)
   }
 })()
}, [])

Для получения видеоданных из захвата экрана используется метод getDisplayMedia(), предоставляемый интерфейсом MediaDevices, входящим в состав Navigator.

К сожалению, на сегодняшний день данный метод поддерживается только десктопными браузерами, что составляет около 40% пользователей, но это все же лучше, чем ничего.

Следует отметить, что getDisplayMedia также умеет захватывать аудиоданные, но в настоящее время эту возможность поддерживают только Edge и Chrome, поэтому мы воспользуемся другим интерфейсом.

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

Получаем аудиоданные из микрофона пользователя:

useEffect(() => {
 ;(async () => {
   // проверяем поддержку
   if (navigator.mediaDevices.getUserMedia) {
     // сначала мы должны получить видеопоток
     if (screenStream) {
       try {
         // получаем поток
         const _voiceStream = await navigator.mediaDevices.getUserMedia({
           audio: true
         })
         // обновляем состояние
         setVoiceStream(_voiceStream)
       } catch (e) {
         console.error('*** getUserMedia', e)
         // см. ниже
         setVoiceStream('unavailable')
       } finally {
         setLoading(false)
       }
     }
   } else {
     console.warn('*** getUserMedia not supported')
     setLoading(false)
   }
 })()
}, [screenStream])

Для получения аудио-потока используется метод getUserMedia. С поддержкой данного метода все намного лучше.

Не знаю точно, с чем это связано, но если мы попытаемся получить потоки одновременно, то получим только видеопоток, а попытка получения аудиопотока завершится ошибкой Permission denied. По крайней мере, такое поведение наблюдается в Chrome.

Мы готовы писать экран без звука, поэтому при возникновении любой ошибки, связанной с получением аудиопотока (включая отказ пользователя в предоставлении разрешения на использование микрофона), мы устанавливаем voiceStream в значение unavailable.

Также обратите внимание на расстановку setLoading(false). При инициализации приложения мы показываем пользователю индикатор загрузки до получения всех необходимых разрешений.

Глянем на разметку:

return (
 <>
   

Screen Recording App

Download )

Ничего особенного:


  • элемент video для просмотра записи
  • элемент a для скачивания записи
  • кнопка для запуска и остановки записи

Метод onClick выглядит так:

const onClick = () => {
 if (!recording) {
   startRecording()
 } else {
   if (mediaRecorder) {
     mediaRecorder.stop()
   }
 }
}

Операция, выполняемая при нажатии кнопки, зависит от значения recording.

function startRecording() {
 if (screenStream && voiceStream && !mediaRecorder) {
   // TODO
 }
}

Для выполнения кода функции startRecording требуется наличие потоков и отсутствие экземпляра MediaRecorder.

// обновляем состояние
setRecording(true)

// удаляем атрибуты
videoRef.current.removeAttribute('src')
linkRef.current.removeAttribute('href')
linkRef.current.removeAttribute('download')

Формируем медиа-поток:

let mediaStream
if (voiceStream === 'unavailable') {
 mediaStream = screenStream
} else {
 mediaStream = new MediaStream([
   ...screenStream.getVideoTracks(),
   ...voiceStream.getAudioTracks()
 ])
}

Состав медиа-потока зависит от доступности аудиопотока. Если аудиопоток недоступен, медиапоток будет состоять только из видеопотока. Иначе формируется объединенный поток из видеотреков видеопотока и аудиотреков аудиопотока. Для объединения потоков используется интерфейс MediaStream. С его поддержкой все хорошо.

Существует также другой способ объединения потоков:

const audioTracks = voiceStream.getAudioTracks()
audioTracks.forEach(track => {
 screenStream.addTrack(track)
})
mediaStream = screenStream
mediaRecorder = new MediaRecorder(mediaStream)
mediaRecorder.ondataavailable = ({ data }) => {
 dataChunks.push(data)
 socketRef.current.emit('screenData:start', {
   username: username.current,
   data
 })
}
mediaRecorder.onstop = stopRecording
mediaRecorder.start(250)

Создаем экземпляр MediaRecorder.

Метод start принимает количество мс. По истечении указанного времени вызывается событие dataavailable. Данные содержатся в свойстве data.

Мы помещаем части записанных данных в массив dataChunks и отправляем их на сервер с помощью сокета. В данном случае мы делаем это 4 раза в секунду.

По окончанию записи вызывается функция stopRecording:

function stopRecording() {
 // обновляем состояние
 setRecording(false)

 // сообщаем серверу о завершении записи
 socketRef.current.emit('screenData:end', username.current)

 // об этом хорошо написано здесь: https://learn.javascript.ru/blob
 // дополнительно:
 // https://developer.mozilla.org/en-US/docs/Web/API/Blob
 // https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL
 const videoBlob = new Blob(dataChunks, {
   type: 'video/webm'
 })
 const videoSrc = URL.createObjectURL(videoBlob)

 // источник видео
 videoRef.current.src = videoSrc
 // ссылка для скачивания файла
 linkRef.current.href = videoSrc
 // название скачиваемого файла
 linkRef.current.download = `${Date.now()}-${username.current}.webm`

 // выполняем сброс
 mediaRecorder = null
 dataChunks = []
}

На этом с клиентом мы закончили.


Сервер

Структура проекта (директория server):

- socket_io
 - onConnection.js - функция для обработки подключения
- utils
 - saveData.js - функция для сохранения записи
- video - директория для записей
- index.js - код сервера
- ...

Начнем с файла, содержащего код сервера (index.js):

import express from 'express'
import http from 'http'
import { Server } from 'socket.io'
import { onConnection } from './socket_io/onConnection.js'

Импортируем библиотеки и функцию для обработки подключения.

const app = express()
const server = http.createServer(app)
const io = new Server(server, {
 cors: {
   origin: 'http://localhost:3000'
 }
})

Создаем экземпляры express-приложения, сервера и socket.io. Обратите внимание на настройку cors. Данная настройка является обязательной.

io.on('connection', onConnection)

server.listen(4000, () => {
 console.log('Server ready 
    
            

© Habrahabr.ru