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
+
+[](https://github.com/utopia-php/video/actions/workflows/tests.yml)
+[](https://packagist.org/packages/utopia-php/video)
+[](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