Skip to content

omnibase/video

A video platform on omnibase: videos and channels, resumable uploads through tusd, HLS transcoding with ffmpeg, a player that keeps playing across pages, views, likes, timed comments, playlists, subscriptions, reports, a members' studio, local suggestions (Gorse optional) and a Typesense index.

Floor
Prepared structure · Omnibase
Version
main
Verified for real
Its README does not say
Licence
MIT
composer require omnibase/video:dev-main

Hosted video for an omnibase site: a platform where members publish (uploads on), or a broadcaster's site whose films come from the back office or an import (uploads off).

Page
Installation the bundle, the routes, the layout, the front-end packages, the containers
The pages every route: watching, channels, search, the library, the studio
Configuration every video.* option
Uploads tusd, the upload token, the two hooks
Transcoding the ladder, the files written, the worker
The player the dock, engines, media:play, beacons, remote HLS, outside sources
Views and suggestions the view rule, the flush, Related, Gorse
Search the Typesense mapping, the fallback
Moderation and back office comments, reports, take-downs, roles, screens
Testing the suite, what it checks

What is whose

Thing Where
Thread, likes, comments, their guard and form, reports, trash, embed_url() omnisense/omnibase
CRUD screens, dashboard tiles, settings sections, the reports' screen omnibase/admin
Video, Source, Channel, Subscription, VideoComment (the moment), WatchEvent, Playlist, Related this bundle
Searching and playing other platforms' films (YouTube, Vimeo...) not here: a Source keeps a reference (platform + id); the engines are another family's

Propose a change docs/index.md

Installation

composer require omnibase/video:dev-main
// config/bundles.php
Typesense\Bundle\TypesenseBundle::class => ['all' => true],
Base\Video\VideoBundle::class => ['all' => true],
# config/routes.yaml
video_controller:
    resource: "@VideoBundle/src/Controller/Client"
    type: attribute
    prefix: /
# config/packages/messenger.yaml - the bundle's messages go to the worker
framework:
    messenger:
        routing:
            Base\Video\Message\TranscodeVideo: async
            Base\Video\Message\FlushViews: async
            Base\Video\Message\ComputeRelated: async
            Base\Video\Message\SuggestFeedback: async

Then a migration (doctrine:migrations:diff, migrate): the tables video, video_source, video_channel, video_channel_owner, video_subscription, video_comment, video_watch, video_playlist, video_playlist_item, video_related.

The layout

