Авторский проект IT-специалиста Олега Барабанова Персональные публикации на тему IT и не только…

Опубликовал "Simple Website Screenshot Tool" — клиентский веб-скриншотер, работающий на базе новых возможностей Screen Capture API

Некоторое время назад я узнал о расширении Screen Capture API новыми экспериментальными API, а именно CropTarget и RestrictionTarget, которые правда пока еще поддерживаются только в новейших версиях Chromium (>= 132). Тем не менее, для каких-то задач, в рамках эксперимента эти API можно попробовать использовать, что я и попытался сделать, разработав библиотеку для создания скриншотов, работающую чисто на клиенте с использованием браузерного API.

Репозиторий проекта: https://github.com/olegbarabanov/simple-website-screenshot-tool

NPM: https://www.npmjs.com/package/simple-website-screenshot-tool

Установка через NPM: npm install simple-website-screenshot-tool

Так-то обычным делом для автоматизированного создания скриншотов является использование серверных возможностей, которые зачастую используют в этих целях движки браузеров в headless режиме и при этом сервер не ограничен CORS. Это практичней и надежней.

Но в нашем случае, создание этой библиотеки преследовало цели испытать новый экспериментальный API в деле, а также сделать хоть и ограниченную, но возможность делать скриншоты чисто с клиентской стороны (без использования браузерных расширений, сервера или каких-либо сторонних ресурсов). Ну и просто еще один небольшой pet-проект.

Ну и еще хочу сразу сказать, что я прекрасно понимаю, возможные последствия работы с экспериментальным API. И скажу честно, ранее я уже неоднократно обжигался на этом. Тем не менее, эта библиотека вроды бы пока работает и поэтому к этому проекту можно относиться как к экспериментальной демонстрации работы экспериментального API.

Назначение экспериментальных CropTarget и RestrictionTarget в Screen Capture API

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

Соответственно часто использовался следующий алгоритм обработки такого видеопотока:

  1. Определялись размер и координаты положения блока, который вы хотите отображать в видеопотоке.
  2. Затем делался видеозахват всего окна браузера.
  3. Из каждого кадра полученного видеопотока вырезалась прямоугольная область с вычисленными размерами и координатами.
  4. Полученное вырезанное изображение отправлялось в качестве кадра в другой исходящий видеопоток.

Затем в 104-м Chromium появился экспериментальный интерфейс CropTarget, в котором все вышеперечисленное было реализовано. Т.к. это по факту было видеозахватом экрана с последующей постобработкой, то если что-то отображалось поверх захватываемого элемента, то это попадало и в видеопоток.

И тут совсем недавно в 132-м Chromium добавили еще один экспериментальный интерфейс RestrictionTarget, при котором в видеопоток попадает только отрендеренная часть самого захватываемого блока и игнорируются любые другие слои наложения. Тем самым мы можем делать видеозахват даже скрытых элементов. Единственное существенное ограничение заключается в необходимости размещения захватываемого элемента в области экрана.

С учётом этих новых возможностей и их ограничений, мне пришла в голову попробовать в качестве эксперимента сделать на базе этого API маленькую библиотеку для создания скриншотов веб-страниц, которая могла бы обходиться только браузерным окружением на клиенте. Данную библиотеку после некоторых раздумий я назвал "Simple Website Screenshot Tool", что по моему вполне отражает ее назначение.

Пример работы "Simple Website Screenshot Tool"

Пример создания единственного скриншота:

import { ScreenshotCapture } from 'simple-website-screenshot-tool'

const capture = new ScreenshotCapture();
const singleResult = await capture.getScreenshot({ url: "https://example.com", width: 1920, height: 4000 });

Пример создания множества скриншотов c помощью метода getScreenshots, который представляет собой асинхронный метод-генератор:

import { ScreenshotCapture } from 'simple-website-screenshot-tool'

const capture = new ScreenshotCapture();
const multipleResults = Array.fromAsync(capture.getScreenshots([
    { url: "https://example.com", width: 1920, height: 4000 }, 
    { url: "https://example.com", width: 1280, height: 1024 },  
    { url: "https://example.com", width: 768, height: 1000 },  
]));

В примере кстати используется новый метод Array.fromAsync() из ES2024, который является расширенной версией Array.from(), но в отличие от последнего, новый метод может работать не только с итераторами и массивоподобными объектами, но и с асинхронными итераторами и генераторами.

Алгоритм работы "Simple Website Screenshot Tool"

