Записываем экран и звук через расширение в браузере и сохраняем в NextCloud

Здравствуйте дорогие читатели.

В статье делюсь опытом создания расширения для Chromium и Google Chrome браузера.

Раньше я пользовался «условно бесплатными расширениями и программами для записи скринкастов», но в какой-то момент некоторые из них стали платными, и их удобства сошли на «нет». А в некоторых оставались вопросы к безопасности данных и сложности с оплатой. К тому же, я не нашёл программ или расширений с функциями сохранения в своём облаке или сервере.

Возможно вы скажите — зачем мне расширение для браузера?! Ведь я могу взять ffmpeg с x11grab, приправить всё это bash-скриптом с использованием curl, и отправлять результаты в облако одной лишь командой в терминале! И возможно быстренько «перенесу» это решение под все операционные системы! И вы будете правы, но решение получится сложным. А если у нас есть под рукой браузер, то воспользуемся его возможностями (да, это странно — браузер для просмотра HTML-страничек, который записывает ваш экран).

Ссылка на готовое решение под катом.

77cd08a0c946857d99f381acd514148a.png

Статья разделена на 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 создаётся в разделе Безопасность.

Пользователю потребуется ввести «Адрес сервера» 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 версии), то с его помощью можно записывать видео с экрана и отправлять его на ваш «собственный» сервер.

Благодарю за внимание и хорошего Вам настроения!

© Habrahabr.ru