diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..d1d664e --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,20 @@ +name: "CodeQL" + +on: [pull_request] +jobs: + lint: + name: CodeQL + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - run: git checkout HEAD^2 + + - name: Run CodeQL + run: | + docker run --rm -v $PWD:/app composer:2.6 sh -c \ + "composer install --profile --ignore-platform-reqs && composer check" diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..133fb14 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,26 @@ +name: "Linter" + +on: + pull_request: + push: + branches: [ main ] + workflow_dispatch: + +jobs: + lint: + name: Linter + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + # Platform requirements are enforced here as they are in the image the + # tests run in: a dependency that could not run on a supported PHP should + # fail the job rather than install and be linted anyway. + - name: Run Linter + run: | + docker run --rm -v $PWD:/app composer:2 sh -c \ + "composer install --profile && composer lint" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..9439913 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,49 @@ +name: "Tests" + +on: + pull_request: + push: + branches: [ main ] + workflow_dispatch: + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-versions: [ '8.2', '8.3', '8.4' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Build + run: | + export PHP_VERSION=${{ matrix.php-versions }} + docker compose build + docker compose up -d + + # The container is ready when PHP and both binaries answer, which is a + # question worth asking rather than a wait worth guessing at. + - name: Wait for the container + run: | + for attempt in $(seq 1 30); do + if docker compose exec -T tests sh -c 'php -v && ffmpeg -version && ffprobe -version' >/dev/null 2>&1; then + echo "ready after ${attempt}s" + exit 0 + fi + sleep 1 + done + echo "the test container never became ready" + docker compose logs + exit 1 + + - name: Run Unit Tests + run: docker compose exec -T tests vendor/bin/phpunit --configuration phpunit.xml --testsuite unit + + - name: Run E2E Tests + run: docker compose exec -T tests vendor/bin/phpunit --configuration phpunit.xml --testsuite e2e diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1e4ac2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +.phpunit* +.phpunit.cache +.idea +.DS_Store diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..e397401 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to make participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity, expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at team@appwrite.io. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..74fed21 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,101 @@ +# Contributing + +We would ❀️ for you to contribute to Utopia-php and help make it better! We want contributing to Utopia-php to be fun, enjoyable, and educational for anyone and everyone. All contributions are welcome, including issues, new docs as well as updates and tweaks, blog posts, workshops, and more. + +## How to Start? + +If you are worried or don’t know where to start, check out our next section explaining what kind of help we could use and where can you get involved. You can reach out with questions to [Eldad Fux (@eldadfux)](https://twitter.com/eldadfux) or anyone from the [Appwrite team on Discord](https://discord.gg/GSeTUeA). You can also submit an issue, and a maintainer can guide you! + +## Code of Conduct + +Help us keep Utopia-php open and inclusive. Please read and follow our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Submit a Pull Request πŸš€ + +Branch naming convention is as following + +`TYPE-ISSUE_ID-DESCRIPTION` + +example: + +``` +doc-548-submit-a-pull-request-section-to-contribution-guide +``` + +When `TYPE` can be: + +- **feat** - is a new feature +- **doc** - documentation only changes +- **cicd** - changes related to CI/CD system +- **fix** - a bug fix +- **refactor** - code change that neither fixes a bug nor adds a feature + +**All PRs must include a commit message with the changes description!** + +For the initial start, fork the project and use git clone command to download the repository to your computer. A standard procedure for working on an issue would be to: + +1. `git pull`, before creating a new branch, pull the changes from upstream. Your `main` needs to be up to date. + +``` +$ git pull +``` + +2. Create new branch from `main` like: `doc-548-submit-a-pull-request-section-to-contribution-guide`
+ +``` +$ git checkout -b [name_of_your_new_branch] +``` + +3. Work - commit - repeat ( be sure to be in your branch ) + +4. Push changes to GitHub + +``` +$ git push origin [name_of_your_new_branch] +``` + +5. Submit your changes for review + If you go to your repository on GitHub, you'll see a `Compare & pull request` button. Click on that button. +6. Start a Pull Request + Now submit the pull request and click on `Create pull request`. +7. Get a code review approval/reject +8. After approval, merge your PR +9. GitHub will automatically delete the branch after the merge is done. (they can still be restored). + +## Introducing New Features + +We would πŸ’– you to contribute to Utopia-php, but we would also like to make sure Utopia-php is as great as possible and loyal to its vision and mission statement πŸ™. + +For us to find the right balance, please open an issue explaining your ideas before introducing a new pull request. + +This will allow the Utopia-php community to have sufficient discussion about the new feature value and how it fits in the product roadmap and vision. + +This is also important for the Utopia-php lead developers to be able to give technical input and different emphasis regarding the feature design and architecture. Some bigger features might need to go through our [RFC process](https://github.com/appwrite/rfc). + +## Other Ways to Help + +Pull requests are great, but there are many other areas where you can help Utopia-php. + +### Blogging & Speaking + +Blogging, speaking about, or creating tutorials about one of Utopia-php’s many features is great way to contribute and help our project grow. + +### Presenting at Meetups + +Presenting at meetups and conferences about your Utopia-php projects. Your unique challenges and successes in building things with Utopia-php can provide great speaking material. We’d love to review your talk abstract/CFP, so get in touch with us if you’d like some help! + +### Sending Feedbacks & Reporting Bugs + +Sending feedback is a great way for us to understand your different use cases of Utopia-php better. If you had any issues, bugs, or want to share about your experience, feel free to do so on our GitHub issues page or at our [Discord channel](https://discord.gg/GSeTUeA). + +### Submitting New Ideas + +If you think Utopia-php could use a new feature, please open an issue on our GitHub repository, stating as much information as you can think about your new idea and it's implications. We would also use this issue to gather more information, get more feedback from the community, and have a proper discussion about the new feature. + +### Improving Documentation + +Submitting documentation updates, enhancements, designs, or bug fixes. Spelling or grammar fixes will be very much appreciated. + +### Helping Someone + +Searching for Utopia-php, GitHub or StackOverflow and helping someone else who needs help. You can also help by teaching others how to contribute to Utopia-php's repo! \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fba6a23 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,110 @@ +ARG FFMPEG_VERSION=8.1.2 +ARG PHP_VERSION=8.2 +ARG SWOOLE_VERSION=v6.2.2 + +FROM php:${PHP_VERSION}-cli-alpine AS ffmpeg + +ARG FFMPEG_VERSION + +RUN apk add --no-cache \ + build-base nasm pkgconf curl xz \ + x264-dev x265-dev libvpx-dev opus-dev lame-dev zlib-dev \ + libtheora-dev libvorbis-dev + +RUN curl -fsSL "https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz" | tar -xJ -C /tmp \ + && cd "/tmp/ffmpeg-${FFMPEG_VERSION}" \ + && ./configure \ + --prefix=/opt/ffmpeg \ + --enable-gpl \ + --enable-libx264 \ + --enable-libx265 \ + --enable-libvpx \ + --enable-libopus \ + --enable-libmp3lame \ + --enable-libtheora \ + --enable-libvorbis \ + --disable-debug \ + --disable-doc \ + --disable-ffplay \ + && make -j"$(nproc)" \ + && make install \ + && /opt/ffmpeg/bin/ffmpeg -version \ + && rm -rf "/tmp/ffmpeg-${FFMPEG_VERSION}" + +# Built from source, pinned, in its own stage so it compiles alongside ffmpeg +# rather than after it. Only the tests need it: the library itself has no swoole +# dependency, it just has to keep yielding when a caller runs it in a coroutine, +# and that is only provable with the real scheduler. +FROM php:${PHP_VERSION}-cli-alpine AS swoole + +ARG SWOOLE_VERSION + +RUN apk add --no-cache \ + build-base autoconf openssl-dev linux-headers git file \ + && git clone --depth 1 --branch "${SWOOLE_VERSION}" https://github.com/swoole/swoole-src.git /tmp/swoole \ + && cd /tmp/swoole \ + && phpize \ + && ./configure \ + && make -j"$(nproc)" \ + && make install \ + && mkdir -p /ext \ + && cp "$(php-config --extension-dir)/swoole.so" /ext/swoole.so \ + && rm -rf /tmp/swoole + +FROM composer:2 AS vendor + +ARG TESTING=false + +WORKDIR /usr/local/src/ + +COPY composer.json composer.lock /usr/local/src/ + +# No --ignore-platform-reqs: the lock is resolved against the oldest supported +# PHP, so anything that would not run in this image should fail the build rather +# than install and break at runtime. +RUN composer install --optimize-autoloader \ + --no-plugins --no-scripts --prefer-dist \ + $(test "$TESTING" = "true" || echo "--no-dev") + +FROM php:${PHP_VERSION}-cli-alpine AS final + +LABEL maintainer="team@appwrite.io" + +ENV TZ=UTC + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN apk add --no-cache \ + x264-libs x265-libs libvpx opus lame-libs libtheora libvorbis libstdc++ + +COPY --from=ffmpeg /opt/ffmpeg/bin/ffmpeg /opt/ffmpeg/bin/ffprobe /usr/local/bin/ + +COPY --from=swoole /ext/swoole.so /ext/swoole.so + +RUN cp /ext/swoole.so "$(php-config --extension-dir)/" \ + && echo extension=swoole.so > /usr/local/etc/php/conf.d/swoole.ini \ + && rm -rf /ext + +# Enough headroom for a cold phpstan run, which needs more than the 128M default. +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \ + && echo "memory_limit=1024M" >> "$PHP_INI_DIR/php.ini" + +WORKDIR /usr/src/code + +COPY --from=vendor /usr/local/src/vendor /usr/src/code/vendor + +# Add Source Code +COPY ./src /usr/src/code/src +COPY ./tests /usr/src/code/tests +COPY ./composer.json /usr/src/code/composer.json +COPY ./phpunit.xml /usr/src/code/phpunit.xml +COPY ./phpstan.neon /usr/src/code/phpstan.neon +COPY ./pint.json /usr/src/code/pint.json + +# Asserted rather than printed: without this the coroutine suite would go back to +# skipping itself, and a skipped test is quiet about being absent. +RUN ffmpeg -version && ffprobe -version \ + && php --ri swoole > /dev/null \ + && php -r 'exit(extension_loaded("swoole") ? 0 : 1);' + +CMD [ "tail", "-f", "/dev/null" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ac621e7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 utopia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bdc3b38 --- /dev/null +++ b/README.md @@ -0,0 +1,433 @@ +# Utopia Video + +[![Tests](https://github.com/utopia-php/video/actions/workflows/tests.yml/badge.svg)](https://github.com/utopia-php/video/actions/workflows/tests.yml) +[![Total Downloads](https://img.shields.io/packagist/dt/utopia-php/video.svg)](https://packagist.org/packages/utopia-php/video) +[![Discord](https://img.shields.io/badge/discord-join-5865F2)](https://appwrite.io/discord) + +Utopia framework video library is simple and lite library for probing, encoding and packaging +video for adaptive streaming. This library is aiming to be as simple and easy to learn and use. This +library is maintained by the [Appwrite team](https://appwrite.io). + +Although this library is part of the [Utopia Framework](https://github.com/utopia-php/framework) +project, it can be used as standalone with any other PHP project or framework. Its only runtime +dependency is [`utopia-php/console`](https://github.com/utopia-php/console), which prints the status +line at the end of a job β€” and even that can be replaced with a `Reporter` of your own. + +It takes local files in, writes local files out, and describes what it produced. It does not talk to +cloud storage, a database, or an HTTP layer β€” that is the calling application's job. + +See [docs/design.md](docs/design.md) for the full design. + +## Getting Started + +Install using composer: + +```bash +composer require utopia-php/video +``` + +Two classes do the work. `Encoder` writes one file β€” an encode, a thumbnail, a sprite sheet. +`Packager` writes an adaptive ladder β€” segments plus the manifests that describe them. + +Read what a file is: + +```php +require_once __DIR__ . '/../../vendor/autoload.php'; + +use Utopia\Video\Encoder; + +$encoder = new Encoder(); + +$info = $encoder->probe('/path/to/video.mp4'); + +$info->duration; // 64.5 seconds +$info->milliseconds(); // 64500 +$info->width; // 1920 +$info->videoCodec; // 'h264' +$info->tags['title']; // container metadata +$info->chapters; // list +$info->tracks; // every stream, embedded subtitles included +``` + +Encode a single file: + +```php +use Utopia\Video\Encoder; +use Utopia\Video\Format\X264; +use Utopia\Video\Representation; + +$path = (new Encoder()) + ->open('/path/to/video.mp4') + ->format((new X264())->crf(22)->bframes(3)) + ->add(new Representation(width: 1280, height: 720, video: 2538, audio: 128)) + ->encode('/path/to/720p.mp4'); +``` + +One rendition of the picture comes out, at exactly the size asked for β€” a source of a different +shape is fitted inside the box rather than stretched. Every audio track comes through, so a release +with four dubs still has four afterwards. + +Package an adaptive ladder: + +```php +use Utopia\Video\Format\X264; +use Utopia\Video\Output\Cmaf; +use Utopia\Video\Packager; +use Utopia\Video\Progress; +use Utopia\Video\Representation; + +$package = (new Packager()) + ->open('/path/to/video.mp4') + ->format((new X264())->crf(22)->bframes(3)) + ->add( + new Representation(width: 640, height: 360, video: 800, audio: 96), + new Representation(width: 1280, height: 720, video: 2538, audio: 128), + ) + ->output((new Cmaf())->segment(6)) + ->on(Packager::PROGRESS, fn (Progress $p) => print("{$p->percent}%\n")) + ->pack('/path/to/output'); + +$package->segments(); // every segment, with duration, size and init flag +$package->variants(); // per rendition metadata +$package->manifests(); // the playlists written, if any were kept +$package->files(); // everything produced, ready to upload +``` + +`Cmaf` writes one set of fragmented MP4 segments described by both a DASH manifest and an HLS +playlist tree, so either kind of player downloads the same bytes. Use `Hls` or `Dash` to write just +one of them. + +A segment can only start where a keyframe already is, so packaging forces a keyframe every segment +unless you ask for something else with `keyframe()`. Nothing has to be kept in step by hand, and a +keyframe interval longer than a segment β€” which cannot produce segments of the length asked for β€” is +rejected before ffmpeg is called. + +Names are checked too. A representation name, an output base name and a sprite sheet name become +filenames in the directory you named, and a rendition name is also how the muxer is told which +variant is which, so anything carrying a path separator, a comma or a space is refused with +`Exception\Input` rather than written somewhere unexpected. + +### Several audio languages + +Every audio track the source carries comes through as its own selectable rendition β€” `EXT-X-MEDIA:TYPE=AUDIO` +rows for HLS, one `` each for DASH. Tagged tracks are labelled with their language; +untagged ones are still carried, just unlabelled, because a dub without a tag is still a dub. Video +rungs stay switchable alternatives of one another; languages are separate choices. + +```php +$package = (new Packager())->open('/path/to/movie.mkv') // 4 dubs + ->format(new X264()) + ->add($rep) + ->output(new Cmaf()) + ->pack('/out'); + +foreach ($package->variants() as $variant) { + $variant->type; // 'video' | 'audio' + $variant->language; // 'eng', 'spa', 'fra', 'jpn', … +} +``` + +Subtitles are read but not packaged. `probe()` reports every embedded subtitle track in +`$info->tracks`, and what to do with them is the calling application's decision β€” extracting them, +converting them and referencing them from a manifest all belong outside this library. + +Applications that serve their own manifests can keep the media and drop the playlists with +`manifests(false)` β€” `segments()` and `variants()` are populated either way: + +```php +->output((new Dash())->template(false)->timeline(false)->manifests(false)) +``` + +Thumbnails and sprite timelines: + +```php +use Utopia\Video\Thumb; +use Utopia\Video\Tile; + +$encoder = new Encoder(); + +$encoder->grab('/path/to/video.mp4', '/out/poster.jpg', (new Thumb())->width(640)); +$sheet = $encoder->tile('/path/to/video.mp4', '/out/timeline'); + +$sheet->cues(); // list with start, end, x, y, width, height +$sheet->render(fn (string $file) => "/preview/{$file}"); // WebVTT with your own URLs +``` + +Given a file with no video but embedded artwork, `grab()` finds the picture rather than failing. + +## Adapters + +One class per backend, each declaring what it can do by implementing capability interfaces from +`Utopia\Video\Adapter`: + +| Backend | Binary | Implements | +| ------------------ | --------- | --------------------- | +| `Adapter\FFmpeg` | `ffmpeg` | `Encoder`, `Packager` | +| `Adapter\FFprobe` | `ffprobe` | `Probe` | +| `Adapter\Mock` | none | all three | + +`new Encoder()` and `new Packager()` need nothing: each wires an `FFmpeg` to work with and an +`FFprobe` to read with. Pass your own to replace either: + +```php +use Utopia\Video\Adapter\FFmpeg; + +// One backend, shared by both facades. +$ffmpeg = new FFmpeg(threads: 4); + +$encoder = new Encoder($ffmpeg); +$packager = new Packager($ffmpeg); +``` + +Whichever probe you pass is the one the adapter reads through β€” the facade hands it over, so an +adapter you built yourself cannot quietly keep one of its own: + +```php +$encoder = new Encoder(probe: new MyProbe()); +``` + +### How much a backend says + +Every adapter has a display level, defaulting to `Adapter::ERROR` β€” only what went wrong, because +the interesting output of an encode is the file it wrote, not its commentary. Raise it when +something needs explaining; whatever the backend then prints arrives as `LOG` events. + +Finished **encode / pack / grab / tile** jobs also report a status line β€” green via +`Utopia\Console::success`, and red via `Console::error` for a failed backend command before the +exception is thrown. Probe stays quiet on success. `Adapter::QUIET` suppresses both status lines as +well as `LOG` events; progress events keep firing at every level, because progress is structured +data rather than commentary. + +```php +use Utopia\Video\Adapter; +use Utopia\Video\Adapter\FFmpeg; + +$encoder = new Encoder(new FFmpeg(level: Adapter::VERBOSE)); + +$encoder->on(Encoder::LOG, fn (string $line) => error_log($line)); +``` + +Listeners belong to the facade, not to one job: `on()` reads the same before or after `open()`, and +a listener registered once is heard by every job that facade runs. `off()` drops them again β€” +`off(Encoder::LOG)` for one event, `off()` for all of them β€” which is what a reused facade wants +before it registers the next job's listener. + +`Adapter::QUIET`, `ERROR` (default), `WARNING`, `INFO`, `VERBOSE`, `DEBUG` β€” quietest first, listed +in `Adapter::LEVELS`. An unrecognised level is rejected when the adapter is constructed, not halfway +through a job. + +### Where status lines go + +The terminal, by default. Somewhere that is not a command line β€” an HTTP worker, a queue consumer, a +test suite β€” owns its own logging and would rather the library did not write to stdout behind its +back, so the destination is a `Reporter`: + +```php +use Utopia\Video\Reporter; + +$packager = new Packager(reporter: new Reporter\Silent()); // nothing at all +$encoder = new Encoder(reporter: new MyPsrLoggerReporter($log)); // two methods to implement +``` + +Whichever one a facade is given is handed to every backend it uses, so half a job cannot end up +printing. `LOG` and `PROGRESS` events are unaffected β€” those already go wherever your listeners send +them. + +A backend defines its own default with a `LEVEL` constant, the same way it declares its `NAME`, +`BINARY` and `TIMEOUT`, and `level()` reports whichever level it ended up with. + +Because `Adapter\FFmpeg` implements `Encoder` *and* `Packager`, `pack()` encodes and packages in a +single pass over the source. A packager that cannot encode is handed one finished file per rung +instead, with progress reported as one run either way β€” that is the route a backend like Shaka will +take when it arrives. + +Ask which backend is serving what: + +```php +$encoder->getName(); // 'ffmpeg' +$packager->getName(); // 'ffmpeg' +``` + +### Coroutines + +The library is built to run inside Swoole coroutines. Two rules make it safe: + +**Config and results are shareable; a facade is not.** `Format`, `Output`, `Thumb` and `Tile` are +immutable β€” every setter returns a modified copy, so a preset held in a container can be handed to +any number of concurrent jobs and configured per job without the jobs seeing each other: + +```php +$preset = (new X264())->crf(22); // shared, safe +$output = (new Cmaf())->segment(6); // shared, safe + +go(function () use ($preset, $output, $in) { + // One facade per coroutine. Constructing one is a handful of assignments β€” + // nothing runs until open() β€” so there is nothing to save by sharing it, + // and a facade holds the job chain, which two coroutines would corrupt. + (new Packager()) + ->open($in) + ->format($preset->keyframe(2.0)) // a copy; $preset is untouched + ->add(new Representation(width: 1280, height: 720, video: 2538, audio: 128)) + ->output($output) + ->pack('/out/a'); +}); +``` + +The same goes for adapters: an `Adapter\FFmpeg` carries its job's state, so give each coroutine its +own rather than passing one instance to several facades running concurrently. Every result object +(`Info`, `Package`, `Spritesheet`, `Progress`) is readonly and safe to share. + +**Enable the hooks.** `Process` drives backends through `proc_open`, `stream_select` and `usleep`, +all of which yield instead of blocking once Swoole's one-click hooks are on β€” including the process +hook (`SWOOLE_HOOK_PROC`, part of `SWOOLE_HOOK_ALL`): + +```php +Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL); +``` + +Without the hooks everything still works; a running backend just blocks the worker instead of +yielding to other coroutines. + +Because setters return copies, a setter called as a standalone statement configures the copy and +throws it away β€” chain from the constructor, or keep the return value. + +### Choosing a backend by name + +Handy when the choice comes from configuration. Each factory returns the narrow capability type, so +the result can be passed straight to a facade: + +```php +use Utopia\Video\Adapter; + +$encoder = new Encoder( + Adapter::encoder($config['encoder'], level: $config['level']), // 'ffmpeg' | 'mock' + Adapter::probe($config['probe']), // 'ffprobe' | 'mock' +); +``` + +The names are exactly what `getName()` reports. An unknown name, or one that names a backend which +cannot do the job, throws `Exception\Unsupported`. + +### Writing your own + +Extend `Utopia\Video\Adapter` for the shared plumbing β€” identity, listeners, binary and timeout +config, the probe seam, and guards that put your backend's name in the failure message β€” then +implement whichever capability interfaces apply: + +```php +use Utopia\Video\Adapter; +use Utopia\Video\Adapter\Packager; +use Utopia\Video\Package; + +class Shaka extends Adapter implements Packager +{ + protected const NAME = 'shaka'; + + protected const BINARY = 'packager'; + + // Optional: override the default display level for this backend only. + protected const LEVEL = self::WARNING; + + public function pack(string $dir): Package + { + // ... + } +} +``` + +Nothing else changes: `new Packager(new Shaka())` works immediately, because a packager that cannot +encode already has a route through the facade. The same holds for an alternative encoder β€” implement +`Adapter\Encoder` and hand it to `new Encoder($yours)`. + +`Adapter\Mock` implements every capability without touching a binary, which makes it useful for +testing code built on this library. + +## Contribute + +We use the [Utopia Framework](https://github.com/utopia-php/framework) contribution guide. See +[CONTRIBUTING.md](CONTRIBUTING.md). + +## System Requirements + +Utopia Video requires PHP 8.2 or later, `ffmpeg` with `ffprobe` on the `PATH`, and +[`utopia-php/console`](https://github.com/utopia-php/console) for status lines. The unit suite runs +without either binary. + +## Tests + +To run all unit tests, use the following Docker command: + +```bash +docker compose up -d +docker compose exec tests vendor/bin/phpunit --testsuite unit +``` + +Every test prints one line saying what it checks and whether it passed: + +``` +Adapter (Utopia\Tests\Unit\Adapter) + βœ” Every backend starts at the default display level + βœ” The display level can be raised or silenced + βœ” An unknown display level is rejected +``` + +That is PHPUnit's testdox output, turned on in `phpunit.xml` so it applies to every run rather than +needing a flag. Test method names are written to read as sentences; where a name cannot, a +`@testdox` annotation gives the wording instead. + +The end-to-end suite drives a real encoder, and the image builds FFmpeg from source (pinned with the +`FFMPEG_VERSION` build arg) so it is reproducible. It also builds `ext-swoole` (pinned with +`SWOOLE_VERSION`), which is what lets the coroutine test above run for real rather than skip: + +```bash +docker compose exec tests vendor/bin/phpunit --testsuite e2e +``` + +### Samples + +A consumer is handed whatever a user uploaded, so the end-to-end suite runs against a library of +sample files rather than one generated clip: + +``` +tests/samples/in the sources +tests/samples/out whatever the tests produced, left behind for inspection +``` + +`out/` is cleared at the start of each run, so everything in it belongs to the run that produced it +and a directory written by a test that has since been deleted cannot linger there looking like +output. + +Build them without running the tests: + +```bash +composer samples # fill in anything missing +composer samples:rebuild # start over +``` + +The media is generated by [tests/Video/Samples.php](tests/Video/Samples.php) rather than +committed β€” it would add megabytes to every clone for files the build reproduces exactly, and a +generator describes each file's shape in a way a binary cannot. The set covers: + +| Group | Files | +| --- | --- | +| Containers | `video.{mp4,mov,mkv,avi,wmv,flv,ts,webm,ogv,3gp}`, each with the codecs that container is normally used with | +| Codecs | h264, HEVC, VP9, MPEG-4, WMV2, FLV1, Theora, plus AAC, MP3, Opus, Vorbis, WMA | +| Many tracks | `multi-audio.mp4` (eng/spa/fra), `multi-audio.mkv` (eng/spa/fra/jpn), `multi-subtitle.mkv` (3 subtitle tracks, so probe can be checked against them), `multi-track.mkv` (4 dubs + 2 subtitle languages) | +| Awkward shapes | `anamorphic.mp4`, `variable-fps.mp4`, `rotated.mp4`, `portrait.mp4`, `tiny.mp4`, `silent.mp4` | +| Metadata | `chapters.mp4`, container tags | +| Sound only | `audio.m4a`, `audio.mp3`, `artwork.m4a` (embedded cover) | + +[SamplesTest](tests/Video/E2E/SamplesTest.php) puts every one of them through probe, grab, tile +and packaging β€” HLS and DASH across the whole container matrix, CMAF and the multi-language cases on +the sources that have something to say β€” and asserts the codec is read from the file rather than its +extension. +[MediaTest](tests/Video/E2E/MediaTest.php) covers the same awkward properties as focused cases. + +Locally, `composer test` runs both, `composer check` runs PHPStan and `composer lint` checks +formatting (`composer format` fixes it). Tests that need an encoder skip themselves when one is not +installed, as does the coroutine test without `ext-swoole` β€” so run the suite in the image if you +want the whole thing, since that is where both are guaranteed to be present. + +## Copyright and license + +The MIT License (MIT) [http://www.opensource.org/licenses/mit-license.php](http://www.opensource.org/licenses/mit-license.php) \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..07e7bcd --- /dev/null +++ b/composer.json @@ -0,0 +1,50 @@ +{ + "name": "utopia-php/video", + "description": "A simple library to probe, encode and package video for adaptive streaming", + "type": "library", + "keywords": ["php", "framework", "upf", "utopia", "streaming", "video", "hls", "dash", "cmaf", "ffmpeg"], + "license": "MIT", + "minimum-stability": "stable", + "authors": [ + { + "name": "Shimon Newman", + "email": "shimon@appwrite.io" + } + ], + "autoload": { + "psr-4": {"Utopia\\Video\\": "src/Video"} + }, + "autoload-dev": { + "psr-4": {"Utopia\\Tests\\": "tests/Video"} + }, + "scripts": { + "test": "./vendor/bin/phpunit --configuration phpunit.xml", + "test:unit": "./vendor/bin/phpunit --configuration phpunit.xml --testsuite unit", + "test:e2e": "./vendor/bin/phpunit --configuration phpunit.xml --testsuite e2e", + "samples": "@php -r \"require 'vendor/autoload.php'; Utopia\\\\Tests\\\\Samples::build();\"", + "samples:rebuild": "@php -r \"require 'vendor/autoload.php'; Utopia\\\\Tests\\\\Samples::build(true);\"", + "check": "./vendor/bin/phpstan analyse --memory-limit=512M", + "format": "./vendor/bin/pint", + "lint": "./vendor/bin/pint --test" + }, + "require": { + "php": ">=8.2", + "ext-dom": "*", + "ext-json": "*", + "utopia-php/console": "0.1.* || ^0.2" + }, + "require-dev": { + "phpunit/phpunit": "9.*", + "phpstan/phpstan": "^2.0", + "laravel/pint": "1.*", + "swoole/ide-helper": "6.2.2" + }, + "suggest": { + "ext-swoole": "Needed to run the library inside Swoole coroutines." + }, + "config": { + "platform": { + "php": "8.2" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..717524f --- /dev/null +++ b/composer.lock @@ -0,0 +1,2051 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "fd123665d381e37cb9364461b0a83277", + "packages": [ + { + "name": "utopia-php/console", + "version": "0.2.3", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/console.git", + "reference": "e67316f7872b6f7b2c16b246fb696d3059d108bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/console/zipball/e67316f7872b6f7b2c16b246fb696d3059d108bd", + "reference": "e67316f7872b6f7b2c16b246fb696d3059d108bd", + "shasum": "" + }, + "require": { + "php": ">=8.0", + "utopia-php/validators": "^0.2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Console helpers for logging, prompting, and executing commands", + "keywords": [ + "cli", + "console", + "php", + "terminal", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/console/issues", + "source": "https://github.com/utopia-php/console/tree/0.2.3" + }, + "time": "2026-06-26T10:48:18+00:00" + }, + { + "name": "utopia-php/validators", + "version": "0.2.8", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/validators.git", + "reference": "53c0cad30b4212627f5bce727af7b97d6ee05b5f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/53c0cad30b4212627f5bce727af7b97d6ee05b5f", + "reference": "53c0cad30b4212627f5bce727af7b97d6ee05b5f", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A lightweight collection of reusable validators for Utopia projects", + "keywords": [ + "php", + "utopia", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/utopia-php/validators/issues", + "source": "https://github.com/utopia-php/validators/tree/0.2.8" + }, + "time": "2026-06-26T10:48:18+00:00" + } + ], + "packages-dev": [ + { + "name": "doctrine/instantiator", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:23:10+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.20.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/53072e8ea22213a7ed168a8a15b96fbb8b82d44b", + "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.66.0", + "illuminate/view": "^10.48.25", + "larastan/larastan": "^2.9.12", + "laravel-zero/framework": "^10.48.25", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^1.17.0", + "pestphp/pest": "^2.36.0" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2025-01-14T16:20:53+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.8", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e285254e60f33c21902efef4a926ca0987c06804", + "reference": "e285254e60f33c21902efef4a926ca0987c06804", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "OndΕ™ej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-08-04T22:21:45+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "9.2.32", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:23:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.6.35", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b", + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.5.0 || ^2", + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", + "sebastian/comparator": "^4.0.10", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.8", + "sebastian/global-state": "^5.0.8", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.6-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.35" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:48:07+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:27:43+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:22:56+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:19:30+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:30:58+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:03:51+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:03:27+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:10:35+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:20:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T06:57:39+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-14T16:00:52+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:13:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "swoole/ide-helper", + "version": "6.2.2", + "source": { + "type": "git", + "url": "https://github.com/swoole/ide-helper.git", + "reference": "ad91a281e135f498426fb2e5297376581a4e2dca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swoole/ide-helper/zipball/ad91a281e135f498426fb2e5297376581a4e2dca", + "reference": "ad91a281e135f498426fb2e5297376581a4e2dca", + "shasum": "" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Team Swoole", + "email": "team@swoole.com" + } + ], + "description": "IDE help files for Swoole.", + "support": { + "issues": "https://github.com/swoole/ide-helper/issues", + "source": "https://github.com/swoole/ide-helper/tree/6.2.2" + }, + "time": "2026-07-25T08:15:29+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.2", + "ext-dom": "*", + "ext-json": "*" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.2" + }, + "plugin-api-version": "2.9.0" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cc194ac --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + tests: + container_name: utopia-video-test-1 + build: + context: . + dockerfile: Dockerfile + args: + TESTING: "true" + PHP_VERSION: ${PHP_VERSION:-8.2} + volumes: + - ./src:/usr/src/code/src + - ./tests:/usr/src/code/tests + - ./phpunit.xml:/usr/src/code/phpunit.xml + - ./phpstan.neon:/usr/src/code/phpstan.neon + - ./pint.json:/usr/src/code/pint.json diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..8d333e1 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,889 @@ +# Utopia\Video v2 β€” Design + +> Status: **implemented** Β· last updated 2026-08-23 Β· targets ffmpeg 8.1 (pinned in the repo image) +> CMAF dual-manifest strategy verified end-to-end against the repo image's ffmpeg build (see Β§4). + +A small, generic PHP library for video workflows: **probe** media and its metadata (tech specs, +tags, chapters, tracks), **encode** renditions, **pack** adaptive streams (HLS, DASH, CMAF), +**grab** thumbnails, and **tile** sprite timelines. +Built on the Adapter pattern so every backend is swappable: the two public classes depend on +capability *interfaces*, never on ffmpeg. + +Two entry points, one per kind of job: + +- **`Encoder`** β€” one file out: an encode, a thumbnail, a sprite sheet. +- **`Packager`** β€” an adaptive ladder out: segments plus the manifests describing them. + +Subtitles are read but not packaged, and metadata comes from one probe. Extracting, converting and +publishing subtitle files belongs to the application that owns them β€” for Appwrite, the Videos +worker (Β§6). + +Boundaries β€” the library: + +- takes **local input paths** (plain strings, so remote/live sources can slot in later β€” Β§11), + writes **local output artifacts**, returns **structured results**; +- does not touch cloud storage, databases, HTTP, auth, queues, DRM, or live streaming + (live is deliberately out of v2, but the API reserves clean seams for adding it later β€” see Β§11); +- depends on [`utopia-php/console`](https://github.com/utopia-php/console) for status lines, behind a + `Reporter` seam so a caller that does not own stdout can redirect or silence them; backends are + external binaries. + +Style: one-word public methods (`open`, `probe`, `encode`, `pack`, `grab`, `tile`, `valid`), +short class names with no redundant context (`Adapter\FFmpeg`, not `FFmpegEncoderAdapter`), +config objects in, readonly results out. + +## 1. Architecture + +**One class per backend**, each declaring what it can do by implementing capability interfaces. +`abstract class Utopia\Video\Adapter` carries the plumbing they all need; the interfaces live in +`Utopia\Video\Adapter\*`: + +``` +Consumer (worker / CLI / app) + β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β–Ό β–Ό + Encoder Packager ─── facades: own the job chain, pick the execution path, + β”‚ β”‚ push config into whichever adapter they were given + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β–Ό + β”œβ”€ Adapter\FFmpeg ffmpeg β†’ Encoder, Packager (default for both facades) + β”œβ”€ Adapter\FFprobe ffprobe β†’ Probe (default probe) + └─ Adapter\Mock none β†’ all three (ships in src/, for tests) + +Contracts: Adapter\{Named, Observable} ← Adapter\{Encoder, Packager, Probe} +Shared: abstract Adapter (identity, listeners, config, guards) Β· Adapter\{Job, Reads} traits + Reporter (Console|Silent) β€” where status lines go Β· trait Decimal, shared by the + adapter, config and argv hierarchies alike (four of them wanted one number formatter) + +Config in (immutable): Format (X264|HEVC|VP9|Copy) Β· Representation Β· Output (Hls|Dash|Cmaf) Β· Thumb Β· Tile +Results out (readonly): Info{Track, Chapter} Β· Package{Variant, Segment, Manifest} Β· Spritesheet{Cue} Β· Progress +Internal (@internal): Process (proc_open) Β· Arguments\{Hls,Dash,Cmaf} (argv) Β· Parser\{M3u8,Mpd} + Name (guards every caller-chosen string that reaches a path or argv) +``` + +Capabilities are interfaces rather than methods on one god-class, so a backend +that cannot do something simply does not declare it β€” and static analysis rejects +passing a pure packager where an encoder is required, rather than leaving it to +fail at run time. But *classes* follow binaries, not capabilities: ffmpeg encodes, +packages and pulls stills, so it is one class implementing two interfaces. +`ffprobe` is separate only because it is a separate binary with its own timeout. + +**Replacing a backend is additive.** `Encoder` names only `Adapter\Encoder`, +`Packager` only `Adapter\Packager` (plus `Adapter\Encoder` for the staged path) β€” +neither mentions `Adapter\FFmpeg` outside a constructor default. So a new backend +is a new class and nothing else: write `Adapter\Shaka implements Adapter\Packager` +and `new Packager(new Shaka())` works, because the route for a packager that +cannot encode already exists and is unit-tested against a pure fake. Shaka itself +is deferred to a later release; the seam it plugs into ships now. + +Three rules keep the design honest: + +0. **Config is pushed into adapters, never passed to their constructors.** No + adapter can build its own probe β€” the base has no default and no way to make + one β€” so `new Encoder(probe: $mine)` reaches the adapter, including one the + caller constructed. An earlier design let each adapter default its own probe, + which silently ignored the one asked for. + +1. **Data flows one way.** `Format`, `Representation`, `Output`, `Thumb`, and `Tile` are pure user config β€” + adapters never write into them, and their setters return modified copies rather than touching the + receiver, so they are immutable in both directions. Probe-derived facts (`hasVideo`, audio tracks, + duration) live in `Info`, produced once at `open()`, and are passed to the argv builders and the + progress reporter. The same `Output` instance is safely shareable across jobs β€” and across + coroutines β€” and progress always knows the duration. Facades and adapters are the opposite: they + hold one job's chain, so concurrent work takes one instance per coroutine (they cost a handful of + assignments to build; nothing runs until `open()`). The one process-global the library touches, + libxml's error flag in `Parser\Mpd`, is set and restored with no suspension point in between. +2. **`Packager::pack()` picks the execution path from the types, not a flag.** If the adapter is also + an `Adapter\Encoder` (i.e. `Adapter\FFmpeg`), the whole job runs as **one fused ffmpeg + invocation** β€” encode and package in a single pass. If it only packages, `Packager` first encodes + one keyframe-aligned intermediate MP4 per representation via the encoder adapter, then hands them + over (progress is unified: encodes weighted ~90%, packaging ~10%). + +## 2. Public API + +### Facades + +Both take the same shape: an adapter, a probe and a reporter, each defaulted, each only ever known by +its interface. Adapter names below are written relative to `Utopia\Video\Adapter`. + +```php +Encoder::__construct(?Adapter\Encoder $adapter = null, ?Adapter\Probe $probe = null, + ?Reporter $reporter = null) +Packager::__construct(?Adapter\Packager $adapter = null, + ?Adapter\Encoder $encoder = null, // staged path only; defaults to the + ?Adapter\Probe $probe = null, // adapter itself when it can encode + ?Reporter $reporter = null) // null keeps Reporter\Console (Β§10.12) +``` + +```php +$encoder = new Encoder(); // ffmpeg to work with, ffprobe to read with +$packager = new Packager(); + +// One backend, shared β€” both facades push the same probe into it. +$ffmpeg = new Adapter\FFmpeg(threads: 4); +$encoder = new Encoder($ffmpeg); +$packager = new Packager($ffmpeg); + +$encoder->getName(); // 'ffmpeg' β€” what a backend chosen from config reports back +``` + +```php +// Read, on either facade, so a caller holding one of them can gate its own input +$info = $encoder->probe($in); // Info β€” specs + tags, chapters, tracks +$ok = $packager->valid($in); // bool + +// One-shot verbs on Encoder +$poster = $encoder->grab($in, $jpg, new Thumb()); // written path β€” thumbnail / poster frame +$sheet = $encoder->tile($in, $dir, new Tile()); // Spritesheet + +// Job chain β€” encode()/pack() are terminal, and clear the job they finish (Β§2, trait Job) +$file = $encoder->open($in)->format($format)->add($rep)->encode($out); // one plain file (≀1 rep) + +$package = $packager->open($in) + ->format((new X264())->crf(22)->bframes(3)->keyframe(2.0)) + ->add(new Representation(width: 1280, height: 720, video: 2538, audio: 128)) + ->output((new Cmaf())->segment(6)) + ->on(Packager::PROGRESS, fn (Progress $p) => /* ... */ null) + ->pack($outDir); // Package +``` + +`Encoder` delegates each call straight through β€” the adapter's own `open()` already restarts a job, +so there is nothing worth buffering. `Packager` buffers the chain, because `pack()` has to know the +whole job before it can choose between the fused and staged routes. + +Listeners are the one thing a facade keeps rather than delegates. An adapter drops its own on +`open()`, which is what stops one job's listeners firing during the next; a facade re-registers +its listeners afterwards, so `on()` means the same thing before `open()` as after it. Without that, +`->on(...)->open(...)->pack(...)` β€” the ordering the chain invites β€” registered a listener and +then silently discarded it. `off($event)` drops one event's listeners and `off()` drops all of +them, which is how a reused facade swaps one job's listener for the next one's. `off()` is on +`Adapter` rather than on `Observable`, so a backend written against the narrow interface still +satisfies it. + +Events: `PROGRESS` β†’ `fn (Progress $p)`, `LOG` β†’ `fn (string $line)` (raw backend stderr). The +constants live on `Adapter\Observable` β€” the interface that declares `on()` β€” and are re-exported by +both facades, so `Packager::PROGRESS` and `Encoder::PROGRESS` are the same string. Throttling is the +consumer's job: the library reports every progress block it gets. How *much* arrives as `LOG` is the +adapter's display level (below), not a filter in the facade β€” the backend decides what to print, and +everything it prints is forwarded. + +### Adapter interfaces + +Two parent interfaces hold what every backend shares, so nothing is declared twice: + +```php +interface Named { public function getName(): string; } +interface Observable { public function on(string $event, callable $listener): static; } + +interface Encoder extends Named, Observable +{ + public function open(string $path): static; + public function format(Format $format): static; + public function add(Representation ...$representations): static; + public function encode(string $path): string; // exactly one output file + + // Stills live here rather than in an interface of their own: they are the same + // work (decode the source, write a picture), and anything that can encode can + // grab a frame, so splitting them out would only make a caller ask for two + // capabilities where one will do. + public function grab(string $path, string $output, ?Thumb $options = null): string; + public function tile(string $path, string $dir, ?Tile $options = null): Spritesheet; +} + +interface Packager extends Named, Observable +{ + public function open(string $path, ?Representation $as = null): static; + // callable more than once within a job, one input per call; $as tags an + // already encoded intermediate with the rung it represents + public function add(Representation ...$representations): static; // fused path: the ABR ladder + public function output(Output $output): static; + public function pack(string $dir): Package; +} + +interface Probe extends Named +{ + public function read(string $path): Info; // read(), not probe() β€” no Probe::probe() stutter + public function valid(string $path): bool; +} +``` + +`abstract class Adapter` supplies `getName()`, `on()`, `available()`, the probe seam, and the guards +(`source()`, `directory()`, `wrote()`) that put the backend's name in every failure message. +`trait Job` holds the chain state shared by encoding and packaging β€” inputs, ladder, output, format β€” +and the rule about where one job ends and the next begins: a **terminal** clears the job it finishes, +so `open()` has nothing of the previous one left to sweep away and drops only the adapter's listeners. +That is what lets the chain read in either order β€” `add($rep)->open($in)` keeps its ladder, where +clearing on `open()` instead discarded it silently, exactly the bug that made listeners outlive a job +in the first place. `trait Reads` holds the one `valid()` body the probes share; `trait Decimal` the +one number formatter four hierarchies wanted. + +`Packager` deliberately has no `format()` β€” a pure packager never encodes. `Adapter\FFmpeg` has it by +also implementing `Encoder`, which is exactly what makes the fused pass possible, and what +`Packager::pack()` branches on. + +Inside the two facade files the interfaces are aliased (`use Adapter\Encoder as EncoderAdapter;`) +because the facade classes carry the same names. Concrete adapters live *in* the `Adapter` namespace, +so they never import the interfaces and never need the alias. + +### Adapters + +Every adapter takes the same constructor, declared once on the base β€” no +positional slot ever means two different things: + +```php +__construct(?string $binary = null, ?int $timeout = null, int $threads = 0, ?string $level = null) +``` + +Defaults come from `NAME` / `BINARY` / `TIMEOUT` / `LEVEL` constants per class. Timeout `0` +disables the limit; `FFprobe` defaults to 30s, because reading metadata should +never hang. + +`$level` is the **display level** β€” how much the backend says about its work: +`Adapter::QUIET | ERROR | WARNING | INFO | VERBOSE | DEBUG`, quietest first, the +set enumerated in `Adapter::LEVELS`. It defaults to `ERROR`, because the +interesting output of an encode is the file it wrote rather than its commentary, +and it maps onto what each backend already understands (`ffmpeg -loglevel`, +`ffprobe -v`). Whatever the backend then prints arrives as `LOG` events, so +raising the level is how a consumer gets an explanation without inventing a +second logging concept. Separately, finished encode / pack / grab / tile jobs +print a green success line through the adapter's `Reporter` (the terminal +unless a facade was given another), and a failed backend command reports an +error before throwing β€” so a consumer sees the outcome without a listener. Probe stays quiet on success. `QUIET` silences both the status lines +and the log; `PROGRESS` fires at every level, because progress is structured +data rather than commentary. An unrecognised level throws +`Exception\Unsupported` at construction β€” while it is still cheap to say so β€” +and `level()` reports whichever level a backend ended up with. + +Like `available()` and `setProbe()`, `level()` lives on the abstract base rather +than on a capability interface: it is shared plumbing, not part of what it means +to be an encoder. Callers that hold an interface-typed backend and want it check +`instanceof Adapter` first, exactly as the facades do before pushing a probe. + +| Class | Binary | Timeout | Notes | +|---|---|---|---| +| `Adapter\FFmpeg` | `ffmpeg` | 0 | `Encoder`, `Packager`. Every invocation is prefixed `-y -hide_banner -loglevel {level}`. Fused encode+package; `Format\Copy` is available for single-file `encode()` remuxes, but adaptive packaging rejects it because every video representation is filtered to its requested size and filters cannot be combined with stream copy. `encode()` writes one rendition of the picture and keeps **every** audio track. `grab()` takes an exact `-ss` seek or an auto representative frame (`-vf thumbnail`), and finds embedded cover art via `Info::$cover`. `tile()` owns the adaptive-interval table | +| `Adapter\FFprobe` | `ffprobe` | 30 | `Probe`. `-v {level} -print_format json -show_format -show_streams -show_chapters`. The payload comes back on stdout and the commentary on stderr, so a raised level cannot corrupt the JSON | +| `Adapter\Mock` | none | β€” | All three, writing placeholder files. Ships in `src/` so consumers can test pipelines with no tools installed | + +Chosen by name when the choice comes from configuration, one factory per +capability so the return type is narrow enough to hand straight to a facade: + +```php +Adapter::encoder(string $name, ?string $binary = null, ?int $timeout = null, + int $threads = 0, ?string $level = null): Encoder +Adapter::packager(...): Packager Adapter::probe(string $name, ?string $binary = null, + ?int $timeout = null, ?string $level = null): Probe +``` + +Names are what `getName()` reports, and `Encoder::getName()`/`Packager::getName()` report them back, +which is how a backend chosen from config stays identifiable. + +### Config objects + +```php +// Representation β€” readonly value object, named-args friendly; no collection class, add() is variadic +new Representation(width: 1280, height: 720, video: 2538, audio: 128, name: '720p', + maxrate: null, bufsize: null); +// name defaults to "{height}p" β€” or "audio" for a rung with no frame size β€” and drives artifact +// naming, so it is restricted to letters, digits, underscores and hyphens: it becomes a filename, and +// it identifies the rendition to the muxer in a list where a comma or a space would end the entry. +// Anything else throws Exception\Input. +// Capped CRF by default: when unset, maxrate = video (hard bitrate ceiling, -maxrate:v:N) and +// bufsize = 2 Γ— maxrate (the smoothing window, -bufsize:v:N) β€” busy scenes can no longer spike +// above the advertised rendition bitrate and stall constrained viewers. + +// Format β€” codec preset + the GOP/quality knobs that gate ABR correctness; bitrate is NOT here +abstract class Format +{ + // Codecs are the preset's own; either is overridable at construction rather than by a + // setter, because a preset whose codec was swapped afterwards is a different preset: + // new X264(audio: 'libfdk_aac') + public function __construct(?string $video = null, ?string $audio = null); + + public function crf(int $crf): static; // -crf + public function bframes(int $count): static; // -bf (unset β‡’ the codec's own default) + public function keyframe(float $seconds): static; // -force_key_frames expr:gte(t,n_forced*S) + // unset β‡’ packaging uses the segment length, + // and an interval longer than a segment is + // rejected with Exception\Unsupported + public function params(array $params): static; // raw argv escape hatch (e.g. -dn -sn -vf …) + + // Getters: video()/audio() are the preset's defaults, codec()/sound() what this instance + // settled on, interval() the configured keyframe cadence, supports() the outputs it can be + // packaged into (which is what rejects VP9 in CMAF β€” Β§4). +} +Format\X264 // libx264 + aac; defaults keyint_min 25, g 250, sc_threshold 0 +Format\HEVC // libx265 + aac +Format\VP9 // libvpx-vp9 + libopus (DASH-only packaging β€” see Β§4) +Format\Copy // -c copy: remux one file with Encoder::encode(); adaptive packaging rejects stream copy + +// Output β€” base: segment(float $seconds = 6.0), manifests(bool = true), name(string = 'stream'), params(array) +// type() is the protocol this output is ('hls'|'dash'|'cmaf'), not a setter +Output\Hls ->segments(Hls::MPEGTS | Hls::FMP4) // default MPEGTS; fmp4 β†’ init segment + EXT-X-MAP, + // playlist VERSION 7 + ->init('init.mp4')->master('master.m3u8') + ->url($prefix) // written in front of every segment reference + ->flags([...]); // hls_flags; defaults to ['independent_segments'] +Output\Dash ->template(bool)->timeline(bool) // both false β‡’ explicit /; default true/true + ->init($pattern)->media($pattern)->manifest('manifest.mpd')->sets($definition); +Output\Cmaf ->master('master.m3u8')->manifest('manifest.mpd') + ->template(bool)->timeline(bool)->init($pattern); // one fMP4 set, both manifests β€” see Β§4 + // Extends Dash but inverts its defaults: template and timeline both start false, because + // explicit addressing is what keeps the two manifests in step over one segment set. + +// Thumb β€” thumbnail options +(new Thumb())->time(null) // null = auto: -vf thumbnail picks a representative frame; float = exact seek (s) + ->width(320) // px, height derived from aspect; 0 = source size + ->quality(2); // image format inferred from the output extension: .jpg / .png / .webp + +// Tile β€” sprite options +(new Tile())->interval(null) // null = adaptive by duration: 2s (<120s), 5s (<600s), 10s (<1800s), 20s (<3600s), 30s + ->width(160) // thumb width px; height derived from aspect ratio + ->grid(5, 5)->quality(3)->name('sprite')->vtt(true); +``` + +Multi-audio sources: every audio stream becomes a separate audio track in the package (HLS +`var_stream_map` audio group / DASH audio adaptation set), with the first flagged as default. A +language tag is what labels a track, not what qualifies it β€” untagged streams are carried unlabelled +rather than dropped, since a source with four untagged dubs still has four. Explicit track +selection/ordering can be added to `Output` later without breaking anything. + +### Result objects (all readonly) + +```php +Info // probe result β€” container + primary video/audio stream + full metadata + duration (s) Β· format Β· hasVideo Β· hasAudio Β· width Β· height Β· aspect ("16:9") Β· fps + fpsMode ('Constant'|'Variable') Β· videoCodec Β· videoFormat Β· videoProfile Β· videoBitrate (bps) + audioCodec Β· audioFormat Β· audioBitrate (bps) Β· sampleRate Β· audioTracks [{codec, language}] + tags [] // container-level descriptive metadata: title, artist, album, creation_time, encoder, … + tracks: Track[] Β· chapters: Chapter[] Β· rotation (degrees, from the display matrix, else null) + cover // stream index of embedded artwork, else null β€” a sound file reports no video but + // may still have a picture, which is what grab() reaches for + raw [] // full decoded backend payload β€” the genericness escape hatch + milliseconds(): int // duration as most catalogues store it + ratio(): ?string // aspect, derived from the frame size when the container omits one + tracks(string $type): Track[] // one stream kind, e.g. tracks(Track::SUBTITLE) + +Track // every stream, not just the primary: index Β· type ('video'|'audio'|'subtitle'|'data') + // codec Β· language Β· title Β· default (bool) Β· forced (bool) Β· tags [] +Chapter // start (s) Β· end (s) Β· title + +Package // pack() result β€” the structured source of truth (see Β§5) + variants(): Variant[] // per HLS variant / DASH AdaptationSet + segments(?string $variant): Segment[] // flat, optionally filtered + manifests(): Manifest[] // [] when Output::manifests(false) + files(): string[] // every artifact path, ready for upload + metadata(): array // MPD attrs (profiles, type, mediaPresentationDuration, + // maxSegmentDuration, minBufferTime) + HLS attrs (version, targetDuration) + duration(): float + variant(string $id): ?Variant // one rung by name + size(): int // total bytes across every segment + +Variant // id Β· type ('video'|'audio') Β· mimeType Β· codecs Β· bandwidth Β· width Β· height Β· sar + // sampleRate Β· language Β· timescale Β· startNumber Β· target Β· segments[] Β· playlist (path|null) + // resolution(): ?string β€” "1280x720", or null for an audio rung +Segment // variant Β· file Β· path Β· duration (0.0 for init) Β· init (bool) Β· number Β· size (bytes) +Manifest // type (Manifest::HLS | Manifest::DASH) Β· path Β· main (bool: master/mpd vs media playlist) + +Spritesheet // images(): string[] Β· cues(): Cue[] Β· vtt(): ?string Β· files(): string[] + // width(): int Β· height(): int β€” one thumbnail's size, for a consumer sizing its own CSS + // render(?callable $url): string β€” the WebVTT body, each sheet URL rewritten by $url +Cue // start Β· end Β· file Β· x Β· y Β· width Β· height + // render(?string $url): string β€” one cue as structured #xywh, URL freely rewritten + +Progress // percent (0–100, computed from $info->duration inside the adapter) Β· time Β· frame Β· fps Β· speed +``` + +### Exceptions + +```php +Exception extends \Exception // library base +Exception\Input // missing/unreadable/invalid source, bad chain state (e.g. encode() with 2 reps), + // and every rejected name, flag or pattern (Β§10.13) +Exception\Unsupported // the job cannot be expressed: a codec the output cannot carry (VP9 + CMAF), + // stream copy in an adaptive package, a keyframe interval longer than a + // segment, a segment container or display level that does not exist +Exception\Runtime // a backend command failed, or succeeded without writing what it promised β€” + // command(): full argv, output(): stderr tail, and the exit code as the + // exception code (Β§10.15) +``` + +### Deviations from v1 + +`save()`/`run()` are gone β€” `encode()`/`pack()` are the terminals, synchronous, returning results. +v1's single `Adapter` interface is replaced by three capability interfaces plus an abstract base, and +v1's one `Encoder` facade gains `Packager` as its sibling. `Representations` is dropped for a +variadic `add()`. `Representation` is a readonly constructor VO, not a setter builder. Config arrays +(`['timeout' => 0, 'ffmpeg.threads' => 4]`) become named constructor args. `Output` no longer carries +probe-derived state. Exceptions root in `\Exception` properly. + +## 3. Adapter matrix + +| Capability | `Adapter\FFmpeg` | `Adapter\FFprobe` | `Adapter\Mock` | +|---|:-:|:-:|:-:| +| encode (scale/bitrate/CRF, capped CRF) | βœ… | β€” | βœ… pretend | +| keeps every audio track through `encode()` | βœ… | β€” | β€” | +| pack HLS (MPEG-TS) | βœ… | β€” | βœ… pretend | +| pack HLS (fMP4) | βœ… | β€” | β€” | +| pack DASH `SegmentTemplate` | βœ… | β€” | β€” | +| pack DASH `SegmentList` | βœ… | β€” | β€” | +| pack CMAF (dual manifests) | βœ… **default** | β€” | β€” | +| one adaptation set / rendition per audio language | βœ… | β€” | β€” | +| fused single-pass encode+pack | βœ… | β€” | βœ… | +| progress events | βœ… `-progress pipe:1` | β€” | βœ… pretend | +| display level (`-loglevel` / `-v`) | βœ… | βœ… | βœ… withholds its log when quiet | +| probe / valid | via the injected probe | βœ… | βœ… | +| tags / chapters / tracks / rotation / cover | β€” | βœ… | partial | +| grab thumbnail / cover art | βœ… | β€” | βœ… pretend | +| tile sprite sheet + WebVTT cues | βœ… | β€” | βœ… pretend | +| external binary | `ffmpeg` | `ffprobe` | none | + +`Adapter\FFmpeg` is the default for both facades and the only backend that does real work; `Mock` +exists so a consumer's pipeline can be tested with no tools installed. + +**Deferred to a later release:** `Adapter\Shaka` (`packager` binary, `Packager` only). It is +deferred rather than designed out β€” the staged route it needs is implemented, documented (Β§1) and +unit-tested against a pure fake, so graduating it is one new class plus a factory arm. Its real +payoff is DRM, which is out of scope here. Its known limits, for whoever writes it: cannot encode, +no progress beyond start/end, `SegmentTemplate`/`SegmentBase` MPDs only (no `SegmentList`), and +fMP4-only for dual output. + +## 4. CMAF strategy + +`Output\Cmaf` = **one shared fMP4 segment set + both an HLS playlist tree and a DASH MPD referencing +the same `.m4s` files**. Default backend: the FFmpeg dash muxer. + +``` +-f dash -dash_segment_type mp4 +-seg_duration 6 -window_size 0 +-use_template 0 -use_timeline 0 # or 1/1 via template()/timeline() +-init_seg_name '{name}_init_$RepresentationID$.m4s' +-media_seg_name '{name}_chunk_$RepresentationID$_$Number%05d$.m4s' +-adaptation_sets 'id=0,streams=v id=1,streams=a' +-hls_playlist 1 -hls_master_name master.m3u8 +{dir}/{name}.mpd +``` + +Verified behavior (ffmpeg `dashenc.c` + formats docs, and reproduced live against the repo image's +ffmpeg 8.1.2 build: one command over a test source produced a `SegmentList`/`SegmentURL`-only MPD, +a VERSION-7 HLS master with an audio group, and `media_%d.m3u8` playlists with `EXT-X-MAP` β€” all +referencing one shared `.m4s` segment set. Re-checked on 8.1.2 when the pin moved off 7.1: every gap +below is still a gap, so every workaround is still load-bearing): + +- `-hls_playlist 1` writes a master plus one media playlist per stream that enumerate the **same + concrete segment files** the MPD references β€” it works in both `SegmentTemplate` and `SegmentList` + modes, because the HLS writer iterates real segment records, never templates. +- Media playlist names are **hardcoded** `media_%d.m3u8` (only the master name is configurable); + `Package` records the stream-index β†’ playlist mapping. +- HLS output requires mp4 segments (`-dash_segment_type mp4` is forced by `Output\Cmaf`); VP9-in-CMAF + is therefore unsupported. The rejection is not the adapter's: `Arguments` checks the output's + protocol against `Format::supports()` before a command is built, so `Format\VP9` + `Cmaf` throws + `Exception\Unsupported` from any backend, and a new codec declares its own limits in one place. +- `-single_file` is never used (byte-range playlists defeat per-segment addressing). +- The dash muxer never writes `EXT-X-INDEPENDENT-SEGMENTS`. Since every segment starts on a forced + keyframe the guarantee genuinely holds, so `pack()` post-appends the tag to the CMAF master β€” + faster quality switching in players and clean Apple validation for one line of string editing. +- Requires ffmpeg β‰₯ 4.1. The image builds and pins **8.1.2**, which is what CI tests against + (Β§10.7). 4.1 remains the documented floor; older lines are not exercised by CI. + +Why FFmpeg is the default, and why Shaka is deferred: one ffmpeg process does encode+package (no +intermediate files), progress reporting exists, `SegmentList` MPDs are supported, and ffmpeg is +already present wherever the encoder runs. Shaka packages beautifully but cannot transcode, reports +no progress, and only emits `SegmentTemplate`/`SegmentBase` MPDs β€” a consumer needing per-segment +URLs from Shaka would have to rely on `Package::segments()` parsed from its HLS playlists (which do +always enumerate concrete files) rather than its MPD. That is exactly the dynamic-manifest mode +described next. + +Standalone HLS fMP4 (`Output\Hls::segments(Hls::FMP4)`) for completeness: `-hls_segment_type fmp4`, +`-hls_fmp4_init_filename`, `.m4s` segment extension, auto `EXT-X-MAP` + `EXT-X-VERSION:7`, plus +`-hls_flags independent_segments`; `hls_allow_cache` is dropped in fMP4 mode (the tag was removed +from the HLS spec at protocol version 7). + +## 5. Manifest dual-mode + +Consumers split into two camps: those who **serve** the playlist files as-is, and those who **ignore** +them and rebuild manifests dynamically (e.g. to rewrite every segment URL to an authenticated, +per-segment endpoint). The library serves both with one rule: + +> `pack()` always parses what the packager produced into structured `Package` data. +> `Output::manifests(bool)` only controls whether the playlist *files* are kept. + +- **`manifests(true)`** (default): playlists stay on disk, listed in `manifests()` and `files()`. +- **`manifests(false)`**: playlists are parsed, then deleted; `manifests()` returns `[]`, and + `files()` contains only segments β€” nothing forces the consumer to host library-written playlists. + +The internal parsers (`Parser\M3u8`, `Parser\Mpd`) are the single source of `segments()`: + +- **M3u8**: `#EXT-X-MAP` β†’ init entry; `#EXTINF` + URI β†’ media entries; captures `TARGETDURATION`, + `VERSION`; master parse yields `BANDWIDTH`/`CODECS`/`RESOLUTION`/audio groups per variant. +- **Mpd**: `SegmentList` mode reads ``/`` plus + `timescale`/`duration`/`startNumber` directly; `SegmentTemplate` mode is **expanded to concrete + segment names** (substituting `$RepresentationID$`, `$Number%0Nd$`, `$Bandwidth$` and `$Time$`, + iterating `SegmentTimeline` runs) so `segments()` is complete regardless of addressing mode. + `$Time$` is the running total of the durations declared ahead of a segment, and an `S@t` restarts + that clock rather than being added to it. Every identifier a name is *allowed* to carry is resolved + here, which is the rule that keeps the writing and reading halves in step: `Name::template()` and + this parser accept exactly the same set, so a template the library approved cannot produce a + manifest pointing at filenames nothing on disk is called. `$ext$` is the one identifier accepted + but never met, because the muxer resolves it while writing the manifest; `$SubNumber$` is refused, + because subsegment addressing is neither written nor read. +- **Integrity check**: after parsing, every referenced segment is checked with `is_file()` and sized + with `filesize()`, and the size is what `Segment::$size` reports. A reference with nothing behind it + fails `pack()` with `Exception\Runtime` β€” a truncated run can never return a plausible-looking + `Package`. + +The same pattern applies to sprites: `Spritesheet::cues()` (structured +`start/end/file/x/y/width/height`) is +always populated; `Tile::vtt(false)` skips writing the `.vtt` file for consumers that generate their +own timeline documents with rewritten URLs. + +## 6. Appwrite Videos worker mapping + +The one concrete consumer driving requirements. Appwrite owns everything around the library; the +library owns everything between a local input file and local artifacts + structured results. + +| Concern | Owner | v2 call / note | +|---|---|---| +| Download / decrypt / decompress source from storage | **Appwrite** (Devices, OpenSSL, Zstd/GZIP) | produces the local `$in` path | +| Videos / profiles / renditions CRUD, DB, permissions | **Appwrite** | β€” | +| **Subtitles end to end** β€” extracting embedded tracks, converting to WebVTT, storing them, referencing them from served manifests | **Appwrite** | the library only *reports* them: `Info::tracks(Track::SUBTITLE)` names each one with its language. Nothing is written, and `pack()` never emits a subtitle rendition | +| Queue, worker loop, statuses, realtime events, usage metrics, cleanup | **Appwrite** | β€” | +| Probe fields for the `videos` document | **Library** | `$encoder->probe($in): Info` β€” duration (seconds; Γ— 1000 for the ms the DB stores), format, width/height, aspect, fps + fpsMode, video codec/format/profile/bitrate, audio codec/format/bitrate/sampleRate, hasVideo/hasAudio. `Adapter\FFprobe` is the one probe; a worker holding only a `Packager` can call `probe()`/`valid()` on it too | +| Descriptive metadata (tags, chapters, embedded track list) | **Library** | `Info::tags` / `chapters` / `tracks` β€” e.g. title, creation time, embedded-subtitle detection, rotation | +| Validity gate before encode | **Library** | `$encoder->valid($in)` | +| Encode + package one rendition (one `Representation` per job) | **Library** | `$packager->open($in)->format((new X264())->crf(22)->bframes(3)->keyframe(2.0)->params(['-dn','-sn']))->add($rep)->output($output->segment(6))->on(Packager::PROGRESS, $cb)->pack($outDir)` | +| DASH with per-segment document-id URLs | **Library** | `(new Dash())->template(false)->timeline(false)` β†’ explicit `` (today's load-bearing `use_template 0 use_timeline 0`) | +| Segment rows + rendition `metadata` JSON | **Appwrite DB ← Library** | `Package::segments()` / `variants()` / `metadata()` replace the worker's `parseHlsMaster` / `parseHlsPlaylist` / `parseMpd` (~200 lines deleted from the worker) | +| Upload artifacts to storage | **Appwrite** | iterate `Package::files()` / `Spritesheet::files()` | +| Progress β†’ DB + realtime | **Appwrite** | `Packager::PROGRESS` callback; worker keeps its `percent % 3 === 0` throttle | +| Dynamic manifest serving (phtml templates) | **Appwrite** | library playlists skipped via `manifests(false)` β€” never served | +| Sprite timeline (160px thumbs, 5Γ—5 jpeg, adaptive interval, WebVTT `#xywh`) | **Library** | `$encoder->tile($in, $dir)` β€” `Tile` defaults match today's table and filter; Appwrite rewrites URLs from `cues()` to its preview endpoints | +| Poster / thumbnail image | **Library** | `$encoder->grab($in, $out, (new Thumb())->time($t))` β€” auto-picks a representative frame when no time is given; resize/crop at serve time stays Appwrite-side | + +## 7. Source layout + +PSR-4 `Utopia\Video\` β†’ `src/Video`, flat and small: + +``` +src/Video/ +β”œβ”€β”€ Encoder.php Β· Packager.php the two facades +β”œβ”€β”€ Adapter.php abstract base: identity, listeners, config, guards +β”œβ”€β”€ Adapter/ +β”‚ β”œβ”€β”€ Named.php Β· Observable.php shared contracts (Observable owns PROGRESS/LOG) +β”‚ β”œβ”€β”€ Encoder.php Β· Packager.php Β· Probe.php capabilities +β”‚ β”œβ”€β”€ Job.php Β· Reads.php @internal traits +β”‚ β”œβ”€β”€ FFmpeg.php Encoder + Packager +β”‚ β”œβ”€β”€ FFprobe.php Probe +β”‚ └── Mock.php all three (no binary, for tests) +β”œβ”€β”€ Format.php Β· Output.php Β· Representation.php Β· Thumb.php Β· Tile.php config +β”œβ”€β”€ Format/ X264.php Β· HEVC.php Β· VP9.php Β· Copy.php +β”œβ”€β”€ Output/ Hls.php Β· Dash.php Β· Cmaf.php +β”œβ”€β”€ Info.php Β· Track.php Β· Chapter.php Β· Package.php Β· Variant.php results +β”œβ”€β”€ Segment.php Β· Manifest.php Β· Progress.php Β· Spritesheet.php Β· Cue.php +β”œβ”€β”€ Exception.php Β· Exception/ Input.php Β· Unsupported.php Β· Runtime.php +β”œβ”€β”€ Reporter.php where status lines go +β”œβ”€β”€ Reporter/ Console.php Β· Silent.php the terminal, or nowhere +β”œβ”€β”€ Name.php @internal guards for caller-chosen strings +β”œβ”€β”€ Decimal.php @internal shared number formatter +β”œβ”€β”€ Process.php @internal proc_open + stream_select wrapper +β”œβ”€β”€ Arguments.php Β· Arguments/ Hls.php Β· Dash.php Β· Cmaf.php @internal argv builders +└── Parser/ M3u8.php Β· Mpd.php @internal manifest β†’ Package readers +``` + +Tests mirror the house split: `tests/Video/Unit` (no binaries needed) and +`tests/Video/E2E` (drives a real encoder), exposed as the `unit` and `e2e` +PHPUnit suites. + +Exception names avoid colliding with the config and runner classes they sat beside: +`Exception\Unsupported` (was `Output`, which clashed with the `Output` config class) and +`Exception\Runtime` (was `Process`, which clashed with the process runner). That removed a forced +`as` alias from 15 files. + +`composer.json`: `"require": {"php": ">=8.2", "ext-dom": "*", "ext-json": "*", +"utopia-php/console": "^0.2"}` β€” `ffmpeg`/`ffprobe` are external binaries, and console only prints the +status line, behind the `Reporter` seam. `ext-swoole` is a **suggest**, never a requirement: the +library only has to keep yielding inside a coroutine, which is a property of how it is written rather +than something it imports (Β§10.17). `swoole/ide-helper` sits in `require-dev` for editors β€” +phpstan needs no help, since it resolves the Swoole symbols from stubs bundled in its own phar. +The `php-ffmpeg/php-ffmpeg` dependency is +dropped β€” +v1 already bypassed its command model, and it was the root of the vendor-type leaks (`Format` +extending `DefaultVideo`, `Probe` exposing `StreamCollection`) and the duration-less progress +listeners. `ffmpeg -progress pipe:1 -nostats` emits stable machine-readable `key=value` blocks; +`ffprobe -print_format json` is one `json_decode` away from `Info`. + +## 8. Migration + +| Today | v2 | +|---|---| +| `aminyazdanpanah/php-ffmpeg-video-streaming` fork: `$media->hls()/dash()` + `setAdditionalParams([...])` | `Adapter\FFmpeg` fused pass; extra args β†’ `Format::params()`; segment duration β†’ `Output::segment()` | +| `use_template 0` / `use_timeline 0` fork patch | first-class: `Output\Dash::template(false)->timeline(false)` | +| `Mhor\MediaInfo` + `FFProbe::isValid` | `Adapter\FFprobe` β†’ `Info`; `valid()` on either facade. MediaInfo is gone: two probes meant two field-mapping surfaces to keep honest for no gain over `Info::$raw` | +| `captioning/captioning` `SubripFile->convertTo('webvtt')` | **stays in the worker.** Subtitle conversion is not video work, needs no encoder, and the worker already owns the files | +| Hand-built sprite ffmpeg command in the worker | `Encoder::tile()` β€” same `select/scale/tile` filter, `-qscale:v 3`, `sprite%d.jpg` naming, adaptive interval table now inside the adapter | +| Poster frames: none today (previews are Imagick-cropped sprite cells at serve time) | first-class `Encoder::grab()` β€” dedicated representative-frame or timestamped poster; sprite-cell cropping for scrubbing stays consumer-side | +| Probe limited to technical fields | `Info` adds `tags`, `chapters`, `tracks` (full stream list incl. embedded subtitles), `rotation`, `cover` | +| Worker-side `parseHlsMaster` / `parseHlsPlaylist` / `parseMpd` | `Parser\M3u8` / `Parser\Mpd` β†’ `Package::variants()/segments()/metadata()` | +| php-ffmpeg stderr-regex progress (duration never wired β†’ dead listeners) | `-progress pipe:1 -nostats` β†’ `Progress` with a real `percent` | +| v1 `save()`/`run()`, `Representations`, `Output` mutated by the adapter | removed / restructured (Β§2) | +| v1 HLS multi-rep bug: N reps β†’ N positional outputs, each re-encoding everything and rewriting `master.m3u8` (last wins) | one command: video mapped once **per rung**, options indexed `:v:N`, a single `-var_stream_map "v:0,agroup:aud,name:360p v:1,… a:0,agroup:aud,default:yes"`, one `master_pl_name`, one `%v` output pattern | +| Raw `exec()` / `Console::execute` shell strings | `Process` (@internal): argv arrays (no shell), line callbacks, timeout, `Exception\Runtime` with command, stderr tail and exit code | + +Consumers migrate incrementally: `probe()` is drop-in first, `tile()`/`grab()` second, `pack()` last +(it changes the parsing/DB-write side from hand-rolled to `Package`). + +## 9. Test plan + +**Unit β€” no binaries required** + +- `Arguments\{Hls,Dash,Cmaf}`: argv assertions on the built command β€” multi-rep single-command shape + (the v1 regression), SegmentList vs SegmentTemplate flags, fMP4 flag set (`independent_segments`, + no `allow_cache`), audio-only inputs dropping all video args, single-file `Format\Copy` remux and + adaptive-package rejection, capped-CRF + defaults (`-maxrate:v:N`/`-bufsize:v:N` derived when unset), multi-audio `var_stream_map` built + from language tags with untagged tracks still carried, and the keyframe cadence falling back to the + segment length (with an interval longer than a segment rejected). +- `Parser\{M3u8,Mpd}` on fixture manifests: TS + fMP4 (`EXT-X-MAP`) playlists, `SegmentList` MPDs, + `SegmentTemplate` expansion (`$Number%05d$`, `$Bandwidth$`, `$Time$`, `SegmentTimeline` `S@t/@d/@r` + runs, including a timeline whose `S@t` restarts the clock rather than advancing it). +- Probe JSON fixtures (ffprobe payloads): every `Info` field incl. `tags`, `chapters`, `tracks` + (multi-track with embedded subtitles), `rotation`, `cover`, audio-only, missing-field defaults. +- The facades against fakes: `Encoder` delegation, `Packager`'s fused-vs-staged choice, staged + progress climbing once 0β†’100 across every rung, listener forwarding, `open()` forgetting the last + job's config, and listeners surviving it (registered before `open()` or after, dropped by `off()`). The staged half runs against a **pure `Adapter\Packager` fake** β€” that is what keeps the v2 + packager seam continuously proven while no pure packager ships. +- `Thumb` defaults and grab argv: exact `-ss` seek vs `-vf thumbnail` auto mode, scale math, + image format from output extension, cover-art (`attached_pic`) mapping. +- `Tile` adaptive-interval table; cue geometry math (`x/y` from grid position); every `Tile` and + `Thumb` knob that cannot work (zero interval, unscalable width, empty grid, off-scale quality) + rejected as `Exception\Input` at the setter rather than as a division by zero mid-job. +- Config validation: `Representation` bounds, `Format` knobs, unsupported `Output` combos throwing + `Exception\Unsupported` β€” including a mixed-aspect ladder for DASH/CMAF, whose message names the + offending rungs (the muxer says only "Conflicting stream aspect ratios", after it has started). +- Config immutability: every setter on `Format`, `Output`, `Thumb` and `Tile`, table-driven β€” + the return is a new instance, the receiver keeps its value, the copy carries the new one β€” plus + the sharing scenario itself (two holders of one preset configuring it independently). An E2E + (`CoroutineTest`) then proves the same thing under a real scheduler: three coroutines, one + `Encoder` each, off one shared `Thumb` under `SWOOLE_HOOK_ALL`, asserting that all three stills + were written, that none of the jobs failed, and that the shared `Thumb` came out unmutated. The + image ships ext-swoole so this runs rather than skips (Β§10.17) β€” it is the only test that shows + `proc_open` and `stream_select` genuinely yielding instead of deadlocking. +- `Process`: fake scripts exercising exit codes, timeouts (including the stderr tail surviving the + termination that ends them), stdout/stderr line callbacks, progress-block parsing, and a stream + that never sends a line break. Plus how a run ended: a signalled command reports 128 + its signal, + a command that ignores `SIGTERM` is killed after the grace period and says so, and a command that + will not stop printing fails on the output cap instead of exhausting memory. +- Names that would leave the directory they were given or break the argument they sit in + (`Representation`, `Output::name()`, manifest and playlist filenames, `Tile::name()`), and the + settings that reach argv without being filenames (`Hls::url()`, `Hls::flags()`, `Dash::sets()`), + each held to the rule its own use needs (Β§10.13). +- Job lifecycle on the adapters: a ladder or format described before `open()` survives it, a terminal + clears the job it finished, and the next `open()` therefore starts from nothing rather than + inheriting it. +- Status lines: reported through a `Reporter`, the terminal by default, whichever one a facade is + given reaching every backend, and a quiet backend reporting nothing. +- Display level: every backend starts at `ERROR`; a level can be raised or silenced; the factories + pass it through and omitting it keeps each backend's default; an unrecognised level is rejected at + construction; the level reaches the built command; a quiet backend still reports progress. + +**Integration β€” ffmpeg required (generated `testsrc`+`sine` fixture, Docker CI)** + +- HLS TS and fMP4: master lists **all** variants (multi-rep regression), media playlists + + segments exist. +- DASH SegmentList: `` present, no ``. +- CMAF: MPD, `master.m3u8`, and `media_0.m3u8` all reference the **same** `.m4s` files; the master + carries the post-appended `EXT-X-INDEPENDENT-SEGMENTS` tag. +- HEVC end-to-end (HLS fMP4, DASH, CMAF) and VP9 end-to-end (DASH) β€” both codecs are first-class; + impossible combos (VP9 + CMAF/HLS) throw `Exception\Unsupported`. +- `Package::segments()` sizes match the files on disk; `manifests(false)` leaves no playlist files; + `files()` uploads cleanly. +- Progress fires with ascending `percent` reaching 100; audio-only input packs; `encode()` + produces a single playable file; `tile()` sprite count and cue math match fixture duration, and + tiling a shorter source into a directory that already holds a longer run's sheets reports only + the sheets it wrote. +- `grab()` writes a decodable image at the requested size (timed and auto modes); cover art is + extracted from an audio fixture with embedded artwork; `probe()` reads a chaptered fixture. +- The display level genuinely changes what ffmpeg reports: a clean encode emits no `LOG` lines at + `QUIET` or at the default `ERROR` (there is nothing wrong to report) and several at `VERBOSE`. +- Several audio languages: one rendition/adaptation set each, `LANGUAGE=` present on every HLS and + CMAF audio rendition, exactly one `DEFAULT=YES`; `encode()` carries all four dubs into MP4, MKV, + MOV, WMV and AVI. +- Subtitles are reported by `probe()` and **never packaged**: a source with three subtitle tracks + yields zero subtitle variants, no `*.vtt` beside the media, and no `TYPE=SUBTITLES` in the master. + +**Breadth** β€” a generated library of 25 sources under `tests/samples/in` (10 containers, 7 video and +5 audio codecs, multi-audio, multi-subtitle, anamorphic/VFR/rotated/portrait/tiny/silent, chapters, +audio-only, embedded artwork) put through probe, grab, tile and packaging, with results left in +`tests/samples/out`. Built by `composer samples`, gitignored rather than committed. + +**Parity** β€” the argv assertions above are written against the captured legacy production command +shape, so a rung, flag or map that v1 emitted cannot quietly disappear. + +Every test prints one line naming what it checks and whether it passed β€” PHPUnit's testdox output, +enabled in `phpunit.xml` so it applies to every run rather than needing a flag. Method names are +written to read as sentences; where a name cannot (a leading acronym, or a digit testdox would split, +as in `Mp4` β†’ "Mp 4"), a `@testdox` annotation supplies the wording instead. A run therefore reads as +a description of what the library does. + +CI: GitHub Actions matrix (PHP 8.2/8.3/8.4 against the pinned ffmpeg 8.1.2 the image builds), phpstan +at level **max** over `src` and `tests`, pint (`psr12`). All three run on pull requests and on pushes +to `main`. +The repo Dockerfile builds ffmpeg **from source**, pinned via the `FFMPEG_VERSION` build arg +(GPL, with libx264/x265/vpx/opus/lame/theora/vorbis β€” exactly the `Format` presets and the sample +library's codecs), in a stage that shares the runtime base image so codec shared libraries match +ABI-for-ABI. ext-swoole is built the same way, pinned via `SWOOLE_VERSION`, in a stage of its own so +it compiles alongside ffmpeg rather than after it. Both are then *asserted* in the final stage, not +merely printed: a swoole that failed to load would otherwise turn the coroutine suite back into a +skip, and a skipped test says nothing about being absent. Building with `TESTING=true` installs dev +dependencies so `phpunit` runs in-image. + +## 9a. Several audio languages, and where subtitles stop + +A source with more than one tagged audio track yields one selectable rendition per language in every +output: `EXT-X-MEDIA:TYPE=AUDIO` rows for HLS, one `` per language for DASH. Video +rungs remain alternatives of one another and share a set; languages are separate choices and must not +β€” collapsing them into one set was a real bug, caught only by a four-language sample. + +Two things the muxers get wrong that are corrected on the way out: + +- The dash muxer's HLS output omits `LANGUAGE` from its audio renditions, so a CMAF package would + offer several dubs with nothing to tell them apart. They are named from the probed track order. +- Neither muxer writes `EXT-X-INDEPENDENT-SEGMENTS` where it counts: the dash muxer never writes it, + and the hls muxer writes it into the video playlist but not the audio one. It is declared once in + the master instead, which covers every rendition. + +`encode()` produces one rendition of the picture but keeps **every audio track**. This matters beyond +convenience: the staged packaging path builds its intermediates with `encode()`, so dropping tracks +there would silently cost every dub but the first before a pure packager ever saw the file. + +**Subtitles are read, not packaged.** `probe()` reports every embedded subtitle track β€” index, codec, +language, title, default/forced flags β€” and that is where the library stops. Extracting each track, +converting it, storing it and referencing it from a served manifest all belong to the application +that owns those files; for Appwrite that is the Videos worker (Β§6). So `pack()` emits no subtitle +rendition, writes no WebVTT, and leaves no `TYPE=SUBTITLES` in a master, whatever the source +contained. There is a regression test that says exactly that against a three-subtitle source. + +(The `.vtt` that `tile()` writes is a thumbnail index, not a subtitle track. Different thing, same +file extension.) + +Container limits worth knowing when reading `Info`: AVI cannot record a per-stream language at all, +and AVI/WMV report French as the bibliographic `fre` where MP4, MOV and Matroska keep `fra`. The +library reports what the file says; `Info::$raw` has the untouched payload. + +## 10. Decisions + +All former open questions, resolved with the maintainer (2026-07-26): + +1. **Package identity**: `utopia-php/video`, renamed from `utopia-php/streaming` alongside the + namespace (2026-07-31). +2. **Namespace**: `Utopia\Video` (2026-07-31), renamed from `Utopia\Streaming`. The library is + named for its subject rather than one of the things it does with it β€” packaging for adaptive + streaming is a feature, not the whole scope, which already covers probing, encoding, stills and + sprite timelines. The public classes stay `Encoder` and `Packager`, matching the `main` branch's + shape (`new Encoder($adapter)`), so they read as `Video\Encoder` and `Video\Packager`. + "Streaming" survives only where it is the domain term β€” adaptive streaming, live streaming, and + the expansion of HLS. +3. **HEVC / VP9**: full first-class support β€” both codecs integration-tested end-to-end (Β§9); + impossible combos (VP9 in CMAF/HLS) fail loudly with `Exception\Unsupported` instead of degrading. +4. **Rate control**: capped CRF by default β€” `Representation` carries optional `maxrate`/`bufsize`, + auto-derived (`maxrate = video`, `bufsize = 2 Γ— maxrate`) when unset (Β§2). +5. **`EXT-X-INDEPENDENT-SEGMENTS`**: post-appended to CMAF masters (Β§4) β€” the guarantee genuinely + holds and the edit costs one line. +6. **Multi-audio**: every audio stream becomes a track, labelled with its language tag where it has + one, first one is default (Β§2); explicit selection is a later additive option. +7. **ffmpeg version**: pin the newest release the suite actually passes on, via the `FFMPEG_VERSION` + build arg; 4.1 is the documented floor. Revised 2026-08-23 from "the most mature LTS line" (7.1): + with no consumers yet there is nothing to be conservative for, so the pin was walked forward + empirically instead. **8.1.2** is where it stopped. 9.0.1 does not work: ffmpeg **segfaults** + (SIGSEGV) decoding an MPEG-TS file that ffmpeg itself wrote, which took out packaging and stills + for that source while every other container passed. Nothing in our argv is implicated β€” a plain + `-vf scale` over the same input crashes just as readily, stderr is empty at `-loglevel error`, and + the 9.0 changelog removes nothing this library emits (`-psnr`, `-map_channel` and the old HLS + *protocol* handler, none of which we use). So the ceiling is upstream, not ours; revisit on 9.0.2. + The cost of moving lines is never the version string β€” it is re-verifying Β§4, because the CMAF + strategy is built on observed muxer gaps that a new release could quietly close. +8. **`Adapter\Shaka`**: deferred to a later release rather than shipped as a stub β€” an untested + adapter for an absent binary is a liability, and DRM (the reason Shaka exists here) is out of + scope. What ships instead is the *seam*: the staged route, exercised by a pure-packager fake, so + graduating Shaka is one new class plus a factory arm and no facade change (Β§1, Β§3). +9. **Batch stills**: `grab()` stays one-frame-per-call; a `Thumb::count(n)` single-pass mode is + added only if looping proves a hot path. +10. **Display level** (2026-07-31): one `LEVEL` constant per backend, defaulting to `ERROR`, + overridable per instance and through the factories, mapped onto each binary's own verbosity flag. + The library does not filter what a backend prints β€” it raises or lowers what the backend says and + forwards all of it as `LOG`, so there is no second logging concept to keep in step. +11. **Subtitles and MediaInfo are out of scope** (2026-07-31): the Videos worker already owns the + subtitle files, and their conversion needs no encoder; a second probe meant a second field-mapping + surface to keep honest for no gain over `Info::$raw`. `probe()` still *reports* subtitle tracks + (Β§9a) β€” reporting and packaging are different jobs. +12. **Status lines behind a `Reporter`**: printing suits a command line and nothing else, so the + destination is an interface β€” `Reporter\Console` by default, `Reporter\Silent` for anywhere stdout + belongs to something else, and two methods for anyone bridging to a PSR-3 logger. Handed to + backends by the facades the same way the probe is, so one job cannot report from half of itself. + The display level still decides *whether* a line is reported; this decides only where it goes. +13. **Names are validated, not trusted**: a representation, output, manifest or sprite name becomes a + filename in a directory the caller named and, for renditions, an entry in `-var_stream_map`. Both + uses break on separators, commas and spaces, so they are rejected at construction with + `Exception\Input` rather than joined onto a path and hoped for. The same reasoning reaches the + settings that are not filenames but still end up in the argument list, each with the rule its own + use needs rather than the strictest one going: `Hls::url()` is a URL or path prefix, so it may + carry separators but no whitespace (which would end the URI it is written in front of); + `Hls::flags()` are single words of muxer vocabulary; `Output\Dash::sets()` is free-form, because + the option's syntax owns its shape β€” spaces separate its groups and `descriptor=` carries XML β€” so + only what would break the argument list is refused. Nothing here is shell quoting: `Process` passes + argv arrays. What is being prevented is a value that reads as *another option*, which is why none + of the three may begin with a dash. +14. **Keyframes follow segments**: a packaging job without an explicit `keyframe()` takes the segment + length as its cadence, and an interval longer than a segment is rejected. Cuts land on keyframes, + so the two were never independent β€” leaving them so meant a ladder built with the defaults could + be segmented wherever the encoder happened to place a keyframe. +15. **How a command ended is reported, not guessed** (2026-08-23): a process that has to be stopped is stopped in + stages β€” `SIGTERM`, `GRACE` seconds to explain itself, then `SIGKILL` β€” and the reason travels with + the failure. `Exception\Runtime` carries the exit code as its own code, using the shell's + 128 + signal convention for anything that was signalled (143 for `SIGTERM`, 137 for `SIGKILL`), so a + timeout is distinguishable from a command that merely failed. Two things follow from the same + reasoning. Every pipe is drained during the grace period, not just stderr: a command blocked on a + full stdout pipe cannot act on the signal it was sent, so draining only the pipe we wanted to read + would hang exactly the case the grace period exists for. And collected output is bounded (`OUTPUT`, + 8 MiB) rather than accumulated indefinitely β€” a backend stuck printing would otherwise exhaust + memory instead of failing, and a command that has produced megabytes of output where a JSON payload + or a filename was expected has already gone wrong. +16. **A result describes the run that produced it** (2026-08-23): `tile()` clears the numbered sheets its own naming + would claim before writing any, so a job that produces fewer sheets than the last one cannot report + the previous run's leftovers among its `images()`. Only names this job could have written are + removed, matched exactly rather than by prefix, so an unrelated file sharing the directory survives. +17. **Swoole is a test dependency, never a library one** (2026-08-23): following the house pattern from + `utopia-php/database` and `utopia-php/queue` β€” `ext-swoole` declared as a `suggest`, + `swoole/ide-helper` in `require-dev`, and the extension itself built from pinned source in the test + image. The library imports nothing from Swoole and must not: coroutine-safety here is a property of + construction (immutable config, one facade per coroutine, and a `Process` built on `proc_open`, + `stream_select` and `usleep`, all of which yield under the hooks). But a property nobody exercises + is a claim, not a guarantee, and this one matters more than most because the consumer that drives + these requirements runs Swoole workers. The extension was previously absent, so the one test that + proves it skipped itself on every run. Installing it costs a build stage that compiles in parallel + with ffmpeg, and buys the suite its first clean run with no skips at all. + +## 11. Future: live streaming + +Live is deliberately out of v2 β€” but nothing in the design blocks adding it. The seams reserved: + +- **Inputs are just strings.** ffmpeg accepts `rtmp://`, `srt://`, or device inputs in the same + argv slot as a file path, so `open('rtmp://…')` needs no signature change β€” only `valid()` + behaves differently (a live source has no file size or fixed duration to check). +- **Live is a new verb, not a mode.** `pack()` stays the synchronous VOD terminal returning a + complete `Package`. Live packaging is a long-running process with a rolling segment window, so it + arrives as a new terminal on the same chain β€” e.g. `start(): Live`, a handle exposing `stop()` + and rolling per-segment events β€” leaving every existing v2 signature untouched. +- **Output configs are open.** `Hls`/`Dash`/`Cmaf` are fluent option bags; live knobs are additive, + non-breaking methods (`live()`, `window(n)`, `latency(s)` mapping to `-window_size`, `-ldash`, + LL-HLS partial segments). +- **The plumbing already fits.** The internal `Process` wrapper is built for indefinitely-running + commands (`timeout: 0`) with streaming line callbacks; the `Observable::PROGRESS`/`LOG` event + surface generalizes to per-segment events; `Parser\M3u8` handles rolling playlists (they are + ordinary playlists whose entries drop off). +- **Backends line up.** The ffmpeg hls/dash muxers support live natively, and so does Shaka β€” whose + live mode pairs naturally with the DRM story that would bring it in (Β§10.8). + +The one rule that keeps this live-safe: **no adapter may assume an input is a seekable local file**, +except the inherently-VOD verbs (`probe`, `tile`, `grab`). diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..256e8d6 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: max + paths: + - src + - tests + excludePaths: + - vendor diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..b7cc597 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,26 @@ + + + + + + ./tests/Video/Unit + + + ./tests/Video/E2E + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..ea5e72c --- /dev/null +++ b/pint.json @@ -0,0 +1,3 @@ +{ + "preset": "psr12" +} diff --git a/src/Video/Adapter.php b/src/Video/Adapter.php new file mode 100644 index 0000000..2ebd2d4 --- /dev/null +++ b/src/Video/Adapter.php @@ -0,0 +1,440 @@ + + */ + public const LEVELS = [ + self::QUIET, + self::ERROR, + self::WARNING, + self::INFO, + self::VERBOSE, + self::DEBUG, + ]; + + /** + * The slug this backend answers to. Matches the factory keys. + * + * Annotated because a constant reached through static:: is only as typed as + * the subclass redeclaring it. PHP 8.3 typed constants would say it in the + * language; 8.2 is the floor here. + * + * @var string + */ + protected const NAME = ''; + + /** + * The binary it drives, or an empty string when it needs none. + * + * @var string + */ + protected const BINARY = ''; + + /** + * Seconds a command may run. 0 disables the limit. + * + * @var int + */ + protected const TIMEOUT = 0; + + /** + * How much this backend says about its work unless told otherwise. + * + * Errors only, because the interesting output of an encode is the file it + * wrote, not its commentary. Raise it when something needs explaining: + * whatever the backend then prints arrives as LOG events. + * + * @var string + */ + protected const LEVEL = self::ERROR; + + protected readonly string $binary; + + protected readonly int $timeout; + + protected readonly int $threads; + + protected readonly string $level; + + /** + * Who reads media details for this adapter. + * + * Deliberately not readonly, and deliberately without a default: an adapter + * that built its own probe could not be told to share the caller's, which + * is exactly the bug this replaces. + */ + protected ?Probe $prober = null; + + /** + * Where status lines go. + * + * Resolved late for the same reason as the probe: a caller replacing the + * destination after construction has to win, and a default built eagerly + * would tie every adapter to the terminal whether or not it is on one. + */ + protected ?Reporter $reporter = null; + + /** @var array> */ + protected array $listeners = []; + + /** Remembered answer to available(), which costs a process to work out. */ + private ?bool $available = null; + + /** + * @param string|null $level One of the constants above; null keeps the default. + * + * @throws Unsupported + */ + public function __construct( + ?string $binary = null, + ?int $timeout = null, + int $threads = 0, + ?string $level = null, + ) { + $this->binary = $binary ?? static::BINARY; + $this->timeout = $timeout ?? static::TIMEOUT; + $this->threads = $threads; + $this->level = $level === null ? static::LEVEL : self::graded($level); + } + + public function getName(): string + { + return static::NAME; + } + + /** + * How much this backend says about its work. + */ + public function level(): string + { + return $this->level; + } + + /** + * Rejects a level no backend understands, while it is still cheap to say so. + * + * @throws Unsupported + */ + private static function graded(string $level): string + { + if (! \in_array($level, self::LEVELS, true)) { + throw new Unsupported( + 'No display level named "'.$level.'"; expected one of '.\implode(', ', self::LEVELS) + ); + } + + return $level; + } + + /** + * The encoder a name refers to. + * + * One factory per capability rather than one returning `Adapter`, so the + * result is narrow enough to hand straight to a facade without a cast. Names + * are the ones getName() reports, which is what makes a backend chosen from + * configuration identifiable again afterwards. + * + * @throws Unsupported + */ + final public static function encoder( + string $name, + ?string $binary = null, + ?int $timeout = null, + int $threads = 0, + ?string $level = null, + ): Encoder { + return match ($name) { + FFmpeg::NAME => new FFmpeg($binary, $timeout, $threads, $level), + Mock::NAME => new Mock(level: $level), + default => throw new Unsupported('No encoder named "'.$name.'"'), + }; + } + + /** + * @throws Unsupported + */ + final public static function packager( + string $name, + ?string $binary = null, + ?int $timeout = null, + int $threads = 0, + ?string $level = null, + ): Packager { + return match ($name) { + FFmpeg::NAME => new FFmpeg($binary, $timeout, $threads, $level), + Mock::NAME => new Mock(level: $level), + default => throw new Unsupported('No packager named "'.$name.'"'), + }; + } + + /** + * @throws Unsupported + */ + final public static function probe( + string $name, + ?string $binary = null, + ?int $timeout = null, + ?string $level = null, + ): Probe { + return match ($name) { + FFprobe::NAME => new FFprobe($binary, $timeout, level: $level), + Mock::NAME => new Mock(level: $level), + default => throw new Unsupported('No probe named "'.$name.'"'), + }; + } + + /** + * Tell this adapter which probe to read media details with. + * + * Called by the facade so that one probe serves the whole job. + */ + public function setProbe(Probe $probe): void + { + $this->prober = $probe; + } + + /** + * Tell this adapter where to put its status lines. + * + * Called by the facade so that one destination serves the whole job. Pass a + * Reporter\Silent to keep the library off stdout entirely. + */ + public function setReporter(Reporter $reporter): void + { + $this->reporter = $reporter; + } + + /** + * Register a listener. See Adapter\Observable::PROGRESS and ::LOG. + * + * @param callable $listener + */ + public function on(string $event, callable $listener): static + { + $this->listeners[$event][] = $listener; + + return $this; + } + + /** + * Whether this backend's binary can actually be run here. + * + * Answered by running the binary, so the answer is remembered: a caller + * checking before each of a hundred jobs should not pay for a hundred + * processes to be spawned. + */ + public function available(): bool + { + return $this->available ??= $this->binary === '' || Process::exists($this->binary); + } + + /** + * Resolved late, so a probe handed over after construction still wins. + */ + protected function prober(): Probe + { + return $this->prober ??= new FFprobe(); + } + + /** + * Resolved late, so a reporter handed over after construction still wins. + */ + protected function reporter(): Reporter + { + return $this->reporter ??= new Reporter\Console(); + } + + /** + * Drop the listeners for one event, or every listener when given nothing. + */ + public function off(?string $event = null): static + { + if ($event === null) { + $this->listeners = []; + } else { + unset($this->listeners[$event]); + } + + return $this; + } + + protected function emit(string $event, mixed $payload): void + { + foreach ($this->listeners[$event] ?? [] as $listener) { + $listener($payload); + } + } + + /** + * Drop every listener, because a new job is starting. + */ + protected function forget(): void + { + $this->off(); + } + + /** + * @throws Input + */ + protected function source(string $path): string + { + if (! \is_file($path)) { + throw new Input($this->getName().': source "'.$path.'" does not exist'); + } + + return $path; + } + + /** + * @throws Runtime + */ + protected function directory(string $path): string + { + $path = \rtrim($path, '/'); + + if (! \is_dir($path) && ! @\mkdir($path, 0o755, true) && ! \is_dir($path)) { + throw new Runtime($this->getName().': unable to create "'.$path.'"'); + } + + return $path; + } + + /** + * Confirms the backend produced what it was asked for. + * + * @throws Runtime + */ + protected function wrote(string $path): string + { + if (! \is_file($path)) { + throw new Runtime($this->getName().': did not write "'.$path.'"'); + } + + return $path; + } + + /** + * Status line for a finished encode, pack, grab or tile. + * + * Quiet backends stay silent; everything else reports so a consumer sees + * the outcome without attaching a LOG listener. + * + * @return bool Whether a line was reported. + */ + protected function reportSuccess(string $message): bool + { + if ($this->level === self::QUIET) { + return false; + } + + $this->reporter()->success($message); + + return true; + } + + /** + * Status line before a command failure is thrown. + * + * @return bool Whether a line was reported. + */ + protected function reportError(string $message): bool + { + if ($this->level === self::QUIET) { + return false; + } + + $this->reporter()->error($message); + + return true; + } + + /** + * Runs a backend command, printing a red line when it fails. + * + * @param list $command + * @param callable(string):void|null $stdout + * @param callable(string):void|null $stderr + * @return string The tail of stderr. + * + * @throws Runtime + */ + protected function process( + array $command, + ?callable $stdout = null, + ?callable $stderr = null, + ?int $timeout = null, + ): string { + try { + return Process::run($command, $stdout, $stderr, $timeout ?? $this->timeout); + } catch (Runtime $e) { + $this->reportError($e->getMessage()); + + throw $e; + } + } + + /** + * Runs a command and returns its stdout, with the same error reporting. + * + * @param list $command + * + * @throws Runtime + */ + protected function capture(array $command, ?int $timeout = null): string + { + $output = ''; + + $this->process($command, Process::collector($output), null, $timeout); + + return $output; + } +} diff --git a/src/Video/Adapter/Encoder.php b/src/Video/Adapter/Encoder.php new file mode 100644 index 0000000..da29ad9 --- /dev/null +++ b/src/Video/Adapter/Encoder.php @@ -0,0 +1,51 @@ +started; + + $this->register($path, $as); + + if ($first) { + // One probe per job, on the first input only. + $this->details = $this->prober()->read($path); + } + + return $this; + } + + /** + * What was learned about the source when it was opened. + */ + public function info(): ?Info + { + return $this->details; + } + + public function encode(string $path): string + { + [$source, $info] = $this->ready(); + + if (\count($this->inputs) > 1) { + throw new Input(self::NAME.': encode() writes a single file; use pack() for a ladder'); + } + + if (\count($this->reps) > 1) { + throw new Input(self::NAME.': encode() writes a single file; use pack() for a ladder'); + } + + $format = $this->encoding ?? new X264(); + $rep = $this->reps[0] ?? null; + + $this->directory(\dirname($path)); + + $video = $info->hasVideo && ($rep === null || $rep->scaled()); + + $args = $this->prefix(); + $args[] = '-i'; + $args[] = $source; + + if ($video) { + $args[] = '-map'; + $args[] = '0:v:0'; + } + + // One rendition of the picture, but every audio track: a file with four + // dubs should come out with all four, not just the first. + if ($info->hasAudio) { + $args[] = '-map'; + $args[] = '0:a'; + } + + foreach ($format->build($video, $info->hasAudio) as $arg) { + $args[] = $arg; + } + + if ($rep !== null && $video && $rep->scaled()) { + $args[] = '-vf'; + $args[] = 'scale='.$rep->width.':'.$rep->height.':force_original_aspect_ratio=decrease,' + .'pad='.$rep->width.':'.$rep->height.':(ow-iw)/2:(oh-ih)/2,setsar=1:1'; + $args[] = '-b:v'; + $args[] = $rep->video.'k'; + $args[] = '-maxrate'; + $args[] = $rep->maxrate.'k'; + $args[] = '-bufsize'; + $args[] = $rep->bufsize.'k'; + } + + if ($rep !== null && $info->hasAudio) { + $args[] = '-b:a'; + $args[] = $rep->audio.'k'; + } + + $args[] = $path; + + try { + $this->execute($args, $info->duration); + + $path = $this->wrote($path); + $this->reportSuccess(self::NAME.': encoded '.$path); + + return $path; + } finally { + // Both terminals end the job, so the next open() starts a new one + // instead of adding a second input to this one. + $this->done(); + } + } + + public function pack(string $dir): Package + { + [, $info] = $this->ready(); + + if ($this->target === null) { + throw new Unsupported(self::NAME.': no output format has been set'); + } + + if ($this->reps === []) { + throw new Unsupported(self::NAME.': at least one representation is required'); + } + + $output = $this->target; + $dir = $this->directory($dir); + + $builder = Arguments::for( + $info, + $this->encoding ?? new X264(), + $this->reps, + $output, + $dir, + \count($this->inputs), + ); + + $args = $this->prefix(); + + foreach ($this->paths() as $input) { + $args[] = '-i'; + $args[] = $input; + } + + foreach ($builder->build() as $arg) { + $args[] = $arg; + } + + $args[] = $builder->target(); + + try { + $this->execute($args, $info->duration); + + $package = $this->result($dir, $output, $info, $info->duration); + $this->reportSuccess(self::NAME.': packed '.$dir); + + return $package; + } finally { + // The job is over, so the next open() starts a new one. + $this->done(); + } + } + + public function grab(string $path, string $output, ?Thumb $options = null): string + { + $path = $this->source($path); + $options ??= new Thumb(); + $info = $this->prober()->read($path); + + // A sound file with artwork has no video to speak of, but it does have a + // picture, so look for one before giving up. + $cover = $info->cover; + + if (! $info->hasVideo && $cover === null) { + throw new Input(self::NAME.': source "'.$path.'" carries no image to grab'); + } + + $this->directory(\dirname($output)); + + $args = $this->prefix(progress: false); + $at = $options->at(); + + if ($at !== null && $cover === null) { + // Seeking before the input is far quicker than decoding up to the mark. + $args[] = '-ss'; + $args[] = self::number($at); + } + + $args[] = '-i'; + $args[] = $path; + $args[] = '-map'; + + // Embedded artwork is a single frame; nothing needs selecting. + $args[] = $cover !== null ? '0:'.$cover : '0:v:0'; + + $filters = []; + + if ($at === null && $cover === null) { + // Let ffmpeg pick the most representative frame it can find. + $filters[] = 'thumbnail'; + } + + if ($options->size() > 0) { + $filters[] = 'scale='.$options->size().':-2'; + } + + if ($filters !== []) { + $args[] = '-vf'; + $args[] = \implode(',', $filters); + } + + $args[] = '-frames:v'; + $args[] = '1'; + $args[] = '-qscale:v'; + $args[] = (string) $options->scale(); + $args[] = $output; + + $this->process($args); + + $output = $this->wrote($output); + $this->reportSuccess(self::NAME.': grabbed '.$output); + + return $output; + } + + public function tile(string $path, string $dir, ?Tile $options = null): Spritesheet + { + $path = $this->source($path); + $options ??= new Tile(); + $info = $this->prober()->read($path); + + if (! $info->hasVideo || ! $info->width || ! $info->height) { + throw new Input(self::NAME.': source "'.$path.'" carries no video to tile'); + } + + if ($info->duration <= 0) { + throw new Input(self::NAME.': source "'.$path.'" has no measurable duration'); + } + + $dir = $this->directory($dir); + + $interval = $options->every($info->duration); + $width = $options->size(); + $height = (int) \round($width / ($info->width / $info->height)); + $height += $height % 2; + + $args = $this->prefix(progress: false); + $args[] = '-i'; + $args[] = $path; + $args[] = '-fps_mode'; + $args[] = 'vfr'; + $args[] = '-vf'; + $args[] = 'select=isnan(prev_selected_t)+gte(t-prev_selected_t\\,'.self::number($interval).')' + .',scale='.$width.':'.$height + .',tile='.$options->columns().'x'.$options->rows(); + $args[] = '-qscale:v'; + $args[] = (string) $options->scale(); + $args[] = $dir.'/'.$options->base().'%d.jpg'; + + // The sheets this run writes are numbered from one, and they are read + // back by counting upward until one is missing. A shorter run over the + // same directory would therefore inherit the tail of a longer one, so + // the numbering starts from an empty slate. + $this->sweep($dir, $options->base()); + + $this->process($args); + + $sheet = $this->sheet($dir, $options, $info->duration, $interval, $width, $height); + $this->reportSuccess(self::NAME.': tiled '.$dir); + + return $sheet; + } + + /** + * Arguments that come before the input on every invocation. + * + * @param bool $progress Whether to ask for the machine readable progress + * stream. Single-frame work finishes before anything + * could listen, so it does not bother. + * @return list + */ + protected function prefix(bool $progress = true): array + { + $args = [$this->binary, '-y', '-hide_banner', '-loglevel', $this->level]; + + if ($progress) { + $args[] = '-progress'; + $args[] = 'pipe:1'; + $args[] = '-nostats'; + } + + if ($this->threads > 0) { + $args[] = '-threads'; + $args[] = (string) $this->threads; + } + + return $args; + } + + /** + * Runs ffmpeg, translating its progress stream into events. + * + * @param list $args + */ + protected function execute(array $args, float $duration): void + { + $frame = 0; + $fps = 0.0; + $speed = 0.0; + + $this->process( + $args, + function (string $line) use (&$frame, &$fps, &$speed, $duration): void { + $split = \strpos($line, '='); + + if ($split === false) { + return; + } + + $key = \trim(\substr($line, 0, $split)); + $value = \trim(\substr($line, $split + 1)); + + switch ($key) { + case 'frame': + $frame = (int) $value; + + break; + case 'fps': + $fps = (float) $value; + + break; + case 'speed': + $speed = (float) \rtrim($value, 'x'); + + break; + case 'out_time_us': + case 'out_time_ms': + // Both keys are reported in microseconds. + $time = ((float) $value) / 1000000; + $this->emit(Observable::PROGRESS, new Progress( + percent: $duration > 0 ? \min(100.0, \round(($time / $duration) * 100, 2)) : 0.0, + time: \max(0.0, $time), + frame: $frame, + fps: $fps, + speed: $speed, + )); + + break; + case 'progress': + if ($value === 'end') { + $this->emit(Observable::PROGRESS, new Progress( + percent: 100.0, + time: $duration, + frame: $frame, + fps: $fps, + speed: $speed, + )); + } + + break; + } + }, + function (string $line): void { + if (\trim($line) !== '') { + $this->emit(Observable::LOG, $line); + } + }, + ); + } + + /** + * Confirms the chain is complete enough to run, and hands back the source + * together with what was learned about it. + * + * @return array{0: string, 1: Info} + */ + protected function ready(): array + { + $source = $this->opened(); + + if ($this->details === null) { + throw new Input(self::NAME.': no source has been opened'); + } + + return [$source, $this->details]; + } + + /** + * Removes the numbered sheets a previous run under this name left behind. + * + * Deliberately narrow: only the exact `{base}{digits}.jpg` shape that this + * adapter writes and sheet() reads back, so a directory shared with anything + * else β€” including a sheet name that merely starts the same way β€” keeps it. + */ + private function sweep(string $dir, string $base): void + { + $matches = \glob($dir.'/'.$base.'[0-9]*.jpg'); + + if ($matches === false) { + return; + } + + $shape = '/^'.\preg_quote($base, '/').'\d+\.jpg$/'; + + foreach ($matches as $match) { + if (\preg_match($shape, \basename($match)) === 1) { + @\unlink($match); + } + } + } + + /** + * Walks the sheets ffmpeg wrote and works out which slice covers when. + */ + private function sheet( + string $dir, + Tile $options, + float $duration, + float $interval, + int $width, + int $height, + ): Spritesheet { + $images = []; + $cues = []; + $thumbs = (int) \ceil($duration / $interval); + $cells = $options->cells(); + $index = 0; + + for ($sheet = 1; ; $sheet++) { + $file = $options->base().$sheet.'.jpg'; + $path = $dir.'/'.$file; + + if (! \is_file($path)) { + break; + } + + $images[] = $path; + + for ($cell = 0; $cell < $cells && $index < $thumbs; $cell++, $index++) { + $start = $index * $interval; + $end = \min($start + $interval, $duration); + + if ($end <= $start) { + break; + } + + $cues[] = new Cue( + start: $start, + end: $end, + file: $file, + x: ($cell % $options->columns()) * $width, + y: \intdiv($cell, $options->columns()) * $height, + width: $width, + height: $height, + ); + } + } + + if ($images === []) { + throw new Runtime(self::NAME.': produced no sprite sheets'); + } + + $vtt = null; + + if ($options->writes()) { + $vtt = $dir.'/'.$options->base().'.vtt'; + $sheet = new Spritesheet($images, $cues, null, $width, $height); + + if (\file_put_contents($vtt, $sheet->render()) === false) { + throw new Runtime(self::NAME.': unable to write "'.$vtt.'"'); + } + } + + return new Spritesheet($images, $cues, $vtt, $width, $height); + } + + /** + * Reads back what was written and describes it. + */ + private function result(string $dir, Output $output, Info $info, float $duration): Package + { + $manifests = []; + + if ($output instanceof Cmaf) { + $master = $dir.'/'.$output->masterFile(); + $mpd = $dir.'/'.$output->manifestFile(); + + self::independent($master); + self::spoken($master, $info); + + $read = Mpd::read($mpd, $dir); + $manifests[] = new Manifest(Manifest::DASH, $mpd, true); + + if (\is_file($master)) { + $manifests[] = new Manifest(Manifest::HLS, $master, true); + + foreach (self::playlists($dir, $master) as $playlist) { + $manifests[] = new Manifest(Manifest::HLS, $playlist); + } + } + } elseif ($output instanceof Dash) { + $mpd = $dir.'/'.$output->manifestFile(); + $read = Mpd::read($mpd, $dir); + $manifests[] = new Manifest(Manifest::DASH, $mpd, true); + } elseif ($output instanceof Hls) { + $master = $dir.'/'.$output->masterFile(); + + self::independent($master); + self::spoken($master, $info); + + $read = M3u8::read($master, $dir); + $manifests[] = new Manifest(Manifest::HLS, $master, true); + + foreach ($read['playlists'] as $playlist) { + $manifests[] = new Manifest(Manifest::HLS, $playlist); + } + } else { + throw new Unsupported(self::NAME.': unsupported output "'.$output::class.'"'); + } + + $metadata = $read['metadata']; + $metadata['duration'] ??= $duration; + + if (! $output->keeps()) { + foreach ($manifests as $manifest) { + @\unlink($manifest->path); + } + + $manifests = []; + } + + return new Package( + variants: $read['variants'], + manifests: $manifests, + metadata: $metadata, + duration: $duration, + ); + } + + /** + * Media playlists written next to a master, excluding the master itself. + * + * @return list + */ + private static function playlists(string $dir, string $master): array + { + $found = []; + + foreach (\glob($dir.'/*.m3u8') ?: [] as $path) { + if ($path !== $master) { + $found[] = $path; + } + } + + \sort($found); + + return $found; + } + + /** + * Names the language of each audio rendition in an HLS master. + * + * The hls muxer writes LANGUAGE itself, but the dash muxer's HLS output does + * not β€” so a CMAF package would offer several dubs with nothing to tell them + * apart. Renditions are emitted in stream order, which is the order the + * source reported its audio tracks in, so they line up. + */ + private static function spoken(string $master, Info $info): void + { + if ($info->audioTracks === [] || ! \is_file($master)) { + return; + } + + $body = \file_get_contents($master); + + if ($body === false) { + return; + } + + $languages = \array_map( + static fn (array $track): string => Name::language($track['language']), + $info->audioTracks, + ); + + $position = 0; + $lines = \explode("\n", $body); + + foreach ($lines as $index => $line) { + if (! \str_starts_with($line, '#EXT-X-MEDIA:TYPE=AUDIO')) { + continue; + } + + $language = $languages[$position++] ?? null; + + if ($language === null || $language === '' || \str_contains($line, 'LANGUAGE=')) { + continue; + } + + $lines[$index] = $line.',LANGUAGE="'.$language.'"'; + } + + self::rewrite($master, \implode("\n", $lines)); + } + + /** + * Records in the master that segments can be decoded independently. + * + * Forcing keyframes makes this true, but the muxers do not say so reliably: + * the dash muxer never writes the tag at all, and the hls muxer writes it + * into the video playlist while leaving it off the audio one. Declaring it + * once in the master covers every rendition, and players switch quality + * faster when they can rely on it. + */ + private static function independent(string $master): void + { + if (! \is_file($master)) { + return; + } + + $body = \file_get_contents($master); + + if ($body === false || \str_contains($body, '#EXT-X-INDEPENDENT-SEGMENTS')) { + return; + } + + $lines = \explode("\n", $body); + $at = 0; + + foreach ($lines as $index => $line) { + if (\str_starts_with($line, '#EXT-X-VERSION')) { + $at = $index + 1; + + break; + } + + if (\str_starts_with($line, '#EXTM3U')) { + $at = $index + 1; + } + } + + \array_splice($lines, $at, 0, '#EXT-X-INDEPENDENT-SEGMENTS'); + self::rewrite($master, \implode("\n", $lines)); + } + + /** + * Writes a manifest back over itself. + * + * Failing quietly here would leave a package whose master says something + * different from what was asked for, which is worse than not packaging. + * + * @throws Runtime + */ + private static function rewrite(string $master, string $body): void + { + if (\file_put_contents($master, $body) === false) { + throw new Runtime(self::NAME.': unable to write "'.$master.'"'); + } + } +} diff --git a/src/Video/Adapter/FFprobe.php b/src/Video/Adapter/FFprobe.php new file mode 100644 index 0000000..27525c9 --- /dev/null +++ b/src/Video/Adapter/FFprobe.php @@ -0,0 +1,327 @@ +capture([ + $this->binary, + '-v', $this->level, + '-print_format', 'json', + '-show_format', + '-show_streams', + '-show_chapters', + $this->source($path), + ]); + + $data = \json_decode($payload, true); + + if (! \is_array($data)) { + throw new Runtime('ffprobe returned output that could not be read'); + } + + /** @var array $data ffprobe's payload is a JSON object. */ + return $this->info($data); + } + + /** + * @param array $data + */ + protected function info(array $data): Info + { + /** @var array $format */ + $format = \is_array($data['format'] ?? null) ? $data['format'] : []; + /** @var list> $streams */ + $streams = \is_array($data['streams'] ?? null) ? \array_values($data['streams']) : []; + + $video = null; + $audio = null; + $cover = null; + $tracks = []; + $audioTracks = []; + + /** @var array $videoTags */ + $videoTags = []; + + foreach ($streams as $stream) { + $type = \is_string($stream['codec_type'] ?? null) ? $stream['codec_type'] : Track::DATA; + + // Read once and passed down: every tag lookup below would otherwise + // rebuild and lowercase the whole set again. + $tags = $this->tags($stream); + + if ($type === Track::VIDEO) { + if ($this->artwork($stream)) { + $cover ??= $this->integer($stream, 'index') ?? 0; + } elseif ($video === null) { + $video = $stream; + $videoTags = $tags; + } + } + + if ($type === Track::AUDIO) { + if ($audio === null) { + $audio = $stream; + } + + $audioTracks[] = [ + 'codec' => $this->string($stream, 'codec_name') ?? '', + 'language' => $tags['language'] ?? 'und', + ]; + } + + $tracks[] = $this->track($stream, $type, $tags); + } + + $duration = (float) ($this->string($format, 'duration') ?? 0); + + if ($duration <= 0) { + foreach ($streams as $stream) { + $duration = \max($duration, (float) ($this->string($stream, 'duration') ?? 0)); + } + } + + return new Info( + duration: $duration, + format: $this->string($format, 'format_name') ?? '', + hasVideo: $video !== null, + hasAudio: $audio !== null, + width: $video !== null ? ($this->integer($video, 'width') ?? 0) : null, + height: $video !== null ? ($this->integer($video, 'height') ?? 0) : null, + aspect: $video !== null ? $this->string($video, 'display_aspect_ratio') : null, + fps: $video !== null ? $this->rate($video) : null, + fpsMode: $video !== null ? $this->mode($video) : null, + videoCodec: $video !== null ? $this->string($video, 'codec_name') : null, + videoFormat: $video !== null ? $this->string($video, 'codec_long_name') : null, + videoProfile: $video !== null ? $this->string($video, 'profile') : null, + videoBitrate: $video !== null ? $this->integer($video, 'bit_rate') : null, + audioCodec: $audio !== null ? $this->string($audio, 'codec_name') : null, + audioFormat: $audio !== null ? $this->string($audio, 'codec_long_name') : null, + audioBitrate: $audio !== null ? $this->integer($audio, 'bit_rate') : null, + sampleRate: $audio !== null ? $this->integer($audio, 'sample_rate') : null, + audioTracks: $audioTracks, + tags: $this->tags($format), + tracks: $tracks, + chapters: $this->chapters($data), + rotation: $video !== null ? $this->rotation($video, $videoTags) : null, + cover: $cover, + raw: $data, + ); + } + + /** + * @param array $stream + * @param array $tags Already read from the stream. + */ + private function track(array $stream, string $type, array $tags): Track + { + /** @var array $disposition */ + $disposition = \is_array($stream['disposition'] ?? null) ? $stream['disposition'] : []; + + return new Track( + index: $this->integer($stream, 'index') ?? 0, + type: $type, + codec: $this->string($stream, 'codec_name'), + language: $tags['language'] ?? null, + title: $tags['title'] ?? null, + default: $this->integer($disposition, 'default') === 1, + forced: $this->integer($disposition, 'forced') === 1, + tags: $tags, + ); + } + + /** + * @param array $data + * @return list + */ + private function chapters(array $data): array + { + if (! \is_array($data['chapters'] ?? null)) { + return []; + } + + $chapters = []; + + foreach ($data['chapters'] as $chapter) { + if (! \is_array($chapter)) { + continue; + } + + /** @var array $chapter */ + $chapters[] = new Chapter( + start: (float) ($this->string($chapter, 'start_time') ?? 0), + end: (float) ($this->string($chapter, 'end_time') ?? 0), + title: $this->tag($chapter, 'title'), + ); + } + + return $chapters; + } + + /** + * A cover image is stored as a single frame video stream. + * + * @param array $stream + */ + private function artwork(array $stream): bool + { + $disposition = $stream['disposition'] ?? null; + + if (! \is_array($disposition)) { + return false; + } + + /** @var array $disposition */ + return $this->integer($disposition, 'attached_pic') === 1; + } + + /** + * @param array $stream + */ + private function rate(array $stream): ?float + { + $value = $this->string($stream, 'avg_frame_rate') ?? $this->string($stream, 'r_frame_rate'); + + if ($value === null || ! \str_contains($value, '/')) { + return $value === null ? null : (float) $value; + } + + [$numerator, $denominator] = \explode('/', $value, 2); + + if ((float) $denominator === 0.0) { + return null; + } + + return \round((float) $numerator / (float) $denominator, 3); + } + + /** + * @param array $stream + */ + private function mode(array $stream): ?string + { + $average = $this->string($stream, 'avg_frame_rate'); + $real = $this->string($stream, 'r_frame_rate'); + + if ($average === null || $real === null) { + return null; + } + + return $average === $real ? 'Constant' : 'Variable'; + } + + /** + * @param array $stream + * @param array $tags Already read from the stream. + */ + private function rotation(array $stream, array $tags): ?int + { + if (\is_array($stream['side_data_list'] ?? null)) { + foreach ($stream['side_data_list'] as $side) { + if (! \is_array($side)) { + continue; + } + + /** @var array $side */ + $angle = $this->string($side, 'rotation'); + + if ($angle !== null) { + return (int) $angle; + } + } + } + + return isset($tags['rotate']) ? (int) $tags['rotate'] : null; + } + + /** + * @param array $source + * @return array + */ + private function tags(array $source): array + { + if (! \is_array($source['tags'] ?? null)) { + return []; + } + + $tags = []; + + foreach ($source['tags'] as $key => $value) { + if (\is_string($key) && (\is_string($value) || \is_numeric($value))) { + $tags[\strtolower($key)] = (string) $value; + } + } + + return $tags; + } + + /** + * One tag off a payload whose tags are not needed for anything else. + * + * Anything reading more than one tag should call tags() once instead. + * + * @param array $source + */ + private function tag(array $source, string $name): ?string + { + return $this->tags($source)[$name] ?? null; + } + + /** + * @param array $source + */ + private function string(array $source, string $key): ?string + { + $value = $source[$key] ?? null; + + if (! \is_string($value) && ! \is_numeric($value)) { + return null; + } + + $value = (string) $value; + + return $value === '' || $value === 'N/A' ? null : $value; + } + + /** + * @param array $source + */ + private function integer(array $source, string $key): ?int + { + $value = $this->string($source, $key); + + return $value === null ? null : (int) $value; + } +} diff --git a/src/Video/Adapter/Job.php b/src/Video/Adapter/Job.php new file mode 100644 index 0000000..c1de157 --- /dev/null +++ b/src/Video/Adapter/Job.php @@ -0,0 +1,150 @@ + */ + protected array $inputs = []; + + /** @var list */ + protected array $reps = []; + + protected ?Output $target = null; + + protected ?Format $encoding = null; + + /** Whether a job is being built up rather than waiting to be started. */ + protected bool $started = false; + + /** + * Register an input. + * + * The first call of a job starts it; later calls add another already encoded + * rendition. Tag an input with the rung it represents when packaging several + * at once. + */ + public function open(string $path, ?Representation $as = null): static + { + $this->register($path, $as); + + return $this; + } + + /** + * The registration itself, kept separate so a backend that needs to do more + * on open() can reuse it. A trait method cannot be reached with parent::. + */ + protected function register(string $path, ?Representation $as = null): void + { + // Validated before anything is remembered, so a source that turns out + // not to exist leaves the chain exactly as it was. + $input = ['path' => $this->source($path), 'rep' => $as]; + + if (! $this->started) { + // A job begins. Only the listeners are dropped: a finished job + // clears its own configuration, so whatever is set here was set by + // the caller for this job, and clearing it would silently discard a + // ladder described before the source was opened. + $this->forget(); + $this->started = true; + } + + $this->inputs[] = $input; + + if ($as !== null) { + $this->reps[] = $as; + } + } + + public function add(Representation ...$representations): static + { + foreach ($representations as $representation) { + $this->reps[] = $representation; + } + + return $this; + } + + public function output(Output $output): static + { + $this->target = $output; + + return $this; + } + + public function format(Format $format): static + { + $this->encoding = $format; + + return $this; + } + + /** + * The job is over, whether it succeeded or not. + * + * Called by every terminal, so that a job clears up after itself rather + * than leaving the next open() to guess what belonged to whom. + */ + protected function done(): void + { + $this->inputs = []; + $this->reps = []; + $this->target = null; + $this->encoding = null; + $this->started = false; + } + + /** + * The path opened first, which is the source unless several were given. + * + * @throws Input + */ + protected function opened(): string + { + $first = $this->inputs[0]['path'] ?? null; + + if ($first === null) { + throw new Input($this->getName().': no source has been opened'); + } + + return $first; + } + + /** + * Every registered input path, in the order they were opened. + * + * @return list + */ + protected function paths(): array + { + return \array_map( + static fn (array $input): string => $input['path'], + $this->inputs, + ); + } + + abstract public function getName(): string; + + abstract protected function source(string $path): string; + + abstract protected function forget(): void; +} diff --git a/src/Video/Adapter/Mock.php b/src/Video/Adapter/Mock.php new file mode 100644 index 0000000..7da010e --- /dev/null +++ b/src/Video/Adapter/Mock.php @@ -0,0 +1,314 @@ +duration = $duration; + $this->width = $width; + $this->height = $height; + $this->audio = $audio; + + return $this; + } + + public function read(string $path): Info + { + $this->source($path); + + return new Info( + duration: $this->duration, + format: 'mock', + hasVideo: $this->width > 0 && $this->height > 0, + hasAudio: $this->audio, + width: $this->width > 0 ? $this->width : null, + height: $this->height > 0 ? $this->height : null, + fps: 25.0, + videoCodec: $this->width > 0 ? 'h264' : null, + audioCodec: $this->audio ? 'aac' : null, + audioTracks: $this->audio ? [['codec' => 'aac', 'language' => 'und']] : [], + tracks: $this->tracks(), + ); + } + + public function valid(string $path): bool + { + return \is_file($path) && $this->duration > 0; + } + + public function info(): ?Info + { + $first = $this->inputs[0]['path'] ?? null; + + return $first === null ? null : $this->read($first); + } + + public function encode(string $path): string + { + $source = $this->opened(); + + if (\count($this->reps) > 1) { + throw new Input(self::NAME.': encode() writes a single file; use pack() for a ladder'); + } + + $this->directory(\dirname($path)); + + try { + $this->progress(); + + \file_put_contents($path, 'mock encode of '.\basename($source)); + + $path = $this->wrote($path); + $this->reportSuccess(self::NAME.': encoded '.$path); + + return $path; + } finally { + // Both terminals end the job, as in the real adapters. + $this->done(); + } + } + + public function pack(string $dir): Package + { + if ($this->inputs === []) { + throw new Input(self::NAME.': no source has been opened'); + } + + if ($this->target === null) { + throw new Unsupported(self::NAME.': no output format has been set'); + } + + if ($this->reps === []) { + throw new Unsupported(self::NAME.': at least one representation is required'); + } + + $output = $this->target; + $dir = $this->directory($dir); + + try { + return $this->packed($dir, $output); + } finally { + // The job is over, so the next open() starts a new one. + $this->done(); + } + } + + /** + * The pretend package itself, kept apart so pack() can end the job whatever + * happens here. + */ + private function packed(string $dir, Output $output): Package + { + $this->progress(); + + $variants = []; + + foreach ($this->reps as $position => $rep) { + $segments = []; + + foreach ([0, 1] as $number) { + $file = $output->base().'_'.$rep->name.'_'.$number.'.ts'; + \file_put_contents($dir.'/'.$file, 'mock segment'); + + $segments[] = new Segment( + variant: (string) $position, + file: $file, + path: $dir.'/'.$file, + duration: $this->duration / 2, + number: $number, + size: (int) \filesize($dir.'/'.$file), + ); + } + + $variants[] = new Variant( + id: (string) $position, + type: Track::VIDEO, + bandwidth: $rep->video * 1000, + width: $rep->width, + height: $rep->height, + target: $output->duration(), + segments: $segments, + ); + } + + $manifests = []; + + if ($output->keeps()) { + $master = $dir.'/'.$output->base().'.m3u8'; + \file_put_contents($master, "#EXTM3U\n"); + $manifests[] = new Manifest(Manifest::HLS, $master, true); + } + + $this->reportSuccess(self::NAME.': packed '.$dir); + + return new Package( + variants: $variants, + manifests: $manifests, + metadata: ['duration' => $this->duration], + duration: $this->duration, + ); + } + + public function grab(string $path, string $output, ?Thumb $options = null): string + { + $this->source($path); + + if ($this->width <= 0 || $this->height <= 0) { + throw new Input(self::NAME.': source "'.$path.'" carries no image to grab'); + } + + $this->directory(\dirname($output)); + + \file_put_contents($output, 'mock still'); + + $output = $this->wrote($output); + $this->reportSuccess(self::NAME.': grabbed '.$output); + + return $output; + } + + public function tile(string $path, string $dir, ?Tile $options = null): Spritesheet + { + $this->source($path); + $options ??= new Tile(); + + // The same refusals the real adapter makes, so code proved against this + // double behaves the same way once ffmpeg is behind it. + if ($this->width <= 0 || $this->height <= 0) { + throw new Input(self::NAME.': source "'.$path.'" carries no video to tile'); + } + + if ($this->duration <= 0) { + throw new Input(self::NAME.': source "'.$path.'" has no measurable duration'); + } + + $dir = $this->directory($dir); + + $interval = $options->every($this->duration); + $width = $options->size(); + $height = (int) \round($width / ($this->width / $this->height)); + + $file = $options->base().'1.jpg'; + \file_put_contents($dir.'/'.$file, 'mock sheet'); + + $cues = []; + $thumbs = (int) \ceil($this->duration / $interval); + + for ($index = 0; $index < $thumbs && $index < $options->cells(); $index++) { + $cues[] = new Cue( + start: $index * $interval, + end: \min(($index + 1) * $interval, $this->duration), + file: $file, + x: ($index % $options->columns()) * $width, + y: \intdiv($index, $options->columns()) * $height, + width: $width, + height: $height, + ); + } + + $vtt = null; + + if ($options->writes()) { + $vtt = $dir.'/'.$options->base().'.vtt'; + \file_put_contents($vtt, (new Spritesheet([$dir.'/'.$file], $cues))->render()); + } + + $this->reportSuccess(self::NAME.': tiled '.$dir); + + return new Spritesheet([$dir.'/'.$file], $cues, $vtt, $width, $height); + } + + protected function prober(): Probe + { + return $this->prober ?? $this; + } + + /** + * Emits the same shape of progress a real backend would. + * + * Progress is structured data rather than commentary, so it is reported at + * every level; the log line is commentary, so a quiet backend withholds it. + */ + private function progress(): void + { + foreach ([25.0, 50.0, 100.0] as $percent) { + $this->emit(Observable::PROGRESS, new Progress( + percent: $percent, + time: $this->duration * ($percent / 100), + )); + } + + if ($this->level !== self::QUIET) { + $this->emit(Observable::LOG, self::NAME.': done'); + } + } + + /** + * @return list + */ + private function tracks(): array + { + $tracks = []; + + if ($this->width > 0 && $this->height > 0) { + $tracks[] = new Track(index: 0, type: Track::VIDEO, codec: 'h264'); + } + + if ($this->audio) { + $tracks[] = new Track(index: \count($tracks), type: Track::AUDIO, codec: 'aac'); + } + + return $tracks; + } +} diff --git a/src/Video/Adapter/Named.php b/src/Video/Adapter/Named.php new file mode 100644 index 0000000..63545c9 --- /dev/null +++ b/src/Video/Adapter/Named.php @@ -0,0 +1,16 @@ +prober()->read($path); + } catch (Input|Runtime) { + return false; + } + + return $info->duration > 0 && ($info->hasVideo || $info->hasAudio); + } + + abstract protected function prober(): Probe; +} diff --git a/src/Video/Arguments.php b/src/Video/Arguments.php new file mode 100644 index 0000000..83f9049 --- /dev/null +++ b/src/Video/Arguments.php @@ -0,0 +1,280 @@ + $reps + * @param int $inputs How many files were opened. More than one means each + * rung reads from its own already encoded input. + */ + public function __construct( + protected readonly Info $info, + protected readonly Format $format, + protected readonly array $reps, + protected readonly Output $output, + protected readonly string $dir, + protected readonly int $inputs = 1, + ) { + if ($reps === []) { + throw new Unsupported('At least one representation is required'); + } + + $names = []; + + foreach ($reps as $rep) { + if (isset($names[$rep->name])) { + throw new Input('Representation name "'.$rep->name.'" is used more than once'); + } + + $names[$rep->name] = true; + } + + if (! \in_array($output->type(), $format->supports(), true)) { + throw new Unsupported( + \strtoupper($output->type()).' cannot carry '.$format->codec() + .'; supported: '.\implode(', ', $format->supports()), + ); + } + + if ($format instanceof Copy) { + throw new Unsupported( + 'Stream copy cannot build an adaptive package: every video representation ' + .'is filtered to its requested size, and filtering cannot be combined with codec copy', + ); + } + + $interval = $format->interval(); + + // A cut can only land on a keyframe, so keyframes further apart than a + // segment cannot produce segments of the length that was asked for. The + // muxer would carry on regardless and write longer ones. + if ($interval !== null && $interval > $output->duration()) { + throw new Unsupported( + 'A keyframe every '.self::number($interval).'s cannot cut ' + .self::number($output->duration()).'s segments; segments start on keyframes, so the ' + .'keyframe interval has to be the segment length or a fraction of it', + ); + } + } + + /** + * The builder that matches the requested output. + * + * Keeping the choice here means callers deal with one name rather than + * importing three builders alongside the three outputs they mirror. + * + * @param list $reps + */ + public static function for( + Info $info, + Format $format, + array $reps, + Output $output, + string $dir, + int $inputs = 1, + ): self { + return match (true) { + $output instanceof Output\Cmaf => new Arguments\Cmaf($info, $format, $reps, $output, $dir, $inputs), + $output instanceof Output\Dash => new Arguments\Dash($info, $format, $reps, $output, $dir, $inputs), + $output instanceof Output\Hls => new Arguments\Hls($info, $format, $reps, $output, $dir, $inputs), + default => throw new Unsupported('Unsupported output "'.$output::class.'"'), + }; + } + + /** + * Arguments that follow the input file, excluding the output path. + * + * @return list + */ + abstract public function build(): array; + + /** + * The path ffmpeg is asked to write. + */ + abstract public function target(): string; + + /** + * Whether this job produces video at all. + */ + protected function visual(): bool + { + if (! $this->info->hasVideo) { + return false; + } + + foreach ($this->reps as $rep) { + if ($rep->scaled()) { + return true; + } + } + + return false; + } + + /** + * Audio streams to carry through, one entry per output audio stream. + * + * Every track comes through, tagged or not: a language is what tells one + * rendition from another in a manifest, but a track without one is still a + * track, and dropping it would lose a dub the source actually carried. + * Untagged tracks are reported with an empty language, which the builders + * read as "nothing to say about this one". + * + * @return list + */ + protected function sound(): array + { + if (! $this->info->hasAudio) { + return []; + } + + $tracks = []; + + foreach ($this->info->audioTracks as $index => $track) { + $language = $track['language']; + + $tracks[] = [ + 'index' => $index, + 'language' => Name::language($language), + ]; + } + + // A probe that found audio without describing it still has one track. + return $tracks === [] ? [['index' => 0, 'language' => '']] : $tracks; + } + + /** + * Stream selection: each video rung reads the same source stream, and every + * audio track is carried once. + * + * Audio always comes from the first input. With one input that is the source + * itself; with several it is the first encoded rung, which carries every + * track the source had, so the track indices hold either way. + * + * @return list + */ + protected function maps(): array + { + $args = []; + $split = $this->inputs > 1; + + foreach ($this->rungs() as $position => $rep) { + $args[] = '-map'; + $args[] = ($split ? $position : 0).':v:0'; + } + + foreach ($this->sound() as $track) { + $args[] = '-map'; + $args[] = '0:a:'.$track['index']; + } + + return $args; + } + + /** + * Codec arguments plus the per-rung sizing and rate control. + * + * @return list + */ + protected function streams(): array + { + $visual = $this->visual(); + $tracks = $this->sound(); + + // The segment length doubles as the keyframe cadence unless the caller + // named one, so a ladder packaged with the defaults is still cut on + // keyframes rather than wherever the encoder happened to put one. + $args = $this->format->build($visual, $tracks !== [], $this->output->duration()); + + if ($visual) { + $position = 0; + + foreach ($this->reps as $rep) { + if (! $rep->scaled()) { + continue; + } + + foreach ($this->scale($rep, $position) as $arg) { + $args[] = $arg; + } + + $position++; + } + } + + foreach (\array_keys($tracks) as $position) { + $rep = $this->reps[\min($position, \count($this->reps) - 1)]; + + $args[] = '-b:a:'.$position; + $args[] = $rep->audio.'k'; + } + + return $args; + } + + /** + * Sizing and rate control for a single rung. + * + * The frame is fitted inside the requested box and padded back out to it, + * so the resolution advertised in the manifest is the resolution delivered + * no matter what shape the source was. + * + * @return list + */ + protected function scale(Representation $rep, int $position): array + { + return [ + '-filter:v:'.$position, + 'scale='.$rep->width.':'.$rep->height.':force_original_aspect_ratio=decrease,' + .'pad='.$rep->width.':'.$rep->height.':(ow-iw)/2:(oh-ih)/2,setsar=1:1', + '-b:v:'.$position, + $rep->video.'k', + '-maxrate:v:'.$position, + $rep->maxrate.'k', + '-bufsize:v:'.$position, + $rep->bufsize.'k', + ]; + } + + /** + * Representations that actually produce a video rung. + * + * @return list + */ + protected function rungs(): array + { + if (! $this->visual()) { + return []; + } + + return \array_values(\array_filter( + $this->reps, + static fn (Representation $rep): bool => $rep->scaled(), + )); + } + + protected function path(string $file): string + { + return \rtrim($this->dir, '/').'/'.$file; + } +} diff --git a/src/Video/Arguments/Cmaf.php b/src/Video/Arguments/Cmaf.php new file mode 100644 index 0000000..03192db --- /dev/null +++ b/src/Video/Arguments/Cmaf.php @@ -0,0 +1,35 @@ +output; + + if (! $cmaf instanceof CmafOutput) { + throw new Unsupported('Expected a CMAF output'); + } + + return [ + // The HLS side of the muxer is skipped unless segments are MP4. + '-dash_segment_type', 'mp4', + '-hls_playlist', '1', + '-hls_master_name', $cmaf->masterFile(), + ]; + } +} diff --git a/src/Video/Arguments/Dash.php b/src/Video/Arguments/Dash.php new file mode 100644 index 0000000..e8a1149 --- /dev/null +++ b/src/Video/Arguments/Dash.php @@ -0,0 +1,149 @@ + $reps + */ + public function __construct( + Info $info, + Format $format, + array $reps, + Output $output, + string $dir, + int $inputs = 1, + ) { + parent::__construct($info, $format, $reps, $output, $dir, $inputs); + + $this->shaped(); + } + + /** + * Every rung of a DASH ladder has to be the same shape. + * + * All video rungs go into one adaptation set, and a set may only hold + * representations that are alternatives of each other β€” so the muxer refuses + * a set whose members disagree on aspect ratio. It only says so once the + * command is already running ("Conflicting stream aspect ratios values"), + * with no mention of which rungs clashed, so the check happens here instead. + * HLS has no such rule and is left alone. + */ + private function shaped(): void + { + $shapes = []; + + foreach ($this->rungs() as $rep) { + $shapes[self::shape($rep->width, $rep->height)][] = $rep->name.' ('.$rep->resolution().')'; + } + + if (\count($shapes) < 2) { + return; + } + + $described = []; + + foreach ($shapes as $shape => $names) { + $described[] = $shape.' β†’ '.\implode(', ', $names); + } + + throw new Unsupported( + \strtoupper($this->output->type()).' needs every rung to share one aspect ratio; got ' + .\implode('; ', $described) + .'. Choose sizes that keep the same shape, or package as HLS, which allows a mix.', + ); + } + + private static function shape(int $width, int $height): string + { + $a = $width; + $b = $height; + + while ($b !== 0) { + [$a, $b] = [$b, $a % $b]; + } + + $divisor = $a === 0 ? 1 : $a; + + return \intdiv($width, $divisor).':'.\intdiv($height, $divisor); + } + + public function build(): array + { + $dash = $this->output; + + if (! $dash instanceof DashOutput) { + throw new Unsupported('Expected a DASH output'); + } + + $args = $this->maps(); + + foreach ($this->streams() as $arg) { + $args[] = $arg; + } + + $args[] = '-f'; + $args[] = 'dash'; + $args[] = '-seg_duration'; + $args[] = self::number($dash->duration()); + $args[] = '-use_template'; + $args[] = $dash->templated() ? '1' : '0'; + $args[] = '-use_timeline'; + $args[] = $dash->timelined() ? '1' : '0'; + $args[] = '-window_size'; + $args[] = '0'; + $args[] = '-init_seg_name'; + $args[] = $dash->initPattern(); + $args[] = '-media_seg_name'; + $args[] = $dash->mediaPattern(); + $args[] = '-adaptation_sets'; + $args[] = $dash->adaptations(\count($this->rungs()), \count($this->sound())); + + foreach ($this->muxer() as $arg) { + $args[] = $arg; + } + + foreach ($dash->extra() as $param) { + $args[] = $param; + } + + return $args; + } + + public function target(): string + { + $dash = $this->output; + + if (! $dash instanceof DashOutput) { + throw new Unsupported('Expected a DASH output'); + } + + return $this->path($dash->manifestFile()); + } + + /** + * Hook for subclasses that need extra muxer options. + * + * @return list + */ + protected function muxer(): array + { + return []; + } +} diff --git a/src/Video/Arguments/Hls.php b/src/Video/Arguments/Hls.php new file mode 100644 index 0000000..d0a204b --- /dev/null +++ b/src/Video/Arguments/Hls.php @@ -0,0 +1,112 @@ +output; + + if (! $hls instanceof HlsOutput) { + throw new Unsupported('Expected an HLS output'); + } + + $args = $this->maps(); + + foreach ($this->streams() as $arg) { + $args[] = $arg; + } + + $args[] = '-f'; + $args[] = 'hls'; + $args[] = '-hls_time'; + $args[] = self::number($hls->duration()); + $args[] = '-hls_list_size'; + $args[] = '0'; + $args[] = '-hls_playlist_type'; + $args[] = 'vod'; + $args[] = '-hls_segment_type'; + $args[] = $hls->container(); + $args[] = '-hls_segment_filename'; + $args[] = $this->path($hls->base().'_%v_%04d.'.$hls->extension()); + + if ($hls->fragmented()) { + $args[] = '-hls_fmp4_init_filename'; + $args[] = $hls->base().'_%v_'.$hls->initFile(); + } + + if ($hls->prefix() !== '') { + $args[] = '-hls_base_url'; + $args[] = $hls->prefix(); + } + + if ($hls->hlsFlags() !== []) { + $args[] = '-hls_flags'; + $args[] = \implode('+', $hls->hlsFlags()); + } + + $args[] = '-master_pl_name'; + $args[] = $hls->masterFile(); + $args[] = '-var_stream_map'; + $args[] = $this->map(); + + foreach ($hls->extra() as $param) { + $args[] = $param; + } + + return $args; + } + + public function target(): string + { + return $this->path($this->output->base().'_%v.m3u8'); + } + + /** + * Describes each variant, and which audio group the video rungs pull from. + */ + private function map(): string + { + $entries = []; + + foreach ($this->rungs() as $position => $rep) { + $entry = 'v:'.$position.',name:'.$rep->name; + + if ($this->sound() !== []) { + $entry .= ',agroup:audio'; + } + + $entries[] = $entry; + } + + foreach ($this->sound() as $position => $track) { + $entry = 'a:'.$position.',agroup:audio'; + + if ($track['language'] !== '') { + $entry .= ',language:'.$track['language']; + } + + $entry .= ',name:audio_'.$position; + + if ($position === 0) { + $entry .= ',default:yes'; + } + + $entries[] = $entry; + } + + return \implode(' ', $entries); + } +} diff --git a/src/Video/Chapter.php b/src/Video/Chapter.php new file mode 100644 index 0000000..c464a20 --- /dev/null +++ b/src/Video/Chapter.php @@ -0,0 +1,18 @@ +start).' --> '.self::timestamp($this->end)."\n" + .($url ?? $this->file).'#xywh='.$this->x.','.$this->y.','.$this->width.','.$this->height; + } + + public static function timestamp(float $seconds): string + { + $hours = (int) ($seconds / 3600); + $minutes = (int) (($seconds - ($hours * 3600)) / 60); + $rest = $seconds - ($hours * 3600) - ($minutes * 60); + + return \sprintf('%02d:%02d:%06.3f', $hours, $minutes, $rest); + } +} diff --git a/src/Video/Decimal.php b/src/Video/Decimal.php new file mode 100644 index 0000000..4052cee --- /dev/null +++ b/src/Video/Decimal.php @@ -0,0 +1,23 @@ +> + */ + protected array $listeners = []; + + /** + * @param Reporter|null $reporter Where status lines go; null keeps them on + * the terminal. + */ + public function __construct( + ?EncoderAdapter $adapter = null, + ?Probe $probe = null, + ?Reporter $reporter = null, + ) { + $this->adapter = $adapter ?? new FFmpeg(); + $this->prober = $probe ?? new FFprobe(); + + // Config is pushed into the adapter rather than passed to its + // constructor, so an adapter supplied by the caller cannot quietly keep + // a probe of its own and ignore the one asked for here. + if ($this->adapter instanceof Adapter) { + $this->adapter->setProbe($this->prober); + + if ($reporter !== null) { + $this->adapter->setReporter($reporter); + } + } + } + + /** + * Which backend is doing the encoding. + */ + public function getName(): string + { + return $this->adapter->getName(); + } + + /** + * Everything that can be learned about a source without decoding it. + */ + public function probe(string $path): Info + { + return $this->prober->read($path); + } + + /** + * Whether a source is usable as input. + */ + public function valid(string $path): bool + { + return $this->prober->valid($path); + } + + /** + * Write a single still image and return its path. + */ + public function grab(string $path, string $output, ?Thumb $options = null): string + { + return $this->adapter->grab($path, $output, $options); + } + + /** + * Write sprite sheets covering the whole timeline. + */ + public function tile(string $path, string $dir, ?Tile $options = null): Spritesheet + { + return $this->adapter->tile($path, $dir, $options); + } + + /** + * Start a job. Anything configured by a previous one is discarded, listeners + * excepted: those belong to this object and outlive the job. See off(). + */ + public function open(string $path): static + { + if (! \is_file($path)) { + throw new Exception\Input('Source "'.$path.'" does not exist'); + } + + // Passed straight through: the adapter's own open() makes the same + // promise about dropping the last job's format and rungs, so there is + // nothing worth buffering here. + $this->adapter->open($path); + + // Listeners are the exception. The adapter drops its own on open(), so + // this object's are handed over again afterwards; without that, on() + // before open() would read perfectly and be silently discarded. + foreach ($this->listeners as $event => $listeners) { + foreach ($listeners as $listener) { + $this->adapter->on($event, $listener); + } + } + + return $this; + } + + public function format(Format $format): static + { + $this->adapter->format($format); + + return $this; + } + + public function add(Representation ...$representations): static + { + $this->adapter->add(...$representations); + + return $this; + } + + /** + * Register a listener for PROGRESS or LOG. + * + * Order does not matter: a listener registered before open() survives it and + * hears the job that follows. + * + * @param callable $listener + */ + public function on(string $event, callable $listener): static + { + $this->listeners[$event][] = $listener; + $this->adapter->on($event, $listener); + + return $this; + } + + /** + * Drop the listeners for one event, or every listener when given nothing. + * + * Listeners outlive a job, so this is how a reused Encoder stops reporting + * to the last job's listener before it registers the next one's. + * + * A backend that extends Adapter is detached immediately. One that only + * implements Adapter\Encoder has no off() to call β€” Observable declares just + * on(), so that a backend written elsewhere is not asked for more β€” and it + * lets go of its copy at the next open() instead. + */ + public function off(?string $event = null): static + { + if ($event === null) { + $this->listeners = []; + } else { + unset($this->listeners[$event]); + } + + // The same test the constructor makes before it pushes a probe. + if ($this->adapter instanceof Adapter) { + $this->adapter->off($event); + } + + return $this; + } + + /** + * Encode to a single file and return the path written. + */ + public function encode(string $path): string + { + return $this->adapter->encode($path); + } +} diff --git a/src/Video/Exception.php b/src/Video/Exception.php new file mode 100644 index 0000000..592bd40 --- /dev/null +++ b/src/Video/Exception.php @@ -0,0 +1,12 @@ + $command The argv that failed, when one was run. + * @param string $output The tail of what the command complained about. + */ + public function __construct( + string $message, + private readonly array $command = [], + private readonly string $output = '', + int $code = 0, + ?Throwable $previous = null, + ) { + parent::__construct($message, $code, $previous); + } + + /** + * @return list + */ + public function command(): array + { + return $this->command; + } + + public function output(): string + { + return $this->output; + } +} diff --git a/src/Video/Exception/Unsupported.php b/src/Video/Exception/Unsupported.php new file mode 100644 index 0000000..a33ba72 --- /dev/null +++ b/src/Video/Exception/Unsupported.php @@ -0,0 +1,15 @@ + */ + protected array $params = []; + + public function __construct(?string $video = null, ?string $audio = null) + { + $this->video = $video ?? $this->video(); + $this->audio = $audio ?? $this->audio(); + } + + /** + * Default video codec for this preset. + */ + abstract public function video(): string; + + /** + * Default audio codec for this preset. + */ + abstract public function audio(): string; + + /** + * Codec specific arguments applied before any user supplied parameters. + * + * @return list + */ + abstract public function defaults(): array; + + /** + * Output formats this codec can be packaged into. + * + * @return list + */ + public function supports(): array + { + return [Output::HLS, Output::DASH, Output::CMAF]; + } + + public function codec(): string + { + return $this->video; + } + + public function sound(): string + { + return $this->audio; + } + + /** + * Constant rate factor: lower means better quality and a larger file. + */ + public function crf(int $crf): static + { + $copy = clone $this; + $copy->crf = $crf; + + return $copy; + } + + /** + * Number of consecutive B frames the encoder may use. + */ + public function bframes(int $count): static + { + $copy = clone $this; + $copy->bframes = $count; + + return $copy; + } + + /** + * Force a keyframe every N seconds so segments can be cut cleanly. + * + * Packaging derives this from the segment length when it is left unset, so + * it only needs setting to ask for something other than one keyframe per + * segment. + */ + public function keyframe(float $seconds): static + { + $copy = clone $this; + $copy->keyframe = $seconds; + + return $copy; + } + + /** + * Raw arguments appended after everything this class understands. + * + * @param list $params + */ + public function params(array $params): static + { + $copy = clone $this; + $copy->params = $params; + + return $copy; + } + + public function interval(): ?float + { + return $this->keyframe; + } + + /** + * Codec arguments for one job, without any stream index suffix. + * + * @param float|null $cadence Keyframe interval to fall back on when none + * was configured. Packaging passes the segment + * length, because segments can only be cut + * where a keyframe already is. + * @return list + */ + public function build(bool $video = true, bool $audio = true, ?float $cadence = null): array + { + $args = []; + + if ($video) { + $args[] = '-c:v'; + $args[] = $this->video; + } + + if ($audio) { + $args[] = '-c:a'; + $args[] = $this->audio; + } + + if ($video) { + foreach ($this->defaults() as $default) { + $args[] = $default; + } + + if ($this->crf !== null) { + $args[] = '-crf'; + $args[] = (string) $this->crf; + } + + if ($this->bframes !== null) { + $args[] = '-bf'; + $args[] = (string) $this->bframes; + } + + $keyframe = $this->keyframe ?? $cadence; + + if ($keyframe !== null) { + $args[] = '-force_key_frames'; + $args[] = 'expr:gte(t,n_forced*'.self::number($keyframe).')'; + } + } + + foreach ($this->params as $param) { + $args[] = $param; + } + + return $args; + } +} diff --git a/src/Video/Format/Copy.php b/src/Video/Format/Copy.php new file mode 100644 index 0000000..3accb3b --- /dev/null +++ b/src/Video/Format/Copy.php @@ -0,0 +1,59 @@ +video; + } + + if ($audio) { + $args[] = '-c:a'; + $args[] = $this->audio; + } + + foreach ($this->params as $param) { + $args[] = $param; + } + + return $args; + } +} diff --git a/src/Video/Format/HEVC.php b/src/Video/Format/HEVC.php new file mode 100644 index 0000000..bc10c0a --- /dev/null +++ b/src/Video/Format/HEVC.php @@ -0,0 +1,28 @@ + $audioTracks + * @param array $tags + * @param list $tracks + * @param list $chapters + * @param array $raw + */ + public function __construct( + public readonly float $duration = 0.0, + public readonly string $format = '', + public readonly bool $hasVideo = false, + public readonly bool $hasAudio = false, + public readonly ?int $width = null, + public readonly ?int $height = null, + public readonly ?string $aspect = null, + public readonly ?float $fps = null, + public readonly ?string $fpsMode = null, + public readonly ?string $videoCodec = null, + public readonly ?string $videoFormat = null, + public readonly ?string $videoProfile = null, + public readonly ?int $videoBitrate = null, + public readonly ?string $audioCodec = null, + public readonly ?string $audioFormat = null, + public readonly ?int $audioBitrate = null, + public readonly ?int $sampleRate = null, + public readonly array $audioTracks = [], + public readonly array $tags = [], + public readonly array $tracks = [], + public readonly array $chapters = [], + public readonly ?int $rotation = null, + /** + * Stream index of an embedded cover image, when the source carries one. + * + * A sound file with artwork reports no video, but there is still a + * picture to be had; this is where it is. + */ + public readonly ?int $cover = null, + public readonly array $raw = [], + ) { + } + + /** + * Duration in milliseconds, which is how most catalogues store it. + */ + public function milliseconds(): int + { + return (int) \round($this->duration * 1000); + } + + /** + * Display aspect ratio derived from the frame size when the container does + * not carry one of its own. + */ + public function ratio(): ?string + { + if ($this->aspect !== null) { + return $this->aspect; + } + + if (! $this->width || ! $this->height) { + return null; + } + + $divisor = self::gcd($this->width, $this->height); + + return ($this->width / $divisor).':'.($this->height / $divisor); + } + + /** + * @return list + */ + public function tracks(string $type): array + { + return \array_values(\array_filter( + $this->tracks, + static fn (Track $track): bool => $track->type === $type, + )); + } + + private static function gcd(int $a, int $b): int + { + while ($b !== 0) { + [$a, $b] = [$b, $a % $b]; + } + + return $a === 0 ? 1 : $a; + } +} diff --git a/src/Video/Manifest.php b/src/Video/Manifest.php new file mode 100644 index 0000000..065263d --- /dev/null +++ b/src/Video/Manifest.php @@ -0,0 +1,27 @@ +path); + } +} diff --git a/src/Video/Name.php b/src/Video/Name.php new file mode 100644 index 0000000..56014ae --- /dev/null +++ b/src/Video/Name.php @@ -0,0 +1,194 @@ + */ + protected array $params = []; + + /** + * Which of the constants above this output represents. + */ + abstract public function type(): string; + + /** + * Target segment length in seconds. + */ + public function segment(float $seconds): static + { + $copy = clone $this; + $copy->segment = $seconds; + + return $copy; + } + + /** + * Whether to keep the playlist files after they have been read. + * + * Segment metadata is returned either way; this only decides whether the + * manifests themselves survive on disk. + */ + public function manifests(bool $write): static + { + $copy = clone $this; + $copy->manifests = $write; + + return $copy; + } + + /** + * Base name shared by every artifact this job writes. + */ + public function name(string $base): static + { + $name = Name::label($base, 'Output name'); + + $copy = clone $this; + $copy->name = $name; + + return $copy; + } + + /** + * Raw muxer arguments appended to the generated command. + * + * @param list $params + */ + public function params(array $params): static + { + $copy = clone $this; + $copy->params = $params; + + return $copy; + } + + public function duration(): float + { + return $this->segment; + } + + public function keeps(): bool + { + return $this->manifests; + } + + public function base(): string + { + return $this->name; + } + + /** + * @return list + */ + public function extra(): array + { + return $this->params; + } +} diff --git a/src/Video/Output/Cmaf.php b/src/Video/Output/Cmaf.php new file mode 100644 index 0000000..3827115 --- /dev/null +++ b/src/Video/Output/Cmaf.php @@ -0,0 +1,51 @@ +manifest = 'manifest.mpd'; + + // Segment addressing has to be explicit for the two manifests to stay + // in step with each other, and listing is what CMAF consumers expect. + $this->template = false; + $this->timeline = false; + } + + public function type(): string + { + return Output::CMAF; + } + + public function master(string $filename): static + { + $master = Name::file($filename, 'Master playlist'); + + $copy = clone $this; + $copy->master = $master; + + return $copy; + } + + public function masterFile(): string + { + return $this->master; + } +} diff --git a/src/Video/Output/Dash.php b/src/Video/Output/Dash.php new file mode 100644 index 0000000..c4a84bd --- /dev/null +++ b/src/Video/Output/Dash.php @@ -0,0 +1,176 @@ +template = $enabled; + + return $copy; + } + + /** + * Include an explicit timeline of segment durations. + */ + public function timeline(bool $enabled): static + { + $copy = clone $this; + $copy->timeline = $enabled; + + return $copy; + } + + public function manifest(string $filename): static + { + $manifest = Name::file($filename, 'Manifest'); + + $copy = clone $this; + $copy->manifest = $manifest; + + return $copy; + } + + /** + * Pattern for initialisation segments, in ffmpeg's template syntax. + */ + public function init(string $pattern): static + { + $pattern = Name::template($pattern, 'Initialisation segment pattern'); + + $copy = clone $this; + $copy->initName = $pattern; + + return $copy; + } + + /** + * Pattern for media segments, in ffmpeg's template syntax. + */ + public function media(string $pattern): static + { + $pattern = Name::template($pattern, 'Media segment pattern'); + + $copy = clone $this; + $copy->mediaName = $pattern; + + return $copy; + } + + /** + * Override how streams are grouped into adaptation sets. + */ + public function sets(string $definition): static + { + $definition = Name::argument($definition, 'Adaptation set definition'); + + $copy = clone $this; + $copy->sets = $definition; + + return $copy; + } + + public function templated(): bool + { + return $this->template; + } + + public function timelined(): bool + { + return $this->timeline; + } + + /** + * True when the manifest will list every segment individually. + */ + public function listed(): bool + { + return ! $this->template && ! $this->timeline; + } + + public function manifestFile(): string + { + return $this->manifest; + } + + public function initPattern(): string + { + return $this->initName ?? $this->name.'_init_$RepresentationID$.$ext$'; + } + + public function mediaPattern(): string + { + return $this->mediaName ?? $this->name.'_chunk_$RepresentationID$_$Number%05d$.$ext$'; + } + + /** + * How streams are grouped into adaptation sets. + * + * A set holds representations that are alternatives of one another, and a + * player picks one from each set. Video rungs are alternatives, so they share + * a set. Audio tracks in different languages are not β€” they are separate + * choices β€” so each gets a set of its own, which is also the only place DASH + * can record a language. + * + * @param int $video Video rungs, occupying the first output streams. + * @param int $audio Audio tracks, following them. + */ + public function adaptations(int $video = 1, int $audio = 1): string + { + if ($this->sets !== null) { + return $this->sets; + } + + $sets = []; + $id = 0; + + if ($video > 0) { + $sets[] = 'id='.$id++.',streams=v'; + } + + if ($audio === 1) { + $sets[] = 'id='.$id++.',streams=a'; + } else { + for ($track = 0; $track < $audio; $track++) { + $sets[] = 'id='.$id++.',streams='.($video + $track); + } + } + + return \implode(' ', $sets); + } +} diff --git a/src/Video/Output/Hls.php b/src/Video/Output/Hls.php new file mode 100644 index 0000000..553051b --- /dev/null +++ b/src/Video/Output/Hls.php @@ -0,0 +1,154 @@ + + */ + public const CONTAINERS = [self::MPEGTS, self::FMP4]; + + private string $segments = self::MPEGTS; + + private string $init = 'init.mp4'; + + private string $master = 'master.m3u8'; + + private string $url = ''; + + /** @var list */ + private array $flags = ['independent_segments']; + + public function type(): string + { + return Output::HLS; + } + + /** + * Transport stream segments, or fragmented MP4 for CMAF compatible output. + * + * Rejected here rather than by the muxer: a container it does not know is a + * typo, and a typo is worth catching before a ladder has been encoded. + * + * @throws Unsupported + */ + public function segments(string $type): static + { + if (! \in_array($type, self::CONTAINERS, true)) { + throw new Unsupported( + 'No HLS segment container named "'.$type.'"; expected one of ' + .\implode(', ', self::CONTAINERS), + ); + } + + $copy = clone $this; + $copy->segments = $type; + + return $copy; + } + + /** + * Name of the initialisation segment, used by fragmented MP4 output only. + */ + public function init(string $filename): static + { + $init = Name::file($filename, 'Initialisation segment'); + + $copy = clone $this; + $copy->init = $init; + + return $copy; + } + + public function master(string $filename): static + { + $master = Name::file($filename, 'Master playlist'); + + $copy = clone $this; + $copy->master = $master; + + return $copy; + } + + /** + * Prefix written in front of every segment reference in the playlists. + */ + public function url(string $url): static + { + $url = Name::prefix($url, 'Segment URL prefix'); + + $copy = clone $this; + $copy->url = $url; + + return $copy; + } + + /** + * @param list $flags + */ + public function flags(array $flags): static + { + foreach ($flags as $flag) { + Name::word($flag, 'HLS flag'); + } + + $copy = clone $this; + $copy->flags = $flags; + + return $copy; + } + + public function fragmented(): bool + { + return $this->segments === self::FMP4; + } + + public function container(): string + { + return $this->segments; + } + + public function extension(): string + { + return $this->fragmented() ? 'm4s' : 'ts'; + } + + public function initFile(): string + { + return $this->init; + } + + public function masterFile(): string + { + return $this->master; + } + + public function prefix(): string + { + return $this->url; + } + + /** + * @return list + */ + public function hlsFlags(): array + { + return $this->flags; + } +} diff --git a/src/Video/Package.php b/src/Video/Package.php new file mode 100644 index 0000000..7d8f789 --- /dev/null +++ b/src/Video/Package.php @@ -0,0 +1,122 @@ + $variants + * @param list $manifests + * @param array $metadata + */ + public function __construct( + private readonly array $variants = [], + private readonly array $manifests = [], + private readonly array $metadata = [], + private readonly float $duration = 0.0, + ) { + } + + /** + * @return list + */ + public function variants(): array + { + return $this->variants; + } + + /** + * Every segment across every variant, or just one variant's when named. + * + * @return list + */ + public function segments(?string $variant = null): array + { + $segments = []; + + foreach ($this->variants as $candidate) { + if ($variant !== null && $candidate->id !== $variant) { + continue; + } + + foreach ($candidate->segments as $segment) { + $segments[] = $segment; + } + } + + return $segments; + } + + /** + * Playlists written to disk. Empty when manifests were not kept. + * + * @return list + */ + public function manifests(): array + { + return $this->manifests; + } + + /** + * Every artifact produced, ready to be handed to a storage layer. + * + * @return list + */ + public function files(): array + { + $files = []; + + foreach ($this->manifests as $manifest) { + $files[] = $manifest->path; + } + + foreach ($this->segments() as $segment) { + $files[] = $segment->path; + } + + return \array_values(\array_unique($files)); + } + + /** + * Container level attributes, keyed by the manifest they came from. + * + * @return array + */ + public function metadata(): array + { + return $this->metadata; + } + + public function duration(): float + { + return $this->duration; + } + + public function variant(string $id): ?Variant + { + foreach ($this->variants as $variant) { + if ($variant->id === $id) { + return $variant; + } + } + + return null; + } + + public function size(): int + { + $size = 0; + + foreach ($this->segments() as $segment) { + $size += $segment->size; + } + + return $size; + } +} diff --git a/src/Video/Packager.php b/src/Video/Packager.php new file mode 100644 index 0000000..313250e --- /dev/null +++ b/src/Video/Packager.php @@ -0,0 +1,369 @@ + */ + protected array $reps = []; + + /** @var array> */ + protected array $listeners = []; + + /** + * @param Reporter|null $reporter Where status lines go; null keeps them on + * the terminal. + */ + public function __construct( + ?PackagerAdapter $adapter = null, + ?EncoderAdapter $encoder = null, + ?Probe $probe = null, + ?Reporter $reporter = null, + ) { + $this->prober = $probe ?? new FFprobe(); + $this->adapter = $adapter ?? new FFmpeg(); + + // A packager that can encode is its own encoder, so the common case + // builds one backend rather than two. Constructing one is a handful of + // assignments; nothing runs until open(). + $this->encoder = $encoder + ?? ($this->adapter instanceof EncoderAdapter ? $this->adapter : new FFmpeg()); + + // Config is pushed into the adapters rather than passed to their + // constructors, so an adapter supplied by the caller cannot quietly keep + // a probe of its own and ignore the one asked for here. + foreach ([$this->adapter, $this->encoder] as $backend) { + if ($backend instanceof Adapter) { + $backend->setProbe($this->prober); + + if ($reporter !== null) { + $backend->setReporter($reporter); + } + } + } + } + + /** + * Which backend is doing the packaging. + */ + public function getName(): string + { + return $this->adapter->getName(); + } + + /** + * Everything that can be learned about a source without decoding it. + */ + public function probe(string $path): Info + { + return $this->prober->read($path); + } + + /** + * Whether a source is usable as input. + */ + public function valid(string $path): bool + { + return $this->prober->valid($path); + } + + /** + * Start a job. Anything configured by a previous one is discarded, listeners + * excepted: those belong to this object and outlive the job. See off(). + */ + public function open(string $path): static + { + if (! \is_file($path)) { + throw new Input('Source "'.$path.'" does not exist'); + } + + $this->source = $path; + $this->encoding = null; + $this->target = null; + $this->reps = []; + + // Listeners are deliberately kept. They were registered on this object, + // not on the job, and clearing them here made on() before open() read + // perfectly and do nothing. Adapters still drop theirs on open(), which + // is why attach() re-registers at the point the work starts. + + return $this; + } + + public function format(Format $format): static + { + $this->encoding = $format; + + return $this; + } + + public function add(Representation ...$representations): static + { + foreach ($representations as $representation) { + $this->reps[] = $representation; + } + + return $this; + } + + public function output(Output $output): static + { + $this->target = $output; + + return $this; + } + + /** + * Register a listener for PROGRESS or LOG. + * + * Order does not matter: a listener registered before open() survives it and + * hears the job that follows. + * + * @param callable $listener + */ + public function on(string $event, callable $listener): static + { + $this->listeners[$event][] = $listener; + + return $this; + } + + /** + * Drop the listeners for one event, or every listener when given nothing. + * + * Listeners outlive a job, so this is how a reused Packager stops reporting + * to the last job's listener before it registers the next one's. + */ + public function off(?string $event = null): static + { + if ($event === null) { + $this->listeners = []; + } else { + unset($this->listeners[$event]); + } + + return $this; + } + + /** + * Encode and package an adaptive ladder. + */ + public function pack(string $dir): Package + { + $source = $this->ready(); + + if ($this->target === null) { + throw new Unsupported('No output format has been set'); + } + + if ($this->reps === []) { + throw new Unsupported('At least one representation is required'); + } + + $names = []; + + foreach ($this->reps as $rep) { + if (isset($names[$rep->name])) { + throw new Input('Representation name "'.$rep->name.'" is used more than once'); + } + + $names[$rep->name] = true; + } + + if ($this->encoding instanceof Copy) { + throw new Unsupported( + 'Stream copy cannot build an adaptive package: every video representation ' + .'is filtered to its requested size, and filtering cannot be combined with codec copy', + ); + } + + $interval = $this->encoding?->interval(); + + if ($interval !== null && $interval > $this->target->duration()) { + throw new Unsupported( + 'A keyframe every '.$interval.'s cannot cut '.$this->target->duration() + .'s segments; the keyframe interval has to be the segment length or a fraction of it', + ); + } + + if ($this->adapter instanceof EncoderAdapter) { + return $this->fused($this->adapter, $source, $this->target, $dir); + } + + return $this->staged($source, $this->target, $dir); + } + + /** + * One pass: the packager encodes and packages together. + */ + private function fused( + EncoderAdapter&PackagerAdapter $packager, + string $source, + Output $output, + string $dir, + ): Package { + $packager->open($source); + + if ($this->encoding !== null) { + $packager->format($this->encoding); + } + + $packager->add(...$this->reps); + $packager->output($output); + $this->attach($packager); + + return $packager->pack($dir); + } + + /** + * Two stages: encode each rung, then package the results. + */ + private function staged(string $source, Output $output, string $dir): Package + { + // Intermediates live beside the output rather than in the system + // temporary directory: they can run to gigabytes, and a small tmpfs or a + // different filesystem would be a poor place to put them. + $work = \rtrim($dir, '/').'/.staging'; + + if (! \is_dir($work) && ! @\mkdir($work, 0o755, true) && ! \is_dir($work)) { + throw new Runtime('Unable to create "'.$work.'"'); + } + + $total = \count($this->reps); + $files = []; + $format = $this->encoding ?? new X264(); + + if ($format->interval() === null) { + $format = $format->keyframe($output->duration()); + } + + try { + foreach ($this->reps as $position => $rep) { + $encoder = $this->encoder->open($source); + $encoder->format($format); + $encoder->add($rep); + + // Encoding is the slow part, so it owns most of the progress bar. + $encoder->on(self::PROGRESS, function (mixed $progress) use ($position, $total): void { + if (! $progress instanceof Progress) { + return; + } + + $share = 90.0 / $total; + + $this->emit(self::PROGRESS, new Progress( + percent: \round(($position * $share) + ($progress->percent * $share / 100), 2), + time: $progress->time, + frame: $progress->frame, + fps: $progress->fps, + speed: $progress->speed, + )); + }); + + $encoder->on(self::LOG, fn (mixed $line) => $this->emit(self::LOG, $line)); + + $files[] = ['path' => $encoder->encode($work.'/'.$rep->name.'.mp4'), 'rep' => $rep]; + } + + foreach ($files as $file) { + $this->adapter->open($file['path'], $file['rep']); + } + + $this->adapter->output($output); + + // Only the log is forwarded here, not progress: a progress-capable + // packager would report its own 0-100 after the encodes already + // claimed 0-90, and the bar would appear to go backwards. + $this->adapter->on(self::LOG, fn (mixed $line) => $this->emit(self::LOG, $line)); + + $package = $this->adapter->pack($dir); + + $this->emit(self::PROGRESS, new Progress(percent: 100.0)); + + return $package; + } finally { + foreach ($files as $file) { + @\unlink($file['path']); + } + + @\rmdir($work); + } + } + + /** + * Forwards this facade's listeners to whichever adapter is doing the work. + */ + private function attach(Observable $adapter): void + { + foreach ($this->listeners as $event => $listeners) { + foreach ($listeners as $listener) { + $adapter->on($event, $listener); + } + } + } + + private function emit(string $event, mixed $payload): void + { + foreach ($this->listeners[$event] ?? [] as $listener) { + $listener($payload); + } + } + + private function ready(): string + { + if ($this->source === null) { + throw new Input('No source has been opened'); + } + + return $this->source; + } +} diff --git a/src/Video/Parser/M3u8.php b/src/Video/Parser/M3u8.php new file mode 100644 index 0000000..a1cda5d --- /dev/null +++ b/src/Video/Parser/M3u8.php @@ -0,0 +1,293 @@ + + */ + public static function attributes(string $line): array + { + $attributes = []; + $offset = \strpos($line, ':'); + + if ($offset === false) { + return $attributes; + } + + \preg_match_all( + '/([A-Z0-9\-]+)=("[^"]*"|[^,]*)/', + \substr($line, $offset + 1), + $matches, + PREG_SET_ORDER, + ); + + foreach ($matches as $match) { + $attributes[$match[1]] = \trim($match[2], '"'); + } + + return $attributes; + } + + /** + * Every variant a master playlist advertises, in declaration order. + * + * @return list + */ + public static function master(string $path): array + { + $lines = self::lines($path); + $variants = []; + $pending = null; + + foreach ($lines as $line) { + if (\str_starts_with($line, '#EXT-X-MEDIA:')) { + $attributes = self::attributes($line); + + if (($attributes['TYPE'] ?? '') !== 'AUDIO' || ! isset($attributes['URI'])) { + continue; + } + + $variants[] = [ + 'id' => (string) \count($variants), + 'file' => \basename($attributes['URI']), + 'type' => Track::AUDIO, + 'codecs' => null, + 'bandwidth' => 0, + 'width' => null, + 'height' => null, + 'language' => $attributes['LANGUAGE'] ?? null, + 'group' => $attributes['GROUP-ID'] ?? null, + ]; + + continue; + } + + if (\str_starts_with($line, '#EXT-X-STREAM-INF:')) { + $pending = self::attributes($line); + + continue; + } + + if ($line === '' || \str_starts_with($line, '#')) { + continue; + } + + if ($pending === null) { + continue; + } + + [$width, $height] = self::resolution($pending['RESOLUTION'] ?? null); + + $variants[] = [ + 'id' => (string) \count($variants), + 'file' => \basename($line), + 'type' => Track::VIDEO, + 'codecs' => $pending['CODECS'] ?? null, + 'bandwidth' => (int) ($pending['BANDWIDTH'] ?? 0), + 'width' => $width, + 'height' => $height, + 'language' => null, + 'group' => $pending['AUDIO'] ?? null, + ]; + + $pending = null; + } + + return $variants; + } + + /** + * Segments of one media playlist, plus its playlist level attributes. + * + * @return array{target: float, version: int, segments: list} + */ + public static function media(string $path): array + { + $lines = self::lines($path); + $segments = []; + $target = 0.0; + $version = 0; + $duration = 0.0; + + foreach ($lines as $line) { + if (\str_starts_with($line, '#EXT-X-TARGETDURATION:')) { + $target = (float) \substr($line, 22); + + continue; + } + + if (\str_starts_with($line, '#EXT-X-VERSION:')) { + $version = (int) \substr($line, 15); + + continue; + } + + if (\str_starts_with($line, '#EXT-X-MAP:')) { + $attributes = self::attributes($line); + + if (isset($attributes['URI'])) { + $segments[] = [ + 'file' => \basename($attributes['URI']), + 'duration' => 0.0, + 'init' => true, + ]; + } + + continue; + } + + if (\str_starts_with($line, '#EXTINF:')) { + $duration = (float) \rtrim(\substr($line, 8), ','); + + continue; + } + + if ($line === '' || \str_starts_with($line, '#')) { + continue; + } + + $segments[] = [ + 'file' => \basename($line), + 'duration' => $duration, + 'init' => false, + ]; + + $duration = 0.0; + } + + return ['target' => $target, 'version' => $version, 'segments' => $segments]; + } + + /** + * Reads a master playlist and everything it points at. + * + * @return array{variants: list, metadata: array, playlists: list} + */ + public static function read(string $master, string $dir): array + { + $dir = \rtrim($dir, '/'); + $variants = []; + $playlists = []; + $target = 0.0; + $version = 0; + + foreach (self::master($master) as $entry) { + $playlist = $dir.'/'.$entry['file']; + + if (! \is_file($playlist)) { + throw new Runtime('Playlist "'.$entry['file'].'" is missing from the package'); + } + + $playlists[] = $playlist; + $media = self::media($playlist); + $target = \max($target, $media['target']); + $version = \max($version, $media['version']); + + $segments = []; + $number = 0; + + foreach ($media['segments'] as $segment) { + $segments[] = self::segment($entry['id'], $dir, $segment, $number); + + if (! $segment['init']) { + $number++; + } + } + + $variants[] = new Variant( + id: $entry['id'], + type: $entry['type'], + codecs: $entry['codecs'], + bandwidth: $entry['bandwidth'], + width: $entry['width'], + height: $entry['height'], + language: $entry['language'], + target: $media['target'], + segments: $segments, + playlist: $playlist, + ); + } + + return [ + 'variants' => $variants, + 'metadata' => ['targetDuration' => $target, 'version' => $version], + 'playlists' => $playlists, + ]; + } + + /** + * @param array{file: string, duration: float, init: bool} $segment + */ + private static function segment(string $variant, string $dir, array $segment, int $number): Segment + { + $path = $dir.'/'.$segment['file']; + + if (! \is_file($path)) { + throw new Runtime('Segment "'.$segment['file'].'" is missing from the package'); + } + + return new Segment( + variant: $variant, + file: $segment['file'], + path: $path, + duration: $segment['duration'], + init: $segment['init'], + number: $segment['init'] ? 0 : $number, + size: (int) \filesize($path), + ); + } + + /** + * @return array{0: ?int, 1: ?int} + */ + private static function resolution(?string $value): array + { + if ($value === null || ! \str_contains($value, 'x')) { + return [null, null]; + } + + [$width, $height] = \explode('x', $value, 2); + + return [(int) $width, (int) $height]; + } + + /** + * @return list + */ + private static function lines(string $path): array + { + $body = \is_file($path) ? \file_get_contents($path) : false; + + if ($body === false) { + throw new Runtime('Unable to read playlist "'.$path.'"'); + } + + $lines = []; + + foreach (\preg_split('/\R/', $body) ?: [] as $line) { + $lines[] = \trim($line); + } + + return $lines; + } +} diff --git a/src/Video/Parser/Mpd.php b/src/Video/Parser/Mpd.php new file mode 100644 index 0000000..d382266 --- /dev/null +++ b/src/Video/Parser/Mpd.php @@ -0,0 +1,480 @@ +, metadata: array} + */ + public static function read(string $path, string $dir): array + { + $dir = \rtrim($dir, '/'); + $document = self::load($path); + $root = $document->documentElement; + + if ($root === null) { + throw new Runtime('Manifest "'.$path.'" has no root element'); + } + + $duration = self::seconds($root->getAttribute('mediaPresentationDuration')); + + // Segment length is declared once, on the root, as an ISO 8601 duration. + // Every variant shares it, so it is read here rather than looked for on + // an AdaptationSet that never carries it. + $target = self::seconds($root->getAttribute('maxSegmentDuration')); + + $metadata = [ + 'profiles' => $root->getAttribute('profiles'), + 'type' => $root->getAttribute('type'), + 'mediaPresentationDuration' => $root->getAttribute('mediaPresentationDuration'), + 'maxSegmentDuration' => $root->getAttribute('maxSegmentDuration'), + 'minBufferTime' => $root->getAttribute('minBufferTime'), + 'duration' => $duration, + ]; + + $variants = []; + + foreach ($root->getElementsByTagName('AdaptationSet') as $index => $set) { + foreach ($set->getElementsByTagName('Representation') as $representation) { + $variants[] = self::variant( + $set, + $representation, + (string) $index, + $dir, + $duration, + $target, + ); + } + } + + return ['variants' => $variants, 'metadata' => $metadata]; + } + + private static function variant( + DOMElement $set, + DOMElement $representation, + string $group, + string $dir, + float $duration, + float $target, + ): Variant { + $id = $representation->getAttribute('id'); + $id = $id !== '' ? $id : $group; + + $mime = $representation->getAttribute('mimeType'); + $mime = $mime !== '' ? $mime : $set->getAttribute('mimeType'); + + $type = \str_starts_with($mime, 'audio') ? Track::AUDIO : Track::VIDEO; + $contentType = $set->getAttribute('contentType'); + + if ($contentType !== '') { + $type = $contentType === 'audio' ? Track::AUDIO : Track::VIDEO; + } + + // Segment names can be described by a formula that includes the + // bandwidth, so it has to be known before they can be resolved. + $bandwidth = (int) $representation->getAttribute('bandwidth'); + + [$segments, $timescale, $start] = self::segments( + $set, + $representation, + $id, + $dir, + $duration, + $bandwidth, + ); + + $media = \array_filter( + $segments, + static fn (Segment $segment): bool => ! $segment->init, + ); + + if ($media === []) { + throw new Runtime('Representation "'.$id.'" contains no media segments'); + } + + $language = $set->getAttribute('lang'); + + return new Variant( + id: $id, + type: $type, + mimeType: $mime !== '' ? $mime : null, + codecs: self::text($representation->getAttribute('codecs')), + bandwidth: $bandwidth, + width: self::number($representation, $set, 'width'), + height: self::number($representation, $set, 'height'), + sar: self::text($representation->getAttribute('sar')), + sampleRate: self::number($representation, $set, 'audioSamplingRate'), + language: $language !== '' && $language !== 'und' ? $language : null, + timescale: $timescale, + startNumber: $start, + target: $target > 0.0 ? $target : self::longest($segments), + segments: $segments, + ); + } + + /** + * @return array{0: list, 1: int, 2: int} + */ + private static function segments( + DOMElement $set, + DOMElement $representation, + string $id, + string $dir, + float $duration, + int $bandwidth, + ): array { + // A Representation describes its own segments when it says anything at + // all, and only falls back to the AdaptationSet when it says nothing. + // Both of its own elements are therefore checked before either of the + // set's, or a rung carrying a template would be handed the set's list. + foreach ([$representation, $set] as $element) { + $list = self::child($element, 'SegmentList'); + + if ($list !== null) { + return self::listed($list, $id, $dir); + } + + $template = self::child($element, 'SegmentTemplate'); + + if ($template !== null) { + return self::templated($template, $id, $dir, $duration, $bandwidth); + } + } + + return [[], 0, 0]; + } + + /** + * Every segment written out one by one. + * + * @return array{0: list, 1: int, 2: int} + */ + private static function listed(DOMElement $list, string $id, string $dir): array + { + $timescale = (int) $list->getAttribute('timescale'); + $start = (int) $list->getAttribute('startNumber'); + $segments = []; + + $duration = $timescale > 0 + ? ((float) $list->getAttribute('duration')) / $timescale + : 0.0; + + // Scoped to the list itself, which holds nothing but its own segments, + // so reaching past a direct child cannot stray into another rung. + $init = self::child($list, 'Initialization') ?? self::descendant($list, 'Initialization'); + + if ($init !== null && $init->getAttribute('sourceURL') !== '') { + $segments[] = self::segment($id, $dir, $init->getAttribute('sourceURL'), 0.0, true, 0); + } + + $number = $start; + + foreach ($list->getElementsByTagName('SegmentURL') as $url) { + $segments[] = self::segment($id, $dir, $url->getAttribute('media'), $duration, false, $number); + $number++; + } + + return [$segments, $timescale, $start]; + } + + /** + * Segment names described by formula, resolved into real filenames. + * + * @return array{0: list, 1: int, 2: int} + */ + private static function templated( + DOMElement $template, + string $id, + string $dir, + float $duration, + int $bandwidth, + ): array { + $timescale = (int) $template->getAttribute('timescale'); + $timescale = $timescale > 0 ? $timescale : 1; + $start = $template->hasAttribute('startNumber') + ? (int) $template->getAttribute('startNumber') + : 1; + + $segments = []; + $init = $template->getAttribute('initialization'); + + if ($init !== '') { + $segments[] = self::segment($id, $dir, self::expand($init, $id, 0, $bandwidth), 0.0, true, 0); + } + + $timeline = self::child($template, 'SegmentTimeline'); + $media = $template->getAttribute('media'); + + if ($timeline !== null) { + $number = $start; + + // Media time, in timescale units, of the segment about to be read. + // A name built from $Time$ is the running total of the durations + // declared ahead of it, so it has to be carried across entries. + $time = 0; + + foreach ($timeline->getElementsByTagName('S') as $entry) { + $units = (int) $entry->getAttribute('d'); + $length = $units / $timescale; + $repeat = $entry->hasAttribute('r') ? (int) $entry->getAttribute('r') : 0; + + // An entry is free to say where it begins, which is how a + // timeline records a gap or starts its clock somewhere other + // than zero. Only when it says nothing does the total carry on. + if ($entry->hasAttribute('t')) { + $time = (int) $entry->getAttribute('t'); + } + + for ($i = 0; $i <= $repeat; $i++) { + $segments[] = self::segment( + $id, + $dir, + self::expand($media, $id, $number, $bandwidth, $time), + $length, + false, + $number, + ); + $number++; + $time += $units; + } + } + + return [$segments, $timescale, $start]; + } + + $units = (int) $template->getAttribute('duration'); + $length = $units / $timescale; + + if ($length <= 0 || $duration <= 0) { + return [$segments, $timescale, $start]; + } + + $count = (int) \ceil($duration / $length); + + for ($i = 0; $i < $count; $i++) { + $number = $start + $i; + $segments[] = self::segment( + $id, + $dir, + self::expand($media, $id, $number, $bandwidth, $i * $units), + $length, + false, + $number, + ); + } + + return [$segments, $timescale, $start]; + } + + /** + * Substitutes the identifiers DASH allows inside a segment name. + * + * $Number$, $Bandwidth$ and $Time$ each take an optional printf width, which + * is how a manifest asks for zero padded names, and $$ is how it asks for a + * literal dollar sign. Every identifier a name is allowed to carry is + * resolved here: one left behind would be looked for on disk verbatim, and + * reported as a missing segment rather than as a name nothing understood. + * + * @param int $time Media time of this segment, in timescale units. + */ + public static function expand( + string $pattern, + string $id, + int $number, + int $bandwidth = 0, + int $time = 0, + ): string { + $name = \str_replace('$RepresentationID$', $id, $pattern); + + foreach (['Number' => $number, 'Bandwidth' => $bandwidth, 'Time' => $time] as $identifier => $value) { + $name = \preg_replace_callback( + '/\$'.$identifier.'(%0?\d*d)?\$/', + // The width group needs at least "%d" to match, so it is either + // absent or usable β€” there is no empty case to guard against. + static fn (array $match): string => isset($match[1]) + ? \sprintf($match[1], $value) + : (string) $value, + $name, + ) ?? $name; + } + + return \str_replace('$$', '$', $name); + } + + private static function segment( + string $variant, + string $dir, + string $file, + float $duration, + bool $init, + int $number, + ): Segment { + $file = \basename($file); + $path = $dir.'/'.$file; + + if (! \is_file($path)) { + throw new Runtime('Segment "'.$file.'" is missing from the package'); + } + + return new Segment( + variant: $variant, + file: $file, + path: $path, + duration: $duration, + init: $init, + number: $number, + size: (int) \filesize($path), + ); + } + + /** + * The longest segment in a list, which is what a target duration means. + * + * Used only when the manifest declares no maximum of its own. + * + * @param list $segments + */ + private static function longest(array $segments): float + { + $longest = 0.0; + + foreach ($segments as $segment) { + $longest = \max($longest, $segment->duration); + } + + return $longest; + } + + /** + * Reads an ISO 8601 duration such as PT1M4.5S. + * + * The date half is accepted as well as the time half, because a manifest is + * free to write PT2.0S as P0Y0M0DT0H0M2.0S and mean the same thing. Years + * and months have no fixed length, so a duration using them is refused + * rather than guessed at. + */ + public static function seconds(string $value): float + { + if ($value === '') { + return 0.0; + } + + $pattern = '/^P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?' + .'(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/'; + + if (\preg_match($pattern, $value, $match) !== 1) { + return 0.0; + } + + // A bare P or PT matches, every group absent, and totals zero on its own. + $part = static fn (int $at): float => (float) ($match[$at] ?? 0); + + // Years and months are the two designators with no fixed length in + // seconds, so a duration built from them is refused, not approximated. + if ($part(1) > 0.0 || $part(2) > 0.0) { + return 0.0; + } + + return $part(3) * 604800 + + $part(4) * 86400 + + $part(5) * 3600 + + $part(6) * 60 + + $part(7); + } + + /** + * The first direct child with this tag. + * + * Direct children only, deliberately. A descendant search run from an + * AdaptationSet walks into its Representations, so a rung that declared + * nothing would be handed whatever a sibling rung declared. + */ + private static function child(DOMElement $element, string $tag): ?DOMElement + { + foreach ($element->childNodes as $node) { + if ($node instanceof DOMElement && $node->tagName === $tag) { + return $node; + } + } + + return null; + } + + /** + * The first descendant with this tag, at any depth. + * + * Only safe on an element that cannot contain another rung's markup. + */ + private static function descendant(DOMElement $element, string $tag): ?DOMElement + { + $first = $element->getElementsByTagName($tag)->item(0); + + return $first instanceof DOMElement ? $first : null; + } + + private static function number(DOMElement $representation, DOMElement $set, string $name): ?int + { + $value = $representation->getAttribute($name); + + if ($value === '') { + $value = $set->getAttribute($name); + } + + if ($value === '') { + $value = $set->getAttribute('max'.\ucfirst($name)); + } + + return $value === '' ? null : (int) $value; + } + + private static function text(string $value): ?string + { + return $value === '' ? null : $value; + } + + private static function load(string $path): DOMDocument + { + // Read first, parse second. The libxml error flag below is process + // global, and under a coroutine runtime the file read is where this + // function can be suspended β€” so the read happens before the flag is + // touched, and nothing between set and restore can yield. + $body = \is_file($path) ? \file_get_contents($path) : false; + + if ($body === false) { + throw new Runtime('Unable to read manifest "'.$path.'"'); + } + + $document = new DOMDocument(); + $previous = \libxml_use_internal_errors(true); + $loaded = $body !== '' && $document->loadXML($body); + \libxml_clear_errors(); + \libxml_use_internal_errors($previous); + + if ($loaded === false) { + throw new Runtime('Manifest "'.$path.'" is not valid XML'); + } + + return $document; + } +} diff --git a/src/Video/Process.php b/src/Video/Process.php new file mode 100644 index 0000000..ceb5a05 --- /dev/null +++ b/src/Video/Process.php @@ -0,0 +1,357 @@ + $command + * @param callable(string):void|null $stdout Called once per line written to stdout. + * @param callable(string):void|null $stderr Called once per line written to stderr. + * @param int $timeout Seconds to allow the command to run; 0 disables the limit. + * @return string The tail of stderr, which is where ffmpeg reports failures. + * + * @throws Runtime + */ + public static function run( + array $command, + ?callable $stdout = null, + ?callable $stderr = null, + int $timeout = 0, + ): string { + if ($command === []) { + throw new Runtime('Cannot run an empty command'); + } + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $pipes = []; + + // A missing binary raises a warning before returning false, and callers + // should get the exception rather than whatever the warning handler does. + $process = @\proc_open($command, $descriptors, $pipes); + + if (! \is_resource($process)) { + throw new Runtime('Unable to start "'.$command[0].'"', $command); + } + + \fclose($pipes[0]); + \stream_set_blocking($pipes[1], false); + \stream_set_blocking($pipes[2], false); + + $buffers = [1 => '', 2 => '']; + $errors = ''; + $started = \microtime(true); + $open = [1 => $pipes[1], 2 => $pipes[2]]; + + $interrupted = 0; + $closed = false; + + try { + while ($open !== []) { + // Checked before the wait rather than after the read, so a command + // that has gone quiet is still stopped on time. + if ($timeout > 0 && (\microtime(true) - $started) > $timeout) { + $failure = self::halt( + $process, + $open, + $errors, + 'Command "'.$command[0].'" timed out after '.$timeout.'s', + $command, + ); + $closed = true; + + throw $failure; + } + + $read = \array_values($open); + $write = null; + $except = null; + + if (@\stream_select($read, $write, $except, 0, 200000) === false) { + if (++$interrupted <= self::RETRIES) { + continue; + } + + $failure = self::halt( + $process, + $open, + $errors, + 'Lost contact with "'.$command[0].'" while reading its output', + $command, + ); + $closed = true; + + throw $failure; + } + + $interrupted = 0; + + foreach ($open as $key => $pipe) { + if (! \in_array($pipe, $read, true)) { + continue; + } + + $chunk = \fread($pipe, 8192); + + if ($chunk === false || $chunk === '') { + if (\feof($pipe)) { + if ($buffers[$key] !== '') { + self::emit($key, $buffers[$key], $stdout, $stderr); + $buffers[$key] = ''; + } + + \fclose($pipe); + unset($open[$key]); + } + + continue; + } + + if ($key === 2) { + $errors = \substr($errors.$chunk, -self::TAIL); + } + + $buffers[$key] .= $chunk; + + while (($break = \strpos($buffers[$key], "\n")) !== false) { + $line = \substr($buffers[$key], 0, $break); + $buffers[$key] = \substr($buffers[$key], $break + 1); + self::emit($key, $line, $stdout, $stderr); + } + + // ffmpeg separates progress blocks with \r when writing to a terminal. + while (($break = \strpos($buffers[$key], "\r")) !== false) { + $line = \substr($buffers[$key], 0, $break); + $buffers[$key] = \substr($buffers[$key], $break + 1); + + if (\trim($line) !== '') { + self::emit($key, $line, $stdout, $stderr); + } + } + + // Nothing says a stream has to send line breaks, and a binary + // one never will, so a buffer that has grown past a plausible + // line is handed over as it stands rather than held forever. + if (\strlen($buffers[$key]) >= self::LINE) { + self::emit($key, $buffers[$key], $stdout, $stderr); + $buffers[$key] = ''; + } + } + } + + $status = \proc_close($process); + $closed = true; + + if ($status !== 0) { + throw new Runtime( + 'Command "'.$command[0].'" failed with exit code '.$status, + $command, + \trim($errors), + $status, + ); + } + + return \trim($errors); + } finally { + // A user listener is application code and may throw. It must not + // strand the child process merely because control left the read + // loop by a route other than our own timeout/error exceptions. + if (! $closed) { + self::halt( + $process, + $open, + $errors, + 'Command "'.$command[0].'" was interrupted while reading its output', + $command, + ); + } + } + } + + /** + * Runs a command and returns everything it wrote to stdout. + * + * @param list $command + * + * @throws Runtime + */ + public static function read(array $command, int $timeout = 0): string + { + $output = ''; + + self::run($command, self::collector($output), null, $timeout); + + return $output; + } + + /** + * A line listener that appends everything it is given to a string. + * + * Collected output is held in memory, so a ceiling is part of the bargain: + * a command that will not stop talking is given up on rather than allowed + * to exhaust the process that ran it. The tail of stderr is bounded the + * same way inside the read loop. Pass 0 to collect without a limit. + * + * @param string $into Filled in as lines arrive. + * @param int $limit Bytes to hold at most; 0 for no limit. + * @return callable(string):void + */ + public static function collector(string &$into, int $limit = self::OUTPUT): callable + { + return static function (string $line) use (&$into, $limit): void { + if ($limit > 0 && \strlen($into) + \strlen($line) + 1 > $limit) { + throw new Runtime('Command produced more than '.$limit.' bytes of output'); + } + + $into .= $line."\n"; + }; + } + + /** + * Reports whether a binary can be executed at all. + */ + public static function exists(string $binary): bool + { + try { + self::run([$binary, '-version'], timeout: 10); + + return true; + } catch (Runtime) { + return false; + } + } + + /** + * Ends a command that cannot be waited on any longer, and describes why. + * + * Asking it to stop before insisting is the point: a backend given a chance + * to exit writes the reason it was unhappy to stderr on the way out, and + * closing the pipes first would throw away the only useful part of the + * failure. Whatever it manages to say inside the grace period is kept, and + * only a process still running afterwards is killed outright. + * + * @param resource $process + * @param array $open + * @param list $command + */ + private static function halt( + $process, + array $open, + string $errors, + string $message, + array $command, + ): Runtime { + \proc_terminate($process, 15); + + $deadline = \microtime(true) + self::GRACE; + $status = \proc_get_status($process); + + while ($status['running'] && \microtime(true) < $deadline) { + // Every pipe, not stderr alone: a backend blocked writing to a pipe + // nobody is draining cannot reach its own exit, and would have to be + // killed for it rather than being allowed to take the hint. + foreach ($open as $key => $pipe) { + $chunk = \fread($pipe, 8192); + + if ($key === 2 && \is_string($chunk) && $chunk !== '') { + $errors = \substr($errors.$chunk, -self::TAIL); + } + } + + \usleep(20000); + $status = \proc_get_status($process); + } + + if ($status['running']) { + \proc_terminate($process, 9); + + // Being reaped is what fills in how it ended, and a killed process + // is gone almost at once. Without waiting for that the status still + // reads "running" and the failure carries no code at all. + for ($attempt = 0; $attempt < 50 && $status['running']; $attempt++) { + \usleep(2000); + $status = \proc_get_status($process); + } + } + + foreach ($open as $handle) { + \fclose($handle); + } + + \proc_close($process); + + return new Runtime($message, $command, \trim($errors), self::ended($status)); + } + + /** + * How a command that had to be stopped ended, as an exit code. + * + * proc_close() reports -1 once proc_get_status() has reaped the child, so + * the status is the only thing still holding the truth. A signalled process + * has no exit code of its own, so the shell's convention of 128 plus the + * signal is borrowed: it tells a command that took the hint (143) from one + * that had to be killed (137) from one that failed on its own. + * + * @param array{running: bool, signaled: bool, termsig: int, exitcode: int} $status + */ + private static function ended(array $status): int + { + if ($status['signaled'] && $status['termsig'] > 0) { + return 128 + $status['termsig']; + } + + return \max(0, $status['exitcode']); + } + + /** + * @param callable(string):void|null $stdout + * @param callable(string):void|null $stderr + */ + private static function emit(int $key, string $line, ?callable $stdout, ?callable $stderr): void + { + $line = \rtrim($line, "\r\n"); + $listener = $key === 1 ? $stdout : $stderr; + + if ($listener !== null) { + $listener($line); + } + } +} diff --git a/src/Video/Progress.php b/src/Video/Progress.php new file mode 100644 index 0000000..1965182 --- /dev/null +++ b/src/Video/Progress.php @@ -0,0 +1,23 @@ +name = $name === null + ? ($height > 0 ? $height.'p' : 'audio') + : Name::label($name, 'Representation name'); + $this->maxrate = $maxrate ?? $video; + $this->bufsize = $bufsize ?? ($this->maxrate * 2); + } + + public function resolution(): string + { + return $this->width.'x'.$this->height; + } + + /** + * An audio only rung carries no frame size. + */ + public function scaled(): bool + { + return $this->width > 0 && $this->height > 0; + } +} diff --git a/src/Video/Segment.php b/src/Video/Segment.php new file mode 100644 index 0000000..c9065fe --- /dev/null +++ b/src/Video/Segment.php @@ -0,0 +1,22 @@ + $images + * @param list $cues + */ + public function __construct( + private readonly array $images = [], + private readonly array $cues = [], + private readonly ?string $vtt = null, + private readonly int $width = 0, + private readonly int $height = 0, + ) { + } + + /** + * @return list + */ + public function images(): array + { + return $this->images; + } + + /** + * @return list + */ + public function cues(): array + { + return $this->cues; + } + + /** + * Path of the written WebVTT timeline, or null when writing was disabled. + */ + public function vtt(): ?string + { + return $this->vtt; + } + + /** + * @return list + */ + public function files(): array + { + $files = $this->images; + + if ($this->vtt !== null) { + $files[] = $this->vtt; + } + + return $files; + } + + /** + * Width of a single thumbnail. + */ + public function width(): int + { + return $this->width; + } + + /** + * Height of a single thumbnail. + */ + public function height(): int + { + return $this->height; + } + + /** + * Renders the timeline as WebVTT, optionally rewriting each sheet URL. + * + * @param callable(string):string|null $url Maps a sheet filename to the URL to publish. + */ + public function render(?callable $url = null): string + { + $body = "WEBVTT\n"; + + foreach ($this->cues as $cue) { + $body .= "\n".$cue->render($url === null ? null : $url($cue->file))."\n"; + } + + return $body; + } +} diff --git a/src/Video/Thumb.php b/src/Video/Thumb.php new file mode 100644 index 0000000..c59ada4 --- /dev/null +++ b/src/Video/Thumb.php @@ -0,0 +1,87 @@ +at = $seconds; + + return $copy; + } + + /** + * Output width in pixels; height follows the source aspect. Zero keeps the + * original size. + */ + public function width(int $pixels): self + { + if ($pixels !== 0 && $pixels < 2) { + throw new Input('Thumbnail width must be 0 or at least 2 pixels, got '.$pixels); + } + + $copy = clone $this; + $copy->width = $pixels; + + return $copy; + } + + /** + * Encoder quality scale, where lower is better. ffmpeg's -qscale:v runs 1 to 31. + */ + public function quality(int $quality): self + { + if ($quality < 1 || $quality > 31) { + throw new Input('Thumbnail quality must be between 1 and 31, got '.$quality); + } + + $copy = clone $this; + $copy->quality = $quality; + + return $copy; + } + + public function at(): ?float + { + return $this->at; + } + + public function size(): int + { + return $this->width; + } + + public function scale(): int + { + return $this->quality; + } +} diff --git a/src/Video/Tile.php b/src/Video/Tile.php new file mode 100644 index 0000000..04c09ac --- /dev/null +++ b/src/Video/Tile.php @@ -0,0 +1,165 @@ +interval = $seconds; + + return $copy; + } + + public function width(int $pixels): self + { + if ($pixels < 2) { + throw new Input('Sprite width must be at least 2 pixels, got '.$pixels); + } + + $copy = clone $this; + $copy->width = $pixels; + + return $copy; + } + + public function grid(int $columns, int $rows): self + { + if ($columns < 1 || $rows < 1) { + throw new Input('Sprite grid must be at least 1x1, got '.$columns.'x'.$rows); + } + + $copy = clone $this; + $copy->columns = $columns; + $copy->rows = $rows; + + return $copy; + } + + /** + * Encoder quality scale, where lower is better. ffmpeg's -qscale:v runs 1 to 31. + */ + public function quality(int $quality): self + { + if ($quality < 1 || $quality > 31) { + throw new Input('Sprite quality must be between 1 and 31, got '.$quality); + } + + $copy = clone $this; + $copy->quality = $quality; + + return $copy; + } + + public function name(string $base): self + { + $name = Name::label($base, 'Sprite sheet name'); + + $copy = clone $this; + $copy->name = $name; + + return $copy; + } + + /** + * Whether to write the WebVTT timeline alongside the sheets. + */ + public function vtt(bool $write): self + { + $copy = clone $this; + $copy->vtt = $write; + + return $copy; + } + + /** + * Interval to use for a source of the given length. + */ + public function every(float $duration): float + { + if ($this->interval !== null) { + return $this->interval; + } + + return match (true) { + $duration < 120 => 2.0, + $duration < 600 => 5.0, + $duration < 1800 => 10.0, + $duration < 3600 => 20.0, + default => 30.0, + }; + } + + public function size(): int + { + return $this->width; + } + + public function columns(): int + { + return $this->columns; + } + + public function rows(): int + { + return $this->rows; + } + + public function cells(): int + { + return $this->columns * $this->rows; + } + + public function scale(): int + { + return $this->quality; + } + + public function base(): string + { + return $this->name; + } + + public function writes(): bool + { + return $this->vtt; + } +} diff --git a/src/Video/Track.php b/src/Video/Track.php new file mode 100644 index 0000000..558f38d --- /dev/null +++ b/src/Video/Track.php @@ -0,0 +1,34 @@ + $tags + */ + public function __construct( + public readonly int $index, + public readonly string $type, + public readonly ?string $codec = null, + public readonly ?string $language = null, + public readonly ?string $title = null, + public readonly bool $default = false, + public readonly bool $forced = false, + public readonly array $tags = [], + ) { + } +} diff --git a/src/Video/Variant.php b/src/Video/Variant.php new file mode 100644 index 0000000..4d97a35 --- /dev/null +++ b/src/Video/Variant.php @@ -0,0 +1,42 @@ + $segments + */ + public function __construct( + public readonly string $id, + public readonly string $type = Track::VIDEO, + public readonly ?string $mimeType = null, + public readonly ?string $codecs = null, + public readonly int $bandwidth = 0, + public readonly ?int $width = null, + public readonly ?int $height = null, + public readonly ?string $sar = null, + public readonly ?int $sampleRate = null, + public readonly ?string $language = null, + public readonly int $timescale = 0, + public readonly int $startNumber = 0, + public readonly float $target = 0.0, + public readonly array $segments = [], + public readonly ?string $playlist = null, + ) { + } + + public function resolution(): ?string + { + if (! $this->width || ! $this->height) { + return null; + } + + return $this->width.'x'.$this->height; + } +} diff --git a/tests/Video/Adapters.php b/tests/Video/Adapters.php new file mode 100644 index 0000000..fb8fe3f --- /dev/null +++ b/tests/Video/Adapters.php @@ -0,0 +1,378 @@ +available()) { + $this->markTestSkipped('the tools this adapter needs are not installed'); + } + + $dir = \sys_get_temp_dir().'/utopia-adapters-'.\bin2hex(\random_bytes(6)); + \mkdir($dir, 0o755, true); + $this->dir = $dir; + } + + protected function tearDown(): void + { + if (isset($this->dir)) { + $this->remove($this->dir); + } + } + + protected function remove(string $dir): void + { + foreach (\glob($dir.'/*') ?: [] as $path) { + \is_dir($path) ? $this->remove($path) : \unlink($path); + } + + @\rmdir($dir); + } + + protected function format(): X264 + { + return (new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast']); + } + + public function testNamesEveryAdapter(): void + { + $names = $this->names(); + + $this->assertSame($names['encoder'], $this->encoder()->getName()); + $this->assertSame($names['packager'], $this->packager()->getName()); + } + + public function testProbesASource(): void + { + $info = $this->encoder()->probe($this->source()); + + $this->assertGreaterThan(0, $info->duration); + $this->assertTrue($info->hasVideo || $info->hasAudio); + } + + /** + * Either facade can gate its input, so a caller holding only one of them + * does not need the other just to check a file. + */ + public function testAcceptsAUsableSource(): void + { + $this->assertTrue($this->encoder()->valid($this->source())); + $this->assertTrue($this->packager()->valid($this->source())); + } + + public function testRejectsAMissingSource(): void + { + $this->assertFalse($this->encoder()->valid($this->dir.'/nothing.mp4')); + } + + public function testEncodesASingleFile(): void + { + $path = $this->encoder() + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/out.mp4'); + + $this->assertFileExists($path); + $this->assertGreaterThan(0, (int) \filesize($path)); + } + + /** + * Both terminals end a job, so an adapter can serve one after another + * without the first job's inputs still being registered. + */ + public function testEncodesTwiceInARow(): void + { + $encoder = $this->encoder(); + + foreach ([0, 1] as $round) { + $path = $encoder + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/out'.$round.'.mp4'); + + $this->assertFileExists($path); + } + } + + public function testRefusesToEncodeALadder(): void + { + $this->expectExceptionMessage('use pack() for a ladder'); + + $this->encoder() + ->open($this->source()) + ->format($this->format()) + ->add( + new Representation(320, 240, 300), + new Representation(640, 480, 800), + ) + ->encode($this->dir.'/two.mp4'); + } + + public function testPacksALadder(): void + { + $package = $this->packager() + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->dir); + + $this->assertNotEmpty($package->segments()); + $this->assertGreaterThan(0, $package->size()); + + foreach ($package->segments() as $segment) { + $this->assertFileExists($segment->path); + } + } + + public function testRefusesToPackWithoutAnOutput(): void + { + $this->expectExceptionMessage('output format'); + + $this->packager() + ->open($this->source()) + ->add(new Representation(320, 240, 400)) + ->pack($this->dir); + } + + public function testRefusesToPackWithoutALadder(): void + { + $this->expectExceptionMessage('representation'); + + $this->packager() + ->open($this->source()) + ->output(new Hls()) + ->pack($this->dir); + } + + public function testReportsProgressThatOnlyClimbs(): void + { + $seen = []; + + $this->packager() + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->on(Packager::PROGRESS, function (mixed $progress) use (&$seen): void { + $this->assertInstanceOf(Progress::class, $progress); + $seen[] = $progress->percent; + }) + ->pack($this->dir); + + $this->assertNotEmpty($seen); + $this->assertSame(100.0, \end($seen)); + + $sorted = $seen; + \sort($sorted); + $this->assertSame($sorted, $seen, 'progress should never go backwards'); + } + + /** + * A listener registered once is heard by every job, the same number of times + * each β€” the adapter must not accumulate a fresh copy on each pack(). + */ + public function testReusingOnePackagerDoesNotRepeatEvents(): void + { + $packager = $this->packager(); + + // A counter per round, so accumulation inside the adapter shows up as + // one round's tally outgrowing the other's rather than hiding in a sum. + $counts = [0, 0, 0]; + $round = 0; + + // LOG rather than PROGRESS because it is deterministic: a backend emits + // the same number of lines for the same job twice over, where progress + // blocks arrive on a timer and vary run to run. A backend that says + // nothing at the default level counts zero every round and passes here + // trivially; the Mock suite is where these tallies are non-zero. + $packager->on(Packager::LOG, function () use (&$counts, &$round): void { + $counts[$round]++; + }); + + for ($round = 0; $round < 3; $round++) { + $packager + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->dir.'/round'.$round); + } + + // Accumulation shows up as 1, 2, 3 rather than as one round being wrong. + $this->assertSame( + [$counts[0], $counts[0], $counts[0]], + $counts, + 'the listener should fire the same number of times for each job', + ); + } + + /** + * on() reads naturally before open(), so it has to work there. + */ + public function testAListenerRegisteredBeforeOpenIsStillHeard(): void + { + $seen = 0; + + $this->packager() + ->on(Packager::PROGRESS, function () use (&$seen): void { + $seen++; + }) + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->dir); + + $this->assertGreaterThan(0, $seen); + } + + public function testAnEncoderListenerRegisteredBeforeOpenIsStillHeard(): void + { + $seen = 0; + + $this->encoder() + ->on(Encoder::PROGRESS, function () use (&$seen): void { + $seen++; + }) + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/early.mp4'); + + $this->assertGreaterThan(0, $seen); + } + + /** + * off() is what a reused facade uses to stop reporting to the last job's + * listener, now that listeners outlive a job. + */ + public function testListenersCanBeDropped(): void + { + $seen = 0; + + $packager = $this->packager()->on(Packager::PROGRESS, function () use (&$seen): void { + $seen++; + }); + + $packager->off(Packager::PROGRESS); + + $packager + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->dir); + + $this->assertSame(0, $seen); + } + + public function testEncoderListenersCanBeDropped(): void + { + $seen = 0; + + $encoder = $this->encoder()->on(Encoder::PROGRESS, function () use (&$seen): void { + $seen++; + }); + + $encoder->off(); + + $encoder + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/dropped.mp4'); + + $this->assertSame(0, $seen); + } + + public function testOpeningAgainForgetsThePreviousJob(): void + { + $packager = $this->packager() + ->open($this->source()) + ->output(new Hls()) + ->add(new Representation(320, 240, 400)); + + $packager->open($this->source()); + + $this->expectExceptionMessage('output format'); + + $packager->pack($this->dir); + } + + public function testGrabsAStill(): void + { + $path = $this->encoder()->grab( + $this->source(), + $this->dir.'/still.jpg', + (new Thumb())->width(160), + ); + + $this->assertFileExists($path); + $this->assertGreaterThan(0, (int) \filesize($path)); + } + + public function testTilesATimeline(): void + { + $sheet = $this->encoder()->tile($this->source(), $this->dir, (new Tile())->interval(2.0)); + + $this->assertNotEmpty($sheet->images()); + $this->assertNotEmpty($sheet->cues()); + $this->assertNotNull($sheet->vtt()); + $this->assertStringStartsWith('WEBVTT', (string) \file_get_contents((string) $sheet->vtt())); + } +} diff --git a/tests/Video/E2E/Base.php b/tests/Video/E2E/Base.php new file mode 100644 index 0000000..94efbac --- /dev/null +++ b/tests/Video/E2E/Base.php @@ -0,0 +1,140 @@ +markTestSkipped('ffmpeg and ffprobe are required'); + } + + $this->dir = self::$root.'/'.\bin2hex(\random_bytes(6)); + \mkdir($this->dir, 0o755, true); + } + + protected function tearDown(): void + { + if (isset($this->dir)) { + self::remove($this->dir); + } + } + + /** + * Eight seconds of colour bars and a tone, with a keyframe every two + * seconds so it can be cut into segments. + */ + protected static function video(): string + { + $path = self::$root.'/source.mp4'; + + if (\is_file($path)) { + return $path; + } + + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'testsrc=duration=8:size=640x480:rate=25', + '-f', 'lavfi', '-i', 'sine=frequency=440:duration=8', + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', + '-force_key_frames', 'expr:gte(t,n_forced*2)', + '-c:a', 'aac', '-shortest', + '-metadata', 'title=Utopia Test', + $path, + ], timeout: 120); + + return $path; + } + + protected static function silent(): string + { + $path = self::$root.'/silent.mp4'; + + if (\is_file($path)) { + return $path; + } + + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'testsrc=duration=4:size=320x240:rate=25', + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', + '-force_key_frames', 'expr:gte(t,n_forced*2)', + $path, + ], timeout: 120); + + return $path; + } + + protected static function audio(): string + { + $path = self::$root.'/audio.m4a'; + + if (\is_file($path)) { + return $path; + } + + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'sine=frequency=440:duration=6', + '-c:a', 'aac', + $path, + ], timeout: 120); + + return $path; + } + + protected static function remove(string $dir): void + { + foreach (\glob($dir.'/*') ?: [] as $path) { + \is_dir($path) ? self::remove($path) : \unlink($path); + } + + @\rmdir($dir); + } + + /** + * Confirms a file exists and is not empty. + */ + protected function assertWritten(string $path, string $message = ''): void + { + $this->assertFileExists($path, $message); + $this->assertGreaterThan(0, (int) \filesize($path), $message !== '' ? $message : $path.' is empty'); + } +} diff --git a/tests/Video/E2E/CmafTest.php b/tests/Video/E2E/CmafTest.php new file mode 100644 index 0000000..f7abdab --- /dev/null +++ b/tests/Video/E2E/CmafTest.php @@ -0,0 +1,198 @@ +open(self::video()) + ->format((new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add($rep ?? new Representation(320, 240, 400, 64)) + ->output(($output ?? new Cmaf())->segment(2)) + ->pack($this->dir); + } + + public function testWritesBothDescriptions(): void + { + $package = $this->pack(); + + $this->assertWritten($this->dir.'/manifest.mpd'); + $this->assertWritten($this->dir.'/master.m3u8'); + + $types = \array_map( + static fn (Manifest $manifest): string => $manifest->type, + $package->manifests(), + ); + + $this->assertContains(Manifest::DASH, $types); + $this->assertContains(Manifest::HLS, $types); + } + + /** + * This is the reason CMAF exists: one copy of the media, addressed by two + * different kinds of player. If the manifests ever drift onto separate + * segment sets the storage cost doubles and the benefit disappears. + */ + public function testBothDescriptionsPointAtTheSameSegments(): void + { + $package = $this->pack(); + + $fromManifest = []; + + foreach ($package->segments() as $segment) { + $fromManifest[] = $segment->file; + } + + $fromPlaylists = []; + + foreach (\glob($this->dir.'/*.m3u8') ?: [] as $playlist) { + if ($playlist === $this->dir.'/master.m3u8') { + continue; + } + + foreach (M3u8::media($playlist)['segments'] as $segment) { + $fromPlaylists[] = $segment['file']; + } + } + + $this->assertNotEmpty($fromPlaylists, 'no media playlists were written'); + + \sort($fromManifest); + \sort($fromPlaylists); + + $this->assertSame($fromManifest, $fromPlaylists); + } + + public function testSegmentsAreFragmentedMp4(): void + { + $package = $this->pack(); + + foreach ($package->segments() as $segment) { + $this->assertMatchesRegularExpression('/\.(m4s|mp4)$/', $segment->file); + $this->assertWritten($segment->path); + } + } + + public function testEverySegmentIsNamedInTheManifest(): void + { + $package = $this->pack(); + $manifest = (string) \file_get_contents($this->dir.'/manifest.mpd'); + + $this->assertStringContainsString('assertStringNotContainsString('segments() as $segment) { + $this->assertStringContainsString($segment->file, $manifest); + } + } + + /** + * The dash muxer never writes this tag, even though forcing keyframes makes + * it true, so the packager adds it once the run is done. + */ + public function testMasterDeclaresSegmentsIndependent(): void + { + $this->pack(); + + $master = (string) \file_get_contents($this->dir.'/master.m3u8'); + + $this->assertStringContainsString('#EXT-X-INDEPENDENT-SEGMENTS', $master); + $this->assertSame(1, \substr_count($master, '#EXT-X-INDEPENDENT-SEGMENTS')); + $this->assertStringStartsWith('#EXTM3U', $master); + } + + public function testMasterStaysValidAfterTheTagIsAdded(): void + { + $this->pack(); + + $variants = M3u8::master($this->dir.'/master.m3u8'); + + $this->assertNotEmpty($variants); + + foreach ($variants as $variant) { + $this->assertFileExists($this->dir.'/'.$variant['file']); + } + } + + public function testHandlesALadder(): void + { + $package = (new Packager()) + ->open(self::video()) + ->format((new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add( + new Representation(320, 240, 300, 64), + new Representation(640, 480, 800, 96), + ) + ->output((new Cmaf())->segment(2)) + ->pack($this->dir); + + $this->assertGreaterThanOrEqual(2, \count($package->variants())); + $this->assertWritten($this->dir.'/manifest.mpd'); + $this->assertWritten($this->dir.'/master.m3u8'); + } + + public function testEveryArtifactIsListedForUpload(): void + { + $package = $this->pack(); + + foreach ($package->files() as $file) { + $this->assertWritten($file); + } + + $this->assertContains($this->dir.'/manifest.mpd', $package->files()); + $this->assertContains($this->dir.'/master.m3u8', $package->files()); + } + + public function testDescriptionsCanBeDiscardedWhileSegmentsRemain(): void + { + $package = $this->pack((new Cmaf())->manifests(false)); + + $this->assertSame([], $package->manifests()); + $this->assertSame([], \glob($this->dir.'/*.m3u8') ?: []); + $this->assertSame([], \glob($this->dir.'/*.mpd') ?: []); + $this->assertNotEmpty($package->segments()); + + foreach ($package->segments() as $segment) { + $this->assertWritten($segment->path); + } + } + + public function testMasterCanBeRenamed(): void + { + $this->pack((new Cmaf())->master('index.m3u8')->manifest('video.mpd')); + + $this->assertWritten($this->dir.'/index.m3u8'); + $this->assertWritten($this->dir.'/video.mpd'); + } + + /** + * WebM cannot carry an HLS playlist, so asking for it should fail loudly + * rather than quietly producing half a package. + */ + public function testRejectsACodecItCannotDescribeTwice(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('CMAF cannot carry libvpx-vp9'); + + (new Packager()) + ->open(self::video()) + ->format(new VP9()) + ->add(new Representation(320, 240, 400, 64)) + ->output(new Cmaf()) + ->pack($this->dir); + } +} diff --git a/tests/Video/E2E/CoroutineTest.php b/tests/Video/E2E/CoroutineTest.php new file mode 100644 index 0000000..add12ce --- /dev/null +++ b/tests/Video/E2E/CoroutineTest.php @@ -0,0 +1,72 @@ +markTestSkipped('ext-swoole is required'); + } + + parent::setUp(); + } + + public function testConcurrentCoroutinesEachWithTheirOwnEncoderDoNotInterfere(): void + { + \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL); + + $source = self::video(); + $thumb = (new Thumb())->width(160); + $written = []; + $failed = []; + + \Swoole\Coroutine\run(function () use ($source, $thumb, &$written, &$failed): void { + foreach ([1.0, 3.0, 5.0] as $index => $second) { + \Swoole\Coroutine::create(function () use ($source, $thumb, $second, $index, &$written, &$failed): void { + try { + // One facade per coroutine; the Thumb is shared and + // configured per job without touching the original. + $encoder = new Encoder(reporter: new Silent()); + + $written[$index] = $encoder->grab( + $source, + $this->dir.'/still-'.$index.'.jpg', + $thumb->time($second), + ); + } catch (\Throwable $e) { + $failed[$index] = $e->getMessage(); + } + }); + } + }); + + $this->assertSame([], $failed); + $this->assertCount(3, $written); + + foreach ($written as $path) { + $this->assertWritten($path); + } + + // The shared Thumb was never written into by any of the three jobs. + $this->assertNull($thumb->at()); + } +} diff --git a/tests/Video/E2E/DashTest.php b/tests/Video/E2E/DashTest.php new file mode 100644 index 0000000..29a2c17 --- /dev/null +++ b/tests/Video/E2E/DashTest.php @@ -0,0 +1,194 @@ +open(self::video()) + ->format((new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add(...$reps) + ->output($output->segment(2)) + ->pack($this->dir); + } + + public function testPackagesWithTemplatedAddressing(): void + { + $package = $this->pack(new Dash(), new Representation(320, 240, 400, 64)); + + $this->assertWritten($this->dir.'/manifest.mpd'); + + $manifest = (string) \file_get_contents($this->dir.'/manifest.mpd'); + $this->assertStringContainsString('assertNotEmpty($package->segments()); + + foreach ($package->segments() as $segment) { + $this->assertWritten($segment->path); + } + } + + /** + * Turning both switches off makes the manifest name every segment. That is + * the only way a consumer can rewrite each reference to an address of its + * own, so it needs to keep working exactly as asked. + */ + public function testPackagesWithEverySegmentListed(): void + { + $package = $this->pack( + (new Dash())->template(false)->timeline(false), + new Representation(320, 240, 400, 64), + ); + + $manifest = (string) \file_get_contents($this->dir.'/manifest.mpd'); + + $this->assertStringContainsString('assertStringContainsString('assertStringNotContainsString('assertNotEmpty($package->segments()); + + foreach ($package->segments() as $segment) { + $this->assertStringContainsString($segment->file, $manifest); + $this->assertWritten($segment->path); + } + } + + public function testListedAndTemplatedDescribeTheSameSegments(): void + { + $listed = $this->pack( + (new Dash())->template(false)->timeline(false), + new Representation(320, 240, 400, 64), + ); + + $names = \array_map( + static fn ($segment): string => $segment->file, + $listed->segments(), + ); + + self::remove($this->dir); + \mkdir($this->dir, 0o755, true); + + $templated = $this->pack(new Dash(), new Representation(320, 240, 400, 64)); + + $this->assertSame( + \count($names), + \count($templated->segments()), + 'both addressing modes should describe the same number of segments', + ); + } + + public function testAudioGetsItsOwnAdaptationSet(): void + { + $package = $this->pack(new Dash(), new Representation(320, 240, 400, 64)); + + $types = \array_map( + static fn ($variant): string => $variant->type, + $package->variants(), + ); + + $this->assertContains(Track::VIDEO, $types); + $this->assertContains(Track::AUDIO, $types); + } + + public function testEveryRungLandsInOneManifest(): void + { + $package = $this->pack( + new Dash(), + new Representation(320, 240, 300, 64), + new Representation(640, 480, 800, 96), + ); + + $video = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::VIDEO, + )); + + $this->assertCount(2, $video); + $this->assertCount(1, \glob($this->dir.'/*.mpd') ?: []); + } + + public function testInitialisationSegmentIsRecognised(): void + { + $package = $this->pack( + (new Dash())->template(false)->timeline(false), + new Representation(320, 240, 400, 64), + ); + + $init = \array_values(\array_filter( + $package->segments(), + static fn ($segment): bool => $segment->init, + )); + + $this->assertNotEmpty($init); + $this->assertWritten($init[0]->path); + } + + public function testCarriesManifestMetadata(): void + { + $package = $this->pack(new Dash(), new Representation(320, 240, 400, 64)); + $metadata = $package->metadata(); + + $this->assertSame('static', $metadata['type']); + $this->assertNotEmpty($metadata['profiles']); + $this->assertIsFloat($metadata['duration']); + $this->assertEqualsWithDelta(8.0, $metadata['duration'], 1.0); + } + + public function testReportedSizesMatchTheFilesOnDisk(): void + { + $package = $this->pack(new Dash(), new Representation(320, 240, 400, 64)); + + foreach ($package->segments() as $segment) { + $this->assertSame(\filesize($segment->path), $segment->size, $segment->file); + } + } + + public function testManifestCanBeDiscarded(): void + { + $package = $this->pack( + (new Dash())->manifests(false), + new Representation(320, 240, 400, 64), + ); + + $this->assertSame([], $package->manifests()); + $this->assertFileDoesNotExist($this->dir.'/manifest.mpd'); + $this->assertNotEmpty($package->segments()); + } + + public function testManifestIsKeptByDefault(): void + { + $package = $this->pack(new Dash(), new Representation(320, 240, 400, 64)); + $manifests = $package->manifests(); + + $this->assertCount(1, $manifests); + $this->assertSame(Manifest::DASH, $manifests[0]->type); + $this->assertTrue($manifests[0]->main); + } + + public function testPackagesVp9(): void + { + $package = (new Packager()) + ->open(self::video()) + ->format((new VP9())->crf(40)->keyframe(2.0)->params(['-b:v', '0', '-cpu-used', '8'])) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Dash())->segment(2)) + ->pack($this->dir); + + $this->assertWritten($this->dir.'/manifest.mpd'); + $this->assertNotEmpty($package->segments()); + } +} diff --git a/tests/Video/E2E/EncodeTest.php b/tests/Video/E2E/EncodeTest.php new file mode 100644 index 0000000..a664313 --- /dev/null +++ b/tests/Video/E2E/EncodeTest.php @@ -0,0 +1,198 @@ +open(self::video()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/out.mp4'); + + $this->assertWritten($path); + + $info = $encoder->probe($path); + $this->assertSame(320, $info->width); + $this->assertSame(240, $info->height); + $this->assertTrue($info->hasAudio); + $this->assertEqualsWithDelta(8.0, $info->duration, 1.0); + } + + /** + * A rung asks for an exact frame size so the size advertised to players is + * the size delivered. A source of a different shape is fitted inside the + * box rather than stretched to fill it. + */ + public function testFitsASourceOfADifferentShapeIntoTheRequestedBox(): void + { + $encoder = new Encoder(); + + $path = $encoder + ->open(self::video()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->add(new Representation(640, 360, 500, 64)) + ->encode($this->dir.'/wide.mp4'); + + $info = $encoder->probe($path); + + $this->assertSame(640, $info->width); + $this->assertSame(360, $info->height); + } + + public function testEncodesWithoutALadder(): void + { + $path = (new Encoder()) + ->open(self::video()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->encode($this->dir.'/plain.mp4'); + + $this->assertWritten($path); + } + + public function testRepackagesWithoutReEncoding(): void + { + $started = \microtime(true); + + $path = (new Encoder()) + ->open(self::video()) + ->format(new Copy()) + ->encode($this->dir.'/copy.mkv'); + + $this->assertWritten($path); + $this->assertLessThan(30, \microtime(true) - $started); + } + + public function testEncodesHevc(): void + { + $encoder = new Encoder(); + + $path = $encoder + ->open(self::video()) + ->format((new HEVC())->crf(35)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/hevc.mp4'); + + $this->assertWritten($path); + $this->assertSame('hevc', $encoder->probe($path)->videoCodec); + } + + public function testPackagesHevcAsFragmentedHls(): void + { + $package = (new Packager()) + ->open(self::video()) + ->format((new HEVC())->crf(35)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segments(Hls::FMP4)->segment(2)) + ->pack($this->dir); + + $this->assertNotEmpty($package->segments()); + $this->assertWritten($this->dir.'/master.m3u8'); + } + + public function testPackagesHevcAsCmaf(): void + { + $package = (new Packager()) + ->open(self::video()) + ->format((new HEVC())->crf(35)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Cmaf())->segment(2)) + ->pack($this->dir); + + $this->assertNotEmpty($package->segments()); + $this->assertWritten($this->dir.'/manifest.mpd'); + $this->assertWritten($this->dir.'/master.m3u8'); + } + + public function testEncodesAnAudioOnlySource(): void + { + $encoder = new Encoder(); + + $path = $encoder + ->open(self::audio()) + ->format(new X264()) + ->add(new Representation(0, 0, 1, 96)) + ->encode($this->dir.'/audio.m4a'); + + $this->assertWritten($path); + + $info = $encoder->probe($path); + $this->assertTrue($info->hasAudio); + $this->assertFalse($info->hasVideo); + } + + public function testProgressReachesCompletion(): void + { + $seen = []; + + (new Encoder()) + ->open(self::video()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->on(Encoder::PROGRESS, function (Progress $progress) use (&$seen): void { + $seen[] = $progress->percent; + }) + ->encode($this->dir.'/progress.mp4'); + + $this->assertNotEmpty($seen); + $this->assertSame(100.0, \end($seen)); + + foreach ($seen as $percent) { + $this->assertGreaterThanOrEqual(0.0, $percent); + $this->assertLessThanOrEqual(100.0, $percent); + } + } + + public function testEncodingALadderIsRejected(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('use pack() for a ladder'); + + (new Encoder()) + ->open(self::video()) + ->format(new X264()) + ->add( + new Representation(320, 240, 300), + new Representation(640, 480, 800), + ) + ->encode($this->dir.'/two.mp4'); + } + + public function testAdapterCanBeReusedForASecondJob(): void + { + $encoder = new Encoder(); + + $first = $encoder + ->open(self::video()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/first.mp4'); + + $second = $encoder + ->open(self::silent()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->add(new Representation(160, 120, 200)) + ->encode($this->dir.'/second.mp4'); + + $this->assertWritten($first); + $this->assertWritten($second); + $this->assertSame(160, $encoder->probe($second)->width); + } +} diff --git a/tests/Video/E2E/FFmpegTest.php b/tests/Video/E2E/FFmpegTest.php new file mode 100644 index 0000000..4f06d39 --- /dev/null +++ b/tests/Video/E2E/FFmpegTest.php @@ -0,0 +1,129 @@ +dir.'/source.mp4'; + + if (! \is_file($path)) { + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'testsrc=duration=6:size=640x480:rate=25', + '-f', 'lavfi', '-i', 'sine=duration=6', + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', + '-force_key_frames', 'expr:gte(t,n_forced*2)', + '-c:a', 'aac', '-shortest', + $path, + ], timeout: 120); + } + + return $path; + } + + protected function names(): array + { + return [ + 'encoder' => 'ffmpeg', + 'packager' => 'ffmpeg', + ]; + } + + /** + * Both facades take the same backend, so an application that encodes and + * packages can build one adapter and hand it to each. + */ + public function testOneAdapterCanServeBothFacades(): void + { + $ffmpeg = new FFmpeg(); + $encoder = new Encoder($ffmpeg); + $packager = new Packager($ffmpeg); + + $this->assertSame('ffmpeg', $encoder->getName()); + $this->assertSame('ffmpeg', $packager->getName()); + + // And the shared adapter still does real work through either of them. + $this->assertGreaterThan(0, $encoder->probe($this->source())->duration); + $this->assertGreaterThan(0, $packager->probe($this->source())->duration); + } + + /** + * The display level decides how much ffmpeg says, and whatever it says + * arrives as LOG events. A clean encode at the default level says nothing. + */ + public function testTheDisplayLevelDecidesHowMuchIsReported(): void + { + $lines = function (?string $level): int { + $count = 0; + + (new Encoder(new FFmpeg(level: $level))) + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->on(Encoder::LOG, function () use (&$count): void { + $count++; + }) + ->encode($this->dir.'/level-'.($level ?? 'default').'.mp4'); + + return $count; + }; + + $quiet = $lines(FFmpeg::QUIET); + $default = $lines(null); + $verbose = $lines(FFmpeg::VERBOSE); + + $this->assertSame(0, $quiet, 'a quiet encoder should say nothing'); + $this->assertSame(0, $default, 'a clean encode has no errors to report'); + $this->assertGreaterThan(0, $verbose, 'a verbose encoder should explain itself'); + } + + /** + * ffmpeg packages and encodes, so a default Packager takes the single-pass + * route rather than writing intermediates first. + */ + public function testTheDefaultPackagerLeavesNoIntermediatesBehind(): void + { + $this->packager() + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->dir.'/fused'); + + $this->assertDirectoryDoesNotExist($this->dir.'/fused/.staging'); + } +} diff --git a/tests/Video/E2E/FrameTest.php b/tests/Video/E2E/FrameTest.php new file mode 100644 index 0000000..5839466 --- /dev/null +++ b/tests/Video/E2E/FrameTest.php @@ -0,0 +1,248 @@ +assertNotFalse($size, $path.' is not a readable image'); + + return [$size[0], $size[1]]; + } + + public function testGrabsAPosterFrame(): void + { + $path = (new Encoder())->grab(self::video(), $this->dir.'/poster.jpg'); + + $this->assertSame($this->dir.'/poster.jpg', $path); + $this->assertWritten($path); + $this->assertSame([320, 240], $this->size($path)); + } + + public function testGrabsAFrameAtAGivenMoment(): void + { + $path = (new Encoder())->grab( + self::video(), + $this->dir.'/at.jpg', + (new Thumb())->time(4.0), + ); + + $this->assertWritten($path); + } + + public function testHonoursTheRequestedWidth(): void + { + $path = (new Encoder())->grab( + self::video(), + $this->dir.'/wide.jpg', + (new Thumb())->width(160), + ); + + [$width, $height] = $this->size($path); + + $this->assertSame(160, $width); + $this->assertSame(120, $height, 'height should follow the source aspect'); + } + + public function testWritesPngWhenAsked(): void + { + $path = (new Encoder())->grab(self::video(), $this->dir.'/poster.png'); + + $this->assertWritten($path); + + $size = \getimagesize($path); + $this->assertNotFalse($size); + $this->assertSame('image/png', $size['mime']); + } + + public function testCreatesTheOutputDirectory(): void + { + $path = (new Encoder())->grab(self::video(), $this->dir.'/nested/poster.jpg'); + + $this->assertWritten($path); + } + + /** + * Audio files often carry their artwork as a single frame video stream, + * which is worth pulling out even though the file is not a video. + */ + public function testGrabsEmbeddedArtwork(): void + { + $cover = $this->dir.'/cover.jpg'; + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'color=c=red:s=320x320:d=1', + '-frames:v', '1', $cover, + ], timeout: 60); + + $source = $this->dir.'/with-art.mp3'; + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3', + '-i', $cover, + '-map', '0:a', '-map', '1:v', + '-c:a', 'libmp3lame', '-c:v', 'copy', + '-id3v2_version', '3', + '-disposition:v:0', 'attached_pic', + $source, + ], timeout: 60); + + $path = (new Encoder())->grab($source, $this->dir.'/art.jpg'); + + $this->assertWritten($path); + } + + public function testGrabbingFromSilenceIsAFailure(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('no image to grab'); + + (new Encoder())->grab(self::audio(), $this->dir.'/nothing.jpg'); + } + + public function testTilesTheWholeTimeline(): void + { + $sheet = (new Encoder())->tile(self::video(), $this->dir); + + $this->assertNotEmpty($sheet->images()); + $this->assertNotEmpty($sheet->cues()); + + foreach ($sheet->images() as $image) { + $this->assertWritten($image); + } + + $this->assertSame(160, $sheet->width()); + $this->assertSame(120, $sheet->height()); + } + + /** + * An eight second clip sampled every two seconds is four thumbnails, and + * they all fit on one five by five sheet. + */ + public function testCueCountFollowsDurationAndInterval(): void + { + $sheet = (new Encoder())->tile(self::video(), $this->dir, (new Tile())->interval(2.0)); + + $this->assertCount(1, $sheet->images()); + $this->assertCount(4, $sheet->cues()); + + $cues = $sheet->cues(); + $this->assertSame(0.0, $cues[0]->start); + $this->assertSame(2.0, $cues[0]->end); + $this->assertSame(2.0, $cues[1]->start); + $this->assertSame(6.0, $cues[3]->start); + } + + public function testCuesWalkTheGridLeftToRightThenDown(): void + { + $sheet = (new Encoder())->tile( + self::video(), + $this->dir, + (new Tile())->interval(2.0)->grid(2, 2)->width(100), + ); + + $cues = $sheet->cues(); + + $this->assertSame([0, 0], [$cues[0]->x, $cues[0]->y]); + $this->assertSame([100, 0], [$cues[1]->x, $cues[1]->y]); + $this->assertSame([0, $cues[0]->height], [$cues[2]->x, $cues[2]->y]); + $this->assertSame([100, $cues[0]->height], [$cues[3]->x, $cues[3]->y]); + } + + public function testSpillsOntoASecondSheetWhenTheGridFills(): void + { + $sheet = (new Encoder())->tile( + self::video(), + $this->dir, + (new Tile())->interval(2.0)->grid(2, 1), + ); + + $this->assertGreaterThanOrEqual(2, \count($sheet->images())); + + $files = \array_unique(\array_map( + static fn ($cue): string => $cue->file, + $sheet->cues(), + )); + + $this->assertGreaterThanOrEqual(2, \count($files)); + } + + /** + * Sheets are read back by walking the numbered files upward until one is + * missing, so tiling a shorter source into a directory a longer one has + * already used must not pick up the tail it left behind: those sheets show + * another timeline, and a consumer uploads whatever files() lists. + */ + public function testTilingAgainDoesNotReportTheLastRunsSheets(): void + { + $options = (new Tile())->interval(2.0)->grid(2, 1); + + // Eight seconds over two-cell sheets fills two of them; four seconds + // fills one, leaving the second behind. + $long = (new Encoder())->tile(self::video(), $this->dir, $options); + $short = (new Encoder())->tile(self::silent(), $this->dir, $options); + + $this->assertCount(2, $long->images()); + $this->assertCount(1, $short->images()); + + // The invariant behind the count: every sheet reported has to be one + // this timeline actually has cues for. + $this->assertLessThanOrEqual( + \count($short->images()) * $options->cells(), + \count($short->cues()), + ); + + foreach ($short->files() as $file) { + $this->assertWritten($file); + } + } + + public function testWritesAWebvttTimeline(): void + { + $sheet = (new Encoder())->tile(self::video(), $this->dir, (new Tile())->interval(2.0)); + + $this->assertNotNull($sheet->vtt()); + $this->assertWritten($sheet->vtt()); + + $vtt = (string) \file_get_contents((string) $sheet->vtt()); + + $this->assertStringStartsWith("WEBVTT\n", $vtt); + $this->assertStringContainsString('#xywh=', $vtt); + $this->assertStringContainsString('00:00:00.000 --> 00:00:02.000', $vtt); + } + + public function testTimelineCanBeSkipped(): void + { + $sheet = (new Encoder())->tile( + self::video(), + $this->dir, + (new Tile())->vtt(false), + ); + + $this->assertNull($sheet->vtt()); + $this->assertSame([], \glob($this->dir.'/*.vtt') ?: []); + $this->assertNotEmpty($sheet->cues()); + } + + public function testTilingSilenceIsAFailure(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('no video to tile'); + + (new Encoder())->tile(self::audio(), $this->dir); + } +} diff --git a/tests/Video/E2E/HlsTest.php b/tests/Video/E2E/HlsTest.php new file mode 100644 index 0000000..82ffece --- /dev/null +++ b/tests/Video/E2E/HlsTest.php @@ -0,0 +1,280 @@ +open(self::video()) + ->format((new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add(...$reps) + ->output($output->segment(2)) + ->pack($this->dir); + } + + public function testPackagesTransportStreamSegments(): void + { + $package = $this->pack(new Hls(), new Representation(320, 240, 400, 64)); + + $this->assertWritten($this->dir.'/master.m3u8'); + $this->assertNotEmpty($package->segments()); + + foreach ($package->segments() as $segment) { + $this->assertWritten($segment->path); + $this->assertStringEndsWith('.ts', $segment->file); + $this->assertGreaterThan(0, $segment->size); + } + } + + /** + * The whole point of a ladder is that one master describes all of it. An + * earlier design ran ffmpeg once per rung, and each run overwrote the + * master the previous one had just written. + */ + public function testOneMasterDescribesEveryRung(): void + { + $package = $this->pack( + new Hls(), + new Representation(320, 240, 300, 64), + new Representation(640, 480, 800, 96), + ); + + $master = (string) \file_get_contents($this->dir.'/master.m3u8'); + + $this->assertSame(2, \substr_count($master, '#EXT-X-STREAM-INF')); + $this->assertStringContainsString('RESOLUTION=320x240', $master); + $this->assertStringContainsString('RESOLUTION=640x480', $master); + + $video = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::VIDEO, + )); + + $this->assertCount(2, $video); + $this->assertSame([240, 480], [$video[0]->height, $video[1]->height]); + } + + /** + * A segment can only start on a keyframe, so a job that never named a + * cadence has to take the segment length as one. Left to itself the encoder + * places keyframes on its own schedule β€” every 250 frames by default, which + * is longer than this whole fixture β€” so this eight second source would come + * back as one long segment however short a segment was asked for. + */ + public function testDefaultsAloneStillCutSegmentsAtTheLengthAskedFor(): void + { + $package = (new Packager()) + ->open(self::video()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(3)) + ->pack($this->dir); + + $video = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::VIDEO, + )); + + $this->assertCount(1, $video); + $this->assertGreaterThan( + 1, + \count($video[0]->segments), + 'the source was not cut at all, so the keyframes were never forced', + ); + + foreach ($video[0]->segments as $segment) { + $this->assertLessThan( + 4.5, + $segment->duration, + 'segment '.$segment->file.' ran past the length that was asked for', + ); + } + } + + public function testEachRungIsEncodedAtItsOwnSize(): void + { + $package = $this->pack( + new Hls(), + new Representation(320, 240, 300, 64), + new Representation(640, 480, 800, 96), + ); + + foreach ($package->variants() as $variant) { + if ($variant->type !== Track::VIDEO) { + continue; + } + + $this->assertNotEmpty($variant->segments, 'rung '.$variant->id.' produced nothing'); + $this->assertGreaterThan(0, $variant->bandwidth); + } + } + + public function testPackagesFragmentedMp4WithAnInitialisationSegment(): void + { + $package = $this->pack( + (new Hls())->segments(Hls::FMP4), + new Representation(320, 240, 400, 64), + ); + + $init = \array_values(\array_filter( + $package->segments(), + static fn ($segment): bool => $segment->init, + )); + + $this->assertNotEmpty($init, 'no initialisation segment was produced'); + $this->assertWritten($init[0]->path); + $this->assertSame(0.0, $init[0]->duration); + + $playlist = (string) \file_get_contents((string) $package->variants()[0]->playlist); + $this->assertStringContainsString('#EXT-X-MAP:URI=', $playlist); + $this->assertStringContainsString('#EXT-X-VERSION:7', $playlist); + + foreach ($package->segments() as $segment) { + if (! $segment->init) { + $this->assertStringEndsWith('.m4s', $segment->file); + } + } + } + + public function testSegmentsAreMarkedIndependent(): void + { + $this->pack(new Hls(), new Representation(320, 240, 400, 64)); + + $master = (string) \file_get_contents($this->dir.'/master.m3u8'); + + $this->assertStringContainsString('#EXT-X-INDEPENDENT-SEGMENTS', $master); + } + + public function testSegmentDurationsAddUpToTheSource(): void + { + $package = $this->pack(new Hls(), new Representation(320, 240, 400, 64)); + + $total = 0.0; + + foreach ($package->segments($package->variants()[0]->id) as $segment) { + $total += $segment->duration; + } + + $this->assertEqualsWithDelta(8.0, $total, 1.0); + } + + public function testReportedSizesMatchTheFilesOnDisk(): void + { + $package = $this->pack(new Hls(), new Representation(320, 240, 400, 64)); + + foreach ($package->segments() as $segment) { + $this->assertSame(\filesize($segment->path), $segment->size, $segment->file); + } + + $this->assertGreaterThan(0, $package->size()); + } + + public function testEveryArtifactIsListedForUpload(): void + { + $package = $this->pack(new Hls(), new Representation(320, 240, 400, 64)); + + foreach ($package->files() as $file) { + $this->assertWritten($file); + } + + $this->assertContains($this->dir.'/master.m3u8', $package->files()); + } + + /** + * A consumer that serves its own manifests should be able to keep the + * media and discard the playlists, without losing the structured data. + */ + public function testPlaylistsCanBeDiscardedWhileSegmentsRemain(): void + { + $package = $this->pack( + (new Hls())->manifests(false), + new Representation(320, 240, 400, 64), + ); + + $this->assertSame([], $package->manifests()); + $this->assertSame([], \glob($this->dir.'/*.m3u8') ?: []); + $this->assertNotEmpty($package->segments()); + + foreach ($package->segments() as $segment) { + $this->assertWritten($segment->path); + } + } + + public function testManifestsAreKeptByDefault(): void + { + $package = $this->pack(new Hls(), new Representation(320, 240, 400, 64)); + + $manifests = $package->manifests(); + $this->assertNotEmpty($manifests); + + $main = \array_values(\array_filter($manifests, static fn (Manifest $m): bool => $m->main)); + $this->assertCount(1, $main); + $this->assertSame('master.m3u8', $main[0]->file()); + $this->assertSame(Manifest::HLS, $main[0]->type); + } + + public function testProgressClimbsToCompletion(): void + { + $seen = []; + + (new Packager()) + ->open(self::video()) + ->format((new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->on(Packager::PROGRESS, function (Progress $progress) use (&$seen): void { + $seen[] = $progress->percent; + }) + ->pack($this->dir); + + $this->assertNotEmpty($seen); + $this->assertSame(100.0, \end($seen)); + + $sorted = $seen; + \sort($sorted); + $this->assertSame($sorted, $seen, 'progress should never go backwards'); + } + + public function testPackagesASilentSource(): void + { + $package = (new Packager()) + ->open(self::silent()) + ->format((new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400)) + ->output((new Hls())->segment(2)) + ->pack($this->dir); + + $this->assertNotEmpty($package->segments()); + + foreach ($package->variants() as $variant) { + $this->assertSame(Track::VIDEO, $variant->type); + } + } + + public function testNamingFlowsThroughToArtifacts(): void + { + $package = $this->pack( + (new Hls())->name('movie')->master('index.m3u8'), + new Representation(320, 240, 400, 64), + ); + + $this->assertWritten($this->dir.'/index.m3u8'); + + foreach ($package->segments() as $segment) { + $this->assertStringStartsWith('movie_', $segment->file); + } + } +} diff --git a/tests/Video/E2E/MediaTest.php b/tests/Video/E2E/MediaTest.php new file mode 100644 index 0000000..025bcb9 --- /dev/null +++ b/tests/Video/E2E/MediaTest.php @@ -0,0 +1,489 @@ +markTestSkipped('ffmpeg and ffprobe are required'); + } + + $this->dir = self::$root.'/'.\bin2hex(\random_bytes(6)); + \mkdir($this->dir, 0o755, true); + } + + protected function tearDown(): void + { + if (isset($this->dir)) { + self::remove($this->dir); + } + } + + private static function remove(string $dir): void + { + foreach (\glob($dir.'/*') ?: [] as $path) { + \is_dir($path) ? self::remove($path) : \unlink($path); + } + + @\rmdir($dir); + } + + /** + * @param list $args + */ + private static function ffmpeg(array $args, string $target): string + { + if (\is_file($target)) { + return $target; + } + + Process::run( + [...['ffmpeg', '-y', '-hide_banner', '-loglevel', 'error'], ...$args, $target], + timeout: 180, + ); + + return $target; + } + + /** + * Eight seconds of moving detail with a tone, keyframed every two seconds. + */ + private static function base(): string + { + return self::ffmpeg([ + '-f', 'lavfi', '-i', 'testsrc2=duration=8:size=640x360:rate=30', + '-f', 'lavfi', '-i', 'sine=frequency=440:duration=8', + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', + '-force_key_frames', 'expr:gte(t,n_forced*2)', + '-c:a', 'aac', '-shortest', + ], self::$root.'/base.mp4'); + } + + private static function format(): X264 + { + return (new X264())->crf(32)->keyframe(2.0)->params(['-preset', 'ultrafast']); + } + + /** + * Non-square pixels: stored 4:3, displayed wider. + */ + public function testReadsAnamorphicPixels(): void + { + $path = self::ffmpeg([ + '-i', self::base(), + '-vf', 'scale=640:480,setsar=4:3', + '-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'copy', + ], self::$root.'/anamorphic.mp4'); + + $info = (new Encoder())->probe($path); + + $this->assertSame(640, $info->width); + $this->assertSame(480, $info->height); + + // The display ratio comes from the container, not from width/height, so + // it must not simply be 4:3 here. + $this->assertNotSame('4:3', $info->ratio()); + } + + /** + * Packaging normalises pixels, so a rung is delivered at the size requested + * however odd the source's were. + */ + public function testPackagesAnamorphicSourceAtTheRequestedSize(): void + { + $path = self::ffmpeg([ + '-i', self::base(), + '-vf', 'scale=640:480,setsar=4:3', + '-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'copy', + ], self::$root.'/anamorphic.mp4'); + + $encoder = new Encoder(); + + $encoded = $encoder + ->open($path) + ->format(self::format()) + ->add(new Representation(320, 180, 300, 64)) + ->encode($this->dir.'/out.mp4'); + + $result = $encoder->probe($encoded); + + $this->assertSame(320, $result->width); + $this->assertSame(180, $result->height); + } + + public function testDetectsAVariableFrameRate(): void + { + $path = self::ffmpeg([ + '-i', self::base(), + '-vf', "select='not(mod(n,3))+gte(t,4)'", + '-fps_mode', 'vfr', + '-c:v', 'libx264', '-preset', 'ultrafast', '-an', + ], self::$root.'/vfr.mp4'); + + $this->assertSame('Variable', (new Encoder())->probe($path)->fpsMode); + } + + /** + * Phone footage carries its orientation in the display matrix rather than in + * the frame size. + */ + public function testReadsRotationFromTheDisplayMatrix(): void + { + $path = self::$root.'/rotated.mp4'; + + if (! \is_file($path)) { + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-display_rotation', '90', '-i', self::base(), '-c', 'copy', $path, + ], timeout: 180); + } + + $info = (new Encoder())->probe($path); + + $this->assertSame(90, $info->rotation); + $this->assertSame(640, $info->width, 'rotation must not change the stored size'); + } + + public function testReadsSeveralAudioTracksAndTheirLanguages(): void + { + $path = self::ffmpeg([ + '-i', self::base(), '-i', self::base(), + '-map', '0:v', '-map', '0:a', '-map', '1:a', + '-metadata:s:a:0', 'language=eng', + '-metadata:s:a:1', 'language=spa', + '-c:v', 'copy', '-c:a', 'aac', + ], self::$root.'/multiaudio.mp4'); + + $info = (new Encoder())->probe($path); + + $this->assertCount(2, $info->audioTracks); + $this->assertSame('eng', $info->audioTracks[0]['language']); + $this->assertSame('spa', $info->audioTracks[1]['language']); + $this->assertCount(2, $info->tracks(Track::AUDIO)); + } + + /** + * Each tagged track becomes its own rendition, so a player can offer the + * choice. + */ + public function testPackagesEachAudioLanguageSeparately(): void + { + $path = self::ffmpeg([ + '-i', self::base(), '-i', self::base(), + '-map', '0:v', '-map', '0:a', '-map', '1:a', + '-metadata:s:a:0', 'language=eng', + '-metadata:s:a:1', 'language=spa', + '-c:v', 'copy', '-c:a', 'aac', + ], self::$root.'/multiaudio.mp4'); + + $package = (new Packager()) + ->open($path) + ->format(self::format()) + ->add(new Representation(320, 180, 300, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->dir); + + $audio = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::AUDIO, + )); + + $this->assertCount(2, $audio, 'both tagged tracks should be offered'); + + $master = (string) \file_get_contents($this->dir.'/master.m3u8'); + $this->assertStringContainsString('LANGUAGE="eng"', $master); + $this->assertStringContainsString('LANGUAGE="spa"', $master); + } + + public function testReadsAnEmbeddedSubtitleTrack(): void + { + $srt = self::$root.'/embedded.srt'; + \file_put_contents($srt, "1\n00:00:01,000 --> 00:00:03,000\nBonjour\n"); + + $path = self::ffmpeg([ + '-i', self::base(), '-i', $srt, + '-map', '0', '-map', '1', + '-c:v', 'copy', '-c:a', 'copy', '-c:s', 'mov_text', + '-metadata:s:s:0', 'language=fra', + ], self::$root.'/subtitled.mp4'); + + $subtitles = (new Encoder())->probe($path)->tracks(Track::SUBTITLE); + + $this->assertCount(1, $subtitles); + $this->assertSame('fra', $subtitles[0]->language); + } + + public function testReadsChaptersAndContainerTags(): void + { + $meta = self::$root.'/chapters.txt'; + \file_put_contents($meta, <<<'META' + ;FFMETADATA1 + title=Real World Clip + artist=Utopia + [CHAPTER] + TIMEBASE=1/1000 + START=0 + END=4000 + title=Opening + META); + + $path = self::ffmpeg([ + '-i', self::base(), '-i', $meta, + '-map_metadata', '1', '-map', '0', '-c', 'copy', + ], self::$root.'/chapters.mp4'); + + $info = (new Encoder())->probe($path); + + $this->assertNotEmpty($info->chapters); + $this->assertSame('Opening', $info->chapters[0]->title); + $this->assertSame('Real World Clip', $info->tags['title'] ?? null); + } + + /** + * Music files carry their artwork as a single frame video stream: no video to + * speak of, but a picture worth having. + */ + public function testGrabsArtworkFromASoundFile(): void + { + $cover = self::ffmpeg( + ['-i', self::base(), '-frames:v', '1', '-vf', 'scale=300:300'], + self::$root.'/cover.jpg', + ); + + $audio = self::ffmpeg(['-i', self::base(), '-vn', '-c:a', 'aac'], self::$root.'/audio.m4a'); + + $path = self::ffmpeg([ + '-i', $audio, '-i', $cover, + '-map', '0:a', '-map', '1:v', '-c', 'copy', + '-disposition:v:0', 'attached_pic', + ], self::$root.'/artwork.m4a'); + + $info = (new Encoder())->probe($path); + + $this->assertFalse($info->hasVideo, 'artwork is not video'); + $this->assertNotNull($info->cover, 'but the picture should be found'); + + $grabbed = (new Encoder())->grab($path, $this->dir.'/art.jpg'); + $this->assertNotFalse(\getimagesize($grabbed)); + } + + public function testASoundFileWithNoArtworkHasNothingToGrab(): void + { + $audio = self::ffmpeg(['-i', self::base(), '-vn', '-c:a', 'aac'], self::$root.'/audio.m4a'); + + $this->expectException(Input::class); + $this->expectExceptionMessage('no image to grab'); + + (new Encoder())->grab($audio, $this->dir.'/none.jpg'); + } + + public function testHandlesPortraitFootage(): void + { + $path = self::ffmpeg([ + '-i', self::base(), + '-vf', 'scale=360:640,setsar=1:1', + '-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'copy', + ], self::$root.'/portrait.mp4'); + + $encoder = new Encoder(); + $info = $encoder->probe($path); + + $this->assertSame(360, $info->width); + $this->assertSame(640, $info->height); + $this->assertSame('9:16', $info->ratio()); + + $sheet = $encoder->tile($path, $this->dir.'/timeline', (new Tile())->interval(2.0)->width(90)); + + $this->assertNotEmpty($sheet->cues()); + $this->assertGreaterThan($sheet->width(), $sheet->height(), 'thumbnails stay portrait'); + } + + public function testHandlesAClipShorterThanOneSegment(): void + { + $path = self::ffmpeg(['-i', self::base(), '-t', '0.5', '-c', 'copy'], self::$root.'/tiny.mp4'); + + $encoder = new Encoder(); + + $this->assertTrue($encoder->valid($path)); + + $package = (new Packager()) + ->open($path) + ->format(self::format()) + ->add(new Representation(320, 180, 300, 64)) + ->output((new Hls())->segment(6)) + ->pack($this->dir); + + $this->assertNotEmpty($package->segments()); + + $sheet = $encoder->tile($path, $this->dir.'/timeline'); + $this->assertNotEmpty($sheet->cues()); + } + + /** + * A DASH adaptation set may only hold rungs that are alternatives of one + * another, so they have to be the same shape. The muxer says so only once it + * is already running, and never says which rungs clashed. + */ + public function testRejectsAMixedShapeLadderForDash(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('share one aspect ratio'); + + (new Packager()) + ->open(self::base()) + ->format(self::format()) + ->add( + new Representation(320, 240, 300, 64), // 4:3 + new Representation(640, 360, 800, 96), // 16:9 + ) + ->output((new Dash())->segment(2)) + ->pack($this->dir); + } + + public function testRejectsAMixedShapeLadderForCmaf(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('share one aspect ratio'); + + (new Packager()) + ->open(self::base()) + ->format(self::format()) + ->add( + new Representation(320, 240, 300, 64), + new Representation(640, 360, 800, 96), + ) + ->output((new Cmaf())->segment(2)) + ->pack($this->dir); + } + + /** + * HLS has no such rule, so the same ladder is fine there. + */ + public function testAllowsAMixedShapeLadderForHls(): void + { + $package = (new Packager()) + ->open(self::base()) + ->format(self::format()) + ->add( + new Representation(320, 240, 300, 64), + new Representation(640, 360, 800, 96), + ) + ->output((new Hls())->segment(2)) + ->pack($this->dir); + + $video = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::VIDEO, + )); + + $this->assertCount(2, $video); + } + + /** + * The reason CMAF exists: two manifests, one copy of the media. + */ + public function testCmafManifestsShareOneSegmentSetOnRealFootage(): void + { + $package = (new Packager()) + ->open(self::base()) + ->format(self::format()) + ->add( + new Representation(320, 180, 300, 64), + new Representation(640, 360, 800, 96), + ) + ->output((new Cmaf())->segment(2)) + ->pack($this->dir); + + $fromManifest = \array_map( + static fn ($segment): string => $segment->file, + $package->segments(), + ); + + $fromPlaylists = []; + + foreach (\glob($this->dir.'/*.m3u8') ?: [] as $playlist) { + if (\basename($playlist) === 'master.m3u8') { + continue; + } + + foreach (M3u8::media($playlist)['segments'] as $segment) { + $fromPlaylists[] = $segment['file']; + } + } + + $this->assertNotEmpty($fromPlaylists); + + \sort($fromManifest); + \sort($fromPlaylists); + + $this->assertSame($fromManifest, $fromPlaylists); + } + + public function testGrabsAPosterAtAGivenMomentFromRealFootage(): void + { + $poster = (new Encoder())->grab( + self::base(), + $this->dir.'/poster.jpg', + (new Thumb())->time(5.0)->width(240), + ); + + $size = \getimagesize($poster); + + $this->assertNotFalse($size); + $this->assertSame(240, $size[0]); + } +} diff --git a/tests/Video/E2E/ProbeTest.php b/tests/Video/E2E/ProbeTest.php new file mode 100644 index 0000000..554b921 --- /dev/null +++ b/tests/Video/E2E/ProbeTest.php @@ -0,0 +1,137 @@ +probe(self::video()); + + $this->assertEqualsWithDelta(8.0, $info->duration, 0.5); + $this->assertSame(8000, (int) \round($info->milliseconds(), -3)); + $this->assertTrue($info->hasVideo); + $this->assertTrue($info->hasAudio); + $this->assertSame(640, $info->width); + $this->assertSame(480, $info->height); + $this->assertSame('h264', $info->videoCodec); + $this->assertSame('aac', $info->audioCodec); + $this->assertSame(25.0, $info->fps); + $this->assertSame('4:3', $info->ratio()); + $this->assertNotEmpty($info->raw); + } + + public function testReadsContainerTags(): void + { + $info = (new Encoder())->probe(self::video()); + + $this->assertSame('Utopia Test', $info->tags['title'] ?? null); + } + + public function testReadsEveryTrack(): void + { + $info = (new Encoder())->probe(self::video()); + + $this->assertCount(2, $info->tracks); + $this->assertCount(1, $info->tracks(Track::VIDEO)); + $this->assertCount(1, $info->tracks(Track::AUDIO)); + } + + public function testReadsASilentSource(): void + { + $info = (new Encoder())->probe(self::silent()); + + $this->assertTrue($info->hasVideo); + $this->assertFalse($info->hasAudio); + $this->assertNull($info->audioCodec); + } + + public function testReadsAnAudioOnlySource(): void + { + $info = (new Encoder())->probe(self::audio()); + + $this->assertFalse($info->hasVideo); + $this->assertTrue($info->hasAudio); + $this->assertNull($info->width); + $this->assertNotNull($info->sampleRate); + } + + public function testReadsChapters(): void + { + $source = $this->dir.'/chapters.mp4'; + $metadata = $this->dir.'/chapters.txt'; + + \file_put_contents($metadata, <<<'META' + ;FFMETADATA1 + [CHAPTER] + TIMEBASE=1/1000 + START=0 + END=4000 + title=Opening + + [CHAPTER] + TIMEBASE=1/1000 + START=4000 + END=8000 + title=Closing + META); + + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-i', self::video(), '-i', $metadata, + '-map_metadata', '1', '-c', 'copy', $source, + ], timeout: 120); + + $info = (new Encoder())->probe($source); + + $this->assertCount(2, $info->chapters); + $this->assertSame('Opening', $info->chapters[0]->title); + $this->assertEqualsWithDelta(4.0, $info->chapters[0]->end, 0.1); + $this->assertSame('Closing', $info->chapters[1]->title); + } + + public function testAcceptsAUsableSource(): void + { + $this->assertTrue((new Encoder())->valid(self::video())); + } + + public function testRejectsSomethingThatIsNotMedia(): void + { + $path = $this->dir.'/notes.txt'; + \file_put_contents($path, 'this is not a video'); + + $this->assertFalse((new Encoder())->valid($path)); + } + + public function testRejectsAMissingFile(): void + { + $this->assertFalse((new Encoder())->valid($this->dir.'/nothing.mp4')); + } + + public function testProbingAMissingFileIsAFailure(): void + { + $this->expectException(Input::class); + + (new Encoder())->probe($this->dir.'/nothing.mp4'); + } + + /** + * A Packager can gate its own input, so a caller holding only one of the two + * facades does not need the other just to check a file. + */ + public function testEitherFacadeReadsTheSameSource(): void + { + $info = (new Packager())->probe(self::video()); + + $this->assertSame((new Encoder())->probe(self::video())->duration, $info->duration); + $this->assertTrue((new Packager())->valid(self::video())); + } +} diff --git a/tests/Video/E2E/SamplesTest.php b/tests/Video/E2E/SamplesTest.php new file mode 100644 index 0000000..ddfe7de --- /dev/null +++ b/tests/Video/E2E/SamplesTest.php @@ -0,0 +1,782 @@ +markTestSkipped('ffmpeg and ffprobe are required'); + } + } + + /** @var array */ + private array $cleared = []; + + /** + * A directory under tests/samples/out, emptied the first time it is asked for + * in a test so a stale run cannot be mistaken for a fresh one β€” and left + * alone afterwards, so the same directory can be handed out twice. + */ + private function out(string $name): string + { + $dir = Samples::out().'/'.$name; + + if (! isset($this->cleared[$dir])) { + self::wipe($dir); + \mkdir($dir, 0o755, true); + $this->cleared[$dir] = true; + } + + return $dir; + } + + private static function wipe(string $dir): void + { + foreach (\glob($dir.'/*') ?: [] as $path) { + \is_dir($path) ? self::wipe($path) : \unlink($path); + } + + @\rmdir($dir); + } + + private function format(): X264 + { + return (new X264())->crf(32)->keyframe(2.0)->params(['-preset', 'ultrafast']); + } + + /** + * @return array + */ + public static function everySample(): array + { + if (! Samples::available()) { + return ['skipped' => ['', '']]; + } + + Samples::build(); + + $cases = []; + + foreach (Samples::all() as $name => $path) { + $cases[$name] = [$name, $path]; + } + + return $cases; + } + + /** + * @return array + */ + public static function everyVideoSample(): array + { + if (! Samples::available()) { + return ['skipped' => ['', '']]; + } + + Samples::build(); + + $cases = []; + + foreach (Samples::all() as $name => $path) { + // Audio-only sources are covered by their own tests. + if (\str_starts_with($name, 'audio.') || \str_starts_with($name, 'artwork.')) { + continue; + } + + $cases[$name] = [$name, $path]; + } + + return $cases; + } + + /** + * Whatever the container, the library should be able to say what it is. + * + * @dataProvider everySample + */ + public function testReadsEverySample(string $name, string $path): void + { + if ($name === '') { + $this->markTestSkipped('no samples'); + } + + $info = (new Encoder())->probe($path); + + $this->assertGreaterThan(0, $info->duration, $name.' reported no duration'); + $this->assertTrue($info->hasVideo || $info->hasAudio, $name.' reported no streams'); + $this->assertNotSame('', $info->format, $name.' reported no container format'); + + if ($info->hasVideo) { + $this->assertNotNull($info->width, $name.' has video but no width'); + $this->assertNotNull($info->videoCodec, $name.' has video but no codec'); + } + + $this->assertTrue((new Encoder())->valid($path), $name.' was rejected as unusable'); + } + + /** + * Extensions lie, so the codec is read from the file rather than the name. + * + * @dataProvider everySample + */ + public function testCodecComesFromTheFileNotTheName(string $name, string $path): void + { + if ($name === '') { + $this->markTestSkipped('no samples'); + } + + $info = (new Encoder())->probe($path); + + // What each sample genuinely contains, which is not deducible from its + // name: .mp4 might be h264 or hevc, and .m4a has no picture at all. + $expected = [ + 'video.mp4' => 'h264', + 'video.mov' => 'h264', + 'video.mkv' => 'h264', + 'video.ts' => 'h264', + 'video.3gp' => 'h264', + 'video.avi' => 'mpeg4', + 'video.wmv' => 'wmv2', + 'video.flv' => 'flv1', + 'video.webm' => 'vp9', + 'video.ogv' => 'theora', + 'hevc.mp4' => 'hevc', + 'anamorphic.mp4' => 'h264', + 'variable-fps.mp4' => 'h264', + 'rotated.mp4' => 'h264', + 'portrait.mp4' => 'h264', + 'silent.mp4' => 'h264', + 'tiny.mp4' => 'h264', + 'chapters.mp4' => 'h264', + 'multi-audio.mp4' => 'h264', + 'multi-audio.mkv' => 'h264', + 'multi-subtitle.mkv' => 'h264', + 'multi-track.mkv' => 'h264', + 'audio.m4a' => null, + 'audio.mp3' => null, + 'artwork.m4a' => null, + ]; + + $this->assertArrayHasKey($name, $expected, $name.' is not described by this test'); + $this->assertSame($expected[$name], $info->videoCodec, $name); + } + + /** + * Packaging normalises whatever came in, so a rung is always delivered at the + * size and codec asked for. + * + * @dataProvider everyVideoSample + */ + public function testPackagesEverySampleAsHls(string $name, string $path): void + { + if ($name === '') { + $this->markTestSkipped('no samples'); + } + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add(new Representation(320, 180, 300, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->out('hls/'.\pathinfo($name, PATHINFO_FILENAME).'-'.\pathinfo($name, PATHINFO_EXTENSION))); + + $this->assertNotEmpty($package->segments(), $name.' produced no segments'); + + foreach ($package->segments() as $segment) { + $this->assertFileExists($segment->path, $name); + $this->assertSame(\filesize($segment->path), $segment->size, $name.' '.$segment->file); + } + } + + /** + * The same breadth for DASH, which is the strictest of the three: it insists + * every rung share an aspect ratio, and it addresses segments through a + * manifest rather than a playlist. Running it over the whole container matrix + * is what proves the MPD describes what was actually written, whatever came + * in. + * + * @dataProvider everyVideoSample + */ + public function testPackagesEverySampleAsDash(string $name, string $path): void + { + if ($name === '') { + $this->markTestSkipped('no samples'); + } + + $dir = $this->out('dash/'.\pathinfo($name, PATHINFO_FILENAME).'-'.\pathinfo($name, PATHINFO_EXTENSION)); + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add(new Representation(320, 180, 300, 64)) + ->output((new Dash())->template(false)->timeline(false)->segment(2)) + ->pack($dir); + + $this->assertNotEmpty($package->segments(), $name.' produced no segments'); + + foreach ($package->segments() as $segment) { + $this->assertFileExists($segment->path, $name); + $this->assertSame(\filesize($segment->path), $segment->size, $name.' '.$segment->file); + } + + // Segments are addressed explicitly, which is the mode a consumer serving + // its own per-segment URLs depends on. + $manifest = (string) \file_get_contents($dir.'/manifest.mpd'); + + $this->assertStringContainsString('assertStringNotContainsString('markTestSkipped('no samples'); + } + + $target = $this->out('stills').'/'.\pathinfo($name, PATHINFO_FILENAME) + .'-'.\pathinfo($name, PATHINFO_EXTENSION).'.jpg'; + + $poster = (new Encoder())->grab($path, $target, (new Thumb())->width(160)); + + $this->assertNotFalse(\getimagesize($poster), $name.' produced an unreadable still'); + } + + /** + * Several audio tracks, each tagged with a language, become separate + * renditions a player can offer by name. + */ + public function testEveryAudioLanguageBecomesItsOwnRendition(): void + { + $samples = Samples::matching('multi-audio.'); + + $this->assertNotEmpty($samples, 'no multi-audio samples were built'); + + foreach ($samples as $name => $path) { + $info = (new Encoder())->probe($path); + + $languages = \array_column($info->audioTracks, 'language'); + + $this->assertGreaterThanOrEqual(3, \count($languages), $name); + $this->assertContains('eng', $languages, $name); + $this->assertContains('spa', $languages, $name); + $this->assertContains('fra', $languages, $name); + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add(new Representation(320, 180, 300, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->out('multi-audio/'.\pathinfo($name, PATHINFO_EXTENSION))); + + $audio = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::AUDIO, + )); + + $this->assertCount(\count($languages), $audio, $name.': one rendition per language'); + + $master = (string) \file_get_contents( + $this->out('multi-audio/'.\pathinfo($name, PATHINFO_EXTENSION)).'/master.m3u8' + ); + + foreach ($languages as $language) { + $this->assertStringContainsString('LANGUAGE="'.$language.'"', $master, $name); + } + + // Exactly one of them is the default a player starts with. + $this->assertSame(1, \substr_count($master, 'DEFAULT=YES'), $name); + } + } + + /** + * @return array + */ + public static function everyOutputShape(): array + { + return ['hls' => ['hls'], 'dash' => ['dash'], 'cmaf' => ['cmaf']]; + } + + private function shape(string $shape): Hls|Dash|Cmaf + { + return match ($shape) { + 'hls' => (new Hls())->segment(2), + 'dash' => (new Dash())->template(false)->timeline(false)->segment(2), + default => (new Cmaf())->segment(2), + }; + } + + /** + * Multi-language audio has to survive into every output shape, not only the + * one it was first made to work in. + * + * @dataProvider everyOutputShape + */ + public function testEveryAudioLanguageSurvivesInto(string $shape): void + { + $path = Samples::all()['multi-audio.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-audio.mkv was not built'); + } + + $expected = ['eng', 'spa', 'fra', 'jpn']; + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add( + new Representation(320, 180, 300, 64), + new Representation(640, 360, 800, 96), + ) + ->output($this->shape($shape)) + ->pack($this->out('multi-audio/'.$shape)); + + $audio = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::AUDIO, + )); + + $this->assertCount(\count($expected), $audio, $shape.': one rendition per language'); + + $languages = \array_map(static fn ($variant): ?string => $variant->language, $audio); + + foreach ($expected as $language) { + $this->assertContains($language, $languages, $shape.' lost '.$language); + } + + // Both video rungs stay switchable alternatives of one another. + $video = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::VIDEO, + )); + + $this->assertCount(2, $video, $shape.': both rungs should survive'); + + foreach ($package->segments() as $segment) { + $this->assertFileExists($segment->path, $shape.' '.$segment->file); + } + } + + /** + * @dataProvider everyOutputShape + */ + public function testTheManifestNamesEveryAudioLanguageIn(string $shape): void + { + $path = Samples::all()['multi-audio.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-audio.mkv was not built'); + } + + $dir = $this->out('multi-audio/manifest-'.$shape); + + (new Packager()) + ->open($path) + ->format($this->format()) + ->add(new Representation(320, 180, 300, 64)) + ->output($this->shape($shape)) + ->pack($dir); + + $expected = ['eng', 'spa', 'fra', 'jpn']; + + // CMAF writes both descriptions, so both have to name the languages β€” + // checking only one of them once hid a master that named none of them. + if ($shape !== 'dash') { + $master = (string) \file_get_contents($dir.'/master.m3u8'); + + foreach ($expected as $language) { + $this->assertStringContainsString( + 'LANGUAGE="'.$language.'"', + $master, + $shape.': the master does not name '.$language, + ); + } + + // Every audio rendition is named, not just some of them. + $this->assertSame( + \substr_count($master, '#EXT-X-MEDIA:TYPE=AUDIO'), + \substr_count($master, 'TYPE=AUDIO') > 0 + ? \preg_match_all('/#EXT-X-MEDIA:TYPE=AUDIO[^\n]*LANGUAGE=/', $master) + : 0, + $shape.': some audio renditions have no language', + ); + + // Exactly one language is the one a player starts with. + $this->assertSame( + 1, + \preg_match_all('/#EXT-X-MEDIA:TYPE=AUDIO[^\n]*DEFAULT=YES/', $master), + $shape, + ); + } + + if ($shape !== 'hls') { + $manifest = (string) \file_get_contents($dir.'/manifest.mpd'); + + foreach ($expected as $language) { + $this->assertStringContainsString('lang="'.$language.'"', $manifest, $shape); + } + + // Each language is its own adaptation set, or a player cannot pick one. + $this->assertSame(4, \substr_count($manifest, 'contentType="audio"'), $shape); + } + } + + /** + * Subtitles are read but never packaged. + * + * Reporting an embedded track and carrying it into a package are different + * jobs, and only the first belongs here β€” the application that owns the + * subtitle files decides what to do with them. A source with three of them + * must therefore come out of pack() with none, and no stray WebVTT beside + * the media. + */ + public function testSubtitlesAreReportedByProbeButNeverPackaged(): void + { + $path = Samples::all()['multi-subtitle.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-subtitle.mkv was not built'); + } + + $this->assertCount( + 3, + (new Encoder())->probe($path)->tracks(Track::SUBTITLE), + 'probe should still report what is in the file', + ); + + $dir = $this->out('multi-subtitle/none'); + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add(new Representation(320, 180, 300, 64)) + ->output((new Hls())->segment(2)) + ->pack($dir); + + foreach ($package->variants() as $variant) { + $this->assertNotSame(Track::SUBTITLE, $variant->type); + } + + $this->assertSame([], \glob($dir.'/*.vtt') ?: [], 'no WebVTT should be written'); + $this->assertStringNotContainsString( + 'TYPE=SUBTITLES', + (string) \file_get_contents($dir.'/master.m3u8'), + ); + } + + /** + * The case a real release actually looks like: two rungs and four dubs out + * of one file, every dub selectable. Its two subtitle languages are the + * application's business, so none of them appear here. + * + * @dataProvider everyOutputShape + */ + public function testEveryDubSurvivesAlongsideTheLadderIn(string $shape): void + { + $path = Samples::all()['multi-track.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-track.mkv was not built'); + } + + $dir = $this->out('multi-track/'.$shape); + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add( + new Representation(320, 180, 300, 64), + new Representation(640, 360, 800, 96), + ) + ->output($this->shape($shape)) + ->pack($dir); + + $of = static fn (string $type): array => \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === $type, + )); + + $video = $of(Track::VIDEO); + $audio = $of(Track::AUDIO); + $subtitles = $of(Track::SUBTITLE); + + $this->assertCount(2, $video, $shape.': two rungs'); + $this->assertCount(4, $audio, $shape.': four dubs'); + $this->assertCount(0, $subtitles, $shape.': subtitles are not packaged'); + + $this->assertSame( + ['eng', 'spa', 'fra', 'jpn'], + \array_map(static fn ($variant): ?string => $variant->language, $audio), + $shape.': dubs, in order', + ); + + // Everything that was produced is on disk and accounted for. + foreach ($package->files() as $file) { + $this->assertFileExists($file, $shape); + } + } + + /** + * Encoding produces one rendition of the picture, but there is no reason to + * discard the other audio along the way β€” a release with four dubs should + * still have four afterwards. + * + * Whether a container can record which language each one is differs, and + * that is the container's business rather than the library's: AVI has + * nowhere to put a per-stream language at all. + * + * @return array + */ + public static function everyEncodeTarget(): array + { + return [ + // container, keeps language tags + 'mp4' => ['mp4', true], + 'mkv' => ['mkv', true], + 'mov' => ['mov', true], + 'wmv' => ['wmv', true], + 'avi' => ['avi', false], + ]; + } + + /** + * @dataProvider everyEncodeTarget + */ + public function testEncodeKeepsEveryDubIn(string $extension, bool $languages): void + { + $path = Samples::all()['multi-track.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-track.mkv was not built'); + } + + $encoder = new Encoder(); + $target = $this->out('encode').'/multi-track.'.$extension; + + $encoder + ->open($path) + ->format($this->format()) + ->add(new Representation(320, 180, 300, 64)) + ->encode($target); + + $result = $encoder->probe($target); + + // One rendition of the picture, at the size asked for. + $this->assertSame(320, $result->width, $extension); + $this->assertSame(180, $result->height, $extension); + + // Every dub survives, whatever the container can say about it. + $this->assertCount(4, $result->tracks(Track::AUDIO), $extension.' lost audio tracks'); + + if ($languages) { + $this->assertSame( + ['eng', 'spa', 'fra', 'jpn'], + \array_map( + // WMV rewrites French as the bibliographic "fre" rather than + // the terminological "fra". Both name the same language, and + // which one appears is the container's choice, not ours. + static fn ($track): ?string => $track->language === 'fre' ? 'fra' : $track->language, + $result->tracks(Track::AUDIO), + ), + $extension.': dubs, in order', + ); + } + + } + + /** + * The staged packaging path builds its intermediates with encode(), so it used + * to hand the packager a file with one dub and no subtitles no matter what + * went in. + */ + public function testStagedIntermediatesKeepEveryDub(): void + { + $path = Samples::all()['multi-track.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-track.mkv was not built'); + } + + $encoder = new Encoder(); + $intermediate = $this->out('encode').'/intermediate.mp4'; + + // The same call staged() makes, one rung at a time. + $encoder + ->open($path) + ->format($this->format()) + ->add(new Representation(640, 360, 800, 96)) + ->encode($intermediate); + + $this->assertCount(4, $encoder->probe($intermediate)->tracks(Track::AUDIO)); + } + + public function testReadsEverySubtitleLanguage(): void + { + $path = Samples::all()['multi-subtitle.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-subtitle.mkv was not built'); + } + + $subtitles = (new Encoder())->probe($path)->tracks(Track::SUBTITLE); + + $languages = \array_map( + static fn ($track): ?string => $track->language, + $subtitles, + ); + + $this->assertCount(3, $subtitles); + $this->assertContains('eng', $languages); + $this->assertContains('fra', $languages); + $this->assertContains('jpn', $languages); + } + + /** + * One shared segment set described two ways, on every container we accept. + */ + public function testCmafSharesOneSegmentSetAcrossContainers(): void + { + foreach (['video.mp4', 'video.mkv', 'video.avi', 'video.wmv'] as $name) { + $path = Samples::all()[$name] ?? null; + + if ($path === null) { + continue; + } + + $dir = $this->out('cmaf/'.\pathinfo($name, PATHINFO_EXTENSION)); + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add( + new Representation(320, 180, 300, 64), + new Representation(640, 360, 800, 96), + ) + ->output((new Cmaf())->segment(2)) + ->pack($dir); + + $fromManifest = \array_map( + static fn ($segment): string => $segment->file, + $package->segments(), + ); + + $fromPlaylists = []; + + foreach (\glob($dir.'/*.m3u8') ?: [] as $playlist) { + if (\basename($playlist) === 'master.m3u8') { + continue; + } + + foreach (M3u8::media($playlist)['segments'] as $segment) { + $fromPlaylists[] = $segment['file']; + } + } + + $this->assertNotEmpty($fromPlaylists, $name); + + \sort($fromManifest); + \sort($fromPlaylists); + + $this->assertSame($fromManifest, $fromPlaylists, $name.': manifests disagree'); + } + } + + public function testTilesATimelineForEveryContainer(): void + { + foreach (['video.mp4', 'video.avi', 'video.wmv', 'video.webm', 'video.flv'] as $name) { + $path = Samples::all()[$name] ?? null; + + if ($path === null) { + continue; + } + + $sheet = (new Encoder())->tile( + $path, + $this->out('timelines/'.\pathinfo($name, PATHINFO_EXTENSION)), + (new Tile())->interval(2.0), + ); + + $this->assertNotEmpty($sheet->images(), $name); + $this->assertNotEmpty($sheet->cues(), $name); + $this->assertNotFalse(\getimagesize($sheet->images()[0]), $name); + } + } + + public function testAudioOnlySamplesHaveNoPictureToGrab(): void + { + $path = Samples::all()['audio.m4a'] ?? null; + + if ($path === null) { + $this->markTestSkipped('audio.m4a was not built'); + } + + $this->expectException(Input::class); + $this->expectExceptionMessage('no image to grab'); + + (new Encoder())->grab($path, $this->out('stills').'/nothing.jpg'); + } + + public function testArtworkIsFoundInASoundFile(): void + { + $path = Samples::all()['artwork.m4a'] ?? null; + + if ($path === null) { + $this->markTestSkipped('artwork.m4a was not built'); + } + + $encoder = new Encoder(); + $info = $encoder->probe($path); + + $this->assertFalse($info->hasVideo); + $this->assertNotNull($info->cover); + + $grabbed = $encoder->grab($path, $this->out('stills').'/artwork.jpg'); + $this->assertNotFalse(\getimagesize($grabbed)); + } + +} diff --git a/tests/Video/Samples.php b/tests/Video/Samples.php new file mode 100644 index 0000000..835329d --- /dev/null +++ b/tests/Video/Samples.php @@ -0,0 +1,506 @@ +> + */ + private const CONTAINERS = [ + // The everyday case. + 'mp4' => ['-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac'], + // Editors and camera rigs. + 'mov' => ['-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac'], + // The container that actually supports many tracks well. + 'mkv' => ['-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac'], + // Older exports, and the reason "just read the extension" fails. + 'avi' => ['-c:v', 'mpeg4', '-vtag', 'DX50', '-c:a', 'libmp3lame'], + 'wmv' => ['-c:v', 'wmv2', '-c:a', 'wmav2'], + 'flv' => ['-c:v', 'flv1', '-c:a', 'libmp3lame', '-ar', '44100'], + // Broadcast and HLS-adjacent. + 'ts' => ['-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac'], + // Royalty-free web. + 'webm' => ['-c:v', 'libvpx-vp9', '-b:v', '300k', '-cpu-used', '8', '-c:a', 'libopus'], + 'ogv' => ['-c:v', 'libtheora', '-q:v', '4', '-c:a', 'libvorbis'], + // Phones. + '3gp' => ['-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-ar', '8000'], + ]; + + /** + * Audio tracks for the multi-language samples: language tag, title, and the + * tone that makes each one audibly distinct. + * + * @var list + */ + private const LANGUAGES = [ + ['code' => 'eng', 'title' => 'English', 'hz' => 440], + ['code' => 'spa', 'title' => 'Espanol', 'hz' => 554], + ['code' => 'fra', 'title' => 'Francais', 'hz' => 659], + ['code' => 'jpn', 'title' => 'Japanese', 'hz' => 880], + ]; + + public static function root(): string + { + return \dirname(__DIR__, 2).'/tests/samples'; + } + + public static function in(): string + { + return self::root().'/in'; + } + + public static function out(): string + { + return self::root().'/out'; + } + + /** + * Whether the tools needed to build and use the samples are installed. + */ + public static function available(): bool + { + return Process::exists('ffmpeg') && Process::exists('ffprobe'); + } + + /** + * Every sample source, keyed by filename. + * + * @return array + */ + public static function all(): array + { + $found = []; + + foreach (\glob(self::in().'/*') ?: [] as $path) { + if (\is_file($path)) { + $found[\basename($path)] = $path; + } + } + + \ksort($found); + + return $found; + } + + /** + * Samples whose name starts with the given prefix. + * + * @return array + */ + public static function matching(string $prefix): array + { + return \array_filter( + self::all(), + static fn (string $name): bool => \str_starts_with($name, $prefix), + ARRAY_FILTER_USE_KEY, + ); + } + + /** + * Builds anything missing. Safe to call repeatedly β€” existing files are left + * alone, so a full run costs nothing after the first. + */ + public static function build(bool $force = false): void + { + $in = self::in(); + + if (! \is_dir($in)) { + \mkdir($in, 0o755, true); + } + + if (! \is_dir(self::out())) { + \mkdir(self::out(), 0o755, true); + } + + if ($force) { + foreach (\glob($in.'/*') ?: [] as $path) { + \unlink($path); + } + } + + $master = self::master(); + + self::containers($master); + self::multitrack($master); + self::shapes($master); + self::sound($master); + } + + /** + * The source everything else is derived from: moving detail that compresses + * like real footage, plus a tone. + */ + private static function master(): string + { + $path = self::in().'/video.mp4'; + + if (\is_file($path)) { + return $path; + } + + self::run([ + '-f', 'lavfi', '-i', 'testsrc2=duration='.self::DURATION.':size=640x360:rate=30', + '-f', 'lavfi', '-i', 'sine=frequency=440:duration='.self::DURATION, + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', + '-force_key_frames', self::KEYFRAME, + '-c:a', 'aac', '-shortest', + '-metadata', 'title=Utopia Video Sample', + '-metadata', 'artist=Utopia', + ], $path); + + return $path; + } + + /** + * Encoders this ffmpeg was actually built with. + * + * Builds differ β€” a distribution package and a hand-rolled one rarely carry + * the same set β€” so the sample library adapts instead of failing on whichever + * codec happens to be missing. + * + * @return list + */ + private static function encoders(): array + { + /** @var list|null $found */ + static $found = null; + + if ($found !== null) { + return $found; + } + + $found = []; + + foreach (\explode("\n", Process::read(['ffmpeg', '-hide_banner', '-encoders'], 30)) as $line) { + if (\preg_match('/^\s*[A-Z.]{6}\s+(\S+)/', $line, $match) === 1) { + $found[] = $match[1]; + } + } + + return $found; + } + + /** + * @param list $codecs + */ + private static function buildable(array $codecs): bool + { + $available = self::encoders(); + + foreach ($codecs as $index => $value) { + // Only inspect the values that follow a -c:v or -c:a flag. + $flag = $codecs[$index - 1] ?? ''; + + if (($flag === '-c:v' || $flag === '-c:a') && ! \in_array($value, $available, true)) { + return false; + } + } + + return true; + } + + /** + * The same content in every container worth supporting, so nothing depends + * on a file extension. + */ + private static function containers(string $master): void + { + foreach (self::CONTAINERS as $extension => $codecs) { + $path = self::in().'/video.'.$extension; + + if ($extension === 'mp4' || \is_file($path)) { + continue; + } + + if (! self::buildable($codecs)) { + // This ffmpeg cannot write that combination; the rest still build. + continue; + } + + $args = ['-i', $master, ...$codecs]; + + // Not every codec honours forced keyframes, so only ask where it helps. + if (\in_array('libx264', $codecs, true) || \in_array('libvpx-vp9', $codecs, true)) { + $args[] = '-force_key_frames'; + $args[] = self::KEYFRAME; + } + + if ($extension === '3gp') { + // 3GP expects a small frame and a low sample rate. + $args[] = '-vf'; + $args[] = 'scale=352:288'; + } + + self::run($args, $path); + } + } + + /** + * Several audio tracks, each tagged with a language, in the two containers + * where that is common: MP4 and Matroska. Matroska also carries subtitles in + * more than one language. + */ + private static function multitrack(string $master): void + { + foreach (['mp4' => 3, 'mkv' => 4] as $extension => $count) { + $path = self::in().'/multi-audio.'.$extension; + + if (\is_file($path)) { + continue; + } + + $args = ['-i', $master]; + $languages = \array_slice(self::LANGUAGES, 0, $count); + + foreach ($languages as $language) { + $args[] = '-f'; + $args[] = 'lavfi'; + $args[] = '-i'; + $args[] = 'sine=frequency='.$language['hz'].':duration='.self::DURATION; + } + + $args[] = '-map'; + $args[] = '0:v:0'; + + foreach (\array_keys($languages) as $index) { + $args[] = '-map'; + $args[] = ($index + 1).':a:0'; + } + + foreach ($languages as $index => $language) { + $args[] = '-metadata:s:a:'.$index; + $args[] = 'language='.$language['code']; + $args[] = '-metadata:s:a:'.$index; + $args[] = 'title='.$language['title']; + } + + $args[] = '-c:v'; + $args[] = 'copy'; + $args[] = '-c:a'; + $args[] = 'aac'; + $args[] = '-shortest'; + + self::run($args, $path); + } + + // The realistic case: a release with several dubs and several subtitle + // languages in the one file. + $both = self::in().'/multi-track.mkv'; + + if (! \is_file($both) && \is_file(self::in().'/multi-audio.mkv')) { + $tracks = ['eng' => "1\n00:00:01,000 --> 00:00:03,000\nHello\n", 'fra' => "1\n00:00:01,000 --> 00:00:03,000\nBonjour\n"]; + $args = ['-i', self::in().'/multi-audio.mkv']; + $written = []; + + foreach ($tracks as $code => $body) { + $srt = self::in().'/.both-'.$code.'.srt'; + \file_put_contents($srt, $body); + $written[] = $srt; + $args[] = '-i'; + $args[] = $srt; + } + + $args[] = '-map'; + $args[] = '0'; + + foreach (\array_keys($tracks) as $index => $code) { + $args[] = '-map'; + $args[] = ($index + 1).':0'; + } + + foreach (\array_keys($tracks) as $index => $code) { + $args[] = '-metadata:s:s:'.$index; + $args[] = 'language='.$code; + } + + $args[] = '-c'; + $args[] = 'copy'; + $args[] = '-c:s'; + $args[] = 'srt'; + + self::run($args, $both); + + foreach ($written as $srt) { + @\unlink($srt); + } + } + + // Subtitles in three languages, which is what Matroska is usually used for. + $subtitled = self::in().'/multi-subtitle.mkv'; + + if (! \is_file($subtitled)) { + $tracks = [ + 'eng' => "1\n00:00:01,000 --> 00:00:03,000\nHello\n", + 'fra' => "1\n00:00:01,000 --> 00:00:03,000\nBonjour\n", + 'jpn' => "1\n00:00:01,000 --> 00:00:03,000\nこんにけは\n", + ]; + + $args = ['-i', $master]; + $written = []; + + foreach ($tracks as $code => $body) { + $srt = self::in().'/.'.$code.'.srt'; + \file_put_contents($srt, $body); + $written[] = $srt; + $args[] = '-i'; + $args[] = $srt; + } + + $args[] = '-map'; + $args[] = '0'; + + foreach (\array_keys($tracks) as $index => $code) { + $args[] = '-map'; + $args[] = ($index + 1).':0'; + } + + foreach (\array_keys($tracks) as $index => $code) { + $args[] = '-metadata:s:s:'.$index; + $args[] = 'language='.$code; + } + + $args[] = '-c:v'; + $args[] = 'copy'; + $args[] = '-c:a'; + $args[] = 'copy'; + $args[] = '-c:s'; + $args[] = 'srt'; + + self::run($args, $subtitled); + + foreach ($written as $srt) { + @\unlink($srt); + } + } + } + + /** + * The properties that make real files awkward. + */ + private static function shapes(string $master): void + { + $in = self::in(); + + // Non-square pixels: stored 4:3, displayed wider. + self::once($in.'/anamorphic.mp4', [ + '-i', $master, '-vf', 'scale=640:480,setsar=4:3', + '-c:v', 'libx264', '-preset', 'ultrafast', '-force_key_frames', self::KEYFRAME, '-c:a', 'copy', + ]); + + // Frames arriving at an uneven rate. + self::once($in.'/variable-fps.mp4', [ + '-i', $master, '-vf', "select='not(mod(n,3))+gte(t,3)'", '-fps_mode', 'vfr', + '-c:v', 'libx264', '-preset', 'ultrafast', '-an', + ]); + + // Orientation in the display matrix rather than the frame size. + self::once($in.'/rotated.mp4', [ + '-display_rotation', '90', '-i', $master, '-c', 'copy', + ]); + + // Shot on a phone, held upright. + self::once($in.'/portrait.mp4', [ + '-i', $master, '-vf', 'scale=360:640,setsar=1:1', + '-c:v', 'libx264', '-preset', 'ultrafast', '-force_key_frames', self::KEYFRAME, '-c:a', 'copy', + ]); + + // Newer codec, same content. + self::once($in.'/hevc.mp4', [ + '-i', $master, '-c:v', 'libx265', '-preset', 'ultrafast', '-tag:v', 'hvc1', + '-force_key_frames', self::KEYFRAME, '-c:a', 'copy', + ]); + + // No sound at all. + self::once($in.'/silent.mp4', ['-i', $master, '-an', '-c:v', 'copy']); + + // Shorter than a single segment. + self::once($in.'/tiny.mp4', ['-i', $master, '-t', '0.5', '-c', 'copy']); + + // Chapters, which players show as a timeline. + $meta = $in.'/.chapters.txt'; + \file_put_contents($meta, <<<'META' + ;FFMETADATA1 + title=Chaptered Sample + [CHAPTER] + TIMEBASE=1/1000 + START=0 + END=3000 + title=Opening + [CHAPTER] + TIMEBASE=1/1000 + START=3000 + END=6000 + title=Closing + META); + self::once($in.'/chapters.mp4', ['-i', $master, '-i', $meta, '-map_metadata', '1', '-map', '0', '-c', 'copy']); + @\unlink($meta); + } + + /** + * Audio-only sources, with and without artwork. + */ + private static function sound(string $master): void + { + $in = self::in(); + + self::once($in.'/audio.m4a', ['-i', $master, '-vn', '-c:a', 'aac']); + self::once($in.'/audio.mp3', ['-i', $master, '-vn', '-c:a', 'libmp3lame']); + + $cover = $in.'/.cover.jpg'; + + if (! \is_file($in.'/artwork.m4a')) { + self::run(['-i', $master, '-frames:v', '1', '-vf', 'scale=300:300'], $cover); + self::run([ + '-i', $in.'/audio.m4a', '-i', $cover, + '-map', '0:a', '-map', '1:v', '-c', 'copy', + '-disposition:v:0', 'attached_pic', + ], $in.'/artwork.m4a'); + @\unlink($cover); + } + } + + /** + * @param list $args + */ + private static function once(string $path, array $args): void + { + if (! \is_file($path)) { + self::run($args, $path); + } + } + + /** + * @param list $args + */ + private static function run(array $args, string $target): void + { + Process::run( + [...['ffmpeg', '-y', '-hide_banner', '-loglevel', 'error'], ...$args, $target], + timeout: 300, + ); + } +} diff --git a/tests/Video/Unit/AdapterTest.php b/tests/Video/Unit/AdapterTest.php new file mode 100644 index 0000000..751e842 --- /dev/null +++ b/tests/Video/Unit/AdapterTest.php @@ -0,0 +1,538 @@ +dir = $dir; + $this->file = $dir.'/source.mp4'; + \file_put_contents($this->file, 'pretend this is a video'); + } + + protected function tearDown(): void + { + foreach (\glob($this->dir.'/*') ?: [] as $path) { + \is_file($path) ? \unlink($path) : @\rmdir($path); + } + + @\rmdir($this->dir); + } + + /** + * The whole point of pushing config in rather than passing it to a + * constructor: an adapter the caller built themselves must still end up + * reading through the probe the caller asked for, not one of its own. + */ + public function testTheProbeGivenToAFacadeIsTheOneAdaptersRead(): void + { + $spy = new class () extends Mock { + public int $reads = 0; + + public function read(string $path): Info + { + $this->reads++; + + return parent::read($path); + } + }; + + $adapter = new FFmpeg(); + + // Constructing the facade is what hands the probe over. + $encoder = new Encoder($adapter, $spy); + + // The adapter reads the source when it opens it, and that read has to + // go through the probe the facade supplied. + $adapter->open($this->file); + + $this->assertSame(1, $spy->reads, 'the adapter read through a probe of its own'); + + // And the facade's own probe() is the same one. + $this->assertSame(8.0, $encoder->probe($this->file)->duration); + $this->assertSame(2, $spy->reads); + } + + /** + * Both facades push the probe, so sharing one backend between them leaves it + * reading through the same probe either way. + */ + public function testEitherFacadePushesItsProbe(): void + { + $spy = new class () extends Mock { + public int $reads = 0; + + public function read(string $path): Info + { + $this->reads++; + + return parent::read($path); + } + }; + + $adapter = new FFmpeg(); + + new Packager($adapter, probe: $spy); + + $adapter->open($this->file); + + $this->assertSame(1, $spy->reads); + } + + public function testOneAdapterCanServeSeveralCapabilities(): void + { + $ffmpeg = new FFmpeg(); + + $this->assertInstanceOf(EncoderAdapter::class, $ffmpeg); + $this->assertInstanceOf(PackagerAdapter::class, $ffmpeg); + + $this->assertSame('ffmpeg', (new Encoder($ffmpeg, new Mock()))->getName()); + $this->assertSame('ffmpeg', (new Packager($ffmpeg, probe: new Mock()))->getName()); + } + + /** + * A backend declares what it can do by which interfaces it implements, and + * that is what the facade branches on. FFprobe reads and nothing else. + */ + public function testABackendDeclaresOnlyWhatItCanDo(): void + { + // Asked of the class rather than of an instance: the claim is about what + // FFprobe declares, and an instanceof against a known type is a claim + // about nothing that analysis will happily prove for you. + $implements = \class_implements(FFprobe::class); + + $this->assertContains(Probe::class, $implements); + $this->assertNotContains(EncoderAdapter::class, $implements); + $this->assertNotContains(PackagerAdapter::class, $implements); + } + + /** + * A finished job must not leave its inputs or listeners behind, or a second + * pack reports every log twice and silently reuses the previous output. + */ + public function testAFinishedJobDoesNotLeakIntoTheNext(): void + { + $adapter = new Mock(); + $rep = new Representation(320, 240, 400); + + $package = $adapter + ->open($this->file, $rep) + ->output(new Hls()) + ->pack($this->dir); + + $this->assertNotEmpty($package->segments()); + + // Opening again starts a new job, so what the last one was told is gone. + $adapter->open($this->file, $rep); + + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('output format'); + + $adapter->pack($this->dir); + } + + /** + * The chain reads as well in one order as the other, so a ladder described + * before the source was opened has to survive the opening. It is the promise + * open() already makes about listeners: what was set on the adapter belongs + * to the caller, and only the previous job's leftovers are swept away. + */ + public function testConfigurationSetBeforeOpeningSurvivesIt(): void + { + $adapter = new Mock(); + + $package = $adapter + ->add(new Representation(320, 240, 400)) + ->output(new Hls()) + ->open($this->file) + ->pack($this->dir); + + $this->assertCount(1, $package->variants()); + $this->assertNotEmpty($package->segments()); + } + + /** + * The other half of the same promise: configuring the next job before + * opening it must not inherit the finished one's ladder. + */ + public function testANewJobConfiguredBeforeOpeningStartsClean(): void + { + $adapter = new Mock(); + + $adapter + ->open($this->file) + ->add(new Representation(320, 240, 400)) + ->output(new Hls()) + ->pack($this->dir); + + $package = $adapter + ->add(new Representation(640, 480, 800)) + ->output(new Hls()) + ->open($this->file) + ->pack($this->dir); + + $this->assertCount(1, $package->variants()); + $this->assertSame(640, $package->variants()[0]->width); + } + + /** + * Packaging several already encoded renditions is what repeated open() calls + * are for, so within one job they add rather than reset. + */ + public function testRepeatedOpensWithinOneJobAddInputs(): void + { + $adapter = new class () extends Mock { + /** @return list */ + public function registered(): array + { + return $this->paths(); + } + }; + + $adapter + ->open($this->file, new Representation(640, 360, 800)) + ->open($this->file, new Representation(1280, 720, 2538)); + + $this->assertCount(2, $adapter->registered()); + + // A rung tagged on the way in counts as part of the ladder. + $package = $adapter->output(new Hls())->pack($this->dir); + + $this->assertCount(2, $package->variants()); + } + + public function testFactoriesResolveNamesToBackends(): void + { + $this->assertInstanceOf(FFmpeg::class, Adapter::encoder('ffmpeg')); + $this->assertInstanceOf(FFmpeg::class, Adapter::packager('ffmpeg')); + $this->assertInstanceOf(FFprobe::class, Adapter::probe('ffprobe')); + $this->assertInstanceOf(Mock::class, Adapter::encoder('mock')); + $this->assertInstanceOf(Mock::class, Adapter::packager('mock')); + $this->assertInstanceOf(Mock::class, Adapter::probe('mock')); + } + + public function testFactoriesRoundTripWithGetName(): void + { + foreach (['ffprobe', 'mock'] as $name) { + $this->assertSame($name, Adapter::probe($name)->getName()); + } + + foreach (['ffmpeg', 'mock'] as $name) { + $this->assertSame($name, Adapter::encoder($name)->getName()); + $this->assertSame($name, Adapter::packager($name)->getName()); + } + } + + /** + * A backend named in configuration can be handed straight to a facade, and + * the facade reports the same name back. + */ + public function testAFactoryResultGoesStraightIntoAFacade(): void + { + $this->assertSame('mock', (new Encoder(Adapter::encoder('mock'), Adapter::probe('mock')))->getName()); + $this->assertSame('mock', (new Packager(Adapter::packager('mock'), probe: Adapter::probe('mock')))->getName()); + } + + public function testFactoriesPassBinaryPathsThrough(): void + { + $probe = Adapter::probe('ffprobe', '/opt/bin/ffprobe'); + + $this->assertInstanceOf(FFprobe::class, $probe); + $this->assertFalse($probe->available(), 'a made-up path should not be runnable'); + } + + /** + * Errors only, because the interesting output of an encode is the file it + * wrote rather than its commentary. + */ + public function testEveryBackendStartsAtTheDefaultDisplayLevel(): void + { + $this->assertSame(Adapter::ERROR, (new FFmpeg())->level()); + $this->assertSame(Adapter::ERROR, (new FFprobe())->level()); + $this->assertSame(Adapter::ERROR, (new Mock())->level()); + } + + public function testTheDisplayLevelCanBeRaisedOrSilenced(): void + { + $this->assertSame(Adapter::DEBUG, (new FFmpeg(level: Adapter::DEBUG))->level()); + $this->assertSame(Adapter::QUIET, (new Mock(level: Adapter::QUIET))->level()); + } + + public function testFactoriesPassTheDisplayLevelThrough(): void + { + $this->assertSame( + Adapter::VERBOSE, + self::levelOf(Adapter::encoder('ffmpeg', level: Adapter::VERBOSE)), + ); + $this->assertSame( + Adapter::INFO, + self::levelOf(Adapter::packager('mock', level: Adapter::INFO)), + ); + $this->assertSame( + Adapter::WARNING, + self::levelOf(Adapter::probe('ffprobe', level: Adapter::WARNING)), + ); + + // Omitting it keeps each backend's own default. + $this->assertSame(Adapter::ERROR, self::levelOf(Adapter::encoder('ffmpeg'))); + } + + /** + * The factories return the narrow capability type, and the display level is + * shared plumbing on the base rather than part of any capability's contract + * β€” the same split as setProbe() and available(), which the facades reach + * through an instanceof check too. + */ + private static function levelOf(Named $backend): string + { + return $backend instanceof Adapter ? $backend->level() : 'not an Adapter'; + } + + /** + * Rejected while it is still cheap to say so, rather than by the backend + * halfway through a job. + */ + public function testAnUnknownDisplayLevelIsRejected(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('No display level named "chatty"'); + + new FFmpeg(level: 'chatty'); + } + + /** + * The level reaches the command, which is the only place it can matter. + */ + public function testTheDisplayLevelReachesTheCommand(): void + { + $adapter = new class (level: Adapter::VERBOSE) extends FFmpeg { + /** @return list */ + public function command(): array + { + return $this->prefix(); + } + }; + + $this->assertSame( + ['ffmpeg', '-y', '-hide_banner', '-loglevel', 'verbose'], + \array_slice($adapter->command(), 0, 5), + ); + } + + /** + * A quiet backend has nothing to say, but still reports its progress β€” + * progress is structured data, not commentary. + */ + public function testAQuietBackendStillReportsProgress(): void + { + $seen = ['progress' => 0, 'log' => 0]; + + (new Mock(level: Adapter::QUIET)) + ->open($this->file) + ->on(Observable::PROGRESS, function () use (&$seen): void { + $seen['progress']++; + }) + ->on(Observable::LOG, function () use (&$seen): void { + $seen['log']++; + }) + ->encode($this->dir.'/quiet.mp4'); + + $this->assertGreaterThan(0, $seen['progress']); + $this->assertSame(0, $seen['log'], 'a quiet backend should log nothing'); + } + + /** + * Finished jobs report a status line unless the backend was told to stay quiet. + */ + public function testStatusLinesFollowTheDisplayLevel(): void + { + $reporter = new Recorder(); + + $quiet = new Mock(level: Adapter::QUIET); + $quiet->setReporter($reporter); + $quiet->open($this->file)->encode($this->dir.'/quiet-status.mp4'); + + $this->assertSame([], $reporter->successes, 'a quiet backend should say nothing'); + + $path = $this->dir.'/loud-status.mp4'; + $loud = new Mock(); + $loud->setReporter($reporter); + $loud->open($this->file)->encode($path); + + $this->assertSame(['mock: encoded '.$path], $reporter->successes); + } + + /** + * A failed backend command says so before the exception leaves. + */ + public function testAFailedCommandReportsAnError(): void + { + $reporter = new Recorder(); + + $adapter = new class () extends FFmpeg { + public function fail(): void + { + $this->process(['false']); + } + }; + + $adapter->setReporter($reporter); + + try { + $adapter->fail(); + $this->fail('a failing command should throw'); + } catch (Runtime $e) { + $this->assertCount(1, $reporter->errors); + $this->assertStringContainsString('failed with exit code', $reporter->errors[0]); + $this->assertSame($reporter->errors[0], $e->getMessage()); + } + + $quiet = new class (level: Adapter::QUIET) extends FFmpeg { + public function fail(): void + { + $this->process(['false']); + } + }; + + $quiet->setReporter($reporter); + + try { + $quiet->fail(); + $this->fail('a failing command should throw'); + } catch (Runtime) { + $this->assertCount(1, $reporter->errors, 'a quiet backend should say nothing'); + } + } + + /** + * Status lines go on the terminal unless told otherwise, because that is + * what a command line caller wants and it costs everyone else one argument + * to redirect. + */ + public function testStatusLinesGoToTheTerminalByDefault(): void + { + $adapter = new class () extends Mock { + public function destination(): Reporter + { + return $this->reporter(); + } + }; + + $this->assertInstanceOf(Reporter\Console::class, $adapter->destination()); + + // And a facade told to keep quiet replaces it before anything runs. + new Encoder(adapter: $adapter, probe: $adapter, reporter: new Reporter\Silent()); + + $this->assertInstanceOf(Reporter\Silent::class, $adapter->destination()); + } + + /** + * A worker that does not own stdout hands over somewhere else to report to, + * and the facade gives it to every backend so half a job cannot end up + * printing. + */ + public function testTheReporterGivenToAFacadeIsTheOneBackendsUse(): void + { + $reporter = new Recorder(); + $mock = new Mock(); + + $package = (new Packager(adapter: $mock, probe: $mock, reporter: $reporter)) + ->open($this->file) + ->add(new Representation(1280, 720, 2538)) + ->output(new Hls()) + ->pack($this->dir.'/reported'); + + $this->assertNotSame([], $package->variants()); + $this->assertSame(['mock: packed '.$this->dir.'/reported'], $reporter->successes); + + $encoded = $this->dir.'/reported.mp4'; + $silent = new Mock(); + + (new Encoder(adapter: $silent, probe: $silent, reporter: $reporter)) + ->open($this->file) + ->encode($encoded); + + $this->assertSame( + ['mock: packed '.$this->dir.'/reported', 'mock: encoded '.$encoded], + $reporter->successes, + ); + } + + public function testAnUnknownNameIsRejected(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('No probe named "nope"'); + + Adapter::probe('nope'); + } + + public function testAKnownNameForTheWrongCapabilityIsRejected(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('No encoder named "ffprobe"'); + + Adapter::encoder('ffprobe'); + } + + /** + * A backend that drives no binary must not be reported as needing a tool + * that happens to be missing. + */ + public function testABackendWithNoBinaryIsAlwaysAvailable(): void + { + $this->assertTrue((new Mock())->available()); + } + + public function testAMissingBinaryIsReported(): void + { + $this->assertFalse((new FFmpeg('utopia-streaming-nope'))->available()); + } + + public function testEveryAdapterCarriesItsName(): void + { + $this->assertSame('ffmpeg', (new FFmpeg())->getName()); + $this->assertSame('ffprobe', (new FFprobe())->getName()); + $this->assertSame('mock', (new Mock())->getName()); + } + + /** + * Failures say which backend produced them, so a message from two layers + * down is still traceable. + */ + public function testFailuresNameTheBackend(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('mock: source "'); + + (new Mock())->open($this->dir.'/nothing.mp4'); + } +} diff --git a/tests/Video/Unit/ArgumentsDashTest.php b/tests/Video/Unit/ArgumentsDashTest.php new file mode 100644 index 0000000..52b8f6c --- /dev/null +++ b/tests/Video/Unit/ArgumentsDashTest.php @@ -0,0 +1,347 @@ + $reps + */ + private function build(Info $info, array $reps, ?Dash $output = null): string + { + $arguments = new Arguments( + $info, + (new X264())->crf(22)->keyframe(2.0), + $reps, + $output ?? new Dash(), + '/out', + ); + + return \implode(' ', $arguments->build()); + } + + public function testTemplatedAddressingIsTheDefault(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-f dash', $argv); + $this->assertStringContainsString('-use_template 1', $argv); + $this->assertStringContainsString('-use_timeline 1', $argv); + } + + /** + * Turning both off is what makes the manifest name every segment outright, + * which is the only way to address them by something other than filename. + */ + public function testListedAddressingIsRequestedByTurningBothOff(): void + { + $output = (new Dash())->template(false)->timeline(false); + + $this->assertTrue($output->listed()); + + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)], $output); + + $this->assertStringContainsString('-use_template 0', $argv); + $this->assertStringContainsString('-use_timeline 0', $argv); + } + + public function testSegmentNamingPatternsAreDeclared(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Dash())->name('video'), + ); + + $this->assertStringContainsString('-init_seg_name video_init_$RepresentationID$.$ext$', $argv); + $this->assertStringContainsString( + '-media_seg_name video_chunk_$RepresentationID$_$Number%05d$.$ext$', + $argv, + ); + } + + public function testSegmentNamingCanBeOverridden(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Dash())->init('i-$RepresentationID$.m4s')->media('c-$Number$.m4s'), + ); + + $this->assertStringContainsString('-init_seg_name i-$RepresentationID$.m4s', $argv); + $this->assertStringContainsString('-media_seg_name c-$Number$.m4s', $argv); + } + + public function testEveryRungLivesInOneCommand(): void + { + $argv = $this->build($this->info(), [ + new Representation(640, 360, 800), + new Representation(1280, 720, 2538), + ]); + + $this->assertSame(2, \substr_count($argv, '-map 0:v:0')); + $this->assertStringContainsString('-filter:v:0 scale=640:360', $argv); + $this->assertStringContainsString('-filter:v:1 scale=1280:720', $argv); + $this->assertSame(1, \substr_count($argv, '-f dash')); + } + + public function testAudioGetsItsOwnAdaptationSet(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-adaptation_sets id=0,streams=v id=1,streams=a', $argv); + } + + /** + * Video rungs are alternatives of one another so they share a set, but audio + * tracks in different languages are separate choices β€” and a set is the only + * place DASH can record a language. Grouping them together would present four + * languages as four bitrates of the first one. + */ + public function testEachAudioLanguageGetsItsOwnAdaptationSet(): void + { + $info = new Info( + duration: 60.0, + format: 'matroska', + hasVideo: true, + hasAudio: true, + width: 1920, + height: 1080, + audioTracks: [ + ['codec' => 'aac', 'language' => 'eng'], + ['codec' => 'aac', 'language' => 'spa'], + ['codec' => 'aac', 'language' => 'fra'], + ], + ); + + $argv = $this->build($info, [ + new Representation(640, 360, 800), + new Representation(1280, 720, 2538), + ]); + + // Two video rungs occupy output streams 0 and 1, so audio starts at 2. + $this->assertStringContainsString( + '-adaptation_sets id=0,streams=v id=1,streams=2 id=2,streams=3 id=3,streams=4', + $argv, + ); + } + + /** + * Untagged tracks are alternatives of nothing in particular, but they are + * still separate tracks, so each needs a set of its own the same way a + * language does. + */ + public function testUntaggedTracksAlsoGetOneAdaptationSetEach(): void + { + $info = new Info( + duration: 60.0, + format: 'matroska', + hasVideo: true, + hasAudio: true, + width: 1920, + height: 1080, + audioTracks: [ + ['codec' => 'aac', 'language' => 'und'], + ['codec' => 'aac', 'language' => 'und'], + ], + ); + + $argv = $this->build($info, [new Representation(1280, 720, 2538)]); + + $this->assertSame(2, \substr_count($argv, '-map 0:a:')); + $this->assertStringContainsString('-adaptation_sets id=0,streams=v id=1,streams=1 id=2,streams=2', $argv); + } + + public function testAdaptationSetsCanStillBeOverridden(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Dash())->sets('id=0,streams=v,a'), + ); + + $this->assertStringContainsString('-adaptation_sets id=0,streams=v,a', $argv); + } + + public function testSilentSourceHasNoAudioAdaptationSet(): void + { + $argv = $this->build($this->info(audio: false), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-adaptation_sets id=0,streams=v', $argv); + $this->assertStringNotContainsString('streams=a', $argv); + } + + public function testWholePresentationStaysInTheManifest(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-window_size 0', $argv); + } + + /** + * @testdox The manifest is written where the output says + */ + public function testManifestPath(): void + { + $arguments = new Arguments( + $this->info(), + new X264(), + [new Representation(1280, 720, 2538)], + (new Dash())->manifest('video.mpd'), + '/out', + ); + + $this->assertSame('/out/video.mpd', $arguments->target()); + } + + /** + * @testdox VP9 packages as DASH + */ + public function testVp9PackagesAsDash(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $arguments = new Arguments( + $this->info(), + new VP9(), + [new Representation(1280, 720, 2538)], + new Dash(), + '/out', + ); + + $this->assertStringContainsString('-f dash', $argv); + $this->assertContains('libvpx-vp9', $arguments->build()); + } + + public function testCmafAsksTheSameMuxerForBothDescriptions(): void + { + $arguments = new CmafArguments( + $this->info(), + (new X264())->crf(22)->keyframe(2.0), + [new Representation(1280, 720, 2538)], + new Cmaf(), + '/out', + ); + + $argv = \implode(' ', $arguments->build()); + + $this->assertStringContainsString('-f dash', $argv); + $this->assertStringContainsString('-dash_segment_type mp4', $argv); + $this->assertStringContainsString('-hls_playlist 1', $argv); + $this->assertStringContainsString('-hls_master_name master.m3u8', $argv); + $this->assertSame('/out/manifest.mpd', $arguments->target()); + } + + /** + * The muxer puts every video rung in one adaptation set and then refuses a + * set whose members disagree on aspect ratio β€” but only once the command is + * already running, and without saying which rungs clashed. + */ + public function testRejectsALadderThatChangesShape(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('share one aspect ratio'); + + new Arguments( + $this->info(), + new X264(), + [ + new Representation(320, 240, 300), // 4:3 + new Representation(640, 360, 800), // 16:9 + ], + new Dash(), + '/out', + ); + } + + public function testTheMessageNamesTheRungsThatClash(): void + { + try { + new Arguments( + $this->info(), + new X264(), + [new Representation(320, 240, 300), new Representation(640, 360, 800)], + new Dash(), + '/out', + ); + $this->fail('expected the mismatch to be rejected'); + } catch (Unsupported $exception) { + $this->assertStringContainsString('4:3', $exception->getMessage()); + $this->assertStringContainsString('240p (320x240)', $exception->getMessage()); + $this->assertStringContainsString('16:9', $exception->getMessage()); + $this->assertStringContainsString('360p (640x360)', $exception->getMessage()); + $this->assertStringContainsString('HLS', $exception->getMessage()); + } + } + + public function testAcceptsALadderThatKeepsItsShape(): void + { + $argv = $this->build($this->info(), [ + new Representation(320, 180, 300), + new Representation(640, 360, 800), + new Representation(1280, 720, 2538), + ]); + + $this->assertStringContainsString('-f dash', $argv); + $this->assertSame(3, \substr_count($argv, '-map 0:v:0')); + } + + public function testCmafInheritsTheSameShapeRule(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('share one aspect ratio'); + + new CmafArguments( + $this->info(), + new X264(), + [new Representation(320, 240, 300), new Representation(640, 360, 800)], + new Cmaf(), + '/out', + ); + } + + public function testCmafListsEverySegmentByDefault(): void + { + $output = new Cmaf(); + + $this->assertTrue($output->listed()); + + $arguments = new CmafArguments( + $this->info(), + new X264(), + [new Representation(1280, 720, 2538)], + $output, + '/out', + ); + + $argv = \implode(' ', $arguments->build()); + + $this->assertStringContainsString('-use_template 0', $argv); + $this->assertStringContainsString('-use_timeline 0', $argv); + } +} diff --git a/tests/Video/Unit/ArgumentsHlsTest.php b/tests/Video/Unit/ArgumentsHlsTest.php new file mode 100644 index 0000000..1c6b517 --- /dev/null +++ b/tests/Video/Unit/ArgumentsHlsTest.php @@ -0,0 +1,388 @@ + $audioTracks + */ + private function info(bool $video = true, bool $audio = true, array $audioTracks = []): Info + { + return new Info( + duration: 60.0, + format: 'mov,mp4', + hasVideo: $video, + hasAudio: $audio, + width: 1920, + height: 1080, + audioTracks: $audioTracks, + ); + } + + /** + * @param list $reps + */ + private function build(Info $info, array $reps, ?Hls $output = null): string + { + $arguments = new Arguments( + $info, + (new X264())->crf(22)->keyframe(2.0), + $reps, + $output ?? new Hls(), + '/out', + ); + + return \implode(' ', $arguments->build()); + } + + public function testSingleRungCarriesSizingAndCappedBitrate(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538, 128)]); + + $this->assertStringContainsString('-map 0:v:0', $argv); + $this->assertStringContainsString('-map 0:a:0', $argv); + $this->assertStringContainsString('-filter:v:0 scale=1280:720', $argv); + $this->assertStringContainsString('-b:v:0 2538k', $argv); + $this->assertStringContainsString('-maxrate:v:0 2538k', $argv); + $this->assertStringContainsString('-bufsize:v:0 5076k', $argv); + $this->assertStringContainsString('-b:a:0 128k', $argv); + } + + /** + * Every rung has to be described by one command; running ffmpeg once per + * rung would decode the source repeatedly and leave each run overwriting + * the master playlist the previous one wrote. + */ + public function testEveryRungLivesInOneCommandAndOneMaster(): void + { + $argv = $this->build($this->info(), [ + new Representation(640, 360, 800, 96), + new Representation(1280, 720, 2538, 128), + ]); + + $this->assertSame(2, \substr_count($argv, '-map 0:v:0')); + $this->assertStringContainsString('-filter:v:0 scale=640:360', $argv); + $this->assertStringContainsString('-filter:v:1 scale=1280:720', $argv); + $this->assertStringContainsString('-b:v:0 800k', $argv); + $this->assertStringContainsString('-b:v:1 2538k', $argv); + $this->assertSame(1, \substr_count($argv, '-master_pl_name')); + $this->assertSame(1, \substr_count($argv, '-var_stream_map')); + $this->assertStringContainsString('v:0,name:360p,agroup:audio v:1,name:720p,agroup:audio', $argv); + } + + public function testOutputPathCarriesTheVariantPlaceholder(): void + { + $arguments = new Arguments( + $this->info(), + new X264(), + [new Representation(1280, 720, 2538)], + (new Hls())->name('video'), + '/out', + ); + + $this->assertSame('/out/video_%v.m3u8', $arguments->target()); + } + + public function testTransportStreamIsTheDefault(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-hls_segment_type mpegts', $argv); + $this->assertStringContainsString('-hls_segment_filename /out/stream_%v_%04d.ts', $argv); + $this->assertStringNotContainsString('-hls_fmp4_init_filename', $argv); + } + + public function testFragmentedMp4AddsAnInitialisationSegment(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Hls())->segments(Hls::FMP4), + ); + + $this->assertStringContainsString('-hls_segment_type fmp4', $argv); + $this->assertStringContainsString('-hls_segment_filename /out/stream_%v_%04d.m4s', $argv); + $this->assertStringContainsString('-hls_fmp4_init_filename stream_%v_init.mp4', $argv); + } + + public function testSegmentsAreDeclaredIndependent(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-hls_flags independent_segments', $argv); + } + + public function testVodPlaylistKeepsEverySegment(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-hls_playlist_type vod', $argv); + $this->assertStringContainsString('-hls_list_size 0', $argv); + } + + public function testSegmentDurationIsHonoured(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Hls())->segment(6), + ); + + $this->assertStringContainsString('-hls_time 6', $argv); + } + + public function testLanguageTaggedTracksBecomeSeparateAudioRenditions(): void + { + $info = $this->info(audioTracks: [ + ['codec' => 'aac', 'language' => 'eng'], + ['codec' => 'aac', 'language' => 'spa'], + ]); + + $argv = $this->build($info, [new Representation(1280, 720, 2538, 128)]); + + $this->assertStringContainsString('-map 0:a:0', $argv); + $this->assertStringContainsString('-map 0:a:1', $argv); + $this->assertStringContainsString('a:0,agroup:audio,language:eng,name:audio_0,default:yes', $argv); + $this->assertStringContainsString('a:1,agroup:audio,language:spa,name:audio_1', $argv); + $this->assertStringNotContainsString('a:1,agroup:audio,language:spa,name:audio_1,default:yes', $argv); + } + + public function testUnsafeLanguageMetadataIsOmittedWithoutDroppingTheTrack(): void + { + $info = $this->info(audioTracks: [ + ['codec' => 'aac', 'language' => 'eng,default:yes'], + ['codec' => 'aac', 'language' => 'en-US'], + ]); + + $argv = $this->build($info, [new Representation(1280, 720, 2538, 128)]); + + $this->assertSame(2, \substr_count($argv, '-map 0:a:')); + $this->assertStringNotContainsString('eng,default:yes', $argv); + $this->assertStringContainsString('a:0,agroup:audio,name:audio_0,default:yes', $argv); + $this->assertStringContainsString('a:1,agroup:audio,language:en-US,name:audio_1', $argv); + } + + public function testUntaggedAudioBecomesOneAnonymousRendition(): void + { + $info = $this->info(audioTracks: [['codec' => 'aac', 'language' => 'und']]); + + $argv = $this->build($info, [new Representation(1280, 720, 2538)]); + + $this->assertSame(1, \substr_count($argv, '-map 0:a:')); + $this->assertStringContainsString('a:0,agroup:audio,name:audio_0,default:yes', $argv); + } + + /** + * A language tag is what tells one rendition from another, but a track + * without one is still a track: a file carrying four untagged dubs has to + * come out with four, not with the first one alone. + */ + public function testEveryUntaggedTrackIsStillCarried(): void + { + $info = $this->info(audioTracks: [ + ['codec' => 'aac', 'language' => 'und'], + ['codec' => 'aac', 'language' => 'und'], + ['codec' => 'aac', 'language' => ''], + ]); + + $argv = $this->build($info, [new Representation(1280, 720, 2538, 128)]); + + $this->assertSame(3, \substr_count($argv, '-map 0:a:')); + $this->assertStringContainsString('-map 0:a:0', $argv); + $this->assertStringContainsString('-map 0:a:1', $argv); + $this->assertStringContainsString('-map 0:a:2', $argv); + $this->assertStringContainsString('a:0,agroup:audio,name:audio_0,default:yes', $argv); + $this->assertStringContainsString('a:1,agroup:audio,name:audio_1', $argv); + $this->assertStringContainsString('a:2,agroup:audio,name:audio_2', $argv); + $this->assertStringNotContainsString('language:und', $argv); + } + + /** + * @testdox A partly tagged source keeps its untagged tracks too + */ + public function testTaggedAndUntaggedTracksTravelTogether(): void + { + $info = $this->info(audioTracks: [ + ['codec' => 'aac', 'language' => 'eng'], + ['codec' => 'aac', 'language' => 'und'], + ['codec' => 'aac', 'language' => 'spa'], + ]); + + $argv = $this->build($info, [new Representation(1280, 720, 2538, 128)]); + + $this->assertSame(3, \substr_count($argv, '-map 0:a:')); + $this->assertStringContainsString('a:0,agroup:audio,language:eng,name:audio_0,default:yes', $argv); + $this->assertStringContainsString('a:1,agroup:audio,name:audio_1', $argv); + $this->assertStringContainsString('a:2,agroup:audio,language:spa,name:audio_2', $argv); + } + + /** + * Segments are cut where a keyframe already is, so a job that never named a + * cadence takes the segment length rather than leaving the encoder to put + * keyframes wherever it liked. + */ + public function testKeyframesFollowTheSegmentLengthWhenNoneWasAskedFor(): void + { + $arguments = new Arguments( + $this->info(), + new X264(), + [new Representation(1280, 720, 2538)], + (new Hls())->segment(4), + '/out', + ); + + $argv = \implode(' ', $arguments->build()); + + $this->assertStringContainsString('-force_key_frames expr:gte(t,n_forced*4)', $argv); + $this->assertStringContainsString('-hls_time 4', $argv); + } + + public function testAnExplicitKeyframeIntervalStillWins(): void + { + $arguments = new Arguments( + $this->info(), + (new X264())->keyframe(2.0), + [new Representation(1280, 720, 2538)], + (new Hls())->segment(6), + '/out', + ); + + $argv = \implode(' ', $arguments->build()); + + $this->assertStringContainsString('-force_key_frames expr:gte(t,n_forced*2)', $argv); + $this->assertStringContainsString('-hls_time 6', $argv); + } + + /** + * @testdox Keyframes further apart than a segment are rejected + */ + public function testKeyframesFurtherApartThanASegmentAreRejected(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('A keyframe every 10s cannot cut 4s segments'); + + new Arguments( + $this->info(), + (new X264())->keyframe(10.0), + [new Representation(1280, 720, 2538)], + (new Hls())->segment(4), + '/out', + ); + } + + public function testAudioOnlySourceDropsEveryVideoArgument(): void + { + $argv = $this->build( + $this->info(video: false), + [new Representation(0, 0, 1, 128)], + ); + + $this->assertStringNotContainsString('-c:v', $argv); + $this->assertStringNotContainsString('-map 0:v:0', $argv); + $this->assertStringNotContainsString('-filter:v:0', $argv); + $this->assertStringNotContainsString('-b:v:0', $argv); + $this->assertStringNotContainsString('-crf', $argv); + $this->assertStringContainsString('-b:a:0 128k', $argv); + } + + public function testSilentSourceDropsEveryAudioArgument(): void + { + $argv = $this->build( + $this->info(audio: false), + [new Representation(1280, 720, 2538)], + ); + + $this->assertStringNotContainsString('-c:a', $argv); + $this->assertStringNotContainsString('-map 0:a', $argv); + $this->assertStringNotContainsString('agroup', $argv); + $this->assertStringContainsString('v:0,name:720p', $argv); + } + + public function testBaseUrlIsWrittenIntoPlaylists(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Hls())->url('https://cdn.example/'), + ); + + $this->assertStringContainsString('-hls_base_url https://cdn.example/', $argv); + } + + public function testRawMuxerParametersAreAppended(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Hls())->params(['-tag:v', 'avc1']), + ); + + $this->assertStringContainsString('-tag:v avc1', $argv); + } + + public function testCodecThatCannotBePackagedIsRejected(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('HLS cannot carry libvpx-vp9'); + + new Arguments( + $this->info(), + new VP9(), + [new Representation(1280, 720, 2538)], + new Hls(), + '/out', + ); + } + + public function testStreamCopyIsRejectedBeforeFfmpegRuns(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('Stream copy cannot build an adaptive package'); + + new Arguments( + $this->info(), + new Copy(), + [new Representation(1280, 720, 2538)], + new Hls(), + '/out', + ); + } + + public function testRepresentationNamesHaveToBeUniqueWithinALadder(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('Representation name "360p" is used more than once'); + + new Arguments( + $this->info(), + new X264(), + [ + new Representation(640, 360, 800), + new Representation(480, 360, 500), + ], + new Hls(), + '/out', + ); + } + + public function testLadderCannotBeEmpty(): void + { + $this->expectException(Unsupported::class); + + new Arguments($this->info(), new X264(), [], new Hls(), '/out'); + } +} diff --git a/tests/Video/Unit/EncoderTest.php b/tests/Video/Unit/EncoderTest.php new file mode 100644 index 0000000..87b0ecc --- /dev/null +++ b/tests/Video/Unit/EncoderTest.php @@ -0,0 +1,121 @@ +dir = $dir; + $this->file = $dir.'/source.mp4'; + \file_put_contents($this->file, 'source'); + } + + protected function tearDown(): void + { + foreach (\glob($this->dir.'/*') ?: [] as $path) { + \unlink($path); + } + + @\rmdir($this->dir); + } + + private function encoder(?FakeEncoder $adapter = null): Encoder + { + return new Encoder($adapter ?? new FakeEncoder(), new FakeProbe()); + } + + public function testDelegatesProbing(): void + { + $info = $this->encoder()->probe($this->file); + + $this->assertSame(10.0, $info->duration); + $this->assertTrue($this->encoder()->valid($this->file)); + } + + public function testReportsWhichBackendIsWorking(): void + { + $this->assertSame('fake', $this->encoder()->getName()); + } + + public function testDelegatesStillsAndSheets(): void + { + $encoder = $this->encoder(); + + $this->assertSame('/out/poster.jpg', $encoder->grab($this->file, '/out/poster.jpg')); + $this->assertCount(1, $encoder->tile($this->file, '/out')->images()); + } + + public function testEncodePassesTheChainToTheAdapter(): void + { + $adapter = new FakeEncoder(); + $format = new X264(); + $rep = new Representation(1280, 720, 2538); + + $path = $this->encoder($adapter) + ->open($this->file) + ->format($format) + ->add($rep) + ->encode($this->dir.'/video.mp4'); + + $this->assertSame($this->dir.'/video.mp4', $path); + $this->assertSame($this->file, $adapter->opened); + $this->assertSame($format, $adapter->format); + $this->assertSame([$rep], $adapter->reps); + } + + public function testListenersReachTheAdapter(): void + { + $adapter = new FakeEncoder(); + $seen = []; + + $this->encoder($adapter) + ->open($this->file) + ->add(new Representation(1280, 720, 2538)) + ->on(Encoder::PROGRESS, function (mixed $progress) use (&$seen): void { + $this->assertInstanceOf(Progress::class, $progress); + $seen[] = $progress->percent; + }) + ->encode($this->dir.'/video.mp4'); + + $this->assertSame([25.0, 100.0], $seen); + } + + /** + * The adapter's own open() drops the previous job, so a reused facade does + * not carry the last job's rungs into this one. + */ + public function testOpeningAgainForgetsThePreviousJob(): void + { + $adapter = new FakeEncoder(); + $encoder = $this->encoder($adapter); + + $encoder->open($this->file)->add(new Representation(1280, 720, 2538)); + $encoder->open($this->file)->encode($this->dir.'/video.mp4'); + + $this->assertSame([], $adapter->reps); + } + + public function testOpeningAMissingSourceIsAFailure(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('does not exist'); + + $this->encoder()->open('/nowhere/nothing.mp4'); + } +} diff --git a/tests/Video/Unit/FakeEncoder.php b/tests/Video/Unit/FakeEncoder.php new file mode 100644 index 0000000..866a939 --- /dev/null +++ b/tests/Video/Unit/FakeEncoder.php @@ -0,0 +1,102 @@ + */ + public array $reps = []; + + public int $encoded = 0; + + /** @var array> */ + private array $listeners = []; + + public function open(string $path): static + { + $this->opened = $path; + $this->reps = []; + $this->listeners = []; + + return $this; + } + + public function valid(string $path): bool + { + return true; + } + + public function format(Format $format): static + { + $this->format = $format; + + return $this; + } + + public function add(Representation ...$representations): static + { + foreach ($representations as $representation) { + $this->reps[] = $representation; + } + + return $this; + } + + public function on(string $event, callable $listener): static + { + $this->listeners[$event][] = $listener; + + return $this; + } + + public function encode(string $path): string + { + $this->encoded++; + + foreach ([25.0, 100.0] as $percent) { + foreach ($this->listeners[Observable::PROGRESS] ?? [] as $listener) { + $listener(new Progress(percent: $percent)); + } + } + + $directory = \dirname($path); + + if (! \is_dir($directory)) { + \mkdir($directory, 0o755, true); + } + + \file_put_contents($path, 'encoded'); + + return $path; + } + + public function grab(string $path, string $output, ?Thumb $options = null): string + { + return $output; + } + + public function tile(string $path, string $dir, ?Tile $options = null): Spritesheet + { + return new Spritesheet([$dir.'/sprite1.jpg'], []); + } + + public function getName(): string + { + return 'fake'; + } +} diff --git a/tests/Video/Unit/FakePackager.php b/tests/Video/Unit/FakePackager.php new file mode 100644 index 0000000..842b19d --- /dev/null +++ b/tests/Video/Unit/FakePackager.php @@ -0,0 +1,79 @@ + */ + public array $opened = []; + + /** @var list */ + public array $tagged = []; + + /** @var list */ + public array $reps = []; + + public ?Output $output = null; + + /** @var array> */ + protected array $listeners = []; + + public function open(string $path, ?Representation $as = null): static + { + $this->opened[] = $path; + $this->tagged[] = $as; + + return $this; + } + + public function add(Representation ...$representations): static + { + foreach ($representations as $representation) { + $this->reps[] = $representation; + } + + return $this; + } + + public function output(Output $output): static + { + $this->output = $output; + + return $this; + } + + public function on(string $event, callable $listener): static + { + $this->listeners[$event][] = $listener; + + return $this; + } + + public function pack(string $dir): Package + { + foreach ($this->listeners[Observable::LOG] ?? [] as $listener) { + $listener('packing'); + } + + return new Package(); + } + + public function getName(): string + { + return 'fake'; + } +} diff --git a/tests/Video/Unit/FakeProbe.php b/tests/Video/Unit/FakeProbe.php new file mode 100644 index 0000000..64cafc6 --- /dev/null +++ b/tests/Video/Unit/FakeProbe.php @@ -0,0 +1,26 @@ +build(); + + $this->assertSame(['-c:v', 'libx264', '-c:a', 'aac'], \array_slice($args, 0, 4)); + $this->assertContains('-keyint_min', $args); + $this->assertContains('-g', $args); + } + + /** + * @testdox The quality knobs reach the command + */ + public function testQualityKnobsAppear(): void + { + $args = (new X264())->crf(22)->bframes(3)->keyframe(2.0)->build(); + $argv = \implode(' ', $args); + + $this->assertStringContainsString('-crf 22', $argv); + $this->assertStringContainsString('-bf 3', $argv); + $this->assertStringContainsString('-force_key_frames expr:gte(t,n_forced*2)', $argv); + } + + public function testFractionalKeyframeIntervalStaysReadable(): void + { + $args = (new X264())->keyframe(1.5)->build(); + + $this->assertContains('expr:gte(t,n_forced*1.5)', $args); + } + + /** + * Packaging passes the segment length as the cadence to fall back on, since + * a segment can only start where a keyframe already is. An encode to a plain + * file has no segments and passes nothing. + */ + public function testAFallbackCadenceIsUsedOnlyWhenNoneWasSet(): void + { + $this->assertContains('expr:gte(t,n_forced*6)', (new X264())->build(cadence: 6.0)); + $this->assertContains( + 'expr:gte(t,n_forced*2)', + (new X264())->keyframe(2.0)->build(cadence: 6.0), + ); + $this->assertNotContains('-force_key_frames', (new X264())->build()); + } + + public function testRawParametersAreAppendedLast(): void + { + $args = (new X264())->crf(22)->params(['-dn', '-sn'])->build(); + + $this->assertSame(['-dn', '-sn'], \array_slice($args, -2)); + } + + public function testAudioOnlyJobDropsVideoArguments(): void + { + $args = (new X264())->crf(22)->bframes(3)->build(video: false); + + $this->assertSame(['-c:a', 'aac'], $args); + $this->assertNotContains('-crf', $args); + $this->assertNotContains('-c:v', $args); + } + + public function testVideoOnlyJobDropsAudioCodec(): void + { + $args = (new X264())->build(audio: false); + + $this->assertNotContains('-c:a', $args); + $this->assertContains('-c:v', $args); + } + + /** + * @testdox HEVC is tagged hvc1 so Apple players accept it + */ + public function testHevcTagsForApplePlayback(): void + { + $args = (new HEVC())->build(); + + $this->assertContains('libx265', $args); + $this->assertContains('hvc1', $args); + } + + /** + * @testdox VP9 can be packaged as DASH but not as HLS or CMAF + */ + public function testVp9PackagesAsDashOnly(): void + { + $format = new VP9(); + + $this->assertSame([Output::DASH], $format->supports()); + $this->assertContains('libvpx-vp9', $format->build()); + $this->assertSame('libopus', $format->sound()); + } + + /** + * @testdox X264 can be packaged as HLS, DASH and CMAF alike + */ + public function testX264PackagesEverywhere(): void + { + $this->assertSame( + [Output::HLS, Output::DASH, Output::CMAF], + (new X264())->supports(), + ); + } + + public function testCopyIgnoresQualityKnobs(): void + { + $args = (new Copy())->crf(22)->bframes(3)->keyframe(2.0)->build(); + + $this->assertSame(['-c:v', 'copy', '-c:a', 'copy'], $args); + } + + public function testCodecsCanBeOverridden(): void + { + $format = new X264('h264_videotoolbox', 'libfdk_aac'); + + $this->assertSame('h264_videotoolbox', $format->codec()); + $this->assertSame('libfdk_aac', $format->sound()); + } + + /** + * codec() and build() have to agree, or a caller inspecting the format is + * told one thing while ffmpeg is told another. + */ + public function testCopyEmitsTheCodecsItReports(): void + { + $copy = new Copy(); + + $this->assertSame('copy', $copy->codec()); + $this->assertSame(['-c:v', 'copy', '-c:a', 'copy'], $copy->build()); + + $named = new Copy('libx264', 'libopus'); + + $this->assertSame('libx264', $named->codec()); + $this->assertSame('libopus', $named->sound()); + $this->assertSame(['-c:v', 'libx264', '-c:a', 'libopus'], $named->build()); + } +} diff --git a/tests/Video/Unit/FusedPackager.php b/tests/Video/Unit/FusedPackager.php new file mode 100644 index 0000000..fdbd1e8 --- /dev/null +++ b/tests/Video/Unit/FusedPackager.php @@ -0,0 +1,47 @@ +format = $format; + + return $this; + } + + public function encode(string $path): string + { + return $path; + } + + public function grab(string $path, string $output, ?Thumb $options = null): string + { + return $output; + } + + public function tile(string $path, string $dir, ?Tile $options = null): Spritesheet + { + return new Spritesheet([$dir.'/sprite1.jpg'], []); + } +} diff --git a/tests/Video/Unit/ImmutabilityTest.php b/tests/Video/Unit/ImmutabilityTest.php new file mode 100644 index 0000000..c173ae0 --- /dev/null +++ b/tests/Video/Unit/ImmutabilityTest.php @@ -0,0 +1,284 @@ + + */ + public static function setters(): array + { + return [ + 'Format::crf' => [ + fn () => new X264(), + fn (X264 $it) => $it->crf(18), + fn (X264 $it) => \in_array('-crf', $it->build(), true), + false, + true, + ], + 'Format::bframes' => [ + fn () => new X264(), + fn (X264 $it) => $it->bframes(3), + fn (X264 $it) => \in_array('-bf', $it->build(), true), + false, + true, + ], + 'Format::keyframe' => [ + fn () => new X264(), + fn (X264 $it) => $it->keyframe(2.0), + fn (X264 $it) => $it->interval(), + null, + 2.0, + ], + 'Format::params' => [ + fn () => new X264(), + fn (X264 $it) => $it->params(['-movflags', '+faststart']), + fn (X264 $it) => \in_array('-movflags', $it->build(), true), + false, + true, + ], + 'Output::segment' => [ + fn () => new Hls(), + fn (Hls $it) => $it->segment(2.0), + fn (Hls $it) => $it->duration(), + 6.0, + 2.0, + ], + 'Output::manifests' => [ + fn () => new Hls(), + fn (Hls $it) => $it->manifests(false), + fn (Hls $it) => $it->keeps(), + true, + false, + ], + 'Output::name' => [ + fn () => new Hls(), + fn (Hls $it) => $it->name('ladder'), + fn (Hls $it) => $it->base(), + 'stream', + 'ladder', + ], + 'Output::params' => [ + fn () => new Hls(), + fn (Hls $it) => $it->params(['-max_muxing_queue_size', '1024']), + fn (Hls $it) => $it->extra(), + [], + ['-max_muxing_queue_size', '1024'], + ], + 'Hls::segments' => [ + fn () => new Hls(), + fn (Hls $it) => $it->segments(Hls::FMP4), + fn (Hls $it) => $it->container(), + Hls::MPEGTS, + Hls::FMP4, + ], + 'Hls::init' => [ + fn () => new Hls(), + fn (Hls $it) => $it->init('start.mp4'), + fn (Hls $it) => $it->initFile(), + 'init.mp4', + 'start.mp4', + ], + 'Hls::master' => [ + fn () => new Hls(), + fn (Hls $it) => $it->master('index.m3u8'), + fn (Hls $it) => $it->masterFile(), + 'master.m3u8', + 'index.m3u8', + ], + 'Hls::url' => [ + fn () => new Hls(), + fn (Hls $it) => $it->url('https://cdn.example/'), + fn (Hls $it) => $it->prefix(), + '', + 'https://cdn.example/', + ], + 'Hls::flags' => [ + fn () => new Hls(), + fn (Hls $it) => $it->flags(['temp_file']), + fn (Hls $it) => $it->hlsFlags(), + ['independent_segments'], + ['temp_file'], + ], + 'Dash::template' => [ + fn () => new Dash(), + fn (Dash $it) => $it->template(false), + fn (Dash $it) => $it->templated(), + true, + false, + ], + 'Dash::timeline' => [ + fn () => new Dash(), + fn (Dash $it) => $it->timeline(false), + fn (Dash $it) => $it->timelined(), + true, + false, + ], + 'Dash::manifest' => [ + fn () => new Dash(), + fn (Dash $it) => $it->manifest('video.mpd'), + fn (Dash $it) => $it->manifestFile(), + 'manifest.mpd', + 'video.mpd', + ], + 'Dash::init' => [ + fn () => new Dash(), + fn (Dash $it) => $it->init('i-$RepresentationID$.$ext$'), + fn (Dash $it) => $it->initPattern(), + 'stream_init_$RepresentationID$.$ext$', + 'i-$RepresentationID$.$ext$', + ], + 'Dash::media' => [ + fn () => new Dash(), + fn (Dash $it) => $it->media('m-$Number$.$ext$'), + fn (Dash $it) => $it->mediaPattern(), + 'stream_chunk_$RepresentationID$_$Number%05d$.$ext$', + 'm-$Number$.$ext$', + ], + 'Dash::sets' => [ + fn () => new Dash(), + fn (Dash $it) => $it->sets('id=0,streams=v'), + fn (Dash $it) => $it->adaptations(1, 1), + 'id=0,streams=v id=1,streams=a', + 'id=0,streams=v', + ], + 'Cmaf::master' => [ + fn () => new Cmaf(), + fn (Cmaf $it) => $it->master('index.m3u8'), + fn (Cmaf $it) => $it->masterFile(), + 'master.m3u8', + 'index.m3u8', + ], + 'Thumb::time' => [ + fn () => new Thumb(), + fn (Thumb $it) => $it->time(12.5), + fn (Thumb $it) => $it->at(), + null, + 12.5, + ], + 'Thumb::width' => [ + fn () => new Thumb(), + fn (Thumb $it) => $it->width(640), + fn (Thumb $it) => $it->size(), + 320, + 640, + ], + 'Thumb::quality' => [ + fn () => new Thumb(), + fn (Thumb $it) => $it->quality(4), + fn (Thumb $it) => $it->scale(), + 2, + 4, + ], + 'Tile::interval' => [ + fn () => new Tile(), + fn (Tile $it) => $it->interval(5.0), + fn (Tile $it) => $it->every(30.0), + 2.0, + 5.0, + ], + 'Tile::width' => [ + fn () => new Tile(), + fn (Tile $it) => $it->width(200), + fn (Tile $it) => $it->size(), + 160, + 200, + ], + 'Tile::grid' => [ + fn () => new Tile(), + fn (Tile $it) => $it->grid(4, 3), + fn (Tile $it) => $it->cells(), + 25, + 12, + ], + 'Tile::quality' => [ + fn () => new Tile(), + fn (Tile $it) => $it->quality(5), + fn (Tile $it) => $it->scale(), + 3, + 5, + ], + 'Tile::name' => [ + fn () => new Tile(), + fn (Tile $it) => $it->name('thumbs'), + fn (Tile $it) => $it->base(), + 'sprite', + 'thumbs', + ], + 'Tile::vtt' => [ + fn () => new Tile(), + fn (Tile $it) => $it->vtt(false), + fn (Tile $it) => $it->writes(), + true, + false, + ], + ]; + } + + /** + * @dataProvider setters + */ + public function testASetterReturnsACopyAndLeavesItsReceiverAlone( + callable $make, + callable $set, + callable $read, + mixed $default, + mixed $changed, + ): void { + $original = $make(); + $modified = $set($original); + + $this->assertNotSame($original, $modified, 'a setter has to return a new instance'); + $this->assertSame($default, $read($original), 'the receiver must keep its value'); + $this->assertSame($changed, $read($modified), 'the copy must carry the new value'); + } + + /** + * A chain accumulates across copies: each call starts from the last copy, + * so the end of the chain carries everything set along the way. + */ + public function testAChainAccumulatesAcrossCopies(): void + { + $format = (new X264())->crf(22)->bframes(3); + $args = $format->build(); + + $this->assertContains('-crf', $args); + $this->assertContains('-bf', $args); + } + + /** + * The sharing scenario itself: two jobs configure one shared preset and + * neither sees the other's settings. + */ + public function testTwoHoldersOfOneInstanceCannotAffectEachOther(): void + { + $shared = new X264(); + + $first = $shared->crf(18); + $second = $shared->crf(30); + + $this->assertContains('18', $first->build()); + $this->assertContains('30', $second->build()); + $this->assertNotContains('-crf', $shared->build()); + } +} diff --git a/tests/Video/Unit/MockTest.php b/tests/Video/Unit/MockTest.php new file mode 100644 index 0000000..d37aeb6 --- /dev/null +++ b/tests/Video/Unit/MockTest.php @@ -0,0 +1,116 @@ +adapter ??= new Mock(); + } + + protected function encoder(): Encoder + { + // Given as the probe as well, so nothing here reaches for a binary. + return new Encoder($this->adapter(), $this->adapter()); + } + + protected function packager(): Packager + { + return new Packager($this->adapter(), probe: $this->adapter()); + } + + protected function source(): string + { + $path = $this->dir.'/source.mp4'; + + if (! \is_file($path)) { + \file_put_contents($path, 'pretend this is a video'); + } + + return $path; + } + + protected function names(): array + { + return [ + 'encoder' => 'mock', + 'packager' => 'mock', + ]; + } + + /** + * One class covering several capabilities is the point of the merge: pass it + * once and it serves every role it can, on either facade. + */ + public function testOneAdapterServesEveryCapabilityItImplements(): void + { + $mock = new Mock(); + + $this->assertSame('mock', (new Encoder($mock, $mock))->getName()); + $this->assertSame('mock', (new Packager($mock, probe: $mock))->getName()); + } + + public function testDescribesTheSourceItWasToldToPretend(): void + { + $adapter = (new Mock())->pretend(duration: 12.0, width: 1280, height: 720, audio: false); + $info = (new Encoder($adapter, $adapter))->probe($this->source()); + + $this->assertSame(12.0, $info->duration); + $this->assertSame(1280, $info->width); + $this->assertFalse($info->hasAudio); + } + + /** + * A double that accepts what the real backend refuses is worse than a plain + * bug: it is what code gets proved against. FFmpeg raises Input for a source + * with no picture in it, so this has to as well β€” it used to divide by zero. + */ + public function testRefusesToTileASourceWithNoVideo(): void + { + $adapter = (new Mock())->pretend(duration: 8.0, width: 0, height: 0, audio: true); + + $this->expectException(Input::class); + $this->expectExceptionMessage('carries no video to tile'); + + (new Encoder($adapter, $adapter))->tile($this->source(), $this->dir.'/tiles'); + } + + public function testRefusesToGrabFromASourceWithNoPicture(): void + { + $adapter = (new Mock())->pretend(duration: 8.0, width: 0, height: 0, audio: true); + + $this->expectException(Input::class); + $this->expectExceptionMessage('carries no image to grab'); + + (new Encoder($adapter, $adapter))->grab($this->source(), $this->dir.'/still.jpg'); + } + + public function testRefusesToTileASourceWithNoDuration(): void + { + $adapter = (new Mock())->pretend(duration: 0.0); + + $this->expectException(Input::class); + $this->expectExceptionMessage('no measurable duration'); + + (new Encoder($adapter, $adapter))->tile($this->source(), $this->dir.'/tiles'); + } +} diff --git a/tests/Video/Unit/OutputTest.php b/tests/Video/Unit/OutputTest.php new file mode 100644 index 0000000..c2efa1a --- /dev/null +++ b/tests/Video/Unit/OutputTest.php @@ -0,0 +1,324 @@ +assertSame(6.0, $output->duration()); + $this->assertTrue($output->keeps()); + $this->assertSame('stream', $output->base()); + $this->assertSame([], $output->extra()); + } + } + + /** + * @testdox Each output reports which kind of stream it is + */ + public function testTypes(): void + { + $this->assertSame(Output::HLS, (new Hls())->type()); + $this->assertSame(Output::DASH, (new Dash())->type()); + $this->assertSame(Output::CMAF, (new Cmaf())->type()); + } + + public function testHlsDefaultsToTransportStream(): void + { + $hls = new Hls(); + + $this->assertFalse($hls->fragmented()); + $this->assertSame('ts', $hls->extension()); + $this->assertSame('master.m3u8', $hls->masterFile()); + $this->assertSame(['independent_segments'], $hls->hlsFlags()); + } + + /** + * @testdox HLS in fMP4 mode asks for an init segment + */ + public function testHlsFragmentedMp4(): void + { + $hls = (new Hls())->segments(Hls::FMP4); + + $this->assertTrue($hls->fragmented()); + $this->assertSame('m4s', $hls->extension()); + } + + public function testDashDefaultsToTemplatedAddressing(): void + { + $dash = new Dash(); + + $this->assertTrue($dash->templated()); + $this->assertTrue($dash->timelined()); + $this->assertFalse($dash->listed()); + $this->assertSame('manifest.mpd', $dash->manifestFile()); + } + + public function testDashListsSegmentsOnlyWhenBothSwitchesAreOff(): void + { + $this->assertFalse((new Dash())->template(false)->listed()); + $this->assertFalse((new Dash())->timeline(false)->listed()); + $this->assertTrue((new Dash())->template(false)->timeline(false)->listed()); + } + + /** + * Both manifests have to describe the same segments, so CMAF starts from + * explicit addressing rather than a formula. + */ + public function testCmafListsSegmentsByDefault(): void + { + $cmaf = new Cmaf(); + + $this->assertTrue($cmaf->listed()); + $this->assertSame('master.m3u8', $cmaf->masterFile()); + $this->assertSame('manifest.mpd', $cmaf->manifestFile()); + } + + public function testNamingFlowsIntoSegmentPatterns(): void + { + $dash = (new Dash())->name('video'); + + $this->assertStringStartsWith('video_init_', $dash->initPattern()); + $this->assertStringStartsWith('video_chunk_', $dash->mediaPattern()); + } + + public function testAdaptationSetsFollowTheStreamsPresent(): void + { + $dash = new Dash(); + + $this->assertSame('id=0,streams=v id=1,streams=a', $dash->adaptations(1, 1)); + $this->assertSame('id=0,streams=v', $dash->adaptations(1, 0)); + $this->assertSame('custom', $dash->sets('custom')->adaptations(1, 1)); + } + + /** + * Languages are separate choices rather than bitrate alternatives, so each + * needs its own set β€” that is also the only place DASH records a language. + */ + public function testEachAudioTrackBeyondTheFirstGetsItsOwnAdaptationSet(): void + { + $dash = new Dash(); + + // One video rung, so audio output streams start at 1. + $this->assertSame( + 'id=0,streams=v id=1,streams=1 id=2,streams=2 id=3,streams=3', + $dash->adaptations(1, 3), + ); + + // Three rungs push the audio along to streams 3 and 4. + $this->assertSame( + 'id=0,streams=v id=1,streams=3 id=2,streams=4', + $dash->adaptations(3, 2), + ); + } + + public function testAudioOnlyOutputHasNoVideoAdaptationSet(): void + { + $this->assertSame('id=0,streams=a', (new Dash())->adaptations(0, 1)); + } + + /** + * Adapters read configuration but must never write back into it, or the + * same object could not be reused for a second job. + */ + public function testConfigurationIsReusable(): void + { + $output = (new Hls())->segment(4)->name('shared'); + + $this->assertSame(4.0, $output->duration()); + $this->assertSame('shared', $output->base()); + $this->assertSame(4.0, $output->duration()); + $this->assertSame('shared', $output->base()); + } + + public function testManifestsCanBeTurnedOff(): void + { + $this->assertFalse((new Hls())->manifests(false)->keeps()); + } + + /** + * The base name is joined onto the output directory to build every filename, + * so a name that walks back out of it is refused rather than followed. + * + * @testdox A base name that leaves the output directory is rejected + */ + public function testBaseNameCannotEscapeTheOutputDirectory(): void + { + foreach (['../escape', '/etc/passwd', 'sub/dir', '', 'with space', "null\0byte"] as $name) { + try { + (new Hls())->name($name); + $this->fail('expected "'.$name.'" to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as a name', $exception->getMessage()); + } + } + } + + public function testManifestAndPlaylistFilenamesHaveToBePlainNames(): void + { + foreach ([Hls::class, Cmaf::class] as $class) { + try { + /** @var Hls|Cmaf $output */ + $output = new $class(); + $output->master('../master.m3u8'); + $this->fail('expected a traversing master name to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as a filename', $exception->getMessage()); + } + } + + $this->expectException(Input::class); + + (new Dash())->manifest('/tmp/manifest.mpd'); + } + + public function testAnInitSegmentNameStillHasToBeAFilename(): void + { + $this->assertSame('start.mp4', (new Hls())->init('start.mp4')->initFile()); + + $this->expectException(Input::class); + + (new Hls())->init('../init.mp4'); + } + + public function testDashSegmentPatternsCannotLeaveTheOutputDirectory(): void + { + foreach ( + [ + '../chunk_$Number$.m4s', + 'nested/chunk_$Number$.m4s', + 'nested\\chunk_$Number$.m4s', + '/tmp/chunk_$Number$.m4s', + 'chunk_$Unknown$.m4s', + ] as $pattern + ) { + try { + (new Dash())->media($pattern); + $this->fail('expected "'.$pattern.'" to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as a DASH filename template', $exception->getMessage()); + } + } + + $output = (new Dash()) + ->init('init_$RepresentationID$.$ext$') + ->media('chunk_$RepresentationID$_$Number%05d$.$ext$'); + + $this->assertSame('init_$RepresentationID$.$ext$', $output->initPattern()); + $this->assertSame('chunk_$RepresentationID$_$Number%05d$.$ext$', $output->mediaPattern()); + } + + /** + * The prefix is written in front of every segment reference in a playlist, + * where a line break or a space would end the URI, and it is handed to the + * muxer as one argument, where a leading dash would pass for an option. + */ + public function testASegmentUrlPrefixHasToBeUsableInAPlaylist(): void + { + foreach (['has space', "two\nlines", '-hls_flags', "null\0byte"] as $prefix) { + try { + (new Hls())->url($prefix); + $this->fail('expected "'.$prefix.'" to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as a URL prefix', $exception->getMessage()); + } + } + + $this->assertSame('https://cdn.example.com/v/', (new Hls())->url('https://cdn.example.com/v/')->prefix()); + $this->assertSame('', (new Hls())->url('')->prefix()); + } + + /** + * Flags are muxer vocabulary, so anything that is not one word of it would + * arrive as a stray argument rather than as the flag it was meant to be. + */ + public function testHlsFlagsHaveToBeSingleWords(): void + { + foreach ([['independent_segments', 'two words'], ['-flag'], [''], ["null\0byte"]] as $flags) { + try { + (new Hls())->flags($flags); + $this->fail('expected '.\json_encode($flags).' to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('HLS flag', $exception->getMessage()); + $this->assertStringContainsString('not usable as a single word', $exception->getMessage()); + } + } + + $this->assertSame( + ['independent_segments', 'delete_segments'], + (new Hls())->flags(['independent_segments', 'delete_segments'])->hlsFlags(), + ); + } + + /** + * An adaptation set definition carries the option's own syntax, so spaces + * and separators are part of it. What it may not do is pass for an option + * itself, which is the one way a value breaks the argument list it sits in. + */ + public function testAnAdaptationSetDefinitionCannotPassForAnOption(): void + { + foreach (['-adaptation_sets', '', "id=0,streams=v\nid=1", "null\0byte"] as $definition) { + try { + (new Dash())->sets($definition); + $this->fail('expected "'.$definition.'" to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as an argument', $exception->getMessage()); + } + } + + $this->assertSame( + 'id=0,streams=v id=1,streams=a', + (new Dash())->sets('id=0,streams=v id=1,streams=a')->adaptations(), + ); + } + + /** + * A container the muxer does not know is a typo, and a typo found after an + * hour of encoding is a typo found too late. + */ + public function testAnUnknownHlsSegmentContainerIsRejected(): void + { + $this->expectException(Unsupported::class); + + (new Hls())->segments('mp4'); + } + + public function testBothHlsSegmentContainersAreAccepted(): void + { + $this->assertSame('ts', (new Hls())->segments(Hls::MPEGTS)->extension()); + $this->assertSame('m4s', (new Hls())->segments(Hls::FMP4)->extension()); + } + + public function testThumbDefaults(): void + { + $thumb = new Thumb(); + + $this->assertNull($thumb->at()); + $this->assertSame(320, $thumb->size()); + $this->assertSame(2, $thumb->scale()); + } + + public function testThumbIsConfigurable(): void + { + $thumb = (new Thumb())->time(12.5)->width(640)->quality(4); + + $this->assertSame(12.5, $thumb->at()); + $this->assertSame(640, $thumb->size()); + $this->assertSame(4, $thumb->scale()); + } +} diff --git a/tests/Video/Unit/PackageTest.php b/tests/Video/Unit/PackageTest.php new file mode 100644 index 0000000..b80ddb3 --- /dev/null +++ b/tests/Video/Unit/PackageTest.php @@ -0,0 +1,129 @@ + 'static'], + duration: 10.0, + ); + } + + public function testFlattensSegmentsAcrossVariants(): void + { + $this->assertCount(4, $this->package()->segments()); + } + + public function testFiltersSegmentsByVariant(): void + { + $segments = $this->package()->segments('1'); + + $this->assertCount(1, $segments); + $this->assertSame('a_0.m4s', $segments[0]->file); + } + + public function testListsEveryArtifactForUpload(): void + { + $files = $this->package()->files(); + + $this->assertContains('/out/manifest.mpd', $files); + $this->assertContains('/out/master.m3u8', $files); + $this->assertContains('/out/v_init.mp4', $files); + $this->assertContains('/out/a_0.m4s', $files); + $this->assertCount(7, $files); + } + + public function testFindsAVariantById(): void + { + $this->assertSame(Track::AUDIO, $this->package()->variant('1')?->type); + $this->assertNull($this->package()->variant('nope')); + } + + public function testAddsUpSegmentSizes(): void + { + $this->assertSame(2700, $this->package()->size()); + } + + public function testCarriesManifestMetadata(): void + { + $package = $this->package(); + + $this->assertSame('static', $package->metadata()['type']); + $this->assertSame(10.0, $package->duration()); + } + + public function testKnowsWhichManifestIsTheEntryPoint(): void + { + $manifests = $this->package()->manifests(); + + $this->assertTrue($manifests[0]->main); + $this->assertFalse($manifests[2]->main); + $this->assertSame('manifest.mpd', $manifests[0]->file()); + } + + /** + * Structured data is the point of the result object; a consumer that builds + * its own manifests should still get everything it needs. + */ + public function testSegmentsSurviveWithoutManifests(): void + { + $package = new Package(variants: $this->package()->variants()); + + $this->assertSame([], $package->manifests()); + $this->assertCount(4, $package->segments()); + $this->assertCount(4, $package->files()); + } + + /** + * @testdox An empty package reports nothing rather than failing + */ + public function testEmptyPackage(): void + { + $package = new Package(); + + $this->assertSame([], $package->variants()); + $this->assertSame([], $package->segments()); + $this->assertSame([], $package->files()); + $this->assertSame(0, $package->size()); + } +} diff --git a/tests/Video/Unit/PackagerTest.php b/tests/Video/Unit/PackagerTest.php new file mode 100644 index 0000000..19bde3e --- /dev/null +++ b/tests/Video/Unit/PackagerTest.php @@ -0,0 +1,336 @@ +dir = $dir; + $this->file = $dir.'/source.mp4'; + \file_put_contents($this->file, 'source'); + } + + protected function tearDown(): void + { + $this->clean($this->dir); + } + + private function clean(string $dir): void + { + foreach (\glob($dir.'/*') ?: [] as $path) { + \is_dir($path) ? $this->clean($path) : \unlink($path); + } + + @\rmdir($dir); + } + + private function packager( + ?PackagerAdapter $adapter = null, + ?EncoderAdapter $encoder = null, + ): Packager { + return new Packager( + adapter: $adapter ?? new FakePackager(), + encoder: $encoder ?? new FakeEncoder(), + probe: new FakeProbe(), + ); + } + + public function testDelegatesProbing(): void + { + $info = $this->packager()->probe($this->file); + + $this->assertSame(10.0, $info->duration); + $this->assertTrue($this->packager()->valid($this->file)); + } + + public function testReportsWhichBackendIsWorking(): void + { + $this->assertSame('fake', $this->packager()->getName()); + } + + /** + * A packager that can also encode gets the source directly, so the whole + * job is one pass instead of writing an intermediate file first. + */ + public function testPackagerThatEncodesReceivesTheSourceItself(): void + { + $encoder = new FakeEncoder(); + $adapter = new FusedPackager(); + $output = new Cmaf(); + $rep = new Representation(1280, 720, 2538); + + $this->packager($adapter, $encoder) + ->open($this->file) + ->format(new X264()) + ->add($rep) + ->output($output) + ->pack($this->dir.'/out'); + + $this->assertSame([$this->file], $adapter->opened); + $this->assertSame($output, $adapter->output); + $this->assertSame([$rep], $adapter->reps); + $this->assertNull($encoder->opened, 'the source should not be encoded separately'); + } + + /** + * A packager that cannot encode is handed one finished file per rung. + * + * This is the seam an alternative packaging backend plugs into: implement + * Adapter\Packager and nothing in the facade has to change. + */ + public function testPackagerThatCannotEncodeReceivesFinishedFiles(): void + { + $encoder = new FakeEncoder(); + $adapter = new FakePackager(); + + $this->packager($adapter, $encoder) + ->open($this->file) + ->format(new X264()) + ->add( + new Representation(640, 360, 800), + new Representation(1280, 720, 2538), + ) + ->output(new Cmaf()) + ->pack($this->dir.'/out'); + + $this->assertSame(2, $encoder->encoded); + $this->assertCount(2, $adapter->opened); + $this->assertStringEndsWith('360p.mp4', $adapter->opened[0]); + $this->assertStringEndsWith('720p.mp4', $adapter->opened[1]); + $this->assertSame('360p', $adapter->tagged[0]?->name); + } + + public function testStagedIntermediatesAreCleanedUp(): void + { + $this->packager() + ->open($this->file) + ->add(new Representation(640, 360, 800)) + ->output(new Cmaf()) + ->pack($this->dir.'/out'); + + $this->assertDirectoryDoesNotExist($this->dir.'/out/.staging'); + } + + public function testStagedEncodingUsesTheSegmentLengthAsItsKeyframeCadence(): void + { + $encoder = new FakeEncoder(); + + $this->packager(encoder: $encoder) + ->open($this->file) + ->format(new X264()) + ->add(new Representation(640, 360, 800)) + ->output((new Cmaf())->segment(4)) + ->pack($this->dir.'/out'); + + $this->assertNotNull($encoder->format); + $this->assertSame(4.0, $encoder->format->interval()); + } + + public function testAnExplicitStagedKeyframeCadenceStillWins(): void + { + $encoder = new FakeEncoder(); + + $this->packager(encoder: $encoder) + ->open($this->file) + ->format((new X264())->keyframe(2)) + ->add(new Representation(640, 360, 800)) + ->output((new Cmaf())->segment(6)) + ->pack($this->dir.'/out'); + + $this->assertNotNull($encoder->format); + $this->assertSame(2.0, $encoder->format->interval()); + } + + public function testAnImpossibleStagedKeyframeCadenceIsRejectedBeforeEncoding(): void + { + $encoder = new FakeEncoder(); + + try { + $this->packager(encoder: $encoder) + ->open($this->file) + ->format((new X264())->keyframe(10)) + ->add(new Representation(640, 360, 800)) + ->output((new Cmaf())->segment(4)) + ->pack($this->dir.'/out'); + $this->fail('Expected the keyframe cadence to be rejected'); + } catch (Unsupported $exception) { + $this->assertStringContainsString('A keyframe every 10s cannot cut 4s segments', $exception->getMessage()); + } + + $this->assertSame(0, $encoder->encoded); + } + + public function testStreamCopyIsRejectedBeforeStagedEncodingStarts(): void + { + $encoder = new FakeEncoder(); + + try { + $this->packager(encoder: $encoder) + ->open($this->file) + ->format(new Copy()) + ->add(new Representation(640, 360, 800)) + ->output(new Cmaf()) + ->pack($this->dir.'/out'); + $this->fail('Expected stream copy to be rejected'); + } catch (Unsupported $exception) { + $this->assertStringContainsString('Stream copy cannot build an adaptive package', $exception->getMessage()); + } + + $this->assertSame(0, $encoder->encoded); + } + + public function testDuplicateNamesAreRejectedBeforeStagedEncodingStarts(): void + { + $encoder = new FakeEncoder(); + + try { + $this->packager(encoder: $encoder) + ->open($this->file) + ->add( + new Representation(640, 360, 800), + new Representation(480, 360, 500), + ) + ->output(new Cmaf()) + ->pack($this->dir.'/out'); + $this->fail('Expected duplicate names to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('Representation name "360p" is used more than once', $exception->getMessage()); + } + + $this->assertSame(0, $encoder->encoded); + } + + public function testStagedProgressClimbsOnceAcrossEveryRung(): void + { + $seen = []; + + $this->packager() + ->open($this->file) + ->format(new X264()) + ->add( + new Representation(640, 360, 800), + new Representation(1280, 720, 2538), + ) + ->output(new Cmaf()) + ->on(Packager::PROGRESS, function (Progress $progress) use (&$seen): void { + $seen[] = $progress->percent; + }) + ->pack($this->dir.'/out'); + + $this->assertNotEmpty($seen); + $this->assertSame(100.0, \end($seen)); + + $sorted = $seen; + \sort($sorted); + $this->assertSame($sorted, $seen, 'progress should never go backwards'); + } + + public function testListenersReachTheAdapterDoingTheWork(): void + { + $adapter = new FusedPackager(); + $lines = []; + + $this->packager($adapter) + ->open($this->file) + ->add(new Representation(1280, 720, 2538)) + ->output(new Cmaf()) + ->on(Packager::LOG, function (string $line) use (&$lines): void { + $lines[] = $line; + }) + ->pack($this->dir.'/out'); + + $this->assertSame(['packing'], $lines); + } + + public function testStagedLogsAreForwardedToo(): void + { + $lines = []; + + $this->packager() + ->open($this->file) + ->add(new Representation(640, 360, 800)) + ->output(new Cmaf()) + ->on(Packager::LOG, function (string $line) use (&$lines): void { + $lines[] = $line; + }) + ->pack($this->dir.'/out'); + + $this->assertSame(['packing'], $lines); + } + + public function testOpeningAgainForgetsThePreviousJob(): void + { + $packager = $this->packager() + ->open($this->file) + ->add(new Representation(1280, 720, 2538)) + ->output(new Cmaf()); + + $packager->open($this->file); + + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('No output format'); + + $packager->pack('/out'); + } + + public function testPackingWithoutASourceIsAFailure(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('No source has been opened'); + + $this->packager()->pack('/out'); + } + + public function testPackingWithoutALadderIsAFailure(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('At least one representation'); + + $this->packager()->open($this->file)->output(new Cmaf())->pack('/out'); + } + + public function testOpeningAMissingSourceIsAFailure(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('does not exist'); + + $this->packager()->open('/nowhere/nothing.mp4'); + } + + /** + * A packager that also encodes needs no separate encoder handed to it. + */ + public function testAFusedAdapterIsItsOwnEncoder(): void + { + $adapter = new FusedPackager(); + + (new Packager($adapter, probe: new FakeProbe())) + ->open($this->file) + ->add(new Representation(1280, 720, 2538)) + ->output(new Cmaf()) + ->pack($this->dir.'/out'); + + $this->assertSame([$this->file], $adapter->opened); + } +} diff --git a/tests/Video/Unit/ParserM3u8Test.php b/tests/Video/Unit/ParserM3u8Test.php new file mode 100644 index 0000000..3987697 --- /dev/null +++ b/tests/Video/Unit/ParserM3u8Test.php @@ -0,0 +1,203 @@ +dir = $dir; + } + + protected function tearDown(): void + { + foreach (\glob($this->dir.'/*') ?: [] as $file) { + \unlink($file); + } + + @\rmdir($this->dir); + } + + private function write(string $name, string $body): string + { + $path = $this->dir.'/'.$name; + \file_put_contents($path, $body); + + return $path; + } + + public function testReadsAttributesWithCommasInsideQuotes(): void + { + $attributes = M3u8::attributes( + '#EXT-X-STREAM-INF:BANDWIDTH=2700000,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=1280x720', + ); + + $this->assertSame('2700000', $attributes['BANDWIDTH']); + $this->assertSame('avc1.64001f,mp4a.40.2', $attributes['CODECS']); + $this->assertSame('1280x720', $attributes['RESOLUTION']); + } + + public function testReadsVariantsFromAMaster(): void + { + $master = $this->write('master.m3u8', <<<'PLAYLIST' + #EXTM3U + #EXT-X-VERSION:7 + #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="eng",DEFAULT=YES,URI="audio.m3u8" + #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001f",AUDIO="audio" + v360.m3u8 + #EXT-X-STREAM-INF:BANDWIDTH=2538000,RESOLUTION=1280x720,CODECS="avc1.64001f",AUDIO="audio" + v720.m3u8 + PLAYLIST); + + $variants = M3u8::master($master); + + $this->assertCount(3, $variants); + $this->assertSame(Track::AUDIO, $variants[0]['type']); + $this->assertSame('eng', $variants[0]['language']); + $this->assertSame(Track::VIDEO, $variants[1]['type']); + $this->assertSame(640, $variants[1]['width']); + $this->assertSame(360, $variants[1]['height']); + $this->assertSame(2538000, $variants[2]['bandwidth']); + $this->assertSame('v720.m3u8', $variants[2]['file']); + } + + public function testReadsSegmentsFromATransportStreamPlaylist(): void + { + $playlist = $this->write('v720.m3u8', <<<'PLAYLIST' + #EXTM3U + #EXT-X-VERSION:3 + #EXT-X-TARGETDURATION:6 + #EXTINF:6.000000, + v720_0000.ts + #EXTINF:4.500000, + v720_0001.ts + #EXT-X-ENDLIST + PLAYLIST); + + $media = M3u8::media($playlist); + + $this->assertSame(6.0, $media['target']); + $this->assertSame(3, $media['version']); + $this->assertCount(2, $media['segments']); + $this->assertSame('v720_0000.ts', $media['segments'][0]['file']); + $this->assertSame(6.0, $media['segments'][0]['duration']); + $this->assertFalse($media['segments'][0]['init']); + $this->assertSame(4.5, $media['segments'][1]['duration']); + } + + public function testInitialisationSegmentIsRecognised(): void + { + $playlist = $this->write('v720.m3u8', <<<'PLAYLIST' + #EXTM3U + #EXT-X-VERSION:7 + #EXT-X-TARGETDURATION:6 + #EXT-X-MAP:URI="v720_init.mp4" + #EXTINF:6.000000, + v720_0000.m4s + PLAYLIST); + + $media = M3u8::media($playlist); + + $this->assertTrue($media['segments'][0]['init']); + $this->assertSame('v720_init.mp4', $media['segments'][0]['file']); + $this->assertSame(0.0, $media['segments'][0]['duration']); + $this->assertFalse($media['segments'][1]['init']); + } + + public function testReadsAWholePackage(): void + { + $this->write('v720_init.mp4', 'init'); + $this->write('v720_0000.m4s', 'aaaa'); + $this->write('v720_0001.m4s', 'bb'); + + $this->write('v720.m3u8', <<<'PLAYLIST' + #EXTM3U + #EXT-X-VERSION:7 + #EXT-X-TARGETDURATION:6 + #EXT-X-MAP:URI="v720_init.mp4" + #EXTINF:6.000000, + v720_0000.m4s + #EXTINF:2.000000, + v720_0001.m4s + PLAYLIST); + + $master = $this->write('master.m3u8', <<<'PLAYLIST' + #EXTM3U + #EXT-X-STREAM-INF:BANDWIDTH=2538000,RESOLUTION=1280x720 + v720.m3u8 + PLAYLIST); + + $read = M3u8::read($master, $this->dir); + + $this->assertCount(1, $read['variants']); + $this->assertSame(6.0, $read['metadata']['targetDuration']); + + $variant = $read['variants'][0]; + $this->assertSame(1280, $variant->width); + $this->assertSame('1280x720', $variant->resolution()); + $this->assertCount(3, $variant->segments); + + $this->assertTrue($variant->segments[0]->init); + $this->assertSame(4, $variant->segments[0]->size); + $this->assertSame(0, $variant->segments[1]->number); + $this->assertSame(1, $variant->segments[2]->number); + $this->assertSame(2, $variant->segments[2]->size); + } + + /** + * A playlist that names a file which is not there means the run was cut + * short, and reporting success on a partial package would be worse than + * failing. + */ + public function testMissingSegmentIsAFailure(): void + { + $this->write('v720.m3u8', "#EXTM3U\n#EXTINF:6.000000,\nmissing.ts\n"); + $master = $this->write('master.m3u8', "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nv720.m3u8\n"); + + $this->expectException(Runtime::class); + $this->expectExceptionMessage('missing.ts'); + + M3u8::read($master, $this->dir); + } + + public function testMissingPlaylistIsAFailure(): void + { + $master = $this->write('master.m3u8', "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\ngone.m3u8\n"); + + $this->expectException(Runtime::class); + $this->expectExceptionMessage('gone.m3u8'); + + M3u8::read($master, $this->dir); + } + + public function testUnreadablePlaylistIsAFailure(): void + { + $this->expectException(Runtime::class); + + M3u8::media($this->dir.'/nothing.m3u8'); + } + + public function testWindowsLineEndingsAreHandled(): void + { + $playlist = $this->write( + 'v.m3u8', + "#EXTM3U\r\n#EXT-X-TARGETDURATION:6\r\n#EXTINF:6.000000,\r\nv_0000.ts\r\n", + ); + + $media = M3u8::media($playlist); + + $this->assertSame(6.0, $media['target']); + $this->assertSame('v_0000.ts', $media['segments'][0]['file']); + } +} diff --git a/tests/Video/Unit/ParserMpdTest.php b/tests/Video/Unit/ParserMpdTest.php new file mode 100644 index 0000000..a72f5a4 --- /dev/null +++ b/tests/Video/Unit/ParserMpdTest.php @@ -0,0 +1,520 @@ +dir = $dir; + } + + protected function tearDown(): void + { + foreach (\glob($this->dir.'/*') ?: [] as $file) { + \unlink($file); + } + + @\rmdir($this->dir); + } + + private function write(string $name, string $body): string + { + $path = $this->dir.'/'.$name; + \file_put_contents($path, $body); + + return $path; + } + + /** + * @testdox Reads ISO 8601 durations + */ + public function testReadsIsoDurations(): void + { + $this->assertSame(0.0, Mpd::seconds('')); + $this->assertSame(64.5, Mpd::seconds('PT1M4.5S')); + $this->assertSame(3725.0, Mpd::seconds('PT1H2M5.0S')); + $this->assertSame(6.0, Mpd::seconds('PT6S')); + + // The date half is legal too, and means the same thing. + $this->assertSame(64.5, Mpd::seconds('P0Y0M0DT0H1M4.50S')); + $this->assertSame(93784.0, Mpd::seconds('P1DT2H3M4S')); + $this->assertSame(604800.0, Mpd::seconds('P1W')); + + // Years and months have no fixed length, so they are refused, not guessed. + $this->assertSame(0.0, Mpd::seconds('P1Y')); + $this->assertSame(0.0, Mpd::seconds('P1M')); + + $this->assertSame(0.0, Mpd::seconds('P')); + $this->assertSame(0.0, Mpd::seconds('nonsense')); + } + + public function testExpandsSegmentNamePatterns(): void + { + $this->assertSame( + 'chunk-stream0-00007.m4s', + Mpd::expand('chunk-stream$RepresentationID$-$Number%05d$.m4s', '0', 7), + ); + + $this->assertSame( + 'seg-3.m4s', + Mpd::expand('seg-$Number$.m4s', '0', 3), + ); + + $this->assertSame( + 'init-stream1.m4s', + Mpd::expand('init-stream$RepresentationID$.m4s', '1', 0), + ); + } + + /** + * A manifest is free to address its segments by bitrate instead of by id, + * and a reader that dropped the identifier would look for the wrong file. + */ + public function testExpandsTheBandwidthIdentifier(): void + { + $this->assertSame( + 'chunk-2538000-4.m4s', + Mpd::expand('chunk-$Bandwidth$-$Number$.m4s', '0', 4, 2538000), + ); + + $this->assertSame( + 'seg-0002538000.m4s', + Mpd::expand('seg-$Bandwidth%010d$.m4s', '0', 1, 2538000), + ); + } + + /** + * @testdox A literal dollar sign survives expansion + */ + public function testLiteralDollarSignSurvives(): void + { + $this->assertSame('odd$name-3.m4s', Mpd::expand('odd$$name-$Number$.m4s', '0', 3, 800)); + } + + public function testReadsATemplatedManifestAddressedByBandwidth(): void + { + $this->write('init-800000.m4s', 'init'); + $this->write('chunk-800000-00001.m4s', 'a'); + $this->write('chunk-800000-00002.m4s', 'b'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + XML); + + $variant = Mpd::read($manifest, $this->dir)['variants'][0]; + + $this->assertSame(800000, $variant->bandwidth); + $this->assertCount(3, $variant->segments); + $this->assertSame('init-800000.m4s', $variant->segments[0]->file); + $this->assertSame('chunk-800000-00001.m4s', $variant->segments[1]->file); + $this->assertSame('chunk-800000-00002.m4s', $variant->segments[2]->file); + } + + /** + * maxSegmentDuration is an ISO 8601 duration, and it is declared once on the + * MPD root. Read off an AdaptationSet, or cast with (float), it is silently 0. + */ + public function testAVariantCarriesTheSegmentLengthTheManifestDeclares(): void + { + $this->write('init-0.m4s', 'init'); + $this->write('chunk-0-1.m4s', 'aaaa'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + XML); + + $read = Mpd::read($manifest, $this->dir); + + $this->assertSame(6.0, $read['variants'][0]->target); + } + + /** + * With no maximum declared, the longest segment is what a target means. + */ + public function testAVariantFallsBackToItsLongestSegment(): void + { + $this->write('chunk-0-1.m4s', 'aaaa'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + XML); + + $read = Mpd::read($manifest, $this->dir); + + $this->assertSame(4.0, $read['variants'][0]->target); + } + + /** + * A descendant search run from an AdaptationSet walks into its Representations, + * so a rung that declares its own addressing must not be handed a sibling's. + */ + public function testARungKeepsItsOwnSegmentsWhenASiblingAddressesThemDifferently(): void + { + foreach (['a_init.m4s', 'a_1.m4s', 'b_init.m4s', 'b_1.m4s'] as $file) { + $this->write($file, 'x'); + } + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + + + + XML); + + $read = Mpd::read($manifest, $this->dir); + + $this->assertCount(2, $read['variants']); + $this->assertSame( + ['a_init.m4s', 'a_1.m4s'], + \array_map(static fn ($segment) => $segment->file, $read['variants'][0]->segments), + ); + $this->assertSame( + ['b_init.m4s', 'b_1.m4s'], + \array_map(static fn ($segment) => $segment->file, $read['variants'][1]->segments), + ); + } + + public function testReadsAListedManifest(): void + { + $this->write('init-0.m4s', 'init'); + $this->write('chunk-0-1.m4s', 'aaaa'); + $this->write('chunk-0-2.m4s', 'bb'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + + XML); + + $read = Mpd::read($manifest, $this->dir); + + $this->assertSame('static', $read['metadata']['type']); + $this->assertSame(8.0, $read['metadata']['duration']); + $this->assertSame('PT6.0S', $read['metadata']['maxSegmentDuration']); + + $this->assertCount(1, $read['variants']); + $variant = $read['variants'][0]; + + $this->assertSame('0', $variant->id); + $this->assertSame(Track::VIDEO, $variant->type); + $this->assertSame('avc1.64001f', $variant->codecs); + $this->assertSame(2538000, $variant->bandwidth); + $this->assertSame(1280, $variant->width); + $this->assertSame('1:1', $variant->sar); + $this->assertSame(1000000, $variant->timescale); + $this->assertSame(1, $variant->startNumber); + + $this->assertCount(3, $variant->segments); + $this->assertTrue($variant->segments[0]->init); + $this->assertSame(6.0, $variant->segments[1]->duration); + $this->assertSame(1, $variant->segments[1]->number); + $this->assertSame(2, $variant->segments[2]->number); + } + + /** + * A manifest that only describes a formula still has to come back as a + * concrete list of segments; expanding it here means callers never have to + * care which addressing mode was used. + */ + public function testExpandsATemplatedManifestWithATimeline(): void + { + $this->write('init-stream0.m4s', 'init'); + $this->write('chunk-stream0-00001.m4s', 'a'); + $this->write('chunk-stream0-00002.m4s', 'b'); + $this->write('chunk-stream0-00003.m4s', 'c'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + + + XML); + + $read = Mpd::read($manifest, $this->dir); + $variant = $read['variants'][0]; + + $this->assertCount(4, $variant->segments); + $this->assertTrue($variant->segments[0]->init); + $this->assertSame('chunk-stream0-00001.m4s', $variant->segments[1]->file); + $this->assertSame(6.0, $variant->segments[1]->duration); + $this->assertSame('chunk-stream0-00002.m4s', $variant->segments[2]->file); + $this->assertSame('chunk-stream0-00003.m4s', $variant->segments[3]->file); + $this->assertSame(2.0, $variant->segments[3]->duration); + } + + public function testExpandsATemplatedManifestWithoutATimeline(): void + { + $this->write('init-stream0.m4s', 'init'); + $this->write('chunk-stream0-00001.m4s', 'a'); + $this->write('chunk-stream0-00002.m4s', 'b'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + XML); + + $variant = Mpd::read($manifest, $this->dir)['variants'][0]; + + $this->assertCount(3, $variant->segments); + $this->assertSame('chunk-stream0-00002.m4s', $variant->segments[2]->file); + } + + /** + * $Time$ is the one identifier only a timeline can resolve: a segment is + * named for the media time it starts at, which is the running total of the + * durations declared before it rather than anything on the segment itself. + */ + public function testExpandsATemplateNamedByTime(): void + { + $this->write('init-stream0.m4s', 'init'); + $this->write('chunk-stream0-0.m4s', 'a'); + $this->write('chunk-stream0-6000.m4s', 'b'); + $this->write('chunk-stream0-12000.m4s', 'c'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + + XML); + + $variant = Mpd::read($manifest, $this->dir)['variants'][0]; + + $this->assertCount(4, $variant->segments); + $this->assertSame('chunk-stream0-0.m4s', $variant->segments[1]->file); + $this->assertSame('chunk-stream0-6000.m4s', $variant->segments[2]->file); + $this->assertSame('chunk-stream0-12000.m4s', $variant->segments[3]->file); + } + + /** + * A timeline may restart its clock, and a segment named for its start time + * has to follow the declaration rather than counting on from the last run. + */ + public function testATimelineRestartingItsClockIsFollowed(): void + { + $this->write('chunk-0-0.m4s', 'a'); + $this->write('chunk-0-9000.m4s', 'b'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + + + XML); + + $variant = Mpd::read($manifest, $this->dir)['variants'][0]; + + $this->assertCount(2, $variant->segments); + $this->assertSame('chunk-0-0.m4s', $variant->segments[0]->file); + $this->assertSame('chunk-0-9000.m4s', $variant->segments[1]->file); + } + + public function testAudioAdaptationSetIsRecognised(): void + { + $this->write('init-1.m4s', 'init'); + $this->write('chunk-1-1.m4s', 'a'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + XML); + + $variant = Mpd::read($manifest, $this->dir)['variants'][0]; + + $this->assertSame(Track::AUDIO, $variant->type); + $this->assertSame('eng', $variant->language); + $this->assertSame(48000, $variant->sampleRate); + $this->assertSame('audio/mp4', $variant->mimeType); + $this->assertNull($variant->resolution()); + } + + public function testARepresentationWithoutMediaSegmentsIsAFailure(): void + { + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + XML); + + $this->expectException(Runtime::class); + $this->expectExceptionMessage('Representation "empty" contains no media segments'); + + Mpd::read($manifest, $this->dir); + } + + public function testMissingSegmentIsAFailure(): void + { + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + XML); + + $this->expectException(Runtime::class); + $this->expectExceptionMessage('gone.m4s'); + + Mpd::read($manifest, $this->dir); + } + + public function testUnreadableManifestIsAFailure(): void + { + $this->expectException(Runtime::class); + + Mpd::read($this->dir.'/nothing.mpd', $this->dir); + } + + public function testMalformedManifestIsAFailure(): void + { + $manifest = $this->write('manifest.mpd', ''); + + $this->expectException(Runtime::class); + + Mpd::read($manifest, $this->dir); + } +} diff --git a/tests/Video/Unit/ProbeFFprobeTest.php b/tests/Video/Unit/ProbeFFprobeTest.php new file mode 100644 index 0000000..81dd3bd --- /dev/null +++ b/tests/Video/Unit/ProbeFFprobeTest.php @@ -0,0 +1,269 @@ + $payload + */ + private function read(array $payload): Info + { + $probe = new class () extends FFprobe { + /** + * @param array $data + */ + public function parse(array $data): Info + { + return $this->info($data); + } + }; + + return $probe->parse($payload); + } + + public function testReadsAVideoWithSound(): void + { + $info = $this->read([ + 'format' => [ + 'format_name' => 'mov,mp4,m4a,3gp,3g2,mj2', + 'duration' => '64.5', + 'tags' => ['title' => 'A Film', 'creation_time' => '2026-01-02T03:04:05.000000Z'], + ], + 'streams' => [ + [ + 'index' => 0, + 'codec_type' => 'video', + 'codec_name' => 'h264', + 'codec_long_name' => 'H.264 / AVC', + 'profile' => 'High', + 'width' => 1920, + 'height' => 1080, + 'display_aspect_ratio' => '16:9', + 'avg_frame_rate' => '25/1', + 'r_frame_rate' => '25/1', + 'bit_rate' => '4500000', + 'disposition' => ['default' => 1], + 'tags' => ['language' => 'und'], + ], + [ + 'index' => 1, + 'codec_type' => 'audio', + 'codec_name' => 'aac', + 'codec_long_name' => 'AAC', + 'bit_rate' => '128000', + 'sample_rate' => '48000', + 'disposition' => ['default' => 1], + 'tags' => ['language' => 'eng'], + ], + ], + ]); + + $this->assertSame(64.5, $info->duration); + $this->assertSame(64500, $info->milliseconds()); + $this->assertTrue($info->hasVideo); + $this->assertTrue($info->hasAudio); + $this->assertSame(1920, $info->width); + $this->assertSame(1080, $info->height); + $this->assertSame('16:9', $info->aspect); + $this->assertSame(25.0, $info->fps); + $this->assertSame('Constant', $info->fpsMode); + $this->assertSame('h264', $info->videoCodec); + $this->assertSame('High', $info->videoProfile); + $this->assertSame(4500000, $info->videoBitrate); + $this->assertSame('aac', $info->audioCodec); + $this->assertSame(128000, $info->audioBitrate); + $this->assertSame(48000, $info->sampleRate); + $this->assertSame([['codec' => 'aac', 'language' => 'eng']], $info->audioTracks); + $this->assertSame('A Film', $info->tags['title']); + $this->assertCount(2, $info->tracks); + } + + public function testDetectsVariableFrameRate(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [[ + 'codec_type' => 'video', + 'avg_frame_rate' => '24000/1001', + 'r_frame_rate' => '30000/1001', + ]], + ]); + + $this->assertSame('Variable', $info->fpsMode); + $this->assertSame(23.976, $info->fps); + } + + public function testDerivesAspectRatioWhenTheContainerOmitsIt(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [['codec_type' => 'video', 'width' => 1920, 'height' => 1080]], + ]); + + $this->assertNull($info->aspect); + $this->assertSame('16:9', $info->ratio()); + } + + public function testReadsEveryTrackIncludingSubtitles(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [ + ['index' => 0, 'codec_type' => 'video', 'codec_name' => 'h264'], + [ + 'index' => 1, + 'codec_type' => 'audio', + 'codec_name' => 'aac', + 'tags' => ['language' => 'eng'], + ], + [ + 'index' => 2, + 'codec_type' => 'subtitle', + 'codec_name' => 'mov_text', + 'tags' => ['language' => 'fra', 'title' => 'French'], + 'disposition' => ['default' => 0, 'forced' => 1], + ], + ], + ]); + + $subtitles = $info->tracks(Track::SUBTITLE); + + $this->assertCount(1, $subtitles); + $this->assertSame('fra', $subtitles[0]->language); + $this->assertSame('French', $subtitles[0]->title); + $this->assertTrue($subtitles[0]->forced); + $this->assertFalse($subtitles[0]->default); + $this->assertCount(1, $info->tracks(Track::VIDEO)); + } + + public function testReadsChapters(): void + { + $info = $this->read([ + 'format' => ['duration' => '120'], + 'streams' => [['codec_type' => 'video']], + 'chapters' => [ + ['start_time' => '0.000000', 'end_time' => '60.000000', 'tags' => ['title' => 'Intro']], + ['start_time' => '60.000000', 'end_time' => '120.000000', 'tags' => ['title' => 'Body']], + ], + ]); + + $this->assertCount(2, $info->chapters); + $this->assertSame('Intro', $info->chapters[0]->title); + $this->assertSame(60.0, $info->chapters[0]->end); + $this->assertSame(60.0, $info->chapters[1]->start); + } + + public function testReadsRotation(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [[ + 'codec_type' => 'video', + 'side_data_list' => [['rotation' => -90]], + ]], + ]); + + $this->assertSame(-90, $info->rotation); + } + + public function testReadsRotationFromATag(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [['codec_type' => 'video', 'tags' => ['rotate' => '90']]], + ]); + + $this->assertSame(90, $info->rotation); + } + + /** + * Cover art is stored as a one frame video stream, and treating it as the + * main picture would make an audio file look like a video. + */ + public function testCoverArtIsNotMistakenForVideo(): void + { + $info = $this->read([ + 'format' => ['duration' => '180', 'format_name' => 'mp3'], + 'streams' => [ + ['index' => 0, 'codec_type' => 'audio', 'codec_name' => 'mp3', 'sample_rate' => '44100'], + [ + 'index' => 1, + 'codec_type' => 'video', + 'codec_name' => 'mjpeg', + 'disposition' => ['attached_pic' => 1], + ], + ], + ]); + + $this->assertFalse($info->hasVideo); + $this->assertTrue($info->hasAudio); + $this->assertNull($info->width); + } + + public function testFallsBackToStreamDurationWhenTheContainerHasNone(): void + { + $info = $this->read([ + 'format' => [], + 'streams' => [['codec_type' => 'video', 'duration' => '12.5']], + ]); + + $this->assertSame(12.5, $info->duration); + } + + public function testSilentSourceReportsNoAudio(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [['codec_type' => 'video', 'width' => 640, 'height' => 480]], + ]); + + $this->assertFalse($info->hasAudio); + $this->assertNull($info->audioCodec); + $this->assertSame([], $info->audioTracks); + } + + public function testUnknownValuesBecomeNull(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [[ + 'codec_type' => 'video', + 'bit_rate' => 'N/A', + 'profile' => '', + ]], + ]); + + $this->assertNull($info->videoBitrate); + $this->assertNull($info->videoProfile); + } + + public function testKeepsTheOriginalPayload(): void + { + $payload = ['format' => ['duration' => '10'], 'streams' => []]; + + $this->assertSame($payload, $this->read($payload)->raw); + } + + public function testRejectsAMissingFile(): void + { + $probe = new FFprobe(); + + $this->assertFalse($probe->valid('/nowhere/nothing.mp4')); + } + + /** + * @testdox The probe carries its name + */ + public function testIsNamed(): void + { + $this->assertSame('ffprobe', (new FFprobe())->getName()); + } +} diff --git a/tests/Video/Unit/ProcessTest.php b/tests/Video/Unit/ProcessTest.php new file mode 100644 index 0000000..df6d68b --- /dev/null +++ b/tests/Video/Unit/ProcessTest.php @@ -0,0 +1,241 @@ +assertSame("one\ntwo\n", $output); + } + + public function testStreamsOutputLineByLine(): void + { + $lines = []; + + Process::run(['printf', "a\nb\nc\n"], function (string $line) use (&$lines): void { + $lines[] = $line; + }); + + $this->assertSame(['a', 'b', 'c'], $lines); + } + + public function testEmitsATrailingLineWithoutANewline(): void + { + $lines = []; + + Process::run(['printf', 'no-newline'], function (string $line) use (&$lines): void { + $lines[] = $line; + }); + + $this->assertSame(['no-newline'], $lines); + } + + public function testSeparatesStandardErrorFromStandardOutput(): void + { + $out = []; + $err = []; + + Process::run( + ['sh', '-c', 'echo good; echo bad 1>&2'], + function (string $line) use (&$out): void { + $out[] = $line; + }, + function (string $line) use (&$err): void { + $err[] = $line; + }, + ); + + $this->assertSame(['good'], $out); + $this->assertSame(['bad'], $err); + } + + public function testCarriageReturnsSplitProgressBlocks(): void + { + $lines = []; + + Process::run(['printf', "one\rtwo\r"], function (string $line) use (&$lines): void { + $lines[] = $line; + }); + + $this->assertSame(['one', 'two'], $lines); + } + + public function testFailureCarriesTheCommandAndItsComplaint(): void + { + try { + Process::run(['sh', '-c', 'echo "went wrong" 1>&2; exit 3']); + $this->fail('Expected the command to fail'); + } catch (Runtime $exception) { + $this->assertSame(3, $exception->getCode()); + $this->assertStringContainsString('exit code 3', $exception->getMessage()); + $this->assertStringContainsString('went wrong', $exception->output()); + $this->assertSame('sh', $exception->command()[0]); + } + } + + public function testReturnsTheTailOfStandardError(): void + { + $errors = Process::run(['sh', '-c', 'echo "a note" 1>&2']); + + $this->assertSame('a note', $errors); + } + + public function testStopsARunawayCommand(): void + { + $started = \microtime(true); + + try { + Process::run(['sleep', '30'], timeout: 1); + $this->fail('Expected the command to time out'); + } catch (Runtime $exception) { + $this->assertStringContainsString('timed out', $exception->getMessage()); + $this->assertSame('sleep', $exception->command()[0]); + } + + $this->assertLessThan(10, \microtime(true) - $started); + } + + /** + * A command that has to be stopped is the one most in need of explaining, so + * whatever it complained about on the way out is kept rather than discarded + * along with its pipes. + */ + public function testATimedOutCommandKeepsItsComplaint(): void + { + try { + Process::run(['sh', '-c', 'echo "half way in" 1>&2; sleep 30'], timeout: 1); + $this->fail('Expected the command to time out'); + } catch (Runtime $exception) { + $this->assertStringContainsString('timed out', $exception->getMessage()); + $this->assertStringContainsString('half way in', $exception->output()); + } + } + + /** + * A command that had to be stopped is worth telling apart from one that + * failed on its own. proc_close() has nothing left to report once the child + * has been reaped, so the signal it died of is what the failure carries, by + * the shell's convention of 128 plus the signal number. + */ + public function testAStoppedCommandReportsHowItWasStopped(): void + { + try { + Process::run(['sleep', '30'], timeout: 1); + $this->fail('Expected the command to time out'); + } catch (Runtime $exception) { + $this->assertSame(143, $exception->getCode(), 'expected SIGTERM to be reported'); + } + } + + /** + * Asking is not the same as insisting. A command that will not take the + * hint is killed outright once its grace period is up, and says so. + */ + public function testACommandThatWillNotStopIsKilled(): void + { + try { + Process::run(['sh', '-c', 'trap "" TERM; sleep 30'], timeout: 1); + $this->fail('Expected the command to time out'); + } catch (Runtime $exception) { + $this->assertSame(137, $exception->getCode(), 'expected SIGKILL to be reported'); + } + } + + /** + * Collected output is held in memory, so a command that never stops talking + * has to be cut off rather than allowed to exhaust the process running it. + * The tail of stderr has always been bounded; this is the other half. + */ + public function testACollectorRefusesMoreThanItWasAllowed(): void + { + $output = ''; + + try { + Process::run(['sh', '-c', 'yes utopia'], Process::collector($output, 64)); + $this->fail('Expected the collector to give up'); + } catch (Runtime $exception) { + $this->assertStringContainsString('more than 64 bytes', $exception->getMessage()); + } + } + + /** + * Nothing obliges a stream to send line breaks, and a backend writing + * something binary never will, so the buffer is handed over once it has + * grown past a plausible line rather than held until the command ends. + */ + public function testAnEndlessLineIsNotBufferedForever(): void + { + $lines = []; + + Process::run( + ['sh', '-c', 'printf "%0200000d" 1'], + function (string $line) use (&$lines): void { + $lines[] = $line; + }, + ); + + $this->assertGreaterThan(1, \count($lines), 'one unbroken line should arrive in pieces'); + $this->assertSame(200000, \array_sum(\array_map('strlen', $lines))); + } + + public function testAThrowingListenerDoesNotLeaveTheChildRunning(): void + { + $marker = \sys_get_temp_dir().'/utopia-process-'.\bin2hex(\random_bytes(6)); + + try { + $failure = null; + + try { + Process::run( + [ + PHP_BINARY, + '-r', + 'echo "ready\n"; flush(); usleep(500000); file_put_contents($argv[1], "alive");', + $marker, + ], + static function (): void { + throw new \LogicException('listener stopped'); + }, + ); + } catch (\Throwable $exception) { + $failure = $exception; + } + + $this->assertInstanceOf(\LogicException::class, $failure); + $this->assertSame('listener stopped', $failure->getMessage()); + + \usleep(700000); + $this->assertFileDoesNotExist($marker, 'the child survived after its listener threw'); + } finally { + @\unlink($marker); + } + } + + public function testMissingBinaryIsAFailure(): void + { + $this->expectException(Runtime::class); + + Process::run(['utopia-streaming-does-not-exist']); + } + + public function testEmptyCommandIsAFailure(): void + { + $this->expectException(Runtime::class); + + Process::run([]); + } + + public function testReportsWhetherABinaryIsUsable(): void + { + $this->assertFalse(Process::exists('utopia-streaming-does-not-exist')); + } +} diff --git a/tests/Video/Unit/Recorder.php b/tests/Video/Unit/Recorder.php new file mode 100644 index 0000000..abe537c --- /dev/null +++ b/tests/Video/Unit/Recorder.php @@ -0,0 +1,32 @@ + */ + public array $successes = []; + + /** @var list */ + public array $errors = []; + + public function success(string $message): void + { + $this->successes[] = $message; + } + + public function error(string $message): void + { + $this->errors[] = $message; + } +} diff --git a/tests/Video/Unit/RepresentationTest.php b/tests/Video/Unit/RepresentationTest.php new file mode 100644 index 0000000..55b46d6 --- /dev/null +++ b/tests/Video/Unit/RepresentationTest.php @@ -0,0 +1,128 @@ +assertSame(1280, $rep->width); + $this->assertSame(720, $rep->height); + $this->assertSame(2538, $rep->video); + $this->assertSame(128, $rep->audio); + $this->assertSame('1280x720', $rep->resolution()); + $this->assertTrue($rep->scaled()); + } + + public function testNamesItselfAfterItsHeight(): void + { + $this->assertSame('720p', (new Representation(1280, 720, 2538))->name); + $this->assertSame('low', (new Representation(1280, 720, 2538, name: 'low'))->name); + } + + public function testCapsBitrateByDefault(): void + { + $rep = new Representation(1280, 720, 2500); + + $this->assertSame(2500, $rep->maxrate); + $this->assertSame(5000, $rep->bufsize); + } + + public function testAcceptsAnExplicitCap(): void + { + $rep = new Representation(1280, 720, 2500, maxrate: 3000, bufsize: 9000); + + $this->assertSame(3000, $rep->maxrate); + $this->assertSame(9000, $rep->bufsize); + } + + public function testAudioOnlyRungCarriesNoFrame(): void + { + $rep = new Representation(width: 0, height: 0, video: 1, audio: 96); + + $this->assertFalse($rep->scaled()); + $this->assertSame('audio', $rep->name); + } + + public function testRejectsOddDimensions(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('must be even'); + + new Representation(1281, 720, 2500); + } + + public function testRejectsNegativeDimensions(): void + { + $this->expectException(Input::class); + + new Representation(-2, 720, 2500); + } + + public function testRejectsUnusableBitrates(): void + { + $this->expectException(Input::class); + + new Representation(1280, 720, 0); + } + + public function testRejectsUnusableAudioBitrate(): void + { + $this->expectException(Input::class); + + new Representation(1280, 720, 2500, audio: 0); + } + + /** + * The name becomes a filename while a ladder is being staged, so a name that + * climbs out of the working directory would write wherever it pointed. + * + * @testdox A name that leaves the working directory is rejected + */ + public function testRejectsANameThatEscapesTheWorkingDirectory(): void + { + foreach (['../../outside', 'sub/dir', '/absolute', '..'] as $name) { + try { + new Representation(1280, 720, 2500, name: $name); + $this->fail('expected "'.$name.'" to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as a name', $exception->getMessage()); + } + } + } + + /** + * The muxer is told which rendition is which in a comma separated list of + * space separated entries, so a name containing either would be read as the + * end of the entry rather than part of the name. + */ + public function testRejectsANameThatWouldBreakTheStreamMap(): void + { + foreach (['720p,name:evil', 'two words', ''] as $name) { + try { + new Representation(1280, 720, 2500, name: $name); + $this->fail('expected "'.$name.'" to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as a name', $exception->getMessage()); + } + } + } + + public function testAcceptsTheNamesLaddersActuallyUse(): void + { + foreach (['720p', 'low', 'audio_eng', 'rung-1', 'HD'] as $name) { + $this->assertSame($name, (new Representation(1280, 720, 2500, name: $name))->name); + } + } +} diff --git a/tests/Video/Unit/SpritesheetTest.php b/tests/Video/Unit/SpritesheetTest.php new file mode 100644 index 0000000..8ae1b4c --- /dev/null +++ b/tests/Video/Unit/SpritesheetTest.php @@ -0,0 +1,220 @@ +assertSame('00:00:00.000', Cue::timestamp(0.0)); + $this->assertSame('00:00:06.500', Cue::timestamp(6.5)); + $this->assertSame('00:01:05.000', Cue::timestamp(65.0)); + $this->assertSame('01:01:01.500', Cue::timestamp(3661.5)); + } + + public function testRendersACueAsAnAddressableRectangle(): void + { + $cue = new Cue(0.0, 2.0, 'sprite1.jpg', 320, 180, 160, 90); + + $this->assertSame( + "00:00:00.000 --> 00:00:02.000\nsprite1.jpg#xywh=320,180,160,90", + $cue->render(), + ); + } + + /** + * The sheet a player fetches is rarely at the path it was written to, so + * the URL has to be replaceable without touching the geometry. + */ + public function testCueUrlCanBeRewritten(): void + { + $cue = new Cue(0.0, 2.0, 'sprite1.jpg', 0, 0, 160, 90); + + $this->assertStringContainsString( + '/previews/abc123#xywh=0,0,160,90', + $cue->render('/previews/abc123'), + ); + } + + public function testRendersAWebvttTimeline(): void + { + $sheet = new Spritesheet( + ['/out/sprite1.jpg'], + [ + new Cue(0.0, 2.0, 'sprite1.jpg', 0, 0, 160, 90), + new Cue(2.0, 4.0, 'sprite1.jpg', 160, 0, 160, 90), + ], + '/out/sprite.vtt', + 160, + 90, + ); + + $vtt = $sheet->render(); + + $this->assertStringStartsWith("WEBVTT\n", $vtt); + $this->assertStringContainsString('00:00:00.000 --> 00:00:02.000', $vtt); + $this->assertStringContainsString('sprite1.jpg#xywh=160,0,160,90', $vtt); + $this->assertSame(160, $sheet->width()); + $this->assertSame(90, $sheet->height()); + } + + public function testTimelineUrlsCanBeRewrittenWholesale(): void + { + $sheet = new Spritesheet( + ['/out/sprite1.jpg'], + [new Cue(0.0, 2.0, 'sprite1.jpg', 0, 0, 160, 90)], + ); + + $vtt = $sheet->render(static fn (string $file): string => '/preview/'.$file); + + $this->assertStringContainsString('/preview/sprite1.jpg#xywh=0,0,160,90', $vtt); + } + + public function testListsEveryArtifact(): void + { + $withVtt = new Spritesheet(['/out/sprite1.jpg'], [], '/out/sprite.vtt'); + $withoutVtt = new Spritesheet(['/out/sprite1.jpg'], []); + + $this->assertSame(['/out/sprite1.jpg', '/out/sprite.vtt'], $withVtt->files()); + $this->assertSame(['/out/sprite1.jpg'], $withoutVtt->files()); + $this->assertNull($withoutVtt->vtt()); + } + + /** + * A two minute clip and a two hour film should not produce the same number + * of sheets, so the sampling interval grows with the source. + */ + public function testSamplingIntervalGrowsWithDuration(): void + { + $tile = new Tile(); + + $this->assertSame(2.0, $tile->every(60)); + $this->assertSame(5.0, $tile->every(300)); + $this->assertSame(10.0, $tile->every(1200)); + $this->assertSame(20.0, $tile->every(2400)); + $this->assertSame(30.0, $tile->every(7200)); + } + + public function testSamplingIntervalCanBePinned(): void + { + $tile = (new Tile())->interval(4.0); + + $this->assertSame(4.0, $tile->every(60)); + $this->assertSame(4.0, $tile->every(7200)); + } + + public function testTileDefaults(): void + { + $tile = new Tile(); + + $this->assertSame(160, $tile->size()); + $this->assertSame(5, $tile->columns()); + $this->assertSame(5, $tile->rows()); + $this->assertSame(25, $tile->cells()); + $this->assertSame(3, $tile->scale()); + $this->assertSame('sprite', $tile->base()); + $this->assertTrue($tile->writes()); + } + + public function testTileIsConfigurable(): void + { + $tile = (new Tile())->width(200)->grid(4, 3)->quality(5)->name('thumbs')->vtt(false); + + $this->assertSame(200, $tile->size()); + $this->assertSame(12, $tile->cells()); + $this->assertSame(5, $tile->scale()); + $this->assertSame('thumbs', $tile->base()); + $this->assertFalse($tile->writes()); + } + + /** + * Sheet names become filenames in the directory they were given, so one that + * leaves it is refused. + */ + public function testASheetNameThatLeavesTheDirectoryIsRejected(): void + { + $this->expectException(Input::class); + + (new Tile())->name('../sheets'); + } + + /** + * The interval is a divisor. Zero used to reach ffmpeg as a DivisionByZeroError + * rather than as anything a caller could catch. + * + * @dataProvider brokenTiles + */ + public function testATileSettingThatCannotWorkIsRejected(callable $configure): void + { + $this->expectException(Input::class); + + $configure(new Tile()); + } + + /** + * @return array + */ + public static function brokenTiles(): array + { + return [ + 'zero interval' => [static fn (Tile $tile) => $tile->interval(0.0)], + 'negative interval' => [static fn (Tile $tile) => $tile->interval(-1.0)], + 'zero width' => [static fn (Tile $tile) => $tile->width(0)], + 'negative width' => [static fn (Tile $tile) => $tile->width(-160)], + 'empty grid' => [static fn (Tile $tile) => $tile->grid(0, 0)], + 'negative grid' => [static fn (Tile $tile) => $tile->grid(5, -1)], + 'quality below scale' => [static fn (Tile $tile) => $tile->quality(0)], + 'quality above scale' => [static fn (Tile $tile) => $tile->quality(32)], + ]; + } + + /** + * An unset interval is how a caller asks for the duration-scaled default, + * so null stays legal. + */ + public function testAnUnsetIntervalIsStillAllowed(): void + { + $this->assertSame(2.0, (new Tile())->interval(null)->every(8.0)); + } + + /** + * @dataProvider brokenThumbs + */ + public function testAThumbSettingThatCannotWorkIsRejected(callable $configure): void + { + $this->expectException(Input::class); + + $configure(new Thumb()); + } + + /** + * @return array + */ + public static function brokenThumbs(): array + { + return [ + 'negative time' => [static fn (Thumb $thumb) => $thumb->time(-1.0)], + 'unscalable width' => [static fn (Thumb $thumb) => $thumb->width(1)], + 'negative width' => [static fn (Thumb $thumb) => $thumb->width(-320)], + 'quality below scale' => [static fn (Thumb $thumb) => $thumb->quality(0)], + 'quality above scale' => [static fn (Thumb $thumb) => $thumb->quality(32)], + ]; + } + + /** + * Zero width is how a caller asks to keep the source size, so it stays legal. + */ + public function testAThumbWidthOfZeroKeepsTheSourceSize(): void + { + $this->assertSame(0, (new Thumb())->width(0)->size()); + } +} diff --git a/tests/samples/.gitignore b/tests/samples/.gitignore new file mode 100644 index 0000000..0421ab7 --- /dev/null +++ b/tests/samples/.gitignore @@ -0,0 +1,8 @@ +# Sample media is generated by tests/Streaming/Samples.php (composer samples), +# not committed: it would add megabytes to every clone for files the build can +# reproduce exactly. The directories themselves are kept so the layout is obvious. +in/* +out/* +!.gitignore +!in/.gitkeep +!out/.gitkeep diff --git a/tests/samples/in/.gitkeep b/tests/samples/in/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/samples/out/.gitkeep b/tests/samples/out/.gitkeep new file mode 100644 index 0000000..e69de29