Aller au contenu

omnibase/social

Social publishing for omnibase: a reel rendered from the site’s own template (ffmpeg), published on each network through omnisense/omnipost, and the account’s feed cached for a wall on the site.

Étage
Structure préparée · Omnibase
Version
main
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnibase/social:dev-main

Social publishing for the sites built on omnisense/omnibase: a reel made from the site's own template - its logo, a band with a name on it, its colours, its font, a clip before and after - sent to each network with what it says there, and the account's feed kept for a wall on the site. It is a Symfony bundle (Base\Social\SocialBundle) on the core 3 or 4; the networks are omnisense/omnipost's providers (instagram, youtube...). This bundle knows the site - its templates, its posts, its settings -, omnipost knows the networks.

A violinist's site posts a concert's excerpt to Instagram and YouTube from its back office; a shop shows its last posts under its products.

The pipeline, carried by three Messenger messages:

  1. Render - each network's film made by ffmpeg with its template (the network's own target's, else the post's, else the network's, else the site's), under <social.storage>/<post id>/<platform>.mp4, its cover beside it.
  2. Publish - the post as that network receives it, checked by omnipost's Validator against the network's capabilities and the provider's own rules, then handed over; at the post's hour when it has one to come.
  3. Check - the network's status read again until it says the post is online (its permalink kept) or why it is not.

How this manual is organised

Proposer une modification docs/index.md

Installation

Requirements

What composer.json requires: PHP 8.2 or later, doctrine/orm 2.15 or 3, omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/admin, omnisense/omnipost 1.x, and symfony/config, dependency-injection, form, http-kernel, security-bundle, process and messenger 6, 7 or 8. Install the omnipost providers the site publishes to (omnipost/instagram, omnipost/youtube...).

ffmpeg and ffprobe belong in the image of whatever renders - the Messenger worker, and the web image for the preview's checks of a post not yet rendered - with a font for the band:

RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
# alpine: apk add --no-cache ffmpeg font-dejavu

Without them a rendering fails with a RenderingException saying so, shown on the target.

The bundles

composer require omnibase/social:dev-main omnipost/instagram:1.x-dev
// config/bundles.php
return [
    // ...
    Base\BaseBundle::class => ['all' => true],
    Base\Admin\AdminBundle::class => ['all' => true],
    Omnipost\Bridge\Symfony\OmnipostBundle::class => ['all' => true],
    Base\Social\SocialBundle::class => ['all' => true],
];