The player lives outside what transparent.js swaps (#content):

{# templates/layout/layout1.html.twig #}
<link rel="stylesheet" href="{{ asset('bundles/video/css/video.css') }}">
...
<main id="content">{% block content %}{% endblock %}</main>
...
{% include '@Video/client/_dock.html.twig' %}

The bundle's pages extend layout1.html.twig and fill its content, title and description blocks. Colours and fonts are custom properties (--video-bg, --video-surface, --video-ink, --video-soft, --video-line, --video-accent, --video-on-accent, --video-radius, --video-font, --video-font-display...): set them on your :root, for each theme.

The player has shapes of its own, apart from the site's cards: --video-player-bg (behind the picture), --video-player-radius (its frame, .9rem), --video-player-control-radius (its buttons, round) and --video-player-thumb (the cursor of the bars, white); the bars and the focus ring take --video-accent-on-dark (then --video-accent). The controls carry their name for assistive technologies only: media-chrome's tooltips are off (notooltip), as placed above a button they would lay "Pause" over the progress bar.

The front end

The bundle's scripts import no package: the application does, and hands them over.

yarn add media-chrome hls-video-element @uppy/core @uppy/dashboard @uppy/tus
// assets/app-defer.js
import 'media-chrome';
import 'media-chrome/menu';
import 'media-chrome/lang/fr';
import 'hls-video-element';
import VideoPlayer from '../vendor/omnibase/video/assets/player.js';

import Uppy from '@uppy/core';
import Dashboard from '@uppy/dashboard';
import Tus from '@uppy/tus';
import '@uppy/core/dist/style.min.css';
import '@uppy/dashboard/dist/style.min.css';
import Studio from '../vendor/omnibase/video/assets/studio.js';

Base.boot({ exceptions: ['/files/*', '/media/*', '/v/*/master.m3u8'] });
VideoPlayer.start();
Studio.start({ Uppy, Dashboard, Tus });   // only where members upload

A content security policy needs blob: in media-src (hls.js plays from MediaSource).

The containers

  • web / worker: ffmpeg and ffprobe in the image; the worker consumes async.
  • upload (tusd), search (Typesense): see Uploads and Search.
  • The web server serves the storage:
location ^~ /media/videos/ {
    alias /srv/app/var/storage/videos/;
    types { application/vnd.apple.mpegurl m3u8; video/mp4 mp4 m4s; text/vtt vtt; image/jpeg jpg; }
    add_header Access-Control-Allow-Origin * always;
}
  • cron (no symfony/scheduler: cron dispatches, Messenger works):
*/5 * * * *  php bin/console video:views:flush --async
30  3 * * *  php bin/console video:related --async
0   4 * * *  php bin/console video:uploads:clean

web, worker and tusd must see the same var/storage (uploads and videos).

Propose a change docs/installation.md

The pages

Every route of the bundle's client controllers (src/Controller/Client). The paths are French, as the platform's; the names are what a template's path() uses.

Watching

Name Path Methods
video_watch /v/{slug} GET the watch page: the player, the film's text, the comments, "à suivre" (The player)
video_manifest /v/{slug}/master.m3u8 GET the HLS master of a film played from another host, read and kept (The player)
video_beacon /v/{slug}/watch POST the player's beacons: what was watched (Views and suggestions)
video_rate /v/{slug}/rate POST like or dislike, rate limited
video_comment /v/{slug}/comment POST a comment, through omnisense/omnibase's guard (Moderation and back office)
video_report /v/{slug}/report POST a report, rate limited

The slug is [A-Za-z0-9_-]+.

Name Path Methods
video_channel /chaine/{slug} GET a channel: its films, its lists
video_subscribe /chaine/{slug}/abonnement POST subscribe or unsubscribe (a toggle); a visitor is sent to sign in (401 in JSON), a bad CSRF token refused (403 in JSON)
video_search /recherche GET q (120 characters at most), categorie (one of video.categories), chaine (a channel's slug), duree (short, medium, long), date (day, week, month, year) (Search)

A member's library

The member's pages answer a visitor with a 403; a list's own page is shown to whoever the list is shown to.

Name Path Methods
video_history /historique GET what the member watched
video_history_clear /historique/effacer POST forget it
video_later /plus-tard GET "plus tard": private, newest first
video_subscriptions /abonnements GET the subscriptions' feed
video_playlists /listes GET, POST the member's lists; POST makes one
video_playlist /liste/{slug} GET, POST a list
video_playlist_toggle /listes/video POST a film in or out of a list
video_playlist_delete /liste/{slug}/supprimer POST delete a list
video_playlist_order /liste/{slug}/ordre POST the list's new order

The studio

Under /studio, for a signed-in member (ROLE_USER); uploading needs video.upload.enabled (Uploads).

Name Path Methods
video_studio /studio GET the member's films
video_studio_upload /studio/envoyer GET the upload page
video_studio_uploaded /studio/envoi/{uploadId} GET an upload finished: the film being made
video_studio_link /studio/lien GET, POST a film from an address instead of a file (LinkDetailsType: the address, a name)
video_studio_edit /studio/video/{id} GET, POST a film's details (VideoDetailsType): title, description, visibility, publication date, category, tags, language, licence, comments on or off, poster
video_studio_status /studio/video/{id}/etat GET where its transcoding stands
video_studio_stats /studio/video/{id}/statistiques GET its figures
video_studio_comments /studio/video/{id}/commentaires GET, POST its comments, moderated by its owner
video_studio_replace /studio/video/{id}/remplacer GET upload another file for it (it keeps playing the old one meanwhile)
video_studio_delete /studio/video/{id}/supprimer POST to the trash
video_studio_trash /studio/corbeille GET, POST the member's trash
video_studio_channel /studio/chaine GET, POST the member's channel (ChannelDetailsType: name, slug, description, shelves, avatar, banner)
video_studio_subscribers /studio/abonnes GET who subscribed

The upload hook

video_upload_hook (POST /video/upload/hook) is tusd's, not a page (Uploads).

Propose a change docs/pages.md

Uploads

Films never go through PHP. The studio's page mounts Uppy with its tus plugin; the browser talks to tusd through the proxy at /files/; tusd writes into video.upload.directory and asks the site twice.

# docker-compose.yml
upload:
    image: tusproject/tusd:v2.8.0
    command:
      - -upload-dir=/srv/app/var/storage/uploads
      - -base-path=/files/
      - -behind-proxy
      - -hooks-http=http://web/video/upload/hook
      - -hooks-enabled-events=pre-create,post-finish
      - -hooks-http-forward-headers=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-Port,X-Forwarded-For
    volumes:
      - storage:/srv/app/var/storage:rw     # the same volume as web and worker
# the proxy
location ^~ /files/ {
    client_max_body_size 0;
    proxy_request_buffering off;
    proxy_pass http://upload:8080;
}
location ^~ /video/upload/hook { return 404; }   # tusd reaches it from inside only

omnibase answers only the hosts it knows: the internal name tusd calls (web) must be among them (HTTP_DOMAIN).

The token

Uploads::token($user, ?$replace) (Twig: video_upload_token()): who, until when, and - to replace a film's file - which video; signed with the kernel secret. Uppy sends it in the upload's metadata. Whoever calls the hook, only a valid token makes it act.

The hooks (POST /video/upload/hook)

tusd asks the site answers
pre-create {} or {RejectUpload: true, HTTPResponse: {StatusCode: 403, Body: "upload.refused.<why>"}}: token, account (banned, locked), verified, size, quota, limit (the video_upload limiter), replace (not their film); 404 disabled when uploads are off
post-finish the Video is made - a private draft titled after the file, on the member's channel (created on their first upload) - or the replaced one found; TranscodeVideo is dispatched. Only a file inside the uploads directory is taken. A repeated call makes nothing twice.

Resuming

A cut connection retries by itself (tus HEAD gives the offset, PATCH continues). A closed tab resumes when the same file is dropped again from the same browser (Uppy keeps the upload's address). video:uploads:clean removes what was abandoned (--days=3).

Propose a change docs/upload.md

Transcoding

TranscodeVideoHandler -> Service\Processing::transcode() -> Service\Transcoder (ffmpeg through symfony/process; the *Command() methods only assemble arguments and are tested as they are).

A video's directory, <video.storage>/<random key>/:

File
master.m3u8 the HLS master, one variant a rung
<height>/index.m3u8, init_N.mp4, seg_000.m4s... each rung, fMP4, a key frame on every segment's start
poster.jpg the still shown before it plays
frame-1.jpg... stills the author picks the poster from
storyboard.jpg, storyboard.vtt the time bar's previews: a sprite and its #xywh boxes
  • The ladder is video.transcode.renditions not above the source: a 720p film gets 720, 480, 360; a film smaller than every rung keeps its own height; a portrait film is laddered on its short side.
  • One ffmpeg pass for the whole ladder (split, one scale a rung, -var_stream_map, -master_pl_name).
  • A replacement is built beside the old ladder and swapped in when whole: the film keeps playing meanwhile, its address, views and comments stay.
  • States (Video::getProcessing()): uploaded -> running -> ready, or failed with getFailure(); none for a remote or embedded film. Its authors are told in omnibase's notification centre.
  • The original is removed once the ladder exists (keep_originals: 0).
bin/console video:transcode <id|slug> /path/to/film.mov   # here and now: a repair, an import

Propose a change docs/transcoding.md

The player

{# a film's page #}
{% include '@Video/client/_player.html.twig' with {video: video, resume: seconds, source: 'home', autoplay: false} %}
{# the layout, outside #content #}
{% include '@Video/client/_dock.html.twig' %}

The first is a stage ([data-video-player], its data-* say what to play); the second the dock: one <media-controller> for the whole visit. On each page (load, transparent:load) assets/player.js looks for a stage:

  • the film it already plays: it lays itself over the stage again;
  • another film: it loads it there;
  • no stage and a film playing: it shrinks into a corner (the mini-player: title, play/pause, close);
  • no stage, nothing playing: it hides.

It resumes (?t= first, then the member's place on the server, then this browser's), seeks on a[data-seek] (the comments' and descriptions' moments: the |timecodes(video) filter), copies the link at the current second ([data-video-share]), has a cinema mode ([data-video-cinema], html.video-cinema), speed and quality menus, a storyboard on the time bar, the keyboard of media-chrome.

A film's page (@Video/client/watch.html.twig) leaves two blocks to a site: video_channel (the channel and its subscribe button - emptied on a single-show site) and video_extra (under the description).

Engines

The player's commands never touch a <video>: they talk to an engine.

VideoPlayer.registerEngine('youtube', function (host, data) {
    return {
        load(data) {},              // src, platform, externalId, poster, start...
        play() {}, pause() {}, seek(seconds) {},
        time() {}, duration() {}, paused() {}, rate() {},
        on(event, fn) {},           // 'ready' | 'play' | 'pause' | 'time' | 'seeking' | 'end'
        destroy() {},
    };
});

The native engine (hls, mp4) is the bundle's. A stage whose data-type names a registered engine gets it. The beacons, the resume, the mini-player and the timecodes work with any engine that gives the time.

One thing at a time: media:play

The player takes part in omnisense/omnibase's media:play contract through its module (window.MediaPlay, bundles/base/js/media.js - the dock links it; a site may also bundle it, import '../vendor/omnisense/omnibase/assets/media/media.js'):

  • it joins (MediaPlay.join(VideoPlayer, pause)): when anything else on the page starts sounding - omnibase/music's bar, another player, a plain <audio> or <video> - it pauses;
  • it announces itself when it starts (MediaPlay.announce(VideoPlayer, {kind: 'video', id, source: 'video', element})), which dispatches on document:
new CustomEvent('media:play', { detail: { player: VideoPlayer, kind: 'video', id, source: 'video', element } })

The dock carries data-media-play="off": its <video> is the player's to announce, not a plain element of the page. Another script takes part the same way (omnibase's docs/40-commons/front-end.md); the player has no pause logic of its own any more.

Remote HLS, without a copy

A Source of kind remote-hls holds a master playlist's address on another origin. GET /v/{slug}/master.m3u8 (video_manifest) reads it on the server - only from video.remote.hosts, https - keeps it video.remote.ttl seconds and serves it with its URIs made absolute: the browser fetches the variant playlists and the segments from the origin itself. Nothing else of the film passes through the site. RemoteManifest::duration() sums a variant's #EXTINF.

Outside sources

A Source of kind embed keeps the film's page address and its reference

  • getPlatform(), getExternalId() - and no logic. Until an engine is registered for its platform it plays in the platform's own frame (omnibase's embed_url()), loaded only once the visitor accepted the EMBEDS feature of omnibase/consent. Members add one by its link in the studio (/studio/lien).

Beacons

POST /v/{slug}/watch with {token, event: start|progress|end, position, watched, source, referrer}: on the first play, every ten seconds, on pause, at the end, and when the page goes. watched is seconds really played (a seek is not watching).

Propose a change docs/player.md

Views and suggestions

Views

A beacon lands on a WatchEvent (one a sitting). It becomes a view once min(video.views.min_seconds, video.views.ratio x duration) seconds were really played, and only once per visitor and film every video.views.dedupe seconds (the pool video.cache). The visitor is the account, or a keyed hash of the address and the browser - never stored readable. The server does not trust watched: it cannot grow faster than the clock.

video:views:flush (cron, every five minutes, --async) adds the counted views to Video::$views in one statement a film. Video::$legacyViews keeps the views a film had where it was imported from; getTotalViews() is what a card shows.

The same rows are a member's history (/historique, erasable) and the studio's figures (WatchEventRepository::stats()): views, average watched, completion, the retention curve, thirty days of views, where they came from (home, search, channel, playlist, suggest, subscriptions, history, external, direct).

Suggestions

interface SuggestInterface
{
    public function feedback(string $type, User $user, Video $video): void;  // read | watch | like
    public function recommend(User $user, int $limit = 24): array;           // a member's feed
    public function session(array $videoIds, int $limit = 24): array;        // a visitor's, from what they watched
    public function similar(Video $video, int $limit = 12): array;           // "à suivre"
}

Inject SuggestInterface: it is Suggestions, which asks Gorse when provider: gorse and it answers, and the local ones otherwise.

Local (always there)

video:related (cron, at night) writes Related: for two listed films,

3   · same channel, or an affinity's 3.0 group (same artist)
+ 2 · same public list, or an affinity's 2.0 group (same episode)
+ 1 · each keyword in common (three at most)
+ 2.5 · co-watching (cosine of their audiences, ninety days)
+ 0.5 · freshness (e^(-age / 30 days))
+ 0.5 · popularity (log of the views against the most seen)

twenty-four neighbours a film, per_channel of one channel at most. A feed is the neighbours of what was watched last, the seen ones left out, one in five from the trends.

Another bundle adds what makes films close with an AffinityInterface service (autoconfigured):

public function groups(array $videos): array   // [videoId => ['artist:12' => 3.0, 'episode:5' => 2.0]]

Gorse (optional)

gorse-in-one over its REST API (symfony/http-client): POST /api/feedback (through Messenger: SuggestFeedback), GET /api/recommend/{user}, POST /api/session/recommend, GET /api/item/{id}/neighbors. In gorse-in-one the API is on the master's HTTP port (8088). The key is video.suggest.gorse_key, or api.gorse.key typed in the back office. Any failure, any short answer: the local suggestions fill in.

Propose a change docs/views-and-suggestions.md

Search\VideoSearch::search($term, $filters, $page) asks the typesense.finder.video finder of glitchr/typesense-bundle - typo tolerance, prefixes, the listed films only - and the database (VideoRepository::searchListed()) when there is no such mapping or the server does not answer. Filters: category, channel (slug), duration (short < 4 min, medium, long > 20 min), date (day, week, month, year), sort (date, views).

# config/packages/typesense.yaml
typesense:
    mappings:
        video:
            connection: default
            class: 'Base\Video\Entity\Video'
            default_sorting_field: published
            fields:
                title:       { property: searchTitle, type: string, infix: true }
                text:        { property: searchText, type: string }
                channel:     { property: searchChannel, type: string }
                channelSlug: { property: searchChannelSlug, type: string, facet: true }
                tags:        { property: searchTags, type: 'string[]', facet: true }
                category:    { property: searchCategory, type: string, facet: true }
                duration:    { property: searchDuration, type: int32, facet: true }
                views:       { property: searchViews, type: int32 }
                published:   { property: searchPublished, type: int64 }
                listed:      { property: searchListed, type: bool, facet: true }
bin/console typesense:create          # the collection
bin/console typesense:action upsert   # every film into it

The index follows Doctrine afterwards. Search\SafeIndexer decorates the bundle's listener: a search server that is down never fails a save, and no visitor is told about it; typesense:action upsert catches up.

Propose a change docs/search.md

Moderation and back office

Comments

A VideoComment is omnibase's Comment (states, one level of replies, Akismet, CommentType, CommentGuard) with moment - where the player stood - and pinned. 1:23 typed in a text seeks the player. A film's author pins one, hides some, closes them (studio); the moderators approve, mark as spam, trash (back office).

Reports

"Signaler" under a film (and per comment) makes an omnibase Complaint (source: video, location: video:<id> / comment:<id>, the reason in its context): handled on omnibase/admin's screen for them.

Take-downs

Moderation::takeDown($video, $reason) (the back office's "Retirer", a reason is required): the film leaves every page, its authors are told why (the DSA's statement of reasons) and still see it; restore() puts it back, private.

Roles and screens

VIDEO_VIEW a film reachable, or its owners', or the moderators'
VIDEO_EDIT its owners (and its channel's), or ROLE_ADMIN
VIDEO_MODERATE ROLE_EDITOR and up
// the dashboard
yield MenuItem::block('video_overview', 'La plateforme', 'fa-solid fa-tv')->setSize(4);
MenuItem::linkToCrud(\Base\Video\Entity\Video::class, 'Vidéos', 'fa-solid fa-film');
MenuItem::linkToCrud(\Base\Video\Entity\Channel::class, 'Chaînes', 'fa-solid fa-tv');
MenuItem::linkToCrud(\Base\Video\Entity\VideoComment::class, 'Commentaires', 'fa-solid fa-comments');
MenuItem::linkToCrud(\Base\Entity\User\Complaint::class, 'Signalements', 'fa-solid fa-hand');

The films and channels are written by ROLE_ADMIN; taking down, restoring and the comments by ROLE_EDITOR. The tile: comments waiting, reports open, conversions running or failed, views of thirty days.

Demonstration accounts

In omnisense/omnibase's demo environment (its docs/20-architecture/demo.md) the sign-in page offers one button for each of a platform's people. Base\Video\Demo\VideoDemoAccounts declares them:

Identifier Role
createur none (ROLE_USER) a channel and its films: the studio, uploading, the statistics, the comments received
membre none (ROLE_USER) watches, likes, comments at a film's moment, subscribes, keeps lists
moderation ROLE_EDITOR the comments to approve, the reports, taking a film down
admin ROLE_ADMIN every film and channel in the back office

The password is the identifier. A creator and a member hold the same role - every member has the studio: the fixtures tell them apart, by taking the accounts from omnibase's factory and giving the first a channel and films, the second subscriptions and a list:

public function __construct(private readonly \Base\Demo\DemoAccountFactory $accounts) {}

$createur = $this->accounts->account('createur', $manager);   // created from its declaration, or the database's
$channel = new Channel('Atelier Mandelbrot', $createur);

Where ROLE_EDITOR stands above the super-administrator (omnibase's usual hierarchy; here the moderators are below the administrators: ROLE_EDITOR: [ROLE_STAFF], ROLE_ADMIN: [ROLE_EDITOR]), moderation is not declared: a super-administrator is never a demonstration account. A platform without one of the accounts leaves it out (base.demo.exclude). The labels are demo.<identifier>.label and .description in the video domain (fr, en).

Propose a change docs/moderation.md

Configuration

Every leaf is also a parameter of the same name (%video.upload.enabled%).

# config/packages/video.yaml
video:
    storage: '%kernel.project_dir%/var/storage/videos'   # one directory a video, named by a random key
    public_path: /media/videos                           # where the web server serves it

    upload:
        enabled: true          # false: a broadcaster's site - no upload page, tusd's hooks refuse
        auto_publish: false    # true: a converted upload goes public at once
        quota: 21474836480     # bytes of originals a member may have sent; 0: none
        max_size: 8589934592   # bytes a file
        verified_only: true    # only a member whose e-mail is verified
        directory: '%kernel.project_dir%/var/storage/uploads'   # tusd's -upload-dir, as the site sees it
        endpoint: /files/
        token_ttl: 86400       # seconds an upload token lives
        keep_originals: 0      # 0: the original is removed once converted

    transcode:
        ffmpeg: ffmpeg
        ffprobe: ffprobe
        renditions: [1080, 720, 480, 360]
        segment: 6             # seconds
        preset: veryfast
        timeout: 14400
        thumbnails: 100        # pictures of the storyboard, at most
        frames: 4              # stills offered as a poster

    views:
        min_seconds: 30        # a view after min(min_seconds, ratio x duration) really played
        ratio: 0.5
        dedupe: 21600          # once a visitor and film every six hours

    comments:
        auto_approve: true     # a comment Akismet finds clean goes online at once
        guests: false          # visitors without an account may comment

    search:
        external: false        # reserved (searching other platforms is not this bundle's)

    suggest:
        provider: local        # local | gorse
        gorse_url: ''          # http://suggest:8088 ; empty: Gorse is off whatever the provider
        gorse_key: ''
        per_channel: 2         # 0: no cap (a single-channel site)

    remote:
        hosts: []              # hosts a "remote-hls" master may be read from (https only)
        ttl: 3600

    categories: [music, film, animation, documentary, education, science, sport, games, travel, cooking, news, comedy, diy, other]
    licenses: [standard, cc-by, cc-by-sa, cc-by-nc, cc0]

The bundle also declares (override them under the same names):

  • the cache pool video.cache (on cache.app; point it at Redis: framework.cache.pools.video.cache: {adapter: cache.adapter.redis, provider: ...});
  • four limiters: video_comment (10 / 10 min), video_rate (60 / min), video_upload (20 / day), video_report (10 / hour).

Translations: domain video (fr, en). A category added to categories needs its category.<key>.

Propose a change docs/configuration.md

Testing

Running the suite

The suite is units: no kernel, no database, no ffmpeg, no network.

vendor/bin/phpunit

Inside a host application:

php vendor/bin/phpunit -c vendor/omnibase/video/phpunit.xml.dist

omnisense/omnibase-harness does not install omnibase/video, its glitchr/typesense-bundle nor symfony/rate-limiter. Run there with the checkout and glitchr/typesense-bundle on the autoloader, the suite gives:

Tests: 60, Assertions: 253, Errors: 4, Skipped: 1.

The four errors are UploadsTest's, for want of symfony/rate-limiter (a requirement of the bundle, so present in a host); the skipped test is PrefetchTest's read itself.

What the tests check

Test Checks
Controller\CrudRequiredFieldsTest omnibase/admin's CrudFieldsTestCase on the screens
Css\WatchLayoutTest the stages row as tall as the player; a phone has one column and its own rows
Demo\VideoDemoAccountsTest one account for each of the platform's people; nobody reaches the super-admin; where the editors are above it, the moderator is left out; each account's label and sentence in the catalogues
Entity\PlaylistTest a list keeps its order and plays on; a film no longer reachable leaves it; "plus tard" is private and newest first; a sitting's seconds never run ahead of the clock
Entity\VideoTest the visibility is the thread's state; a film taken down is out of sight with its reason; the player starts from the master; an outside source keeps its reference only; times and views; the moments typed in a comment
Player\MediaPlayTest, Player\PrefetchTest the player takes part through media:play; the dock; what was read ahead is read first; its words in each language
Repository\FiguresTest the studio's figures from raw sittings; no sitting, no division; the search filters
Service\RemoteManifestTest the URIs made absolute against the origin; what is not a playlist refused; the length summed; only the allowed hosts read; the master read once, then kept; a failing origin is a remote exception
Service\TranscoderTest the ladder never above the source; a portrait film laddered on its short side; one HLS variant a rung in fMP4; a silent film maps no sound; the storyboard and its VTT; ffprobe's JSON; frame times and bitrates
Service\UploadsTest a token says who, until when and which film; pre-create refuses without a valid token, checks the member, the size, the quota and the limiter; post-finish takes only a file of the uploads directory; an address part is lowercase hex
Suggest\SuggestTest a pair's score; only pairs that share something; the cap per channel; Gorse read and its failures fall back; a view needs thirty seconds or half the film
Twig\VideoExtensionTest timecodes become links and the text is escaped; compact counts; an excerpt cut at a word

Propose a change docs/testing.md