Библиотека реализует свой функционал следующим образом:

  1. Библиотека принимает на вход список URL с дополнительными параметрами, такими как размеры ширины и высоты, время задержки и пр.
  2. Затем в корневом элементе сайта создается маленький viewport-блок (например размером 300x300), в роли которого выступает обычный <div>, внутри которого создается пустой <iframe>
  3. Этот viewport-блок размещается в body, но со своим контекстом наложения (stacking context) благодаря position:fixed. А благодаря отрицательному z-index и некоторым другим CSS-свойствам этот блок скрывается под базовым слоем документа.
  4. Затем с помощью getDisplayMedia запрашиваем у пользователя доступ к видеозахвату viewport-элемента и запускаем его видеозахват с определенными параметрами.
  5. Задаем в iframe нужный URL сайта, указываем необходимые размеры и ждем загрузки (onload).
  6. После загрузки iframe (если CORS позволит) задаем для него изначальное положение относительно viewport-элемента равное нулям.
  7. Создаем отдельный элемент OffscreenCanvas, в котором будет формироваться итоговое изображение-скриншот. Размер этого изображения равен размеру iframe.
  8. Сдвигая iframe внутри viewport-элемента на величину размера viewport-элемента, мы каждый раз делаем снимок этого viewport-элемента и наносим его на результирующее изображение. Например если у нас есть iframe с размером 1500x1500 и viewport-элемент с размерами 300x300, то соответственно будет создано 25 снимков, которые в итоге будет склеены в виде одного результирующего изображения.
  9. Возвращаем пользователю результат в виде BLOB заданного формата изображения.
  10. Повторяем пункты 5-8 до тех пор, пока не пройдемся по всему переданному изначально списку URL.

Вы можете спросить зачем такие сложности с созданием общего скриншота по частям. К сожалению RestrictionTarget ограничен размером видимой части экрана браузера и поэтому вся остальная часть, находящаяся за границами, не попадает в видеозахват. Соответственно, если iframe не умещается в размеры экрана, вы не сможете сделать его цельный снимок. Именно поэтому и сделаны все эти сложности с формированием скриншота по кусочкам.

Проблемы, с которыми я столкнулся при разработке этой библиотеки

К сожалению в процессе написания библиотеки я столкнулся с несколькими проблемами которые до сих пор в той или иной мере остаются открытыми.

Проблема синхронизации видеозахвата и этапов перерисовки (repaint) браузера

В процессе реализации библиотеки выяснилась неприятная особенность, связанная с тем, что при быстром создании кадров для общего скриншота, некоторые эти кадры были пустыми или явно некорректными. По мере увеличения задержек после сдвига iframe, проблем становилось намного меньше, но при этом существенно падала общая скорость формирования скриншота.

Чтобы понять корень проблемы, надо обратить внимание на два процесса, которые работают независимо друг от друга:

  1. Процесс перерисовки (Repaint) элементов в браузере. Суть в том, что смещение iframe как и любое изменение внутри него автоматически вызывает этап перерисовки в браузере. Этот процесс быстрый но не моментальный.
  2. Процесс видеозахвата. При захвате видеопотока, невозможно гарантировать какую-то определенную частоту видеопотока и соответственно контролировать момент захвата кадра. Факторов, которые могут повлиять на частоту кадров полно. Это может быть недостаточная производительность устройства, его чрезмерная загруженность и пр. множество, которое нет смысла тут перечислять.

К сожалению пока что затруднительно надежно синхронизировать эти два процесса, так чтобы захват кадра происходил ровно в тот момент, когда уже завершена перерисовка iframe и viewport-блока. Сейчас же просто берется достаточный запас по времени, что непроизводительно и ненадежно.

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

Проблема E2E тестирования

Изначально я хотел библиотеку просто покрыть e2e-тестами на базе фреймворка Playwright с установленным Chromium и с запуском всего этого добра в Docker-контейнере, причем так, чтобы это все было автоматизированно для использования с Ci / CD инструментами (например с Github Actions).

Уже в процессе написания тестов, вскрылся неприятный нюанс, который я пока не смог побороть. Суть в том, что при вызове метода getDisplayMedia браузер интерактивно запрашивает у пользователя разрешение на видеозахват. К сожалению в API Playwright (и вероятно самого Chromium) нет методов, которые бы позволяли управлять этим окном запроса, для автоматического подтверждения. Я пытался что-нибудь найти среди параметров запуска самого Chromium, но тоже ничего не смог найти из того, что могло бы помочь. Поэтому этот вопрос до сих пор остается открытым.

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

Заключение

Разработка этой библиотеки была достаточно интересным экспериментом в плане изучения новых возможностей Screen Capture API. Но как и в любом эксперименте, никогда нельзя быть уверенным в абсолютно успешном результате. Вот и тут, при разработке этой небольшой библиотеки вскрылись ограничения, которые пока еще не удалось корректно обойти. Тем не менее, хоть и с ограничениями, но эта библиотека работает и именно поэтому я её даже в таком виде, но выложил в NPM.

А так постепенно со временем буду её дорабатывать по мере возможностей. Например надо будет добавить возможность автоматического вычисления высоты скриншота в соответствии с размером сайта, что вполне возможно сделать в тех случаях, когда разрешен CORS, но крайне затруднительно и ненадежно в тех случаях, когда есть запрет CORS. А в общем говоря, там много чего еще можно доработать по мере необходимости, главное не перемурить с функционалом.

↑ ↓