SocialBundle aliases Base\Social\Entity\* to App\Entity\Social\* and the repositories to App\Repository\Social\* (the core's convention: an application declares its own class there to take one over); its templates/ is @Social, its public/ bundles/social/.

omnipost's configuration holds the apps' ids and secrets; the tokens are the back office's:

# config/packages/omnipost.yaml
omnipost:
    providers:
        instagram: { factory: instagram, options: { app_id: '%env(default::INSTAGRAM_APP_ID)%', app_secret: '%env(default::INSTAGRAM_APP_SECRET)%' } }
        youtube:   { factory: youtube, options: { client_id: '%env(default::YOUTUBE_CLIENT_ID)%', client_secret: '%env(default::YOUTUBE_CLIENT_SECRET)%', channel_id: '%env(default::YOUTUBE_CHANNEL_ID)%' } }
# config/packages/social.yaml
social:
    providers: [instagram, youtube]

The routes

# config/routes.yaml
social_controller:
    resource: "@SocialBundle/src/Controller/Client"
    type: attribute
    prefix: /
social_admin:
    resource: "@SocialBundle/src/Controller/Admin/AccountsController.php"
    type: attribute
    prefix: /

The first imports social_media (the films' public address); the second the accounts page and the OAuth callback /connect/social/{provider} (social_oauth_callback) - declare that address in each network's app.

Messenger

# config/packages/messenger.yaml
framework:
    messenger:
        routing:
            Base\Social\Message\RenderPostMessage: async
            Base\Social\Message\PublishTargetMessage: async
            Base\Social\Message\CheckTargetMessage: async

A rendering takes a while and a network processes a reel for a minute or two. The async transport must take delays (Doctrine, Redis and AMQP do; sync:// runs the checks at once and gives up early): scheduled posts and status checks are DelayStamps.

The site's public address

A worker has no request to take the host from: set framework.router.default_uri to the site's public address, and keep /social/media/ reachable from the internet (no access restriction, maintenance or launch page on it) - Instagram comes for the film there.

The schema and the public files

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
bin/console assets:install

Tables social_post, social_post_target, social_template, social_media; the stylesheet bundles/social/css/social.css.

The crons

0 * * * *  bin/console social:sync            # the walls' posts, hourly
30 4 * * * bin/console social:refresh-tokens  # tokens that die within ten days

Proposer une modification docs/installation.md

Posts, targets and templates

SocialPost

What goes out (social_post):

Property Column Meaning
video upload, video, 1 GB the film as it was shot
image upload, image, 8 MB or a picture
kind string(16), reel an omnipost PostKind value: reel or image in the back office
caption text
title string(255) for the networks that want one (YouTube)
tags json hashtags, kept without #, blanks or doubles (SocialPost::cleanTags())
template many-to-one Template, SET NULL
useTemplate boolean, true off: the film goes out as it is, not encoded again
coverSecond float, 0 the frame the cover is taken at
scheduledAt datetime, nullable the hour it leaves; the site holds the message back until then
link string(255) a URL
state PostState read from its targets
targets one-to-many SocialPostTarget, cascade, orphan removal one per network

refreshState(): no enabled target DRAFT; one sent or processing PUBLISHING; all published DONE; one failed (and none on its way) FAILED; some published PUBLISHING; all rendered READY; else DRAFT. PostState also has RENDERING, set while it renders.

toPost(?callable $urls = null): Omnipost\Model\Post gives omnipost's canonical post (the first network's rendering as its media) and, per enabled network, a Variant with the target's own caption, title, hashtags, rendering, cover and options. $urls gives each target's public address (fn (SocialPostTarget $target, bool $cover): string); without it the local path stands in - enough to validate, not to publish. The hour is not passed on to the networks.

SocialPostTarget

A post on one network (social_post_target, unique on post and platform):

Property Meaning
platform an omnipost Platform value - and the name of the provider that serves it
enabled whether it goes there
caption, title, tags, template what it says there when not what the post says; empty: the post's
options the network's extras: share_to_feed (Instagram), privacy (public, unlisted, private: YouTube)
renderedPath, coverPath, duration, width, height, size what the renderer made
publicationId, permalink, publishedAt what the network said
state TargetState
error why it failed
attempts looks at the network's status so far
updatedAt when the state last moved

TargetState: PENDING, RENDERED, SENT, PROCESSING, PUBLISHED, FAILED; isFinal() (published or failed), isInFlight() (sent or processing). rendered(Rendering) records a rendering and clears what a previous publication left; fail(string); isMediaOpen(int $ttl = 86400) - the public address answers while the publication is on its way and $ttl seconds after it ended; toMedia() - the rendering as an omnipost Media.

Template

The site's identity on a reel (social_template):

Property Default Meaning
name not blank
default (is_default) false the site's own; saving one as default takes the flag off the others of the same platform
platform null a template for that network alone
logo a picture (8 MB)
logoPosition TOP_RIGHT TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, CENTER
logoScale 0.2 the logo's width as a part of the frame's, 0.1 to 0.5
band false a band across the lower third
bandText its text (the artist's name), 120 at most
bandColor, textColor #000000, #ffffff #rrggbb or #rrggbbaa
font a TTF or OTF (8 MB); none: social.ffmpeg.font
background #000000 behind a film shown whole
intro, outro clips before and after (200 MB)
fit COVER COVER: cropped to fill 9:16; CONTAIN: whole, letterboxed
fadeSeconds 0.5 fades in and out, 0 to 5

TemplateRepository::resolve(SocialPostTarget) - the template a target is rendered with: none when the post says "as it is", else the target's, the post's, the network's (findForPlatform()), the site's (findDefault(): default with no platform, else any default).

SocialMedia

One of the account's own posts as the last sync read it - what the wall shows (social_media, unique on provider and remote id): provider, remoteId, kind, permalink, caption, publishedAt, thumbnail (a local copy, 8 MB: the networks' own addresses expire), mediaUrl, videoUrl (the network's, they may expire), likes, comments, hidden (kept off the wall), fetchedAt.

SocialMediaRepository: findWall(int $limit = 12, ?string $provider = null) (not hidden, newest first), findOneRemote(), lastSync(string $provider), countFor(). SocialPostTargetRepository::findLastFor(string $platform) - the last target that moved.

Proposer une modification docs/concepts.md

The pipeline

Base\Social\Service\Publisher holds the logic; the three handlers only call it.

Rendering

RenderPostMessage($postId, $publish = false) → Publisher::render(): the post RENDERING, then each enabled target not yet published rendered by Base\Social\Service\Renderer::render() with TemplateRepository::resolve($target). A rendering that fails (RenderingException) fails its network alone, the message kept as its error. The post's state is then read from its targets; with $publish, the rendered targets are handed over.

Renderer::render(SocialPost $post, ?Template $template = null, ?Platform $platform = null): Rendering:

  • a picture is measured (ffprobe) and given back as it is;
  • without a template (the post says "as it is", or the site has none) the film is measured and given back - no second encoding -, its cover taken;
  • otherwise ffmpeg makes <storage>/<post id>/<platform>.mp4 and its cover <platform>.jpg (the frame at coverSecond, at most 0.1 s before the end). A cover that fails does not stop the film (the network takes a frame itself) unless ffmpeg is missing.

probe() reads ffprobe's JSON (Renderer::measures()): the first video stream's size - turned for a phone's portrait film stored lying down (rotation 90 or 270) -, the length, the weight, whether there is sound, the MIME type.

The ffmpeg command

Renderer::command(array $input): list<string> only assembles the arguments - no file is touched - so the filter graph is read and tested as it is. One -filter_complex:

  • the source to 1080 x 1920: COVER scales up and crops, CONTAIN scales down and pads on the background; setsar=1, fps=<social.ffmpeg.fps>;
  • the logo scaled to its part of the width (even), laid with overlay 64 px from the edges;
  • the band: drawbox at y 1380, 140 px high, then drawtext centred, expansion=none, the template's font or font=Sans, size 56 shrinking for a long text (28 at least, Renderer::fontSize()); texts and paths escaped for the option and the graph (Renderer::escape());
  • fade and afade in and out (none when longer than half the film); a film without sound gets a silence as long as it;
  • the intro and outro brought to the same frame, rate and sound, joined with concat;
  • H.264 High 4.1, yuv420p, -r <fps>, -crf <social.ffmpeg.crf>, AAC 160k at 48 kHz, +faststart - what Instagram, YouTube, Facebook and TikTok all take.

The filter graph of a cropped film with a logo, a band and fades, run in omnisense/omnibase's harness image (new Renderer(), its defaults):

use Base\Social\Enum\Fit;
use Base\Social\Enum\LogoPosition;
use Base\Social\Service\Renderer;

$command = (new Renderer())->command([
    'source' => '/in.mov', 'output' => '/out.mp4', 'duration' => 12.0,
    'fit' => Fit::COVER, 'logo' => '/logo.png', 'logoPosition' => LogoPosition::TOP_RIGHT, 'logoScale' => 0.2,
    'band' => true, 'bandText' => 'Clara Weiss: harp', 'fade' => 0.5,
]);
$i = array_search('-filter_complex', $command, true);
echo str_replace(';', ";\n", $command[$i + 1]), "\n";
echo implode(' ', array_slice($command, $i + 2)), "\n";
[0:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,fps=30[base];
[1:v]scale=216:-1[logo];
[base][logo]overlay=W-w-64:64[logod];
[logod]drawbox=x=0:y=1380:w=1080:h=140:color=0x000000:t=fill,drawtext=font=Sans:text=Clara Weiss\\: harp:expansion=none:fontcolor=0xffffff:fontsize=56:x=(w-text_w)/2:y=1380+(140-text_h)/2,fade=t=in:st=0:d=0.5,fade=t=out:st=11.5:d=0.5,format=yuv420p[v];
[0:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,afade=t=in:st=0:d=0.5,afade=t=out:st=11.5:d=0.5[a]
-map [v] -map [a] -c:v libx264 -profile:v high -level 4.1 -pix_fmt yuv420p -r 30 -crf 20 -c:a aac -b:a 160k -ar 48000 -movflags +faststart /out.mp4

Checking

Publisher::validate(SocialPostTarget $target): list<Omnipost\Model\Violation> - what the network would refuse, before anything is sent: the post as that network receives it (SocialPost::toPost()->for($platform)) against the provider's capabilities() (omnipost's Validator), and against the provider's own validate() when it has one (a reel is 9:16 on Instagram). A target not rendered yet is checked as it will be: the source's measures and length, the template's 1080 x 1920 frame. A provider that cannot be built, or does not publish, is a violation. The same complaint is given once.

Publishing

Publisher::dispatchPublish(SocialPost $post) sends a PublishTargetMessage per rendered target, with a DelayStamp up to scheduledAt when it is to come (the networks' own scheduling is not used). Publisher::publish(SocialPostTarget $target):

  • a disabled target, one on its way or published is left;
  • no rendering: FAILED, "render the post first";
  • violations: FAILED with the reasons, nothing sent;
  • otherwise the provider's publish() with the public addresses; the network's id kept and its answer applied - PUBLISHED (the permalink and date), PROCESSING, SENT (pending) or FAILED (its reason). An omnipost exception fails the target; an UnauthorizedException adds "connect the account again (Social › Accounts)".
  • On its way, a CheckTargetMessage follows after social.check.delay seconds.

Status checks

Publisher::check(SocialPostTarget $target): bool reads the status again for a target on its way: applied as above; a network that does not answer once is asked again (logged); an unauthorised answer fails it. After social.check.attempts looks still processing, it fails ("Still processing on Instagram after 20 looks: see the account itself"). Again on its way: another CheckTargetMessage after the delay.

Retrying

Publisher::retry(SocialPostTarget $target) - its attempts and error cleared, rendered again when its film is gone (else RENDERED), and a PublishTargetMessage sent.

The public address of a film

Instagram takes no upload: it is given an address and comes for the film. Base\Social\Service\MediaUrls::for(SocialPostTarget $target, bool $cover = false) gives /social/media/{target}/{token}.{ext} (social_media), absolute, signed by Base\Social\Service\MediaToken: the first 32 hex characters of an HMAC-SHA256 of the target's id and the file's path with the kernel secret - it names that one file, and a file rendered again gets another.

MediaController answers it (GET, HEAD, ranges, Cache-Control: private, max-age=3600, X-Robots-Tag: noindex, nofollow) only when the token names the target's rendering or cover, the extension is the file's, the file exists, and the publication is on its way or ended less than social.media_url_ttl seconds ago; anything else is the same 404.

Proposer une modification docs/usage-publishing.md

Accounts and the wall

The accounts

Base\Social\Service\Accounts keeps the site's accounts in the core's settings, in the vault when the setting bag can (SettingBag::secure()), as the back office's API keys are:

Setting Holds
api.social.<provider>.token the token, an Omnipost\Model\Token::toArray() as JSON
api.social.<provider>.account who it is: id, username, name, url, picture, followers, posts, kind, when read
api.social.<provider>.api_key a reading key, for the providers that take one (YouTube's Data API key)
  • names() - social.providers that omnipost declares, in order; has($name).
  • provider($name) - omnipost's Registry::create() with the settings as overrides: access_token (left out when dead with a refresh token at hand: the provider draws a new one), refresh_token, api_key.
  • connected($name) - a token is kept that is alive or refreshable, or omnipost's options carry one.
  • saveToken() keeps the old refresh token, scopes and account id when the new token lacks them; refresh($name) asks a refreshable provider for a new token and keeps it; disconnect() forgets the token and the account.

Connecting a network

On /admin/social (social_admin_accounts), "connect" (POST, token social-connect-<provider>) keeps a random state in the session and sends the administrator to the network's consent page (OAuthInterface::authorizationUrl()). Back at /connect/social/{provider} (social_oauth_callback), a matching state and a code are exchanged for the token, kept; a provider with a feed then reads its account. A refused or forged answer is a flash message.

Instagram, for one artist's account: a professional account (creator or business), and a Meta app in development mode with the artist added as an Instagram Tester - no App Review is needed for an account of the app's own.

The feed and the wall

Base\Social\Service\FeedSync::sync(string $provider, ?int $limit = null): int reads the account (saved) and its last posts (social.feed.limit, 24) through a provider with a feed (FeedInterface; another is an InvalidConfigException): new ones added, known ones updated (caption, likes, comments, the network's fresh addresses), the thumbnail - the film's still, or the picture - copied once into the storage when social.feed.cache_thumbnails (only an answer of type image/*; a picture that does not answer costs the row its thumbnail and nothing more).

Commands

Command Options What it does
social:sync [provider] --limit, -l reads the feeds of every provider that has one, or one; an unknown provider is an error; fails (exit 1) when one failed
social:refresh-tokens --days=10, --force, -f refreshes the refreshable providers' tokens that die within that many days (Instagram's live sixty and are refreshed only while alive), or every one

Twig functions

Base\Social\Twig\SocialExtension:

Function Renders
social_wall(limit = 12, provider = null) the account's last posts in a grid (@Social/client/_wall.html.twig): the local thumbnail, a film played muted on hover from the network's address while it answers, each opening the post on the network; hidden posts left out
social_embed(url, consent = false) any public post: an Instagram post or reel (Instagram's own embed), a YouTube film (youtube-nocookie). Nothing reaches the network before a click - or at once with consent true, or once a social:consent event is dispatched on document
social_connected(provider) whether the account is connected

SocialExtension::recognize(string $url) reads an address - instagram.com/<user>/p|reel|reels|tv/<id> or a YouTube watch, shorts, embed, live or youtu.be address - into {network, permalink, id}:

use Base\Social\Entity\SocialPost;
use Base\Social\Twig\SocialExtension;

print_r(SocialPost::cleanTags(['#violin', ' bach ', 'violin', '']));
print_r(SocialExtension::recognize('https://www.instagram.com/clara/reels/C8xYz12AbC/?igsh=1'));
Array
(
    [0] => violin
    [1] => bach
)
Array
(
    [network] => instagram
    [permalink] => https://www.instagram.com/reel/C8xYz12AbC/
    [id] => C8xYz12AbC
)

The look is bundles/social/css/social.css, on the host's --social-accent, --social-soft, --social-surface, --social-radius, --social-gap, --social-columns, --social-video-ratio, --social-embed-width and --social-embed-video-width. Translations: the domain social (ICU), in English, French and German.

Proposer une modification docs/usage-accounts.md

The back office

Loaded when omnibase/admin is installed; the CRUDs keep omnibase/admin's default permissions, the accounts page is ROLE_ADMIN.

Posts

SocialPostCrudController (slug social-posts): title, kind (reel or picture), state (read), film, picture, caption, hashtags (typed, split on commas, spaces, semicolons), template (chosen in a list), use the template, cover's second, hour, link, and the targets (TargetType: network, enabled, caption, title, hashtags typed as a line, template - "the post's" when empty -, Instagram's share to feed and YouTube's privacy). A new post gets every network of social.providers, enabled. Filters: state, template.

Actions on the list, the record and the edit page:

  • Preview (/admin/social-posts/{entityId}/preview) - each network's reel and cover (served by /reel/{target}/video|cover, whatever the state), its checks, its capabilities, its state, permalink and error, whether the account is connected, and a button to send one network again (/retry/{target}).
  • Render - a RenderPostMessage; the worker renders.
  • Publish (asked to confirm) - every enabled network checked first: one refusal and nothing leaves, the reasons listed; all rendered, the publish messages go (at the post's hour), else a rendering followed by the publication.

Templates and the feed

TemplateCrudController (templates): name, default, network (one of social.providers), logo, its corner and width, band, its text and colours, font, fit, background, fade, intro, outro. Saving a default takes the flag off the other defaults of the same network.

SocialMediaCrudController (social-medias): the posts the last sync read, read-only but hidden; Show / hide on the wall and Open the post on the network.

The accounts page

/admin/social, in the admin's layout: per provider, the account connected (name, picture, followers), when its token dies, how many posts are kept and when the feed was read, and the buttons - connect, refresh the token, read the feed now, disconnect - and the reading key (api_key; empty puts the configuration's back). Each is a POST with its token (social-<action>-<provider>).

The dashboard tile

SocialWidgetType, named social_overview (@Social/admin/widget/overview.html.twig): per network, the account, the last target that moved and its state, when the feed was read, and a word when a token without a refresh token dies within ten days.

yield MenuItem::block('social_overview', 'Réseaux', 'fa-solid fa-share-nodes');

Proposer une modification docs/back-office.md

Configuration reference

social:

Base\Social\DependencyInjection\SocialConfiguration, dumped with Symfony's YamlReferenceDumper in omnisense/omnibase's harness image:

social:

    # The omnipost providers the site uses, by name, in the order the back office lists them.
    providers:

        # Default:
        - instagram
    ffmpeg:

        # The ffmpeg binary: a name on the PATH, or a path.
        binary:               ffmpeg

        # The ffprobe binary.
        ffprobe:              ffprobe

        # x264 quality: lower is finer and heavier.
        crf:                  20

        # Frames per second of a rendered reel.
        fps:                  30

        # Seconds a rendering may take.
        timeout:              900

        # The band's font when the template has none.
        font:                 /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf

    # Where the rendered reels and their covers are kept: <storage>/<post id>/<platform>.mp4.
    storage:              '%kernel.project_dir%/var/storage/social'
    feed:

        # Posts read from the account at each sync.
        limit:                24

        # A local copy of each thumbnail: the networks' own addresses expire.
        cache_thumbnails:     true

    # Seconds the public address of a rendered file still answers once its publication is over.
    media_url_ttl:        86400
    check:

        # Seconds between two looks at a publication the network is still processing.
        delay:                30

        # Looks before giving up.
        attempts:             20
Option Type Default Read by
providers list of omnipost provider names [instagram] Accounts, TargetType (only those omnipost declares are used)
ffmpeg.binary, ffmpeg.ffprobe string ffmpeg, ffprobe Renderer (a name on the PATH, or a path)
ffmpeg.crf int, 0 to 51 20 Renderer
ffmpeg.fps int, 1 to 60 30 Renderer
ffmpeg.timeout int, 10 or more 900 Renderer, seconds per process
ffmpeg.font path DejaVu Sans Renderer (used when the file exists)
storage path %kernel.project_dir%/var/storage/social Renderer
feed.limit int, 1 to 100 24 FeedSync
feed.cache_thumbnails bool true FeedSync
media_url_ttl int, 60 or more 86400 MediaController
check.delay int, 1 or more 30 Publisher
check.attempts int, 1 or more 20 Publisher

Parameters: one per key and level (social.providers, social.ffmpeg.binary, social.check.delay...).

Services

config/services.php autowires and autoconfigures every class of src/ but DependencyInjection/, Entity/, Enum/, Exception/, Message/, Model/, Controller/Admin/, Admin/ and the bundle class; Controller/Client/ is tagged controller.service_arguments; with omnibase/admin, Controller/Admin/ and Admin/. The three handlers are #[AsMessageHandler], the two commands #[AsCommand]. Accounts needs omnipost's Registry (its bundle registered) and the core's SettingBagInterface; Publisher omnipost's Validator.

Proposer une modification docs/configuration.md

Testing

vendor/bin/phpunit                                                     # checked out with its dependencies
php vendor/bin/phpunit -c vendor/omnibase/social/phpunit.xml.dist      # from an application

The tests need no kernel. omnisense/omnibase's harness image does not install omnisense/omnipost: there, its checkouts go on the autoloader with a prepended file that registers the PSR-4 namespaces of every composer.json found under /fam/*/*/:

<?php
// prepend-families.php
$loader = require '/srv/demo/vendor/autoload.php';
foreach (glob('/fam/*/*/composer.json') ?: [] as $file) {
    $c = json_decode((string) file_get_contents($file), true) ?: [];
    foreach ($c['autoload']['psr-4'] ?? [] as $ns => $path) {
        if (!isset($loader->getPrefixesPsr4()[$ns])) {
            $loader->addPsr4($ns, dirname($file).'/'.trim((string) $path, './'));
        }
    }
}
docker run --rm --network none \
    -v "$PWD":/srv/demo/vendor/omnibase/social:ro -v ~/Sites/omnipost:/fam/omnipost:ro -v /path/to/tools:/tools:ro \
    --entrypoint sh omnisense/omnibase-harness -c \
    'cd /srv/demo && APP_ENV=test php -d auto_prepend_file=/tools/prepend-families.php vendor/bin/phpunit -c vendor/omnibase/social/phpunit.xml.dist --do-not-cache-result'
......................                                            22 / 22 (100%)

OK (22 tests, 153 assertions)
File What
Service/RendererCommandTest a cropped film with logo, band and intro; a contained one with an outro, without sound or logo; a plain one has no concat and maps the dressed streams; the cover command and the helpers
Service/MediaUrlsTest the token round-trips; the address carries the target, the token and the extension
Entity/SocialPostTest toPost() builds the canonical post and a variant per network; for() applies a target's caption, title, hashtags and rendering; an empty override keeps the post's and a picture is an image; the state follows the networks; the file's address closes a day after the end
Controller/Admin/CrudFormsTest the template's enums through EnumType, rendered and translated, named on its detail page; a post takes its targets as objects and chooses its template in a list
Controller/CrudRequiredFieldsTest the CRUDs' required fields

Proposer une modification docs/testing.md