Перейти к содержимому

Медиа

Эта страница переведена с английского. Читать страницу на английском

Zeyn отдаёт медиафайлы с поддержкой HTTP range-запросов и воспроизводит их в компонентах Video и Audio. Декодированием занимаются штатные элементы <video> и <audio>; Zeyn рисует поверх них собственные доступные элементы управления.

Отдача файлов

Зарегистрируйте MediaSource в feature с указанием права:

Feature::new("library")
    .media("films", "library.watch", FilmFolder::new())
    .media("music", "library.listen", SongTable::new())

Браузер запрашивает GET /_zeyn/media/{source}/{id}. Прежде чем источник что-либо увидит, сервер:

  1. отклоняет идентификаторы, которые не состоят из 1-128 символов A-Z a-z 0-9 - _ ., начинаются с точки или содержат .. (404);
  2. проверяет право источника (403) и guard-ы feature;
  3. запускает перехватчики (interceptors) (CallKind::Media, с именем источника в screen).

open(ctx, id) ищет идентификатор в собственном каталоге приложения и возвращает Media: Media::file(path) для файла на диске (тип по расширению, entity tag по размеру и времени изменения), Media::bytes(data, type) для данных в памяти или Media::new(reader, len, type) для любого Read + Seek. Идентификатор это имя, а не путь: стройте путь из своего поиска, а не из идентификатора.

Ответы:

ЗапросОтвет
без Range, другая единица или несколько диапазонов200 со всем файлом
bytes=a-b, bytes=a-, bytes=-n206 с Content-Range: bytes a-b/len
диапазон bytes с ошибкой в записи или начинающийся за концом416 с Content-Range: bytes */len
If-Range, не совпадающий с entity tag200 со всем файлом
If-None-Match, совпадающий с entity tag304

В каждом ответе есть Accept-Ranges: bytes, Cache-Control: private, no-cache и X-Content-Type-Options: nosniff. Inline отдаются только аудио, видео, растровые изображения, WebVTT и потоковые плейлисты; любой другой тип (HTML, SVG, скрипты) отправляется как application/octet-stream с Content-Disposition: attachment, поэтому файл никогда не сможет выполниться как страница приложения. Файлы читаются блоками по 64 KiB в blocking-пуле, в очереди не более четырёх блоков, так что память остаётся постоянной для файла любого размера, а ушедший клиент прекращает чтение. Лимиты запросов действуют так же, как для других запросов; чтение медиа не является записью.

Content security policy менять не нужно: default-src 'self' уже разрешает медиа из приложения. Библиотеке адаптивной трансляции, использующей Media Source Extensions, нужен blob: в media-src, который Zeyn не добавляет.

Публичные файлы: изображения, логотипы, загрузки

Файлы, которые может видеть любой, вошедший в систему или нет, помещаются в публичный источник:

Feature::new("catalog").public_media("images", 86_400, ImageFolder::new("data/images"))

GET /_zeyn/media/images/{id} пропускает проверку права (идентификаторы, guard-ы и перехватчики по-прежнему работают), не устанавливает cookie и отвечает с Cache-Control: public, max-age=86400 (не более MAX_PUBLIC_MEDIA_CACHE, один год), так что браузеры и CDN перед приложением хранят его. Entity tag по-прежнему позволяет им перепроверять с 304. Используйте путь как src для Image. Для множества крупных изображений отдельный хост изображений за CDN всё же дешевле; разрешите его через App::csp(|c| c.img_src([...])).

  • open должен возвращать одни и те же байты каждому посетителю. Никогда не делайте публичный файл зависимым от пользователя, роли, арендатора (tenant) или языка: CDN или прокси хранит первый ответ и отдаёт его всем. Файлы, различающиеся по пользователям, относятся к источнику, зарегистрированному через Feature::media.
  • При большом max_age давайте идентификаторы, которые меняются вместе с содержимым, например хеш содержимого (poster-3f9a2c.jpg). Тогда изменённый файл получает новый адрес, и старые копии не живут в кэшах до истечения срока.
  • Чтения публичных источников (GET и HEAD) не учитываются в App::rate_limit: одна страница может загружать десятки постеров, а многие мобильные посетители делят один адрес за NAT оператора. Приватные источники по-прежнему учитываются, а правила IP allow и deny применяются к обоим. Ограничение злоупотребления трафиком это задача CDN или обратного прокси перед приложением.

