Записываем экран и звук через расширение в браузере и сохраняем в NextCloud
Здравствуйте дорогие читатели.
В статье делюсь опытом создания расширения для Chromium и Google Chrome браузера.
Раньше я пользовался «условно бесплатными расширениями и программами для записи скринкастов», но в какой-то момент некоторые из них стали платными, и их удобства сошли на «нет». А в некоторых оставались вопросы к безопасности данных и сложности с оплатой. К тому же, я не нашёл программ или расширений с функциями сохранения в своём облаке или сервере.
Возможно вы скажите — зачем мне расширение для браузера?! Ведь я могу взять ffmpeg с x11grab, приправить всё это bash-скриптом с использованием curl, и отправлять результаты в облако одной лишь командой в терминале! И возможно быстренько «перенесу» это решение под все операционные системы! И вы будете правы, но решение получится сложным. А если у нас есть под рукой браузер, то воспользуемся его возможностями (да, это странно — браузер для просмотра HTML-страничек, который записывает ваш экран).
Ссылка на готовое решение под катом.
Статья разделена на 2 части: первая часть относится к extensions API и MediaStream API, вторая к интеграции в Nextcloud.
В статье есть отсылки к исходному коду, поэтому предлагаю во время чтения подсматривать в исходный код.
Часть первая
Первое с чего я начал: оценка возможности создания такого расширения и возможность собрать минимально-рабочую версию. Логично было сразу зайти в документацию по extensions API для Chrome и проверить есть ли что-то про «desktop capture» — и такое там есть.
Далее нужно было изучить desktopCapture API и проверить как это работает. Там же был найден пример кода и расширения для записи содержимого вкладки, с замечанием на то, что код можно адаптировать для записи экрана.
Было требование, чтобы основной «код записи видеопотока» выполнялся в «background», но работа с offscreen api имеет ограничения. Например, в момент начала записи нужно запросить разрешение на экран и на микрофон (если это запись экрана со звуком) . Через offscreen api не получилось сделать, поэтому использовался другой подход: перед записью экрана будет открываться вкладка, в которой будут запрашиваться разрешения. И эта вкладка будет открыта во время записи, но на ней не будет фокусировки. (возможно следовало оставить фокусировку на этой вкладке, чтобы предоставить более детальную информацию о процессе записи).
И это сработало. В момент начала записи происходит открытие вкладки и фокусировка на «вкладку расширения», а затем происходит возврат на исходную вкладку.
Файл manifest.json
{
"name": "__MSG_extName__",
"description": "__MSG_extDesc__",
"version": "1.0",
"manifest_version": 3,
"default_locale": "ru",
"icons": {
"16": "images/icon-16.png",
"32": "images/icon-32.png",
"48": "images/icon-48.png",
"128": "images/icon-128.png"
},
"action":{
"16": "images/icon-16.png",
"32": "images/icon-32.png",
"default_popup":"popup.html"
},
"background": {
"service_worker": "background.js"
},
"permissions": [
"tabs",
"activeTab",
"desktopCapture",
"storage",
"unlimitedStorage"
],
"host_permissions": [
"*://*/*"
]
}
Здесь service_worker служит для перенаправления сообщений во вкладку записи. Можно сказать что background.js это «маршрутизатор сообщений». Он инициирует запуск вкладки recorder.html через сообщение chrome.tabs.sendMessage (tabId) .
Внутри recorder.html (через recorder.js) мы запрашиваем разрешение на микрофон (если это запись со звуком) и выбираем экран для записи (если у вас подключено несколько экранов) и там же подписываемся на входящие сообщения от background.js и выполняем в нём основной код.
Теперь при закрытии popup.html запись будет продолжаться, а для индикации состояния записи, используем подсветку и вывод текста методами.
chrome.action.setBadgeText({text: chrome.i18n.getMessage('rec')});
chrome.action.setBadgeBackgroundColor({color: '#eb1e3e'});
chrome.action.setTitle({title: chrome.i18n.getMessage('recording')});
После просмотра видеофайла, оказалось что его нельзя «проматывать». Файл не проматывался, не показывал длительность и невозможно было выполнить перемотку. После поиска в интернете, удалось найти задачу в ней обозначается то, что MediaRecorder не поддерживает и не собирается поддерживать мета информацию → https://github.com/w3c/mediacapture-record/issues/119 что привело к комментарию-решению использовать ts-ebml библиотеку.
Что такое ts-ebml? Это форк библиотеки, которая позволяет работать с медиа-контейнерами webm или mkv. Записывать и читать метаданные.
Далее я начал экспериментировать с этой библиотекой. Клонировал её и собрал в файл EBML.js, чтобы встроить в расширение и получить «проматываемый видеофайл» (seekable video). Для этого проанализировал код примера и применил внутри расширения (без модификации примера)… И ничего не сработало.
Файл не проматывался. Пришлось изучать исходные коды библиотеки, а также искать ошибки в примере или в «данных» для примера (например слишком короткий тестовый видеофайл примера мог быть неподходящим). В итоге сработал ручной сбор длительности через API EMBL.Reader:
mediaRecorder.onstop = async function(e) {
mediaStream.getTracks().forEach(track => track.stop());
const blobFileSource = new Blob(chunks, {type: nextCloud.getVideoMime()});
const webMBuf = await fetch(URL.createObjectURL(blobFileSource)).then(res=> res.arrayBuffer());
const decoder = new EBML.Decoder();
const reader = new EBML.Reader();
reader.drop_default_duration = false;
var last_duration = 0;
// Этот важный код отсутсвует в оригинальном примере
reader.addListener("duration", ({timecodeScale, duration})=>{
last_duration += duration;
});
const elms = decoder.decode(webMBuf);
elms.forEach((elm)=>{ reader.read(elm); });
reader.stop();
const refinedMetadataBuf = EBML.tools.makeMetadataSeekable(reader.metadatas, last_duration, reader.cues);
const body = webMBuf.slice(reader.metadataSize);
const blobFile = new Blob([refinedMetadataBuf, body], {type: nextCloud.getVideoMime()});
const url = URL.createObjectURL(blobFile);
//...
}
Теперь файл проматывается и можно увидеть его длительность в плеере. На этом этапе оценка возможности выполнена. Стало понятно — это работает, а значит теперь результаты можно загружать в «облако» (под облаком подразумевается Nextcloud).
Часть вторая
Для облака использую Nextcloud, поэтому первым делом проанализировал возможности для интеграции, и первым доступным способом стал «протокол WebDAV». У Nextcloud есть документация по работе с WebDAV. В этой документации есть примеры работы для Javascript.
Далее нужно было разобраться с загрузкой файла. Документация требует путь до папки для загружаемого файла. Поэтому в расширении был добавлен раздел «Настройки» , в котором можно настроить доступы к Nextcloud и путь для папки «загружаемых файлов».
Пароль приложения к Nextcloud создаётся в разделе Безопасность.
Пользователю потребуется ввести «Адрес сервера» Nextcloud без «trailing slash» (например https://example.ru), а в поле «Директория» нужно ввести путь до папки куда будут сохраняться видеофайлы (например »/screencasts». или »/».). Далее логин и пароль приложения, который создаётся в разделе «Безопасность». Этот логин и пароль, будет передаваться в заголовке запроса Authorization: Basic base64encode () .
Если вам понадобится сделать форк нашего расширения и привязать свой сервис и свои настройки, то рекомендуем использовать раздел «Настроек». Код который отвечает за эту форму находится в функции »_bindSettingsForm» в файле popup.js
Настройки сохраняются через chrome.storage.sync, поэтому они могут автоматически переносится на другие устройства.
Если настройки заполнены, то расширение будет отправлять запросы через «fetch api» к указанному в настройках серверу, и видеофайл будет автоматически загружаться в Nextcloud, а затем выдавать ссылку на видеоплеер (просмотр файла).
Если во время загрузки возникает ошибка, то расширение предложит сохранить файл локально.
Загрузка файла разделена на 2 этапа: первый этап выполнение PUT запроса к webdav адресу будущего файла (смотрите метод uploadFile в recorder.js).
uploadFile: async function (blob, fileName) {
let url = this.upload_path(fileName);
const totalBytes = blob.size;
let bytesUploaded = 0;
// Этот код нужен для отслеживания прогресса загрузки данных
const progressTrackingStream = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk);
bytesUploaded += chunk.byteLength;
chrome.runtime.sendMessage({ name: 'nextCloudUploadProgress', data: bytesUploaded / totalBytes });
},
flush(controller) {
console.log("completed stream");
},
});
return await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/octet-stream",
"Authorization": this.createAuthHeaderValue()
},
body: blob.stream().pipeThrough(progressTrackingStream),
duplex: "half",
});
}
Данной код, позволяет в процессе загрузки файла, выводить индикатор прогресса.
Второй этап — получение информации о загруженных файлах (смотрите метод fetchVideoPlayerLink в recorder.js).
fetchVideoPlayerLink: async function (fileName) {
const propertyRequestBody = `
`;
return new Promise((resolve, reject) => {
fetch(this.base_uri(), {
method: 'PROPFIND',
headers: {
"Accept": "text/plain",
"Depth": 1,
"Content-Type": "application/xml",
"Authorization": this.createAuthHeaderValue()
},
body: propertyRequestBody
}).then((response) => {
response.text().then(text => {
if (response.status < 400) {
const nodes = this.parseWebDavFileListXML(text, nextCloud.base_uri(fileName));
let lastNode = nodes.pop();
if(lastNode && lastNode.hasOwnProperty('fileid'))
{
let playerLink = nextCloud.createPlayerLink(lastNode.fileid);
resolve(playerLink);
}
else
{
resolve(null);
}
} else {
reject(new Error({ response }), text)
}
});
}, (reason => {
chrome.runtime.sendMessage({ name: 'nextCloudUploadFileError', data: 'fetch_link_error' });
console.error('fetch_link_error');
}));
});
}
В этом коде есть недостаток: каждая загрузка файла вытягивает список ранее загруженных. К сожалению, я не разобрался (или не нашёл) способа получения только 1 актуального файла. Если кто-то сталкивался с такой задачей (ограничение кол-ва файлов и сортировка по дате модификации файла в Nextcloud) буду признателен за совет.
Выводы
Если у вас современный Chrome или Chromium (например 119 версии), то с его помощью можно записывать видео с экрана и отправлять его на ваш «собственный» сервер.
Благодарю за внимание и хорошего Вам настроения!