Плееры

Video::new("films", t!("films.harbor"))
    .subtitle(t!("films.harbor_sub"))
    .file("harbor.webm")
    .file("harbor.mp4")
    .hls("harbor.m3u8")
    .poster("harbor.jpg")
    .captions("harbor.en.vtt", "en", "English")
    .captions("harbor.tr.vtt", "tr", "Türkçe")

Audio::new("music", t!("songs.dawn")).subtitle(t!("songs.artist")).file("dawn.mp3")

Элементы управления: воспроизведение и пауза, ползунок перемотки, время, отключение звука, ползунок громкости, меню скорости воспроизведения, кнопка субтитров и (при нескольких файлах субтитров) меню языка, а для видео картинка в картинке (где её поддерживает браузер) и полный экран. Ползунки следуют паттерну WAI-ARIA slider: стрелки сдвигают на 5 секунд (или на 5 процентов громкости), Page Up и Page Down сдвигают на десятую часть, Home и End ведут к краям. Меню следуют паттерну menu button. Пока фокус внутри плеера, k или Space воспроизводит и ставит на паузу, j и l перематывают на 10 секунд, m отключает звук, c переключает субтитры, а f переключает полный экран; в остальных местах страницы эти клавиши ничего не делают.

Субтитры это файлы WebVTT, отдаваемые тем же источником. Плеер рисует активную реплику простым текстом (разметка реплик отбрасывается), поверх картинки для видео и под заголовком для аудио, поэтому субтитры выглядят одинаково во всех браузерах и в полноэкранном режиме.

Продолжение воспроизведения

Плеер сохраняет место, где остановился пользователь: каждые 15 секунд во время воспроизведения, на паузе, когда страница скрывается или закрывается, и 0, когда медиа заканчивается. Воспроизведение продолжается, если сохранённая точка отстоит более чем на 5 секунд от любого из концов.

  • Когда источник реализует position и save_position, позиции хранятся на сервере: GET /_zeyn/media/position?source=&id= отвечает {"at": seconds, "kept": true}, а POST /_zeyn/media/position с {source, id, at} сохраняет (204). Оба проверяют идентификатор, право и guard-ы и требуют вошедшего пользователя; POST также требует заголовок CSRF и считается записью для лимитов запросов. Позиции больше MAX_POSITION_SECONDS или меньше нуля дают 422.
  • В остальных случаях (методы источника по умолчанию или анонимный пользователь) чтение отвечает {"at": null, "kept": false}, а плеер хранит позицию в локальном хранилище браузера под ключом zeyn.media.{source}/{id}. Ошибки хранилища (приватные окна, заблокированное хранилище) игнорируются.

Серверные позиции лучше для вошедших пользователей: они следуют за пользователем на каждое устройство, и приложение может читать их для строки «продолжить просмотр». Локальное хранилище не требует кода, но остаётся в одном браузере и общее для всех, кто им пользуется.

HLS и DASH

hls(id) и dash(id) добавляют адаптивный поток. Safari воспроизводит HLS нативно, поэтому использует плейлист. Другим браузерам нужна JavaScript-библиотека (например hls.js или dash.js), которую Zeyn не поставляет. Прежде чем перейти к прогрессивным файлам, плеер отправляет на своём корне отменяемое событие zeyn:media-adaptive:

document.addEventListener('zeyn:media-adaptive', (e) => {
  const { media, kind, url } = e.detail;
  if (kind !== 'hls' || !window.Hls?.isSupported()) return;
  e.preventDefault();
  const hls = new window.Hls();
  hls.loadSource(url);
  hls.attachMedia(media);
});

Загружайте этот скрипт из файла вашего приложения (inline-скрипты блокируются content security policy). Когда ни один слушатель не вызывает preventDefault(), записи file воспроизводятся по порядку. Файлы плейлистов и сегментов отдаются тем же источником, поэтому их относительные URL работают, пока их идентификаторы перечислены в нём.

Загруженные файлы

Файлы, загруженные через Form::file, отдаются через GET /_zeyn/files/{store}/{id} с теми же ответами, что и медиафайлы (диапазоны, entity tag, nosniff, inline только для растровых изображений, аудио и видео), после проверки права хранилища, guard-ов feature и арендатора (tenant). См. раздел о загрузке файлов в examples/docs/pages/forms.md.