From 0415eddcdab3c4f90598e67fe18cef973b952be1 Mon Sep 17 00:00:00 2001 From: shimon Date: Mon, 30 Jan 2023 20:05:04 +0200 Subject: [PATCH 01/12] KickOff --- .gitignore | 3 ++ Dockerfile | 35 ++++++++++++ LICENSE | 21 ++++++++ README.md | 73 +++++++++++++++++++++++++ composer.json | 41 ++++++++++++++ docker-compose.yml | 10 ++++ phpunit.xml | 16 ++++++ src/Streaming/Adapter.php | 35 ++++++++++++ src/Streaming/Adapter/FFmpeg.php | 87 ++++++++++++++++++++++++++++++ src/Streaming/Audio.php | 25 +++++++++ src/Streaming/Encoder.php | 65 ++++++++++++++++++++++ src/Streaming/Format/Format.php | 93 ++++++++++++++++++++++++++++++++ src/Streaming/Format/HEVC.php | 64 ++++++++++++++++++++++ src/Streaming/Format/VP9.php | 58 ++++++++++++++++++++ src/Streaming/Format/X264.php | 63 ++++++++++++++++++++++ src/Streaming/Video.php | 33 ++++++++++++ tests/Streaming/FFmpegTest.php | 32 +++++++++++ 17 files changed, 754 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 docker-compose.yml create mode 100644 phpunit.xml create mode 100644 src/Streaming/Adapter.php create mode 100644 src/Streaming/Adapter/FFmpeg.php create mode 100644 src/Streaming/Audio.php create mode 100644 src/Streaming/Encoder.php create mode 100644 src/Streaming/Format/Format.php create mode 100644 src/Streaming/Format/HEVC.php create mode 100644 src/Streaming/Format/VP9.php create mode 100644 src/Streaming/Format/X264.php create mode 100644 src/Streaming/Video.php create mode 100644 tests/Streaming/FFmpegTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0876df --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +.phpunit* +.idea \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d740fa5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM composer:2.0 as step0 + +ARG TESTING=false +ENV TESTING=$TESTING + +WORKDIR /usr/local/src/ + +COPY composer.lock /usr/local/src/ +COPY composer.json /usr/local/src/ + +#docker run -v "$(pwd)":/usr/src/code -it --net=host --rm google/shaka-packager + +RUN composer update --ignore-platform-reqs --optimize-autoloader \ + --no-plugins --no-scripts --prefer-dist + +FROM php:8.1-cli-alpine as final + +LABEL maintainer="team@appwrite.io" + +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN \ + apk update \ + && apk add --no-cache make automake autoconf gcc g++ git zlib-dev libmemcached-dev \ + ffmpeg \ + && rm -rf /var/cache/apk/* + +WORKDIR /usr/src/code + +# Add Source Code +COPY ./ /usr/src/code + +COPY --from=step0 /usr/local/src/vendor /usr/src/code/vendor + +CMD [ "tail", "-f", "/dev/null" ] \ No newline at end of file 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..4ab26cc --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Utopia Queue + +[![Build Status](https://travis-ci.com/utopia-php/queue.svg?branch=main)](https://travis-ci.com/utopia-php/queue) +![Total Downloads](https://img.shields.io/packagist/dt/utopia-php/queue.svg) +[![Discord](https://img.shields.io/discord/564160730845151244?label=discord)](https://appwrite.io/discord) + +Utopia Queue is a powerful Queue library. 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 is dependency free and can be used as standalone with any other PHP project or framework. + +## Getting Started + +Install using composer: + +```bash +composer require utopia-php/queue +``` + +Init in your application: + +```php +job() + ->inject('message') + ->action(function (Message $message) { + var_dump($message); + }); + +$server + ->error() + ->inject('error') + ->action(function ($error) { + echo $error->getMessage() . PHP_EOL; + }); + +$server + ->workerStart() + ->action(function () { + echo "Worker Started" . PHP_EOL; + }); + +$server->start(); + + +// Enqueue messages to the worker using the Redis adapter +$connection = new Redis('redis', 6379); +$client = new Client('my-queue', $connection); + +$client->enqueue([ + 'type' => 'test_number', + 'value' => 123 +]); +``` + +## System Requirements + +Utopia Framework requires PHP 8.0 or later. We recommend using the latest PHP version whenever possible. + +## Copyright and license + +The MIT License (MIT) [http://www.opensource.org/licenses/mit-license.php](http://www.opensource.org/licenses/mit-license.php) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d809029 --- /dev/null +++ b/composer.json @@ -0,0 +1,41 @@ +{ + "name": "shimonewman/streaming", + "description": "", + "type": "library", + "keywords": ["bla","bla"], + "license": "MIT", + "minimum-stability": "stable", + "authors": [ + { + "name": "Shimon Newman", + "email": "shimon@appwrite.io" + } + ], + "autoload": { + "psr-4": {"Utopia\\Streaming\\": "src/Streaming"} + }, + "autoload-dev": { + "psr-4": {"Utopia\\Tests\\": "tests/Streaming"} + }, + "scripts":{ + "test": "phpunit", + "analyse": "vendor/bin/phpstan analyse", + "format": "vendor/bin/pint", + "lint": "vendor/bin/pint --test" + }, + "require": { + "php": ">=8.0", + "utopia-php/cli": "0.13.*", + "utopia-php/framework": "0.*.*", + "php-ffmpeg/php-ffmpeg": "1.*.*" + }, + "require-dev": { + "swoole/ide-helper": "4.8.8", + "phpunit/phpunit": "^9.5.5", + "laravel/pint": "^0.2.3", + "phpstan/phpstan": "^1.8" + }, + "suggest": { + "ext-swoole": "Needed to support Swoole." + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b4af9e7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3.1' + +services: + php8: + container_name: php + build: + context: . + dockerfile: Dockerfile + volumes: + - ./:/usr/src/code \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..3712938 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,16 @@ + + + + ./tests/Streaming/FFmpegTest.php + + + \ No newline at end of file diff --git a/src/Streaming/Adapter.php b/src/Streaming/Adapter.php new file mode 100644 index 0000000..35b7398 --- /dev/null +++ b/src/Streaming/Adapter.php @@ -0,0 +1,35 @@ +ffprobe = BFFProbe::create(); + $this->ffmpeg = BFFMpeg::create($config,null, $this->ffprobe); + } + + /** + * @param string $path + * @return FFmpeg + */ + public function open(string $path): FFmpeg + { + if (null === $streams = $this->ffprobe->streams($path)) { + throw new RuntimeException(sprintf('Unable to probe "%s".', $path)); + } + + if (0 < count($streams->videos())) { + $this->setVideo(new Video($streams)); + + return $this; + } + + throw new InvalidArgumentException('Unable to detect file format'); + } + + /** + * @param string $path + * @return bool + */ + public function isValid(string $path): bool + { + try { + return $this->ffprobe->format($path)->get('duration') > 0; + } catch(\Exception $e) { + return false; + } + } + + /** + * @param Video $video + * @return FFmpeg + */ + public function setVideo(Video $video): FFmpeg + { + $this->video = $video; + + return $this; + } + + /** + * @param Format $format + * @return FFmpeg + */ + public function setFormat(Format $format): FFmpeg + { + $this->format = $format; + + return $this; + } + +} + + diff --git a/src/Streaming/Audio.php b/src/Streaming/Audio.php new file mode 100644 index 0000000..16a6f9d --- /dev/null +++ b/src/Streaming/Audio.php @@ -0,0 +1,25 @@ +audioStreams = $streams; + + return $this; + } + +} \ No newline at end of file diff --git a/src/Streaming/Encoder.php b/src/Streaming/Encoder.php new file mode 100644 index 0000000..5f50281 --- /dev/null +++ b/src/Streaming/Encoder.php @@ -0,0 +1,65 @@ +adapter = $adapter; + } + + /** + * @param string $path + * @return FFmpeg + */ + public function open(string $path): FFmpeg + { + return $this->adapter->open($path); + } + + /** + * @param string $path + * @return bool + */ + public function isValid(string $path): bool + { + return $this->adapter->isValid($path); + } + + /** + * @param Video $video + * @return FFmpeg + */ + public function setVideo(Video $video): FFmpeg + { + $this->adapter->setVideo($video); + + return $this; + } + + /** + * @param Format $format + * @return FFmpeg + */ + public function setFormat(Format $format): FFmpeg + { + $this->adapter->setFormat($format); + + return $this; + } + +} \ No newline at end of file diff --git a/src/Streaming/Format/Format.php b/src/Streaming/Format/Format.php new file mode 100644 index 0000000..7d743b1 --- /dev/null +++ b/src/Streaming/Format/Format.php @@ -0,0 +1,93 @@ +audioCodec; + } + + /** + * Sets the audio codec, Should be in the available ones, otherwise an + * exception is thrown. + * + * @param string $audioCodec + * + * @throws InvalidArgumentException + */ + public function setAudioCodec(string $audioCodec): Format + { + if (!in_array($audioCodec, $this->getAvailableAudioCodecs())) { + throw new InvalidArgumentException(sprintf( + 'Wrong audiocodec value for %s, available formats are %s' + , $audioCodec, implode(', ', $this->getAvailableAudioCodecs()) + )); + } + + $this->audioCodec = $audioCodec; + + return $this; + } + + + /** + * @Return string + */ + public function getVideoCodec(): string + { + return $this->videoCodec; + } + + /** + * Sets the video codec, Should be in the available ones, otherwise an + * exception is thrown. + * + * @param string $videoCodec + * @throws InvalidArgumentException + */ + public function setVideoCodec(string $videoCodec) + { + if (!in_array($videoCodec, $this->getAvailableVideoCodecs())) { + throw new InvalidArgumentException(sprintf( + 'Wrong videocodec value for %s, available formats are %s' + , $videoCodec, implode(', ', $this->getAvailableVideoCodecs()) + )); + } + + $this->videoCodec = $videoCodec; + + return $this; + } + + + /** + * @param array $additionalParameters + * @return Format + */ + public function setAdditionalParameters(array $additionalParameters): Format + { + $this->additionalParameters = $additionalParameters; + + return $this; + } + + /** + * @return int + */ + public function getModulus(): int + { + return $this->modulus; + } +} \ No newline at end of file diff --git a/src/Streaming/Format/HEVC.php b/src/Streaming/Format/HEVC.php new file mode 100644 index 0000000..8944802 --- /dev/null +++ b/src/Streaming/Format/HEVC.php @@ -0,0 +1,64 @@ +setVideoCodec($video_codec) + ->setAudioCodec($audio_codec); + + /** + * set the default value of h265 codec options + * see https://ffmpeg.org/ffmpeg-codecs.html#Options-29 for more information about options + */ + if ($default_init_opts) { + $this->setAdditionalParameters([ + 'keyint_min' => 25, + 'g' => 250, + 'sc_threshold' => 40 + ]); + } + } + + /** + * Returns the list of available audio codecs for this format. + * + * @return array + */ + public function getAvailableAudioCodecs() + { + return ['aac', 'libvo_aacenc', 'libfaac', 'libmp3lame', 'libfdk_aac']; + } + + /** + * @return array + */ + public function getAvailableVideoCodecs(): array + { + return ['libx265', 'h265', 'hevc_nvenc']; + } + + /** + * Returns true if the current format supports B-Frames. + * + * @see https://wikipedia.org/wiki/Video_compression_picture_types + * + * @return Boolean + */ + public function supportBFrames(): bool + { + return true; + } +} \ No newline at end of file diff --git a/src/Streaming/Format/VP9.php b/src/Streaming/Format/VP9.php new file mode 100644 index 0000000..8f3a1b2 --- /dev/null +++ b/src/Streaming/Format/VP9.php @@ -0,0 +1,58 @@ +setVideoCodec($video_codec) + ->setAudioCodec($audio_codec); + + /** + * set the default value of h265 codec options + * see https://ffmpeg.org/ffmpeg-codecs.html#Options-26 for more information about options + */ + if ($default_init_opts) { + //@TODO: add default vp9 + } + } + + /** + * {@inheritDoc} + */ + public function getAvailableAudioCodecs() + { + return ['libvorbis']; + } + + /** + * @return array + */ + public function getAvailableVideoCodecs(): array + { + return ['libvpx', 'libvpx-vp9']; + } + + /** + * Returns true if the current format supports B-Frames. + * + * @see https://wikipedia.org/wiki/Video_compression_picture_types + * + * @return Boolean + */ + public function supportBFrames(): bool + { + return true; + } +} diff --git a/src/Streaming/Format/X264.php b/src/Streaming/Format/X264.php new file mode 100644 index 0000000..90638ec --- /dev/null +++ b/src/Streaming/Format/X264.php @@ -0,0 +1,63 @@ +setVideoCodec($video_codec) + ->setAudioCodec($audio_codec); + + /** + * set the default value of h264 codec options + * see https://ffmpeg.org/ffmpeg-codecs.html#Options-28 for more information about options + * return array + */ + if ($default_init_opts) { + $this->setAdditionalParameters([ + 'bf' => 1, + 'keyint_min' => 25, + 'g' => 250, + 'sc_threshold' => 40 + ]); + } + } + + /** + * @return array + */ + public function getAvailableAudioCodecs() + { + return ['aac', 'libvo_aacenc', 'libfaac', 'libmp3lame', 'libfdk_aac']; + } + + /** + * @return array + */ + public function getAvailableVideoCodecs() + { + return ['libx264', 'h264', 'h264_afm', 'h264_nvenc']; + } + + + /** + * Returns true if the current format supports B-Frames. + * + * @see https://wikipedia.org/wiki/Video_compression_picture_types + * + * @return Boolean + */ + public function supportBFrames() + { + return true; + } +} \ No newline at end of file diff --git a/src/Streaming/Video.php b/src/Streaming/Video.php new file mode 100644 index 0000000..3b16e7a --- /dev/null +++ b/src/Streaming/Video.php @@ -0,0 +1,33 @@ +setVideoStreams($streams->videos()); + $this->setAudioStreams($streams->audios()); + } + + /** + * @param $streams + * @return Video + */ + public function setVideoStreams($streams): Video + { + $this->videoStreams = $streams; + + return $this; + } + +} \ No newline at end of file diff --git a/tests/Streaming/FFmpegTest.php b/tests/Streaming/FFmpegTest.php new file mode 100644 index 0000000..9166b3e --- /dev/null +++ b/tests/Streaming/FFmpegTest.php @@ -0,0 +1,32 @@ +open(__DIR__ . '/../resources/sample.mp4'); + $encoder->setFormat(new \Utopia\Streaming\Format\X264()); + var_dump($encoder); + //self::assertInstanceOf(self::$encoder, BFFmpeg); + } + + +} From 5abf56972e1077c95381ac7d2026ee201d975dc2 Mon Sep 17 00:00:00 2001 From: shimon Date: Mon, 30 Jan 2023 20:06:18 +0200 Subject: [PATCH 02/12] KickOff --- README.md | 73 ------------------------------------------------------- 1 file changed, 73 deletions(-) diff --git a/README.md b/README.md index 4ab26cc..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,73 +0,0 @@ -# Utopia Queue - -[![Build Status](https://travis-ci.com/utopia-php/queue.svg?branch=main)](https://travis-ci.com/utopia-php/queue) -![Total Downloads](https://img.shields.io/packagist/dt/utopia-php/queue.svg) -[![Discord](https://img.shields.io/discord/564160730845151244?label=discord)](https://appwrite.io/discord) - -Utopia Queue is a powerful Queue library. 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 is dependency free and can be used as standalone with any other PHP project or framework. - -## Getting Started - -Install using composer: - -```bash -composer require utopia-php/queue -``` - -Init in your application: - -```php -job() - ->inject('message') - ->action(function (Message $message) { - var_dump($message); - }); - -$server - ->error() - ->inject('error') - ->action(function ($error) { - echo $error->getMessage() . PHP_EOL; - }); - -$server - ->workerStart() - ->action(function () { - echo "Worker Started" . PHP_EOL; - }); - -$server->start(); - - -// Enqueue messages to the worker using the Redis adapter -$connection = new Redis('redis', 6379); -$client = new Client('my-queue', $connection); - -$client->enqueue([ - 'type' => 'test_number', - 'value' => 123 -]); -``` - -## System Requirements - -Utopia Framework requires PHP 8.0 or later. We recommend using the latest PHP version whenever possible. - -## Copyright and license - -The MIT License (MIT) [http://www.opensource.org/licenses/mit-license.php](http://www.opensource.org/licenses/mit-license.php) From 5c22b3342b35e108745afd5f97ab5b42dbefabce Mon Sep 17 00:00:00 2001 From: shimon Date: Tue, 31 Jan 2023 21:01:49 +0200 Subject: [PATCH 03/12] basic structure --- src/Streaming/Adapter.php | 27 +++- src/Streaming/Adapter/FFmpeg.php | 68 ++++++++--- src/Streaming/Audio.php | 14 ++- src/Streaming/Encoder.php | 60 ++++++--- src/Streaming/Format/Format.php | 24 ++-- src/Streaming/Format/HEVC.php | 16 ++- src/Streaming/Format/VP9.php | 12 +- src/Streaming/Format/X264.php | 15 ++- src/Streaming/Output.php | 27 ++++ src/Streaming/Output/Hls.php | 203 +++++++++++++++++++++++++++++++ src/Streaming/Representation.php | 107 ++++++++++++++++ src/Streaming/Stream.php | 69 +++++++++++ src/Streaming/Video.php | 7 +- tests/Streaming/FFmpegTest.php | 31 +++-- 14 files changed, 580 insertions(+), 100 deletions(-) create mode 100644 src/Streaming/Output.php create mode 100644 src/Streaming/Output/Hls.php create mode 100644 src/Streaming/Representation.php create mode 100644 src/Streaming/Stream.php diff --git a/src/Streaming/Adapter.php b/src/Streaming/Adapter.php index 35b7398..230fb87 100644 --- a/src/Streaming/Adapter.php +++ b/src/Streaming/Adapter.php @@ -3,6 +3,7 @@ namespace Utopia\Streaming; use Utopia\Streaming\Adapter\FFmpeg; +use Utopia\Streaming\Encoder; use Utopia\Streaming\Format\Format; interface Adapter @@ -10,9 +11,9 @@ interface Adapter /** * @param string $path - * @return FFmpeg + * @return self */ - public function open(string $path): FFmpeg; + public function open(string $path): self; /** * @param string $path @@ -22,14 +23,28 @@ public function isValid(string $path): bool; /** * @param Video $video - * @return FFmpeg + * @return self */ - public function setVideo(Video $video): FFmpeg; + public function setVideo(Video $video): self; /** * @param Format $format - * @return FFmpeg + * @return self */ - public function setFormat(Format $format): FFmpeg; + public function setFormat(Format $format): self; + /** + * @param Representation $representation + * @return self + */ + public function addRepresentation(Representation $representation): self; + + /** + * @param Output $output + * @return self + */ + public function setOutput(Output $output): self; + + + public function run(): void; } \ No newline at end of file diff --git a/src/Streaming/Adapter/FFmpeg.php b/src/Streaming/Adapter/FFmpeg.php index 50f3e25..fd6a417 100644 --- a/src/Streaming/Adapter/FFmpeg.php +++ b/src/Streaming/Adapter/FFmpeg.php @@ -7,32 +7,40 @@ use FFMpeg\FFMpeg as BFFmpeg; use FFMpeg\FFProbe as BFFProbe; use Utopia\Streaming\Adapter; -use Utopia\Streaming\Video; use Utopia\Streaming\Format\Format; +use Utopia\Streaming\Output; +use Utopia\Streaming\Representation; +use Utopia\Streaming\Video; class FFmpeg implements Adapter { - protected BFFProbe $ffprobe; + protected BFFmpeg $ffmpeg; + protected Video $video; + + protected array $representations; + public Format $format; + public Output $output; + /** - * @param array $config + * @param array $config * @return void */ public function __construct(array $config = []) { $this->ffprobe = BFFProbe::create(); - $this->ffmpeg = BFFMpeg::create($config,null, $this->ffprobe); + $this->ffmpeg = BFFMpeg::create($config, null, $this->ffprobe); } /** - * @param string $path - * @return FFmpeg + * @param string $path + * @return self */ - public function open(string $path): FFmpeg + public function open(string $path): self { if (null === $streams = $this->ffprobe->streams($path)) { throw new RuntimeException(sprintf('Unable to probe "%s".', $path)); @@ -48,23 +56,23 @@ public function open(string $path): FFmpeg } /** - * @param string $path + * @param string $path * @return bool */ public function isValid(string $path): bool { try { return $this->ffprobe->format($path)->get('duration') > 0; - } catch(\Exception $e) { + } catch (\Exception $e) { return false; } } /** - * @param Video $video - * @return FFmpeg + * @param Video $video + * @return self */ - public function setVideo(Video $video): FFmpeg + public function setVideo(Video $video): self { $this->video = $video; @@ -72,16 +80,40 @@ public function setVideo(Video $video): FFmpeg } /** - * @param Format $format - * @return FFmpeg + * @param Format $format + * @return self */ - public function setFormat(Format $format): FFmpeg + public function setFormat(Format $format): self { - $this->format = $format; + $this->format = $format; - return $this; + return $this; } -} + /** + * @param Representation $representation + * @return self + */ + public function addRepresentation(Representation $representation): self + { + $this->representations[] = $representation; + return $this; + } + + /** + * @param Output $output + * @return self + */ + public function setOutput(Output $output): self + { + $this->output = $output; + return $this; + } + + public function run(): void + { + var_dump($this->output); + } +} diff --git a/src/Streaming/Audio.php b/src/Streaming/Audio.php index 16a6f9d..d4e16db 100644 --- a/src/Streaming/Audio.php +++ b/src/Streaming/Audio.php @@ -2,24 +2,28 @@ namespace Utopia\Streaming; - use FFMpeg\FFProbe\DataMapping\StreamCollection; class Audio { - protected StreamCollection $audioStreams; + protected array $audioTracks; /** - * @param StreamCollection $streams + * @param StreamCollection $streams * @return Audio */ public function setAudioStreams(StreamCollection $streams): Audio { $this->audioStreams = $streams; + foreach ($this->audioStreams->getIterator() ?? [] as $stream) { + if (! empty($stream->get('tags')['language'])) { + $this->audioTracks[] = $stream->get('tags')['language']; + } + } + return $this; } - -} \ No newline at end of file +} diff --git a/src/Streaming/Encoder.php b/src/Streaming/Encoder.php index 5f50281..0dd91ed 100644 --- a/src/Streaming/Encoder.php +++ b/src/Streaming/Encoder.php @@ -2,18 +2,13 @@ namespace Utopia\Streaming; -use Utopia\Streaming\Adapter\FFmpeg; use Utopia\Streaming\Format\Format; class Encoder { - /** - * @var Adapter - */ private Adapter $adapter; - /** * @param Adapter $adapter */ @@ -23,16 +18,16 @@ public function __construct(Adapter $adapter) } /** - * @param string $path - * @return FFmpeg + * @param string $path + * @return Adapter */ - public function open(string $path): FFmpeg + public function open(string $path): Adapter { return $this->adapter->open($path); } /** - * @param string $path + * @param string $path * @return bool */ public function isValid(string $path): bool @@ -40,26 +35,55 @@ public function isValid(string $path): bool return $this->adapter->isValid($path); } + /** - * @param Video $video - * @return FFmpeg + * @param Format $format + * @return Encoder */ - public function setVideo(Video $video): FFmpeg + public function setFormat(Format $format): Encoder { - $this->adapter->setVideo($video); + $this->adapter->setFormat($format); return $this; } /** - * @param Format $format - * @return FFmpeg + * @param Representation $representation + * @return Encoder */ - public function setFormat(Format $format): FFmpeg + public function addRepresentation(Representation $representation): Encoder { - $this->adapter->setFormat($format); + $this->adapter->addRepresentation($representation); + + return $this; + } + + /** + * @param $representations Representation[] + * @return Encoder + */ + public function addRepresentations(array $representations): Encoder + { + foreach ($representations as $representation) { + $this->adapter->addRepresentation($representation); + } return $this; } -} \ No newline at end of file + /** + * @param Output $output + * @return Encoder + */ + public function setOutput(Output $output): Encoder + { + $this->adapter->setOutput($output); + + return $this; + } + + public function run(): void + { + $this->adapter->run(); + } +} diff --git a/src/Streaming/Format/Format.php b/src/Streaming/Format/Format.php index 7d743b1..065b821 100644 --- a/src/Streaming/Format/Format.php +++ b/src/Streaming/Format/Format.php @@ -7,8 +7,11 @@ abstract class Format { protected int $modulus = 2; + protected string $audioCodec; + protected string $videoCodec; + protected array $additionalParameters; /** @@ -23,16 +26,15 @@ public function getAudioCodec(): string * Sets the audio codec, Should be in the available ones, otherwise an * exception is thrown. * - * @param string $audioCodec + * @param string $audioCodec * * @throws InvalidArgumentException */ public function setAudioCodec(string $audioCodec): Format { - if (!in_array($audioCodec, $this->getAvailableAudioCodecs())) { + if (! in_array($audioCodec, $this->getAvailableAudioCodecs())) { throw new InvalidArgumentException(sprintf( - 'Wrong audiocodec value for %s, available formats are %s' - , $audioCodec, implode(', ', $this->getAvailableAudioCodecs()) + 'Wrong audiocodec value for %s, available formats are %s', $audioCodec, implode(', ', $this->getAvailableAudioCodecs()) )); } @@ -41,7 +43,6 @@ public function setAudioCodec(string $audioCodec): Format return $this; } - /** * @Return string */ @@ -54,15 +55,15 @@ public function getVideoCodec(): string * Sets the video codec, Should be in the available ones, otherwise an * exception is thrown. * - * @param string $videoCodec + * @param string $videoCodec + * * @throws InvalidArgumentException */ public function setVideoCodec(string $videoCodec) { - if (!in_array($videoCodec, $this->getAvailableVideoCodecs())) { + if (! in_array($videoCodec, $this->getAvailableVideoCodecs())) { throw new InvalidArgumentException(sprintf( - 'Wrong videocodec value for %s, available formats are %s' - , $videoCodec, implode(', ', $this->getAvailableVideoCodecs()) + 'Wrong videocodec value for %s, available formats are %s', $videoCodec, implode(', ', $this->getAvailableVideoCodecs()) )); } @@ -71,9 +72,8 @@ public function setVideoCodec(string $videoCodec) return $this; } - /** - * @param array $additionalParameters + * @param array $additionalParameters * @return Format */ public function setAdditionalParameters(array $additionalParameters): Format @@ -90,4 +90,4 @@ public function getModulus(): int { return $this->modulus; } -} \ No newline at end of file +} diff --git a/src/Streaming/Format/HEVC.php b/src/Streaming/Format/HEVC.php index 8944802..f8c0951 100644 --- a/src/Streaming/Format/HEVC.php +++ b/src/Streaming/Format/HEVC.php @@ -2,16 +2,14 @@ namespace Utopia\Streaming\Format; -use Utopia\Streaming\Format\Format; - class HEVC extends Format { - /** * HEVC constructor. - * @param string $video_codec - * @param string|null $audio_codec - * @param bool $default_init_opts + * + * @param string $video_codec + * @param string|null $audio_codec + * @param bool $default_init_opts */ public function __construct(string $video_codec = 'libx265', string $audio_codec = 'aac', bool $default_init_opts = true) { @@ -27,7 +25,7 @@ public function __construct(string $video_codec = 'libx265', string $audio_codec $this->setAdditionalParameters([ 'keyint_min' => 25, 'g' => 250, - 'sc_threshold' => 40 + 'sc_threshold' => 40, ]); } } @@ -55,10 +53,10 @@ public function getAvailableVideoCodecs(): array * * @see https://wikipedia.org/wiki/Video_compression_picture_types * - * @return Boolean + * @return bool */ public function supportBFrames(): bool { return true; } -} \ No newline at end of file +} diff --git a/src/Streaming/Format/VP9.php b/src/Streaming/Format/VP9.php index 8f3a1b2..c23338e 100644 --- a/src/Streaming/Format/VP9.php +++ b/src/Streaming/Format/VP9.php @@ -2,16 +2,12 @@ namespace Utopia\Streaming\Format; -use Utopia\Streaming\Format\Format; - - class VP9 extends Format { - /** - * @param string $video_codec - * @param string|null $audio_codec - * @param bool $default_init_opts + * @param string $video_codec + * @param string|null $audio_codec + * @param bool $default_init_opts */ public function __construct(string $video_codec = 'libvpx-vp9', string $audio_codec = 'aac', bool $default_init_opts = true) { @@ -49,7 +45,7 @@ public function getAvailableVideoCodecs(): array * * @see https://wikipedia.org/wiki/Video_compression_picture_types * - * @return Boolean + * @return bool */ public function supportBFrames(): bool { diff --git a/src/Streaming/Format/X264.php b/src/Streaming/Format/X264.php index 90638ec..c7558b0 100644 --- a/src/Streaming/Format/X264.php +++ b/src/Streaming/Format/X264.php @@ -4,12 +4,12 @@ class X264 extends Format { - /** * X264 constructor. - * @param string $video_codec - * @param string $audio_codec - * @param bool $default_init_opts + * + * @param string $video_codec + * @param string $audio_codec + * @param bool $default_init_opts */ public function __construct($video_codec = 'libx264', string $audio_codec = 'aac', bool $default_init_opts = true) { @@ -27,7 +27,7 @@ public function __construct($video_codec = 'libx264', string $audio_codec = 'aac 'bf' => 1, 'keyint_min' => 25, 'g' => 250, - 'sc_threshold' => 40 + 'sc_threshold' => 40, ]); } } @@ -48,16 +48,15 @@ public function getAvailableVideoCodecs() return ['libx264', 'h264', 'h264_afm', 'h264_nvenc']; } - /** * Returns true if the current format supports B-Frames. * * @see https://wikipedia.org/wiki/Video_compression_picture_types * - * @return Boolean + * @return bool */ public function supportBFrames() { return true; } -} \ No newline at end of file +} diff --git a/src/Streaming/Output.php b/src/Streaming/Output.php new file mode 100644 index 0000000..14c925b --- /dev/null +++ b/src/Streaming/Output.php @@ -0,0 +1,27 @@ +strict = $strict; + + return $this; + } + + /** + * @return string + */ + public function getStrict(): string + { + return $this->strict; + } +} diff --git a/src/Streaming/Output/Hls.php b/src/Streaming/Output/Hls.php new file mode 100644 index 0000000..873eb60 --- /dev/null +++ b/src/Streaming/Output/Hls.php @@ -0,0 +1,203 @@ +seg_sub_directory; + } + + /** + * @param string $seg_sub_directory + * @return Hls + */ + public function setSegSubDirectory(string $seg_sub_directory): Hls + { + $this->seg_sub_directory = $seg_sub_directory; + + return $this; + } + + /** + * @param string $hls_time + * @return HLS + */ + public function setHlsTime(string $hls_time): HLS + { + $this->hls_time = $hls_time; + + return $this; + } + + /** + * @return string + */ + public function getHlsTime(): string + { + return $this->hls_time; + } + + /** + * @param bool $hls_allow_cache + * @return HLS + */ + public function setHlsAllowCache(bool $hls_allow_cache): HLS + { + $this->hls_allow_cache = $hls_allow_cache; + + return $this; + } + + /** + * @return bool + */ + public function isHlsAllowCache(): bool + { + return $this->hls_allow_cache; + } + + /** + * @param string $hls_base_url + * @return HLS + */ + public function setHlsBaseUrl(string $hls_base_url): HLS + { + $this->hls_base_url = $hls_base_url; + + return $this; + } + + /** + * @return string + */ + public function getHlsBaseUrl(): string + { + return $this->hls_base_url; + } + + /** + * @param int $hls_list_size + * @return HLS + */ + public function setHlsListSize(int $hls_list_size): HLS + { + $this->hls_list_size = $hls_list_size; + + return $this; + } + + /** + * @return int + */ + public function getHlsListSize(): int + { + return $this->hls_list_size; + } + + /** + * @param string $master_playlist + * @param array $stream_des + * @return HLS + */ + public function setMasterPlaylist(string $master_playlist, array $stream_des = []): HLS + { + $this->master_playlist = $master_playlist; + $this->stream_des = $stream_des; + + return $this; + } + + /** + * @return HLS + */ + public function fragmentedMP4(): HLS + { + $this->setHlsSegmentType('fmp4'); + + return $this; + } + + /** + * @param string $hls_segment_type + * @return HLS + */ + public function setHlsSegmentType(string $hls_segment_type): HLS + { + $this->hls_segment_type = $hls_segment_type; + + return $this; + } + + /** + * @return string + */ + public function getHlsSegmentType(): string + { + return $this->hls_segment_type; + } + + /** + * @param string $hls_fmp4_init_filename + * @return HLS + */ + public function setHlsFmp4InitFilename(string $hls_fmp4_init_filename): HLS + { + $this->hls_fmp4_init_filename = $hls_fmp4_init_filename; + + return $this; + } + + /** + * @return string + */ + public function getHlsFmp4InitFilename(): string + { + return $this->hls_fmp4_init_filename; + } + + /** + * @param array $flags + * @return HLS + */ + public function setFlags(array $flags): HLS + { + $this->flags = array_merge($this->flags, $flags); + + return $this; + } + + /** + * @return array + */ + public function getFlags(): array + { + return $this->flags; + } +} diff --git a/src/Streaming/Representation.php b/src/Streaming/Representation.php new file mode 100644 index 0000000..a9973d8 --- /dev/null +++ b/src/Streaming/Representation.php @@ -0,0 +1,107 @@ +width = $width; + $this->height = $height; + + return $this; + } + + /** + * @return int + */ + public function getWidth(): int + { + return $this->width; + } + + /** + * @return int + */ + public function getHeight(): int + { + return $this->height; + } + + /** + * @return int + */ + public function getSize(): int + { + return implode('x', [$this->getWidth(), $this->getHeight()]); + } + + /** + * @param int $videoKiloBitrate + * @return Representation + */ + public function setVideoKiloBitrate(int $videoKiloBitrate): Representation + { + if ($videoKiloBitrate < 1) { + throw new InvalidArgumentException('Invalid video kilo bit rate value'); + } + + $this->videoKiloBitrate = $videoKiloBitrate; + + return $this; + } + + /** + * @return int + */ + public function getVideoKiloBitrate(): int + { + return $this->videoKiloBitrate; + } + + /** + * @param int $audioKiloBitrate + * @return Representation + */ + public function setAudioKiloBitrate(int $audioKiloBitrate): Representation + { + if ($audioKiloBitrate < 1) { + throw new InvalidArgumentException('Invalid audio kilo bit rate value'); + } + + $this->audioKiloBitrate = $audioKiloBitrate; + + return $this; + } + + /** + * @return int + */ + public function getAudioKiloBitrate(): int + { + return $this->audioKiloBitrate; + } +} diff --git a/src/Streaming/Stream.php b/src/Streaming/Stream.php new file mode 100644 index 0000000..d2e6939 --- /dev/null +++ b/src/Streaming/Stream.php @@ -0,0 +1,69 @@ +encoder = $encoder; + } + + /** + * @param string $path + * @return self + */ + public function open(string $path): self + { + $this->encoder->open($path); + + return $this; + } + + /** + * @param Format $format + * @return self + */ + public function setFormat(Format $format): self + { + $this->encoder->setFormat($format); + + return $this; + } + + /** + * @param Representation $representation + * @return self + */ + public function addRepresentation(Representation $representation): self + { + $this->encoder->addRepresentation($representation); + + return $this; + } + + /** + * @param Output $output + * @return self + */ + public function setOutput(Output $output): self + { + $this->encoder->setOutput($output); + + return $this; + } + + + public function run(): void + { + $this->encoder->run(); + } +} diff --git a/src/Streaming/Video.php b/src/Streaming/Video.php index 3b16e7a..8868705 100644 --- a/src/Streaming/Video.php +++ b/src/Streaming/Video.php @@ -6,12 +6,10 @@ class Video extends Audio { - protected StreamCollection $videoStreams; - /** - * @param StreamCollection $streams + * @param StreamCollection $streams */ public function __construct(StreamCollection $streams) { @@ -29,5 +27,4 @@ public function setVideoStreams($streams): Video return $this; } - -} \ No newline at end of file +} diff --git a/tests/Streaming/FFmpegTest.php b/tests/Streaming/FFmpegTest.php index 9166b3e..7696784 100644 --- a/tests/Streaming/FFmpegTest.php +++ b/tests/Streaming/FFmpegTest.php @@ -2,13 +2,10 @@ namespace Utopia\Tests; - use PHPUnit\Framework\TestCase; -use Streaming\Format\X264; use Utopia\Streaming\Adapter\FFmpeg; use Utopia\Streaming\Encoder; -use \FFMpeg\FFMpeg as BFFmpeg; - +use Utopia\Streaming\Stream; class FFmpegTest extends TestCase { @@ -16,17 +13,29 @@ class FFmpegTest extends TestCase public static function setUpBeforeClass(): void { - self::$encoder = new Encoder(new FFmpeg()); - + self::$encoder = new Encoder(new FFmpeg([ + 'timeout' => 0, + 'ffmpeg.threads' => 12, + ] + )); } public function testFFmpeg(): void { - $encoder= self::$encoder->open(__DIR__ . '/../resources/sample.mp4'); - $encoder->setFormat(new \Utopia\Streaming\Format\X264()); - var_dump($encoder); - //self::assertInstanceOf(self::$encoder, BFFmpeg); - } + $representation = (new \Utopia\Streaming\Representation()) + ->setVideoKiloBitrate(6000) + ->setAudioKiloBitrate(128) + ->setResize(1080, 768); + (new Stream(self::$encoder)) + ->open(__DIR__.'/../resources/sample.mp4') + ->setFormat(new \Utopia\Streaming\Format\X264()) + ->addRepresentation($representation) + ->setOutput(new \Utopia\Streaming\Output\Hls()) + ->run() + ; + + + } } From 7c6bcbf72311a74fcfc1a56afe3257a23d757bd1 Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 26 Jul 2026 19:04:38 +0300 Subject: [PATCH 04/12] Update project configuration and structure: enhanced .gitignore, improved composer.json with a description and updated keywords, refined Dockerfile for better image management, added README for project documentation, and refactored streaming classes for improved functionality and clarity. --- .gitignore | 4 +- Dockerfile | 21 +- README.md | 79 + composer.json | 8 +- composer.lock | 2800 +++++++++++++++++ phpstan.neon | 6 + phpunit.xml | 5 +- pint.json | 3 + src/Streaming/Adapter.php | 37 +- src/Streaming/Adapter/FFmpeg.php | 243 +- src/Streaming/Arguments.php | 76 + src/Streaming/Arguments/Dash.php | 111 + src/Streaming/Arguments/Hls.php | 208 ++ src/Streaming/Audio.php | 29 - src/Streaming/CommandBuilder.php | 59 + src/Streaming/Encoder.php | 89 - src/Streaming/Exception/Exception.php | 9 + .../Exception/InvalidArgumentException.php | 9 + src/Streaming/Exception/RuntimeException.php | 9 + src/Streaming/File.php | 53 + src/Streaming/Format.php | 33 + src/Streaming/Format/Format.php | 93 - src/Streaming/Format/HEVC.php | 60 +- src/Streaming/Format/VP9.php | 68 +- src/Streaming/Format/X264.php | 63 +- src/Streaming/Output.php | 65 +- src/Streaming/Output/Dash.php | 108 + src/Streaming/Output/Hls.php | 161 +- src/Streaming/Probe.php | 101 + src/Streaming/Representation.php | 53 +- src/Streaming/Representations.php | 71 + src/Streaming/Stream.php | 62 +- src/Streaming/Utils.php | 41 + src/Streaming/Video.php | 30 - tests/Streaming/ArgumentsDashTest.php | 65 + tests/Streaming/ArgumentsHlsTest.php | 73 + tests/Streaming/CommandBuilderTest.php | 52 + tests/Streaming/FFmpegTest.php | 39 +- tests/Streaming/PackagingIntegrationTest.php | 145 + tests/Streaming/RepresentationTest.php | 46 + tests/Streaming/UtilsTest.php | 33 + 41 files changed, 4686 insertions(+), 634 deletions(-) create mode 100644 composer.lock create mode 100644 phpstan.neon create mode 100644 pint.json create mode 100644 src/Streaming/Arguments.php create mode 100644 src/Streaming/Arguments/Dash.php create mode 100644 src/Streaming/Arguments/Hls.php delete mode 100644 src/Streaming/Audio.php create mode 100644 src/Streaming/CommandBuilder.php delete mode 100644 src/Streaming/Encoder.php create mode 100644 src/Streaming/Exception/Exception.php create mode 100644 src/Streaming/Exception/InvalidArgumentException.php create mode 100644 src/Streaming/Exception/RuntimeException.php create mode 100644 src/Streaming/File.php create mode 100644 src/Streaming/Format.php delete mode 100644 src/Streaming/Format/Format.php create mode 100644 src/Streaming/Output/Dash.php create mode 100644 src/Streaming/Probe.php create mode 100644 src/Streaming/Representations.php create mode 100644 src/Streaming/Utils.php delete mode 100644 src/Streaming/Video.php create mode 100644 tests/Streaming/ArgumentsDashTest.php create mode 100644 tests/Streaming/ArgumentsHlsTest.php create mode 100644 tests/Streaming/CommandBuilderTest.php create mode 100644 tests/Streaming/PackagingIntegrationTest.php create mode 100644 tests/Streaming/RepresentationTest.php create mode 100644 tests/Streaming/UtilsTest.php diff --git a/.gitignore b/.gitignore index b0876df..f1e4ac2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /vendor/ .phpunit* -.idea \ No newline at end of file +.phpunit.cache +.idea +.DS_Store diff --git a/Dockerfile b/Dockerfile index d740fa5..44d6eb3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM composer:2.0 as step0 +FROM composer:2 AS step0 ARG TESTING=false ENV TESTING=$TESTING @@ -8,28 +8,23 @@ WORKDIR /usr/local/src/ COPY composer.lock /usr/local/src/ COPY composer.json /usr/local/src/ -#docker run -v "$(pwd)":/usr/src/code -it --net=host --rm google/shaka-packager +RUN composer install --ignore-platform-reqs --optimize-autoloader \ + --no-plugins --no-scripts --prefer-dist --no-dev -RUN composer update --ignore-platform-reqs --optimize-autoloader \ - --no-plugins --no-scripts --prefer-dist - -FROM php:8.1-cli-alpine as final +FROM php:8.1-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 update \ - && apk add --no-cache make automake autoconf gcc g++ git zlib-dev libmemcached-dev \ - ffmpeg \ +RUN apk add --no-cache ffmpeg \ && rm -rf /var/cache/apk/* WORKDIR /usr/src/code -# Add Source Code COPY ./ /usr/src/code - COPY --from=step0 /usr/local/src/vendor /usr/src/code/vendor -CMD [ "tail", "-f", "/dev/null" ] \ No newline at end of file +CMD ["tail", "-f", "/dev/null"] diff --git a/README.md b/README.md index e69de29..c3ec1fb 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,79 @@ +# Utopia Streaming + +FFmpeg-based ABR packaging library for HLS and DASH under the `Utopia\Streaming` namespace. + +## Requirements + +- PHP 8.1+ +- FFmpeg and FFProbe on `PATH` +- Composer + +## Install + +```bash +composer require shimonewman/streaming +``` + +## Usage + +```php +use Utopia\Streaming\Adapter\FFmpeg; +use Utopia\Streaming\Format\X264; +use Utopia\Streaming\Output\Hls; +use Utopia\Streaming\Representation; +use Utopia\Streaming\Stream; + +$stream = new Stream(new FFmpeg([ + 'timeout' => 0, + 'ffmpeg.threads' => 4, +])); + +$stream + ->open('/path/to/input.mp4') + ->setFormat(new X264()) + ->addRepresentations([ + (new Representation())->setResize(640, 360)->setKiloBitrate(276)->setAudioKiloBitrate(128), + (new Representation())->setResize(1280, 720)->setKiloBitrate(2048)->setAudioKiloBitrate(128), + ]) + ->setOutput(new Hls()) + ->save('/path/to/output/stream.m3u8'); +``` + +DASH: + +```php +use Utopia\Streaming\Output\Dash; + +$stream + ->open('/path/to/input.mp4') + ->setFormat(new X264()) + ->addRepresentations([/* ... */]) + ->setOutput( + (new Dash()) + ->setUseTimeline(1) + ->setUseTemplate(1) + ->setInitSegmentName(true) + ->setMediaSegmentName(true) + ) + ->save('/path/to/output/stream.mpd'); +``` + +## Development + +```bash +composer install +composer test +composer analyse +composer lint +``` + +Docker: + +```bash +docker compose up --build -d +docker compose exec php8 vendor/bin/phpunit +``` + +## License + +MIT diff --git a/composer.json b/composer.json index d809029..bf3dc00 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,8 @@ { "name": "shimonewman/streaming", - "description": "", + "description": "FFmpeg-based ABR packaging library for HLS and DASH", "type": "library", - "keywords": ["bla","bla"], + "keywords": ["hls", "dash", "ffmpeg", "streaming", "abr", "adaptive"], "license": "MIT", "minimum-stability": "stable", "authors": [ @@ -24,9 +24,7 @@ "lint": "vendor/bin/pint --test" }, "require": { - "php": ">=8.0", - "utopia-php/cli": "0.13.*", - "utopia-php/framework": "0.*.*", + "php": "^8.1", "php-ffmpeg/php-ffmpeg": "1.*.*" }, "require-dev": { diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..1c0db20 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2800 @@ +{ + "_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": "b9bd83dae0523cdf91751e715da47ab6", + "packages": [ + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "php-ffmpeg/php-ffmpeg", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/PHP-FFMpeg/PHP-FFMpeg.git", + "reference": "41fa00949ea2758cd34f076a8030df7198d57759" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-FFMpeg/PHP-FFMpeg/zipball/41fa00949ea2758cd34f076a8030df7198d57759", + "reference": "41fa00949ea2758cd34f076a8030df7198d57759", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0", + "php": "^8.0 || ^8.1 || ^8.2 || ^8.3 || ^8.4 || ^8.5", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "spatie/temporary-directory": "^2.0", + "symfony/cache": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "phpunit/phpunit": "^9.5.10 || ^10.0" + }, + "suggest": { + "php-ffmpeg/extras": "A compilation of common audio & video drivers for PHP-FFMpeg" + }, + "type": "library", + "autoload": { + "psr-4": { + "FFMpeg\\": "src/FFMpeg", + "Alchemy\\BinaryDriver\\": "src/Alchemy/BinaryDriver" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Romain Neutron", + "email": "imprec@gmail.com", + "homepage": "http://www.lickmychip.com/" + }, + { + "name": "Phraseanet Team", + "email": "info@alchemy.fr", + "homepage": "http://www.phraseanet.com/" + }, + { + "name": "Patrik Karisch", + "email": "patrik@karisch.guru", + "homepage": "http://www.karisch.guru" + }, + { + "name": "Romain Biard", + "email": "romain.biard@gmail.com", + "homepage": "https://www.strime.io/" + }, + { + "name": "Jens Hausdorf", + "email": "hello@jens-hausdorf.de", + "homepage": "https://jens-hausdorf.de" + }, + { + "name": "Pascal Baljet", + "email": "pascal@protone.media", + "homepage": "https://protone.media" + } + ], + "description": "FFMpeg PHP, an Object Oriented library to communicate with AVconv / ffmpeg", + "keywords": [ + "audio", + "audio processing", + "avconv", + "avprobe", + "ffmpeg", + "ffprobe", + "video", + "video processing" + ], + "support": { + "issues": "https://github.com/PHP-FFMpeg/PHP-FFMpeg/issues", + "source": "https://github.com/PHP-FFMpeg/PHP-FFMpeg/tree/v1.4.0" + }, + "time": "2026-01-19T21:15:14+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "spatie/temporary-directory", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/temporary-directory.git", + "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/32cbb9645b28839cf4f476708e99a2c70e6802c9", + "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\TemporaryDirectory\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily create, use and destroy temporary directories", + "homepage": "https://github.com/spatie/temporary-directory", + "keywords": [ + "php", + "spatie", + "temporary-directory" + ], + "support": { + "issues": "https://github.com/spatie/temporary-directory/issues", + "source": "https://github.com/spatie/temporary-directory/tree/2.4.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-06-22T07:55:44+00:00" + }, + { + "name": "symfony/cache", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "9adfcb2a7fc3924473b09f5a0b058dcc2cc7be9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/9adfcb2a7fc3924473b09f5a0b058dcc2cc7be9a", + "reference": "9adfcb2a7fc3924473b09f5a0b058dcc2cc7be9a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "ext-redis": "<6.1", + "ext-relay": "<0.12.1", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-17T14:44:48+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f5804be144caceb570f6747519999636b664f24c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T09:55:08+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "0118811b1d59f323bf131250b3fb919febfece28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/0118811b1d59f323bf131250b3fb919febfece28", + "reference": "0118811b1d59f323bf131250b3fb919febfece28", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T08:41:53+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": "v0.2.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "45c9fe899abfeeb7794c5a8c4074c140250a84c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/45c9fe899abfeeb7794c5a8c4074c140250a84c2", + "reference": "45c9fe899abfeeb7794c5a8c4074c140250a84c2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.8.0", + "illuminate/view": "^9.17.0", + "laravel-zero/framework": "^9.1.1", + "mockery/mockery": "^1.5.0", + "nunomaduro/larastan": "^2.1.11", + "nunomaduro/termwind": "^1.10.1", + "pestphp/pest": "^1.21.3" + }, + "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": "2022-07-13T17:57:52+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": "1.12.33", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", + "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "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-02-28T20:30:03+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": "4.8.8", + "source": { + "type": "git", + "url": "https://github.com/swoole/ide-helper.git", + "reference": "dd87843a5040831f9ad40b68fb57879b7342ef61" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swoole/ide-helper/zipball/dd87843a5040831f9ad40b68fb57879b7342ef61", + "reference": "dd87843a5040831f9ad40b68fb57879b7342ef61", + "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/4.8.8" + }, + "funding": [ + { + "url": "https://gitee.com/swoole/swoole?donate=true", + "type": "custom" + }, + { + "url": "https://github.com/swoole", + "type": "github" + } + ], + "time": "2022-03-17T18:24:39+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.1" + }, + "platform-dev": [], + "plugin-api-version": "2.2.0" +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..5021229 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 5 + paths: + - src + excludePaths: + - vendor diff --git a/phpunit.xml b/phpunit.xml index 3712938..9557619 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,3 +1,4 @@ + - ./tests/Streaming/FFmpegTest.php + ./tests/Streaming - \ No newline at end of file + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..93061b6 --- /dev/null +++ b/pint.json @@ -0,0 +1,3 @@ +{ + "preset": "laravel" +} diff --git a/src/Streaming/Adapter.php b/src/Streaming/Adapter.php index 230fb87..96ff1b6 100644 --- a/src/Streaming/Adapter.php +++ b/src/Streaming/Adapter.php @@ -1,50 +1,27 @@ $reps */ + public function addRepresentations(array $reps): self; + public function setOutput(Output $output): self; + public function save(?string $path = null): self; public function run(): void; -} \ No newline at end of file +} diff --git a/src/Streaming/Adapter/FFmpeg.php b/src/Streaming/Adapter/FFmpeg.php index fd6a417..2c7da57 100644 --- a/src/Streaming/Adapter/FFmpeg.php +++ b/src/Streaming/Adapter/FFmpeg.php @@ -1,110 +1,128 @@ */ + protected array $inputOptions = []; /** - * @param array $config - * @return void + * @param array $config */ public function __construct(array $config = []) { - $this->ffprobe = BFFProbe::create(); - $this->ffmpeg = BFFMpeg::create($config, null, $this->ffprobe); + $this->ffprobe = BaseFFProbe::create($config); + $this->ffmpeg = BaseFFmpeg::create($config, null, $this->ffprobe); + $this->reps = new Representations(); } - /** - * @param string $path - * @return self - */ public function open(string $path): self { - if (null === $streams = $this->ffprobe->streams($path)) { - throw new RuntimeException(sprintf('Unable to probe "%s".', $path)); + $streams = $this->ffprobe->streams($path); + + $hasVideo = count($streams->videos()) > 0; + $hasAudio = count($streams->audios()) > 0; + + if (! $hasVideo && ! $hasAudio) { + throw new InvalidArgumentException( + 'Unable to detect file format, only audio and video are supported' + ); + } + + $format = null; + try { + $format = $this->ffprobe->format($path); + } catch (\Throwable) { + // Duration may still be available on individual streams. } - if (0 < count($streams->videos())) { - $this->setVideo(new Video($streams)); + $this->probe = new Probe($streams, $format); - return $this; + try { + $this->media = $this->ffmpeg->open($path); + } catch (FFmpegExceptionInterface $e) { + throw new RuntimeException( + 'An error occurred while opening the file: '.$e->getMessage(), + (int) $e->getCode(), + $e + ); } - throw new InvalidArgumentException('Unable to detect file format'); + return $this; } - /** - * @param string $path - * @return bool - */ public function isValid(string $path): bool { try { - return $this->ffprobe->format($path)->get('duration') > 0; - } catch (\Exception $e) { + return (float) $this->ffprobe->format($path)->get('duration') > 0; + } catch (\Throwable) { return false; } } - /** - * @param Video $video - * @return self - */ - public function setVideo(Video $video): self + public function setFormat(Format $format): self { - $this->video = $video; + $this->format = $format; return $this; } - /** - * @param Format $format - * @return self - */ - public function setFormat(Format $format): self + public function addRepresentation(Representation $representation): self { - $this->format = $format; + $this->reps->add($representation); return $this; } /** - * @param Representation $representation - * @return self + * @param list $reps */ - public function addRepresentation(Representation $representation): self + public function addRepresentations(array $reps): self { - $this->representations[] = $representation; + foreach ($reps as $representation) { + $this->addRepresentation($representation); + } return $this; } - /** - * @param Output $output - * @return self - */ public function setOutput(Output $output): self { $this->output = $output; @@ -112,8 +130,127 @@ public function setOutput(Output $output): self return $this; } + /** + * @param array $inputOptions + */ + public function setInputOptions(array $inputOptions): self + { + $this->inputOptions = $inputOptions; + + return $this; + } + + public function save(?string $path = null): self + { + $this->assertReady(); + + /** @var Probe $probe */ + $probe = $this->probe; + /** @var Format $format */ + $format = $this->format; + /** @var Output $output */ + $output = $this->output; + /** @var AbstractMediaType $media */ + $media = $this->media; + + $output + ->setHasVideo($probe->hasVideo()) + ->setAudioTracks($probe->getAudioTracks()); + + $resolvedPath = $this->resolvePath($path); + $arguments = $this->createArguments($resolvedPath, $format, $output); + $outputPath = $arguments->getOutputPath(); + + File::makeDir(dirname($outputPath)); + + $commands = (new CommandBuilder($media, $format, $this->inputOptions)) + ->build($arguments->build(), $outputPath); + + $pass = (int) $format->getPasses(); + $listeners = $format->createProgressListener( + $media, + $media->getFFProbe(), + 1, + $pass + ); + + try { + $media->getFFMpegDriver()->command($commands, false, $listeners); + } catch (FFmpegExceptionInterface $e) { + throw new RuntimeException( + 'An error occurred while saving files: '.$e->getMessage(), + (int) $e->getCode(), + $e + ); + } + + return $this; + } + public function run(): void { - var_dump($this->output); + $this->save(null); + } + + public function getProbe(): ?Probe + { + return $this->probe; + } + + public function getRepresentations(): Representations + { + return $this->reps; + } + + private function assertReady(): void + { + if ($this->media === null || $this->probe === null) { + throw new InvalidArgumentException('Call open() before save()'); + } + + if ($this->format === null) { + throw new InvalidArgumentException('Call setFormat() before save()'); + } + + if ($this->output === null) { + throw new InvalidArgumentException('Call setOutput() before save()'); + } + + if ($this->reps->count() === 0) { + throw new InvalidArgumentException('Add at least one representation before save()'); + } + } + + private function resolvePath(?string $path): string + { + if ($path !== null) { + if (strlen($path) > PHP_MAXPATHLEN) { + throw new InvalidArgumentException('The path is too long'); + } + + File::makeDir(dirname($path)); + + return $path; + } + + /** @var AbstractMediaType $media */ + $media = $this->media; + + return $media->getPathfile(); + } + + private function createArguments(string $path, Format $format, Output $output): Arguments + { + if ($output instanceof HlsOutput) { + return new HlsArguments($path, $format, $output, $this->reps); + } + + if ($output instanceof DashOutput) { + return new DashArguments($path, $format, $output, $this->reps); + } + + throw new InvalidArgumentException( + 'Unsupported output type: '.get_class($output) + ); } } diff --git a/src/Streaming/Arguments.php b/src/Streaming/Arguments.php new file mode 100644 index 0000000..cd2626f --- /dev/null +++ b/src/Streaming/Arguments.php @@ -0,0 +1,76 @@ +dirname = pathinfo($normalized, PATHINFO_DIRNAME); + $this->filename = pathinfo($normalized, PATHINFO_FILENAME); + $this->format = $format; + $this->output = $output; + $this->reps = $reps; + } + + /** + * Flat argv fragment (without input options or final output path). + * + * @return list + */ + abstract public function build(): array; + + /** + * Path that becomes the ffmpeg output argument. + */ + abstract public function getOutputPath(): string; + + /** + * @return list + */ + protected function formatOptions(): array + { + $codecs = [ + 'c:a' => $this->format->getAudioCodec(), + ]; + + if ($this->output->hasVideo()) { + $codecs = [ + 'c:v' => $this->format->getVideoCodec(), + 'c:a' => $this->format->getAudioCodec(), + ]; + } + + $basic = Utils::arrayToFFmpegOpt($codecs); + $extra = Utils::arrayToFFmpegOpt($this->format->getAdditionalParameters() ?: []); + + return [...$basic, ...$extra]; + } + + protected function basePath(): string + { + return $this->dirname.'/'.$this->filename; + } +} diff --git a/src/Streaming/Arguments/Dash.php b/src/Streaming/Arguments/Dash.php new file mode 100644 index 0000000..6597c2d --- /dev/null +++ b/src/Streaming/Arguments/Dash.php @@ -0,0 +1,111 @@ +dash = $output; + } + + public function build(): array + { + if ($this->reps->count() === 0) { + throw new InvalidArgumentException('At least one representation is required'); + } + + return [ + ...$this->formatOptions(), + ...$this->initOptions(), + ...$this->streamOptions(), + '-strict', + $this->dash->getStrict(), + ]; + } + + public function getOutputPath(): string + { + return $this->basePath().'.mpd'; + } + + /** + * @return list + */ + private function initOptions(): array + { + $init = [ + 'use_timeline' => $this->dash->getUseTimeline(), + 'use_template' => $this->dash->getUseTemplate(), + 'seg_duration' => $this->dash->getSegmentDuration(), + 'hls_playlist' => (int) $this->dash->isGenerateHlsPlaylist(), + 'f' => 'dash', + ]; + + if ($this->dash->getInitSegmentName()) { + $init['init_seg_name'] = $this->filename.'_init_$RepresentationID$.$ext$'; + } + + if ($this->dash->getMediaSegmentName()) { + $init['media_seg_name'] = $this->filename.'_chunk_$RepresentationID$_$Number%05d$.$ext$'; + } + + $args = Utils::arrayToFFmpegOpt($init); + + if ($this->dash->getAdaption() !== '') { + $args[] = '-adaptation_sets'; + $args[] = $this->dash->getAdaption(); + } + + return [...$args, ...Utils::arrayToFFmpegOpt($this->dash->getAdditionalParams())]; + } + + /** + * @return list + */ + private function streamOptions(): array + { + $chunks = []; + + foreach ($this->reps as $key => $rep) { + /** @var Representation $rep */ + $opts = ['map' => '0']; + + if ($this->dash->hasVideo()) { + $opts['s:v:'.$key] = $rep->getResolution(); + $opts['b:v:'.$key] = $rep->getVideoKiloBitrate().'k'; + } + + $chunks[] = Utils::arrayToFFmpegOpt($opts); + + $audioBitrate = $rep->getAudioKiloBitrate(); + if ($audioBitrate > 0) { + $chunks[] = ['-b:a:'.$key, $audioBitrate.'k']; + } + } + + return $chunks === [] ? [] : array_merge(...$chunks); + } +} diff --git a/src/Streaming/Arguments/Hls.php b/src/Streaming/Arguments/Hls.php new file mode 100644 index 0000000..7cf7f46 --- /dev/null +++ b/src/Streaming/Arguments/Hls.php @@ -0,0 +1,208 @@ + */ + private array $maps = []; + + public function __construct( + string $path, + StreamingFormat $format, + Output $output, + Representations $reps + ) { + if (! $output instanceof HlsOutput) { + throw new InvalidArgumentException('Arguments\\Hls requires Output\\Hls'); + } + + parent::__construct($path, $format, $output, $reps); + + $this->hls = $output; + $this->preparePaths(); + $this->streamMap = $this->buildStreamMap(); + $this->maps = $this->buildMaps(); + } + + public function build(): array + { + if ($this->reps->count() === 0) { + throw new InvalidArgumentException('At least one representation is required'); + } + + $chunks = []; + $last = $this->reps->last(); + + foreach ($this->reps as $rep) { + $chunks[] = $this->formatOptions(); + $chunks[] = Utils::arrayToFFmpegOpt($this->representationOptions($rep)); + $chunks[] = $this->maps; + $chunks[] = Utils::arrayToFFmpegOpt($this->hls->getAdditionalParams()); + $chunks[] = ['-strict', $this->hls->getStrict()]; + + // Intermediate variant playlists are positional outputs; + // the last rung becomes the command's output argument. + if ($rep !== $last) { + $chunks[] = [$this->variantPlaylistPath($rep)]; + } + } + + return array_merge(...$chunks); + } + + public function getOutputPath(): string + { + $last = $this->reps->last(); + if ($last === null) { + throw new InvalidArgumentException('At least one representation is required'); + } + + return $this->variantPlaylistPath($last); + } + + private function preparePaths(): void + { + if ($this->hls->getSegmentSubDirectory() !== '') { + File::makeDir($this->dirname.'/'.$this->hls->getSegmentSubDirectory()); + } + + $this->segmentSubDir = Utils::appendSlash($this->hls->getSegmentSubDirectory()); + $this->segmentFilename = $this->dirname.'/'.$this->segmentSubDir.$this->filename; + $this->baseUrl = Utils::appendSlash($this->hls->getBaseUrl()).$this->segmentSubDir; + } + + /** + * @return array + */ + private function representationOptions(Representation $rep): array + { + $opts = [ + 'hls_list_size' => $this->hls->getPlaylistSize(), + 'hls_time' => $this->hls->getSegmentDuration(), + 'hls_allow_cache' => (int) $this->hls->allowsCache(), + 'hls_segment_type' => $this->hls->getSegmentType(), + 'hls_fmp4_init_filename' => $this->initFilename($rep), + 'hls_segment_filename' => $this->segmentFilename($rep), + 'master_pl_name' => $this->hls->getMasterPlaylistName(), + 'f' => 'hls', + 'var_stream_map' => $this->streamMap, + ]; + + if ($this->hls->hasVideo()) { + $opts['s:v:0'] = $rep->getResolution(); + $opts['b:v:0'] = $rep->getVideoKiloBitrate().'k'; + } + + foreach ($this->hls->getAudioTracks() as $i => $track) { + $bitrate = $rep->getAudioKiloBitrate(); + if ($bitrate > 0) { + $opts['b:a:'.$i] = $bitrate.'k'; + } + } + + if ($this->baseUrl !== '') { + $opts['hls_base_url'] = $this->baseUrl; + } + + if ($this->hls->getFlags() !== []) { + $opts['hls_flags'] = implode('+', $this->hls->getFlags()); + } + + return $opts; + } + + private function initFilename(Representation $rep): string + { + $height = $this->hls->hasVideo() ? $rep->getHeight().'p_' : ''; + + return $this->segmentSubDir.$this->filename.'_%v_'.$height.$this->hls->getInitFilename(); + } + + private function segmentFilename(Representation $rep): string + { + $ext = $this->hls->getSegmentType() === 'fmp4' ? 'm4s' : 'ts'; + $height = $this->hls->hasVideo() ? $rep->getHeight().'p_' : ''; + + return $this->segmentFilename.'_%v_'.$height.'%04d.'.$ext; + } + + private function variantPlaylistPath(Representation $rep): string + { + if ($this->hls->hasVideo()) { + return $this->basePath().'_%v_'.$rep->getHeight().'p.m3u8'; + } + + return $this->basePath().'_%v.m3u8'; + } + + private function buildStreamMap(): string + { + $parts = []; + + foreach ($this->hls->getAudioTracks() as $i => $track) { + $part = 'a:'.$i.',agroup:audio,language:'.$track['language']; + if ($i === 0) { + $part .= ',default:yes'; + } + $parts[] = $part; + } + + if ($this->hls->hasVideo()) { + $parts[] = $this->hls->getAudioTracks() !== [] + ? 'v:0,agroup:audio' + : 'v:0'; + } + + return implode(' ', $parts); + } + + /** + * @return list + */ + private function buildMaps(): array + { + $maps = []; + + if ($this->hls->hasVideo()) { + $maps[] = '-map'; + $maps[] = '0:v:0'; + } + + foreach ($this->hls->getAudioTracks() as $i => $track) { + $maps[] = '-map'; + $maps[] = '0:a:'.$i; + } + + // No named tracks — still map all audio if present so encode doesn't drop it. + if ($this->hls->getAudioTracks() === [] && ! $this->hls->hasVideo()) { + $maps[] = '-map'; + $maps[] = '0:a:0'; + } + + return $maps; + } +} diff --git a/src/Streaming/Audio.php b/src/Streaming/Audio.php deleted file mode 100644 index d4e16db..0000000 --- a/src/Streaming/Audio.php +++ /dev/null @@ -1,29 +0,0 @@ -audioStreams = $streams; - - foreach ($this->audioStreams->getIterator() ?? [] as $stream) { - if (! empty($stream->get('tags')['language'])) { - $this->audioTracks[] = $stream->get('tags')['language']; - } - } - - return $this; - } -} diff --git a/src/Streaming/CommandBuilder.php b/src/Streaming/CommandBuilder.php new file mode 100644 index 0000000..2eab281 --- /dev/null +++ b/src/Streaming/CommandBuilder.php @@ -0,0 +1,59 @@ + */ + private array $inputOptions; + + /** + * @param array $inputOptions + */ + public function __construct( + AbstractMediaType $media, + Format $format, + array $inputOptions = [] + ) { + $this->media = $media; + $this->format = $format; + $this->inputOptions = $inputOptions; + } + + /** + * @param list $argumentGroups + * @return list + */ + public function build(array $argumentGroups, string $outputPath): array + { + $commands = [ + ...Utils::arrayToFFmpegOpt($this->inputOptions), + ...($this->format->getInitialParameters() ?: []), + '-y', + '-i', + $this->media->getPathfile(), + ...$argumentGroups, + ]; + + $driver = $this->media->getFFMpegDriver(); + if ($driver->getConfiguration()->has('ffmpeg.threads')) { + $commands[] = '-threads'; + $commands[] = (string) $driver->getConfiguration()->get('ffmpeg.threads'); + } + + $commands[] = $outputPath; + + return $commands; + } +} diff --git a/src/Streaming/Encoder.php b/src/Streaming/Encoder.php deleted file mode 100644 index 0dd91ed..0000000 --- a/src/Streaming/Encoder.php +++ /dev/null @@ -1,89 +0,0 @@ -adapter = $adapter; - } - - /** - * @param string $path - * @return Adapter - */ - public function open(string $path): Adapter - { - return $this->adapter->open($path); - } - - /** - * @param string $path - * @return bool - */ - public function isValid(string $path): bool - { - return $this->adapter->isValid($path); - } - - - /** - * @param Format $format - * @return Encoder - */ - public function setFormat(Format $format): Encoder - { - $this->adapter->setFormat($format); - - return $this; - } - - /** - * @param Representation $representation - * @return Encoder - */ - public function addRepresentation(Representation $representation): Encoder - { - $this->adapter->addRepresentation($representation); - - return $this; - } - - /** - * @param $representations Representation[] - * @return Encoder - */ - public function addRepresentations(array $representations): Encoder - { - foreach ($representations as $representation) { - $this->adapter->addRepresentation($representation); - } - - return $this; - } - - /** - * @param Output $output - * @return Encoder - */ - public function setOutput(Output $output): Encoder - { - $this->adapter->setOutput($output); - - return $this; - } - - public function run(): void - { - $this->adapter->run(); - } -} diff --git a/src/Streaming/Exception/Exception.php b/src/Streaming/Exception/Exception.php new file mode 100644 index 0000000..d8c76ce --- /dev/null +++ b/src/Streaming/Exception/Exception.php @@ -0,0 +1,9 @@ +audioCodec; - } - - /** - * Sets the audio codec, Should be in the available ones, otherwise an - * exception is thrown. - * - * @param string $audioCodec - * - * @throws InvalidArgumentException - */ - public function setAudioCodec(string $audioCodec): Format - { - if (! in_array($audioCodec, $this->getAvailableAudioCodecs())) { - throw new InvalidArgumentException(sprintf( - 'Wrong audiocodec value for %s, available formats are %s', $audioCodec, implode(', ', $this->getAvailableAudioCodecs()) - )); - } - - $this->audioCodec = $audioCodec; - - return $this; - } - - /** - * @Return string - */ - public function getVideoCodec(): string - { - return $this->videoCodec; - } - - /** - * Sets the video codec, Should be in the available ones, otherwise an - * exception is thrown. - * - * @param string $videoCodec - * - * @throws InvalidArgumentException - */ - public function setVideoCodec(string $videoCodec) - { - if (! in_array($videoCodec, $this->getAvailableVideoCodecs())) { - throw new InvalidArgumentException(sprintf( - 'Wrong videocodec value for %s, available formats are %s', $videoCodec, implode(', ', $this->getAvailableVideoCodecs()) - )); - } - - $this->videoCodec = $videoCodec; - - return $this; - } - - /** - * @param array $additionalParameters - * @return Format - */ - public function setAdditionalParameters(array $additionalParameters): Format - { - $this->additionalParameters = $additionalParameters; - - return $this; - } - - /** - * @return int - */ - public function getModulus(): int - { - return $this->modulus; - } -} diff --git a/src/Streaming/Format/HEVC.php b/src/Streaming/Format/HEVC.php index f8c0951..ac05308 100644 --- a/src/Streaming/Format/HEVC.php +++ b/src/Streaming/Format/HEVC.php @@ -1,27 +1,29 @@ setVideoCodec($video_codec) - ->setAudioCodec($audio_codec); - - /** - * set the default value of h265 codec options - * see https://ffmpeg.org/ffmpeg-codecs.html#Options-29 for more information about options - */ - if ($default_init_opts) { + ->setVideoCodec($videoCodec) + ->setAudioCodec($audioCodec); + + if ($defaultInitOpts) { $this->setAdditionalParameters([ 'keyint_min' => 25, 'g' => 250, @@ -31,30 +33,26 @@ public function __construct(string $video_codec = 'libx265', string $audio_codec } /** - * Returns the list of available audio codecs for this format. - * - * @return array + * @return list */ - public function getAvailableAudioCodecs() + public function getAvailableAudioCodecs(): array { - return ['aac', 'libvo_aacenc', 'libfaac', 'libmp3lame', 'libfdk_aac']; + return self::AUDIO_CODECS; } /** - * @return array + * @return list */ public function getAvailableVideoCodecs(): array { - return ['libx265', 'h265', 'hevc_nvenc']; + return self::VIDEO_CODECS; + } + + public function getModulus(): int + { + return self::MODULUS; } - /** - * Returns true if the current format supports B-Frames. - * - * @see https://wikipedia.org/wiki/Video_compression_picture_types - * - * @return bool - */ public function supportBFrames(): bool { return true; diff --git a/src/Streaming/Format/VP9.php b/src/Streaming/Format/VP9.php index c23338e..9ed5e30 100644 --- a/src/Streaming/Format/VP9.php +++ b/src/Streaming/Format/VP9.php @@ -1,54 +1,64 @@ setVideoCodec($video_codec) - ->setAudioCodec($audio_codec); - - /** - * set the default value of h265 codec options - * see https://ffmpeg.org/ffmpeg-codecs.html#Options-26 for more information about options - */ - if ($default_init_opts) { - //@TODO: add default vp9 + ->setVideoCodec($videoCodec) + ->setAudioCodec($audioCodec); + + if ($defaultInitOpts) { + $this->setAdditionalParameters([ + 'deadline' => 'good', + 'cpu-used' => 2, + 'row-mt' => 1, + 'tile-columns' => 2, + 'frame-parallel' => 1, + 'g' => 250, + 'keyint_min' => 25, + ]); } } /** - * {@inheritDoc} + * @return list */ - public function getAvailableAudioCodecs() + public function getAvailableAudioCodecs(): array { - return ['libvorbis']; + return self::AUDIO_CODECS; } /** - * @return array + * @return list */ public function getAvailableVideoCodecs(): array { - return ['libvpx', 'libvpx-vp9']; + return self::VIDEO_CODECS; + } + + public function getModulus(): int + { + return self::MODULUS; } - /** - * Returns true if the current format supports B-Frames. - * - * @see https://wikipedia.org/wiki/Video_compression_picture_types - * - * @return bool - */ public function supportBFrames(): bool { - return true; + return false; } } diff --git a/src/Streaming/Format/X264.php b/src/Streaming/Format/X264.php index c7558b0..204937d 100644 --- a/src/Streaming/Format/X264.php +++ b/src/Streaming/Format/X264.php @@ -1,28 +1,29 @@ setVideoCodec($video_codec) - ->setAudioCodec($audio_codec); - - /** - * set the default value of h264 codec options - * see https://ffmpeg.org/ffmpeg-codecs.html#Options-28 for more information about options - * return array - */ - if ($default_init_opts) { + ->setVideoCodec($videoCodec) + ->setAudioCodec($audioCodec); + + if ($defaultInitOpts) { $this->setAdditionalParameters([ 'bf' => 1, 'keyint_min' => 25, @@ -33,29 +34,27 @@ public function __construct($video_codec = 'libx264', string $audio_codec = 'aac } /** - * @return array + * @return list */ - public function getAvailableAudioCodecs() + public function getAvailableAudioCodecs(): array { - return ['aac', 'libvo_aacenc', 'libfaac', 'libmp3lame', 'libfdk_aac']; + return self::AUDIO_CODECS; } /** - * @return array + * @return list */ - public function getAvailableVideoCodecs() + public function getAvailableVideoCodecs(): array { - return ['libx264', 'h264', 'h264_afm', 'h264_nvenc']; + return self::VIDEO_CODECS; } - /** - * Returns true if the current format supports B-Frames. - * - * @see https://wikipedia.org/wiki/Video_compression_picture_types - * - * @return bool - */ - public function supportBFrames() + public function getModulus(): int + { + return self::MODULUS; + } + + public function supportBFrames(): bool { return true; } diff --git a/src/Streaming/Output.php b/src/Streaming/Output.php index 14c925b..16a135e 100644 --- a/src/Streaming/Output.php +++ b/src/Streaming/Output.php @@ -1,27 +1,80 @@ */ + private array $additionalParams = []; + + private bool $hasVideo = false; + /** - * @param string $strict - * @return output + * @var list */ - public function setStrict(string $strict): output + private array $audioTracks = []; + + public function setStrict(string $strict): self { $this->strict = $strict; return $this; } - /** - * @return string - */ public function getStrict(): string { return $this->strict; } + + /** + * @param array $additionalParams + */ + public function setAdditionalParams(array $additionalParams): self + { + $this->additionalParams = $additionalParams; + + return $this; + } + + /** + * @return array + */ + public function getAdditionalParams(): array + { + return $this->additionalParams; + } + + public function setHasVideo(bool $hasVideo): self + { + $this->hasVideo = $hasVideo; + + return $this; + } + + public function hasVideo(): bool + { + return $this->hasVideo; + } + + /** + * @param list $audioTracks + */ + public function setAudioTracks(array $audioTracks): self + { + $this->audioTracks = $audioTracks; + + return $this; + } + + /** + * @return list + */ + public function getAudioTracks(): array + { + return $this->audioTracks; + } } diff --git a/src/Streaming/Output/Dash.php b/src/Streaming/Output/Dash.php new file mode 100644 index 0000000..a739719 --- /dev/null +++ b/src/Streaming/Output/Dash.php @@ -0,0 +1,108 @@ +adaption = $adaption; + + return $this; + } + + public function getAdaption(): string + { + return $this->adaption; + } + + public function setSegmentDuration(int $segmentDuration): self + { + $this->segmentDuration = $segmentDuration; + + return $this; + } + + public function getSegmentDuration(): int + { + return $this->segmentDuration; + } + + public function generateHlsPlaylist(bool $generateHlsPlaylist = true): self + { + $this->generateHlsPlaylist = $generateHlsPlaylist; + + return $this; + } + + public function isGenerateHlsPlaylist(): bool + { + return $this->generateHlsPlaylist; + } + + public function setUseTimeline(int $useTimeline): self + { + $this->useTimeline = $useTimeline; + + return $this; + } + + public function getUseTimeline(): int + { + return $this->useTimeline; + } + + public function setUseTemplate(int $useTemplate): self + { + $this->useTemplate = $useTemplate; + + return $this; + } + + public function getUseTemplate(): int + { + return $this->useTemplate; + } + + public function setInitSegmentName(bool $initSegmentName): self + { + $this->initSegmentName = $initSegmentName; + + return $this; + } + + public function getInitSegmentName(): bool + { + return $this->initSegmentName; + } + + public function setMediaSegmentName(bool $mediaSegmentName): self + { + $this->mediaSegmentName = $mediaSegmentName; + + return $this; + } + + public function getMediaSegmentName(): bool + { + return $this->mediaSegmentName; + } +} diff --git a/src/Streaming/Output/Hls.php b/src/Streaming/Output/Hls.php index 873eb60..16e0a9a 100644 --- a/src/Streaming/Output/Hls.php +++ b/src/Streaming/Output/Hls.php @@ -1,200 +1,145 @@ */ private array $flags = []; - /** - * @return string - */ - public function getSegSubDirectory(): string + public function setSegmentDuration(int $segmentDuration): self { - return $this->seg_sub_directory; + $this->segmentDuration = $segmentDuration; + + return $this; } - /** - * @param string $seg_sub_directory - * @return Hls - */ - public function setSegSubDirectory(string $seg_sub_directory): Hls + public function getSegmentDuration(): int { - $this->seg_sub_directory = $seg_sub_directory; - - return $this; + return $this->segmentDuration; } - /** - * @param string $hls_time - * @return HLS - */ - public function setHlsTime(string $hls_time): HLS + public function setAllowCache(bool $allowCache): self { - $this->hls_time = $hls_time; + $this->allowCache = $allowCache; return $this; } - /** - * @return string - */ - public function getHlsTime(): string + public function allowsCache(): bool { - return $this->hls_time; + return $this->allowCache; } - /** - * @param bool $hls_allow_cache - * @return HLS - */ - public function setHlsAllowCache(bool $hls_allow_cache): HLS + public function setSegmentSubDirectory(string $segmentSubDirectory): self { - $this->hls_allow_cache = $hls_allow_cache; + $this->segmentSubDirectory = $segmentSubDirectory; return $this; } - /** - * @return bool - */ - public function isHlsAllowCache(): bool + public function getSegmentSubDirectory(): string { - return $this->hls_allow_cache; + return $this->segmentSubDirectory; } - /** - * @param string $hls_base_url - * @return HLS - */ - public function setHlsBaseUrl(string $hls_base_url): HLS + public function setBaseUrl(string $baseUrl): self { - $this->hls_base_url = $hls_base_url; + $this->baseUrl = $baseUrl; return $this; } - /** - * @return string - */ - public function getHlsBaseUrl(): string + public function getBaseUrl(): string { - return $this->hls_base_url; + return $this->baseUrl; } - /** - * @param int $hls_list_size - * @return HLS - */ - public function setHlsListSize(int $hls_list_size): HLS + public function setPlaylistSize(int $playlistSize): self { - $this->hls_list_size = $hls_list_size; + $this->playlistSize = $playlistSize; return $this; } - /** - * @return int - */ - public function getHlsListSize(): int + public function getPlaylistSize(): int { - return $this->hls_list_size; + return $this->playlistSize; } - /** - * @param string $master_playlist - * @param array $stream_des - * @return HLS - */ - public function setMasterPlaylist(string $master_playlist, array $stream_des = []): HLS + public function setSegmentType(string $segmentType): self { - $this->master_playlist = $master_playlist; - $this->stream_des = $stream_des; + $this->segmentType = $segmentType; return $this; } - /** - * @return HLS - */ - public function fragmentedMP4(): HLS + public function getSegmentType(): string { - $this->setHlsSegmentType('fmp4'); + return $this->segmentType; + } - return $this; + public function fragmentedMp4(): self + { + return $this->setSegmentType('fmp4'); } - /** - * @param string $hls_segment_type - * @return HLS - */ - public function setHlsSegmentType(string $hls_segment_type): HLS + public function setInitFilename(string $initFilename): self { - $this->hls_segment_type = $hls_segment_type; + $this->initFilename = $initFilename; return $this; } - /** - * @return string - */ - public function getHlsSegmentType(): string + public function getInitFilename(): string { - return $this->hls_segment_type; + return $this->initFilename; } - /** - * @param string $hls_fmp4_init_filename - * @return HLS - */ - public function setHlsFmp4InitFilename(string $hls_fmp4_init_filename): HLS + public function setMasterPlaylistName(string $masterPlaylistName): self { - $this->hls_fmp4_init_filename = $hls_fmp4_init_filename; + $this->masterPlaylistName = $masterPlaylistName; return $this; } - /** - * @return string - */ - public function getHlsFmp4InitFilename(): string + public function getMasterPlaylistName(): string { - return $this->hls_fmp4_init_filename; + return $this->masterPlaylistName; } /** - * @param array $flags - * @return HLS + * @param list $flags */ - public function setFlags(array $flags): HLS + public function setFlags(array $flags): self { - $this->flags = array_merge($this->flags, $flags); + $this->flags = array_values(array_merge($this->flags, $flags)); return $this; } /** - * @return array + * @return list */ public function getFlags(): array { diff --git a/src/Streaming/Probe.php b/src/Streaming/Probe.php new file mode 100644 index 0000000..90ba078 --- /dev/null +++ b/src/Streaming/Probe.php @@ -0,0 +1,101 @@ + */ + private array $audioTracks; + + private float $duration; + + public function __construct(StreamCollection $streams, ?ProbeFormat $format = null) + { + $this->streams = $streams; + $this->hasVideo = count($streams->videos()) > 0; + $this->audioTracks = $this->collectAudioTracks($streams); + $this->duration = $this->resolveDuration($streams, $format); + } + + public function getStreams(): StreamCollection + { + return $this->streams; + } + + public function hasVideo(): bool + { + return $this->hasVideo; + } + + public function hasAudio(): bool + { + return $this->audioTracks !== [] || count($this->streams->audios()) > 0; + } + + /** + * @return list + */ + public function getAudioTracks(): array + { + return $this->audioTracks; + } + + public function getDuration(): float + { + return $this->duration; + } + + /** + * @return list + */ + private function collectAudioTracks(StreamCollection $streams): array + { + $tracks = []; + + foreach ($streams->audios() as $stream) { + $tags = $stream->get('tags') ?? []; + $language = is_array($tags) && ! empty($tags['language']) + ? (string) $tags['language'] + : 'und'; + + $tracks[] = [ + 'codec' => (string) ($stream->get('codec_name') ?? 'aac'), + 'language' => $language, + ]; + } + + return $tracks; + } + + private function resolveDuration(StreamCollection $streams, ?ProbeFormat $format): float + { + if ($format !== null) { + $duration = $format->get('duration'); + if (is_numeric($duration)) { + return (float) $duration; + } + } + + foreach ($streams as $stream) { + $duration = $stream->get('duration'); + if (is_numeric($duration)) { + return (float) $duration; + } + } + + return 0.0; + } +} diff --git a/src/Streaming/Representation.php b/src/Streaming/Representation.php index a9973d8..aa9e30b 100644 --- a/src/Streaming/Representation.php +++ b/src/Streaming/Representation.php @@ -1,15 +1,13 @@ width; } - /** - * @return int - */ public function getHeight(): int { return $this->height; } /** - * @return int + * Resolution as an ffmpeg size string, e.g. "1280x720". */ - public function getSize(): int + public function getResolution(): string { - return implode('x', [$this->getWidth(), $this->getHeight()]); + return $this->width.'x'.$this->height; } - /** - * @param int $videoKiloBitrate - * @return Representation - */ - public function setVideoKiloBitrate(int $videoKiloBitrate): Representation + public function setVideoKiloBitrate(int $videoKiloBitrate): self { if ($videoKiloBitrate < 1) { throw new InvalidArgumentException('Invalid video kilo bit rate value'); @@ -75,18 +58,27 @@ public function setVideoKiloBitrate(int $videoKiloBitrate): Representation } /** - * @return int + * Alias matching the fork's Representation::setKiloBitrate(). */ + public function setKiloBitrate(int $kiloBitrate): self + { + return $this->setVideoKiloBitrate($kiloBitrate); + } + public function getVideoKiloBitrate(): int { return $this->videoKiloBitrate; } /** - * @param int $audioKiloBitrate - * @return Representation + * Alias matching the fork's Representation::getKiloBitrate(). */ - public function setAudioKiloBitrate(int $audioKiloBitrate): Representation + public function getKiloBitrate(): int + { + return $this->videoKiloBitrate; + } + + public function setAudioKiloBitrate(int $audioKiloBitrate): self { if ($audioKiloBitrate < 1) { throw new InvalidArgumentException('Invalid audio kilo bit rate value'); @@ -97,9 +89,6 @@ public function setAudioKiloBitrate(int $audioKiloBitrate): Representation return $this; } - /** - * @return int - */ public function getAudioKiloBitrate(): int { return $this->audioKiloBitrate; diff --git a/src/Streaming/Representations.php b/src/Streaming/Representations.php new file mode 100644 index 0000000..0d534f4 --- /dev/null +++ b/src/Streaming/Representations.php @@ -0,0 +1,71 @@ + + */ +class Representations implements Countable, IteratorAggregate +{ + /** @var list */ + private array $items = []; + + /** + * @param list $reps + */ + public function __construct(array $reps = []) + { + foreach ($reps as $representation) { + $this->add($representation); + } + } + + public function add(Representation $representation): self + { + $this->items[] = $representation; + + return $this; + } + + public function first(): ?Representation + { + return $this->items[0] ?? null; + } + + public function last(): ?Representation + { + if ($this->items === []) { + return null; + } + + return $this->items[array_key_last($this->items)]; + } + + /** + * @return list + */ + public function all(): array + { + return $this->items; + } + + public function count(): int + { + return count($this->items); + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->items); + } +} diff --git a/src/Streaming/Stream.php b/src/Streaming/Stream.php index d2e6939..7422ed7 100644 --- a/src/Streaming/Stream.php +++ b/src/Streaming/Stream.php @@ -1,69 +1,75 @@ encoder = $encoder; + $this->adapter = $adapter; } - /** - * @param string $path - * @return self - */ public function open(string $path): self { - $this->encoder->open($path); + $this->adapter->open($path); return $this; } - /** - * @param Format $format - * @return self - */ + public function isValid(string $path): bool + { + return $this->adapter->isValid($path); + } + public function setFormat(Format $format): self { - $this->encoder->setFormat($format); + $this->adapter->setFormat($format); return $this; } - /** - * @param Representation $representation - * @return self - */ public function addRepresentation(Representation $representation): self { - $this->encoder->addRepresentation($representation); + $this->adapter->addRepresentation($representation); return $this; } /** - * @param Output $output - * @return self + * @param list $reps */ + public function addRepresentations(array $reps): self + { + $this->adapter->addRepresentations($reps); + + return $this; + } + public function setOutput(Output $output): self { - $this->encoder->setOutput($output); + $this->adapter->setOutput($output); return $this; } + public function save(?string $path = null): self + { + $this->adapter->save($path); + + return $this; + } public function run(): void { - $this->encoder->run(); + $this->adapter->run(); + } + + public function getAdapter(): Adapter + { + return $this->adapter; } } diff --git a/src/Streaming/Utils.php b/src/Streaming/Utils.php new file mode 100644 index 0000000..f8c00c5 --- /dev/null +++ b/src/Streaming/Utils.php @@ -0,0 +1,41 @@ + '750k', 's:v' => '640x360'] → ['-b:v', '750k', '-s:v', '640x360'] + * + * If any key is not a string the original array is returned unchanged + * (already a flat argv list). + * + * @param array $array + * @return list + */ + public static function arrayToFFmpegOpt(array $array, string $startWith = '-'): array + { + $out = []; + + foreach ($array as $key => $value) { + if (! is_string($key)) { + /** @var list $array */ + return array_map('strval', $array); + } + + $out[] = $startWith.$key; + $out[] = (string) $value; + } + + return $out; + } +} diff --git a/src/Streaming/Video.php b/src/Streaming/Video.php deleted file mode 100644 index 8868705..0000000 --- a/src/Streaming/Video.php +++ /dev/null @@ -1,30 +0,0 @@ -setVideoStreams($streams->videos()); - $this->setAudioStreams($streams->audios()); - } - - /** - * @param $streams - * @return Video - */ - public function setVideoStreams($streams): Video - { - $this->videoStreams = $streams; - - return $this; - } -} diff --git a/tests/Streaming/ArgumentsDashTest.php b/tests/Streaming/ArgumentsDashTest.php new file mode 100644 index 0000000..bf6d77e --- /dev/null +++ b/tests/Streaming/ArgumentsDashTest.php @@ -0,0 +1,65 @@ +setResize(640, 360)->setKiloBitrate(276)->setAudioKiloBitrate(128), + (new Representation())->setResize(1280, 720)->setKiloBitrate(2048)->setAudioKiloBitrate(128), + (new Representation())->setResize(1920, 1080)->setKiloBitrate(4096)->setAudioKiloBitrate(192), + ]); + + $output = (new Dash()) + ->setHasVideo(true) + ->setSegmentDuration(4) + ->setUseTimeline(1) + ->setUseTemplate(1) + ->setInitSegmentName(true) + ->setMediaSegmentName(true); + + $args = new DashArguments('/tmp/pack/video.mp4', new X264(), $output, $reps); + $joined = implode(' ', $args->build()); + + $this->assertStringContainsString('-f dash', $joined); + $this->assertStringContainsString('-seg_duration 4', $joined); + $this->assertStringContainsString('-use_timeline 1', $joined); + $this->assertStringContainsString('-use_template 1', $joined); + $this->assertStringContainsString('-init_seg_name video_init_$RepresentationID$.$ext$', $joined); + $this->assertStringContainsString('-media_seg_name video_chunk_$RepresentationID$_$Number%05d$.$ext$', $joined); + $this->assertStringContainsString('-s:v:0 640x360', $joined); + $this->assertStringContainsString('-b:v:0 276k', $joined); + $this->assertStringContainsString('-s:v:1 1280x720', $joined); + $this->assertStringContainsString('-b:v:2 4096k', $joined); + $this->assertStringContainsString('-b:a:0 128k', $joined); + $this->assertStringContainsString('-b:a:2 192k', $joined); + $this->assertSame('/tmp/pack/video.mpd', $args->getOutputPath()); + } + + public function testAudioOnlyOmitsVideoSizing(): void + { + $reps = new Representations([ + (new Representation())->setResize(640, 360)->setKiloBitrate(128)->setAudioKiloBitrate(96), + ]); + + $output = (new Dash())->setHasVideo(false); + $args = new DashArguments('/tmp/pack/audio.mp4', new X264(), $output, $reps); + $joined = implode(' ', $args->build()); + + $this->assertStringNotContainsString('-s:v:0', $joined); + $this->assertStringNotContainsString('-b:v:0', $joined); + $this->assertStringContainsString('-map 0', $joined); + $this->assertStringContainsString('-b:a:0 96k', $joined); + } +} diff --git a/tests/Streaming/ArgumentsHlsTest.php b/tests/Streaming/ArgumentsHlsTest.php new file mode 100644 index 0000000..f54be8b --- /dev/null +++ b/tests/Streaming/ArgumentsHlsTest.php @@ -0,0 +1,73 @@ +setResize(640, 360)->setKiloBitrate(276)->setAudioKiloBitrate(128), + (new Representation())->setResize(1280, 720)->setKiloBitrate(2048)->setAudioKiloBitrate(128), + ]); + + $output = (new Hls()) + ->setHasVideo(true) + ->setAudioTracks([ + ['codec' => 'aac', 'language' => 'eng'], + ['codec' => 'aac', 'language' => 'spa'], + ]) + ->setSegmentDuration(6); + + $args = new HlsArguments('/tmp/pack/video.mp4', new X264(), $output, $reps); + $argv = $args->build(); + $joined = implode(' ', $argv); + + $this->assertStringContainsString('-var_stream_map a:0,agroup:audio,language:eng,default:yes a:1,agroup:audio,language:spa v:0,agroup:audio', $joined); + $this->assertStringContainsString('-master_pl_name master.m3u8', $joined); + $this->assertStringContainsString('-s:v:0 640x360', $joined); + $this->assertStringContainsString('-b:v:0 276k', $joined); + $this->assertStringContainsString('-s:v:0 1280x720', $joined); + $this->assertStringContainsString('-b:v:0 2048k', $joined); + $this->assertStringContainsString('-map 0:v:0', $joined); + $this->assertStringContainsString('-map 0:a:0', $joined); + $this->assertStringContainsString('-map 0:a:1', $joined); + $this->assertStringContainsString('-hls_time 6', $joined); + $this->assertStringContainsString('/tmp/pack/video_%v_360p.m3u8', $joined); + $this->assertSame('/tmp/pack/video_%v_720p.m3u8', $args->getOutputPath()); + $this->assertStringContainsString('_%v_', $args->getOutputPath()); + } + + public function testAudioOnlyOmitsVideoSizing(): void + { + $reps = new Representations([ + (new Representation())->setResize(640, 360)->setKiloBitrate(128)->setAudioKiloBitrate(128), + ]); + + $output = (new Hls()) + ->setHasVideo(false) + ->setAudioTracks([ + ['codec' => 'aac', 'language' => 'eng'], + ]); + + $args = new HlsArguments('/tmp/pack/audio.mp4', new X264(), $output, $reps); + $joined = implode(' ', $args->build()); + + $this->assertStringNotContainsString('-s:v:0', $joined); + $this->assertStringNotContainsString('-b:v:0', $joined); + $this->assertStringNotContainsString('-c:v', $joined); + $this->assertStringNotContainsString('-map 0:v:0', $joined); + $this->assertStringContainsString('-map 0:a:0', $joined); + $this->assertStringContainsString('-var_stream_map a:0,agroup:audio,language:eng,default:yes', $joined); + $this->assertSame('/tmp/pack/audio_%v.m3u8', $args->getOutputPath()); + } +} diff --git a/tests/Streaming/CommandBuilderTest.php b/tests/Streaming/CommandBuilderTest.php new file mode 100644 index 0000000..e560d29 --- /dev/null +++ b/tests/Streaming/CommandBuilderTest.php @@ -0,0 +1,52 @@ +createMock(FFMpegDriver::class); + $driver->method('getConfiguration')->willReturn(new Configuration([ + 'ffmpeg.threads' => 4, + ])); + + $ffprobe = $this->createMock(FFProbe::class); + + $media = $this->getMockBuilder(AbstractMediaType::class) + ->disableOriginalConstructor() + ->onlyMethods(['getPathfile', 'getFFMpegDriver', 'getFFProbe', 'filters']) + ->getMock(); + $media->method('getPathfile')->willReturn('/input/sample.mp4'); + $media->method('getFFMpegDriver')->willReturn($driver); + $media->method('getFFProbe')->willReturn($ffprobe); + + $commands = (new CommandBuilder($media, new X264(), ['ss' => '0'])) + ->build(['-c:v', 'libx264', '-f', 'hls'], '/out/video_%v_720p.m3u8'); + + $this->assertSame([ + '-ss', + '0', + '-y', + '-i', + '/input/sample.mp4', + '-c:v', + 'libx264', + '-f', + 'hls', + '-threads', + '4', + '/out/video_%v_720p.m3u8', + ], $commands); + } +} diff --git a/tests/Streaming/FFmpegTest.php b/tests/Streaming/FFmpegTest.php index 7696784..fafd023 100644 --- a/tests/Streaming/FFmpegTest.php +++ b/tests/Streaming/FFmpegTest.php @@ -1,41 +1,34 @@ 0, - 'ffmpeg.threads' => 12, - ] - )); - } - - public function testFFmpeg(): void + public function testFluentApiWiresAdapter(): void { + $adapter = new FFmpeg(['timeout' => 0]); + $stream = new Stream($adapter); - $representation = (new \Utopia\Streaming\Representation()) - ->setVideoKiloBitrate(6000) + $representation = (new Representation()) + ->setVideoKiloBitrate(600) ->setAudioKiloBitrate(128) - ->setResize(1080, 768); + ->setResize(640, 360); - (new Stream(self::$encoder)) - ->open(__DIR__.'/../resources/sample.mp4') - ->setFormat(new \Utopia\Streaming\Format\X264()) + $stream + ->setFormat(new X264()) ->addRepresentation($representation) - ->setOutput(new \Utopia\Streaming\Output\Hls()) - ->run() - ; - + ->setOutput(new Hls()); + $this->assertSame($adapter, $stream->getAdapter()); + $this->assertCount(1, $adapter->getRepresentations()); } } diff --git a/tests/Streaming/PackagingIntegrationTest.php b/tests/Streaming/PackagingIntegrationTest.php new file mode 100644 index 0000000..addabf2 --- /dev/null +++ b/tests/Streaming/PackagingIntegrationTest.php @@ -0,0 +1,145 @@ +/dev/null', + escapeshellarg(self::$fixture) + ); + exec($cmd, $out, $code); + + if ($code !== 0 || ! is_file(self::$fixture)) { + self::$fixture = ''; + } + } + + public static function tearDownAfterClass(): void + { + if (self::$workDir !== '' && is_dir(self::$workDir)) { + self::removeDir(self::$workDir); + } + } + + public function testHlsPackagingProducesMasterAndVariants(): void + { + if (self::$fixture === '') { + $this->markTestSkipped('ffmpeg binary or fixture generation unavailable'); + } + + $outDir = self::$workDir.'/hls'; + mkdir($outDir); + + $stream = new Stream(new FFmpeg(['timeout' => 0])); + $stream + ->open(self::$fixture) + ->setFormat(new X264()) + ->addRepresentations([ + (new Representation())->setResize(320, 240)->setKiloBitrate(200)->setAudioKiloBitrate(64), + (new Representation())->setResize(640, 480)->setKiloBitrate(500)->setAudioKiloBitrate(96), + ]) + ->setOutput(new Hls()) + ->save($outDir.'/stream.m3u8'); + + $master = $outDir.'/master.m3u8'; + $this->assertFileExists($master, 'ffmpeg should write master.m3u8 via master_pl_name'); + + $masterContents = (string) file_get_contents($master); + $this->assertStringContainsString('#EXTM3U', $masterContents); + $this->assertStringContainsString('#EXT-X-STREAM-INF', $masterContents); + + $playlists = glob($outDir.'/*.m3u8') ?: []; + $this->assertGreaterThanOrEqual(2, count($playlists), 'expected master + at least one variant playlist'); + + $segments = array_merge( + glob($outDir.'/*.ts') ?: [], + glob($outDir.'/*.m4s') ?: [] + ); + $this->assertNotEmpty($segments, 'expected media segments'); + } + + public function testDashPackagingProducesMpd(): void + { + if (self::$fixture === '') { + $this->markTestSkipped('ffmpeg binary or fixture generation unavailable'); + } + + $outDir = self::$workDir.'/dash'; + mkdir($outDir); + + $stream = new Stream(new FFmpeg(['timeout' => 0])); + $stream + ->open(self::$fixture) + ->setFormat(new X264()) + ->addRepresentations([ + (new Representation())->setResize(320, 240)->setKiloBitrate(200)->setAudioKiloBitrate(64), + (new Representation())->setResize(640, 480)->setKiloBitrate(500)->setAudioKiloBitrate(96), + ]) + ->setOutput( + (new Dash()) + ->setUseTimeline(1) + ->setUseTemplate(1) + ->setInitSegmentName(true) + ->setMediaSegmentName(true) + ) + ->save($outDir.'/stream.mpd'); + + $this->assertFileExists($outDir.'/stream.mpd'); + $mpd = (string) file_get_contents($outDir.'/stream.mpd'); + $this->assertStringContainsString('MPD', $mpd); + } + + private static function ffmpegAvailable(): bool + { + exec('ffmpeg -version 2>/dev/null', $out, $code); + + return $code === 0; + } + + private static function removeDir(string $dir): void + { + $items = scandir($dir); + if ($items === false) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir.DIRECTORY_SEPARATOR.$item; + if (is_dir($path)) { + self::removeDir($path); + } else { + @unlink($path); + } + } + + @rmdir($dir); + } +} diff --git a/tests/Streaming/RepresentationTest.php b/tests/Streaming/RepresentationTest.php new file mode 100644 index 0000000..a575c4d --- /dev/null +++ b/tests/Streaming/RepresentationTest.php @@ -0,0 +1,46 @@ +setResize(1280, 720) + ->setKiloBitrate(2048) + ->setAudioKiloBitrate(128); + + $this->assertSame(1280, $rep->getWidth()); + $this->assertSame(720, $rep->getHeight()); + $this->assertSame('1280x720', $rep->getResolution()); + $this->assertSame(2048, $rep->getVideoKiloBitrate()); + $this->assertSame(2048, $rep->getKiloBitrate()); + $this->assertSame(128, $rep->getAudioKiloBitrate()); + } + + public function testInvalidResizeThrows(): void + { + $this->expectException(InvalidArgumentException::class); + (new Representation())->setResize(10, 10); + } + + public function testCollection(): void + { + $a = (new Representation())->setResize(640, 360)->setKiloBitrate(276); + $b = (new Representation())->setResize(1280, 720)->setKiloBitrate(2048); + $reps = new Representations([$a, $b]); + + $this->assertCount(2, $reps); + $this->assertSame($a, $reps->first()); + $this->assertSame($b, $reps->last()); + $this->assertSame([$a, $b], $reps->all()); + } +} diff --git a/tests/Streaming/UtilsTest.php b/tests/Streaming/UtilsTest.php new file mode 100644 index 0000000..d61ccc1 --- /dev/null +++ b/tests/Streaming/UtilsTest.php @@ -0,0 +1,33 @@ +assertSame( + ['-b:v', '750k', '-s:v', '640x360'], + Utils::arrayToFFmpegOpt(['b:v' => '750k', 's:v' => '640x360']) + ); + } + + public function testArrayToFFmpegOptPassesThroughFlatList(): void + { + $flat = ['-map', '0:v:0', '-map', '0:a:0']; + + $this->assertSame($flat, Utils::arrayToFFmpegOpt($flat)); + } + + public function testAppendSlash(): void + { + $this->assertSame('foo/', Utils::appendSlash('foo')); + $this->assertSame('foo/', Utils::appendSlash('foo/')); + $this->assertSame('', Utils::appendSlash('')); + } +} From 2a510440e1ddbd8dfc41600e19dd4806f813abe7 Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 2 Aug 2026 18:49:19 +0300 Subject: [PATCH 05/12] Remove deprecated streaming classes and interfaces to streamline the codebase, enhancing maintainability and clarity. --- src/Streaming/Adapter.php | 27 -- src/Streaming/Adapter/FFmpeg.php | 256 ------------------ src/Streaming/Arguments.php | 76 ------ src/Streaming/Arguments/Dash.php | 111 -------- src/Streaming/Arguments/Hls.php | 208 -------------- src/Streaming/CommandBuilder.php | 59 ---- src/Streaming/Exception/Exception.php | 9 - .../Exception/InvalidArgumentException.php | 9 - src/Streaming/Exception/RuntimeException.php | 9 - src/Streaming/File.php | 53 ---- src/Streaming/Format.php | 33 --- src/Streaming/Format/HEVC.php | 60 ---- src/Streaming/Format/VP9.php | 64 ----- src/Streaming/Format/X264.php | 61 ----- src/Streaming/Output.php | 80 ------ src/Streaming/Output/Dash.php | 108 -------- src/Streaming/Output/Hls.php | 148 ---------- src/Streaming/Probe.php | 101 ------- src/Streaming/Representation.php | 96 ------- src/Streaming/Representations.php | 71 ----- src/Streaming/Stream.php | 75 ----- src/Streaming/Utils.php | 41 --- tests/Streaming/ArgumentsDashTest.php | 65 ----- tests/Streaming/ArgumentsHlsTest.php | 73 ----- tests/Streaming/CommandBuilderTest.php | 52 ---- tests/Streaming/FFmpegTest.php | 34 --- tests/Streaming/PackagingIntegrationTest.php | 145 ---------- tests/Streaming/RepresentationTest.php | 46 ---- tests/Streaming/UtilsTest.php | 33 --- 29 files changed, 2203 deletions(-) delete mode 100644 src/Streaming/Adapter.php delete mode 100644 src/Streaming/Adapter/FFmpeg.php delete mode 100644 src/Streaming/Arguments.php delete mode 100644 src/Streaming/Arguments/Dash.php delete mode 100644 src/Streaming/Arguments/Hls.php delete mode 100644 src/Streaming/CommandBuilder.php delete mode 100644 src/Streaming/Exception/Exception.php delete mode 100644 src/Streaming/Exception/InvalidArgumentException.php delete mode 100644 src/Streaming/Exception/RuntimeException.php delete mode 100644 src/Streaming/File.php delete mode 100644 src/Streaming/Format.php delete mode 100644 src/Streaming/Format/HEVC.php delete mode 100644 src/Streaming/Format/VP9.php delete mode 100644 src/Streaming/Format/X264.php delete mode 100644 src/Streaming/Output.php delete mode 100644 src/Streaming/Output/Dash.php delete mode 100644 src/Streaming/Output/Hls.php delete mode 100644 src/Streaming/Probe.php delete mode 100644 src/Streaming/Representation.php delete mode 100644 src/Streaming/Representations.php delete mode 100644 src/Streaming/Stream.php delete mode 100644 src/Streaming/Utils.php delete mode 100644 tests/Streaming/ArgumentsDashTest.php delete mode 100644 tests/Streaming/ArgumentsHlsTest.php delete mode 100644 tests/Streaming/CommandBuilderTest.php delete mode 100644 tests/Streaming/FFmpegTest.php delete mode 100644 tests/Streaming/PackagingIntegrationTest.php delete mode 100644 tests/Streaming/RepresentationTest.php delete mode 100644 tests/Streaming/UtilsTest.php diff --git a/src/Streaming/Adapter.php b/src/Streaming/Adapter.php deleted file mode 100644 index 96ff1b6..0000000 --- a/src/Streaming/Adapter.php +++ /dev/null @@ -1,27 +0,0 @@ - $reps - */ - public function addRepresentations(array $reps): self; - - public function setOutput(Output $output): self; - - public function save(?string $path = null): self; - - public function run(): void; -} diff --git a/src/Streaming/Adapter/FFmpeg.php b/src/Streaming/Adapter/FFmpeg.php deleted file mode 100644 index 2c7da57..0000000 --- a/src/Streaming/Adapter/FFmpeg.php +++ /dev/null @@ -1,256 +0,0 @@ - */ - protected array $inputOptions = []; - - /** - * @param array $config - */ - public function __construct(array $config = []) - { - $this->ffprobe = BaseFFProbe::create($config); - $this->ffmpeg = BaseFFmpeg::create($config, null, $this->ffprobe); - $this->reps = new Representations(); - } - - public function open(string $path): self - { - $streams = $this->ffprobe->streams($path); - - $hasVideo = count($streams->videos()) > 0; - $hasAudio = count($streams->audios()) > 0; - - if (! $hasVideo && ! $hasAudio) { - throw new InvalidArgumentException( - 'Unable to detect file format, only audio and video are supported' - ); - } - - $format = null; - try { - $format = $this->ffprobe->format($path); - } catch (\Throwable) { - // Duration may still be available on individual streams. - } - - $this->probe = new Probe($streams, $format); - - try { - $this->media = $this->ffmpeg->open($path); - } catch (FFmpegExceptionInterface $e) { - throw new RuntimeException( - 'An error occurred while opening the file: '.$e->getMessage(), - (int) $e->getCode(), - $e - ); - } - - return $this; - } - - public function isValid(string $path): bool - { - try { - return (float) $this->ffprobe->format($path)->get('duration') > 0; - } catch (\Throwable) { - return false; - } - } - - public function setFormat(Format $format): self - { - $this->format = $format; - - return $this; - } - - public function addRepresentation(Representation $representation): self - { - $this->reps->add($representation); - - return $this; - } - - /** - * @param list $reps - */ - public function addRepresentations(array $reps): self - { - foreach ($reps as $representation) { - $this->addRepresentation($representation); - } - - return $this; - } - - public function setOutput(Output $output): self - { - $this->output = $output; - - return $this; - } - - /** - * @param array $inputOptions - */ - public function setInputOptions(array $inputOptions): self - { - $this->inputOptions = $inputOptions; - - return $this; - } - - public function save(?string $path = null): self - { - $this->assertReady(); - - /** @var Probe $probe */ - $probe = $this->probe; - /** @var Format $format */ - $format = $this->format; - /** @var Output $output */ - $output = $this->output; - /** @var AbstractMediaType $media */ - $media = $this->media; - - $output - ->setHasVideo($probe->hasVideo()) - ->setAudioTracks($probe->getAudioTracks()); - - $resolvedPath = $this->resolvePath($path); - $arguments = $this->createArguments($resolvedPath, $format, $output); - $outputPath = $arguments->getOutputPath(); - - File::makeDir(dirname($outputPath)); - - $commands = (new CommandBuilder($media, $format, $this->inputOptions)) - ->build($arguments->build(), $outputPath); - - $pass = (int) $format->getPasses(); - $listeners = $format->createProgressListener( - $media, - $media->getFFProbe(), - 1, - $pass - ); - - try { - $media->getFFMpegDriver()->command($commands, false, $listeners); - } catch (FFmpegExceptionInterface $e) { - throw new RuntimeException( - 'An error occurred while saving files: '.$e->getMessage(), - (int) $e->getCode(), - $e - ); - } - - return $this; - } - - public function run(): void - { - $this->save(null); - } - - public function getProbe(): ?Probe - { - return $this->probe; - } - - public function getRepresentations(): Representations - { - return $this->reps; - } - - private function assertReady(): void - { - if ($this->media === null || $this->probe === null) { - throw new InvalidArgumentException('Call open() before save()'); - } - - if ($this->format === null) { - throw new InvalidArgumentException('Call setFormat() before save()'); - } - - if ($this->output === null) { - throw new InvalidArgumentException('Call setOutput() before save()'); - } - - if ($this->reps->count() === 0) { - throw new InvalidArgumentException('Add at least one representation before save()'); - } - } - - private function resolvePath(?string $path): string - { - if ($path !== null) { - if (strlen($path) > PHP_MAXPATHLEN) { - throw new InvalidArgumentException('The path is too long'); - } - - File::makeDir(dirname($path)); - - return $path; - } - - /** @var AbstractMediaType $media */ - $media = $this->media; - - return $media->getPathfile(); - } - - private function createArguments(string $path, Format $format, Output $output): Arguments - { - if ($output instanceof HlsOutput) { - return new HlsArguments($path, $format, $output, $this->reps); - } - - if ($output instanceof DashOutput) { - return new DashArguments($path, $format, $output, $this->reps); - } - - throw new InvalidArgumentException( - 'Unsupported output type: '.get_class($output) - ); - } -} diff --git a/src/Streaming/Arguments.php b/src/Streaming/Arguments.php deleted file mode 100644 index cd2626f..0000000 --- a/src/Streaming/Arguments.php +++ /dev/null @@ -1,76 +0,0 @@ -dirname = pathinfo($normalized, PATHINFO_DIRNAME); - $this->filename = pathinfo($normalized, PATHINFO_FILENAME); - $this->format = $format; - $this->output = $output; - $this->reps = $reps; - } - - /** - * Flat argv fragment (without input options or final output path). - * - * @return list - */ - abstract public function build(): array; - - /** - * Path that becomes the ffmpeg output argument. - */ - abstract public function getOutputPath(): string; - - /** - * @return list - */ - protected function formatOptions(): array - { - $codecs = [ - 'c:a' => $this->format->getAudioCodec(), - ]; - - if ($this->output->hasVideo()) { - $codecs = [ - 'c:v' => $this->format->getVideoCodec(), - 'c:a' => $this->format->getAudioCodec(), - ]; - } - - $basic = Utils::arrayToFFmpegOpt($codecs); - $extra = Utils::arrayToFFmpegOpt($this->format->getAdditionalParameters() ?: []); - - return [...$basic, ...$extra]; - } - - protected function basePath(): string - { - return $this->dirname.'/'.$this->filename; - } -} diff --git a/src/Streaming/Arguments/Dash.php b/src/Streaming/Arguments/Dash.php deleted file mode 100644 index 6597c2d..0000000 --- a/src/Streaming/Arguments/Dash.php +++ /dev/null @@ -1,111 +0,0 @@ -dash = $output; - } - - public function build(): array - { - if ($this->reps->count() === 0) { - throw new InvalidArgumentException('At least one representation is required'); - } - - return [ - ...$this->formatOptions(), - ...$this->initOptions(), - ...$this->streamOptions(), - '-strict', - $this->dash->getStrict(), - ]; - } - - public function getOutputPath(): string - { - return $this->basePath().'.mpd'; - } - - /** - * @return list - */ - private function initOptions(): array - { - $init = [ - 'use_timeline' => $this->dash->getUseTimeline(), - 'use_template' => $this->dash->getUseTemplate(), - 'seg_duration' => $this->dash->getSegmentDuration(), - 'hls_playlist' => (int) $this->dash->isGenerateHlsPlaylist(), - 'f' => 'dash', - ]; - - if ($this->dash->getInitSegmentName()) { - $init['init_seg_name'] = $this->filename.'_init_$RepresentationID$.$ext$'; - } - - if ($this->dash->getMediaSegmentName()) { - $init['media_seg_name'] = $this->filename.'_chunk_$RepresentationID$_$Number%05d$.$ext$'; - } - - $args = Utils::arrayToFFmpegOpt($init); - - if ($this->dash->getAdaption() !== '') { - $args[] = '-adaptation_sets'; - $args[] = $this->dash->getAdaption(); - } - - return [...$args, ...Utils::arrayToFFmpegOpt($this->dash->getAdditionalParams())]; - } - - /** - * @return list - */ - private function streamOptions(): array - { - $chunks = []; - - foreach ($this->reps as $key => $rep) { - /** @var Representation $rep */ - $opts = ['map' => '0']; - - if ($this->dash->hasVideo()) { - $opts['s:v:'.$key] = $rep->getResolution(); - $opts['b:v:'.$key] = $rep->getVideoKiloBitrate().'k'; - } - - $chunks[] = Utils::arrayToFFmpegOpt($opts); - - $audioBitrate = $rep->getAudioKiloBitrate(); - if ($audioBitrate > 0) { - $chunks[] = ['-b:a:'.$key, $audioBitrate.'k']; - } - } - - return $chunks === [] ? [] : array_merge(...$chunks); - } -} diff --git a/src/Streaming/Arguments/Hls.php b/src/Streaming/Arguments/Hls.php deleted file mode 100644 index 7cf7f46..0000000 --- a/src/Streaming/Arguments/Hls.php +++ /dev/null @@ -1,208 +0,0 @@ - */ - private array $maps = []; - - public function __construct( - string $path, - StreamingFormat $format, - Output $output, - Representations $reps - ) { - if (! $output instanceof HlsOutput) { - throw new InvalidArgumentException('Arguments\\Hls requires Output\\Hls'); - } - - parent::__construct($path, $format, $output, $reps); - - $this->hls = $output; - $this->preparePaths(); - $this->streamMap = $this->buildStreamMap(); - $this->maps = $this->buildMaps(); - } - - public function build(): array - { - if ($this->reps->count() === 0) { - throw new InvalidArgumentException('At least one representation is required'); - } - - $chunks = []; - $last = $this->reps->last(); - - foreach ($this->reps as $rep) { - $chunks[] = $this->formatOptions(); - $chunks[] = Utils::arrayToFFmpegOpt($this->representationOptions($rep)); - $chunks[] = $this->maps; - $chunks[] = Utils::arrayToFFmpegOpt($this->hls->getAdditionalParams()); - $chunks[] = ['-strict', $this->hls->getStrict()]; - - // Intermediate variant playlists are positional outputs; - // the last rung becomes the command's output argument. - if ($rep !== $last) { - $chunks[] = [$this->variantPlaylistPath($rep)]; - } - } - - return array_merge(...$chunks); - } - - public function getOutputPath(): string - { - $last = $this->reps->last(); - if ($last === null) { - throw new InvalidArgumentException('At least one representation is required'); - } - - return $this->variantPlaylistPath($last); - } - - private function preparePaths(): void - { - if ($this->hls->getSegmentSubDirectory() !== '') { - File::makeDir($this->dirname.'/'.$this->hls->getSegmentSubDirectory()); - } - - $this->segmentSubDir = Utils::appendSlash($this->hls->getSegmentSubDirectory()); - $this->segmentFilename = $this->dirname.'/'.$this->segmentSubDir.$this->filename; - $this->baseUrl = Utils::appendSlash($this->hls->getBaseUrl()).$this->segmentSubDir; - } - - /** - * @return array - */ - private function representationOptions(Representation $rep): array - { - $opts = [ - 'hls_list_size' => $this->hls->getPlaylistSize(), - 'hls_time' => $this->hls->getSegmentDuration(), - 'hls_allow_cache' => (int) $this->hls->allowsCache(), - 'hls_segment_type' => $this->hls->getSegmentType(), - 'hls_fmp4_init_filename' => $this->initFilename($rep), - 'hls_segment_filename' => $this->segmentFilename($rep), - 'master_pl_name' => $this->hls->getMasterPlaylistName(), - 'f' => 'hls', - 'var_stream_map' => $this->streamMap, - ]; - - if ($this->hls->hasVideo()) { - $opts['s:v:0'] = $rep->getResolution(); - $opts['b:v:0'] = $rep->getVideoKiloBitrate().'k'; - } - - foreach ($this->hls->getAudioTracks() as $i => $track) { - $bitrate = $rep->getAudioKiloBitrate(); - if ($bitrate > 0) { - $opts['b:a:'.$i] = $bitrate.'k'; - } - } - - if ($this->baseUrl !== '') { - $opts['hls_base_url'] = $this->baseUrl; - } - - if ($this->hls->getFlags() !== []) { - $opts['hls_flags'] = implode('+', $this->hls->getFlags()); - } - - return $opts; - } - - private function initFilename(Representation $rep): string - { - $height = $this->hls->hasVideo() ? $rep->getHeight().'p_' : ''; - - return $this->segmentSubDir.$this->filename.'_%v_'.$height.$this->hls->getInitFilename(); - } - - private function segmentFilename(Representation $rep): string - { - $ext = $this->hls->getSegmentType() === 'fmp4' ? 'm4s' : 'ts'; - $height = $this->hls->hasVideo() ? $rep->getHeight().'p_' : ''; - - return $this->segmentFilename.'_%v_'.$height.'%04d.'.$ext; - } - - private function variantPlaylistPath(Representation $rep): string - { - if ($this->hls->hasVideo()) { - return $this->basePath().'_%v_'.$rep->getHeight().'p.m3u8'; - } - - return $this->basePath().'_%v.m3u8'; - } - - private function buildStreamMap(): string - { - $parts = []; - - foreach ($this->hls->getAudioTracks() as $i => $track) { - $part = 'a:'.$i.',agroup:audio,language:'.$track['language']; - if ($i === 0) { - $part .= ',default:yes'; - } - $parts[] = $part; - } - - if ($this->hls->hasVideo()) { - $parts[] = $this->hls->getAudioTracks() !== [] - ? 'v:0,agroup:audio' - : 'v:0'; - } - - return implode(' ', $parts); - } - - /** - * @return list - */ - private function buildMaps(): array - { - $maps = []; - - if ($this->hls->hasVideo()) { - $maps[] = '-map'; - $maps[] = '0:v:0'; - } - - foreach ($this->hls->getAudioTracks() as $i => $track) { - $maps[] = '-map'; - $maps[] = '0:a:'.$i; - } - - // No named tracks — still map all audio if present so encode doesn't drop it. - if ($this->hls->getAudioTracks() === [] && ! $this->hls->hasVideo()) { - $maps[] = '-map'; - $maps[] = '0:a:0'; - } - - return $maps; - } -} diff --git a/src/Streaming/CommandBuilder.php b/src/Streaming/CommandBuilder.php deleted file mode 100644 index 2eab281..0000000 --- a/src/Streaming/CommandBuilder.php +++ /dev/null @@ -1,59 +0,0 @@ - */ - private array $inputOptions; - - /** - * @param array $inputOptions - */ - public function __construct( - AbstractMediaType $media, - Format $format, - array $inputOptions = [] - ) { - $this->media = $media; - $this->format = $format; - $this->inputOptions = $inputOptions; - } - - /** - * @param list $argumentGroups - * @return list - */ - public function build(array $argumentGroups, string $outputPath): array - { - $commands = [ - ...Utils::arrayToFFmpegOpt($this->inputOptions), - ...($this->format->getInitialParameters() ?: []), - '-y', - '-i', - $this->media->getPathfile(), - ...$argumentGroups, - ]; - - $driver = $this->media->getFFMpegDriver(); - if ($driver->getConfiguration()->has('ffmpeg.threads')) { - $commands[] = '-threads'; - $commands[] = (string) $driver->getConfiguration()->get('ffmpeg.threads'); - } - - $commands[] = $outputPath; - - return $commands; - } -} diff --git a/src/Streaming/Exception/Exception.php b/src/Streaming/Exception/Exception.php deleted file mode 100644 index d8c76ce..0000000 --- a/src/Streaming/Exception/Exception.php +++ /dev/null @@ -1,9 +0,0 @@ -setVideoCodec($videoCodec) - ->setAudioCodec($audioCodec); - - if ($defaultInitOpts) { - $this->setAdditionalParameters([ - 'keyint_min' => 25, - 'g' => 250, - 'sc_threshold' => 40, - ]); - } - } - - /** - * @return list - */ - public function getAvailableAudioCodecs(): array - { - return self::AUDIO_CODECS; - } - - /** - * @return list - */ - public function getAvailableVideoCodecs(): array - { - return self::VIDEO_CODECS; - } - - public function getModulus(): int - { - return self::MODULUS; - } - - public function supportBFrames(): bool - { - return true; - } -} diff --git a/src/Streaming/Format/VP9.php b/src/Streaming/Format/VP9.php deleted file mode 100644 index 9ed5e30..0000000 --- a/src/Streaming/Format/VP9.php +++ /dev/null @@ -1,64 +0,0 @@ -setVideoCodec($videoCodec) - ->setAudioCodec($audioCodec); - - if ($defaultInitOpts) { - $this->setAdditionalParameters([ - 'deadline' => 'good', - 'cpu-used' => 2, - 'row-mt' => 1, - 'tile-columns' => 2, - 'frame-parallel' => 1, - 'g' => 250, - 'keyint_min' => 25, - ]); - } - } - - /** - * @return list - */ - public function getAvailableAudioCodecs(): array - { - return self::AUDIO_CODECS; - } - - /** - * @return list - */ - public function getAvailableVideoCodecs(): array - { - return self::VIDEO_CODECS; - } - - public function getModulus(): int - { - return self::MODULUS; - } - - public function supportBFrames(): bool - { - return false; - } -} diff --git a/src/Streaming/Format/X264.php b/src/Streaming/Format/X264.php deleted file mode 100644 index 204937d..0000000 --- a/src/Streaming/Format/X264.php +++ /dev/null @@ -1,61 +0,0 @@ -setVideoCodec($videoCodec) - ->setAudioCodec($audioCodec); - - if ($defaultInitOpts) { - $this->setAdditionalParameters([ - 'bf' => 1, - 'keyint_min' => 25, - 'g' => 250, - 'sc_threshold' => 40, - ]); - } - } - - /** - * @return list - */ - public function getAvailableAudioCodecs(): array - { - return self::AUDIO_CODECS; - } - - /** - * @return list - */ - public function getAvailableVideoCodecs(): array - { - return self::VIDEO_CODECS; - } - - public function getModulus(): int - { - return self::MODULUS; - } - - public function supportBFrames(): bool - { - return true; - } -} diff --git a/src/Streaming/Output.php b/src/Streaming/Output.php deleted file mode 100644 index 16a135e..0000000 --- a/src/Streaming/Output.php +++ /dev/null @@ -1,80 +0,0 @@ - */ - private array $additionalParams = []; - - private bool $hasVideo = false; - - /** - * @var list - */ - private array $audioTracks = []; - - public function setStrict(string $strict): self - { - $this->strict = $strict; - - return $this; - } - - public function getStrict(): string - { - return $this->strict; - } - - /** - * @param array $additionalParams - */ - public function setAdditionalParams(array $additionalParams): self - { - $this->additionalParams = $additionalParams; - - return $this; - } - - /** - * @return array - */ - public function getAdditionalParams(): array - { - return $this->additionalParams; - } - - public function setHasVideo(bool $hasVideo): self - { - $this->hasVideo = $hasVideo; - - return $this; - } - - public function hasVideo(): bool - { - return $this->hasVideo; - } - - /** - * @param list $audioTracks - */ - public function setAudioTracks(array $audioTracks): self - { - $this->audioTracks = $audioTracks; - - return $this; - } - - /** - * @return list - */ - public function getAudioTracks(): array - { - return $this->audioTracks; - } -} diff --git a/src/Streaming/Output/Dash.php b/src/Streaming/Output/Dash.php deleted file mode 100644 index a739719..0000000 --- a/src/Streaming/Output/Dash.php +++ /dev/null @@ -1,108 +0,0 @@ -adaption = $adaption; - - return $this; - } - - public function getAdaption(): string - { - return $this->adaption; - } - - public function setSegmentDuration(int $segmentDuration): self - { - $this->segmentDuration = $segmentDuration; - - return $this; - } - - public function getSegmentDuration(): int - { - return $this->segmentDuration; - } - - public function generateHlsPlaylist(bool $generateHlsPlaylist = true): self - { - $this->generateHlsPlaylist = $generateHlsPlaylist; - - return $this; - } - - public function isGenerateHlsPlaylist(): bool - { - return $this->generateHlsPlaylist; - } - - public function setUseTimeline(int $useTimeline): self - { - $this->useTimeline = $useTimeline; - - return $this; - } - - public function getUseTimeline(): int - { - return $this->useTimeline; - } - - public function setUseTemplate(int $useTemplate): self - { - $this->useTemplate = $useTemplate; - - return $this; - } - - public function getUseTemplate(): int - { - return $this->useTemplate; - } - - public function setInitSegmentName(bool $initSegmentName): self - { - $this->initSegmentName = $initSegmentName; - - return $this; - } - - public function getInitSegmentName(): bool - { - return $this->initSegmentName; - } - - public function setMediaSegmentName(bool $mediaSegmentName): self - { - $this->mediaSegmentName = $mediaSegmentName; - - return $this; - } - - public function getMediaSegmentName(): bool - { - return $this->mediaSegmentName; - } -} diff --git a/src/Streaming/Output/Hls.php b/src/Streaming/Output/Hls.php deleted file mode 100644 index 16e0a9a..0000000 --- a/src/Streaming/Output/Hls.php +++ /dev/null @@ -1,148 +0,0 @@ - */ - private array $flags = []; - - public function setSegmentDuration(int $segmentDuration): self - { - $this->segmentDuration = $segmentDuration; - - return $this; - } - - public function getSegmentDuration(): int - { - return $this->segmentDuration; - } - - public function setAllowCache(bool $allowCache): self - { - $this->allowCache = $allowCache; - - return $this; - } - - public function allowsCache(): bool - { - return $this->allowCache; - } - - public function setSegmentSubDirectory(string $segmentSubDirectory): self - { - $this->segmentSubDirectory = $segmentSubDirectory; - - return $this; - } - - public function getSegmentSubDirectory(): string - { - return $this->segmentSubDirectory; - } - - public function setBaseUrl(string $baseUrl): self - { - $this->baseUrl = $baseUrl; - - return $this; - } - - public function getBaseUrl(): string - { - return $this->baseUrl; - } - - public function setPlaylistSize(int $playlistSize): self - { - $this->playlistSize = $playlistSize; - - return $this; - } - - public function getPlaylistSize(): int - { - return $this->playlistSize; - } - - public function setSegmentType(string $segmentType): self - { - $this->segmentType = $segmentType; - - return $this; - } - - public function getSegmentType(): string - { - return $this->segmentType; - } - - public function fragmentedMp4(): self - { - return $this->setSegmentType('fmp4'); - } - - public function setInitFilename(string $initFilename): self - { - $this->initFilename = $initFilename; - - return $this; - } - - public function getInitFilename(): string - { - return $this->initFilename; - } - - public function setMasterPlaylistName(string $masterPlaylistName): self - { - $this->masterPlaylistName = $masterPlaylistName; - - return $this; - } - - public function getMasterPlaylistName(): string - { - return $this->masterPlaylistName; - } - - /** - * @param list $flags - */ - public function setFlags(array $flags): self - { - $this->flags = array_values(array_merge($this->flags, $flags)); - - return $this; - } - - /** - * @return list - */ - public function getFlags(): array - { - return $this->flags; - } -} diff --git a/src/Streaming/Probe.php b/src/Streaming/Probe.php deleted file mode 100644 index 90ba078..0000000 --- a/src/Streaming/Probe.php +++ /dev/null @@ -1,101 +0,0 @@ - */ - private array $audioTracks; - - private float $duration; - - public function __construct(StreamCollection $streams, ?ProbeFormat $format = null) - { - $this->streams = $streams; - $this->hasVideo = count($streams->videos()) > 0; - $this->audioTracks = $this->collectAudioTracks($streams); - $this->duration = $this->resolveDuration($streams, $format); - } - - public function getStreams(): StreamCollection - { - return $this->streams; - } - - public function hasVideo(): bool - { - return $this->hasVideo; - } - - public function hasAudio(): bool - { - return $this->audioTracks !== [] || count($this->streams->audios()) > 0; - } - - /** - * @return list - */ - public function getAudioTracks(): array - { - return $this->audioTracks; - } - - public function getDuration(): float - { - return $this->duration; - } - - /** - * @return list - */ - private function collectAudioTracks(StreamCollection $streams): array - { - $tracks = []; - - foreach ($streams->audios() as $stream) { - $tags = $stream->get('tags') ?? []; - $language = is_array($tags) && ! empty($tags['language']) - ? (string) $tags['language'] - : 'und'; - - $tracks[] = [ - 'codec' => (string) ($stream->get('codec_name') ?? 'aac'), - 'language' => $language, - ]; - } - - return $tracks; - } - - private function resolveDuration(StreamCollection $streams, ?ProbeFormat $format): float - { - if ($format !== null) { - $duration = $format->get('duration'); - if (is_numeric($duration)) { - return (float) $duration; - } - } - - foreach ($streams as $stream) { - $duration = $stream->get('duration'); - if (is_numeric($duration)) { - return (float) $duration; - } - } - - return 0.0; - } -} diff --git a/src/Streaming/Representation.php b/src/Streaming/Representation.php deleted file mode 100644 index aa9e30b..0000000 --- a/src/Streaming/Representation.php +++ /dev/null @@ -1,96 +0,0 @@ -width = $width; - $this->height = $height; - - return $this; - } - - public function getWidth(): int - { - return $this->width; - } - - public function getHeight(): int - { - return $this->height; - } - - /** - * Resolution as an ffmpeg size string, e.g. "1280x720". - */ - public function getResolution(): string - { - return $this->width.'x'.$this->height; - } - - public function setVideoKiloBitrate(int $videoKiloBitrate): self - { - if ($videoKiloBitrate < 1) { - throw new InvalidArgumentException('Invalid video kilo bit rate value'); - } - - $this->videoKiloBitrate = $videoKiloBitrate; - - return $this; - } - - /** - * Alias matching the fork's Representation::setKiloBitrate(). - */ - public function setKiloBitrate(int $kiloBitrate): self - { - return $this->setVideoKiloBitrate($kiloBitrate); - } - - public function getVideoKiloBitrate(): int - { - return $this->videoKiloBitrate; - } - - /** - * Alias matching the fork's Representation::getKiloBitrate(). - */ - public function getKiloBitrate(): int - { - return $this->videoKiloBitrate; - } - - public function setAudioKiloBitrate(int $audioKiloBitrate): self - { - if ($audioKiloBitrate < 1) { - throw new InvalidArgumentException('Invalid audio kilo bit rate value'); - } - - $this->audioKiloBitrate = $audioKiloBitrate; - - return $this; - } - - public function getAudioKiloBitrate(): int - { - return $this->audioKiloBitrate; - } -} diff --git a/src/Streaming/Representations.php b/src/Streaming/Representations.php deleted file mode 100644 index 0d534f4..0000000 --- a/src/Streaming/Representations.php +++ /dev/null @@ -1,71 +0,0 @@ - - */ -class Representations implements Countable, IteratorAggregate -{ - /** @var list */ - private array $items = []; - - /** - * @param list $reps - */ - public function __construct(array $reps = []) - { - foreach ($reps as $representation) { - $this->add($representation); - } - } - - public function add(Representation $representation): self - { - $this->items[] = $representation; - - return $this; - } - - public function first(): ?Representation - { - return $this->items[0] ?? null; - } - - public function last(): ?Representation - { - if ($this->items === []) { - return null; - } - - return $this->items[array_key_last($this->items)]; - } - - /** - * @return list - */ - public function all(): array - { - return $this->items; - } - - public function count(): int - { - return count($this->items); - } - - /** - * @return Traversable - */ - public function getIterator(): Traversable - { - return new ArrayIterator($this->items); - } -} diff --git a/src/Streaming/Stream.php b/src/Streaming/Stream.php deleted file mode 100644 index 7422ed7..0000000 --- a/src/Streaming/Stream.php +++ /dev/null @@ -1,75 +0,0 @@ -adapter = $adapter; - } - - public function open(string $path): self - { - $this->adapter->open($path); - - return $this; - } - - public function isValid(string $path): bool - { - return $this->adapter->isValid($path); - } - - public function setFormat(Format $format): self - { - $this->adapter->setFormat($format); - - return $this; - } - - public function addRepresentation(Representation $representation): self - { - $this->adapter->addRepresentation($representation); - - return $this; - } - - /** - * @param list $reps - */ - public function addRepresentations(array $reps): self - { - $this->adapter->addRepresentations($reps); - - return $this; - } - - public function setOutput(Output $output): self - { - $this->adapter->setOutput($output); - - return $this; - } - - public function save(?string $path = null): self - { - $this->adapter->save($path); - - return $this; - } - - public function run(): void - { - $this->adapter->run(); - } - - public function getAdapter(): Adapter - { - return $this->adapter; - } -} diff --git a/src/Streaming/Utils.php b/src/Streaming/Utils.php deleted file mode 100644 index f8c00c5..0000000 --- a/src/Streaming/Utils.php +++ /dev/null @@ -1,41 +0,0 @@ - '750k', 's:v' => '640x360'] → ['-b:v', '750k', '-s:v', '640x360'] - * - * If any key is not a string the original array is returned unchanged - * (already a flat argv list). - * - * @param array $array - * @return list - */ - public static function arrayToFFmpegOpt(array $array, string $startWith = '-'): array - { - $out = []; - - foreach ($array as $key => $value) { - if (! is_string($key)) { - /** @var list $array */ - return array_map('strval', $array); - } - - $out[] = $startWith.$key; - $out[] = (string) $value; - } - - return $out; - } -} diff --git a/tests/Streaming/ArgumentsDashTest.php b/tests/Streaming/ArgumentsDashTest.php deleted file mode 100644 index bf6d77e..0000000 --- a/tests/Streaming/ArgumentsDashTest.php +++ /dev/null @@ -1,65 +0,0 @@ -setResize(640, 360)->setKiloBitrate(276)->setAudioKiloBitrate(128), - (new Representation())->setResize(1280, 720)->setKiloBitrate(2048)->setAudioKiloBitrate(128), - (new Representation())->setResize(1920, 1080)->setKiloBitrate(4096)->setAudioKiloBitrate(192), - ]); - - $output = (new Dash()) - ->setHasVideo(true) - ->setSegmentDuration(4) - ->setUseTimeline(1) - ->setUseTemplate(1) - ->setInitSegmentName(true) - ->setMediaSegmentName(true); - - $args = new DashArguments('/tmp/pack/video.mp4', new X264(), $output, $reps); - $joined = implode(' ', $args->build()); - - $this->assertStringContainsString('-f dash', $joined); - $this->assertStringContainsString('-seg_duration 4', $joined); - $this->assertStringContainsString('-use_timeline 1', $joined); - $this->assertStringContainsString('-use_template 1', $joined); - $this->assertStringContainsString('-init_seg_name video_init_$RepresentationID$.$ext$', $joined); - $this->assertStringContainsString('-media_seg_name video_chunk_$RepresentationID$_$Number%05d$.$ext$', $joined); - $this->assertStringContainsString('-s:v:0 640x360', $joined); - $this->assertStringContainsString('-b:v:0 276k', $joined); - $this->assertStringContainsString('-s:v:1 1280x720', $joined); - $this->assertStringContainsString('-b:v:2 4096k', $joined); - $this->assertStringContainsString('-b:a:0 128k', $joined); - $this->assertStringContainsString('-b:a:2 192k', $joined); - $this->assertSame('/tmp/pack/video.mpd', $args->getOutputPath()); - } - - public function testAudioOnlyOmitsVideoSizing(): void - { - $reps = new Representations([ - (new Representation())->setResize(640, 360)->setKiloBitrate(128)->setAudioKiloBitrate(96), - ]); - - $output = (new Dash())->setHasVideo(false); - $args = new DashArguments('/tmp/pack/audio.mp4', new X264(), $output, $reps); - $joined = implode(' ', $args->build()); - - $this->assertStringNotContainsString('-s:v:0', $joined); - $this->assertStringNotContainsString('-b:v:0', $joined); - $this->assertStringContainsString('-map 0', $joined); - $this->assertStringContainsString('-b:a:0 96k', $joined); - } -} diff --git a/tests/Streaming/ArgumentsHlsTest.php b/tests/Streaming/ArgumentsHlsTest.php deleted file mode 100644 index f54be8b..0000000 --- a/tests/Streaming/ArgumentsHlsTest.php +++ /dev/null @@ -1,73 +0,0 @@ -setResize(640, 360)->setKiloBitrate(276)->setAudioKiloBitrate(128), - (new Representation())->setResize(1280, 720)->setKiloBitrate(2048)->setAudioKiloBitrate(128), - ]); - - $output = (new Hls()) - ->setHasVideo(true) - ->setAudioTracks([ - ['codec' => 'aac', 'language' => 'eng'], - ['codec' => 'aac', 'language' => 'spa'], - ]) - ->setSegmentDuration(6); - - $args = new HlsArguments('/tmp/pack/video.mp4', new X264(), $output, $reps); - $argv = $args->build(); - $joined = implode(' ', $argv); - - $this->assertStringContainsString('-var_stream_map a:0,agroup:audio,language:eng,default:yes a:1,agroup:audio,language:spa v:0,agroup:audio', $joined); - $this->assertStringContainsString('-master_pl_name master.m3u8', $joined); - $this->assertStringContainsString('-s:v:0 640x360', $joined); - $this->assertStringContainsString('-b:v:0 276k', $joined); - $this->assertStringContainsString('-s:v:0 1280x720', $joined); - $this->assertStringContainsString('-b:v:0 2048k', $joined); - $this->assertStringContainsString('-map 0:v:0', $joined); - $this->assertStringContainsString('-map 0:a:0', $joined); - $this->assertStringContainsString('-map 0:a:1', $joined); - $this->assertStringContainsString('-hls_time 6', $joined); - $this->assertStringContainsString('/tmp/pack/video_%v_360p.m3u8', $joined); - $this->assertSame('/tmp/pack/video_%v_720p.m3u8', $args->getOutputPath()); - $this->assertStringContainsString('_%v_', $args->getOutputPath()); - } - - public function testAudioOnlyOmitsVideoSizing(): void - { - $reps = new Representations([ - (new Representation())->setResize(640, 360)->setKiloBitrate(128)->setAudioKiloBitrate(128), - ]); - - $output = (new Hls()) - ->setHasVideo(false) - ->setAudioTracks([ - ['codec' => 'aac', 'language' => 'eng'], - ]); - - $args = new HlsArguments('/tmp/pack/audio.mp4', new X264(), $output, $reps); - $joined = implode(' ', $args->build()); - - $this->assertStringNotContainsString('-s:v:0', $joined); - $this->assertStringNotContainsString('-b:v:0', $joined); - $this->assertStringNotContainsString('-c:v', $joined); - $this->assertStringNotContainsString('-map 0:v:0', $joined); - $this->assertStringContainsString('-map 0:a:0', $joined); - $this->assertStringContainsString('-var_stream_map a:0,agroup:audio,language:eng,default:yes', $joined); - $this->assertSame('/tmp/pack/audio_%v.m3u8', $args->getOutputPath()); - } -} diff --git a/tests/Streaming/CommandBuilderTest.php b/tests/Streaming/CommandBuilderTest.php deleted file mode 100644 index e560d29..0000000 --- a/tests/Streaming/CommandBuilderTest.php +++ /dev/null @@ -1,52 +0,0 @@ -createMock(FFMpegDriver::class); - $driver->method('getConfiguration')->willReturn(new Configuration([ - 'ffmpeg.threads' => 4, - ])); - - $ffprobe = $this->createMock(FFProbe::class); - - $media = $this->getMockBuilder(AbstractMediaType::class) - ->disableOriginalConstructor() - ->onlyMethods(['getPathfile', 'getFFMpegDriver', 'getFFProbe', 'filters']) - ->getMock(); - $media->method('getPathfile')->willReturn('/input/sample.mp4'); - $media->method('getFFMpegDriver')->willReturn($driver); - $media->method('getFFProbe')->willReturn($ffprobe); - - $commands = (new CommandBuilder($media, new X264(), ['ss' => '0'])) - ->build(['-c:v', 'libx264', '-f', 'hls'], '/out/video_%v_720p.m3u8'); - - $this->assertSame([ - '-ss', - '0', - '-y', - '-i', - '/input/sample.mp4', - '-c:v', - 'libx264', - '-f', - 'hls', - '-threads', - '4', - '/out/video_%v_720p.m3u8', - ], $commands); - } -} diff --git a/tests/Streaming/FFmpegTest.php b/tests/Streaming/FFmpegTest.php deleted file mode 100644 index fafd023..0000000 --- a/tests/Streaming/FFmpegTest.php +++ /dev/null @@ -1,34 +0,0 @@ - 0]); - $stream = new Stream($adapter); - - $representation = (new Representation()) - ->setVideoKiloBitrate(600) - ->setAudioKiloBitrate(128) - ->setResize(640, 360); - - $stream - ->setFormat(new X264()) - ->addRepresentation($representation) - ->setOutput(new Hls()); - - $this->assertSame($adapter, $stream->getAdapter()); - $this->assertCount(1, $adapter->getRepresentations()); - } -} diff --git a/tests/Streaming/PackagingIntegrationTest.php b/tests/Streaming/PackagingIntegrationTest.php deleted file mode 100644 index addabf2..0000000 --- a/tests/Streaming/PackagingIntegrationTest.php +++ /dev/null @@ -1,145 +0,0 @@ -/dev/null', - escapeshellarg(self::$fixture) - ); - exec($cmd, $out, $code); - - if ($code !== 0 || ! is_file(self::$fixture)) { - self::$fixture = ''; - } - } - - public static function tearDownAfterClass(): void - { - if (self::$workDir !== '' && is_dir(self::$workDir)) { - self::removeDir(self::$workDir); - } - } - - public function testHlsPackagingProducesMasterAndVariants(): void - { - if (self::$fixture === '') { - $this->markTestSkipped('ffmpeg binary or fixture generation unavailable'); - } - - $outDir = self::$workDir.'/hls'; - mkdir($outDir); - - $stream = new Stream(new FFmpeg(['timeout' => 0])); - $stream - ->open(self::$fixture) - ->setFormat(new X264()) - ->addRepresentations([ - (new Representation())->setResize(320, 240)->setKiloBitrate(200)->setAudioKiloBitrate(64), - (new Representation())->setResize(640, 480)->setKiloBitrate(500)->setAudioKiloBitrate(96), - ]) - ->setOutput(new Hls()) - ->save($outDir.'/stream.m3u8'); - - $master = $outDir.'/master.m3u8'; - $this->assertFileExists($master, 'ffmpeg should write master.m3u8 via master_pl_name'); - - $masterContents = (string) file_get_contents($master); - $this->assertStringContainsString('#EXTM3U', $masterContents); - $this->assertStringContainsString('#EXT-X-STREAM-INF', $masterContents); - - $playlists = glob($outDir.'/*.m3u8') ?: []; - $this->assertGreaterThanOrEqual(2, count($playlists), 'expected master + at least one variant playlist'); - - $segments = array_merge( - glob($outDir.'/*.ts') ?: [], - glob($outDir.'/*.m4s') ?: [] - ); - $this->assertNotEmpty($segments, 'expected media segments'); - } - - public function testDashPackagingProducesMpd(): void - { - if (self::$fixture === '') { - $this->markTestSkipped('ffmpeg binary or fixture generation unavailable'); - } - - $outDir = self::$workDir.'/dash'; - mkdir($outDir); - - $stream = new Stream(new FFmpeg(['timeout' => 0])); - $stream - ->open(self::$fixture) - ->setFormat(new X264()) - ->addRepresentations([ - (new Representation())->setResize(320, 240)->setKiloBitrate(200)->setAudioKiloBitrate(64), - (new Representation())->setResize(640, 480)->setKiloBitrate(500)->setAudioKiloBitrate(96), - ]) - ->setOutput( - (new Dash()) - ->setUseTimeline(1) - ->setUseTemplate(1) - ->setInitSegmentName(true) - ->setMediaSegmentName(true) - ) - ->save($outDir.'/stream.mpd'); - - $this->assertFileExists($outDir.'/stream.mpd'); - $mpd = (string) file_get_contents($outDir.'/stream.mpd'); - $this->assertStringContainsString('MPD', $mpd); - } - - private static function ffmpegAvailable(): bool - { - exec('ffmpeg -version 2>/dev/null', $out, $code); - - return $code === 0; - } - - private static function removeDir(string $dir): void - { - $items = scandir($dir); - if ($items === false) { - return; - } - - foreach ($items as $item) { - if ($item === '.' || $item === '..') { - continue; - } - $path = $dir.DIRECTORY_SEPARATOR.$item; - if (is_dir($path)) { - self::removeDir($path); - } else { - @unlink($path); - } - } - - @rmdir($dir); - } -} diff --git a/tests/Streaming/RepresentationTest.php b/tests/Streaming/RepresentationTest.php deleted file mode 100644 index a575c4d..0000000 --- a/tests/Streaming/RepresentationTest.php +++ /dev/null @@ -1,46 +0,0 @@ -setResize(1280, 720) - ->setKiloBitrate(2048) - ->setAudioKiloBitrate(128); - - $this->assertSame(1280, $rep->getWidth()); - $this->assertSame(720, $rep->getHeight()); - $this->assertSame('1280x720', $rep->getResolution()); - $this->assertSame(2048, $rep->getVideoKiloBitrate()); - $this->assertSame(2048, $rep->getKiloBitrate()); - $this->assertSame(128, $rep->getAudioKiloBitrate()); - } - - public function testInvalidResizeThrows(): void - { - $this->expectException(InvalidArgumentException::class); - (new Representation())->setResize(10, 10); - } - - public function testCollection(): void - { - $a = (new Representation())->setResize(640, 360)->setKiloBitrate(276); - $b = (new Representation())->setResize(1280, 720)->setKiloBitrate(2048); - $reps = new Representations([$a, $b]); - - $this->assertCount(2, $reps); - $this->assertSame($a, $reps->first()); - $this->assertSame($b, $reps->last()); - $this->assertSame([$a, $b], $reps->all()); - } -} diff --git a/tests/Streaming/UtilsTest.php b/tests/Streaming/UtilsTest.php deleted file mode 100644 index d61ccc1..0000000 --- a/tests/Streaming/UtilsTest.php +++ /dev/null @@ -1,33 +0,0 @@ -assertSame( - ['-b:v', '750k', '-s:v', '640x360'], - Utils::arrayToFFmpegOpt(['b:v' => '750k', 's:v' => '640x360']) - ); - } - - public function testArrayToFFmpegOptPassesThroughFlatList(): void - { - $flat = ['-map', '0:v:0', '-map', '0:a:0']; - - $this->assertSame($flat, Utils::arrayToFFmpegOpt($flat)); - } - - public function testAppendSlash(): void - { - $this->assertSame('foo/', Utils::appendSlash('foo')); - $this->assertSame('foo/', Utils::appendSlash('foo/')); - $this->assertSame('', Utils::appendSlash('')); - } -} From 58a0d28efb70560f7e06ed793860d34d738276b3 Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 2 Aug 2026 19:01:32 +0300 Subject: [PATCH 06/12] Add Code of Conduct and Contributing Guidelines; refactor project structure for video library - Introduced CODE_OF_CONDUCT.md to establish community standards. - Added CONTRIBUTING.md to guide contributions and collaboration. - Updated composer.json to reflect new project name and description. - Refactored Dockerfile and docker-compose.yml for improved testing setup. - Enhanced README.md with updated usage examples and project information. - Implemented PHPStan and PHPUnit configurations for better code quality assurance. - Established GitHub workflows for automated testing and linting. --- .github/workflows/codeql-analysis.yml | 18 + .github/workflows/linter.yml | 18 + .github/workflows/tests.yml | 30 + CODE_OF_CONDUCT.md | 76 ++ CONTRIBUTING.md | 101 +++ Dockerfile | 69 +- README.md | 384 ++++++++-- composer.json | 43 +- composer.lock | 898 ++---------------------- docker-compose.yml | 15 +- docs/design.md | 720 +++++++++++++++++++ phpstan.neon | 3 +- phpunit.xml | 13 +- pint.json | 2 +- src/Video/Adapter.php | 376 ++++++++++ src/Video/Adapter/Encoder.php | 51 ++ src/Video/Adapter/FFmpeg.php | 648 +++++++++++++++++ src/Video/Adapter/FFprobe.php | 300 ++++++++ src/Video/Adapter/Job.php | 135 ++++ src/Video/Adapter/Mock.php | 282 ++++++++ src/Video/Adapter/Named.php | 16 + src/Video/Adapter/Observable.php | 24 + src/Video/Adapter/Packager.php | 37 + src/Video/Adapter/Probe.php | 17 + src/Video/Adapter/Reads.php | 37 + src/Video/Arguments.php | 234 ++++++ src/Video/Arguments/Cmaf.php | 35 + src/Video/Arguments/Dash.php | 158 +++++ src/Video/Arguments/Hls.php | 121 ++++ src/Video/Chapter.php | 18 + src/Video/Cue.php | 43 ++ src/Video/Decimal.php | 23 + src/Video/Encoder.php | 136 ++++ src/Video/Exception.php | 12 + src/Video/Exception/Input.php | 14 + src/Video/Exception/Runtime.php | 40 ++ src/Video/Exception/Unsupported.php | 15 + src/Video/Format.php | 176 +++++ src/Video/Format/Copy.php | 55 ++ src/Video/Format/HEVC.php | 28 + src/Video/Format/VP9.php | 45 ++ src/Video/Format/X264.php | 28 + src/Video/Info.php | 103 +++ src/Video/Manifest.php | 27 + src/Video/Output.php | 112 +++ src/Video/Output/Cmaf.php | 57 ++ src/Video/Output/Dash.php | 161 +++++ src/Video/Output/Hls.php | 118 ++++ src/Video/Package.php | 122 ++++ src/Video/Packager.php | 308 ++++++++ src/Video/Parser/M3u8.php | 293 ++++++++ src/Video/Parser/Mpd.php | 359 ++++++++++ src/Video/Process.php | 194 +++++ src/Video/Progress.php | 23 + src/Video/Representation.php | 66 ++ src/Video/Segment.php | 22 + src/Video/Spritesheet.php | 94 +++ src/Video/Thumb.php | 64 ++ src/Video/Tile.php | 128 ++++ src/Video/Track.php | 34 + src/Video/Variant.php | 42 ++ tests/Video/Adapters.php | 291 ++++++++ tests/Video/E2E/Base.php | 140 ++++ tests/Video/E2E/CmafTest.php | 198 ++++++ tests/Video/E2E/DashTest.php | 194 +++++ tests/Video/E2E/EncodeTest.php | 198 ++++++ tests/Video/E2E/FFmpegTest.php | 129 ++++ tests/Video/E2E/FrameTest.php | 218 ++++++ tests/Video/E2E/HlsTest.php | 243 +++++++ tests/Video/E2E/MediaTest.php | 489 +++++++++++++ tests/Video/E2E/ProbeTest.php | 137 ++++ tests/Video/E2E/SamplesTest.php | 782 +++++++++++++++++++++ tests/Video/Samples.php | 505 +++++++++++++ tests/Video/Unit/AdapterTest.php | 468 ++++++++++++ tests/Video/Unit/ArgumentsDashTest.php | 321 +++++++++ tests/Video/Unit/ArgumentsHlsTest.php | 242 +++++++ tests/Video/Unit/EncoderTest.php | 119 ++++ tests/Video/Unit/FakeEncoder.php | 102 +++ tests/Video/Unit/FakePackager.php | 79 +++ tests/Video/Unit/FakeProbe.php | 26 + tests/Video/Unit/FormatTest.php | 120 ++++ tests/Video/Unit/FusedPackager.php | 47 ++ tests/Video/Unit/MockTest.php | 80 +++ tests/Video/Unit/OutputTest.php | 169 +++++ tests/Video/Unit/PackageTest.php | 129 ++++ tests/Video/Unit/PackagerTest.php | 246 +++++++ tests/Video/Unit/ParserM3u8Test.php | 203 ++++++ tests/Video/Unit/ParserMpdTest.php | 260 +++++++ tests/Video/Unit/ProbeFFprobeTest.php | 269 +++++++ tests/Video/Unit/ProcessTest.php | 124 ++++ tests/Video/Unit/RepresentationTest.php | 86 +++ tests/Video/Unit/SpritesheetTest.php | 136 ++++ tests/samples/.gitignore | 8 + tests/samples/in/.gitkeep | 0 tests/samples/out/.gitkeep | 0 95 files changed, 13640 insertions(+), 939 deletions(-) create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/linter.yml create mode 100644 .github/workflows/tests.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 docs/design.md create mode 100644 src/Video/Adapter.php create mode 100644 src/Video/Adapter/Encoder.php create mode 100644 src/Video/Adapter/FFmpeg.php create mode 100644 src/Video/Adapter/FFprobe.php create mode 100644 src/Video/Adapter/Job.php create mode 100644 src/Video/Adapter/Mock.php create mode 100644 src/Video/Adapter/Named.php create mode 100644 src/Video/Adapter/Observable.php create mode 100644 src/Video/Adapter/Packager.php create mode 100644 src/Video/Adapter/Probe.php create mode 100644 src/Video/Adapter/Reads.php create mode 100644 src/Video/Arguments.php create mode 100644 src/Video/Arguments/Cmaf.php create mode 100644 src/Video/Arguments/Dash.php create mode 100644 src/Video/Arguments/Hls.php create mode 100644 src/Video/Chapter.php create mode 100644 src/Video/Cue.php create mode 100644 src/Video/Decimal.php create mode 100644 src/Video/Encoder.php create mode 100644 src/Video/Exception.php create mode 100644 src/Video/Exception/Input.php create mode 100644 src/Video/Exception/Runtime.php create mode 100644 src/Video/Exception/Unsupported.php create mode 100644 src/Video/Format.php create mode 100644 src/Video/Format/Copy.php create mode 100644 src/Video/Format/HEVC.php create mode 100644 src/Video/Format/VP9.php create mode 100644 src/Video/Format/X264.php create mode 100644 src/Video/Info.php create mode 100644 src/Video/Manifest.php create mode 100644 src/Video/Output.php create mode 100644 src/Video/Output/Cmaf.php create mode 100644 src/Video/Output/Dash.php create mode 100644 src/Video/Output/Hls.php create mode 100644 src/Video/Package.php create mode 100644 src/Video/Packager.php create mode 100644 src/Video/Parser/M3u8.php create mode 100644 src/Video/Parser/Mpd.php create mode 100644 src/Video/Process.php create mode 100644 src/Video/Progress.php create mode 100644 src/Video/Representation.php create mode 100644 src/Video/Segment.php create mode 100644 src/Video/Spritesheet.php create mode 100644 src/Video/Thumb.php create mode 100644 src/Video/Tile.php create mode 100644 src/Video/Track.php create mode 100644 src/Video/Variant.php create mode 100644 tests/Video/Adapters.php create mode 100644 tests/Video/E2E/Base.php create mode 100644 tests/Video/E2E/CmafTest.php create mode 100644 tests/Video/E2E/DashTest.php create mode 100644 tests/Video/E2E/EncodeTest.php create mode 100644 tests/Video/E2E/FFmpegTest.php create mode 100644 tests/Video/E2E/FrameTest.php create mode 100644 tests/Video/E2E/HlsTest.php create mode 100644 tests/Video/E2E/MediaTest.php create mode 100644 tests/Video/E2E/ProbeTest.php create mode 100644 tests/Video/E2E/SamplesTest.php create mode 100644 tests/Video/Samples.php create mode 100644 tests/Video/Unit/AdapterTest.php create mode 100644 tests/Video/Unit/ArgumentsDashTest.php create mode 100644 tests/Video/Unit/ArgumentsHlsTest.php create mode 100644 tests/Video/Unit/EncoderTest.php create mode 100644 tests/Video/Unit/FakeEncoder.php create mode 100644 tests/Video/Unit/FakePackager.php create mode 100644 tests/Video/Unit/FakeProbe.php create mode 100644 tests/Video/Unit/FormatTest.php create mode 100644 tests/Video/Unit/FusedPackager.php create mode 100644 tests/Video/Unit/MockTest.php create mode 100644 tests/Video/Unit/OutputTest.php create mode 100644 tests/Video/Unit/PackageTest.php create mode 100644 tests/Video/Unit/PackagerTest.php create mode 100644 tests/Video/Unit/ParserM3u8Test.php create mode 100644 tests/Video/Unit/ParserMpdTest.php create mode 100644 tests/Video/Unit/ProbeFFprobeTest.php create mode 100644 tests/Video/Unit/ProcessTest.php create mode 100644 tests/Video/Unit/RepresentationTest.php create mode 100644 tests/Video/Unit/SpritesheetTest.php create mode 100644 tests/samples/.gitignore create mode 100644 tests/samples/in/.gitkeep create mode 100644 tests/samples/out/.gitkeep diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..4177c79 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,18 @@ +name: "CodeQL" + +on: [ pull_request ] +jobs: + check: + name: CodeQL + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Run CodeQL + run: | + docker run --rm -v $PWD:/app composer 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..7aed26c --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,18 @@ +name: "Linter" + +on: [ pull_request ] +jobs: + lint: + name: Linter + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Run Linter + run: | + docker run --rm -v $PWD:/app composer sh -c \ + "composer install --profile --ignore-platform-reqs && composer lint" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..42d8ac5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: "Tests" + +on: [ pull_request ] +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-versions: [ '8.1', '8.2', '8.3' ] + + 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 + sleep 5 + + - 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/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..4f6d73c --- /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](https://github.com/appwrite/appwrite/blob/master/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 master needs to be up to date. + +``` +$ git pull +``` + +2. Create new branch from `master` 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 index 44d6eb3..ad5191b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,51 @@ -FROM composer:2 AS step0 +ARG FFMPEG_VERSION=7.1.1 +ARG PHP_VERSION=8.1 + +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}" + +FROM composer:2 AS vendor ARG TESTING=false -ENV TESTING=$TESTING WORKDIR /usr/local/src/ -COPY composer.lock /usr/local/src/ -COPY composer.json /usr/local/src/ +COPY composer.json composer.lock /usr/local/src/ -RUN composer install --ignore-platform-reqs --optimize-autoloader \ - --no-plugins --no-scripts --prefer-dist --no-dev +# 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:8.1-cli-alpine AS final +FROM php:${PHP_VERSION}-cli-alpine AS final LABEL maintainer="team@appwrite.io" @@ -19,12 +53,23 @@ ENV TZ=UTC RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -RUN apk add --no-cache ffmpeg \ - && rm -rf /var/cache/apk/* +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/ WORKDIR /usr/src/code -COPY ./ /usr/src/code -COPY --from=step0 /usr/local/src/vendor /usr/src/code/vendor +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 + +RUN ffmpeg -version && ffprobe -version -CMD ["tail", "-f", "/dev/null"] +CMD [ "tail", "-f", "/dev/null" ] diff --git a/README.md b/README.md index c3ec1fb..2945b35 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,353 @@ -# Utopia Streaming +# Utopia Video -FFmpeg-based ABR packaging library for HLS and DASH under the `Utopia\Streaming` namespace. +[Tests](https://github.com/utopia-php/video/actions/workflows/tests.yml) +Total Downloads +[Discord](https://appwrite.io/discord) -## Requirements +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). -- PHP 8.1+ -- FFmpeg and FFProbe on `PATH` -- Composer +Although this library is part of the [Utopia Framework](https://github.com/utopia-php/framework) +project it is dependency free, and can be used as standalone with any other PHP project or framework. -## Install +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 shimonewman/streaming +composer require utopia-php/video ``` -## Usage +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 -use Utopia\Streaming\Adapter\FFmpeg; -use Utopia\Streaming\Format\X264; -use Utopia\Streaming\Output\Hls; -use Utopia\Streaming\Representation; -use Utopia\Streaming\Stream; - -$stream = new Stream(new FFmpeg([ - 'timeout' => 0, - 'ffmpeg.threads' => 4, -])); - -$stream - ->open('/path/to/input.mp4') - ->setFormat(new X264()) - ->addRepresentations([ - (new Representation())->setResize(640, 360)->setKiloBitrate(276)->setAudioKiloBitrate(128), - (new Representation())->setResize(1280, 720)->setKiloBitrate(2048)->setAudioKiloBitrate(128), - ]) - ->setOutput(new Hls()) - ->save('/path/to/output/stream.m3u8'); -``` - -DASH: +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\Streaming\Output\Dash; - -$stream - ->open('/path/to/input.mp4') - ->setFormat(new X264()) - ->addRepresentations([/* ... */]) - ->setOutput( - (new Dash()) - ->setUseTimeline(1) - ->setUseTemplate(1) - ->setInitSegmentName(true) - ->setMediaSegmentName(true) +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)->keyframe(2.0)) + ->add( + new Representation(width: 640, height: 360, video: 800, audio: 96), + new Representation(width: 1280, height: 720, video: 2538, audio: 128), ) - ->save('/path/to/output/stream.mpd'); + ->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. + +### Several audio languages + +A source with more than one tagged audio track produces one selectable rendition per language, in +every output — `EXT-X-MEDIA:TYPE=AUDIO` rows for HLS, one `` per language for DASH. +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 print a green status line via +`Utopia\Console::success`, and a failed backend command prints a red line via +`Console::error` 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)); +``` + +`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. + +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' +``` + +### 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 + { + // ... + } +} ``` -## Development +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.1 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 -composer install -composer test -composer analyse -composer lint +docker compose up -d +docker compose exec tests vendor/bin/phpunit --testsuite unit ``` -Docker: +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: ```bash -docker compose up --build -d -docker compose exec php8 vendor/bin/phpunit +docker compose exec tests vendor/bin/phpunit --testsuite e2e ``` -## License +### 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. + +## Copyright and license -MIT +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 index bf3dc00..3e7d06c 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,8 @@ { - "name": "shimonewman/streaming", - "description": "FFmpeg-based ABR packaging library for HLS and DASH", + "name": "utopia-php/video", + "description": "A simple library to probe, encode and package video for adaptive streaming", "type": "library", - "keywords": ["hls", "dash", "ffmpeg", "streaming", "abr", "adaptive"], + "keywords": ["php", "framework", "upf", "utopia", "streaming", "video", "hls", "dash", "cmaf", "ffmpeg"], "license": "MIT", "minimum-stability": "stable", "authors": [ @@ -12,28 +12,35 @@ } ], "autoload": { - "psr-4": {"Utopia\\Streaming\\": "src/Streaming"} + "psr-4": {"Utopia\\Video\\": "src/Video"} }, "autoload-dev": { - "psr-4": {"Utopia\\Tests\\": "tests/Streaming"} + "psr-4": {"Utopia\\Tests\\": "tests/Video"} }, - "scripts":{ - "test": "phpunit", - "analyse": "vendor/bin/phpstan analyse", - "format": "vendor/bin/pint", - "lint": "vendor/bin/pint --test" + "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", + "format": "./vendor/bin/pint", + "lint": "./vendor/bin/pint --test" }, "require": { - "php": "^8.1", - "php-ffmpeg/php-ffmpeg": "1.*.*" + "php": ">=8.1", + "ext-dom": "*", + "ext-json": "*", + "utopia-php/console": "^0.2" }, "require-dev": { - "swoole/ide-helper": "4.8.8", - "phpunit/phpunit": "^9.5.5", - "laravel/pint": "^0.2.3", - "phpstan/phpstan": "^1.8" + "phpunit/phpunit": "9.*", + "phpstan/phpstan": "1.*", + "laravel/pint": "1.*" }, - "suggest": { - "ext-swoole": "Needed to support Swoole." + "config": { + "platform": { + "php": "8.1" + } } } diff --git a/composer.lock b/composer.lock index 1c0db20..739198f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,844 +4,89 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b9bd83dae0523cdf91751e715da47ab6", + "content-hash": "a143e6cc036ae3c53911187a32e32cf3", "packages": [ { - "name": "evenement/evenement", - "version": "v3.0.2", + "name": "utopia-php/console", + "version": "0.2.3", "source": { "type": "git", - "url": "https://github.com/igorw/evenement.git", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + "url": "https://github.com/utopia-php/console.git", + "reference": "e67316f7872b6f7b2c16b246fb696d3059d108bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "url": "https://api.github.com/repos/utopia-php/console/zipball/e67316f7872b6f7b2c16b246fb696d3059d108bd", + "reference": "e67316f7872b6f7b2c16b246fb696d3059d108bd", "shasum": "" }, "require": { - "php": ">=7.0" - }, - "require-dev": { - "phpunit/phpunit": "^9 || ^6" + "php": ">=8.0", + "utopia-php/validators": "^0.2.0" }, "type": "library", "autoload": { "psr-4": { - "Evenement\\": "src/" + "Utopia\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - } - ], - "description": "Événement is a very simple event dispatching library for PHP", - "keywords": [ - "event-dispatcher", - "event-emitter" - ], - "support": { - "issues": "https://github.com/igorw/evenement/issues", - "source": "https://github.com/igorw/evenement/tree/v3.0.2" - }, - "time": "2023-08-08T05:53:35+00:00" - }, - { - "name": "php-ffmpeg/php-ffmpeg", - "version": "v1.4.0", - "source": { - "type": "git", - "url": "https://github.com/PHP-FFMpeg/PHP-FFMpeg.git", - "reference": "41fa00949ea2758cd34f076a8030df7198d57759" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHP-FFMpeg/PHP-FFMpeg/zipball/41fa00949ea2758cd34f076a8030df7198d57759", - "reference": "41fa00949ea2758cd34f076a8030df7198d57759", - "shasum": "" - }, - "require": { - "evenement/evenement": "^3.0", - "php": "^8.0 || ^8.1 || ^8.2 || ^8.3 || ^8.4 || ^8.5", - "psr/log": "^1.0 || ^2.0 || ^3.0", - "spatie/temporary-directory": "^2.0", - "symfony/cache": "^5.4 || ^6.0 || ^7.0 || ^8.0", - "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0" - }, - "require-dev": { - "mockery/mockery": "^1.5", - "phpunit/phpunit": "^9.5.10 || ^10.0" - }, - "suggest": { - "php-ffmpeg/extras": "A compilation of common audio & video drivers for PHP-FFMpeg" - }, - "type": "library", - "autoload": { - "psr-4": { - "FFMpeg\\": "src/FFMpeg", - "Alchemy\\BinaryDriver\\": "src/Alchemy/BinaryDriver" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Romain Neutron", - "email": "imprec@gmail.com", - "homepage": "http://www.lickmychip.com/" - }, - { - "name": "Phraseanet Team", - "email": "info@alchemy.fr", - "homepage": "http://www.phraseanet.com/" - }, - { - "name": "Patrik Karisch", - "email": "patrik@karisch.guru", - "homepage": "http://www.karisch.guru" - }, - { - "name": "Romain Biard", - "email": "romain.biard@gmail.com", - "homepage": "https://www.strime.io/" - }, - { - "name": "Jens Hausdorf", - "email": "hello@jens-hausdorf.de", - "homepage": "https://jens-hausdorf.de" - }, - { - "name": "Pascal Baljet", - "email": "pascal@protone.media", - "homepage": "https://protone.media" - } - ], - "description": "FFMpeg PHP, an Object Oriented library to communicate with AVconv / ffmpeg", - "keywords": [ - "audio", - "audio processing", - "avconv", - "avprobe", - "ffmpeg", - "ffprobe", - "video", - "video processing" - ], - "support": { - "issues": "https://github.com/PHP-FFMpeg/PHP-FFMpeg/issues", - "source": "https://github.com/PHP-FFMpeg/PHP-FFMpeg/tree/v1.4.0" - }, - "time": "2026-01-19T21:15:14+00:00" - }, - { - "name": "psr/cache", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], - "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" - }, - "time": "2021-02-03T23:26:27+00:00" - }, - { - "name": "psr/container", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" - }, - "time": "2021-11-05T16:47:00+00:00" - }, - { - "name": "psr/log", - "version": "3.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" - }, - "time": "2024-09-11T13:17:53+00:00" - }, - { - "name": "spatie/temporary-directory", - "version": "2.4.0", - "source": { - "type": "git", - "url": "https://github.com/spatie/temporary-directory.git", - "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/32cbb9645b28839cf4f476708e99a2c70e6802c9", - "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9", - "shasum": "" - }, - "require": { - "php": "^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Spatie\\TemporaryDirectory\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Alex Vanderbist", - "email": "alex@spatie.be", - "homepage": "https://spatie.be", - "role": "Developer" - } - ], - "description": "Easily create, use and destroy temporary directories", - "homepage": "https://github.com/spatie/temporary-directory", + "description": "Console helpers for logging, prompting, and executing commands", "keywords": [ + "cli", + "console", "php", - "spatie", - "temporary-directory" - ], - "support": { - "issues": "https://github.com/spatie/temporary-directory/issues", - "source": "https://github.com/spatie/temporary-directory/tree/2.4.0" - }, - "funding": [ - { - "url": "https://spatie.be/open-source/support-us", - "type": "custom" - }, - { - "url": "https://github.com/spatie", - "type": "github" - } - ], - "time": "2026-06-22T07:55:44+00:00" - }, - { - "name": "symfony/cache", - "version": "v7.4.14", - "source": { - "type": "git", - "url": "https://github.com/symfony/cache.git", - "reference": "9adfcb2a7fc3924473b09f5a0b058dcc2cc7be9a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/9adfcb2a7fc3924473b09f5a0b058dcc2cc7be9a", - "reference": "9adfcb2a7fc3924473b09f5a0b058dcc2cc7be9a", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "psr/cache": "^2.0|^3.0", - "psr/log": "^1.1|^2|^3", - "symfony/cache-contracts": "^3.6", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/service-contracts": "^2.5|^3", - "symfony/var-exporter": "^6.4|^7.0|^8.0" - }, - "conflict": { - "doctrine/dbal": "<3.6", - "ext-redis": "<6.1", - "ext-relay": "<0.12.1", - "symfony/dependency-injection": "<6.4", - "symfony/http-kernel": "<6.4", - "symfony/var-dumper": "<6.4" - }, - "provide": { - "psr/cache-implementation": "2.0|3.0", - "psr/simple-cache-implementation": "1.0|2.0|3.0", - "symfony/cache-implementation": "1.1|2.0|3.0" - }, - "require-dev": { - "cache/integration-tests": "dev-master", - "doctrine/dbal": "^3.6|^4", - "predis/predis": "^1.1|^2.0", - "psr/simple-cache": "^1.0|^2.0|^3.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/filesystem": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Cache\\": "" - }, - "classmap": [ - "Traits/ValueWrapper.php" - ], - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", - "homepage": "https://symfony.com", - "keywords": [ - "caching", - "psr6" - ], - "support": { - "source": "https://github.com/symfony/cache/tree/v7.4.14" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-17T14:44:48+00:00" - }, - { - "name": "symfony/cache-contracts", - "version": "v3.7.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/cache-contracts.git", - "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", - "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "psr/cache": "^3.0" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Cache\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to caching", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "terminal", + "utopia" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" + "issues": "https://github.com/utopia-php/console/issues", + "source": "https://github.com/utopia-php/console/tree/0.2.3" }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-06-26T10:48:18+00:00" }, { - "name": "symfony/deprecation-contracts", - "version": "v3.7.1", + "name": "utopia-php/validators", + "version": "0.2.8", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + "url": "https://github.com/utopia-php/validators.git", + "reference": "53c0cad30b4212627f5bce727af7b97d6ee05b5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/53c0cad30b4212627f5bce727af7b97d6ee05b5f", + "reference": "53c0cad30b4212627f5bce727af7b97d6ee05b5f", "shasum": "" }, "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-05T06:23:12+00:00" - }, - { - "name": "symfony/process", - "version": "v7.4.13", - "source": { - "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "f5804be144caceb570f6747519999636b664f24c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", - "reference": "f5804be144caceb570f6747519999636b664f24c", - "shasum": "" - }, - "require": { - "php": ">=8.2" + "php": ">=8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Process\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "Utopia\\": "src/" } - ], - "description": "Executes commands in sub-processes", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/process/tree/v7.4.13" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-05-23T16:05:06+00:00" - }, - { - "name": "symfony/service-contracts", - "version": "v3.7.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "conflict": { - "ext-psr": "<1.1|>=2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Service\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-16T09:55:08+00:00" - }, - { - "name": "symfony/var-exporter", - "version": "v7.4.14", - "source": { - "type": "git", - "url": "https://github.com/symfony/var-exporter.git", - "reference": "0118811b1d59f323bf131250b3fb919febfece28" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/0118811b1d59f323bf131250b3fb919febfece28", - "reference": "0118811b1d59f323bf131250b3fb919febfece28", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "require-dev": { - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\VarExporter\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Allows exporting any serializable PHP data structure to plain PHP code", - "homepage": "https://symfony.com", + "description": "A lightweight collection of reusable validators for Utopia projects", "keywords": [ - "clone", - "construct", - "export", - "hydrate", - "instantiate", - "lazy-loading", - "proxy", - "serialize" + "php", + "utopia", + "validation", + "validator" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.4.14" + "issues": "https://github.com/utopia-php/validators/issues", + "source": "https://github.com/utopia-php/validators/tree/0.2.8" }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-06-27T08:41:53+00:00" + "time": "2026-06-26T10:48:18+00:00" } ], "packages-dev": [ @@ -917,16 +162,16 @@ }, { "name": "laravel/pint", - "version": "v0.2.4", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "45c9fe899abfeeb7794c5a8c4074c140250a84c2" + "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/45c9fe899abfeeb7794c5a8c4074c140250a84c2", - "reference": "45c9fe899abfeeb7794c5a8c4074c140250a84c2", + "url": "https://api.github.com/repos/laravel/pint/zipball/53072e8ea22213a7ed168a8a15b96fbb8b82d44b", + "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b", "shasum": "" }, "require": { @@ -934,16 +179,16 @@ "ext-mbstring": "*", "ext-tokenizer": "*", "ext-xml": "*", - "php": "^8.0" + "php": "^8.1.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.8.0", - "illuminate/view": "^9.17.0", - "laravel-zero/framework": "^9.1.1", - "mockery/mockery": "^1.5.0", - "nunomaduro/larastan": "^2.1.11", - "nunomaduro/termwind": "^1.10.1", - "pestphp/pest": "^1.21.3" + "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" @@ -979,7 +224,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2022-07-13T17:57:52+00:00" + "time": "2025-01-14T16:20:53+00:00" }, { "name": "myclabs/deep-copy", @@ -2694,48 +1939,6 @@ ], "time": "2020-09-28T06:39:44+00:00" }, - { - "name": "swoole/ide-helper", - "version": "4.8.8", - "source": { - "type": "git", - "url": "https://github.com/swoole/ide-helper.git", - "reference": "dd87843a5040831f9ad40b68fb57879b7342ef61" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/swoole/ide-helper/zipball/dd87843a5040831f9ad40b68fb57879b7342ef61", - "reference": "dd87843a5040831f9ad40b68fb57879b7342ef61", - "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/4.8.8" - }, - "funding": [ - { - "url": "https://gitee.com/swoole/swoole?donate=true", - "type": "custom" - }, - { - "url": "https://github.com/swoole", - "type": "github" - } - ], - "time": "2022-03-17T18:24:39+00:00" - }, { "name": "theseer/tokenizer", "version": "1.3.1", @@ -2793,8 +1996,13 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^8.1" + "php": ">=8.1", + "ext-dom": "*", + "ext-json": "*" }, "platform-dev": [], + "platform-overrides": { + "php": "8.1" + }, "plugin-api-version": "2.2.0" } diff --git a/docker-compose.yml b/docker-compose.yml index b4af9e7..e48c507 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,15 @@ -version: '3.1' - services: - php8: - container_name: php + tests: + container_name: utopia-video-test-1 build: context: . dockerfile: Dockerfile + args: + TESTING: "true" + PHP_VERSION: ${PHP_VERSION:-8.1} volumes: - - ./:/usr/src/code \ No newline at end of file + - ./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..fb056c3 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,720 @@ +# Utopia\Video v2 — Design + +> Status: **implemented** · last updated 2026-07-31 · targets ffmpeg 7.1 LTS (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; backends are external binaries. + +Style: one-word public methods (`open`, `probe`, `encode`, `pack`, `grab`, `tile`, `valid`), +short class names with no redundant context (`Encoder\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) · trait Job · trait Reads · trait Decimal + +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} +``` + +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. 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 reusable across jobs, and progress always knows the duration. +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 and a probe, 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) +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 +``` + +```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 — open() restarts, encode()/pack() are terminal +$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. + +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), anything that can encode can grab + // a frame, and PHP 8.1 has no nullable intersection type — `?(Encoder&Frame)` + // needs 8.2 DNF — so a separate interface would make them unrequestable from + // one optional constructor argument. + 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 that the first `open()` of a job forgets the previous one. `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 `Console::success` line, and a failed backend command prints +`Console::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, or `-c copy` remux with `Format\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" and drives artifact naming. +// Capped CRF by default: when unset, maxrate = video (hard bitrate ceiling, -maxrate:v:N) and +// bufsize = 2 × video (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 +{ + public function crf(int $crf): static; // -crf + public function bframes(int $count): static; // -bf + public function keyframe(float $seconds): static; // -force_key_frames expr:gte(t,n_forced*S) + public function audio(string $codec): static; // override audio codec + public function params(array $params): static; // raw argv escape hatch (e.g. -dn -sn -vf …) +} +Format\X264 // libx264 + aac; defaults bf 1, keyint_min 25, g 250, sc_threshold 40 +Format\HEVC // libx265 + aac +Format\VP9 // libvpx-vp9 + libopus (DASH-only packaging — see §4) +Format\Copy // -c copy: package/remux without re-encoding (input must be keyframe-aligned) + +// Output — base: segment(float $seconds = 6.0), manifests(bool = true), name(string = 'stream'), params(array) +Output\Hls ->type(Hls::MPEGTS | Hls::FMP4) // fmp4 → init segment + EXT-X-MAP, playlist VERSION 7 + ->init('init.mp4')->master('master.m3u8')->base($url)->flags([...]); +Output\Dash ->template(bool)->timeline(bool) // both false ⇒ explicit /; default true/true + ->init($pattern)->media($pattern)->manifest('manifest.mpd'); +Output\Cmaf ->master('master.m3u8')->manifest('manifest.mpd') + ->template(bool)->timeline(bool)->init($pattern); // one fMP4 set, both manifests — see §4 + +// 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: audio streams carrying a language tag become separate audio tracks in the +package (HLS `var_stream_map` audio group / DASH audio adaptation set), with the first flagged as +default — mirroring the proven v1 tag-based detection. 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) + raw [] // full decoded backend payload — the genericness escape hatch + +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 // id · type ('video'|'audio') · mimeType · codecs · bandwidth · width · height · sar + // sampleRate · timescale · startNumber · target · segments[] · playlist (path|null) +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[] +Cue // start · end · file · x · y · w · h (structured #xywh — consumers rewrite URLs freely) + +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) +Exception\Output // Output combo unsupported by the chosen adapter (e.g. SegmentList on Shaka) +Exception\Process // backend command failed — command(): full argv, output(): stderr tail +``` + +### 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 7.1.1 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): + +- `-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 — `Packager\FFmpeg` throws `Exception\Output` for `Format\VP9` + `Cmaf`. +- `-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 library targets the most mature LTS line — **7.1**, exactly what the + repo image builds and pins — and keeps 5.x in CI as the supported floor. + +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::type(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$`, iterating `SegmentTimeline` + runs) so `segments()` is complete regardless of addressing mode. +- **Integrity check**: after parsing, every referenced segment is `stat()`-ed (existence + byte + size). Any mismatch fails `pack()` with `Exception\Process` — a truncated run can never return a + plausible-looking `Package`. + +The same pattern applies to sprites: `Spritesheet::cues()` (structured `start/end/file/x/y/w/h`) 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 +├── 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.1", "ext-dom": "*", "ext-json": "*"}`, and nothing else at +runtime — `ffmpeg`/`ffprobe` are external binaries. 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([...])` | `Packager\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\Process` with command + stderr tail | + +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}`: exact argv golden tests — 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, `Format\Copy` remux, capped-CRF + defaults (`-maxrate:v:N`/`-bufsize:v:N` derived when unset), multi-audio `var_stream_map` built + from language tags. +- `Parser\{M3u8,Mpd}` on fixture manifests: TS + fMP4 (`EXT-X-MAP`) playlists, `SegmentList` MPDs, + `SegmentTemplate` expansion (`$Number%05d$`, `SegmentTimeline` `S@t/@d/@r` runs). +- 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. 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). +- 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). +- `Process`: fake scripts exercising exit codes, timeouts, stdout/stderr line callbacks, + progress-block parsing. +- 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\Output`. +- `Package::segments()` sizes match `stat()`; `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. +- `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** — golden argv comparison against the captured legacy production command shape. + +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.1/8.3 × ffmpeg 5.x floor / 7.1 LTS target), phpstan at level **max** +over `src` and `tests`, pint (`psr12`). +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. 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\Output` instead of degrading. +4. **Rate control**: capped CRF by default — `Representation` carries optional `maxrate`/`bufsize`, + auto-derived (`maxrate = video`, `bufsize = 2 × video`) 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**: mirror v1's tag-based detection — audio streams with a language tag become + tracks, first one is default (§2); explicit selection is a later additive option. +7. **ffmpeg version**: target the most mature LTS line — 7.1, pinned in the repo image via the + `FFMPEG_VERSION` build arg; 5.x remains the documented and CI-covered floor. +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). +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. +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. + +## 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 index 5021229..256e8d6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,7 @@ parameters: - level: 5 + level: max paths: - src + - tests excludePaths: - vendor diff --git a/phpunit.xml b/phpunit.xml index 9557619..b7cc597 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,4 +1,9 @@ + - - ./tests/Streaming + + ./tests/Video/Unit + + + ./tests/Video/E2E diff --git a/pint.json b/pint.json index 93061b6..ea5e72c 100644 --- a/pint.json +++ b/pint.json @@ -1,3 +1,3 @@ { - "preset": "laravel" + "preset": "psr12" } diff --git a/src/Video/Adapter.php b/src/Video/Adapter.php new file mode 100644 index 0000000..afaadb0 --- /dev/null +++ b/src/Video/Adapter.php @@ -0,0 +1,376 @@ + + */ + 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. */ + protected const NAME = ''; + + /** The binary it drives, or an empty string when it needs none. */ + protected const BINARY = ''; + + /** Seconds a command may run. 0 disables the limit. */ + 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. + */ + 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; + + /** @var array> */ + protected array $listeners = []; + + /** + * @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; + } + + /** + * 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. + */ + public function available(): bool + { + return $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(); + } + + 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->listeners = []; + } + + /** + * @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; + } + + /** + * Green status line for a finished encode, pack, grab or tile. + * + * Quiet backends stay silent; everything else prints so a consumer sees + * the outcome without attaching a LOG listener. + * + * @return bool Whether a line was printed. + */ + protected function reportSuccess(string $message): bool + { + if ($this->level === self::QUIET) { + return false; + } + + Console::success($message); + + return true; + } + + /** + * Red status line before a command failure is thrown. + * + * @return bool Whether a line was printed. + */ + protected function reportError(string $message): bool + { + if ($this->level === self::QUIET) { + return false; + } + + Console::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, function (string $line) use (&$output): void { + $output .= $line."\n"; + }, 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 @@ +inputs === []; + + $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->inputs = []; + } + } + + 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(), + \array_values($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->inputs = []; + } + } + + 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'; + + $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]; + } + + /** + * 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_values(\array_map( + static fn (array $track): string => $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 === 'und' || \str_contains($line, 'LANGUAGE=')) { + continue; + } + + $lines[$index] = $line.',LANGUAGE="'.$language.'"'; + } + + \file_put_contents($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'); + \file_put_contents($master, \implode("\n", $lines)); + } +} diff --git a/src/Video/Adapter/FFprobe.php b/src/Video/Adapter/FFprobe.php new file mode 100644 index 0000000..244c70f --- /dev/null +++ b/src/Video/Adapter/FFprobe.php @@ -0,0 +1,300 @@ +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'); + } + + 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 = []; + + foreach ($streams as $stream) { + $type = \is_string($stream['codec_type'] ?? null) ? $stream['codec_type'] : Track::DATA; + + if ($type === Track::VIDEO && $this->artwork($stream)) { + $cover ??= $this->integer($stream, 'index') ?? 0; + } + + if ($type === Track::VIDEO && $video === null && ! $this->artwork($stream)) { + $video = $stream; + } + + if ($type === Track::AUDIO) { + if ($audio === null) { + $audio = $stream; + } + + $audioTracks[] = [ + 'codec' => $this->string($stream, 'codec_name') ?? '', + 'language' => $this->tag($stream, 'language') ?? 'und', + ]; + } + + $tracks[] = $this->track($stream, $type); + } + + $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) : null, + cover: $cover, + raw: $data, + ); + } + + /** + * @param array $stream + */ + private function track(array $stream, string $type): 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: $this->tag($stream, 'language'), + title: $this->tag($stream, 'title'), + default: $this->integer($disposition, 'default') === 1, + forced: $this->integer($disposition, 'forced') === 1, + tags: $this->tags($stream), + ); + } + + /** + * @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; + } + + $chapters[] = new Chapter( + start: (float) ($chapter['start_time'] ?? 0), + end: (float) ($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 + { + return \is_array($stream['disposition'] ?? null) + && (int) ($stream['disposition']['attached_pic'] ?? 0) === 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 + */ + private function rotation(array $stream): ?int + { + if (\is_array($stream['side_data_list'] ?? null)) { + foreach ($stream['side_data_list'] as $side) { + if (\is_array($side) && isset($side['rotation'])) { + return (int) $side['rotation']; + } + } + } + + $tag = $this->tag($stream, 'rotate'); + + return $tag === null ? null : (int) $tag; + } + + /** + * @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; + } + + /** + * @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..04c2a04 --- /dev/null +++ b/src/Video/Adapter/Job.php @@ -0,0 +1,135 @@ + */ + protected array $inputs = []; + + /** @var list */ + protected array $reps = []; + + protected ?Output $target = null; + + protected ?Format $encoding = null; + + /** + * Register an input. + * + * The first call of a job starts a new one, dropping everything the last + * job left behind; 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 + { + if ($this->inputs === []) { + $this->restart(); + } + + $this->inputs[] = ['path' => $this->source($path), 'rep' => $as]; + + 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; + } + + /** + * Forget the previous job entirely, listeners included. + */ + protected function restart(): void + { + $this->inputs = []; + $this->reps = []; + $this->target = null; + $this->encoding = null; + $this->forget(); + } + + /** + * 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..cdc3d2f --- /dev/null +++ b/src/Video/Adapter/Mock.php @@ -0,0 +1,282 @@ +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)); + $this->progress(); + + \file_put_contents($path, 'mock encode of '.\basename($source)); + + try { + $path = $this->wrote($path); + $this->reportSuccess(self::NAME.': encoded '.$path); + + return $path; + } finally { + // Both terminals end the job, as in the real adapters. + $this->inputs = []; + } + } + + 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); + $this->progress(); + + $variants = []; + + foreach (\array_values($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->inputs = []; + $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); + $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(); + $dir = $this->directory($dir); + + $interval = $options->every($this->duration); + $width = $options->size(); + $height = (int) \round($width / ($this->width / \max(1, $this->height))); + + $file = $options->base().'1.jpg'; + \file_put_contents($dir.'/'.$file, 'mock sheet'); + + $cues = []; + $thumbs = (int) \ceil($this->duration / $interval); + + for ($index = 0; $index < $thumbs && $index < $options->cells(); $index++) { + $cues[] = new Cue( + start: $index * $interval, + end: \min(($index + 1) * $interval, $this->duration), + file: $file, + x: ($index % $options->columns()) * $width, + y: \intdiv($index, $options->columns()) * $height, + width: $width, + height: $height, + ); + } + + $vtt = null; + + if ($options->writes()) { + $vtt = $dir.'/'.$options->base().'.vtt'; + \file_put_contents($vtt, (new Spritesheet([$dir.'/'.$file], $cues))->render()); + } + + $this->reportSuccess(self::NAME.': tiled '.$dir); + + return new Spritesheet([$dir.'/'.$file], $cues, $vtt, $width, $height); + } + + protected function prober(): Probe + { + return $this->prober ?? $this; + } + + /** + * Emits the same shape of progress a real backend would. + * + * Progress is structured data rather than commentary, so it is reported at + * every level; the log line is commentary, so a quiet backend withholds it. + */ + private function progress(): void + { + foreach ([25.0, 50.0, 100.0] as $percent) { + $this->emit(Observable::PROGRESS, new Progress( + percent: $percent, + time: $this->duration * ($percent / 100), + )); + } + + if ($this->level !== self::QUIET) { + $this->emit(Observable::LOG, self::NAME.': done'); + } + } + + /** + * @return list + */ + private function tracks(): array + { + $tracks = []; + + if ($this->width > 0 && $this->height > 0) { + $tracks[] = new Track(index: 0, type: Track::VIDEO, codec: 'h264'); + } + + if ($this->audio) { + $tracks[] = new Track(index: \count($tracks), type: Track::AUDIO, codec: 'aac'); + } + + return $tracks; + } +} diff --git a/src/Video/Adapter/Named.php b/src/Video/Adapter/Named.php new file mode 100644 index 0000000..63545c9 --- /dev/null +++ b/src/Video/Adapter/Named.php @@ -0,0 +1,16 @@ +prober()->read($path); + } catch (Input|Runtime) { + return false; + } + + return $info->duration > 0 && ($info->hasVideo || $info->hasAudio); + } + + abstract protected function prober(): Probe; +} diff --git a/src/Video/Arguments.php b/src/Video/Arguments.php new file mode 100644 index 0000000..7ecb7d9 --- /dev/null +++ b/src/Video/Arguments.php @@ -0,0 +1,234 @@ + $reps + * @param int $inputs How many files were opened. More than one means each + * rung reads from its own already encoded input. + */ + public function __construct( + protected readonly Info $info, + protected readonly Format $format, + protected readonly array $reps, + protected readonly Output $output, + protected readonly string $dir, + protected readonly int $inputs = 1, + ) { + if ($reps === []) { + throw new Unsupported('At least one representation is required'); + } + + if (! \in_array($output->type(), $format->supports(), true)) { + throw new Unsupported( + \strtoupper($output->type()).' cannot carry '.$format->codec() + .'; supported: '.\implode(', ', $format->supports()), + ); + } + } + + /** + * The builder that matches the requested output. + * + * Keeping the choice here means callers deal with one name rather than + * importing three builders alongside the three outputs they mirror. + * + * @param list $reps + */ + public static function for( + Info $info, + Format $format, + array $reps, + Output $output, + string $dir, + int $inputs = 1, + ): self { + return match (true) { + $output instanceof Output\Cmaf => new Arguments\Cmaf($info, $format, $reps, $output, $dir, $inputs), + $output instanceof Output\Dash => new Arguments\Dash($info, $format, $reps, $output, $dir, $inputs), + $output instanceof Output\Hls => new Arguments\Hls($info, $format, $reps, $output, $dir, $inputs), + default => throw new Unsupported('Unsupported output "'.$output::class.'"'), + }; + } + + /** + * Arguments that follow the input file, excluding the output path. + * + * @return list + */ + abstract public function build(): array; + + /** + * The path ffmpeg is asked to write. + */ + abstract public function target(): string; + + /** + * Whether this job produces video at all. + */ + protected function visual(): bool + { + if (! $this->info->hasVideo) { + return false; + } + + foreach ($this->reps as $rep) { + if ($rep->scaled()) { + return true; + } + } + + return false; + } + + /** + * Audio streams to carry through, one entry per output audio stream. + * + * @return list + */ + protected function sound(): array + { + if (! $this->info->hasAudio) { + return []; + } + + $tracks = []; + + foreach ($this->info->audioTracks as $index => $track) { + $language = $track['language']; + + if ($language === '' || $language === 'und') { + continue; + } + + $tracks[] = ['index' => $index, 'language' => $language]; + } + + // Nothing carried a language tag, so there is just one anonymous track. + return $tracks === [] ? [['index' => 0, 'language' => '']] : $tracks; + } + + /** + * Stream selection: each video rung reads the same source stream, and every + * audio track is carried once. + * + * @return list + */ + protected function maps(): array + { + $args = []; + $split = $this->inputs > 1; + + foreach ($this->rungs() as $position => $rep) { + $args[] = '-map'; + $args[] = ($split ? $position : 0).':v:0'; + } + + foreach ($this->sound() as $track) { + $args[] = '-map'; + $args[] = '0:a:'.($split ? 0 : $track['index']); + } + + return $args; + } + + /** + * Codec arguments plus the per-rung sizing and rate control. + * + * @return list + */ + protected function streams(): array + { + $visual = $this->visual(); + $tracks = $this->sound(); + + $args = $this->format->build($visual, $tracks !== []); + + if ($visual) { + $position = 0; + + foreach ($this->reps as $rep) { + if (! $rep->scaled()) { + continue; + } + + foreach ($this->scale($rep, $position) as $arg) { + $args[] = $arg; + } + + $position++; + } + } + + foreach (\array_keys($tracks) as $position) { + $rep = $this->reps[\min($position, \count($this->reps) - 1)]; + + $args[] = '-b:a:'.$position; + $args[] = $rep->audio.'k'; + } + + return $args; + } + + /** + * Sizing and rate control for a single rung. + * + * The frame is fitted inside the requested box and padded back out to it, + * so the resolution advertised in the manifest is the resolution delivered + * no matter what shape the source was. + * + * @return list + */ + protected function scale(Representation $rep, int $position): array + { + return [ + '-filter:v:'.$position, + 'scale='.$rep->width.':'.$rep->height.':force_original_aspect_ratio=decrease,' + .'pad='.$rep->width.':'.$rep->height.':(ow-iw)/2:(oh-ih)/2,setsar=1:1', + '-b:v:'.$position, + $rep->video.'k', + '-maxrate:v:'.$position, + $rep->maxrate.'k', + '-bufsize:v:'.$position, + $rep->bufsize.'k', + ]; + } + + /** + * Representations that actually produce a video rung. + * + * @return list + */ + protected function rungs(): array + { + if (! $this->visual()) { + return []; + } + + return \array_values(\array_filter( + $this->reps, + static fn (Representation $rep): bool => $rep->scaled(), + )); + } + + protected function path(string $file): string + { + return \rtrim($this->dir, '/').'/'.$file; + } +} diff --git a/src/Video/Arguments/Cmaf.php b/src/Video/Arguments/Cmaf.php new file mode 100644 index 0000000..03192db --- /dev/null +++ b/src/Video/Arguments/Cmaf.php @@ -0,0 +1,35 @@ +output; + + if (! $cmaf instanceof CmafOutput) { + throw new Unsupported('Expected a CMAF output'); + } + + return [ + // The HLS side of the muxer is skipped unless segments are MP4. + '-dash_segment_type', 'mp4', + '-hls_playlist', '1', + '-hls_master_name', $cmaf->masterFile(), + ]; + } +} diff --git a/src/Video/Arguments/Dash.php b/src/Video/Arguments/Dash.php new file mode 100644 index 0000000..5755f80 --- /dev/null +++ b/src/Video/Arguments/Dash.php @@ -0,0 +1,158 @@ + $reps + */ + public function __construct( + Info $info, + Format $format, + array $reps, + Output $output, + string $dir, + int $inputs = 1, + ) { + parent::__construct($info, $format, $reps, $output, $dir, $inputs); + + $this->shaped(); + } + + /** + * Every rung of a DASH ladder has to be the same shape. + * + * All video rungs go into one adaptation set, and a set may only hold + * representations that are alternatives of each other — so the muxer refuses + * a set whose members disagree on aspect ratio. It only says so once the + * command is already running ("Conflicting stream aspect ratios values"), + * with no mention of which rungs clashed, so the check happens here instead. + * HLS has no such rule and is left alone. + */ + private function shaped(): void + { + $shapes = []; + + foreach ($this->rungs() as $rep) { + $shapes[self::shape($rep->width, $rep->height)][] = $rep->name.' ('.$rep->resolution().')'; + } + + if (\count($shapes) < 2) { + return; + } + + $described = []; + + foreach ($shapes as $shape => $names) { + $described[] = $shape.' → '.\implode(', ', $names); + } + + throw new Unsupported( + \strtoupper($this->output->type()).' needs every rung to share one aspect ratio; got ' + .\implode('; ', $described) + .'. Choose sizes that keep the same shape, or package as HLS, which allows a mix.', + ); + } + + private static function shape(int $width, int $height): string + { + $a = $width; + $b = $height; + + while ($b !== 0) { + [$a, $b] = [$b, $a % $b]; + } + + $divisor = $a === 0 ? 1 : $a; + + return \intdiv($width, $divisor).':'.\intdiv($height, $divisor); + } + + public function build(): array + { + $dash = $this->output; + + if (! $dash instanceof DashOutput) { + throw new Unsupported('Expected a DASH output'); + } + + $args = $this->maps(); + + foreach ($this->streams() as $arg) { + $args[] = $arg; + } + + $args[] = '-f'; + $args[] = 'dash'; + $args[] = '-seg_duration'; + $args[] = self::seconds($dash->duration()); + $args[] = '-use_template'; + $args[] = $dash->templated() ? '1' : '0'; + $args[] = '-use_timeline'; + $args[] = $dash->timelined() ? '1' : '0'; + $args[] = '-window_size'; + $args[] = '0'; + $args[] = '-init_seg_name'; + $args[] = $dash->initPattern(); + $args[] = '-media_seg_name'; + $args[] = $dash->mediaPattern(); + $args[] = '-adaptation_sets'; + $args[] = $dash->adaptations(\count($this->rungs()), \count($this->sound())); + + foreach ($this->muxer() as $arg) { + $args[] = $arg; + } + + foreach ($dash->extra() as $param) { + $args[] = $param; + } + + return $args; + } + + public function target(): string + { + $dash = $this->output; + + if (! $dash instanceof DashOutput) { + throw new Unsupported('Expected a DASH output'); + } + + return $this->path($dash->manifestFile()); + } + + /** + * Hook for subclasses that need extra muxer options. + * + * @return list + */ + protected function muxer(): array + { + return []; + } + + protected static function seconds(float $value): string + { + if ((float) (int) $value === $value) { + return (string) (int) $value; + } + + return \rtrim(\rtrim(\sprintf('%.3F', $value), '0'), '.'); + } +} diff --git a/src/Video/Arguments/Hls.php b/src/Video/Arguments/Hls.php new file mode 100644 index 0000000..5f7f624 --- /dev/null +++ b/src/Video/Arguments/Hls.php @@ -0,0 +1,121 @@ +output; + + if (! $hls instanceof HlsOutput) { + throw new Unsupported('Expected an HLS output'); + } + + $args = $this->maps(); + + foreach ($this->streams() as $arg) { + $args[] = $arg; + } + + $args[] = '-f'; + $args[] = 'hls'; + $args[] = '-hls_time'; + $args[] = self::seconds($hls->duration()); + $args[] = '-hls_list_size'; + $args[] = '0'; + $args[] = '-hls_playlist_type'; + $args[] = 'vod'; + $args[] = '-hls_segment_type'; + $args[] = $hls->container(); + $args[] = '-hls_segment_filename'; + $args[] = $this->path($hls->base().'_%v_%04d.'.$hls->extension()); + + if ($hls->fragmented()) { + $args[] = '-hls_fmp4_init_filename'; + $args[] = $hls->base().'_%v_'.$hls->initFile(); + } + + if ($hls->prefix() !== '') { + $args[] = '-hls_base_url'; + $args[] = $hls->prefix(); + } + + if ($hls->hlsFlags() !== []) { + $args[] = '-hls_flags'; + $args[] = \implode('+', $hls->hlsFlags()); + } + + $args[] = '-master_pl_name'; + $args[] = $hls->masterFile(); + $args[] = '-var_stream_map'; + $args[] = $this->map(); + + foreach ($hls->extra() as $param) { + $args[] = $param; + } + + return $args; + } + + public function target(): string + { + return $this->path($this->output->base().'_%v.m3u8'); + } + + /** + * Describes each variant, and which audio group the video rungs pull from. + */ + private function map(): string + { + $entries = []; + + foreach ($this->rungs() as $position => $rep) { + $entry = 'v:'.$position.',name:'.$rep->name; + + if ($this->sound() !== []) { + $entry .= ',agroup:audio'; + } + + $entries[] = $entry; + } + + foreach ($this->sound() as $position => $track) { + $entry = 'a:'.$position.',agroup:audio'; + + if ($track['language'] !== '') { + $entry .= ',language:'.$track['language']; + } + + $entry .= ',name:audio_'.$position; + + if ($position === 0) { + $entry .= ',default:yes'; + } + + $entries[] = $entry; + } + + return \implode(' ', $entries); + } + + private static function seconds(float $value): string + { + if ((float) (int) $value === $value) { + return (string) (int) $value; + } + + return \rtrim(\rtrim(\sprintf('%.3F', $value), '0'), '.'); + } +} diff --git a/src/Video/Chapter.php b/src/Video/Chapter.php new file mode 100644 index 0000000..c464a20 --- /dev/null +++ b/src/Video/Chapter.php @@ -0,0 +1,18 @@ +start).' --> '.self::timestamp($this->end)."\n" + .($url ?? $this->file).'#xywh='.$this->x.','.$this->y.','.$this->width.','.$this->height; + } + + public static function timestamp(float $seconds): string + { + $hours = (int) ($seconds / 3600); + $minutes = (int) (($seconds - ($hours * 3600)) / 60); + $rest = $seconds - ($hours * 3600) - ($minutes * 60); + + return \sprintf('%02d:%02d:%06.3f', $hours, $minutes, $rest); + } +} diff --git a/src/Video/Decimal.php b/src/Video/Decimal.php new file mode 100644 index 0000000..4052cee --- /dev/null +++ b/src/Video/Decimal.php @@ -0,0 +1,23 @@ +adapter = $adapter ?? new FFmpeg(); + $this->prober = $probe ?? new FFprobe(); + + // Config is pushed into the adapter rather than passed to its + // constructor, so an adapter supplied by the caller cannot quietly keep + // a probe of its own and ignore the one asked for here. + if ($this->adapter instanceof Adapter) { + $this->adapter->setProbe($this->prober); + } + } + + /** + * Which backend is doing the encoding. + */ + public function getName(): string + { + return $this->adapter->getName(); + } + + /** + * Everything that can be learned about a source without decoding it. + */ + public function probe(string $path): Info + { + return $this->prober->read($path); + } + + /** + * Whether a source is usable as input. + */ + public function valid(string $path): bool + { + return $this->prober->valid($path); + } + + /** + * Write a single still image and return its path. + */ + public function grab(string $path, string $output, ?Thumb $options = null): string + { + return $this->adapter->grab($path, $output, $options); + } + + /** + * Write sprite sheets covering the whole timeline. + */ + public function tile(string $path, string $dir, ?Tile $options = null): Spritesheet + { + return $this->adapter->tile($path, $dir, $options); + } + + /** + * Start a job. Anything configured by a previous one is discarded. + */ + public function open(string $path): static + { + if (! \is_file($path)) { + throw new Exception\Input('Source "'.$path.'" does not exist'); + } + + // Passed straight through: the adapter's own open() makes the same + // promise about dropping the last job's format, rungs and listeners, so + // there is nothing worth buffering here. + $this->adapter->open($path); + + return $this; + } + + public function format(Format $format): static + { + $this->adapter->format($format); + + return $this; + } + + public function add(Representation ...$representations): static + { + $this->adapter->add(...$representations); + + return $this; + } + + /** + * @param callable $listener + */ + public function on(string $event, callable $listener): static + { + $this->adapter->on($event, $listener); + + return $this; + } + + /** + * Encode to a single file and return the path written. + */ + public function encode(string $path): string + { + return $this->adapter->encode($path); + } +} diff --git a/src/Video/Exception.php b/src/Video/Exception.php new file mode 100644 index 0000000..592bd40 --- /dev/null +++ b/src/Video/Exception.php @@ -0,0 +1,12 @@ + $command The argv that failed, when one was run. + * @param string $output The tail of what the command complained about. + */ + public function __construct( + string $message, + private readonly array $command = [], + private readonly string $output = '', + int $code = 0, + ) { + parent::__construct($message, $code); + } + + /** + * @return list + */ + public function command(): array + { + return $this->command; + } + + public function output(): string + { + return $this->output; + } +} diff --git a/src/Video/Exception/Unsupported.php b/src/Video/Exception/Unsupported.php new file mode 100644 index 0000000..a33ba72 --- /dev/null +++ b/src/Video/Exception/Unsupported.php @@ -0,0 +1,15 @@ + */ + protected array $params = []; + + public function __construct(?string $video = null, ?string $audio = null) + { + $this->video = $video ?? $this->video(); + $this->audio = $audio ?? $this->audio(); + } + + /** + * Default video codec for this preset. + */ + abstract public function video(): string; + + /** + * Default audio codec for this preset. + */ + abstract public function audio(): string; + + /** + * Codec specific arguments applied before any user supplied parameters. + * + * @return list + */ + abstract public function defaults(): array; + + /** + * Output formats this codec can be packaged into. + * + * @return list + */ + public function supports(): array + { + return [Output::HLS, Output::DASH, Output::CMAF]; + } + + public function codec(): string + { + return $this->video; + } + + public function sound(): string + { + return $this->audio; + } + + /** + * Constant rate factor: lower means better quality and a larger file. + */ + public function crf(int $crf): static + { + $this->crf = $crf; + + return $this; + } + + /** + * Number of consecutive B frames the encoder may use. + */ + public function bframes(int $count): static + { + $this->bframes = $count; + + return $this; + } + + /** + * Force a keyframe every N seconds so segments can be cut cleanly. + */ + public function keyframe(float $seconds): static + { + $this->keyframe = $seconds; + + return $this; + } + + /** + * Raw arguments appended after everything this class understands. + * + * @param list $params + */ + public function params(array $params): static + { + $this->params = $params; + + return $this; + } + + public function interval(): ?float + { + return $this->keyframe; + } + + /** + * Codec arguments for one job, without any stream index suffix. + * + * @return list + */ + public function build(bool $video = true, bool $audio = true): array + { + $args = []; + + if ($video) { + $args[] = '-c:v'; + $args[] = $this->video; + } + + if ($audio) { + $args[] = '-c:a'; + $args[] = $this->audio; + } + + if ($video) { + foreach ($this->defaults() as $default) { + $args[] = $default; + } + + if ($this->crf !== null) { + $args[] = '-crf'; + $args[] = (string) $this->crf; + } + + if ($this->bframes !== null) { + $args[] = '-bf'; + $args[] = (string) $this->bframes; + } + + if ($this->keyframe !== null) { + $args[] = '-force_key_frames'; + $args[] = 'expr:gte(t,n_forced*'.self::number($this->keyframe).')'; + } + } + + foreach ($this->params as $param) { + $args[] = $param; + } + + return $args; + } + + protected static function number(float $value): string + { + if ((float) (int) $value === $value) { + return (string) (int) $value; + } + + return \rtrim(\rtrim(\sprintf('%.3F', $value), '0'), '.'); + } +} diff --git a/src/Video/Format/Copy.php b/src/Video/Format/Copy.php new file mode 100644 index 0000000..8b7cc00 --- /dev/null +++ b/src/Video/Format/Copy.php @@ -0,0 +1,55 @@ +params as $param) { + $args[] = $param; + } + + return $args; + } +} diff --git a/src/Video/Format/HEVC.php b/src/Video/Format/HEVC.php new file mode 100644 index 0000000..bc10c0a --- /dev/null +++ b/src/Video/Format/HEVC.php @@ -0,0 +1,28 @@ + $audioTracks + * @param array $tags + * @param list $tracks + * @param list $chapters + * @param array $raw + */ + public function __construct( + public readonly float $duration = 0.0, + public readonly string $format = '', + public readonly bool $hasVideo = false, + public readonly bool $hasAudio = false, + public readonly ?int $width = null, + public readonly ?int $height = null, + public readonly ?string $aspect = null, + public readonly ?float $fps = null, + public readonly ?string $fpsMode = null, + public readonly ?string $videoCodec = null, + public readonly ?string $videoFormat = null, + public readonly ?string $videoProfile = null, + public readonly ?int $videoBitrate = null, + public readonly ?string $audioCodec = null, + public readonly ?string $audioFormat = null, + public readonly ?int $audioBitrate = null, + public readonly ?int $sampleRate = null, + public readonly array $audioTracks = [], + public readonly array $tags = [], + public readonly array $tracks = [], + public readonly array $chapters = [], + public readonly ?int $rotation = null, + /** + * Stream index of an embedded cover image, when the source carries one. + * + * A sound file with artwork reports no video, but there is still a + * picture to be had; this is where it is. + */ + public readonly ?int $cover = null, + public readonly array $raw = [], + ) { + } + + /** + * Duration in milliseconds, which is how most catalogues store it. + */ + public function milliseconds(): int + { + return (int) \round($this->duration * 1000); + } + + /** + * Display aspect ratio derived from the frame size when the container does + * not carry one of its own. + */ + public function ratio(): ?string + { + if ($this->aspect !== null) { + return $this->aspect; + } + + if (! $this->width || ! $this->height) { + return null; + } + + $divisor = self::gcd($this->width, $this->height); + + return ($this->width / $divisor).':'.($this->height / $divisor); + } + + /** + * @return list + */ + public function tracks(string $type): array + { + return \array_values(\array_filter( + $this->tracks, + static fn (Track $track): bool => $track->type === $type, + )); + } + + private static function gcd(int $a, int $b): int + { + while ($b !== 0) { + [$a, $b] = [$b, $a % $b]; + } + + return $a === 0 ? 1 : $a; + } +} diff --git a/src/Video/Manifest.php b/src/Video/Manifest.php new file mode 100644 index 0000000..065263d --- /dev/null +++ b/src/Video/Manifest.php @@ -0,0 +1,27 @@ +path); + } +} diff --git a/src/Video/Output.php b/src/Video/Output.php new file mode 100644 index 0000000..bffec98 --- /dev/null +++ b/src/Video/Output.php @@ -0,0 +1,112 @@ + */ + protected array $params = []; + + /** + * Which of the constants above this output represents. + */ + abstract public function type(): string; + + /** + * Target segment length in seconds. + */ + public function segment(float $seconds): static + { + $this->segment = $seconds; + + return $this; + } + + /** + * Whether to keep the playlist files after they have been read. + * + * Segment metadata is returned either way; this only decides whether the + * manifests themselves survive on disk. + */ + public function manifests(bool $write): static + { + $this->manifests = $write; + + return $this; + } + + /** + * Base name shared by every artifact this job writes. + */ + public function name(string $base): static + { + $this->name = $base; + + return $this; + } + + /** + * Raw muxer arguments appended to the generated command. + * + * @param list $params + */ + public function params(array $params): static + { + $this->params = $params; + + return $this; + } + + public function duration(): float + { + return $this->segment; + } + + public function keeps(): bool + { + return $this->manifests; + } + + public function base(): string + { + return $this->name; + } + + /** + * @return list + */ + public function extra(): array + { + return $this->params; + } + + protected static function number(float $value): string + { + if ((float) (int) $value === $value) { + return (string) (int) $value; + } + + return \rtrim(\rtrim(\sprintf('%.3F', $value), '0'), '.'); + } +} diff --git a/src/Video/Output/Cmaf.php b/src/Video/Output/Cmaf.php new file mode 100644 index 0000000..e0d6a4b --- /dev/null +++ b/src/Video/Output/Cmaf.php @@ -0,0 +1,57 @@ +manifest = 'manifest.mpd'; + + // Segment addressing has to be explicit for the two manifests to stay + // in step with each other, and listing is what CMAF consumers expect. + $this->template = false; + $this->timeline = false; + } + + public function type(): string + { + return Output::CMAF; + } + + public function master(string $filename): static + { + $this->master = $filename; + + return $this; + } + + public function masterFile(): string + { + return $this->master; + } + + public function initPattern(): string + { + return $this->initName ?? $this->name.'_init_$RepresentationID$.$ext$'; + } + + public function mediaPattern(): string + { + return $this->mediaName ?? $this->name.'_chunk_$RepresentationID$_$Number%05d$.$ext$'; + } +} diff --git a/src/Video/Output/Dash.php b/src/Video/Output/Dash.php new file mode 100644 index 0000000..fda10e5 --- /dev/null +++ b/src/Video/Output/Dash.php @@ -0,0 +1,161 @@ +template = $enabled; + + return $this; + } + + /** + * Include an explicit timeline of segment durations. + */ + public function timeline(bool $enabled): static + { + $this->timeline = $enabled; + + return $this; + } + + public function manifest(string $filename): static + { + $this->manifest = $filename; + + return $this; + } + + /** + * Pattern for initialisation segments, in ffmpeg's template syntax. + */ + public function init(string $pattern): static + { + $this->initName = $pattern; + + return $this; + } + + /** + * Pattern for media segments, in ffmpeg's template syntax. + */ + public function media(string $pattern): static + { + $this->mediaName = $pattern; + + return $this; + } + + /** + * Override how streams are grouped into adaptation sets. + */ + public function sets(string $definition): static + { + $this->sets = $definition; + + return $this; + } + + public function templated(): bool + { + return $this->template; + } + + public function timelined(): bool + { + return $this->timeline; + } + + /** + * True when the manifest will list every segment individually. + */ + public function listed(): bool + { + return ! $this->template && ! $this->timeline; + } + + public function manifestFile(): string + { + return $this->manifest; + } + + public function initPattern(): string + { + return $this->initName ?? $this->name.'_init_$RepresentationID$.$ext$'; + } + + public function mediaPattern(): string + { + return $this->mediaName ?? $this->name.'_chunk_$RepresentationID$_$Number%05d$.$ext$'; + } + + /** + * How streams are grouped into adaptation sets. + * + * A set holds representations that are alternatives of one another, and a + * player picks one from each set. Video rungs are alternatives, so they share + * a set. Audio tracks in different languages are not — they are separate + * choices — so each gets a set of its own, which is also the only place DASH + * can record a language. + * + * @param int $video Video rungs, occupying the first output streams. + * @param int $audio Audio tracks, following them. + */ + public function adaptations(int $video = 1, int $audio = 1): string + { + if ($this->sets !== null) { + return $this->sets; + } + + $sets = []; + $id = 0; + + if ($video > 0) { + $sets[] = 'id='.$id++.',streams=v'; + } + + if ($audio === 1) { + $sets[] = 'id='.$id++.',streams=a'; + } else { + for ($track = 0; $track < $audio; $track++) { + $sets[] = 'id='.$id++.',streams='.($video + $track); + } + } + + return \implode(' ', $sets); + } +} diff --git a/src/Video/Output/Hls.php b/src/Video/Output/Hls.php new file mode 100644 index 0000000..9098f8c --- /dev/null +++ b/src/Video/Output/Hls.php @@ -0,0 +1,118 @@ + */ + private array $flags = ['independent_segments']; + + public function type(): string + { + return Output::HLS; + } + + /** + * Transport stream segments, or fragmented MP4 for CMAF compatible output. + */ + public function segments(string $type): static + { + $this->segments = $type; + + return $this; + } + + /** + * Name of the initialisation segment, used by fragmented MP4 output only. + */ + public function init(string $filename): static + { + $this->init = $filename; + + return $this; + } + + public function master(string $filename): static + { + $this->master = $filename; + + return $this; + } + + /** + * Prefix written in front of every segment reference in the playlists. + */ + public function url(string $url): static + { + $this->url = $url; + + return $this; + } + + /** + * @param list $flags + */ + public function flags(array $flags): static + { + $this->flags = $flags; + + return $this; + } + + public function fragmented(): bool + { + return $this->segments === self::FMP4; + } + + public function container(): string + { + return $this->segments; + } + + public function extension(): string + { + return $this->fragmented() ? 'm4s' : 'ts'; + } + + public function initFile(): string + { + return $this->init; + } + + public function masterFile(): string + { + return $this->master; + } + + public function prefix(): string + { + return $this->url; + } + + /** + * @return list + */ + public function hlsFlags(): array + { + return $this->flags; + } +} diff --git a/src/Video/Package.php b/src/Video/Package.php new file mode 100644 index 0000000..7d8f789 --- /dev/null +++ b/src/Video/Package.php @@ -0,0 +1,122 @@ + $variants + * @param list $manifests + * @param array $metadata + */ + public function __construct( + private readonly array $variants = [], + private readonly array $manifests = [], + private readonly array $metadata = [], + private readonly float $duration = 0.0, + ) { + } + + /** + * @return list + */ + public function variants(): array + { + return $this->variants; + } + + /** + * Every segment across every variant, or just one variant's when named. + * + * @return list + */ + public function segments(?string $variant = null): array + { + $segments = []; + + foreach ($this->variants as $candidate) { + if ($variant !== null && $candidate->id !== $variant) { + continue; + } + + foreach ($candidate->segments as $segment) { + $segments[] = $segment; + } + } + + return $segments; + } + + /** + * Playlists written to disk. Empty when manifests were not kept. + * + * @return list + */ + public function manifests(): array + { + return $this->manifests; + } + + /** + * Every artifact produced, ready to be handed to a storage layer. + * + * @return list + */ + public function files(): array + { + $files = []; + + foreach ($this->manifests as $manifest) { + $files[] = $manifest->path; + } + + foreach ($this->segments() as $segment) { + $files[] = $segment->path; + } + + return \array_values(\array_unique($files)); + } + + /** + * Container level attributes, keyed by the manifest they came from. + * + * @return array + */ + public function metadata(): array + { + return $this->metadata; + } + + public function duration(): float + { + return $this->duration; + } + + public function variant(string $id): ?Variant + { + foreach ($this->variants as $variant) { + if ($variant->id === $id) { + return $variant; + } + } + + return null; + } + + public function size(): int + { + $size = 0; + + foreach ($this->segments() as $segment) { + $size += $segment->size; + } + + return $size; + } +} diff --git a/src/Video/Packager.php b/src/Video/Packager.php new file mode 100644 index 0000000..220a910 --- /dev/null +++ b/src/Video/Packager.php @@ -0,0 +1,308 @@ + */ + protected array $reps = []; + + /** @var array> */ + protected array $listeners = []; + + public function __construct( + ?PackagerAdapter $adapter = null, + ?EncoderAdapter $encoder = null, + ?Probe $probe = null, + ) { + $this->prober = $probe ?? new FFprobe(); + $this->adapter = $adapter ?? new FFmpeg(); + + // A packager that can encode is its own encoder, so the common case + // builds one backend rather than two. Constructing one is a handful of + // assignments; nothing runs until open(). + $this->encoder = $encoder + ?? ($this->adapter instanceof EncoderAdapter ? $this->adapter : new FFmpeg()); + + // Config is pushed into the adapters rather than passed to their + // constructors, so an adapter supplied by the caller cannot quietly keep + // a probe of its own and ignore the one asked for here. + foreach ([$this->adapter, $this->encoder] as $backend) { + if ($backend instanceof Adapter) { + $backend->setProbe($this->prober); + } + } + } + + /** + * Which backend is doing the packaging. + */ + public function getName(): string + { + return $this->adapter->getName(); + } + + /** + * Everything that can be learned about a source without decoding it. + */ + public function probe(string $path): Info + { + return $this->prober->read($path); + } + + /** + * Whether a source is usable as input. + */ + public function valid(string $path): bool + { + return $this->prober->valid($path); + } + + /** + * Start a job. Anything configured by a previous one is discarded. + */ + public function open(string $path): static + { + if (! \is_file($path)) { + throw new Input('Source "'.$path.'" does not exist'); + } + + $this->source = $path; + $this->encoding = null; + $this->target = null; + $this->reps = []; + + // Listeners belong to a job too, so a reused Packager does not report the + // previous job's listeners alongside this one's. The adapters make the + // same promise about open(). + $this->listeners = []; + + return $this; + } + + public function format(Format $format): static + { + $this->encoding = $format; + + return $this; + } + + public function add(Representation ...$representations): static + { + foreach ($representations as $representation) { + $this->reps[] = $representation; + } + + return $this; + } + + public function output(Output $output): static + { + $this->target = $output; + + return $this; + } + + /** + * @param callable $listener + */ + public function on(string $event, callable $listener): static + { + $this->listeners[$event][] = $listener; + + return $this; + } + + /** + * Encode and package an adaptive ladder. + */ + public function pack(string $dir): Package + { + $source = $this->ready(); + + if ($this->target === null) { + throw new Unsupported('No output format has been set'); + } + + if ($this->reps === []) { + throw new Unsupported('At least one representation is required'); + } + + if ($this->adapter instanceof EncoderAdapter) { + return $this->fused($this->adapter, $source, $this->target, $dir); + } + + return $this->staged($source, $this->target, $dir); + } + + /** + * One pass: the packager encodes and packages together. + */ + private function fused( + EncoderAdapter&PackagerAdapter $packager, + string $source, + Output $output, + string $dir, + ): Package { + $packager->open($source); + + if ($this->encoding !== null) { + $packager->format($this->encoding); + } + + $packager->add(...$this->reps); + $packager->output($output); + $this->attach($packager); + + return $packager->pack($dir); + } + + /** + * Two stages: encode each rung, then package the results. + */ + private function staged(string $source, Output $output, string $dir): Package + { + // Intermediates live beside the output rather than in the system + // temporary directory: they can run to gigabytes, and a small tmpfs or a + // different filesystem would be a poor place to put them. + $work = \rtrim($dir, '/').'/.staging'; + + if (! \is_dir($work) && ! @\mkdir($work, 0o755, true) && ! \is_dir($work)) { + throw new Runtime('Unable to create "'.$work.'"'); + } + + $total = \count($this->reps); + $files = []; + + try { + foreach ($this->reps as $position => $rep) { + $encoder = $this->encoder->open($source); + + if ($this->encoding !== null) { + $encoder->format($this->encoding); + } + + $encoder->add($rep); + + // Encoding is the slow part, so it owns most of the progress bar. + $encoder->on(self::PROGRESS, function (mixed $progress) use ($position, $total): void { + if (! $progress instanceof Progress) { + return; + } + + $share = 90.0 / $total; + + $this->emit(self::PROGRESS, new Progress( + percent: \round(($position * $share) + ($progress->percent * $share / 100), 2), + time: $progress->time, + frame: $progress->frame, + fps: $progress->fps, + speed: $progress->speed, + )); + }); + + $encoder->on(self::LOG, fn (mixed $line) => $this->emit(self::LOG, $line)); + + $files[] = ['path' => $encoder->encode($work.'/'.$rep->name.'.mp4'), 'rep' => $rep]; + } + + foreach ($files as $file) { + $this->adapter->open($file['path'], $file['rep']); + } + + $this->adapter->output($output); + + // Only the log is forwarded here, not progress: a progress-capable + // packager would report its own 0-100 after the encodes already + // claimed 0-90, and the bar would appear to go backwards. + $this->adapter->on(self::LOG, fn (mixed $line) => $this->emit(self::LOG, $line)); + + $package = $this->adapter->pack($dir); + + $this->emit(self::PROGRESS, new Progress(percent: 100.0)); + + return $package; + } finally { + foreach ($files as $file) { + @\unlink($file['path']); + } + + @\rmdir($work); + } + } + + /** + * Forwards this facade's listeners to whichever adapter is doing the work. + */ + private function attach(Observable $adapter): void + { + foreach ($this->listeners as $event => $listeners) { + foreach ($listeners as $listener) { + $adapter->on($event, $listener); + } + } + } + + private function emit(string $event, mixed $payload): void + { + foreach ($this->listeners[$event] ?? [] as $listener) { + $listener($payload); + } + } + + private function ready(): string + { + if ($this->source === null) { + throw new Input('No source has been opened'); + } + + return $this->source; + } +} diff --git a/src/Video/Parser/M3u8.php b/src/Video/Parser/M3u8.php new file mode 100644 index 0000000..a1cda5d --- /dev/null +++ b/src/Video/Parser/M3u8.php @@ -0,0 +1,293 @@ + + */ + public static function attributes(string $line): array + { + $attributes = []; + $offset = \strpos($line, ':'); + + if ($offset === false) { + return $attributes; + } + + \preg_match_all( + '/([A-Z0-9\-]+)=("[^"]*"|[^,]*)/', + \substr($line, $offset + 1), + $matches, + PREG_SET_ORDER, + ); + + foreach ($matches as $match) { + $attributes[$match[1]] = \trim($match[2], '"'); + } + + return $attributes; + } + + /** + * Every variant a master playlist advertises, in declaration order. + * + * @return list + */ + public static function master(string $path): array + { + $lines = self::lines($path); + $variants = []; + $pending = null; + + foreach ($lines as $line) { + if (\str_starts_with($line, '#EXT-X-MEDIA:')) { + $attributes = self::attributes($line); + + if (($attributes['TYPE'] ?? '') !== 'AUDIO' || ! isset($attributes['URI'])) { + continue; + } + + $variants[] = [ + 'id' => (string) \count($variants), + 'file' => \basename($attributes['URI']), + 'type' => Track::AUDIO, + 'codecs' => null, + 'bandwidth' => 0, + 'width' => null, + 'height' => null, + 'language' => $attributes['LANGUAGE'] ?? null, + 'group' => $attributes['GROUP-ID'] ?? null, + ]; + + continue; + } + + if (\str_starts_with($line, '#EXT-X-STREAM-INF:')) { + $pending = self::attributes($line); + + continue; + } + + if ($line === '' || \str_starts_with($line, '#')) { + continue; + } + + if ($pending === null) { + continue; + } + + [$width, $height] = self::resolution($pending['RESOLUTION'] ?? null); + + $variants[] = [ + 'id' => (string) \count($variants), + 'file' => \basename($line), + 'type' => Track::VIDEO, + 'codecs' => $pending['CODECS'] ?? null, + 'bandwidth' => (int) ($pending['BANDWIDTH'] ?? 0), + 'width' => $width, + 'height' => $height, + 'language' => null, + 'group' => $pending['AUDIO'] ?? null, + ]; + + $pending = null; + } + + return $variants; + } + + /** + * Segments of one media playlist, plus its playlist level attributes. + * + * @return array{target: float, version: int, segments: list} + */ + public static function media(string $path): array + { + $lines = self::lines($path); + $segments = []; + $target = 0.0; + $version = 0; + $duration = 0.0; + + foreach ($lines as $line) { + if (\str_starts_with($line, '#EXT-X-TARGETDURATION:')) { + $target = (float) \substr($line, 22); + + continue; + } + + if (\str_starts_with($line, '#EXT-X-VERSION:')) { + $version = (int) \substr($line, 15); + + continue; + } + + if (\str_starts_with($line, '#EXT-X-MAP:')) { + $attributes = self::attributes($line); + + if (isset($attributes['URI'])) { + $segments[] = [ + 'file' => \basename($attributes['URI']), + 'duration' => 0.0, + 'init' => true, + ]; + } + + continue; + } + + if (\str_starts_with($line, '#EXTINF:')) { + $duration = (float) \rtrim(\substr($line, 8), ','); + + continue; + } + + if ($line === '' || \str_starts_with($line, '#')) { + continue; + } + + $segments[] = [ + 'file' => \basename($line), + 'duration' => $duration, + 'init' => false, + ]; + + $duration = 0.0; + } + + return ['target' => $target, 'version' => $version, 'segments' => $segments]; + } + + /** + * Reads a master playlist and everything it points at. + * + * @return array{variants: list, metadata: array, playlists: list} + */ + public static function read(string $master, string $dir): array + { + $dir = \rtrim($dir, '/'); + $variants = []; + $playlists = []; + $target = 0.0; + $version = 0; + + foreach (self::master($master) as $entry) { + $playlist = $dir.'/'.$entry['file']; + + if (! \is_file($playlist)) { + throw new Runtime('Playlist "'.$entry['file'].'" is missing from the package'); + } + + $playlists[] = $playlist; + $media = self::media($playlist); + $target = \max($target, $media['target']); + $version = \max($version, $media['version']); + + $segments = []; + $number = 0; + + foreach ($media['segments'] as $segment) { + $segments[] = self::segment($entry['id'], $dir, $segment, $number); + + if (! $segment['init']) { + $number++; + } + } + + $variants[] = new Variant( + id: $entry['id'], + type: $entry['type'], + codecs: $entry['codecs'], + bandwidth: $entry['bandwidth'], + width: $entry['width'], + height: $entry['height'], + language: $entry['language'], + target: $media['target'], + segments: $segments, + playlist: $playlist, + ); + } + + return [ + 'variants' => $variants, + 'metadata' => ['targetDuration' => $target, 'version' => $version], + 'playlists' => $playlists, + ]; + } + + /** + * @param array{file: string, duration: float, init: bool} $segment + */ + private static function segment(string $variant, string $dir, array $segment, int $number): Segment + { + $path = $dir.'/'.$segment['file']; + + if (! \is_file($path)) { + throw new Runtime('Segment "'.$segment['file'].'" is missing from the package'); + } + + return new Segment( + variant: $variant, + file: $segment['file'], + path: $path, + duration: $segment['duration'], + init: $segment['init'], + number: $segment['init'] ? 0 : $number, + size: (int) \filesize($path), + ); + } + + /** + * @return array{0: ?int, 1: ?int} + */ + private static function resolution(?string $value): array + { + if ($value === null || ! \str_contains($value, 'x')) { + return [null, null]; + } + + [$width, $height] = \explode('x', $value, 2); + + return [(int) $width, (int) $height]; + } + + /** + * @return list + */ + private static function lines(string $path): array + { + $body = \is_file($path) ? \file_get_contents($path) : false; + + if ($body === false) { + throw new Runtime('Unable to read playlist "'.$path.'"'); + } + + $lines = []; + + foreach (\preg_split('/\R/', $body) ?: [] as $line) { + $lines[] = \trim($line); + } + + return $lines; + } +} diff --git a/src/Video/Parser/Mpd.php b/src/Video/Parser/Mpd.php new file mode 100644 index 0000000..de20661 --- /dev/null +++ b/src/Video/Parser/Mpd.php @@ -0,0 +1,359 @@ +, metadata: array} + */ + public static function read(string $path, string $dir): array + { + $dir = \rtrim($dir, '/'); + $document = self::load($path); + $root = $document->documentElement; + + if ($root === null) { + throw new Runtime('Manifest "'.$path.'" has no root element'); + } + + $duration = self::seconds($root->getAttribute('mediaPresentationDuration')); + + $metadata = [ + 'profiles' => $root->getAttribute('profiles'), + 'type' => $root->getAttribute('type'), + 'mediaPresentationDuration' => $root->getAttribute('mediaPresentationDuration'), + 'maxSegmentDuration' => $root->getAttribute('maxSegmentDuration'), + 'minBufferTime' => $root->getAttribute('minBufferTime'), + 'duration' => $duration, + ]; + + $variants = []; + + foreach ($root->getElementsByTagName('AdaptationSet') as $index => $set) { + if (! $set instanceof DOMElement) { + continue; + } + + foreach ($set->getElementsByTagName('Representation') as $representation) { + if (! $representation instanceof DOMElement) { + continue; + } + + $variants[] = self::variant($set, $representation, (string) $index, $dir, $duration); + } + } + + return ['variants' => $variants, 'metadata' => $metadata]; + } + + private static function variant( + DOMElement $set, + DOMElement $representation, + string $group, + string $dir, + float $duration, + ): Variant { + $id = $representation->getAttribute('id'); + $id = $id !== '' ? $id : $group; + + $mime = $representation->getAttribute('mimeType'); + $mime = $mime !== '' ? $mime : $set->getAttribute('mimeType'); + + $type = \str_starts_with($mime, 'audio') ? Track::AUDIO : Track::VIDEO; + $contentType = $set->getAttribute('contentType'); + + if ($contentType !== '') { + $type = $contentType === 'audio' ? Track::AUDIO : Track::VIDEO; + } + + [$segments, $timescale, $start] = self::segments($set, $representation, $id, $dir, $duration); + + $language = $set->getAttribute('lang'); + + return new Variant( + id: $id, + type: $type, + mimeType: $mime !== '' ? $mime : null, + codecs: self::text($representation->getAttribute('codecs')), + bandwidth: (int) $representation->getAttribute('bandwidth'), + width: self::number($representation, $set, 'width'), + height: self::number($representation, $set, 'height'), + sar: self::text($representation->getAttribute('sar')), + sampleRate: self::number($representation, $set, 'audioSamplingRate'), + language: $language !== '' && $language !== 'und' ? $language : null, + timescale: $timescale, + startNumber: $start, + target: (float) $set->getAttribute('maxSegmentDuration'), + segments: $segments, + ); + } + + /** + * @return array{0: list, 1: int, 2: int} + */ + private static function segments( + DOMElement $set, + DOMElement $representation, + string $id, + string $dir, + float $duration, + ): array { + $list = self::child($representation, 'SegmentList') ?? self::child($set, 'SegmentList'); + + if ($list !== null) { + return self::listed($list, $id, $dir); + } + + $template = self::child($representation, 'SegmentTemplate') ?? self::child($set, 'SegmentTemplate'); + + if ($template !== null) { + return self::templated($template, $id, $dir, $duration); + } + + return [[], 0, 0]; + } + + /** + * Every segment written out one by one. + * + * @return array{0: list, 1: int, 2: int} + */ + private static function listed(DOMElement $list, string $id, string $dir): array + { + $timescale = (int) $list->getAttribute('timescale'); + $start = (int) $list->getAttribute('startNumber'); + $segments = []; + + $duration = $timescale > 0 + ? ((float) $list->getAttribute('duration')) / $timescale + : 0.0; + + $init = self::child($list, 'Initialization'); + + if ($init !== null && $init->getAttribute('sourceURL') !== '') { + $segments[] = self::segment($id, $dir, $init->getAttribute('sourceURL'), 0.0, true, 0); + } + + $number = $start; + + foreach ($list->getElementsByTagName('SegmentURL') as $url) { + if (! $url instanceof DOMElement) { + continue; + } + + $segments[] = self::segment($id, $dir, $url->getAttribute('media'), $duration, false, $number); + $number++; + } + + return [$segments, $timescale, $start]; + } + + /** + * Segment names described by formula, resolved into real filenames. + * + * @return array{0: list, 1: int, 2: int} + */ + private static function templated(DOMElement $template, string $id, string $dir, float $duration): array + { + $timescale = (int) $template->getAttribute('timescale'); + $timescale = $timescale > 0 ? $timescale : 1; + $start = $template->hasAttribute('startNumber') + ? (int) $template->getAttribute('startNumber') + : 1; + + $segments = []; + $init = $template->getAttribute('initialization'); + + if ($init !== '') { + $segments[] = self::segment($id, $dir, self::expand($init, $id, 0), 0.0, true, 0); + } + + $timeline = self::child($template, 'SegmentTimeline'); + $media = $template->getAttribute('media'); + + if ($timeline !== null) { + $number = $start; + + foreach ($timeline->getElementsByTagName('S') as $entry) { + if (! $entry instanceof DOMElement) { + continue; + } + + $length = ((float) $entry->getAttribute('d')) / $timescale; + $repeat = $entry->hasAttribute('r') ? (int) $entry->getAttribute('r') : 0; + + for ($i = 0; $i <= $repeat; $i++) { + $segments[] = self::segment( + $id, + $dir, + self::expand($media, $id, $number), + $length, + false, + $number, + ); + $number++; + } + } + + return [$segments, $timescale, $start]; + } + + $length = ((float) $template->getAttribute('duration')) / $timescale; + + if ($length <= 0 || $duration <= 0) { + return [$segments, $timescale, $start]; + } + + $count = (int) \ceil($duration / $length); + + for ($i = 0; $i < $count; $i++) { + $number = $start + $i; + $segments[] = self::segment( + $id, + $dir, + self::expand($media, $id, $number), + $length, + false, + $number, + ); + } + + return [$segments, $timescale, $start]; + } + + /** + * Substitutes the identifiers DASH allows inside a segment name. + */ + public static function expand(string $pattern, string $id, int $number): string + { + $name = \str_replace(['$RepresentationID$', '$Bandwidth$'], [$id, ''], $pattern); + + $name = \preg_replace_callback( + '/\$Number(%0?\d*d)?\$/', + static fn (array $match): string => isset($match[1]) && $match[1] !== '' + ? \sprintf($match[1], $number) + : (string) $number, + $name, + ) ?? $name; + + return \str_replace('$$', '$', $name); + } + + private static function segment( + string $variant, + string $dir, + string $file, + float $duration, + bool $init, + int $number, + ): Segment { + $file = \basename($file); + $path = $dir.'/'.$file; + + if (! \is_file($path)) { + throw new Runtime('Segment "'.$file.'" is missing from the package'); + } + + return new Segment( + variant: $variant, + file: $file, + path: $path, + duration: $duration, + init: $init, + number: $number, + size: (int) \filesize($path), + ); + } + + /** + * Reads an ISO 8601 duration such as PT1M4.5S. + */ + public static function seconds(string $value): float + { + if ($value === '') { + return 0.0; + } + + if (\preg_match('/^PT(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?$/', $value, $match) !== 1) { + return 0.0; + } + + return ((float) ($match[1] ?? 0)) * 3600 + + ((float) ($match[2] ?? 0)) * 60 + + ((float) ($match[3] ?? 0)); + } + + private static function child(DOMElement $element, string $tag): ?DOMElement + { + foreach ($element->childNodes as $node) { + if ($node instanceof DOMElement && $node->tagName === $tag) { + return $node; + } + } + + // Initialization can sit one level down inside a SegmentList. + $nodes = $element->getElementsByTagName($tag); + $first = $nodes->item(0); + + return $first instanceof DOMElement ? $first : null; + } + + private static function number(DOMElement $representation, DOMElement $set, string $name): ?int + { + $value = $representation->getAttribute($name); + + if ($value === '') { + $value = $set->getAttribute($name); + } + + if ($value === '') { + $value = $set->getAttribute('max'.\ucfirst($name)); + } + + return $value === '' ? null : (int) $value; + } + + private static function text(string $value): ?string + { + return $value === '' ? null : $value; + } + + private static function load(string $path): DOMDocument + { + if (! \is_file($path)) { + throw new Runtime('Unable to read manifest "'.$path.'"'); + } + + $document = new DOMDocument(); + $previous = \libxml_use_internal_errors(true); + $loaded = $document->load($path); + \libxml_clear_errors(); + \libxml_use_internal_errors($previous); + + if ($loaded === false) { + throw new Runtime('Manifest "'.$path.'" is not valid XML'); + } + + return $document; + } +} diff --git a/src/Video/Process.php b/src/Video/Process.php new file mode 100644 index 0000000..db22f6a --- /dev/null +++ b/src/Video/Process.php @@ -0,0 +1,194 @@ + $command + * @param callable(string):void|null $stdout Called once per line written to stdout. + * @param callable(string):void|null $stderr Called once per line written to stderr. + * @param int $timeout Seconds to allow the command to run; 0 disables the limit. + * @return string The tail of stderr, which is where ffmpeg reports failures. + * + * @throws Runtime + */ + public static function run( + array $command, + ?callable $stdout = null, + ?callable $stderr = null, + int $timeout = 0, + ): string { + if ($command === []) { + throw new Runtime('Cannot run an empty command'); + } + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $pipes = []; + + // A missing binary raises a warning before returning false, and callers + // should get the exception rather than whatever the warning handler does. + $process = @\proc_open($command, $descriptors, $pipes); + + if (! \is_resource($process)) { + throw new Runtime('Unable to start "'.$command[0].'"', $command); + } + + \fclose($pipes[0]); + \stream_set_blocking($pipes[1], false); + \stream_set_blocking($pipes[2], false); + + $buffers = [1 => '', 2 => '']; + $errors = ''; + $started = \microtime(true); + $open = [1 => $pipes[1], 2 => $pipes[2]]; + + while ($open !== []) { + $read = \array_values($open); + $write = null; + $except = null; + + if (@\stream_select($read, $write, $except, 0, 200000) === false) { + break; + } + + foreach ($open as $key => $pipe) { + if (! \in_array($pipe, $read, true)) { + continue; + } + + $chunk = \fread($pipe, 8192); + + if ($chunk === false || $chunk === '') { + if (\feof($pipe)) { + if ($buffers[$key] !== '') { + self::emit($key, $buffers[$key], $stdout, $stderr); + $buffers[$key] = ''; + } + + \fclose($pipe); + unset($open[$key]); + } + + continue; + } + + if ($key === 2) { + $errors = \substr($errors.$chunk, -self::TAIL); + } + + $buffers[$key] .= $chunk; + + while (($break = \strpos($buffers[$key], "\n")) !== false) { + $line = \substr($buffers[$key], 0, $break); + $buffers[$key] = \substr($buffers[$key], $break + 1); + self::emit($key, $line, $stdout, $stderr); + } + + // ffmpeg separates progress blocks with \r when writing to a terminal. + while (($break = \strpos($buffers[$key], "\r")) !== false) { + $line = \substr($buffers[$key], 0, $break); + $buffers[$key] = \substr($buffers[$key], $break + 1); + + if (\trim($line) !== '') { + self::emit($key, $line, $stdout, $stderr); + } + } + } + + if ($timeout > 0 && (\microtime(true) - $started) > $timeout) { + foreach ($open as $pipe) { + \fclose($pipe); + } + + \proc_terminate($process, 9); + \proc_close($process); + + throw new Runtime( + 'Command "'.$command[0].'" timed out after '.$timeout.'s', + $command, + $errors, + ); + } + } + + $status = \proc_close($process); + + if ($status !== 0) { + throw new Runtime( + 'Command "'.$command[0].'" failed with exit code '.$status, + $command, + \trim($errors), + $status, + ); + } + + return \trim($errors); + } + + /** + * Runs a command and returns everything it wrote to stdout. + * + * @param list $command + * + * @throws Runtime + */ + public static function read(array $command, int $timeout = 0): string + { + $output = ''; + + self::run($command, function (string $line) use (&$output): void { + $output .= $line."\n"; + }, null, $timeout); + + return $output; + } + + /** + * Reports whether a binary can be executed at all. + */ + public static function exists(string $binary): bool + { + try { + self::run([$binary, '-version'], timeout: 10); + + return true; + } catch (Runtime) { + return false; + } + } + + /** + * @param callable(string):void|null $stdout + * @param callable(string):void|null $stderr + */ + private static function emit(int $key, string $line, ?callable $stdout, ?callable $stderr): void + { + $line = \rtrim($line, "\r\n"); + $listener = $key === 1 ? $stdout : $stderr; + + if ($listener !== null) { + $listener($line); + } + } +} diff --git a/src/Video/Progress.php b/src/Video/Progress.php new file mode 100644 index 0000000..1965182 --- /dev/null +++ b/src/Video/Progress.php @@ -0,0 +1,23 @@ +name = $name ?? ($height > 0 ? $height.'p' : 'audio'); + $this->maxrate = $maxrate ?? $video; + $this->bufsize = $bufsize ?? ($this->maxrate * 2); + } + + public function resolution(): string + { + return $this->width.'x'.$this->height; + } + + /** + * An audio only rung carries no frame size. + */ + public function scaled(): bool + { + return $this->width > 0 && $this->height > 0; + } +} diff --git a/src/Video/Segment.php b/src/Video/Segment.php new file mode 100644 index 0000000..c9065fe --- /dev/null +++ b/src/Video/Segment.php @@ -0,0 +1,22 @@ + $images + * @param list $cues + */ + public function __construct( + private readonly array $images = [], + private readonly array $cues = [], + private readonly ?string $vtt = null, + private readonly int $width = 0, + private readonly int $height = 0, + ) { + } + + /** + * @return list + */ + public function images(): array + { + return $this->images; + } + + /** + * @return list + */ + public function cues(): array + { + return $this->cues; + } + + /** + * Path of the written WebVTT timeline, or null when writing was disabled. + */ + public function vtt(): ?string + { + return $this->vtt; + } + + /** + * @return list + */ + public function files(): array + { + $files = $this->images; + + if ($this->vtt !== null) { + $files[] = $this->vtt; + } + + return $files; + } + + /** + * Width of a single thumbnail. + */ + public function width(): int + { + return $this->width; + } + + /** + * Height of a single thumbnail. + */ + public function height(): int + { + return $this->height; + } + + /** + * Renders the timeline as WebVTT, optionally rewriting each sheet URL. + * + * @param callable(string):string|null $url Maps a sheet filename to the URL to publish. + */ + public function render(?callable $url = null): string + { + $body = "WEBVTT\n"; + + foreach ($this->cues as $cue) { + $body .= "\n".$cue->render($url === null ? null : $url($cue->file))."\n"; + } + + return $body; + } +} diff --git a/src/Video/Thumb.php b/src/Video/Thumb.php new file mode 100644 index 0000000..07ee7d3 --- /dev/null +++ b/src/Video/Thumb.php @@ -0,0 +1,64 @@ +at = $seconds; + + return $this; + } + + /** + * Output width in pixels; height follows the source aspect. Zero keeps the + * original size. + */ + public function width(int $pixels): self + { + $this->width = $pixels; + + return $this; + } + + /** + * Encoder quality scale, where lower is better. + */ + public function quality(int $quality): self + { + $this->quality = $quality; + + return $this; + } + + public function at(): ?float + { + return $this->at; + } + + public function size(): int + { + return $this->width; + } + + public function scale(): int + { + return $this->quality; + } +} diff --git a/src/Video/Tile.php b/src/Video/Tile.php new file mode 100644 index 0000000..f0e25f7 --- /dev/null +++ b/src/Video/Tile.php @@ -0,0 +1,128 @@ +interval = $seconds; + + return $this; + } + + public function width(int $pixels): self + { + $this->width = $pixels; + + return $this; + } + + public function grid(int $columns, int $rows): self + { + $this->columns = $columns; + $this->rows = $rows; + + return $this; + } + + public function quality(int $quality): self + { + $this->quality = $quality; + + return $this; + } + + public function name(string $base): self + { + $this->name = $base; + + return $this; + } + + /** + * Whether to write the WebVTT timeline alongside the sheets. + */ + public function vtt(bool $write): self + { + $this->vtt = $write; + + return $this; + } + + /** + * Interval to use for a source of the given length. + */ + public function every(float $duration): float + { + if ($this->interval !== null) { + return $this->interval; + } + + return match (true) { + $duration < 120 => 2.0, + $duration < 600 => 5.0, + $duration < 1800 => 10.0, + $duration < 3600 => 20.0, + default => 30.0, + }; + } + + public function size(): int + { + return $this->width; + } + + public function columns(): int + { + return $this->columns; + } + + public function rows(): int + { + return $this->rows; + } + + public function cells(): int + { + return $this->columns * $this->rows; + } + + public function scale(): int + { + return $this->quality; + } + + public function base(): string + { + return $this->name; + } + + public function writes(): bool + { + return $this->vtt; + } +} diff --git a/src/Video/Track.php b/src/Video/Track.php new file mode 100644 index 0000000..558f38d --- /dev/null +++ b/src/Video/Track.php @@ -0,0 +1,34 @@ + $tags + */ + public function __construct( + public readonly int $index, + public readonly string $type, + public readonly ?string $codec = null, + public readonly ?string $language = null, + public readonly ?string $title = null, + public readonly bool $default = false, + public readonly bool $forced = false, + public readonly array $tags = [], + ) { + } +} diff --git a/src/Video/Variant.php b/src/Video/Variant.php new file mode 100644 index 0000000..4d97a35 --- /dev/null +++ b/src/Video/Variant.php @@ -0,0 +1,42 @@ + $segments + */ + public function __construct( + public readonly string $id, + public readonly string $type = Track::VIDEO, + public readonly ?string $mimeType = null, + public readonly ?string $codecs = null, + public readonly int $bandwidth = 0, + public readonly ?int $width = null, + public readonly ?int $height = null, + public readonly ?string $sar = null, + public readonly ?int $sampleRate = null, + public readonly ?string $language = null, + public readonly int $timescale = 0, + public readonly int $startNumber = 0, + public readonly float $target = 0.0, + public readonly array $segments = [], + public readonly ?string $playlist = null, + ) { + } + + public function resolution(): ?string + { + if (! $this->width || ! $this->height) { + return null; + } + + return $this->width.'x'.$this->height; + } +} diff --git a/tests/Video/Adapters.php b/tests/Video/Adapters.php new file mode 100644 index 0000000..f88d52e --- /dev/null +++ b/tests/Video/Adapters.php @@ -0,0 +1,291 @@ +available()) { + $this->markTestSkipped('the tools this adapter needs are not installed'); + } + + $dir = \sys_get_temp_dir().'/utopia-adapters-'.\bin2hex(\random_bytes(6)); + \mkdir($dir, 0o755, true); + $this->dir = $dir; + } + + protected function tearDown(): void + { + if (isset($this->dir)) { + $this->remove($this->dir); + } + } + + protected function remove(string $dir): void + { + foreach (\glob($dir.'/*') ?: [] as $path) { + \is_dir($path) ? $this->remove($path) : \unlink($path); + } + + @\rmdir($dir); + } + + protected function format(): X264 + { + return (new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast']); + } + + public function testNamesEveryAdapter(): void + { + $names = $this->names(); + + $this->assertSame($names['encoder'], $this->encoder()->getName()); + $this->assertSame($names['packager'], $this->packager()->getName()); + } + + public function testProbesASource(): void + { + $info = $this->encoder()->probe($this->source()); + + $this->assertGreaterThan(0, $info->duration); + $this->assertTrue($info->hasVideo || $info->hasAudio); + } + + /** + * Either facade can gate its input, so a caller holding only one of them + * does not need the other just to check a file. + */ + public function testAcceptsAUsableSource(): void + { + $this->assertTrue($this->encoder()->valid($this->source())); + $this->assertTrue($this->packager()->valid($this->source())); + } + + public function testRejectsAMissingSource(): void + { + $this->assertFalse($this->encoder()->valid($this->dir.'/nothing.mp4')); + } + + public function testEncodesASingleFile(): void + { + $path = $this->encoder() + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/out.mp4'); + + $this->assertFileExists($path); + $this->assertGreaterThan(0, (int) \filesize($path)); + } + + /** + * Both terminals end a job, so an adapter can serve one after another + * without the first job's inputs still being registered. + */ + public function testEncodesTwiceInARow(): void + { + $encoder = $this->encoder(); + + foreach ([0, 1] as $round) { + $path = $encoder + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/out'.$round.'.mp4'); + + $this->assertFileExists($path); + } + } + + public function testRefusesToEncodeALadder(): void + { + $this->expectExceptionMessage('use pack() for a ladder'); + + $this->encoder() + ->open($this->source()) + ->format($this->format()) + ->add( + new Representation(320, 240, 300), + new Representation(640, 480, 800), + ) + ->encode($this->dir.'/two.mp4'); + } + + public function testPacksALadder(): void + { + $package = $this->packager() + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->dir); + + $this->assertNotEmpty($package->segments()); + $this->assertGreaterThan(0, $package->size()); + + foreach ($package->segments() as $segment) { + $this->assertFileExists($segment->path); + } + } + + public function testRefusesToPackWithoutAnOutput(): void + { + $this->expectExceptionMessage('output format'); + + $this->packager() + ->open($this->source()) + ->add(new Representation(320, 240, 400)) + ->pack($this->dir); + } + + public function testRefusesToPackWithoutALadder(): void + { + $this->expectExceptionMessage('representation'); + + $this->packager() + ->open($this->source()) + ->output(new Hls()) + ->pack($this->dir); + } + + public function testReportsProgressThatOnlyClimbs(): void + { + $seen = []; + + $this->packager() + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->on(Packager::PROGRESS, function (mixed $progress) use (&$seen): void { + $this->assertInstanceOf(Progress::class, $progress); + $seen[] = $progress->percent; + }) + ->pack($this->dir); + + $this->assertNotEmpty($seen); + $this->assertSame(100.0, \end($seen)); + + $sorted = $seen; + \sort($sorted); + $this->assertSame($sorted, $seen, 'progress should never go backwards'); + } + + /** + * A finished job must not leak into the next one, on the facade or inside + * the adapter it delegates to. + */ + public function testReusingOnePackagerDoesNotRepeatEvents(): void + { + $packager = $this->packager(); + + // A counter per round, so the two closures cannot share a variable and + // hide the very accumulation this is looking for. + $counts = [0, 0]; + + foreach ([0, 1] as $round) { + $packager + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->on(Packager::LOG, function () use (&$counts, $round): void { + $counts[$round]++; + }) + ->pack($this->dir.'/round'.$round); + } + + $this->assertSame( + $counts[1], + $counts[0], + 'the first job\'s listener should not still be firing during the second', + ); + } + + public function testOpeningAgainForgetsThePreviousJob(): void + { + $packager = $this->packager() + ->open($this->source()) + ->output(new Hls()) + ->add(new Representation(320, 240, 400)); + + $packager->open($this->source()); + + $this->expectExceptionMessage('output format'); + + $packager->pack($this->dir); + } + + public function testGrabsAStill(): void + { + $path = $this->encoder()->grab( + $this->source(), + $this->dir.'/still.jpg', + (new Thumb())->width(160), + ); + + $this->assertFileExists($path); + $this->assertGreaterThan(0, (int) \filesize($path)); + } + + public function testTilesATimeline(): void + { + $sheet = $this->encoder()->tile($this->source(), $this->dir, (new Tile())->interval(2.0)); + + $this->assertNotEmpty($sheet->images()); + $this->assertNotEmpty($sheet->cues()); + $this->assertNotNull($sheet->vtt()); + $this->assertStringStartsWith('WEBVTT', (string) \file_get_contents((string) $sheet->vtt())); + } +} diff --git a/tests/Video/E2E/Base.php b/tests/Video/E2E/Base.php new file mode 100644 index 0000000..94efbac --- /dev/null +++ b/tests/Video/E2E/Base.php @@ -0,0 +1,140 @@ +markTestSkipped('ffmpeg and ffprobe are required'); + } + + $this->dir = self::$root.'/'.\bin2hex(\random_bytes(6)); + \mkdir($this->dir, 0o755, true); + } + + protected function tearDown(): void + { + if (isset($this->dir)) { + self::remove($this->dir); + } + } + + /** + * Eight seconds of colour bars and a tone, with a keyframe every two + * seconds so it can be cut into segments. + */ + protected static function video(): string + { + $path = self::$root.'/source.mp4'; + + if (\is_file($path)) { + return $path; + } + + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'testsrc=duration=8:size=640x480:rate=25', + '-f', 'lavfi', '-i', 'sine=frequency=440:duration=8', + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', + '-force_key_frames', 'expr:gte(t,n_forced*2)', + '-c:a', 'aac', '-shortest', + '-metadata', 'title=Utopia Test', + $path, + ], timeout: 120); + + return $path; + } + + protected static function silent(): string + { + $path = self::$root.'/silent.mp4'; + + if (\is_file($path)) { + return $path; + } + + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'testsrc=duration=4:size=320x240:rate=25', + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', + '-force_key_frames', 'expr:gte(t,n_forced*2)', + $path, + ], timeout: 120); + + return $path; + } + + protected static function audio(): string + { + $path = self::$root.'/audio.m4a'; + + if (\is_file($path)) { + return $path; + } + + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'sine=frequency=440:duration=6', + '-c:a', 'aac', + $path, + ], timeout: 120); + + return $path; + } + + protected static function remove(string $dir): void + { + foreach (\glob($dir.'/*') ?: [] as $path) { + \is_dir($path) ? self::remove($path) : \unlink($path); + } + + @\rmdir($dir); + } + + /** + * Confirms a file exists and is not empty. + */ + protected function assertWritten(string $path, string $message = ''): void + { + $this->assertFileExists($path, $message); + $this->assertGreaterThan(0, (int) \filesize($path), $message !== '' ? $message : $path.' is empty'); + } +} diff --git a/tests/Video/E2E/CmafTest.php b/tests/Video/E2E/CmafTest.php new file mode 100644 index 0000000..f7abdab --- /dev/null +++ b/tests/Video/E2E/CmafTest.php @@ -0,0 +1,198 @@ +open(self::video()) + ->format((new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add($rep ?? new Representation(320, 240, 400, 64)) + ->output(($output ?? new Cmaf())->segment(2)) + ->pack($this->dir); + } + + public function testWritesBothDescriptions(): void + { + $package = $this->pack(); + + $this->assertWritten($this->dir.'/manifest.mpd'); + $this->assertWritten($this->dir.'/master.m3u8'); + + $types = \array_map( + static fn (Manifest $manifest): string => $manifest->type, + $package->manifests(), + ); + + $this->assertContains(Manifest::DASH, $types); + $this->assertContains(Manifest::HLS, $types); + } + + /** + * This is the reason CMAF exists: one copy of the media, addressed by two + * different kinds of player. If the manifests ever drift onto separate + * segment sets the storage cost doubles and the benefit disappears. + */ + public function testBothDescriptionsPointAtTheSameSegments(): void + { + $package = $this->pack(); + + $fromManifest = []; + + foreach ($package->segments() as $segment) { + $fromManifest[] = $segment->file; + } + + $fromPlaylists = []; + + foreach (\glob($this->dir.'/*.m3u8') ?: [] as $playlist) { + if ($playlist === $this->dir.'/master.m3u8') { + continue; + } + + foreach (M3u8::media($playlist)['segments'] as $segment) { + $fromPlaylists[] = $segment['file']; + } + } + + $this->assertNotEmpty($fromPlaylists, 'no media playlists were written'); + + \sort($fromManifest); + \sort($fromPlaylists); + + $this->assertSame($fromManifest, $fromPlaylists); + } + + public function testSegmentsAreFragmentedMp4(): void + { + $package = $this->pack(); + + foreach ($package->segments() as $segment) { + $this->assertMatchesRegularExpression('/\.(m4s|mp4)$/', $segment->file); + $this->assertWritten($segment->path); + } + } + + public function testEverySegmentIsNamedInTheManifest(): void + { + $package = $this->pack(); + $manifest = (string) \file_get_contents($this->dir.'/manifest.mpd'); + + $this->assertStringContainsString('assertStringNotContainsString('segments() as $segment) { + $this->assertStringContainsString($segment->file, $manifest); + } + } + + /** + * The dash muxer never writes this tag, even though forcing keyframes makes + * it true, so the packager adds it once the run is done. + */ + public function testMasterDeclaresSegmentsIndependent(): void + { + $this->pack(); + + $master = (string) \file_get_contents($this->dir.'/master.m3u8'); + + $this->assertStringContainsString('#EXT-X-INDEPENDENT-SEGMENTS', $master); + $this->assertSame(1, \substr_count($master, '#EXT-X-INDEPENDENT-SEGMENTS')); + $this->assertStringStartsWith('#EXTM3U', $master); + } + + public function testMasterStaysValidAfterTheTagIsAdded(): void + { + $this->pack(); + + $variants = M3u8::master($this->dir.'/master.m3u8'); + + $this->assertNotEmpty($variants); + + foreach ($variants as $variant) { + $this->assertFileExists($this->dir.'/'.$variant['file']); + } + } + + public function testHandlesALadder(): void + { + $package = (new Packager()) + ->open(self::video()) + ->format((new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add( + new Representation(320, 240, 300, 64), + new Representation(640, 480, 800, 96), + ) + ->output((new Cmaf())->segment(2)) + ->pack($this->dir); + + $this->assertGreaterThanOrEqual(2, \count($package->variants())); + $this->assertWritten($this->dir.'/manifest.mpd'); + $this->assertWritten($this->dir.'/master.m3u8'); + } + + public function testEveryArtifactIsListedForUpload(): void + { + $package = $this->pack(); + + foreach ($package->files() as $file) { + $this->assertWritten($file); + } + + $this->assertContains($this->dir.'/manifest.mpd', $package->files()); + $this->assertContains($this->dir.'/master.m3u8', $package->files()); + } + + public function testDescriptionsCanBeDiscardedWhileSegmentsRemain(): void + { + $package = $this->pack((new Cmaf())->manifests(false)); + + $this->assertSame([], $package->manifests()); + $this->assertSame([], \glob($this->dir.'/*.m3u8') ?: []); + $this->assertSame([], \glob($this->dir.'/*.mpd') ?: []); + $this->assertNotEmpty($package->segments()); + + foreach ($package->segments() as $segment) { + $this->assertWritten($segment->path); + } + } + + public function testMasterCanBeRenamed(): void + { + $this->pack((new Cmaf())->master('index.m3u8')->manifest('video.mpd')); + + $this->assertWritten($this->dir.'/index.m3u8'); + $this->assertWritten($this->dir.'/video.mpd'); + } + + /** + * WebM cannot carry an HLS playlist, so asking for it should fail loudly + * rather than quietly producing half a package. + */ + public function testRejectsACodecItCannotDescribeTwice(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('CMAF cannot carry libvpx-vp9'); + + (new Packager()) + ->open(self::video()) + ->format(new VP9()) + ->add(new Representation(320, 240, 400, 64)) + ->output(new Cmaf()) + ->pack($this->dir); + } +} diff --git a/tests/Video/E2E/DashTest.php b/tests/Video/E2E/DashTest.php new file mode 100644 index 0000000..29a2c17 --- /dev/null +++ b/tests/Video/E2E/DashTest.php @@ -0,0 +1,194 @@ +open(self::video()) + ->format((new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add(...$reps) + ->output($output->segment(2)) + ->pack($this->dir); + } + + public function testPackagesWithTemplatedAddressing(): void + { + $package = $this->pack(new Dash(), new Representation(320, 240, 400, 64)); + + $this->assertWritten($this->dir.'/manifest.mpd'); + + $manifest = (string) \file_get_contents($this->dir.'/manifest.mpd'); + $this->assertStringContainsString('assertNotEmpty($package->segments()); + + foreach ($package->segments() as $segment) { + $this->assertWritten($segment->path); + } + } + + /** + * Turning both switches off makes the manifest name every segment. That is + * the only way a consumer can rewrite each reference to an address of its + * own, so it needs to keep working exactly as asked. + */ + public function testPackagesWithEverySegmentListed(): void + { + $package = $this->pack( + (new Dash())->template(false)->timeline(false), + new Representation(320, 240, 400, 64), + ); + + $manifest = (string) \file_get_contents($this->dir.'/manifest.mpd'); + + $this->assertStringContainsString('assertStringContainsString('assertStringNotContainsString('assertNotEmpty($package->segments()); + + foreach ($package->segments() as $segment) { + $this->assertStringContainsString($segment->file, $manifest); + $this->assertWritten($segment->path); + } + } + + public function testListedAndTemplatedDescribeTheSameSegments(): void + { + $listed = $this->pack( + (new Dash())->template(false)->timeline(false), + new Representation(320, 240, 400, 64), + ); + + $names = \array_map( + static fn ($segment): string => $segment->file, + $listed->segments(), + ); + + self::remove($this->dir); + \mkdir($this->dir, 0o755, true); + + $templated = $this->pack(new Dash(), new Representation(320, 240, 400, 64)); + + $this->assertSame( + \count($names), + \count($templated->segments()), + 'both addressing modes should describe the same number of segments', + ); + } + + public function testAudioGetsItsOwnAdaptationSet(): void + { + $package = $this->pack(new Dash(), new Representation(320, 240, 400, 64)); + + $types = \array_map( + static fn ($variant): string => $variant->type, + $package->variants(), + ); + + $this->assertContains(Track::VIDEO, $types); + $this->assertContains(Track::AUDIO, $types); + } + + public function testEveryRungLandsInOneManifest(): void + { + $package = $this->pack( + new Dash(), + new Representation(320, 240, 300, 64), + new Representation(640, 480, 800, 96), + ); + + $video = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::VIDEO, + )); + + $this->assertCount(2, $video); + $this->assertCount(1, \glob($this->dir.'/*.mpd') ?: []); + } + + public function testInitialisationSegmentIsRecognised(): void + { + $package = $this->pack( + (new Dash())->template(false)->timeline(false), + new Representation(320, 240, 400, 64), + ); + + $init = \array_values(\array_filter( + $package->segments(), + static fn ($segment): bool => $segment->init, + )); + + $this->assertNotEmpty($init); + $this->assertWritten($init[0]->path); + } + + public function testCarriesManifestMetadata(): void + { + $package = $this->pack(new Dash(), new Representation(320, 240, 400, 64)); + $metadata = $package->metadata(); + + $this->assertSame('static', $metadata['type']); + $this->assertNotEmpty($metadata['profiles']); + $this->assertIsFloat($metadata['duration']); + $this->assertEqualsWithDelta(8.0, $metadata['duration'], 1.0); + } + + public function testReportedSizesMatchTheFilesOnDisk(): void + { + $package = $this->pack(new Dash(), new Representation(320, 240, 400, 64)); + + foreach ($package->segments() as $segment) { + $this->assertSame(\filesize($segment->path), $segment->size, $segment->file); + } + } + + public function testManifestCanBeDiscarded(): void + { + $package = $this->pack( + (new Dash())->manifests(false), + new Representation(320, 240, 400, 64), + ); + + $this->assertSame([], $package->manifests()); + $this->assertFileDoesNotExist($this->dir.'/manifest.mpd'); + $this->assertNotEmpty($package->segments()); + } + + public function testManifestIsKeptByDefault(): void + { + $package = $this->pack(new Dash(), new Representation(320, 240, 400, 64)); + $manifests = $package->manifests(); + + $this->assertCount(1, $manifests); + $this->assertSame(Manifest::DASH, $manifests[0]->type); + $this->assertTrue($manifests[0]->main); + } + + public function testPackagesVp9(): void + { + $package = (new Packager()) + ->open(self::video()) + ->format((new VP9())->crf(40)->keyframe(2.0)->params(['-b:v', '0', '-cpu-used', '8'])) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Dash())->segment(2)) + ->pack($this->dir); + + $this->assertWritten($this->dir.'/manifest.mpd'); + $this->assertNotEmpty($package->segments()); + } +} diff --git a/tests/Video/E2E/EncodeTest.php b/tests/Video/E2E/EncodeTest.php new file mode 100644 index 0000000..a664313 --- /dev/null +++ b/tests/Video/E2E/EncodeTest.php @@ -0,0 +1,198 @@ +open(self::video()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/out.mp4'); + + $this->assertWritten($path); + + $info = $encoder->probe($path); + $this->assertSame(320, $info->width); + $this->assertSame(240, $info->height); + $this->assertTrue($info->hasAudio); + $this->assertEqualsWithDelta(8.0, $info->duration, 1.0); + } + + /** + * A rung asks for an exact frame size so the size advertised to players is + * the size delivered. A source of a different shape is fitted inside the + * box rather than stretched to fill it. + */ + public function testFitsASourceOfADifferentShapeIntoTheRequestedBox(): void + { + $encoder = new Encoder(); + + $path = $encoder + ->open(self::video()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->add(new Representation(640, 360, 500, 64)) + ->encode($this->dir.'/wide.mp4'); + + $info = $encoder->probe($path); + + $this->assertSame(640, $info->width); + $this->assertSame(360, $info->height); + } + + public function testEncodesWithoutALadder(): void + { + $path = (new Encoder()) + ->open(self::video()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->encode($this->dir.'/plain.mp4'); + + $this->assertWritten($path); + } + + public function testRepackagesWithoutReEncoding(): void + { + $started = \microtime(true); + + $path = (new Encoder()) + ->open(self::video()) + ->format(new Copy()) + ->encode($this->dir.'/copy.mkv'); + + $this->assertWritten($path); + $this->assertLessThan(30, \microtime(true) - $started); + } + + public function testEncodesHevc(): void + { + $encoder = new Encoder(); + + $path = $encoder + ->open(self::video()) + ->format((new HEVC())->crf(35)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/hevc.mp4'); + + $this->assertWritten($path); + $this->assertSame('hevc', $encoder->probe($path)->videoCodec); + } + + public function testPackagesHevcAsFragmentedHls(): void + { + $package = (new Packager()) + ->open(self::video()) + ->format((new HEVC())->crf(35)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segments(Hls::FMP4)->segment(2)) + ->pack($this->dir); + + $this->assertNotEmpty($package->segments()); + $this->assertWritten($this->dir.'/master.m3u8'); + } + + public function testPackagesHevcAsCmaf(): void + { + $package = (new Packager()) + ->open(self::video()) + ->format((new HEVC())->crf(35)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Cmaf())->segment(2)) + ->pack($this->dir); + + $this->assertNotEmpty($package->segments()); + $this->assertWritten($this->dir.'/manifest.mpd'); + $this->assertWritten($this->dir.'/master.m3u8'); + } + + public function testEncodesAnAudioOnlySource(): void + { + $encoder = new Encoder(); + + $path = $encoder + ->open(self::audio()) + ->format(new X264()) + ->add(new Representation(0, 0, 1, 96)) + ->encode($this->dir.'/audio.m4a'); + + $this->assertWritten($path); + + $info = $encoder->probe($path); + $this->assertTrue($info->hasAudio); + $this->assertFalse($info->hasVideo); + } + + public function testProgressReachesCompletion(): void + { + $seen = []; + + (new Encoder()) + ->open(self::video()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->on(Encoder::PROGRESS, function (Progress $progress) use (&$seen): void { + $seen[] = $progress->percent; + }) + ->encode($this->dir.'/progress.mp4'); + + $this->assertNotEmpty($seen); + $this->assertSame(100.0, \end($seen)); + + foreach ($seen as $percent) { + $this->assertGreaterThanOrEqual(0.0, $percent); + $this->assertLessThanOrEqual(100.0, $percent); + } + } + + public function testEncodingALadderIsRejected(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('use pack() for a ladder'); + + (new Encoder()) + ->open(self::video()) + ->format(new X264()) + ->add( + new Representation(320, 240, 300), + new Representation(640, 480, 800), + ) + ->encode($this->dir.'/two.mp4'); + } + + public function testAdapterCanBeReusedForASecondJob(): void + { + $encoder = new Encoder(); + + $first = $encoder + ->open(self::video()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/first.mp4'); + + $second = $encoder + ->open(self::silent()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->add(new Representation(160, 120, 200)) + ->encode($this->dir.'/second.mp4'); + + $this->assertWritten($first); + $this->assertWritten($second); + $this->assertSame(160, $encoder->probe($second)->width); + } +} diff --git a/tests/Video/E2E/FFmpegTest.php b/tests/Video/E2E/FFmpegTest.php new file mode 100644 index 0000000..4f06d39 --- /dev/null +++ b/tests/Video/E2E/FFmpegTest.php @@ -0,0 +1,129 @@ +dir.'/source.mp4'; + + if (! \is_file($path)) { + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'testsrc=duration=6:size=640x480:rate=25', + '-f', 'lavfi', '-i', 'sine=duration=6', + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', + '-force_key_frames', 'expr:gte(t,n_forced*2)', + '-c:a', 'aac', '-shortest', + $path, + ], timeout: 120); + } + + return $path; + } + + protected function names(): array + { + return [ + 'encoder' => 'ffmpeg', + 'packager' => 'ffmpeg', + ]; + } + + /** + * Both facades take the same backend, so an application that encodes and + * packages can build one adapter and hand it to each. + */ + public function testOneAdapterCanServeBothFacades(): void + { + $ffmpeg = new FFmpeg(); + $encoder = new Encoder($ffmpeg); + $packager = new Packager($ffmpeg); + + $this->assertSame('ffmpeg', $encoder->getName()); + $this->assertSame('ffmpeg', $packager->getName()); + + // And the shared adapter still does real work through either of them. + $this->assertGreaterThan(0, $encoder->probe($this->source())->duration); + $this->assertGreaterThan(0, $packager->probe($this->source())->duration); + } + + /** + * The display level decides how much ffmpeg says, and whatever it says + * arrives as LOG events. A clean encode at the default level says nothing. + */ + public function testTheDisplayLevelDecidesHowMuchIsReported(): void + { + $lines = function (?string $level): int { + $count = 0; + + (new Encoder(new FFmpeg(level: $level))) + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->on(Encoder::LOG, function () use (&$count): void { + $count++; + }) + ->encode($this->dir.'/level-'.($level ?? 'default').'.mp4'); + + return $count; + }; + + $quiet = $lines(FFmpeg::QUIET); + $default = $lines(null); + $verbose = $lines(FFmpeg::VERBOSE); + + $this->assertSame(0, $quiet, 'a quiet encoder should say nothing'); + $this->assertSame(0, $default, 'a clean encode has no errors to report'); + $this->assertGreaterThan(0, $verbose, 'a verbose encoder should explain itself'); + } + + /** + * ffmpeg packages and encodes, so a default Packager takes the single-pass + * route rather than writing intermediates first. + */ + public function testTheDefaultPackagerLeavesNoIntermediatesBehind(): void + { + $this->packager() + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->dir.'/fused'); + + $this->assertDirectoryDoesNotExist($this->dir.'/fused/.staging'); + } +} diff --git a/tests/Video/E2E/FrameTest.php b/tests/Video/E2E/FrameTest.php new file mode 100644 index 0000000..b12025f --- /dev/null +++ b/tests/Video/E2E/FrameTest.php @@ -0,0 +1,218 @@ +assertNotFalse($size, $path.' is not a readable image'); + + return [$size[0], $size[1]]; + } + + public function testGrabsAPosterFrame(): void + { + $path = (new Encoder())->grab(self::video(), $this->dir.'/poster.jpg'); + + $this->assertSame($this->dir.'/poster.jpg', $path); + $this->assertWritten($path); + $this->assertSame([320, 240], $this->size($path)); + } + + public function testGrabsAFrameAtAGivenMoment(): void + { + $path = (new Encoder())->grab( + self::video(), + $this->dir.'/at.jpg', + (new Thumb())->time(4.0), + ); + + $this->assertWritten($path); + } + + public function testHonoursTheRequestedWidth(): void + { + $path = (new Encoder())->grab( + self::video(), + $this->dir.'/wide.jpg', + (new Thumb())->width(160), + ); + + [$width, $height] = $this->size($path); + + $this->assertSame(160, $width); + $this->assertSame(120, $height, 'height should follow the source aspect'); + } + + public function testWritesPngWhenAsked(): void + { + $path = (new Encoder())->grab(self::video(), $this->dir.'/poster.png'); + + $this->assertWritten($path); + + $size = \getimagesize($path); + $this->assertNotFalse($size); + $this->assertSame('image/png', $size['mime']); + } + + public function testCreatesTheOutputDirectory(): void + { + $path = (new Encoder())->grab(self::video(), $this->dir.'/nested/poster.jpg'); + + $this->assertWritten($path); + } + + /** + * Audio files often carry their artwork as a single frame video stream, + * which is worth pulling out even though the file is not a video. + */ + public function testGrabsEmbeddedArtwork(): void + { + $cover = $this->dir.'/cover.jpg'; + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'color=c=red:s=320x320:d=1', + '-frames:v', '1', $cover, + ], timeout: 60); + + $source = $this->dir.'/with-art.mp3'; + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'sine=frequency=440:duration=3', + '-i', $cover, + '-map', '0:a', '-map', '1:v', + '-c:a', 'libmp3lame', '-c:v', 'copy', + '-id3v2_version', '3', + '-disposition:v:0', 'attached_pic', + $source, + ], timeout: 60); + + $path = (new Encoder())->grab($source, $this->dir.'/art.jpg'); + + $this->assertWritten($path); + } + + public function testGrabbingFromSilenceIsAFailure(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('no image to grab'); + + (new Encoder())->grab(self::audio(), $this->dir.'/nothing.jpg'); + } + + public function testTilesTheWholeTimeline(): void + { + $sheet = (new Encoder())->tile(self::video(), $this->dir); + + $this->assertNotEmpty($sheet->images()); + $this->assertNotEmpty($sheet->cues()); + + foreach ($sheet->images() as $image) { + $this->assertWritten($image); + } + + $this->assertSame(160, $sheet->width()); + $this->assertSame(120, $sheet->height()); + } + + /** + * An eight second clip sampled every two seconds is four thumbnails, and + * they all fit on one five by five sheet. + */ + public function testCueCountFollowsDurationAndInterval(): void + { + $sheet = (new Encoder())->tile(self::video(), $this->dir, (new Tile())->interval(2.0)); + + $this->assertCount(1, $sheet->images()); + $this->assertCount(4, $sheet->cues()); + + $cues = $sheet->cues(); + $this->assertSame(0.0, $cues[0]->start); + $this->assertSame(2.0, $cues[0]->end); + $this->assertSame(2.0, $cues[1]->start); + $this->assertSame(6.0, $cues[3]->start); + } + + public function testCuesWalkTheGridLeftToRightThenDown(): void + { + $sheet = (new Encoder())->tile( + self::video(), + $this->dir, + (new Tile())->interval(2.0)->grid(2, 2)->width(100), + ); + + $cues = $sheet->cues(); + + $this->assertSame([0, 0], [$cues[0]->x, $cues[0]->y]); + $this->assertSame([100, 0], [$cues[1]->x, $cues[1]->y]); + $this->assertSame([0, $cues[0]->height], [$cues[2]->x, $cues[2]->y]); + $this->assertSame([100, $cues[0]->height], [$cues[3]->x, $cues[3]->y]); + } + + public function testSpillsOntoASecondSheetWhenTheGridFills(): void + { + $sheet = (new Encoder())->tile( + self::video(), + $this->dir, + (new Tile())->interval(2.0)->grid(2, 1), + ); + + $this->assertGreaterThanOrEqual(2, \count($sheet->images())); + + $files = \array_unique(\array_map( + static fn ($cue): string => $cue->file, + $sheet->cues(), + )); + + $this->assertGreaterThanOrEqual(2, \count($files)); + } + + public function testWritesAWebvttTimeline(): void + { + $sheet = (new Encoder())->tile(self::video(), $this->dir, (new Tile())->interval(2.0)); + + $this->assertNotNull($sheet->vtt()); + $this->assertWritten($sheet->vtt()); + + $vtt = (string) \file_get_contents((string) $sheet->vtt()); + + $this->assertStringStartsWith("WEBVTT\n", $vtt); + $this->assertStringContainsString('#xywh=', $vtt); + $this->assertStringContainsString('00:00:00.000 --> 00:00:02.000', $vtt); + } + + public function testTimelineCanBeSkipped(): void + { + $sheet = (new Encoder())->tile( + self::video(), + $this->dir, + (new Tile())->vtt(false), + ); + + $this->assertNull($sheet->vtt()); + $this->assertSame([], \glob($this->dir.'/*.vtt') ?: []); + $this->assertNotEmpty($sheet->cues()); + } + + public function testTilingSilenceIsAFailure(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('no video to tile'); + + (new Encoder())->tile(self::audio(), $this->dir); + } +} diff --git a/tests/Video/E2E/HlsTest.php b/tests/Video/E2E/HlsTest.php new file mode 100644 index 0000000..94fca26 --- /dev/null +++ b/tests/Video/E2E/HlsTest.php @@ -0,0 +1,243 @@ +open(self::video()) + ->format((new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add(...$reps) + ->output($output->segment(2)) + ->pack($this->dir); + } + + public function testPackagesTransportStreamSegments(): void + { + $package = $this->pack(new Hls(), new Representation(320, 240, 400, 64)); + + $this->assertWritten($this->dir.'/master.m3u8'); + $this->assertNotEmpty($package->segments()); + + foreach ($package->segments() as $segment) { + $this->assertWritten($segment->path); + $this->assertStringEndsWith('.ts', $segment->file); + $this->assertGreaterThan(0, $segment->size); + } + } + + /** + * The whole point of a ladder is that one master describes all of it. An + * earlier design ran ffmpeg once per rung, and each run overwrote the + * master the previous one had just written. + */ + public function testOneMasterDescribesEveryRung(): void + { + $package = $this->pack( + new Hls(), + new Representation(320, 240, 300, 64), + new Representation(640, 480, 800, 96), + ); + + $master = (string) \file_get_contents($this->dir.'/master.m3u8'); + + $this->assertSame(2, \substr_count($master, '#EXT-X-STREAM-INF')); + $this->assertStringContainsString('RESOLUTION=320x240', $master); + $this->assertStringContainsString('RESOLUTION=640x480', $master); + + $video = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::VIDEO, + )); + + $this->assertCount(2, $video); + $this->assertSame([240, 480], [$video[0]->height, $video[1]->height]); + } + + public function testEachRungIsEncodedAtItsOwnSize(): void + { + $package = $this->pack( + new Hls(), + new Representation(320, 240, 300, 64), + new Representation(640, 480, 800, 96), + ); + + foreach ($package->variants() as $variant) { + if ($variant->type !== Track::VIDEO) { + continue; + } + + $this->assertNotEmpty($variant->segments, 'rung '.$variant->id.' produced nothing'); + $this->assertGreaterThan(0, $variant->bandwidth); + } + } + + public function testPackagesFragmentedMp4WithAnInitialisationSegment(): void + { + $package = $this->pack( + (new Hls())->segments(Hls::FMP4), + new Representation(320, 240, 400, 64), + ); + + $init = \array_values(\array_filter( + $package->segments(), + static fn ($segment): bool => $segment->init, + )); + + $this->assertNotEmpty($init, 'no initialisation segment was produced'); + $this->assertWritten($init[0]->path); + $this->assertSame(0.0, $init[0]->duration); + + $playlist = (string) \file_get_contents((string) $package->variants()[0]->playlist); + $this->assertStringContainsString('#EXT-X-MAP:URI=', $playlist); + $this->assertStringContainsString('#EXT-X-VERSION:7', $playlist); + + foreach ($package->segments() as $segment) { + if (! $segment->init) { + $this->assertStringEndsWith('.m4s', $segment->file); + } + } + } + + public function testSegmentsAreMarkedIndependent(): void + { + $this->pack(new Hls(), new Representation(320, 240, 400, 64)); + + $master = (string) \file_get_contents($this->dir.'/master.m3u8'); + + $this->assertStringContainsString('#EXT-X-INDEPENDENT-SEGMENTS', $master); + } + + public function testSegmentDurationsAddUpToTheSource(): void + { + $package = $this->pack(new Hls(), new Representation(320, 240, 400, 64)); + + $total = 0.0; + + foreach ($package->segments($package->variants()[0]->id) as $segment) { + $total += $segment->duration; + } + + $this->assertEqualsWithDelta(8.0, $total, 1.0); + } + + public function testReportedSizesMatchTheFilesOnDisk(): void + { + $package = $this->pack(new Hls(), new Representation(320, 240, 400, 64)); + + foreach ($package->segments() as $segment) { + $this->assertSame(\filesize($segment->path), $segment->size, $segment->file); + } + + $this->assertGreaterThan(0, $package->size()); + } + + public function testEveryArtifactIsListedForUpload(): void + { + $package = $this->pack(new Hls(), new Representation(320, 240, 400, 64)); + + foreach ($package->files() as $file) { + $this->assertWritten($file); + } + + $this->assertContains($this->dir.'/master.m3u8', $package->files()); + } + + /** + * A consumer that serves its own manifests should be able to keep the + * media and discard the playlists, without losing the structured data. + */ + public function testPlaylistsCanBeDiscardedWhileSegmentsRemain(): void + { + $package = $this->pack( + (new Hls())->manifests(false), + new Representation(320, 240, 400, 64), + ); + + $this->assertSame([], $package->manifests()); + $this->assertSame([], \glob($this->dir.'/*.m3u8') ?: []); + $this->assertNotEmpty($package->segments()); + + foreach ($package->segments() as $segment) { + $this->assertWritten($segment->path); + } + } + + public function testManifestsAreKeptByDefault(): void + { + $package = $this->pack(new Hls(), new Representation(320, 240, 400, 64)); + + $manifests = $package->manifests(); + $this->assertNotEmpty($manifests); + + $main = \array_values(\array_filter($manifests, static fn (Manifest $m): bool => $m->main)); + $this->assertCount(1, $main); + $this->assertSame('master.m3u8', $main[0]->file()); + $this->assertSame(Manifest::HLS, $main[0]->type); + } + + public function testProgressClimbsToCompletion(): void + { + $seen = []; + + (new Packager()) + ->open(self::video()) + ->format((new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->on(Packager::PROGRESS, function (Progress $progress) use (&$seen): void { + $seen[] = $progress->percent; + }) + ->pack($this->dir); + + $this->assertNotEmpty($seen); + $this->assertSame(100.0, \end($seen)); + + $sorted = $seen; + \sort($sorted); + $this->assertSame($sorted, $seen, 'progress should never go backwards'); + } + + public function testPackagesASilentSource(): void + { + $package = (new Packager()) + ->open(self::silent()) + ->format((new X264())->crf(30)->keyframe(2.0)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400)) + ->output((new Hls())->segment(2)) + ->pack($this->dir); + + $this->assertNotEmpty($package->segments()); + + foreach ($package->variants() as $variant) { + $this->assertSame(Track::VIDEO, $variant->type); + } + } + + public function testNamingFlowsThroughToArtifacts(): void + { + $package = $this->pack( + (new Hls())->name('movie')->master('index.m3u8'), + new Representation(320, 240, 400, 64), + ); + + $this->assertWritten($this->dir.'/index.m3u8'); + + foreach ($package->segments() as $segment) { + $this->assertStringStartsWith('movie_', $segment->file); + } + } +} diff --git a/tests/Video/E2E/MediaTest.php b/tests/Video/E2E/MediaTest.php new file mode 100644 index 0000000..025bcb9 --- /dev/null +++ b/tests/Video/E2E/MediaTest.php @@ -0,0 +1,489 @@ +markTestSkipped('ffmpeg and ffprobe are required'); + } + + $this->dir = self::$root.'/'.\bin2hex(\random_bytes(6)); + \mkdir($this->dir, 0o755, true); + } + + protected function tearDown(): void + { + if (isset($this->dir)) { + self::remove($this->dir); + } + } + + private static function remove(string $dir): void + { + foreach (\glob($dir.'/*') ?: [] as $path) { + \is_dir($path) ? self::remove($path) : \unlink($path); + } + + @\rmdir($dir); + } + + /** + * @param list $args + */ + private static function ffmpeg(array $args, string $target): string + { + if (\is_file($target)) { + return $target; + } + + Process::run( + [...['ffmpeg', '-y', '-hide_banner', '-loglevel', 'error'], ...$args, $target], + timeout: 180, + ); + + return $target; + } + + /** + * Eight seconds of moving detail with a tone, keyframed every two seconds. + */ + private static function base(): string + { + return self::ffmpeg([ + '-f', 'lavfi', '-i', 'testsrc2=duration=8:size=640x360:rate=30', + '-f', 'lavfi', '-i', 'sine=frequency=440:duration=8', + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', + '-force_key_frames', 'expr:gte(t,n_forced*2)', + '-c:a', 'aac', '-shortest', + ], self::$root.'/base.mp4'); + } + + private static function format(): X264 + { + return (new X264())->crf(32)->keyframe(2.0)->params(['-preset', 'ultrafast']); + } + + /** + * Non-square pixels: stored 4:3, displayed wider. + */ + public function testReadsAnamorphicPixels(): void + { + $path = self::ffmpeg([ + '-i', self::base(), + '-vf', 'scale=640:480,setsar=4:3', + '-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'copy', + ], self::$root.'/anamorphic.mp4'); + + $info = (new Encoder())->probe($path); + + $this->assertSame(640, $info->width); + $this->assertSame(480, $info->height); + + // The display ratio comes from the container, not from width/height, so + // it must not simply be 4:3 here. + $this->assertNotSame('4:3', $info->ratio()); + } + + /** + * Packaging normalises pixels, so a rung is delivered at the size requested + * however odd the source's were. + */ + public function testPackagesAnamorphicSourceAtTheRequestedSize(): void + { + $path = self::ffmpeg([ + '-i', self::base(), + '-vf', 'scale=640:480,setsar=4:3', + '-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'copy', + ], self::$root.'/anamorphic.mp4'); + + $encoder = new Encoder(); + + $encoded = $encoder + ->open($path) + ->format(self::format()) + ->add(new Representation(320, 180, 300, 64)) + ->encode($this->dir.'/out.mp4'); + + $result = $encoder->probe($encoded); + + $this->assertSame(320, $result->width); + $this->assertSame(180, $result->height); + } + + public function testDetectsAVariableFrameRate(): void + { + $path = self::ffmpeg([ + '-i', self::base(), + '-vf', "select='not(mod(n,3))+gte(t,4)'", + '-fps_mode', 'vfr', + '-c:v', 'libx264', '-preset', 'ultrafast', '-an', + ], self::$root.'/vfr.mp4'); + + $this->assertSame('Variable', (new Encoder())->probe($path)->fpsMode); + } + + /** + * Phone footage carries its orientation in the display matrix rather than in + * the frame size. + */ + public function testReadsRotationFromTheDisplayMatrix(): void + { + $path = self::$root.'/rotated.mp4'; + + if (! \is_file($path)) { + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-display_rotation', '90', '-i', self::base(), '-c', 'copy', $path, + ], timeout: 180); + } + + $info = (new Encoder())->probe($path); + + $this->assertSame(90, $info->rotation); + $this->assertSame(640, $info->width, 'rotation must not change the stored size'); + } + + public function testReadsSeveralAudioTracksAndTheirLanguages(): void + { + $path = self::ffmpeg([ + '-i', self::base(), '-i', self::base(), + '-map', '0:v', '-map', '0:a', '-map', '1:a', + '-metadata:s:a:0', 'language=eng', + '-metadata:s:a:1', 'language=spa', + '-c:v', 'copy', '-c:a', 'aac', + ], self::$root.'/multiaudio.mp4'); + + $info = (new Encoder())->probe($path); + + $this->assertCount(2, $info->audioTracks); + $this->assertSame('eng', $info->audioTracks[0]['language']); + $this->assertSame('spa', $info->audioTracks[1]['language']); + $this->assertCount(2, $info->tracks(Track::AUDIO)); + } + + /** + * Each tagged track becomes its own rendition, so a player can offer the + * choice. + */ + public function testPackagesEachAudioLanguageSeparately(): void + { + $path = self::ffmpeg([ + '-i', self::base(), '-i', self::base(), + '-map', '0:v', '-map', '0:a', '-map', '1:a', + '-metadata:s:a:0', 'language=eng', + '-metadata:s:a:1', 'language=spa', + '-c:v', 'copy', '-c:a', 'aac', + ], self::$root.'/multiaudio.mp4'); + + $package = (new Packager()) + ->open($path) + ->format(self::format()) + ->add(new Representation(320, 180, 300, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->dir); + + $audio = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::AUDIO, + )); + + $this->assertCount(2, $audio, 'both tagged tracks should be offered'); + + $master = (string) \file_get_contents($this->dir.'/master.m3u8'); + $this->assertStringContainsString('LANGUAGE="eng"', $master); + $this->assertStringContainsString('LANGUAGE="spa"', $master); + } + + public function testReadsAnEmbeddedSubtitleTrack(): void + { + $srt = self::$root.'/embedded.srt'; + \file_put_contents($srt, "1\n00:00:01,000 --> 00:00:03,000\nBonjour\n"); + + $path = self::ffmpeg([ + '-i', self::base(), '-i', $srt, + '-map', '0', '-map', '1', + '-c:v', 'copy', '-c:a', 'copy', '-c:s', 'mov_text', + '-metadata:s:s:0', 'language=fra', + ], self::$root.'/subtitled.mp4'); + + $subtitles = (new Encoder())->probe($path)->tracks(Track::SUBTITLE); + + $this->assertCount(1, $subtitles); + $this->assertSame('fra', $subtitles[0]->language); + } + + public function testReadsChaptersAndContainerTags(): void + { + $meta = self::$root.'/chapters.txt'; + \file_put_contents($meta, <<<'META' + ;FFMETADATA1 + title=Real World Clip + artist=Utopia + [CHAPTER] + TIMEBASE=1/1000 + START=0 + END=4000 + title=Opening + META); + + $path = self::ffmpeg([ + '-i', self::base(), '-i', $meta, + '-map_metadata', '1', '-map', '0', '-c', 'copy', + ], self::$root.'/chapters.mp4'); + + $info = (new Encoder())->probe($path); + + $this->assertNotEmpty($info->chapters); + $this->assertSame('Opening', $info->chapters[0]->title); + $this->assertSame('Real World Clip', $info->tags['title'] ?? null); + } + + /** + * Music files carry their artwork as a single frame video stream: no video to + * speak of, but a picture worth having. + */ + public function testGrabsArtworkFromASoundFile(): void + { + $cover = self::ffmpeg( + ['-i', self::base(), '-frames:v', '1', '-vf', 'scale=300:300'], + self::$root.'/cover.jpg', + ); + + $audio = self::ffmpeg(['-i', self::base(), '-vn', '-c:a', 'aac'], self::$root.'/audio.m4a'); + + $path = self::ffmpeg([ + '-i', $audio, '-i', $cover, + '-map', '0:a', '-map', '1:v', '-c', 'copy', + '-disposition:v:0', 'attached_pic', + ], self::$root.'/artwork.m4a'); + + $info = (new Encoder())->probe($path); + + $this->assertFalse($info->hasVideo, 'artwork is not video'); + $this->assertNotNull($info->cover, 'but the picture should be found'); + + $grabbed = (new Encoder())->grab($path, $this->dir.'/art.jpg'); + $this->assertNotFalse(\getimagesize($grabbed)); + } + + public function testASoundFileWithNoArtworkHasNothingToGrab(): void + { + $audio = self::ffmpeg(['-i', self::base(), '-vn', '-c:a', 'aac'], self::$root.'/audio.m4a'); + + $this->expectException(Input::class); + $this->expectExceptionMessage('no image to grab'); + + (new Encoder())->grab($audio, $this->dir.'/none.jpg'); + } + + public function testHandlesPortraitFootage(): void + { + $path = self::ffmpeg([ + '-i', self::base(), + '-vf', 'scale=360:640,setsar=1:1', + '-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'copy', + ], self::$root.'/portrait.mp4'); + + $encoder = new Encoder(); + $info = $encoder->probe($path); + + $this->assertSame(360, $info->width); + $this->assertSame(640, $info->height); + $this->assertSame('9:16', $info->ratio()); + + $sheet = $encoder->tile($path, $this->dir.'/timeline', (new Tile())->interval(2.0)->width(90)); + + $this->assertNotEmpty($sheet->cues()); + $this->assertGreaterThan($sheet->width(), $sheet->height(), 'thumbnails stay portrait'); + } + + public function testHandlesAClipShorterThanOneSegment(): void + { + $path = self::ffmpeg(['-i', self::base(), '-t', '0.5', '-c', 'copy'], self::$root.'/tiny.mp4'); + + $encoder = new Encoder(); + + $this->assertTrue($encoder->valid($path)); + + $package = (new Packager()) + ->open($path) + ->format(self::format()) + ->add(new Representation(320, 180, 300, 64)) + ->output((new Hls())->segment(6)) + ->pack($this->dir); + + $this->assertNotEmpty($package->segments()); + + $sheet = $encoder->tile($path, $this->dir.'/timeline'); + $this->assertNotEmpty($sheet->cues()); + } + + /** + * A DASH adaptation set may only hold rungs that are alternatives of one + * another, so they have to be the same shape. The muxer says so only once it + * is already running, and never says which rungs clashed. + */ + public function testRejectsAMixedShapeLadderForDash(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('share one aspect ratio'); + + (new Packager()) + ->open(self::base()) + ->format(self::format()) + ->add( + new Representation(320, 240, 300, 64), // 4:3 + new Representation(640, 360, 800, 96), // 16:9 + ) + ->output((new Dash())->segment(2)) + ->pack($this->dir); + } + + public function testRejectsAMixedShapeLadderForCmaf(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('share one aspect ratio'); + + (new Packager()) + ->open(self::base()) + ->format(self::format()) + ->add( + new Representation(320, 240, 300, 64), + new Representation(640, 360, 800, 96), + ) + ->output((new Cmaf())->segment(2)) + ->pack($this->dir); + } + + /** + * HLS has no such rule, so the same ladder is fine there. + */ + public function testAllowsAMixedShapeLadderForHls(): void + { + $package = (new Packager()) + ->open(self::base()) + ->format(self::format()) + ->add( + new Representation(320, 240, 300, 64), + new Representation(640, 360, 800, 96), + ) + ->output((new Hls())->segment(2)) + ->pack($this->dir); + + $video = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::VIDEO, + )); + + $this->assertCount(2, $video); + } + + /** + * The reason CMAF exists: two manifests, one copy of the media. + */ + public function testCmafManifestsShareOneSegmentSetOnRealFootage(): void + { + $package = (new Packager()) + ->open(self::base()) + ->format(self::format()) + ->add( + new Representation(320, 180, 300, 64), + new Representation(640, 360, 800, 96), + ) + ->output((new Cmaf())->segment(2)) + ->pack($this->dir); + + $fromManifest = \array_map( + static fn ($segment): string => $segment->file, + $package->segments(), + ); + + $fromPlaylists = []; + + foreach (\glob($this->dir.'/*.m3u8') ?: [] as $playlist) { + if (\basename($playlist) === 'master.m3u8') { + continue; + } + + foreach (M3u8::media($playlist)['segments'] as $segment) { + $fromPlaylists[] = $segment['file']; + } + } + + $this->assertNotEmpty($fromPlaylists); + + \sort($fromManifest); + \sort($fromPlaylists); + + $this->assertSame($fromManifest, $fromPlaylists); + } + + public function testGrabsAPosterAtAGivenMomentFromRealFootage(): void + { + $poster = (new Encoder())->grab( + self::base(), + $this->dir.'/poster.jpg', + (new Thumb())->time(5.0)->width(240), + ); + + $size = \getimagesize($poster); + + $this->assertNotFalse($size); + $this->assertSame(240, $size[0]); + } +} diff --git a/tests/Video/E2E/ProbeTest.php b/tests/Video/E2E/ProbeTest.php new file mode 100644 index 0000000..554b921 --- /dev/null +++ b/tests/Video/E2E/ProbeTest.php @@ -0,0 +1,137 @@ +probe(self::video()); + + $this->assertEqualsWithDelta(8.0, $info->duration, 0.5); + $this->assertSame(8000, (int) \round($info->milliseconds(), -3)); + $this->assertTrue($info->hasVideo); + $this->assertTrue($info->hasAudio); + $this->assertSame(640, $info->width); + $this->assertSame(480, $info->height); + $this->assertSame('h264', $info->videoCodec); + $this->assertSame('aac', $info->audioCodec); + $this->assertSame(25.0, $info->fps); + $this->assertSame('4:3', $info->ratio()); + $this->assertNotEmpty($info->raw); + } + + public function testReadsContainerTags(): void + { + $info = (new Encoder())->probe(self::video()); + + $this->assertSame('Utopia Test', $info->tags['title'] ?? null); + } + + public function testReadsEveryTrack(): void + { + $info = (new Encoder())->probe(self::video()); + + $this->assertCount(2, $info->tracks); + $this->assertCount(1, $info->tracks(Track::VIDEO)); + $this->assertCount(1, $info->tracks(Track::AUDIO)); + } + + public function testReadsASilentSource(): void + { + $info = (new Encoder())->probe(self::silent()); + + $this->assertTrue($info->hasVideo); + $this->assertFalse($info->hasAudio); + $this->assertNull($info->audioCodec); + } + + public function testReadsAnAudioOnlySource(): void + { + $info = (new Encoder())->probe(self::audio()); + + $this->assertFalse($info->hasVideo); + $this->assertTrue($info->hasAudio); + $this->assertNull($info->width); + $this->assertNotNull($info->sampleRate); + } + + public function testReadsChapters(): void + { + $source = $this->dir.'/chapters.mp4'; + $metadata = $this->dir.'/chapters.txt'; + + \file_put_contents($metadata, <<<'META' + ;FFMETADATA1 + [CHAPTER] + TIMEBASE=1/1000 + START=0 + END=4000 + title=Opening + + [CHAPTER] + TIMEBASE=1/1000 + START=4000 + END=8000 + title=Closing + META); + + Process::run([ + 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', + '-i', self::video(), '-i', $metadata, + '-map_metadata', '1', '-c', 'copy', $source, + ], timeout: 120); + + $info = (new Encoder())->probe($source); + + $this->assertCount(2, $info->chapters); + $this->assertSame('Opening', $info->chapters[0]->title); + $this->assertEqualsWithDelta(4.0, $info->chapters[0]->end, 0.1); + $this->assertSame('Closing', $info->chapters[1]->title); + } + + public function testAcceptsAUsableSource(): void + { + $this->assertTrue((new Encoder())->valid(self::video())); + } + + public function testRejectsSomethingThatIsNotMedia(): void + { + $path = $this->dir.'/notes.txt'; + \file_put_contents($path, 'this is not a video'); + + $this->assertFalse((new Encoder())->valid($path)); + } + + public function testRejectsAMissingFile(): void + { + $this->assertFalse((new Encoder())->valid($this->dir.'/nothing.mp4')); + } + + public function testProbingAMissingFileIsAFailure(): void + { + $this->expectException(Input::class); + + (new Encoder())->probe($this->dir.'/nothing.mp4'); + } + + /** + * A Packager can gate its own input, so a caller holding only one of the two + * facades does not need the other just to check a file. + */ + public function testEitherFacadeReadsTheSameSource(): void + { + $info = (new Packager())->probe(self::video()); + + $this->assertSame((new Encoder())->probe(self::video())->duration, $info->duration); + $this->assertTrue((new Packager())->valid(self::video())); + } +} diff --git a/tests/Video/E2E/SamplesTest.php b/tests/Video/E2E/SamplesTest.php new file mode 100644 index 0000000..ddfe7de --- /dev/null +++ b/tests/Video/E2E/SamplesTest.php @@ -0,0 +1,782 @@ +markTestSkipped('ffmpeg and ffprobe are required'); + } + } + + /** @var array */ + private array $cleared = []; + + /** + * A directory under tests/samples/out, emptied the first time it is asked for + * in a test so a stale run cannot be mistaken for a fresh one — and left + * alone afterwards, so the same directory can be handed out twice. + */ + private function out(string $name): string + { + $dir = Samples::out().'/'.$name; + + if (! isset($this->cleared[$dir])) { + self::wipe($dir); + \mkdir($dir, 0o755, true); + $this->cleared[$dir] = true; + } + + return $dir; + } + + private static function wipe(string $dir): void + { + foreach (\glob($dir.'/*') ?: [] as $path) { + \is_dir($path) ? self::wipe($path) : \unlink($path); + } + + @\rmdir($dir); + } + + private function format(): X264 + { + return (new X264())->crf(32)->keyframe(2.0)->params(['-preset', 'ultrafast']); + } + + /** + * @return array + */ + public static function everySample(): array + { + if (! Samples::available()) { + return ['skipped' => ['', '']]; + } + + Samples::build(); + + $cases = []; + + foreach (Samples::all() as $name => $path) { + $cases[$name] = [$name, $path]; + } + + return $cases; + } + + /** + * @return array + */ + public static function everyVideoSample(): array + { + if (! Samples::available()) { + return ['skipped' => ['', '']]; + } + + Samples::build(); + + $cases = []; + + foreach (Samples::all() as $name => $path) { + // Audio-only sources are covered by their own tests. + if (\str_starts_with($name, 'audio.') || \str_starts_with($name, 'artwork.')) { + continue; + } + + $cases[$name] = [$name, $path]; + } + + return $cases; + } + + /** + * Whatever the container, the library should be able to say what it is. + * + * @dataProvider everySample + */ + public function testReadsEverySample(string $name, string $path): void + { + if ($name === '') { + $this->markTestSkipped('no samples'); + } + + $info = (new Encoder())->probe($path); + + $this->assertGreaterThan(0, $info->duration, $name.' reported no duration'); + $this->assertTrue($info->hasVideo || $info->hasAudio, $name.' reported no streams'); + $this->assertNotSame('', $info->format, $name.' reported no container format'); + + if ($info->hasVideo) { + $this->assertNotNull($info->width, $name.' has video but no width'); + $this->assertNotNull($info->videoCodec, $name.' has video but no codec'); + } + + $this->assertTrue((new Encoder())->valid($path), $name.' was rejected as unusable'); + } + + /** + * Extensions lie, so the codec is read from the file rather than the name. + * + * @dataProvider everySample + */ + public function testCodecComesFromTheFileNotTheName(string $name, string $path): void + { + if ($name === '') { + $this->markTestSkipped('no samples'); + } + + $info = (new Encoder())->probe($path); + + // What each sample genuinely contains, which is not deducible from its + // name: .mp4 might be h264 or hevc, and .m4a has no picture at all. + $expected = [ + 'video.mp4' => 'h264', + 'video.mov' => 'h264', + 'video.mkv' => 'h264', + 'video.ts' => 'h264', + 'video.3gp' => 'h264', + 'video.avi' => 'mpeg4', + 'video.wmv' => 'wmv2', + 'video.flv' => 'flv1', + 'video.webm' => 'vp9', + 'video.ogv' => 'theora', + 'hevc.mp4' => 'hevc', + 'anamorphic.mp4' => 'h264', + 'variable-fps.mp4' => 'h264', + 'rotated.mp4' => 'h264', + 'portrait.mp4' => 'h264', + 'silent.mp4' => 'h264', + 'tiny.mp4' => 'h264', + 'chapters.mp4' => 'h264', + 'multi-audio.mp4' => 'h264', + 'multi-audio.mkv' => 'h264', + 'multi-subtitle.mkv' => 'h264', + 'multi-track.mkv' => 'h264', + 'audio.m4a' => null, + 'audio.mp3' => null, + 'artwork.m4a' => null, + ]; + + $this->assertArrayHasKey($name, $expected, $name.' is not described by this test'); + $this->assertSame($expected[$name], $info->videoCodec, $name); + } + + /** + * Packaging normalises whatever came in, so a rung is always delivered at the + * size and codec asked for. + * + * @dataProvider everyVideoSample + */ + public function testPackagesEverySampleAsHls(string $name, string $path): void + { + if ($name === '') { + $this->markTestSkipped('no samples'); + } + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add(new Representation(320, 180, 300, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->out('hls/'.\pathinfo($name, PATHINFO_FILENAME).'-'.\pathinfo($name, PATHINFO_EXTENSION))); + + $this->assertNotEmpty($package->segments(), $name.' produced no segments'); + + foreach ($package->segments() as $segment) { + $this->assertFileExists($segment->path, $name); + $this->assertSame(\filesize($segment->path), $segment->size, $name.' '.$segment->file); + } + } + + /** + * The same breadth for DASH, which is the strictest of the three: it insists + * every rung share an aspect ratio, and it addresses segments through a + * manifest rather than a playlist. Running it over the whole container matrix + * is what proves the MPD describes what was actually written, whatever came + * in. + * + * @dataProvider everyVideoSample + */ + public function testPackagesEverySampleAsDash(string $name, string $path): void + { + if ($name === '') { + $this->markTestSkipped('no samples'); + } + + $dir = $this->out('dash/'.\pathinfo($name, PATHINFO_FILENAME).'-'.\pathinfo($name, PATHINFO_EXTENSION)); + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add(new Representation(320, 180, 300, 64)) + ->output((new Dash())->template(false)->timeline(false)->segment(2)) + ->pack($dir); + + $this->assertNotEmpty($package->segments(), $name.' produced no segments'); + + foreach ($package->segments() as $segment) { + $this->assertFileExists($segment->path, $name); + $this->assertSame(\filesize($segment->path), $segment->size, $name.' '.$segment->file); + } + + // Segments are addressed explicitly, which is the mode a consumer serving + // its own per-segment URLs depends on. + $manifest = (string) \file_get_contents($dir.'/manifest.mpd'); + + $this->assertStringContainsString('assertStringNotContainsString('markTestSkipped('no samples'); + } + + $target = $this->out('stills').'/'.\pathinfo($name, PATHINFO_FILENAME) + .'-'.\pathinfo($name, PATHINFO_EXTENSION).'.jpg'; + + $poster = (new Encoder())->grab($path, $target, (new Thumb())->width(160)); + + $this->assertNotFalse(\getimagesize($poster), $name.' produced an unreadable still'); + } + + /** + * Several audio tracks, each tagged with a language, become separate + * renditions a player can offer by name. + */ + public function testEveryAudioLanguageBecomesItsOwnRendition(): void + { + $samples = Samples::matching('multi-audio.'); + + $this->assertNotEmpty($samples, 'no multi-audio samples were built'); + + foreach ($samples as $name => $path) { + $info = (new Encoder())->probe($path); + + $languages = \array_column($info->audioTracks, 'language'); + + $this->assertGreaterThanOrEqual(3, \count($languages), $name); + $this->assertContains('eng', $languages, $name); + $this->assertContains('spa', $languages, $name); + $this->assertContains('fra', $languages, $name); + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add(new Representation(320, 180, 300, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->out('multi-audio/'.\pathinfo($name, PATHINFO_EXTENSION))); + + $audio = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::AUDIO, + )); + + $this->assertCount(\count($languages), $audio, $name.': one rendition per language'); + + $master = (string) \file_get_contents( + $this->out('multi-audio/'.\pathinfo($name, PATHINFO_EXTENSION)).'/master.m3u8' + ); + + foreach ($languages as $language) { + $this->assertStringContainsString('LANGUAGE="'.$language.'"', $master, $name); + } + + // Exactly one of them is the default a player starts with. + $this->assertSame(1, \substr_count($master, 'DEFAULT=YES'), $name); + } + } + + /** + * @return array + */ + public static function everyOutputShape(): array + { + return ['hls' => ['hls'], 'dash' => ['dash'], 'cmaf' => ['cmaf']]; + } + + private function shape(string $shape): Hls|Dash|Cmaf + { + return match ($shape) { + 'hls' => (new Hls())->segment(2), + 'dash' => (new Dash())->template(false)->timeline(false)->segment(2), + default => (new Cmaf())->segment(2), + }; + } + + /** + * Multi-language audio has to survive into every output shape, not only the + * one it was first made to work in. + * + * @dataProvider everyOutputShape + */ + public function testEveryAudioLanguageSurvivesInto(string $shape): void + { + $path = Samples::all()['multi-audio.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-audio.mkv was not built'); + } + + $expected = ['eng', 'spa', 'fra', 'jpn']; + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add( + new Representation(320, 180, 300, 64), + new Representation(640, 360, 800, 96), + ) + ->output($this->shape($shape)) + ->pack($this->out('multi-audio/'.$shape)); + + $audio = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::AUDIO, + )); + + $this->assertCount(\count($expected), $audio, $shape.': one rendition per language'); + + $languages = \array_map(static fn ($variant): ?string => $variant->language, $audio); + + foreach ($expected as $language) { + $this->assertContains($language, $languages, $shape.' lost '.$language); + } + + // Both video rungs stay switchable alternatives of one another. + $video = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::VIDEO, + )); + + $this->assertCount(2, $video, $shape.': both rungs should survive'); + + foreach ($package->segments() as $segment) { + $this->assertFileExists($segment->path, $shape.' '.$segment->file); + } + } + + /** + * @dataProvider everyOutputShape + */ + public function testTheManifestNamesEveryAudioLanguageIn(string $shape): void + { + $path = Samples::all()['multi-audio.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-audio.mkv was not built'); + } + + $dir = $this->out('multi-audio/manifest-'.$shape); + + (new Packager()) + ->open($path) + ->format($this->format()) + ->add(new Representation(320, 180, 300, 64)) + ->output($this->shape($shape)) + ->pack($dir); + + $expected = ['eng', 'spa', 'fra', 'jpn']; + + // CMAF writes both descriptions, so both have to name the languages — + // checking only one of them once hid a master that named none of them. + if ($shape !== 'dash') { + $master = (string) \file_get_contents($dir.'/master.m3u8'); + + foreach ($expected as $language) { + $this->assertStringContainsString( + 'LANGUAGE="'.$language.'"', + $master, + $shape.': the master does not name '.$language, + ); + } + + // Every audio rendition is named, not just some of them. + $this->assertSame( + \substr_count($master, '#EXT-X-MEDIA:TYPE=AUDIO'), + \substr_count($master, 'TYPE=AUDIO') > 0 + ? \preg_match_all('/#EXT-X-MEDIA:TYPE=AUDIO[^\n]*LANGUAGE=/', $master) + : 0, + $shape.': some audio renditions have no language', + ); + + // Exactly one language is the one a player starts with. + $this->assertSame( + 1, + \preg_match_all('/#EXT-X-MEDIA:TYPE=AUDIO[^\n]*DEFAULT=YES/', $master), + $shape, + ); + } + + if ($shape !== 'hls') { + $manifest = (string) \file_get_contents($dir.'/manifest.mpd'); + + foreach ($expected as $language) { + $this->assertStringContainsString('lang="'.$language.'"', $manifest, $shape); + } + + // Each language is its own adaptation set, or a player cannot pick one. + $this->assertSame(4, \substr_count($manifest, 'contentType="audio"'), $shape); + } + } + + /** + * Subtitles are read but never packaged. + * + * Reporting an embedded track and carrying it into a package are different + * jobs, and only the first belongs here — the application that owns the + * subtitle files decides what to do with them. A source with three of them + * must therefore come out of pack() with none, and no stray WebVTT beside + * the media. + */ + public function testSubtitlesAreReportedByProbeButNeverPackaged(): void + { + $path = Samples::all()['multi-subtitle.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-subtitle.mkv was not built'); + } + + $this->assertCount( + 3, + (new Encoder())->probe($path)->tracks(Track::SUBTITLE), + 'probe should still report what is in the file', + ); + + $dir = $this->out('multi-subtitle/none'); + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add(new Representation(320, 180, 300, 64)) + ->output((new Hls())->segment(2)) + ->pack($dir); + + foreach ($package->variants() as $variant) { + $this->assertNotSame(Track::SUBTITLE, $variant->type); + } + + $this->assertSame([], \glob($dir.'/*.vtt') ?: [], 'no WebVTT should be written'); + $this->assertStringNotContainsString( + 'TYPE=SUBTITLES', + (string) \file_get_contents($dir.'/master.m3u8'), + ); + } + + /** + * The case a real release actually looks like: two rungs and four dubs out + * of one file, every dub selectable. Its two subtitle languages are the + * application's business, so none of them appear here. + * + * @dataProvider everyOutputShape + */ + public function testEveryDubSurvivesAlongsideTheLadderIn(string $shape): void + { + $path = Samples::all()['multi-track.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-track.mkv was not built'); + } + + $dir = $this->out('multi-track/'.$shape); + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add( + new Representation(320, 180, 300, 64), + new Representation(640, 360, 800, 96), + ) + ->output($this->shape($shape)) + ->pack($dir); + + $of = static fn (string $type): array => \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === $type, + )); + + $video = $of(Track::VIDEO); + $audio = $of(Track::AUDIO); + $subtitles = $of(Track::SUBTITLE); + + $this->assertCount(2, $video, $shape.': two rungs'); + $this->assertCount(4, $audio, $shape.': four dubs'); + $this->assertCount(0, $subtitles, $shape.': subtitles are not packaged'); + + $this->assertSame( + ['eng', 'spa', 'fra', 'jpn'], + \array_map(static fn ($variant): ?string => $variant->language, $audio), + $shape.': dubs, in order', + ); + + // Everything that was produced is on disk and accounted for. + foreach ($package->files() as $file) { + $this->assertFileExists($file, $shape); + } + } + + /** + * Encoding produces one rendition of the picture, but there is no reason to + * discard the other audio along the way — a release with four dubs should + * still have four afterwards. + * + * Whether a container can record which language each one is differs, and + * that is the container's business rather than the library's: AVI has + * nowhere to put a per-stream language at all. + * + * @return array + */ + public static function everyEncodeTarget(): array + { + return [ + // container, keeps language tags + 'mp4' => ['mp4', true], + 'mkv' => ['mkv', true], + 'mov' => ['mov', true], + 'wmv' => ['wmv', true], + 'avi' => ['avi', false], + ]; + } + + /** + * @dataProvider everyEncodeTarget + */ + public function testEncodeKeepsEveryDubIn(string $extension, bool $languages): void + { + $path = Samples::all()['multi-track.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-track.mkv was not built'); + } + + $encoder = new Encoder(); + $target = $this->out('encode').'/multi-track.'.$extension; + + $encoder + ->open($path) + ->format($this->format()) + ->add(new Representation(320, 180, 300, 64)) + ->encode($target); + + $result = $encoder->probe($target); + + // One rendition of the picture, at the size asked for. + $this->assertSame(320, $result->width, $extension); + $this->assertSame(180, $result->height, $extension); + + // Every dub survives, whatever the container can say about it. + $this->assertCount(4, $result->tracks(Track::AUDIO), $extension.' lost audio tracks'); + + if ($languages) { + $this->assertSame( + ['eng', 'spa', 'fra', 'jpn'], + \array_map( + // WMV rewrites French as the bibliographic "fre" rather than + // the terminological "fra". Both name the same language, and + // which one appears is the container's choice, not ours. + static fn ($track): ?string => $track->language === 'fre' ? 'fra' : $track->language, + $result->tracks(Track::AUDIO), + ), + $extension.': dubs, in order', + ); + } + + } + + /** + * The staged packaging path builds its intermediates with encode(), so it used + * to hand the packager a file with one dub and no subtitles no matter what + * went in. + */ + public function testStagedIntermediatesKeepEveryDub(): void + { + $path = Samples::all()['multi-track.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-track.mkv was not built'); + } + + $encoder = new Encoder(); + $intermediate = $this->out('encode').'/intermediate.mp4'; + + // The same call staged() makes, one rung at a time. + $encoder + ->open($path) + ->format($this->format()) + ->add(new Representation(640, 360, 800, 96)) + ->encode($intermediate); + + $this->assertCount(4, $encoder->probe($intermediate)->tracks(Track::AUDIO)); + } + + public function testReadsEverySubtitleLanguage(): void + { + $path = Samples::all()['multi-subtitle.mkv'] ?? null; + + if ($path === null) { + $this->markTestSkipped('multi-subtitle.mkv was not built'); + } + + $subtitles = (new Encoder())->probe($path)->tracks(Track::SUBTITLE); + + $languages = \array_map( + static fn ($track): ?string => $track->language, + $subtitles, + ); + + $this->assertCount(3, $subtitles); + $this->assertContains('eng', $languages); + $this->assertContains('fra', $languages); + $this->assertContains('jpn', $languages); + } + + /** + * One shared segment set described two ways, on every container we accept. + */ + public function testCmafSharesOneSegmentSetAcrossContainers(): void + { + foreach (['video.mp4', 'video.mkv', 'video.avi', 'video.wmv'] as $name) { + $path = Samples::all()[$name] ?? null; + + if ($path === null) { + continue; + } + + $dir = $this->out('cmaf/'.\pathinfo($name, PATHINFO_EXTENSION)); + + $package = (new Packager()) + ->open($path) + ->format($this->format()) + ->add( + new Representation(320, 180, 300, 64), + new Representation(640, 360, 800, 96), + ) + ->output((new Cmaf())->segment(2)) + ->pack($dir); + + $fromManifest = \array_map( + static fn ($segment): string => $segment->file, + $package->segments(), + ); + + $fromPlaylists = []; + + foreach (\glob($dir.'/*.m3u8') ?: [] as $playlist) { + if (\basename($playlist) === 'master.m3u8') { + continue; + } + + foreach (M3u8::media($playlist)['segments'] as $segment) { + $fromPlaylists[] = $segment['file']; + } + } + + $this->assertNotEmpty($fromPlaylists, $name); + + \sort($fromManifest); + \sort($fromPlaylists); + + $this->assertSame($fromManifest, $fromPlaylists, $name.': manifests disagree'); + } + } + + public function testTilesATimelineForEveryContainer(): void + { + foreach (['video.mp4', 'video.avi', 'video.wmv', 'video.webm', 'video.flv'] as $name) { + $path = Samples::all()[$name] ?? null; + + if ($path === null) { + continue; + } + + $sheet = (new Encoder())->tile( + $path, + $this->out('timelines/'.\pathinfo($name, PATHINFO_EXTENSION)), + (new Tile())->interval(2.0), + ); + + $this->assertNotEmpty($sheet->images(), $name); + $this->assertNotEmpty($sheet->cues(), $name); + $this->assertNotFalse(\getimagesize($sheet->images()[0]), $name); + } + } + + public function testAudioOnlySamplesHaveNoPictureToGrab(): void + { + $path = Samples::all()['audio.m4a'] ?? null; + + if ($path === null) { + $this->markTestSkipped('audio.m4a was not built'); + } + + $this->expectException(Input::class); + $this->expectExceptionMessage('no image to grab'); + + (new Encoder())->grab($path, $this->out('stills').'/nothing.jpg'); + } + + public function testArtworkIsFoundInASoundFile(): void + { + $path = Samples::all()['artwork.m4a'] ?? null; + + if ($path === null) { + $this->markTestSkipped('artwork.m4a was not built'); + } + + $encoder = new Encoder(); + $info = $encoder->probe($path); + + $this->assertFalse($info->hasVideo); + $this->assertNotNull($info->cover); + + $grabbed = $encoder->grab($path, $this->out('stills').'/artwork.jpg'); + $this->assertNotFalse(\getimagesize($grabbed)); + } + +} diff --git a/tests/Video/Samples.php b/tests/Video/Samples.php new file mode 100644 index 0000000..0fcd1bc --- /dev/null +++ b/tests/Video/Samples.php @@ -0,0 +1,505 @@ +> + */ + private const CONTAINERS = [ + // The everyday case. + 'mp4' => ['-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac'], + // Editors and camera rigs. + 'mov' => ['-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac'], + // The container that actually supports many tracks well. + 'mkv' => ['-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac'], + // Older exports, and the reason "just read the extension" fails. + 'avi' => ['-c:v', 'mpeg4', '-vtag', 'DX50', '-c:a', 'libmp3lame'], + 'wmv' => ['-c:v', 'wmv2', '-c:a', 'wmav2'], + 'flv' => ['-c:v', 'flv1', '-c:a', 'libmp3lame', '-ar', '44100'], + // Broadcast and HLS-adjacent. + 'ts' => ['-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac'], + // Royalty-free web. + 'webm' => ['-c:v', 'libvpx-vp9', '-b:v', '300k', '-cpu-used', '8', '-c:a', 'libopus'], + 'ogv' => ['-c:v', 'libtheora', '-q:v', '4', '-c:a', 'libvorbis'], + // Phones. + '3gp' => ['-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-ar', '8000'], + ]; + + /** + * Audio tracks for the multi-language samples: language tag, title, and the + * tone that makes each one audibly distinct. + * + * @var list + */ + private const LANGUAGES = [ + ['code' => 'eng', 'title' => 'English', 'hz' => 440], + ['code' => 'spa', 'title' => 'Espanol', 'hz' => 554], + ['code' => 'fra', 'title' => 'Francais', 'hz' => 659], + ['code' => 'jpn', 'title' => 'Japanese', 'hz' => 880], + ]; + + public static function root(): string + { + return \dirname(__DIR__, 2).'/tests/samples'; + } + + public static function in(): string + { + return self::root().'/in'; + } + + public static function out(): string + { + return self::root().'/out'; + } + + /** + * Whether the tools needed to build and use the samples are installed. + */ + public static function available(): bool + { + return Process::exists('ffmpeg') && Process::exists('ffprobe'); + } + + /** + * Every sample source, keyed by filename. + * + * @return array + */ + public static function all(): array + { + $found = []; + + foreach (\glob(self::in().'/*') ?: [] as $path) { + if (\is_file($path)) { + $found[\basename($path)] = $path; + } + } + + \ksort($found); + + return $found; + } + + /** + * Samples whose name starts with the given prefix. + * + * @return array + */ + public static function matching(string $prefix): array + { + return \array_filter( + self::all(), + static fn (string $name): bool => \str_starts_with($name, $prefix), + ARRAY_FILTER_USE_KEY, + ); + } + + /** + * Builds anything missing. Safe to call repeatedly — existing files are left + * alone, so a full run costs nothing after the first. + */ + public static function build(bool $force = false): void + { + $in = self::in(); + + if (! \is_dir($in)) { + \mkdir($in, 0o755, true); + } + + if (! \is_dir(self::out())) { + \mkdir(self::out(), 0o755, true); + } + + if ($force) { + foreach (\glob($in.'/*') ?: [] as $path) { + \unlink($path); + } + } + + $master = self::master(); + + self::containers($master); + self::multitrack($master); + self::shapes($master); + self::sound($master); + } + + /** + * The source everything else is derived from: moving detail that compresses + * like real footage, plus a tone. + */ + private static function master(): string + { + $path = self::in().'/video.mp4'; + + if (\is_file($path)) { + return $path; + } + + self::run([ + '-f', 'lavfi', '-i', 'testsrc2=duration='.self::DURATION.':size=640x360:rate=30', + '-f', 'lavfi', '-i', 'sine=frequency=440:duration='.self::DURATION, + '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', + '-force_key_frames', self::KEYFRAME, + '-c:a', 'aac', '-shortest', + '-metadata', 'title=Utopia Video Sample', + '-metadata', 'artist=Utopia', + ], $path); + + return $path; + } + + /** + * Encoders this ffmpeg was actually built with. + * + * Builds differ — a distribution package and a hand-rolled one rarely carry + * the same set — so the sample library adapts instead of failing on whichever + * codec happens to be missing. + * + * @return list + */ + private static function encoders(): array + { + static $found = null; + + if ($found !== null) { + return $found; + } + + $found = []; + + foreach (\explode("\n", Process::read(['ffmpeg', '-hide_banner', '-encoders'], 30)) as $line) { + if (\preg_match('/^\s*[A-Z.]{6}\s+(\S+)/', $line, $match) === 1) { + $found[] = $match[1]; + } + } + + return $found; + } + + /** + * @param list $codecs + */ + private static function buildable(array $codecs): bool + { + $available = self::encoders(); + + foreach ($codecs as $index => $value) { + // Only inspect the values that follow a -c:v or -c:a flag. + $flag = $codecs[$index - 1] ?? ''; + + if (($flag === '-c:v' || $flag === '-c:a') && ! \in_array($value, $available, true)) { + return false; + } + } + + return true; + } + + /** + * The same content in every container worth supporting, so nothing depends + * on a file extension. + */ + private static function containers(string $master): void + { + foreach (self::CONTAINERS as $extension => $codecs) { + $path = self::in().'/video.'.$extension; + + if ($extension === 'mp4' || \is_file($path)) { + continue; + } + + if (! self::buildable($codecs)) { + // This ffmpeg cannot write that combination; the rest still build. + continue; + } + + $args = ['-i', $master, ...$codecs]; + + // Not every codec honours forced keyframes, so only ask where it helps. + if (\in_array('libx264', $codecs, true) || \in_array('libvpx-vp9', $codecs, true)) { + $args[] = '-force_key_frames'; + $args[] = self::KEYFRAME; + } + + if ($extension === '3gp') { + // 3GP expects a small frame and a low sample rate. + $args[] = '-vf'; + $args[] = 'scale=352:288'; + } + + self::run($args, $path); + } + } + + /** + * Several audio tracks, each tagged with a language, in the two containers + * where that is common: MP4 and Matroska. Matroska also carries subtitles in + * more than one language. + */ + private static function multitrack(string $master): void + { + foreach (['mp4' => 3, 'mkv' => 4] as $extension => $count) { + $path = self::in().'/multi-audio.'.$extension; + + if (\is_file($path)) { + continue; + } + + $args = ['-i', $master]; + $languages = \array_slice(self::LANGUAGES, 0, $count); + + foreach ($languages as $language) { + $args[] = '-f'; + $args[] = 'lavfi'; + $args[] = '-i'; + $args[] = 'sine=frequency='.$language['hz'].':duration='.self::DURATION; + } + + $args[] = '-map'; + $args[] = '0:v:0'; + + foreach (\array_keys($languages) as $index) { + $args[] = '-map'; + $args[] = ($index + 1).':a:0'; + } + + foreach ($languages as $index => $language) { + $args[] = '-metadata:s:a:'.$index; + $args[] = 'language='.$language['code']; + $args[] = '-metadata:s:a:'.$index; + $args[] = 'title='.$language['title']; + } + + $args[] = '-c:v'; + $args[] = 'copy'; + $args[] = '-c:a'; + $args[] = 'aac'; + $args[] = '-shortest'; + + self::run($args, $path); + } + + // The realistic case: a release with several dubs and several subtitle + // languages in the one file. + $both = self::in().'/multi-track.mkv'; + + if (! \is_file($both) && \is_file(self::in().'/multi-audio.mkv')) { + $tracks = ['eng' => "1\n00:00:01,000 --> 00:00:03,000\nHello\n", 'fra' => "1\n00:00:01,000 --> 00:00:03,000\nBonjour\n"]; + $args = ['-i', self::in().'/multi-audio.mkv']; + $written = []; + + foreach ($tracks as $code => $body) { + $srt = self::in().'/.both-'.$code.'.srt'; + \file_put_contents($srt, $body); + $written[] = $srt; + $args[] = '-i'; + $args[] = $srt; + } + + $args[] = '-map'; + $args[] = '0'; + + foreach (\array_keys($tracks) as $index => $code) { + $args[] = '-map'; + $args[] = ($index + 1).':0'; + } + + foreach (\array_keys($tracks) as $index => $code) { + $args[] = '-metadata:s:s:'.$index; + $args[] = 'language='.$code; + } + + $args[] = '-c'; + $args[] = 'copy'; + $args[] = '-c:s'; + $args[] = 'srt'; + + self::run($args, $both); + + foreach ($written as $srt) { + @\unlink($srt); + } + } + + // Subtitles in three languages, which is what Matroska is usually used for. + $subtitled = self::in().'/multi-subtitle.mkv'; + + if (! \is_file($subtitled)) { + $tracks = [ + 'eng' => "1\n00:00:01,000 --> 00:00:03,000\nHello\n", + 'fra' => "1\n00:00:01,000 --> 00:00:03,000\nBonjour\n", + 'jpn' => "1\n00:00:01,000 --> 00:00:03,000\nこんにちは\n", + ]; + + $args = ['-i', $master]; + $written = []; + + foreach ($tracks as $code => $body) { + $srt = self::in().'/.'.$code.'.srt'; + \file_put_contents($srt, $body); + $written[] = $srt; + $args[] = '-i'; + $args[] = $srt; + } + + $args[] = '-map'; + $args[] = '0'; + + foreach (\array_keys($tracks) as $index => $code) { + $args[] = '-map'; + $args[] = ($index + 1).':0'; + } + + foreach (\array_keys($tracks) as $index => $code) { + $args[] = '-metadata:s:s:'.$index; + $args[] = 'language='.$code; + } + + $args[] = '-c:v'; + $args[] = 'copy'; + $args[] = '-c:a'; + $args[] = 'copy'; + $args[] = '-c:s'; + $args[] = 'srt'; + + self::run($args, $subtitled); + + foreach ($written as $srt) { + @\unlink($srt); + } + } + } + + /** + * The properties that make real files awkward. + */ + private static function shapes(string $master): void + { + $in = self::in(); + + // Non-square pixels: stored 4:3, displayed wider. + self::once($in.'/anamorphic.mp4', [ + '-i', $master, '-vf', 'scale=640:480,setsar=4:3', + '-c:v', 'libx264', '-preset', 'ultrafast', '-force_key_frames', self::KEYFRAME, '-c:a', 'copy', + ]); + + // Frames arriving at an uneven rate. + self::once($in.'/variable-fps.mp4', [ + '-i', $master, '-vf', "select='not(mod(n,3))+gte(t,3)'", '-fps_mode', 'vfr', + '-c:v', 'libx264', '-preset', 'ultrafast', '-an', + ]); + + // Orientation in the display matrix rather than the frame size. + self::once($in.'/rotated.mp4', [ + '-display_rotation', '90', '-i', $master, '-c', 'copy', + ]); + + // Shot on a phone, held upright. + self::once($in.'/portrait.mp4', [ + '-i', $master, '-vf', 'scale=360:640,setsar=1:1', + '-c:v', 'libx264', '-preset', 'ultrafast', '-force_key_frames', self::KEYFRAME, '-c:a', 'copy', + ]); + + // Newer codec, same content. + self::once($in.'/hevc.mp4', [ + '-i', $master, '-c:v', 'libx265', '-preset', 'ultrafast', '-tag:v', 'hvc1', + '-force_key_frames', self::KEYFRAME, '-c:a', 'copy', + ]); + + // No sound at all. + self::once($in.'/silent.mp4', ['-i', $master, '-an', '-c:v', 'copy']); + + // Shorter than a single segment. + self::once($in.'/tiny.mp4', ['-i', $master, '-t', '0.5', '-c', 'copy']); + + // Chapters, which players show as a timeline. + $meta = $in.'/.chapters.txt'; + \file_put_contents($meta, <<<'META' + ;FFMETADATA1 + title=Chaptered Sample + [CHAPTER] + TIMEBASE=1/1000 + START=0 + END=3000 + title=Opening + [CHAPTER] + TIMEBASE=1/1000 + START=3000 + END=6000 + title=Closing + META); + self::once($in.'/chapters.mp4', ['-i', $master, '-i', $meta, '-map_metadata', '1', '-map', '0', '-c', 'copy']); + @\unlink($meta); + } + + /** + * Audio-only sources, with and without artwork. + */ + private static function sound(string $master): void + { + $in = self::in(); + + self::once($in.'/audio.m4a', ['-i', $master, '-vn', '-c:a', 'aac']); + self::once($in.'/audio.mp3', ['-i', $master, '-vn', '-c:a', 'libmp3lame']); + + $cover = $in.'/.cover.jpg'; + + if (! \is_file($in.'/artwork.m4a')) { + self::run(['-i', $master, '-frames:v', '1', '-vf', 'scale=300:300'], $cover); + self::run([ + '-i', $in.'/audio.m4a', '-i', $cover, + '-map', '0:a', '-map', '1:v', '-c', 'copy', + '-disposition:v:0', 'attached_pic', + ], $in.'/artwork.m4a'); + @\unlink($cover); + } + } + + /** + * @param list $args + */ + private static function once(string $path, array $args): void + { + if (! \is_file($path)) { + self::run($args, $path); + } + } + + /** + * @param list $args + */ + private static function run(array $args, string $target): void + { + Process::run( + [...['ffmpeg', '-y', '-hide_banner', '-loglevel', 'error'], ...$args, $target], + timeout: 300, + ); + } +} diff --git a/tests/Video/Unit/AdapterTest.php b/tests/Video/Unit/AdapterTest.php new file mode 100644 index 0000000..6b57803 --- /dev/null +++ b/tests/Video/Unit/AdapterTest.php @@ -0,0 +1,468 @@ +dir = $dir; + $this->file = $dir.'/source.mp4'; + \file_put_contents($this->file, 'pretend this is a video'); + } + + protected function tearDown(): void + { + foreach (\glob($this->dir.'/*') ?: [] as $path) { + \is_file($path) ? \unlink($path) : @\rmdir($path); + } + + @\rmdir($this->dir); + } + + /** + * The whole point of pushing config in rather than passing it to a + * constructor: an adapter the caller built themselves must still end up + * reading through the probe the caller asked for, not one of its own. + */ + public function testTheProbeGivenToAFacadeIsTheOneAdaptersRead(): void + { + $spy = new class () extends Mock { + public int $reads = 0; + + public function read(string $path): Info + { + $this->reads++; + + return parent::read($path); + } + }; + + $adapter = new FFmpeg(); + + // Constructing the facade is what hands the probe over. + $encoder = new Encoder($adapter, $spy); + + // The adapter reads the source when it opens it, and that read has to + // go through the probe the facade supplied. + $adapter->open($this->file); + + $this->assertSame(1, $spy->reads, 'the adapter read through a probe of its own'); + + // And the facade's own probe() is the same one. + $this->assertSame(8.0, $encoder->probe($this->file)->duration); + $this->assertSame(2, $spy->reads); + } + + /** + * Both facades push the probe, so sharing one backend between them leaves it + * reading through the same probe either way. + */ + public function testEitherFacadePushesItsProbe(): void + { + $spy = new class () extends Mock { + public int $reads = 0; + + public function read(string $path): Info + { + $this->reads++; + + return parent::read($path); + } + }; + + $adapter = new FFmpeg(); + + new Packager($adapter, probe: $spy); + + $adapter->open($this->file); + + $this->assertSame(1, $spy->reads); + } + + public function testOneAdapterCanServeSeveralCapabilities(): void + { + $ffmpeg = new FFmpeg(); + + $this->assertInstanceOf(EncoderAdapter::class, $ffmpeg); + $this->assertInstanceOf(PackagerAdapter::class, $ffmpeg); + + $this->assertSame('ffmpeg', (new Encoder($ffmpeg, new Mock()))->getName()); + $this->assertSame('ffmpeg', (new Packager($ffmpeg, probe: new Mock()))->getName()); + } + + /** + * A backend declares what it can do by which interfaces it implements, and + * that is what the facade branches on. FFprobe reads and nothing else. + */ + public function testABackendDeclaresOnlyWhatItCanDo(): void + { + $ffprobe = new FFprobe(); + + $this->assertInstanceOf(Probe::class, $ffprobe); + $this->assertNotInstanceOf(EncoderAdapter::class, $ffprobe); + $this->assertNotInstanceOf(PackagerAdapter::class, $ffprobe); + } + + /** + * A finished job must not leave its inputs or listeners behind, or a second + * pack reports every log twice and silently reuses the previous output. + */ + public function testAFinishedJobDoesNotLeakIntoTheNext(): void + { + $adapter = new Mock(); + $rep = new Representation(320, 240, 400); + + $package = $adapter + ->open($this->file, $rep) + ->output(new Hls()) + ->pack($this->dir); + + $this->assertNotEmpty($package->segments()); + + // Opening again starts a new job, so what the last one was told is gone. + $adapter->open($this->file, $rep); + + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('output format'); + + $adapter->pack($this->dir); + } + + /** + * Packaging several already encoded renditions is what repeated open() calls + * are for, so within one job they add rather than reset. + */ + public function testRepeatedOpensWithinOneJobAddInputs(): void + { + $adapter = new class () extends Mock { + /** @return list */ + public function registered(): array + { + return $this->paths(); + } + }; + + $adapter + ->open($this->file, new Representation(640, 360, 800)) + ->open($this->file, new Representation(1280, 720, 2538)); + + $this->assertCount(2, $adapter->registered()); + + // A rung tagged on the way in counts as part of the ladder. + $package = $adapter->output(new Hls())->pack($this->dir); + + $this->assertCount(2, $package->variants()); + } + + public function testFactoriesResolveNamesToBackends(): void + { + $this->assertInstanceOf(FFmpeg::class, Adapter::encoder('ffmpeg')); + $this->assertInstanceOf(FFmpeg::class, Adapter::packager('ffmpeg')); + $this->assertInstanceOf(FFprobe::class, Adapter::probe('ffprobe')); + $this->assertInstanceOf(Mock::class, Adapter::encoder('mock')); + $this->assertInstanceOf(Mock::class, Adapter::packager('mock')); + $this->assertInstanceOf(Mock::class, Adapter::probe('mock')); + } + + public function testFactoriesRoundTripWithGetName(): void + { + foreach (['ffprobe', 'mock'] as $name) { + $this->assertSame($name, Adapter::probe($name)->getName()); + } + + foreach (['ffmpeg', 'mock'] as $name) { + $this->assertSame($name, Adapter::encoder($name)->getName()); + $this->assertSame($name, Adapter::packager($name)->getName()); + } + } + + /** + * A backend named in configuration can be handed straight to a facade, and + * the facade reports the same name back. + */ + public function testAFactoryResultGoesStraightIntoAFacade(): void + { + $this->assertSame('mock', (new Encoder(Adapter::encoder('mock'), Adapter::probe('mock')))->getName()); + $this->assertSame('mock', (new Packager(Adapter::packager('mock'), probe: Adapter::probe('mock')))->getName()); + } + + public function testFactoriesPassBinaryPathsThrough(): void + { + $probe = Adapter::probe('ffprobe', '/opt/bin/ffprobe'); + + $this->assertInstanceOf(FFprobe::class, $probe); + $this->assertFalse($probe->available(), 'a made-up path should not be runnable'); + } + + /** + * Errors only, because the interesting output of an encode is the file it + * wrote rather than its commentary. + */ + public function testEveryBackendStartsAtTheDefaultDisplayLevel(): void + { + $this->assertSame(Adapter::ERROR, (new FFmpeg())->level()); + $this->assertSame(Adapter::ERROR, (new FFprobe())->level()); + $this->assertSame(Adapter::ERROR, (new Mock())->level()); + } + + public function testTheDisplayLevelCanBeRaisedOrSilenced(): void + { + $this->assertSame(Adapter::DEBUG, (new FFmpeg(level: Adapter::DEBUG))->level()); + $this->assertSame(Adapter::QUIET, (new Mock(level: Adapter::QUIET))->level()); + } + + public function testFactoriesPassTheDisplayLevelThrough(): void + { + $this->assertSame( + Adapter::VERBOSE, + self::levelOf(Adapter::encoder('ffmpeg', level: Adapter::VERBOSE)), + ); + $this->assertSame( + Adapter::INFO, + self::levelOf(Adapter::packager('mock', level: Adapter::INFO)), + ); + $this->assertSame( + Adapter::WARNING, + self::levelOf(Adapter::probe('ffprobe', level: Adapter::WARNING)), + ); + + // Omitting it keeps each backend's own default. + $this->assertSame(Adapter::ERROR, self::levelOf(Adapter::encoder('ffmpeg'))); + } + + /** + * The factories return the narrow capability type, and the display level is + * shared plumbing on the base rather than part of any capability's contract + * — the same split as setProbe() and available(), which the facades reach + * through an instanceof check too. + */ + private static function levelOf(Named $backend): string + { + return $backend instanceof Adapter ? $backend->level() : 'not an Adapter'; + } + + /** + * Rejected while it is still cheap to say so, rather than by the backend + * halfway through a job. + */ + public function testAnUnknownDisplayLevelIsRejected(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('No display level named "chatty"'); + + new FFmpeg(level: 'chatty'); + } + + /** + * The level reaches the command, which is the only place it can matter. + */ + public function testTheDisplayLevelReachesTheCommand(): void + { + $adapter = new class (level: Adapter::VERBOSE) extends FFmpeg { + /** @return list */ + public function command(): array + { + return $this->prefix(); + } + }; + + $this->assertSame( + ['ffmpeg', '-y', '-hide_banner', '-loglevel', 'verbose'], + \array_slice($adapter->command(), 0, 5), + ); + } + + /** + * A quiet backend has nothing to say, but still reports its progress — + * progress is structured data, not commentary. + */ + public function testAQuietBackendStillReportsProgress(): void + { + $seen = ['progress' => 0, 'log' => 0]; + + (new Mock(level: Adapter::QUIET)) + ->open($this->file) + ->on(Observable::PROGRESS, function () use (&$seen): void { + $seen['progress']++; + }) + ->on(Observable::LOG, function () use (&$seen): void { + $seen['log']++; + }) + ->encode($this->dir.'/quiet.mp4'); + + $this->assertGreaterThan(0, $seen['progress']); + $this->assertSame(0, $seen['log'], 'a quiet backend should log nothing'); + } + + /** + * Finished jobs print a status line unless the backend was told to stay quiet. + */ + public function testStatusLinesFollowTheDisplayLevel(): void + { + $quiet = new class (level: Adapter::QUIET) extends Mock { + /** @var list */ + public array $printed = []; + + protected function reportSuccess(string $message): bool + { + return $this->printed[] = parent::reportSuccess($message); + } + }; + + $quiet->open($this->file)->encode($this->dir.'/quiet-status.mp4'); + + $this->assertSame([false], $quiet->printed); + + $loud = new class () extends Mock { + /** @var list */ + public array $printed = []; + + /** @var list */ + public array $messages = []; + + protected function reportSuccess(string $message): bool + { + $this->messages[] = $message; + + return $this->printed[] = parent::reportSuccess($message); + } + }; + + $path = $this->dir.'/loud-status.mp4'; + $loud->open($this->file)->encode($path); + + $this->assertSame([true], $loud->printed); + $this->assertSame(['mock: encoded '.$path], $loud->messages); + } + + /** + * A failed backend command prints before the exception leaves. + */ + public function testAFailedCommandReportsAnError(): void + { + $adapter = new class () extends FFmpeg { + /** @var list */ + public array $errors = []; + + protected function reportError(string $message): bool + { + $this->errors[] = $message; + + return parent::reportError($message); + } + + public function fail(): void + { + $this->process(['false']); + } + }; + + try { + $adapter->fail(); + $this->fail('a failing command should throw'); + } catch (Runtime $e) { + $this->assertNotSame([], $adapter->errors); + $this->assertStringContainsString('failed with exit code', $adapter->errors[0]); + $this->assertSame($adapter->errors[0], $e->getMessage()); + } + + $quiet = new class (level: Adapter::QUIET) extends FFmpeg { + /** @var list */ + public array $printed = []; + + protected function reportError(string $message): bool + { + return $this->printed[] = parent::reportError($message); + } + + public function fail(): void + { + $this->process(['false']); + } + }; + + try { + $quiet->fail(); + $this->fail('a failing command should throw'); + } catch (Runtime) { + $this->assertSame([false], $quiet->printed); + } + } + + public function testAnUnknownNameIsRejected(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('No probe named "nope"'); + + Adapter::probe('nope'); + } + + public function testAKnownNameForTheWrongCapabilityIsRejected(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('No encoder named "ffprobe"'); + + Adapter::encoder('ffprobe'); + } + + /** + * A backend that drives no binary must not be reported as needing a tool + * that happens to be missing. + */ + public function testABackendWithNoBinaryIsAlwaysAvailable(): void + { + $this->assertTrue((new Mock())->available()); + } + + public function testAMissingBinaryIsReported(): void + { + $this->assertFalse((new FFmpeg('utopia-streaming-nope'))->available()); + } + + public function testEveryAdapterCarriesItsName(): void + { + $this->assertSame('ffmpeg', (new FFmpeg())->getName()); + $this->assertSame('ffprobe', (new FFprobe())->getName()); + $this->assertSame('mock', (new Mock())->getName()); + } + + /** + * Failures say which backend produced them, so a message from two layers + * down is still traceable. + */ + public function testFailuresNameTheBackend(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('mock: source "'); + + (new Mock())->open($this->dir.'/nothing.mp4'); + } +} diff --git a/tests/Video/Unit/ArgumentsDashTest.php b/tests/Video/Unit/ArgumentsDashTest.php new file mode 100644 index 0000000..e7d537d --- /dev/null +++ b/tests/Video/Unit/ArgumentsDashTest.php @@ -0,0 +1,321 @@ + $reps + */ + private function build(Info $info, array $reps, ?Dash $output = null): string + { + $arguments = new Arguments( + $info, + (new X264())->crf(22)->keyframe(2.0), + $reps, + $output ?? new Dash(), + '/out', + ); + + return \implode(' ', $arguments->build()); + } + + public function testTemplatedAddressingIsTheDefault(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-f dash', $argv); + $this->assertStringContainsString('-use_template 1', $argv); + $this->assertStringContainsString('-use_timeline 1', $argv); + } + + /** + * Turning both off is what makes the manifest name every segment outright, + * which is the only way to address them by something other than filename. + */ + public function testListedAddressingIsRequestedByTurningBothOff(): void + { + $output = (new Dash())->template(false)->timeline(false); + + $this->assertTrue($output->listed()); + + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)], $output); + + $this->assertStringContainsString('-use_template 0', $argv); + $this->assertStringContainsString('-use_timeline 0', $argv); + } + + public function testSegmentNamingPatternsAreDeclared(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Dash())->name('video'), + ); + + $this->assertStringContainsString('-init_seg_name video_init_$RepresentationID$.$ext$', $argv); + $this->assertStringContainsString( + '-media_seg_name video_chunk_$RepresentationID$_$Number%05d$.$ext$', + $argv, + ); + } + + public function testSegmentNamingCanBeOverridden(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Dash())->init('i-$RepresentationID$.m4s')->media('c-$Number$.m4s'), + ); + + $this->assertStringContainsString('-init_seg_name i-$RepresentationID$.m4s', $argv); + $this->assertStringContainsString('-media_seg_name c-$Number$.m4s', $argv); + } + + public function testEveryRungLivesInOneCommand(): void + { + $argv = $this->build($this->info(), [ + new Representation(640, 360, 800), + new Representation(1280, 720, 2538), + ]); + + $this->assertSame(2, \substr_count($argv, '-map 0:v:0')); + $this->assertStringContainsString('-filter:v:0 scale=640:360', $argv); + $this->assertStringContainsString('-filter:v:1 scale=1280:720', $argv); + $this->assertSame(1, \substr_count($argv, '-f dash')); + } + + public function testAudioGetsItsOwnAdaptationSet(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-adaptation_sets id=0,streams=v id=1,streams=a', $argv); + } + + /** + * Video rungs are alternatives of one another so they share a set, but audio + * tracks in different languages are separate choices — and a set is the only + * place DASH can record a language. Grouping them together would present four + * languages as four bitrates of the first one. + */ + public function testEachAudioLanguageGetsItsOwnAdaptationSet(): void + { + $info = new Info( + duration: 60.0, + format: 'matroska', + hasVideo: true, + hasAudio: true, + width: 1920, + height: 1080, + audioTracks: [ + ['codec' => 'aac', 'language' => 'eng'], + ['codec' => 'aac', 'language' => 'spa'], + ['codec' => 'aac', 'language' => 'fra'], + ], + ); + + $argv = $this->build($info, [ + new Representation(640, 360, 800), + new Representation(1280, 720, 2538), + ]); + + // Two video rungs occupy output streams 0 and 1, so audio starts at 2. + $this->assertStringContainsString( + '-adaptation_sets id=0,streams=v id=1,streams=2 id=2,streams=3 id=3,streams=4', + $argv, + ); + } + + public function testAdaptationSetsCanStillBeOverridden(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Dash())->sets('id=0,streams=v,a'), + ); + + $this->assertStringContainsString('-adaptation_sets id=0,streams=v,a', $argv); + } + + public function testSilentSourceHasNoAudioAdaptationSet(): void + { + $argv = $this->build($this->info(audio: false), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-adaptation_sets id=0,streams=v', $argv); + $this->assertStringNotContainsString('streams=a', $argv); + } + + public function testWholePresentationStaysInTheManifest(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-window_size 0', $argv); + } + + /** + * @testdox The manifest is written where the output says + */ + public function testManifestPath(): void + { + $arguments = new Arguments( + $this->info(), + new X264(), + [new Representation(1280, 720, 2538)], + (new Dash())->manifest('video.mpd'), + '/out', + ); + + $this->assertSame('/out/video.mpd', $arguments->target()); + } + + /** + * @testdox VP9 packages as DASH + */ + public function testVp9PackagesAsDash(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $arguments = new Arguments( + $this->info(), + new VP9(), + [new Representation(1280, 720, 2538)], + new Dash(), + '/out', + ); + + $this->assertStringContainsString('-f dash', $argv); + $this->assertContains('libvpx-vp9', $arguments->build()); + } + + public function testCmafAsksTheSameMuxerForBothDescriptions(): void + { + $arguments = new CmafArguments( + $this->info(), + (new X264())->crf(22)->keyframe(2.0), + [new Representation(1280, 720, 2538)], + new Cmaf(), + '/out', + ); + + $argv = \implode(' ', $arguments->build()); + + $this->assertStringContainsString('-f dash', $argv); + $this->assertStringContainsString('-dash_segment_type mp4', $argv); + $this->assertStringContainsString('-hls_playlist 1', $argv); + $this->assertStringContainsString('-hls_master_name master.m3u8', $argv); + $this->assertSame('/out/manifest.mpd', $arguments->target()); + } + + /** + * The muxer puts every video rung in one adaptation set and then refuses a + * set whose members disagree on aspect ratio — but only once the command is + * already running, and without saying which rungs clashed. + */ + public function testRejectsALadderThatChangesShape(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('share one aspect ratio'); + + new Arguments( + $this->info(), + new X264(), + [ + new Representation(320, 240, 300), // 4:3 + new Representation(640, 360, 800), // 16:9 + ], + new Dash(), + '/out', + ); + } + + public function testTheMessageNamesTheRungsThatClash(): void + { + try { + new Arguments( + $this->info(), + new X264(), + [new Representation(320, 240, 300), new Representation(640, 360, 800)], + new Dash(), + '/out', + ); + $this->fail('expected the mismatch to be rejected'); + } catch (Unsupported $exception) { + $this->assertStringContainsString('4:3', $exception->getMessage()); + $this->assertStringContainsString('240p (320x240)', $exception->getMessage()); + $this->assertStringContainsString('16:9', $exception->getMessage()); + $this->assertStringContainsString('360p (640x360)', $exception->getMessage()); + $this->assertStringContainsString('HLS', $exception->getMessage()); + } + } + + public function testAcceptsALadderThatKeepsItsShape(): void + { + $argv = $this->build($this->info(), [ + new Representation(320, 180, 300), + new Representation(640, 360, 800), + new Representation(1280, 720, 2538), + ]); + + $this->assertStringContainsString('-f dash', $argv); + $this->assertSame(3, \substr_count($argv, '-map 0:v:0')); + } + + public function testCmafInheritsTheSameShapeRule(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('share one aspect ratio'); + + new CmafArguments( + $this->info(), + new X264(), + [new Representation(320, 240, 300), new Representation(640, 360, 800)], + new Cmaf(), + '/out', + ); + } + + public function testCmafListsEverySegmentByDefault(): void + { + $output = new Cmaf(); + + $this->assertTrue($output->listed()); + + $arguments = new CmafArguments( + $this->info(), + new X264(), + [new Representation(1280, 720, 2538)], + $output, + '/out', + ); + + $argv = \implode(' ', $arguments->build()); + + $this->assertStringContainsString('-use_template 0', $argv); + $this->assertStringContainsString('-use_timeline 0', $argv); + } +} diff --git a/tests/Video/Unit/ArgumentsHlsTest.php b/tests/Video/Unit/ArgumentsHlsTest.php new file mode 100644 index 0000000..51e63a6 --- /dev/null +++ b/tests/Video/Unit/ArgumentsHlsTest.php @@ -0,0 +1,242 @@ + $audioTracks + */ + private function info(bool $video = true, bool $audio = true, array $audioTracks = []): Info + { + return new Info( + duration: 60.0, + format: 'mov,mp4', + hasVideo: $video, + hasAudio: $audio, + width: 1920, + height: 1080, + audioTracks: $audioTracks, + ); + } + + /** + * @param list $reps + */ + private function build(Info $info, array $reps, ?Hls $output = null): string + { + $arguments = new Arguments( + $info, + (new X264())->crf(22)->keyframe(2.0), + $reps, + $output ?? new Hls(), + '/out', + ); + + return \implode(' ', $arguments->build()); + } + + public function testSingleRungCarriesSizingAndCappedBitrate(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538, 128)]); + + $this->assertStringContainsString('-map 0:v:0', $argv); + $this->assertStringContainsString('-map 0:a:0', $argv); + $this->assertStringContainsString('-filter:v:0 scale=1280:720', $argv); + $this->assertStringContainsString('-b:v:0 2538k', $argv); + $this->assertStringContainsString('-maxrate:v:0 2538k', $argv); + $this->assertStringContainsString('-bufsize:v:0 5076k', $argv); + $this->assertStringContainsString('-b:a:0 128k', $argv); + } + + /** + * Every rung has to be described by one command; running ffmpeg once per + * rung would decode the source repeatedly and leave each run overwriting + * the master playlist the previous one wrote. + */ + public function testEveryRungLivesInOneCommandAndOneMaster(): void + { + $argv = $this->build($this->info(), [ + new Representation(640, 360, 800, 96), + new Representation(1280, 720, 2538, 128), + ]); + + $this->assertSame(2, \substr_count($argv, '-map 0:v:0')); + $this->assertStringContainsString('-filter:v:0 scale=640:360', $argv); + $this->assertStringContainsString('-filter:v:1 scale=1280:720', $argv); + $this->assertStringContainsString('-b:v:0 800k', $argv); + $this->assertStringContainsString('-b:v:1 2538k', $argv); + $this->assertSame(1, \substr_count($argv, '-master_pl_name')); + $this->assertSame(1, \substr_count($argv, '-var_stream_map')); + $this->assertStringContainsString('v:0,name:360p,agroup:audio v:1,name:720p,agroup:audio', $argv); + } + + public function testOutputPathCarriesTheVariantPlaceholder(): void + { + $arguments = new Arguments( + $this->info(), + new X264(), + [new Representation(1280, 720, 2538)], + (new Hls())->name('video'), + '/out', + ); + + $this->assertSame('/out/video_%v.m3u8', $arguments->target()); + } + + public function testTransportStreamIsTheDefault(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-hls_segment_type mpegts', $argv); + $this->assertStringContainsString('-hls_segment_filename /out/stream_%v_%04d.ts', $argv); + $this->assertStringNotContainsString('-hls_fmp4_init_filename', $argv); + } + + public function testFragmentedMp4AddsAnInitialisationSegment(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Hls())->segments(Hls::FMP4), + ); + + $this->assertStringContainsString('-hls_segment_type fmp4', $argv); + $this->assertStringContainsString('-hls_segment_filename /out/stream_%v_%04d.m4s', $argv); + $this->assertStringContainsString('-hls_fmp4_init_filename stream_%v_init.mp4', $argv); + } + + public function testSegmentsAreDeclaredIndependent(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-hls_flags independent_segments', $argv); + } + + public function testVodPlaylistKeepsEverySegment(): void + { + $argv = $this->build($this->info(), [new Representation(1280, 720, 2538)]); + + $this->assertStringContainsString('-hls_playlist_type vod', $argv); + $this->assertStringContainsString('-hls_list_size 0', $argv); + } + + public function testSegmentDurationIsHonoured(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Hls())->segment(6), + ); + + $this->assertStringContainsString('-hls_time 6', $argv); + } + + public function testLanguageTaggedTracksBecomeSeparateAudioRenditions(): void + { + $info = $this->info(audioTracks: [ + ['codec' => 'aac', 'language' => 'eng'], + ['codec' => 'aac', 'language' => 'spa'], + ]); + + $argv = $this->build($info, [new Representation(1280, 720, 2538, 128)]); + + $this->assertStringContainsString('-map 0:a:0', $argv); + $this->assertStringContainsString('-map 0:a:1', $argv); + $this->assertStringContainsString('a:0,agroup:audio,language:eng,name:audio_0,default:yes', $argv); + $this->assertStringContainsString('a:1,agroup:audio,language:spa,name:audio_1', $argv); + $this->assertStringNotContainsString('a:1,agroup:audio,language:spa,name:audio_1,default:yes', $argv); + } + + public function testUntaggedAudioBecomesOneAnonymousRendition(): void + { + $info = $this->info(audioTracks: [['codec' => 'aac', 'language' => 'und']]); + + $argv = $this->build($info, [new Representation(1280, 720, 2538)]); + + $this->assertSame(1, \substr_count($argv, '-map 0:a:')); + $this->assertStringContainsString('a:0,agroup:audio,name:audio_0,default:yes', $argv); + } + + public function testAudioOnlySourceDropsEveryVideoArgument(): void + { + $argv = $this->build( + $this->info(video: false), + [new Representation(0, 0, 1, 128)], + ); + + $this->assertStringNotContainsString('-c:v', $argv); + $this->assertStringNotContainsString('-map 0:v:0', $argv); + $this->assertStringNotContainsString('-filter:v:0', $argv); + $this->assertStringNotContainsString('-b:v:0', $argv); + $this->assertStringNotContainsString('-crf', $argv); + $this->assertStringContainsString('-b:a:0 128k', $argv); + } + + public function testSilentSourceDropsEveryAudioArgument(): void + { + $argv = $this->build( + $this->info(audio: false), + [new Representation(1280, 720, 2538)], + ); + + $this->assertStringNotContainsString('-c:a', $argv); + $this->assertStringNotContainsString('-map 0:a', $argv); + $this->assertStringNotContainsString('agroup', $argv); + $this->assertStringContainsString('v:0,name:720p', $argv); + } + + public function testBaseUrlIsWrittenIntoPlaylists(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Hls())->url('https://cdn.example/'), + ); + + $this->assertStringContainsString('-hls_base_url https://cdn.example/', $argv); + } + + public function testRawMuxerParametersAreAppended(): void + { + $argv = $this->build( + $this->info(), + [new Representation(1280, 720, 2538)], + (new Hls())->params(['-tag:v', 'avc1']), + ); + + $this->assertStringContainsString('-tag:v avc1', $argv); + } + + public function testCodecThatCannotBePackagedIsRejected(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('HLS cannot carry libvpx-vp9'); + + new Arguments( + $this->info(), + new VP9(), + [new Representation(1280, 720, 2538)], + new Hls(), + '/out', + ); + } + + public function testLadderCannotBeEmpty(): void + { + $this->expectException(Unsupported::class); + + new Arguments($this->info(), new X264(), [], new Hls(), '/out'); + } +} diff --git a/tests/Video/Unit/EncoderTest.php b/tests/Video/Unit/EncoderTest.php new file mode 100644 index 0000000..3f2a08c --- /dev/null +++ b/tests/Video/Unit/EncoderTest.php @@ -0,0 +1,119 @@ +dir = $dir; + $this->file = $dir.'/source.mp4'; + \file_put_contents($this->file, 'source'); + } + + protected function tearDown(): void + { + foreach (\glob($this->dir.'/*') ?: [] as $path) { + \unlink($path); + } + + @\rmdir($this->dir); + } + + private function encoder(?FakeEncoder $adapter = null): Encoder + { + return new Encoder($adapter ?? new FakeEncoder(), new FakeProbe()); + } + + public function testDelegatesProbing(): void + { + $info = $this->encoder()->probe($this->file); + + $this->assertSame(10.0, $info->duration); + $this->assertTrue($this->encoder()->valid($this->file)); + } + + public function testReportsWhichBackendIsWorking(): void + { + $this->assertSame('fake', $this->encoder()->getName()); + } + + public function testDelegatesStillsAndSheets(): void + { + $encoder = $this->encoder(); + + $this->assertSame('/out/poster.jpg', $encoder->grab($this->file, '/out/poster.jpg')); + $this->assertCount(1, $encoder->tile($this->file, '/out')->images()); + } + + public function testEncodePassesTheChainToTheAdapter(): void + { + $adapter = new FakeEncoder(); + $format = new X264(); + $rep = new Representation(1280, 720, 2538); + + $path = $this->encoder($adapter) + ->open($this->file) + ->format($format) + ->add($rep) + ->encode($this->dir.'/video.mp4'); + + $this->assertSame($this->dir.'/video.mp4', $path); + $this->assertSame($this->file, $adapter->opened); + $this->assertSame($format, $adapter->format); + $this->assertSame([$rep], $adapter->reps); + } + + public function testListenersReachTheAdapter(): void + { + $adapter = new FakeEncoder(); + $seen = []; + + $this->encoder($adapter) + ->open($this->file) + ->add(new Representation(1280, 720, 2538)) + ->on(Encoder::PROGRESS, function ($progress) use (&$seen): void { + $seen[] = $progress->percent; + }) + ->encode($this->dir.'/video.mp4'); + + $this->assertSame([25.0, 100.0], $seen); + } + + /** + * The adapter's own open() drops the previous job, so a reused facade does + * not carry the last job's rungs into this one. + */ + public function testOpeningAgainForgetsThePreviousJob(): void + { + $adapter = new FakeEncoder(); + $encoder = $this->encoder($adapter); + + $encoder->open($this->file)->add(new Representation(1280, 720, 2538)); + $encoder->open($this->file)->encode($this->dir.'/video.mp4'); + + $this->assertSame([], $adapter->reps); + } + + public function testOpeningAMissingSourceIsAFailure(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('does not exist'); + + $this->encoder()->open('/nowhere/nothing.mp4'); + } +} diff --git a/tests/Video/Unit/FakeEncoder.php b/tests/Video/Unit/FakeEncoder.php new file mode 100644 index 0000000..866a939 --- /dev/null +++ b/tests/Video/Unit/FakeEncoder.php @@ -0,0 +1,102 @@ + */ + public array $reps = []; + + public int $encoded = 0; + + /** @var array> */ + private array $listeners = []; + + public function open(string $path): static + { + $this->opened = $path; + $this->reps = []; + $this->listeners = []; + + return $this; + } + + public function valid(string $path): bool + { + return true; + } + + public function format(Format $format): static + { + $this->format = $format; + + return $this; + } + + public function add(Representation ...$representations): static + { + foreach ($representations as $representation) { + $this->reps[] = $representation; + } + + return $this; + } + + public function on(string $event, callable $listener): static + { + $this->listeners[$event][] = $listener; + + return $this; + } + + public function encode(string $path): string + { + $this->encoded++; + + foreach ([25.0, 100.0] as $percent) { + foreach ($this->listeners[Observable::PROGRESS] ?? [] as $listener) { + $listener(new Progress(percent: $percent)); + } + } + + $directory = \dirname($path); + + if (! \is_dir($directory)) { + \mkdir($directory, 0o755, true); + } + + \file_put_contents($path, 'encoded'); + + return $path; + } + + public function grab(string $path, string $output, ?Thumb $options = null): string + { + return $output; + } + + public function tile(string $path, string $dir, ?Tile $options = null): Spritesheet + { + return new Spritesheet([$dir.'/sprite1.jpg'], []); + } + + public function getName(): string + { + return 'fake'; + } +} diff --git a/tests/Video/Unit/FakePackager.php b/tests/Video/Unit/FakePackager.php new file mode 100644 index 0000000..842b19d --- /dev/null +++ b/tests/Video/Unit/FakePackager.php @@ -0,0 +1,79 @@ + */ + public array $opened = []; + + /** @var list */ + public array $tagged = []; + + /** @var list */ + public array $reps = []; + + public ?Output $output = null; + + /** @var array> */ + protected array $listeners = []; + + public function open(string $path, ?Representation $as = null): static + { + $this->opened[] = $path; + $this->tagged[] = $as; + + return $this; + } + + public function add(Representation ...$representations): static + { + foreach ($representations as $representation) { + $this->reps[] = $representation; + } + + return $this; + } + + public function output(Output $output): static + { + $this->output = $output; + + return $this; + } + + public function on(string $event, callable $listener): static + { + $this->listeners[$event][] = $listener; + + return $this; + } + + public function pack(string $dir): Package + { + foreach ($this->listeners[Observable::LOG] ?? [] as $listener) { + $listener('packing'); + } + + return new Package(); + } + + public function getName(): string + { + return 'fake'; + } +} diff --git a/tests/Video/Unit/FakeProbe.php b/tests/Video/Unit/FakeProbe.php new file mode 100644 index 0000000..64cafc6 --- /dev/null +++ b/tests/Video/Unit/FakeProbe.php @@ -0,0 +1,26 @@ +build(); + + $this->assertSame(['-c:v', 'libx264', '-c:a', 'aac'], \array_slice($args, 0, 4)); + $this->assertContains('-keyint_min', $args); + $this->assertContains('-g', $args); + } + + /** + * @testdox The quality knobs reach the command + */ + public function testQualityKnobsAppear(): void + { + $args = (new X264())->crf(22)->bframes(3)->keyframe(2.0)->build(); + $argv = \implode(' ', $args); + + $this->assertStringContainsString('-crf 22', $argv); + $this->assertStringContainsString('-bf 3', $argv); + $this->assertStringContainsString('-force_key_frames expr:gte(t,n_forced*2)', $argv); + } + + public function testFractionalKeyframeIntervalStaysReadable(): void + { + $args = (new X264())->keyframe(1.5)->build(); + + $this->assertContains('expr:gte(t,n_forced*1.5)', $args); + } + + public function testRawParametersAreAppendedLast(): void + { + $args = (new X264())->crf(22)->params(['-dn', '-sn'])->build(); + + $this->assertSame(['-dn', '-sn'], \array_slice($args, -2)); + } + + public function testAudioOnlyJobDropsVideoArguments(): void + { + $args = (new X264())->crf(22)->bframes(3)->build(video: false); + + $this->assertSame(['-c:a', 'aac'], $args); + $this->assertNotContains('-crf', $args); + $this->assertNotContains('-c:v', $args); + } + + public function testVideoOnlyJobDropsAudioCodec(): void + { + $args = (new X264())->build(audio: false); + + $this->assertNotContains('-c:a', $args); + $this->assertContains('-c:v', $args); + } + + /** + * @testdox HEVC is tagged hvc1 so Apple players accept it + */ + public function testHevcTagsForApplePlayback(): void + { + $args = (new HEVC())->build(); + + $this->assertContains('libx265', $args); + $this->assertContains('hvc1', $args); + } + + /** + * @testdox VP9 can be packaged as DASH but not as HLS or CMAF + */ + public function testVp9PackagesAsDashOnly(): void + { + $format = new VP9(); + + $this->assertSame([Output::DASH], $format->supports()); + $this->assertContains('libvpx-vp9', $format->build()); + $this->assertSame('libopus', $format->sound()); + } + + /** + * @testdox X264 can be packaged as HLS, DASH and CMAF alike + */ + public function testX264PackagesEverywhere(): void + { + $this->assertSame( + [Output::HLS, Output::DASH, Output::CMAF], + (new X264())->supports(), + ); + } + + public function testCopyIgnoresQualityKnobs(): void + { + $args = (new Copy())->crf(22)->bframes(3)->keyframe(2.0)->build(); + + $this->assertSame(['-c:v', 'copy', '-c:a', 'copy'], $args); + } + + public function testCodecsCanBeOverridden(): void + { + $format = new X264('h264_videotoolbox', 'libfdk_aac'); + + $this->assertSame('h264_videotoolbox', $format->codec()); + $this->assertSame('libfdk_aac', $format->sound()); + } +} diff --git a/tests/Video/Unit/FusedPackager.php b/tests/Video/Unit/FusedPackager.php new file mode 100644 index 0000000..fdbd1e8 --- /dev/null +++ b/tests/Video/Unit/FusedPackager.php @@ -0,0 +1,47 @@ +format = $format; + + return $this; + } + + public function encode(string $path): string + { + return $path; + } + + public function grab(string $path, string $output, ?Thumb $options = null): string + { + return $output; + } + + public function tile(string $path, string $dir, ?Tile $options = null): Spritesheet + { + return new Spritesheet([$dir.'/sprite1.jpg'], []); + } +} diff --git a/tests/Video/Unit/MockTest.php b/tests/Video/Unit/MockTest.php new file mode 100644 index 0000000..4ccaf5d --- /dev/null +++ b/tests/Video/Unit/MockTest.php @@ -0,0 +1,80 @@ +adapter ??= new Mock(); + } + + protected function encoder(): Encoder + { + // Given as the probe as well, so nothing here reaches for a binary. + return new Encoder($this->adapter(), $this->adapter()); + } + + protected function packager(): Packager + { + return new Packager($this->adapter(), probe: $this->adapter()); + } + + protected function source(): string + { + $path = $this->dir.'/source.mp4'; + + if (! \is_file($path)) { + \file_put_contents($path, 'pretend this is a video'); + } + + return $path; + } + + protected function names(): array + { + return [ + 'encoder' => 'mock', + 'packager' => 'mock', + ]; + } + + /** + * One class covering several capabilities is the point of the merge: pass it + * once and it serves every role it can, on either facade. + */ + public function testOneAdapterServesEveryCapabilityItImplements(): void + { + $mock = new Mock(); + + $this->assertSame('mock', (new Encoder($mock, $mock))->getName()); + $this->assertSame('mock', (new Packager($mock, probe: $mock))->getName()); + } + + public function testDescribesTheSourceItWasToldToPretend(): void + { + $adapter = (new Mock())->pretend(duration: 12.0, width: 1280, height: 720, audio: false); + $info = (new Encoder($adapter, $adapter))->probe($this->source()); + + $this->assertSame(12.0, $info->duration); + $this->assertSame(1280, $info->width); + $this->assertFalse($info->hasAudio); + } +} diff --git a/tests/Video/Unit/OutputTest.php b/tests/Video/Unit/OutputTest.php new file mode 100644 index 0000000..8d192db --- /dev/null +++ b/tests/Video/Unit/OutputTest.php @@ -0,0 +1,169 @@ +assertSame(6.0, $output->duration()); + $this->assertTrue($output->keeps()); + $this->assertSame('stream', $output->base()); + $this->assertSame([], $output->extra()); + } + } + + /** + * @testdox Each output reports which kind of stream it is + */ + public function testTypes(): void + { + $this->assertSame(Output::HLS, (new Hls())->type()); + $this->assertSame(Output::DASH, (new Dash())->type()); + $this->assertSame(Output::CMAF, (new Cmaf())->type()); + } + + public function testHlsDefaultsToTransportStream(): void + { + $hls = new Hls(); + + $this->assertFalse($hls->fragmented()); + $this->assertSame('ts', $hls->extension()); + $this->assertSame('master.m3u8', $hls->masterFile()); + $this->assertSame(['independent_segments'], $hls->hlsFlags()); + } + + /** + * @testdox HLS in fMP4 mode asks for an init segment + */ + public function testHlsFragmentedMp4(): void + { + $hls = (new Hls())->segments(Hls::FMP4); + + $this->assertTrue($hls->fragmented()); + $this->assertSame('m4s', $hls->extension()); + } + + public function testDashDefaultsToTemplatedAddressing(): void + { + $dash = new Dash(); + + $this->assertTrue($dash->templated()); + $this->assertTrue($dash->timelined()); + $this->assertFalse($dash->listed()); + $this->assertSame('manifest.mpd', $dash->manifestFile()); + } + + public function testDashListsSegmentsOnlyWhenBothSwitchesAreOff(): void + { + $this->assertFalse((new Dash())->template(false)->listed()); + $this->assertFalse((new Dash())->timeline(false)->listed()); + $this->assertTrue((new Dash())->template(false)->timeline(false)->listed()); + } + + /** + * Both manifests have to describe the same segments, so CMAF starts from + * explicit addressing rather than a formula. + */ + public function testCmafListsSegmentsByDefault(): void + { + $cmaf = new Cmaf(); + + $this->assertTrue($cmaf->listed()); + $this->assertSame('master.m3u8', $cmaf->masterFile()); + $this->assertSame('manifest.mpd', $cmaf->manifestFile()); + } + + public function testNamingFlowsIntoSegmentPatterns(): void + { + $dash = (new Dash())->name('video'); + + $this->assertStringStartsWith('video_init_', $dash->initPattern()); + $this->assertStringStartsWith('video_chunk_', $dash->mediaPattern()); + } + + public function testAdaptationSetsFollowTheStreamsPresent(): void + { + $dash = new Dash(); + + $this->assertSame('id=0,streams=v id=1,streams=a', $dash->adaptations(1, 1)); + $this->assertSame('id=0,streams=v', $dash->adaptations(1, 0)); + $this->assertSame('custom', $dash->sets('custom')->adaptations(1, 1)); + } + + /** + * Languages are separate choices rather than bitrate alternatives, so each + * needs its own set — that is also the only place DASH records a language. + */ + public function testEachAudioTrackBeyondTheFirstGetsItsOwnAdaptationSet(): void + { + $dash = new Dash(); + + // One video rung, so audio output streams start at 1. + $this->assertSame( + 'id=0,streams=v id=1,streams=1 id=2,streams=2 id=3,streams=3', + $dash->adaptations(1, 3), + ); + + // Three rungs push the audio along to streams 3 and 4. + $this->assertSame( + 'id=0,streams=v id=1,streams=3 id=2,streams=4', + $dash->adaptations(3, 2), + ); + } + + public function testAudioOnlyOutputHasNoVideoAdaptationSet(): void + { + $this->assertSame('id=0,streams=a', (new Dash())->adaptations(0, 1)); + } + + /** + * Adapters read configuration but must never write back into it, or the + * same object could not be reused for a second job. + */ + public function testConfigurationIsReusable(): void + { + $output = (new Hls())->segment(4)->name('shared'); + + $this->assertSame(4.0, $output->duration()); + $this->assertSame('shared', $output->base()); + $this->assertSame(4.0, $output->duration()); + $this->assertSame('shared', $output->base()); + } + + public function testManifestsCanBeTurnedOff(): void + { + $this->assertFalse((new Hls())->manifests(false)->keeps()); + } + + public function testThumbDefaults(): void + { + $thumb = new Thumb(); + + $this->assertNull($thumb->at()); + $this->assertSame(320, $thumb->size()); + $this->assertSame(2, $thumb->scale()); + } + + public function testThumbIsConfigurable(): void + { + $thumb = (new Thumb())->time(12.5)->width(640)->quality(4); + + $this->assertSame(12.5, $thumb->at()); + $this->assertSame(640, $thumb->size()); + $this->assertSame(4, $thumb->scale()); + } +} diff --git a/tests/Video/Unit/PackageTest.php b/tests/Video/Unit/PackageTest.php new file mode 100644 index 0000000..b80ddb3 --- /dev/null +++ b/tests/Video/Unit/PackageTest.php @@ -0,0 +1,129 @@ + 'static'], + duration: 10.0, + ); + } + + public function testFlattensSegmentsAcrossVariants(): void + { + $this->assertCount(4, $this->package()->segments()); + } + + public function testFiltersSegmentsByVariant(): void + { + $segments = $this->package()->segments('1'); + + $this->assertCount(1, $segments); + $this->assertSame('a_0.m4s', $segments[0]->file); + } + + public function testListsEveryArtifactForUpload(): void + { + $files = $this->package()->files(); + + $this->assertContains('/out/manifest.mpd', $files); + $this->assertContains('/out/master.m3u8', $files); + $this->assertContains('/out/v_init.mp4', $files); + $this->assertContains('/out/a_0.m4s', $files); + $this->assertCount(7, $files); + } + + public function testFindsAVariantById(): void + { + $this->assertSame(Track::AUDIO, $this->package()->variant('1')?->type); + $this->assertNull($this->package()->variant('nope')); + } + + public function testAddsUpSegmentSizes(): void + { + $this->assertSame(2700, $this->package()->size()); + } + + public function testCarriesManifestMetadata(): void + { + $package = $this->package(); + + $this->assertSame('static', $package->metadata()['type']); + $this->assertSame(10.0, $package->duration()); + } + + public function testKnowsWhichManifestIsTheEntryPoint(): void + { + $manifests = $this->package()->manifests(); + + $this->assertTrue($manifests[0]->main); + $this->assertFalse($manifests[2]->main); + $this->assertSame('manifest.mpd', $manifests[0]->file()); + } + + /** + * Structured data is the point of the result object; a consumer that builds + * its own manifests should still get everything it needs. + */ + public function testSegmentsSurviveWithoutManifests(): void + { + $package = new Package(variants: $this->package()->variants()); + + $this->assertSame([], $package->manifests()); + $this->assertCount(4, $package->segments()); + $this->assertCount(4, $package->files()); + } + + /** + * @testdox An empty package reports nothing rather than failing + */ + public function testEmptyPackage(): void + { + $package = new Package(); + + $this->assertSame([], $package->variants()); + $this->assertSame([], $package->segments()); + $this->assertSame([], $package->files()); + $this->assertSame(0, $package->size()); + } +} diff --git a/tests/Video/Unit/PackagerTest.php b/tests/Video/Unit/PackagerTest.php new file mode 100644 index 0000000..b5f47c3 --- /dev/null +++ b/tests/Video/Unit/PackagerTest.php @@ -0,0 +1,246 @@ +dir = $dir; + $this->file = $dir.'/source.mp4'; + \file_put_contents($this->file, 'source'); + } + + protected function tearDown(): void + { + $this->clean($this->dir); + } + + private function clean(string $dir): void + { + foreach (\glob($dir.'/*') ?: [] as $path) { + \is_dir($path) ? $this->clean($path) : \unlink($path); + } + + @\rmdir($dir); + } + + private function packager( + ?PackagerAdapter $adapter = null, + ?EncoderAdapter $encoder = null, + ): Packager { + return new Packager( + adapter: $adapter ?? new FakePackager(), + encoder: $encoder ?? new FakeEncoder(), + probe: new FakeProbe(), + ); + } + + public function testDelegatesProbing(): void + { + $info = $this->packager()->probe($this->file); + + $this->assertSame(10.0, $info->duration); + $this->assertTrue($this->packager()->valid($this->file)); + } + + public function testReportsWhichBackendIsWorking(): void + { + $this->assertSame('fake', $this->packager()->getName()); + } + + /** + * A packager that can also encode gets the source directly, so the whole + * job is one pass instead of writing an intermediate file first. + */ + public function testPackagerThatEncodesReceivesTheSourceItself(): void + { + $encoder = new FakeEncoder(); + $adapter = new FusedPackager(); + $output = new Cmaf(); + $rep = new Representation(1280, 720, 2538); + + $this->packager($adapter, $encoder) + ->open($this->file) + ->format(new X264()) + ->add($rep) + ->output($output) + ->pack($this->dir.'/out'); + + $this->assertSame([$this->file], $adapter->opened); + $this->assertSame($output, $adapter->output); + $this->assertSame([$rep], $adapter->reps); + $this->assertNull($encoder->opened, 'the source should not be encoded separately'); + } + + /** + * A packager that cannot encode is handed one finished file per rung. + * + * This is the seam an alternative packaging backend plugs into: implement + * Adapter\Packager and nothing in the facade has to change. + */ + public function testPackagerThatCannotEncodeReceivesFinishedFiles(): void + { + $encoder = new FakeEncoder(); + $adapter = new FakePackager(); + + $this->packager($adapter, $encoder) + ->open($this->file) + ->format(new X264()) + ->add( + new Representation(640, 360, 800), + new Representation(1280, 720, 2538), + ) + ->output(new Cmaf()) + ->pack($this->dir.'/out'); + + $this->assertSame(2, $encoder->encoded); + $this->assertCount(2, $adapter->opened); + $this->assertStringEndsWith('360p.mp4', $adapter->opened[0]); + $this->assertStringEndsWith('720p.mp4', $adapter->opened[1]); + $this->assertSame('360p', $adapter->tagged[0]?->name); + } + + public function testStagedIntermediatesAreCleanedUp(): void + { + $this->packager() + ->open($this->file) + ->add(new Representation(640, 360, 800)) + ->output(new Cmaf()) + ->pack($this->dir.'/out'); + + $this->assertDirectoryDoesNotExist($this->dir.'/out/.staging'); + } + + public function testStagedProgressClimbsOnceAcrossEveryRung(): void + { + $seen = []; + + $this->packager() + ->open($this->file) + ->format(new X264()) + ->add( + new Representation(640, 360, 800), + new Representation(1280, 720, 2538), + ) + ->output(new Cmaf()) + ->on(Packager::PROGRESS, function (Progress $progress) use (&$seen): void { + $seen[] = $progress->percent; + }) + ->pack($this->dir.'/out'); + + $this->assertNotEmpty($seen); + $this->assertSame(100.0, \end($seen)); + + $sorted = $seen; + \sort($sorted); + $this->assertSame($sorted, $seen, 'progress should never go backwards'); + } + + public function testListenersReachTheAdapterDoingTheWork(): void + { + $adapter = new FusedPackager(); + $lines = []; + + $this->packager($adapter) + ->open($this->file) + ->add(new Representation(1280, 720, 2538)) + ->output(new Cmaf()) + ->on(Packager::LOG, function (string $line) use (&$lines): void { + $lines[] = $line; + }) + ->pack($this->dir.'/out'); + + $this->assertSame(['packing'], $lines); + } + + public function testStagedLogsAreForwardedToo(): void + { + $lines = []; + + $this->packager() + ->open($this->file) + ->add(new Representation(640, 360, 800)) + ->output(new Cmaf()) + ->on(Packager::LOG, function (string $line) use (&$lines): void { + $lines[] = $line; + }) + ->pack($this->dir.'/out'); + + $this->assertSame(['packing'], $lines); + } + + public function testOpeningAgainForgetsThePreviousJob(): void + { + $packager = $this->packager() + ->open($this->file) + ->add(new Representation(1280, 720, 2538)) + ->output(new Cmaf()); + + $packager->open($this->file); + + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('No output format'); + + $packager->pack('/out'); + } + + public function testPackingWithoutASourceIsAFailure(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('No source has been opened'); + + $this->packager()->pack('/out'); + } + + public function testPackingWithoutALadderIsAFailure(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('At least one representation'); + + $this->packager()->open($this->file)->output(new Cmaf())->pack('/out'); + } + + public function testOpeningAMissingSourceIsAFailure(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('does not exist'); + + $this->packager()->open('/nowhere/nothing.mp4'); + } + + /** + * A packager that also encodes needs no separate encoder handed to it. + */ + public function testAFusedAdapterIsItsOwnEncoder(): void + { + $adapter = new FusedPackager(); + + (new Packager($adapter, probe: new FakeProbe())) + ->open($this->file) + ->add(new Representation(1280, 720, 2538)) + ->output(new Cmaf()) + ->pack($this->dir.'/out'); + + $this->assertSame([$this->file], $adapter->opened); + } +} diff --git a/tests/Video/Unit/ParserM3u8Test.php b/tests/Video/Unit/ParserM3u8Test.php new file mode 100644 index 0000000..3987697 --- /dev/null +++ b/tests/Video/Unit/ParserM3u8Test.php @@ -0,0 +1,203 @@ +dir = $dir; + } + + protected function tearDown(): void + { + foreach (\glob($this->dir.'/*') ?: [] as $file) { + \unlink($file); + } + + @\rmdir($this->dir); + } + + private function write(string $name, string $body): string + { + $path = $this->dir.'/'.$name; + \file_put_contents($path, $body); + + return $path; + } + + public function testReadsAttributesWithCommasInsideQuotes(): void + { + $attributes = M3u8::attributes( + '#EXT-X-STREAM-INF:BANDWIDTH=2700000,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=1280x720', + ); + + $this->assertSame('2700000', $attributes['BANDWIDTH']); + $this->assertSame('avc1.64001f,mp4a.40.2', $attributes['CODECS']); + $this->assertSame('1280x720', $attributes['RESOLUTION']); + } + + public function testReadsVariantsFromAMaster(): void + { + $master = $this->write('master.m3u8', <<<'PLAYLIST' + #EXTM3U + #EXT-X-VERSION:7 + #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="eng",DEFAULT=YES,URI="audio.m3u8" + #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001f",AUDIO="audio" + v360.m3u8 + #EXT-X-STREAM-INF:BANDWIDTH=2538000,RESOLUTION=1280x720,CODECS="avc1.64001f",AUDIO="audio" + v720.m3u8 + PLAYLIST); + + $variants = M3u8::master($master); + + $this->assertCount(3, $variants); + $this->assertSame(Track::AUDIO, $variants[0]['type']); + $this->assertSame('eng', $variants[0]['language']); + $this->assertSame(Track::VIDEO, $variants[1]['type']); + $this->assertSame(640, $variants[1]['width']); + $this->assertSame(360, $variants[1]['height']); + $this->assertSame(2538000, $variants[2]['bandwidth']); + $this->assertSame('v720.m3u8', $variants[2]['file']); + } + + public function testReadsSegmentsFromATransportStreamPlaylist(): void + { + $playlist = $this->write('v720.m3u8', <<<'PLAYLIST' + #EXTM3U + #EXT-X-VERSION:3 + #EXT-X-TARGETDURATION:6 + #EXTINF:6.000000, + v720_0000.ts + #EXTINF:4.500000, + v720_0001.ts + #EXT-X-ENDLIST + PLAYLIST); + + $media = M3u8::media($playlist); + + $this->assertSame(6.0, $media['target']); + $this->assertSame(3, $media['version']); + $this->assertCount(2, $media['segments']); + $this->assertSame('v720_0000.ts', $media['segments'][0]['file']); + $this->assertSame(6.0, $media['segments'][0]['duration']); + $this->assertFalse($media['segments'][0]['init']); + $this->assertSame(4.5, $media['segments'][1]['duration']); + } + + public function testInitialisationSegmentIsRecognised(): void + { + $playlist = $this->write('v720.m3u8', <<<'PLAYLIST' + #EXTM3U + #EXT-X-VERSION:7 + #EXT-X-TARGETDURATION:6 + #EXT-X-MAP:URI="v720_init.mp4" + #EXTINF:6.000000, + v720_0000.m4s + PLAYLIST); + + $media = M3u8::media($playlist); + + $this->assertTrue($media['segments'][0]['init']); + $this->assertSame('v720_init.mp4', $media['segments'][0]['file']); + $this->assertSame(0.0, $media['segments'][0]['duration']); + $this->assertFalse($media['segments'][1]['init']); + } + + public function testReadsAWholePackage(): void + { + $this->write('v720_init.mp4', 'init'); + $this->write('v720_0000.m4s', 'aaaa'); + $this->write('v720_0001.m4s', 'bb'); + + $this->write('v720.m3u8', <<<'PLAYLIST' + #EXTM3U + #EXT-X-VERSION:7 + #EXT-X-TARGETDURATION:6 + #EXT-X-MAP:URI="v720_init.mp4" + #EXTINF:6.000000, + v720_0000.m4s + #EXTINF:2.000000, + v720_0001.m4s + PLAYLIST); + + $master = $this->write('master.m3u8', <<<'PLAYLIST' + #EXTM3U + #EXT-X-STREAM-INF:BANDWIDTH=2538000,RESOLUTION=1280x720 + v720.m3u8 + PLAYLIST); + + $read = M3u8::read($master, $this->dir); + + $this->assertCount(1, $read['variants']); + $this->assertSame(6.0, $read['metadata']['targetDuration']); + + $variant = $read['variants'][0]; + $this->assertSame(1280, $variant->width); + $this->assertSame('1280x720', $variant->resolution()); + $this->assertCount(3, $variant->segments); + + $this->assertTrue($variant->segments[0]->init); + $this->assertSame(4, $variant->segments[0]->size); + $this->assertSame(0, $variant->segments[1]->number); + $this->assertSame(1, $variant->segments[2]->number); + $this->assertSame(2, $variant->segments[2]->size); + } + + /** + * A playlist that names a file which is not there means the run was cut + * short, and reporting success on a partial package would be worse than + * failing. + */ + public function testMissingSegmentIsAFailure(): void + { + $this->write('v720.m3u8', "#EXTM3U\n#EXTINF:6.000000,\nmissing.ts\n"); + $master = $this->write('master.m3u8', "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nv720.m3u8\n"); + + $this->expectException(Runtime::class); + $this->expectExceptionMessage('missing.ts'); + + M3u8::read($master, $this->dir); + } + + public function testMissingPlaylistIsAFailure(): void + { + $master = $this->write('master.m3u8', "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\ngone.m3u8\n"); + + $this->expectException(Runtime::class); + $this->expectExceptionMessage('gone.m3u8'); + + M3u8::read($master, $this->dir); + } + + public function testUnreadablePlaylistIsAFailure(): void + { + $this->expectException(Runtime::class); + + M3u8::media($this->dir.'/nothing.m3u8'); + } + + public function testWindowsLineEndingsAreHandled(): void + { + $playlist = $this->write( + 'v.m3u8', + "#EXTM3U\r\n#EXT-X-TARGETDURATION:6\r\n#EXTINF:6.000000,\r\nv_0000.ts\r\n", + ); + + $media = M3u8::media($playlist); + + $this->assertSame(6.0, $media['target']); + $this->assertSame('v_0000.ts', $media['segments'][0]['file']); + } +} diff --git a/tests/Video/Unit/ParserMpdTest.php b/tests/Video/Unit/ParserMpdTest.php new file mode 100644 index 0000000..c5742ec --- /dev/null +++ b/tests/Video/Unit/ParserMpdTest.php @@ -0,0 +1,260 @@ +dir = $dir; + } + + protected function tearDown(): void + { + foreach (\glob($this->dir.'/*') ?: [] as $file) { + \unlink($file); + } + + @\rmdir($this->dir); + } + + private function write(string $name, string $body): string + { + $path = $this->dir.'/'.$name; + \file_put_contents($path, $body); + + return $path; + } + + /** + * @testdox Reads ISO 8601 durations + */ + public function testReadsIsoDurations(): void + { + $this->assertSame(0.0, Mpd::seconds('')); + $this->assertSame(64.5, Mpd::seconds('PT1M4.5S')); + $this->assertSame(3725.0, Mpd::seconds('PT1H2M5.0S')); + $this->assertSame(6.0, Mpd::seconds('PT6S')); + } + + public function testExpandsSegmentNamePatterns(): void + { + $this->assertSame( + 'chunk-stream0-00007.m4s', + Mpd::expand('chunk-stream$RepresentationID$-$Number%05d$.m4s', '0', 7), + ); + + $this->assertSame( + 'seg-3.m4s', + Mpd::expand('seg-$Number$.m4s', '0', 3), + ); + + $this->assertSame( + 'init-stream1.m4s', + Mpd::expand('init-stream$RepresentationID$.m4s', '1', 0), + ); + } + + public function testReadsAListedManifest(): void + { + $this->write('init-0.m4s', 'init'); + $this->write('chunk-0-1.m4s', 'aaaa'); + $this->write('chunk-0-2.m4s', 'bb'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + + XML); + + $read = Mpd::read($manifest, $this->dir); + + $this->assertSame('static', $read['metadata']['type']); + $this->assertSame(8.0, $read['metadata']['duration']); + $this->assertSame('PT6.0S', $read['metadata']['maxSegmentDuration']); + + $this->assertCount(1, $read['variants']); + $variant = $read['variants'][0]; + + $this->assertSame('0', $variant->id); + $this->assertSame(Track::VIDEO, $variant->type); + $this->assertSame('avc1.64001f', $variant->codecs); + $this->assertSame(2538000, $variant->bandwidth); + $this->assertSame(1280, $variant->width); + $this->assertSame('1:1', $variant->sar); + $this->assertSame(1000000, $variant->timescale); + $this->assertSame(1, $variant->startNumber); + + $this->assertCount(3, $variant->segments); + $this->assertTrue($variant->segments[0]->init); + $this->assertSame(6.0, $variant->segments[1]->duration); + $this->assertSame(1, $variant->segments[1]->number); + $this->assertSame(2, $variant->segments[2]->number); + } + + /** + * A manifest that only describes a formula still has to come back as a + * concrete list of segments; expanding it here means callers never have to + * care which addressing mode was used. + */ + public function testExpandsATemplatedManifestWithATimeline(): void + { + $this->write('init-stream0.m4s', 'init'); + $this->write('chunk-stream0-00001.m4s', 'a'); + $this->write('chunk-stream0-00002.m4s', 'b'); + $this->write('chunk-stream0-00003.m4s', 'c'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + + + XML); + + $read = Mpd::read($manifest, $this->dir); + $variant = $read['variants'][0]; + + $this->assertCount(4, $variant->segments); + $this->assertTrue($variant->segments[0]->init); + $this->assertSame('chunk-stream0-00001.m4s', $variant->segments[1]->file); + $this->assertSame(6.0, $variant->segments[1]->duration); + $this->assertSame('chunk-stream0-00002.m4s', $variant->segments[2]->file); + $this->assertSame('chunk-stream0-00003.m4s', $variant->segments[3]->file); + $this->assertSame(2.0, $variant->segments[3]->duration); + } + + public function testExpandsATemplatedManifestWithoutATimeline(): void + { + $this->write('init-stream0.m4s', 'init'); + $this->write('chunk-stream0-00001.m4s', 'a'); + $this->write('chunk-stream0-00002.m4s', 'b'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + XML); + + $variant = Mpd::read($manifest, $this->dir)['variants'][0]; + + $this->assertCount(3, $variant->segments); + $this->assertSame('chunk-stream0-00002.m4s', $variant->segments[2]->file); + } + + public function testAudioAdaptationSetIsRecognised(): void + { + $this->write('init-1.m4s', 'init'); + $this->write('chunk-1-1.m4s', 'a'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + XML); + + $variant = Mpd::read($manifest, $this->dir)['variants'][0]; + + $this->assertSame(Track::AUDIO, $variant->type); + $this->assertSame('eng', $variant->language); + $this->assertSame(48000, $variant->sampleRate); + $this->assertSame('audio/mp4', $variant->mimeType); + $this->assertNull($variant->resolution()); + } + + public function testMissingSegmentIsAFailure(): void + { + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + XML); + + $this->expectException(Runtime::class); + $this->expectExceptionMessage('gone.m4s'); + + Mpd::read($manifest, $this->dir); + } + + public function testUnreadableManifestIsAFailure(): void + { + $this->expectException(Runtime::class); + + Mpd::read($this->dir.'/nothing.mpd', $this->dir); + } + + public function testMalformedManifestIsAFailure(): void + { + $manifest = $this->write('manifest.mpd', ''); + + $this->expectException(Runtime::class); + + Mpd::read($manifest, $this->dir); + } +} diff --git a/tests/Video/Unit/ProbeFFprobeTest.php b/tests/Video/Unit/ProbeFFprobeTest.php new file mode 100644 index 0000000..81dd3bd --- /dev/null +++ b/tests/Video/Unit/ProbeFFprobeTest.php @@ -0,0 +1,269 @@ + $payload + */ + private function read(array $payload): Info + { + $probe = new class () extends FFprobe { + /** + * @param array $data + */ + public function parse(array $data): Info + { + return $this->info($data); + } + }; + + return $probe->parse($payload); + } + + public function testReadsAVideoWithSound(): void + { + $info = $this->read([ + 'format' => [ + 'format_name' => 'mov,mp4,m4a,3gp,3g2,mj2', + 'duration' => '64.5', + 'tags' => ['title' => 'A Film', 'creation_time' => '2026-01-02T03:04:05.000000Z'], + ], + 'streams' => [ + [ + 'index' => 0, + 'codec_type' => 'video', + 'codec_name' => 'h264', + 'codec_long_name' => 'H.264 / AVC', + 'profile' => 'High', + 'width' => 1920, + 'height' => 1080, + 'display_aspect_ratio' => '16:9', + 'avg_frame_rate' => '25/1', + 'r_frame_rate' => '25/1', + 'bit_rate' => '4500000', + 'disposition' => ['default' => 1], + 'tags' => ['language' => 'und'], + ], + [ + 'index' => 1, + 'codec_type' => 'audio', + 'codec_name' => 'aac', + 'codec_long_name' => 'AAC', + 'bit_rate' => '128000', + 'sample_rate' => '48000', + 'disposition' => ['default' => 1], + 'tags' => ['language' => 'eng'], + ], + ], + ]); + + $this->assertSame(64.5, $info->duration); + $this->assertSame(64500, $info->milliseconds()); + $this->assertTrue($info->hasVideo); + $this->assertTrue($info->hasAudio); + $this->assertSame(1920, $info->width); + $this->assertSame(1080, $info->height); + $this->assertSame('16:9', $info->aspect); + $this->assertSame(25.0, $info->fps); + $this->assertSame('Constant', $info->fpsMode); + $this->assertSame('h264', $info->videoCodec); + $this->assertSame('High', $info->videoProfile); + $this->assertSame(4500000, $info->videoBitrate); + $this->assertSame('aac', $info->audioCodec); + $this->assertSame(128000, $info->audioBitrate); + $this->assertSame(48000, $info->sampleRate); + $this->assertSame([['codec' => 'aac', 'language' => 'eng']], $info->audioTracks); + $this->assertSame('A Film', $info->tags['title']); + $this->assertCount(2, $info->tracks); + } + + public function testDetectsVariableFrameRate(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [[ + 'codec_type' => 'video', + 'avg_frame_rate' => '24000/1001', + 'r_frame_rate' => '30000/1001', + ]], + ]); + + $this->assertSame('Variable', $info->fpsMode); + $this->assertSame(23.976, $info->fps); + } + + public function testDerivesAspectRatioWhenTheContainerOmitsIt(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [['codec_type' => 'video', 'width' => 1920, 'height' => 1080]], + ]); + + $this->assertNull($info->aspect); + $this->assertSame('16:9', $info->ratio()); + } + + public function testReadsEveryTrackIncludingSubtitles(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [ + ['index' => 0, 'codec_type' => 'video', 'codec_name' => 'h264'], + [ + 'index' => 1, + 'codec_type' => 'audio', + 'codec_name' => 'aac', + 'tags' => ['language' => 'eng'], + ], + [ + 'index' => 2, + 'codec_type' => 'subtitle', + 'codec_name' => 'mov_text', + 'tags' => ['language' => 'fra', 'title' => 'French'], + 'disposition' => ['default' => 0, 'forced' => 1], + ], + ], + ]); + + $subtitles = $info->tracks(Track::SUBTITLE); + + $this->assertCount(1, $subtitles); + $this->assertSame('fra', $subtitles[0]->language); + $this->assertSame('French', $subtitles[0]->title); + $this->assertTrue($subtitles[0]->forced); + $this->assertFalse($subtitles[0]->default); + $this->assertCount(1, $info->tracks(Track::VIDEO)); + } + + public function testReadsChapters(): void + { + $info = $this->read([ + 'format' => ['duration' => '120'], + 'streams' => [['codec_type' => 'video']], + 'chapters' => [ + ['start_time' => '0.000000', 'end_time' => '60.000000', 'tags' => ['title' => 'Intro']], + ['start_time' => '60.000000', 'end_time' => '120.000000', 'tags' => ['title' => 'Body']], + ], + ]); + + $this->assertCount(2, $info->chapters); + $this->assertSame('Intro', $info->chapters[0]->title); + $this->assertSame(60.0, $info->chapters[0]->end); + $this->assertSame(60.0, $info->chapters[1]->start); + } + + public function testReadsRotation(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [[ + 'codec_type' => 'video', + 'side_data_list' => [['rotation' => -90]], + ]], + ]); + + $this->assertSame(-90, $info->rotation); + } + + public function testReadsRotationFromATag(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [['codec_type' => 'video', 'tags' => ['rotate' => '90']]], + ]); + + $this->assertSame(90, $info->rotation); + } + + /** + * Cover art is stored as a one frame video stream, and treating it as the + * main picture would make an audio file look like a video. + */ + public function testCoverArtIsNotMistakenForVideo(): void + { + $info = $this->read([ + 'format' => ['duration' => '180', 'format_name' => 'mp3'], + 'streams' => [ + ['index' => 0, 'codec_type' => 'audio', 'codec_name' => 'mp3', 'sample_rate' => '44100'], + [ + 'index' => 1, + 'codec_type' => 'video', + 'codec_name' => 'mjpeg', + 'disposition' => ['attached_pic' => 1], + ], + ], + ]); + + $this->assertFalse($info->hasVideo); + $this->assertTrue($info->hasAudio); + $this->assertNull($info->width); + } + + public function testFallsBackToStreamDurationWhenTheContainerHasNone(): void + { + $info = $this->read([ + 'format' => [], + 'streams' => [['codec_type' => 'video', 'duration' => '12.5']], + ]); + + $this->assertSame(12.5, $info->duration); + } + + public function testSilentSourceReportsNoAudio(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [['codec_type' => 'video', 'width' => 640, 'height' => 480]], + ]); + + $this->assertFalse($info->hasAudio); + $this->assertNull($info->audioCodec); + $this->assertSame([], $info->audioTracks); + } + + public function testUnknownValuesBecomeNull(): void + { + $info = $this->read([ + 'format' => ['duration' => '10'], + 'streams' => [[ + 'codec_type' => 'video', + 'bit_rate' => 'N/A', + 'profile' => '', + ]], + ]); + + $this->assertNull($info->videoBitrate); + $this->assertNull($info->videoProfile); + } + + public function testKeepsTheOriginalPayload(): void + { + $payload = ['format' => ['duration' => '10'], 'streams' => []]; + + $this->assertSame($payload, $this->read($payload)->raw); + } + + public function testRejectsAMissingFile(): void + { + $probe = new FFprobe(); + + $this->assertFalse($probe->valid('/nowhere/nothing.mp4')); + } + + /** + * @testdox The probe carries its name + */ + public function testIsNamed(): void + { + $this->assertSame('ffprobe', (new FFprobe())->getName()); + } +} diff --git a/tests/Video/Unit/ProcessTest.php b/tests/Video/Unit/ProcessTest.php new file mode 100644 index 0000000..0a49c04 --- /dev/null +++ b/tests/Video/Unit/ProcessTest.php @@ -0,0 +1,124 @@ +assertSame("one\ntwo\n", $output); + } + + public function testStreamsOutputLineByLine(): void + { + $lines = []; + + Process::run(['printf', "a\nb\nc\n"], function (string $line) use (&$lines): void { + $lines[] = $line; + }); + + $this->assertSame(['a', 'b', 'c'], $lines); + } + + public function testEmitsATrailingLineWithoutANewline(): void + { + $lines = []; + + Process::run(['printf', 'no-newline'], function (string $line) use (&$lines): void { + $lines[] = $line; + }); + + $this->assertSame(['no-newline'], $lines); + } + + public function testSeparatesStandardErrorFromStandardOutput(): void + { + $out = []; + $err = []; + + Process::run( + ['sh', '-c', 'echo good; echo bad 1>&2'], + function (string $line) use (&$out): void { + $out[] = $line; + }, + function (string $line) use (&$err): void { + $err[] = $line; + }, + ); + + $this->assertSame(['good'], $out); + $this->assertSame(['bad'], $err); + } + + public function testCarriageReturnsSplitProgressBlocks(): void + { + $lines = []; + + Process::run(['printf', "one\rtwo\r"], function (string $line) use (&$lines): void { + $lines[] = $line; + }); + + $this->assertSame(['one', 'two'], $lines); + } + + public function testFailureCarriesTheCommandAndItsComplaint(): void + { + try { + Process::run(['sh', '-c', 'echo "went wrong" 1>&2; exit 3']); + $this->fail('Expected the command to fail'); + } catch (Runtime $exception) { + $this->assertSame(3, $exception->getCode()); + $this->assertStringContainsString('exit code 3', $exception->getMessage()); + $this->assertStringContainsString('went wrong', $exception->output()); + $this->assertSame('sh', $exception->command()[0]); + } + } + + public function testReturnsTheTailOfStandardError(): void + { + $errors = Process::run(['sh', '-c', 'echo "a note" 1>&2']); + + $this->assertSame('a note', $errors); + } + + public function testStopsARunawayCommand(): void + { + $started = \microtime(true); + + try { + Process::run(['sleep', '30'], timeout: 1); + $this->fail('Expected the command to time out'); + } catch (Runtime $exception) { + $this->assertStringContainsString('timed out', $exception->getMessage()); + } + + $this->assertLessThan(10, \microtime(true) - $started); + } + + public function testMissingBinaryIsAFailure(): void + { + $this->expectException(Runtime::class); + + Process::run(['utopia-streaming-does-not-exist']); + } + + public function testEmptyCommandIsAFailure(): void + { + $this->expectException(Runtime::class); + + Process::run([]); + } + + public function testReportsWhetherABinaryIsUsable(): void + { + $this->assertFalse(Process::exists('utopia-streaming-does-not-exist')); + } +} diff --git a/tests/Video/Unit/RepresentationTest.php b/tests/Video/Unit/RepresentationTest.php new file mode 100644 index 0000000..e35abb7 --- /dev/null +++ b/tests/Video/Unit/RepresentationTest.php @@ -0,0 +1,86 @@ +assertSame(1280, $rep->width); + $this->assertSame(720, $rep->height); + $this->assertSame(2538, $rep->video); + $this->assertSame(128, $rep->audio); + $this->assertSame('1280x720', $rep->resolution()); + $this->assertTrue($rep->scaled()); + } + + public function testNamesItselfAfterItsHeight(): void + { + $this->assertSame('720p', (new Representation(1280, 720, 2538))->name); + $this->assertSame('low', (new Representation(1280, 720, 2538, name: 'low'))->name); + } + + public function testCapsBitrateByDefault(): void + { + $rep = new Representation(1280, 720, 2500); + + $this->assertSame(2500, $rep->maxrate); + $this->assertSame(5000, $rep->bufsize); + } + + public function testAcceptsAnExplicitCap(): void + { + $rep = new Representation(1280, 720, 2500, maxrate: 3000, bufsize: 9000); + + $this->assertSame(3000, $rep->maxrate); + $this->assertSame(9000, $rep->bufsize); + } + + public function testAudioOnlyRungCarriesNoFrame(): void + { + $rep = new Representation(width: 0, height: 0, video: 1, audio: 96); + + $this->assertFalse($rep->scaled()); + $this->assertSame('audio', $rep->name); + } + + public function testRejectsOddDimensions(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('must be even'); + + new Representation(1281, 720, 2500); + } + + public function testRejectsNegativeDimensions(): void + { + $this->expectException(Input::class); + + new Representation(-2, 720, 2500); + } + + public function testRejectsUnusableBitrates(): void + { + $this->expectException(Input::class); + + new Representation(1280, 720, 0); + } + + public function testRejectsUnusableAudioBitrate(): void + { + $this->expectException(Input::class); + + new Representation(1280, 720, 2500, audio: 0); + } +} diff --git a/tests/Video/Unit/SpritesheetTest.php b/tests/Video/Unit/SpritesheetTest.php new file mode 100644 index 0000000..6dd3fb0 --- /dev/null +++ b/tests/Video/Unit/SpritesheetTest.php @@ -0,0 +1,136 @@ +assertSame('00:00:00.000', Cue::timestamp(0.0)); + $this->assertSame('00:00:06.500', Cue::timestamp(6.5)); + $this->assertSame('00:01:05.000', Cue::timestamp(65.0)); + $this->assertSame('01:01:01.500', Cue::timestamp(3661.5)); + } + + public function testRendersACueAsAnAddressableRectangle(): void + { + $cue = new Cue(0.0, 2.0, 'sprite1.jpg', 320, 180, 160, 90); + + $this->assertSame( + "00:00:00.000 --> 00:00:02.000\nsprite1.jpg#xywh=320,180,160,90", + $cue->render(), + ); + } + + /** + * The sheet a player fetches is rarely at the path it was written to, so + * the URL has to be replaceable without touching the geometry. + */ + public function testCueUrlCanBeRewritten(): void + { + $cue = new Cue(0.0, 2.0, 'sprite1.jpg', 0, 0, 160, 90); + + $this->assertStringContainsString( + '/previews/abc123#xywh=0,0,160,90', + $cue->render('/previews/abc123'), + ); + } + + public function testRendersAWebvttTimeline(): void + { + $sheet = new Spritesheet( + ['/out/sprite1.jpg'], + [ + new Cue(0.0, 2.0, 'sprite1.jpg', 0, 0, 160, 90), + new Cue(2.0, 4.0, 'sprite1.jpg', 160, 0, 160, 90), + ], + '/out/sprite.vtt', + 160, + 90, + ); + + $vtt = $sheet->render(); + + $this->assertStringStartsWith("WEBVTT\n", $vtt); + $this->assertStringContainsString('00:00:00.000 --> 00:00:02.000', $vtt); + $this->assertStringContainsString('sprite1.jpg#xywh=160,0,160,90', $vtt); + $this->assertSame(160, $sheet->width()); + $this->assertSame(90, $sheet->height()); + } + + public function testTimelineUrlsCanBeRewrittenWholesale(): void + { + $sheet = new Spritesheet( + ['/out/sprite1.jpg'], + [new Cue(0.0, 2.0, 'sprite1.jpg', 0, 0, 160, 90)], + ); + + $vtt = $sheet->render(static fn (string $file): string => '/preview/'.$file); + + $this->assertStringContainsString('/preview/sprite1.jpg#xywh=0,0,160,90', $vtt); + } + + public function testListsEveryArtifact(): void + { + $withVtt = new Spritesheet(['/out/sprite1.jpg'], [], '/out/sprite.vtt'); + $withoutVtt = new Spritesheet(['/out/sprite1.jpg'], []); + + $this->assertSame(['/out/sprite1.jpg', '/out/sprite.vtt'], $withVtt->files()); + $this->assertSame(['/out/sprite1.jpg'], $withoutVtt->files()); + $this->assertNull($withoutVtt->vtt()); + } + + /** + * A two minute clip and a two hour film should not produce the same number + * of sheets, so the sampling interval grows with the source. + */ + public function testSamplingIntervalGrowsWithDuration(): void + { + $tile = new Tile(); + + $this->assertSame(2.0, $tile->every(60)); + $this->assertSame(5.0, $tile->every(300)); + $this->assertSame(10.0, $tile->every(1200)); + $this->assertSame(20.0, $tile->every(2400)); + $this->assertSame(30.0, $tile->every(7200)); + } + + public function testSamplingIntervalCanBePinned(): void + { + $tile = (new Tile())->interval(4.0); + + $this->assertSame(4.0, $tile->every(60)); + $this->assertSame(4.0, $tile->every(7200)); + } + + public function testTileDefaults(): void + { + $tile = new Tile(); + + $this->assertSame(160, $tile->size()); + $this->assertSame(5, $tile->columns()); + $this->assertSame(5, $tile->rows()); + $this->assertSame(25, $tile->cells()); + $this->assertSame(3, $tile->scale()); + $this->assertSame('sprite', $tile->base()); + $this->assertTrue($tile->writes()); + } + + public function testTileIsConfigurable(): void + { + $tile = (new Tile())->width(200)->grid(4, 3)->quality(5)->name('thumbs')->vtt(false); + + $this->assertSame(200, $tile->size()); + $this->assertSame(12, $tile->cells()); + $this->assertSame(5, $tile->scale()); + $this->assertSame('thumbs', $tile->base()); + $this->assertFalse($tile->writes()); + } +} diff --git a/tests/samples/.gitignore b/tests/samples/.gitignore new file mode 100644 index 0000000..0421ab7 --- /dev/null +++ b/tests/samples/.gitignore @@ -0,0 +1,8 @@ +# Sample media is generated by tests/Streaming/Samples.php (composer samples), +# not committed: it would add megabytes to every clone for files the build can +# reproduce exactly. The directories themselves are kept so the layout is obvious. +in/* +out/* +!.gitignore +!in/.gitkeep +!out/.gitkeep diff --git a/tests/samples/in/.gitkeep b/tests/samples/in/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/samples/out/.gitkeep b/tests/samples/out/.gitkeep new file mode 100644 index 0000000..e69de29 From 4097deb91cfcccc8f68e609302c5d86fec33066d Mon Sep 17 00:00:00 2001 From: shimon Date: Wed, 19 Aug 2026 10:06:37 +0300 Subject: [PATCH 07/12] Update project dependencies and configurations for PHP 8.2 compatibility; enhance documentation and workflows - Updated composer.json and composer.lock to require PHP 8.2. - Adjusted Dockerfile and docker-compose.yml to use PHP 8.2. - Improved CONTRIBUTING.md to reflect changes in branch naming conventions. - Enhanced README.md with additional usage details and badges. - Refined GitHub workflows for linting and testing, including improved container readiness checks. - Removed deprecated CodeQL workflow file. --- .github/workflows/codeql-analysis.yml | 18 ---- .github/workflows/linter.yml | 14 ++- .github/workflows/static-analysis.yml | 23 +++++ .github/workflows/tests.yml | 25 ++++- CONTRIBUTING.md | 6 +- Dockerfile | 2 +- README.md | 58 ++++++++--- composer.json | 6 +- composer.lock | 6 +- docker-compose.yml | 2 +- docs/design.md | 82 +++++++++++----- src/Video/Adapter.php | 43 ++++++-- src/Video/Arguments.php | 43 ++++++-- src/Video/Arguments/Dash.php | 11 +-- src/Video/Arguments/Hls.php | 11 +-- src/Video/Encoder.php | 15 ++- src/Video/Format.php | 34 ++++--- src/Video/Format/Copy.php | 2 +- src/Video/Name.php | 57 +++++++++++ src/Video/Output.php | 13 +-- src/Video/Output/Cmaf.php | 3 +- src/Video/Output/Dash.php | 3 +- src/Video/Output/Hls.php | 5 +- src/Video/Packager.php | 9 ++ src/Video/Parser/Mpd.php | 59 +++++++---- src/Video/Process.php | 114 ++++++++++++++++++--- src/Video/Reporter.php | 35 +++++++ src/Video/Reporter/Console.php | 28 ++++++ src/Video/Reporter/Silent.php | 25 +++++ src/Video/Representation.php | 8 +- src/Video/Tile.php | 2 +- tests/Video/Unit/AdapterTest.php | 125 ++++++++++++++---------- tests/Video/Unit/ArgumentsDashTest.php | 26 +++++ tests/Video/Unit/ArgumentsHlsTest.php | 98 +++++++++++++++++++ tests/Video/Unit/FormatTest.php | 15 +++ tests/Video/Unit/OutputTest.php | 46 +++++++++ tests/Video/Unit/ParserMpdTest.php | 55 +++++++++++ tests/Video/Unit/ProcessTest.php | 37 +++++++ tests/Video/Unit/Recorder.php | 32 ++++++ tests/Video/Unit/RepresentationTest.php | 42 ++++++++ tests/Video/Unit/SpritesheetTest.php | 12 +++ 41 files changed, 1022 insertions(+), 228 deletions(-) delete mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/static-analysis.yml create mode 100644 src/Video/Name.php create mode 100644 src/Video/Reporter.php create mode 100644 src/Video/Reporter/Console.php create mode 100644 src/Video/Reporter/Silent.php create mode 100644 tests/Video/Unit/Recorder.php diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 4177c79..0000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: "CodeQL" - -on: [ pull_request ] -jobs: - check: - name: CodeQL - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Run CodeQL - run: | - docker run --rm -v $PWD:/app composer sh -c \ - "composer install --profile --ignore-platform-reqs && composer check" diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 7aed26c..133fb14 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -1,6 +1,11 @@ name: "Linter" -on: [ pull_request ] +on: + pull_request: + push: + branches: [ main ] + workflow_dispatch: + jobs: lint: name: Linter @@ -12,7 +17,10 @@ jobs: 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 sh -c \ - "composer install --profile --ignore-platform-reqs && composer lint" + docker run --rm -v $PWD:/app composer:2 sh -c \ + "composer install --profile && composer lint" diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..321106d --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,23 @@ +name: "Static analysis" + +on: + pull_request: + push: + branches: [ main ] + workflow_dispatch: + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Run PHPStan + run: | + docker run --rm -v $PWD:/app composer:2 sh -c \ + "composer install --profile && composer check" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 42d8ac5..9439913 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,11 @@ name: "Tests" -on: [ pull_request ] +on: + pull_request: + push: + branches: [ main ] + workflow_dispatch: + jobs: tests: name: Tests @@ -8,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: [ '8.1', '8.2', '8.3' ] + php-versions: [ '8.2', '8.3', '8.4' ] steps: - name: Checkout repository @@ -21,7 +26,21 @@ jobs: export PHP_VERSION=${{ matrix.php-versions }} docker compose build docker compose up -d - sleep 5 + + # 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f6d73c..74fed21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ If you are worried or don’t know where to start, check out our next section ex ## Code of Conduct -Help us keep Utopia-php open and inclusive. Please read and follow our [Code of Conduct](https://github.com/appwrite/appwrite/blob/master/CODE_OF_CONDUCT.md). +Help us keep Utopia-php open and inclusive. Please read and follow our [Code of Conduct](CODE_OF_CONDUCT.md). ## Submit a Pull Request 🚀 @@ -34,13 +34,13 @@ When `TYPE` can be: 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 master needs to be up to date. +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 `master` like: `doc-548-submit-a-pull-request-section-to-contribution-guide`
+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] diff --git a/Dockerfile b/Dockerfile index ad5191b..79fd58a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ ARG FFMPEG_VERSION=7.1.1 -ARG PHP_VERSION=8.1 +ARG PHP_VERSION=8.2 FROM php:${PHP_VERSION}-cli-alpine AS ffmpeg diff --git a/README.md b/README.md index 2945b35..2ca7078 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,17 @@ # Utopia Video -[Tests](https://github.com/utopia-php/video/actions/workflows/tests.yml) -Total Downloads -[Discord](https://appwrite.io/discord) +[![Tests](https://github.com/utopia-php/video/actions/workflows/tests.yml/badge.svg)](https://github.com/utopia-php/video/actions/workflows/tests.yml) +[![Total Downloads](https://img.shields.io/packagist/dt/utopia-php/video.svg)](https://packagist.org/packages/utopia-php/video) +[![Discord](https://img.shields.io/badge/discord-join-5865F2)](https://appwrite.io/discord) Utopia framework video library is simple and lite library for probing, encoding and packaging video for adaptive streaming. This library is aiming to be as simple and easy to learn and use. This library is maintained by the [Appwrite team](https://appwrite.io). Although this library is part of the [Utopia Framework](https://github.com/utopia-php/framework) -project it is dependency free, and can be used as standalone with any other PHP project or 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. @@ -76,7 +78,7 @@ use Utopia\Video\Representation; $package = (new Packager()) ->open('/path/to/video.mp4') - ->format((new X264())->crf(22)->bframes(3)->keyframe(2.0)) + ->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), @@ -95,11 +97,22 @@ $package->files(); // everything produced, ready to upload 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 -A source with more than one tagged audio track produces one selectable rendition per language, in -every output — `EXT-X-MEDIA:TYPE=AUDIO` rows for HLS, one `` per language for DASH. -Video rungs stay switchable alternatives of one another; languages are separate choices. +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 @@ -179,11 +192,11 @@ Every adapter has a display level, defaulting to `Adapter::ERROR` — only what 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 print a green status line via -`Utopia\Console::success`, and a failed backend command prints a red line via -`Console::error` 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. +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; @@ -198,6 +211,23 @@ $encoder->on(Encoder::LOG, fn (string $line) => error_log($line)); 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. @@ -271,7 +301,7 @@ We use the [Utopia Framework](https://github.com/utopia-php/framework) contribut ## System Requirements -Utopia Video requires PHP 8.1 or later, `ffmpeg` with `ffprobe` on the `PATH`, and +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. diff --git a/composer.json b/composer.json index 3e7d06c..63cb335 100644 --- a/composer.json +++ b/composer.json @@ -23,12 +23,12 @@ "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", + "check": "./vendor/bin/phpstan analyse --memory-limit=512M", "format": "./vendor/bin/pint", "lint": "./vendor/bin/pint --test" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "ext-dom": "*", "ext-json": "*", "utopia-php/console": "^0.2" @@ -40,7 +40,7 @@ }, "config": { "platform": { - "php": "8.1" + "php": "8.2" } } } diff --git a/composer.lock b/composer.lock index 739198f..393e39c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a143e6cc036ae3c53911187a32e32cf3", + "content-hash": "c0a016876c3fc8363bf165f62d35a3df", "packages": [ { "name": "utopia-php/console", @@ -1996,13 +1996,13 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.1", + "php": ">=8.2", "ext-dom": "*", "ext-json": "*" }, "platform-dev": [], "platform-overrides": { - "php": "8.1" + "php": "8.2" }, "plugin-api-version": "2.2.0" } diff --git a/docker-compose.yml b/docker-compose.yml index e48c507..cc194ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: dockerfile: Dockerfile args: TESTING: "true" - PHP_VERSION: ${PHP_VERSION:-8.1} + PHP_VERSION: ${PHP_VERSION:-8.2} volumes: - ./src:/usr/src/code/src - ./tests:/usr/src/code/tests diff --git a/docs/design.md b/docs/design.md index fb056c3..bccb641 100644 --- a/docs/design.md +++ b/docs/design.md @@ -24,7 +24,9 @@ Boundaries — the library: 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; backends are external binaries. +- 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 (`Encoder\FFmpeg`, not `FFmpegEncoderAdapter`), @@ -163,10 +165,9 @@ interface Encoder extends Named, Observable 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), anything that can encode can grab - // a frame, and PHP 8.1 has no nullable intersection type — `?(Encoder&Frame)` - // needs 8.2 DNF — so a separate interface would make them unrequestable from - // one optional constructor argument. + // 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; } @@ -261,7 +262,9 @@ which is how a backend chosen from config stays identifiable. // 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" and drives artifact naming. +// name defaults to "{height}p" 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 × video (the smoothing window, -bufsize:v:N) — busy scenes can no longer spike // above the advertised rendition bitrate and stall constrained viewers. @@ -272,6 +275,9 @@ abstract class Format public function crf(int $crf): static; // -crf public function bframes(int $count): static; // -bf 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 audio(string $codec): static; // override audio codec public function params(array $params): static; // raw argv escape hatch (e.g. -dn -sn -vf …) } @@ -299,10 +305,11 @@ Output\Cmaf ->master('master.m3u8')->manifest('manifest.mpd') ->grid(5, 5)->quality(3)->name('sprite')->vtt(true); ``` -Multi-audio sources: audio streams carrying a language tag become separate audio tracks in the -package (HLS `var_stream_map` audio group / DASH audio adaptation set), with the first flagged as -default — mirroring the proven v1 tag-based detection. Explicit track selection/ordering can be -added to `Output` later without breaking anything. +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) @@ -421,7 +428,8 @@ referencing one shared `.m4s` segment set): 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 library targets the most mature LTS line — **7.1**, exactly what the - repo image builds and pins — and keeps 5.x in CI as the supported floor. + repo image builds and pins, and what CI therefore tests against. 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 @@ -524,8 +532,9 @@ Exception names avoid colliding with the config and runner classes they sat besi `Exception\Runtime` (was `Process`, which clashed with the process runner). That removed a forced `as` alias from 15 files. -`composer.json`: `"require": {"php": "^8.1", "ext-dom": "*", "ext-json": "*"}`, and nothing else at -runtime — `ffmpeg`/`ffprobe` are external binaries. The `php-ffmpeg/php-ffmpeg` dependency is +`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. 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 @@ -556,13 +565,14 @@ Consumers migrate incrementally: `probe()` is drop-in first, `tile()`/`grab()` s **Unit — no binaries required** -- `Arguments\{Hls,Dash,Cmaf}`: exact argv golden tests — 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, `Format\Copy` remux, capped-CRF +- `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, `Format\Copy` remux, capped-CRF defaults (`-maxrate:v:N`/`-bufsize:v:N` derived when unset), multi-audio `var_stream_map` built - from language tags. + 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$`, `SegmentTimeline` `S@t/@d/@r` runs). + `SegmentTemplate` expansion (`$Number%05d$`, `$Bandwidth$`, `SegmentTimeline` `S@t/@d/@r` runs). - 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 @@ -575,8 +585,13 @@ Consumers migrate incrementally: `probe()` is drop-in first, `tile()`/`grab()` s - 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). -- `Process`: fake scripts exercising exit codes, timeouts, stdout/stderr line callbacks, - progress-block parsing. +- `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. +- 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()`). +- 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. @@ -609,7 +624,8 @@ Consumers migrate incrementally: `probe()` is drop-in first, `tile()`/`grab()` s 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** — golden argv comparison against the captured legacy production command shape. +**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 @@ -617,8 +633,9 @@ written to read as sentences; where a name cannot (a leading acronym, or a digit 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.1/8.3 × ffmpeg 5.x floor / 7.1 LTS target), phpstan at level **max** -over `src` and `tests`, pint (`psr12`). +CI: GitHub Actions matrix (PHP 8.2/8.3/8.4 against the pinned ffmpeg 7.1.1 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 @@ -676,10 +693,10 @@ All former open questions, resolved with the maintainer (2026-07-26): auto-derived (`maxrate = video`, `bufsize = 2 × video`) 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**: mirror v1's tag-based detection — audio streams with a language tag become - tracks, first one is default (§2); explicit selection is a later additive option. +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**: target the most mature LTS line — 7.1, pinned in the repo image via the - `FFMPEG_VERSION` build arg; 5.x remains the documented and CI-covered floor. + `FFMPEG_VERSION` build arg, and the only line CI builds against; 4.1 is the documented floor. 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 @@ -694,6 +711,19 @@ All former open questions, resolved with the maintainer (2026-07-26): (§9a) — reporting and packaging are different jobs. 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. +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. +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. ## 11. Future: live streaming diff --git a/src/Video/Adapter.php b/src/Video/Adapter.php index afaadb0..66ab0a8 100644 --- a/src/Video/Adapter.php +++ b/src/Video/Adapter.php @@ -4,7 +4,6 @@ namespace Utopia\Video; -use Utopia\Console; use Utopia\Video\Adapter\Encoder; use Utopia\Video\Adapter\FFmpeg; use Utopia\Video\Adapter\FFprobe; @@ -97,6 +96,15 @@ abstract class Adapter */ 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 = []; @@ -213,6 +221,17 @@ 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. * @@ -241,6 +260,14 @@ 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(); + } + protected function emit(string $event, mixed $payload): void { foreach ($this->listeners[$event] ?? [] as $listener) { @@ -297,12 +324,12 @@ protected function wrote(string $path): string } /** - * Green status line for a finished encode, pack, grab or tile. + * Status line for a finished encode, pack, grab or tile. * - * Quiet backends stay silent; everything else prints so a consumer sees + * Quiet backends stay silent; everything else reports so a consumer sees * the outcome without attaching a LOG listener. * - * @return bool Whether a line was printed. + * @return bool Whether a line was reported. */ protected function reportSuccess(string $message): bool { @@ -310,15 +337,15 @@ protected function reportSuccess(string $message): bool return false; } - Console::success($message); + $this->reporter()->success($message); return true; } /** - * Red status line before a command failure is thrown. + * Status line before a command failure is thrown. * - * @return bool Whether a line was printed. + * @return bool Whether a line was reported. */ protected function reportError(string $message): bool { @@ -326,7 +353,7 @@ protected function reportError(string $message): bool return false; } - Console::error($message); + $this->reporter()->error($message); return true; } diff --git a/src/Video/Arguments.php b/src/Video/Arguments.php index 7ecb7d9..ce8e0d4 100644 --- a/src/Video/Arguments.php +++ b/src/Video/Arguments.php @@ -18,6 +18,8 @@ */ abstract class Arguments { + use Decimal; + /** * @param list $reps * @param int $inputs How many files were opened. More than one means each @@ -41,6 +43,19 @@ public function __construct( .'; supported: '.\implode(', ', $format->supports()), ); } + + $interval = $format->interval(); + + // A cut can only land on a keyframe, so keyframes further apart than a + // segment cannot produce segments of the length that was asked for. The + // muxer would carry on regardless and write longer ones. + if ($interval !== null && $interval > $output->duration()) { + throw new Unsupported( + 'A keyframe every '.self::number($interval).'s cannot cut ' + .self::number($output->duration()).'s segments; segments start on keyframes, so the ' + .'keyframe interval has to be the segment length or a fraction of it', + ); + } } /** @@ -100,6 +115,12 @@ protected function visual(): bool /** * Audio streams to carry through, one entry per output audio stream. * + * Every track comes through, tagged or not: a language is what tells one + * rendition from another in a manifest, but a track without one is still a + * track, and dropping it would lose a dub the source actually carried. + * Untagged tracks are reported with an empty language, which the builders + * read as "nothing to say about this one". + * * @return list */ protected function sound(): array @@ -113,14 +134,13 @@ protected function sound(): array foreach ($this->info->audioTracks as $index => $track) { $language = $track['language']; - if ($language === '' || $language === 'und') { - continue; - } - - $tracks[] = ['index' => $index, 'language' => $language]; + $tracks[] = [ + 'index' => $index, + 'language' => $language === 'und' ? '' : $language, + ]; } - // Nothing carried a language tag, so there is just one anonymous track. + // A probe that found audio without describing it still has one track. return $tracks === [] ? [['index' => 0, 'language' => '']] : $tracks; } @@ -128,6 +148,10 @@ protected function sound(): array * Stream selection: each video rung reads the same source stream, and every * audio track is carried once. * + * Audio always comes from the first input. With one input that is the source + * itself; with several it is the first encoded rung, which carries every + * track the source had, so the track indices hold either way. + * * @return list */ protected function maps(): array @@ -142,7 +166,7 @@ protected function maps(): array foreach ($this->sound() as $track) { $args[] = '-map'; - $args[] = '0:a:'.($split ? 0 : $track['index']); + $args[] = '0:a:'.$track['index']; } return $args; @@ -158,7 +182,10 @@ protected function streams(): array $visual = $this->visual(); $tracks = $this->sound(); - $args = $this->format->build($visual, $tracks !== []); + // The segment length doubles as the keyframe cadence unless the caller + // named one, so a ladder packaged with the defaults is still cut on + // keyframes rather than wherever the encoder happened to put one. + $args = $this->format->build($visual, $tracks !== [], $this->output->duration()); if ($visual) { $position = 0; diff --git a/src/Video/Arguments/Dash.php b/src/Video/Arguments/Dash.php index 5755f80..e8a1149 100644 --- a/src/Video/Arguments/Dash.php +++ b/src/Video/Arguments/Dash.php @@ -101,7 +101,7 @@ public function build(): array $args[] = '-f'; $args[] = 'dash'; $args[] = '-seg_duration'; - $args[] = self::seconds($dash->duration()); + $args[] = self::number($dash->duration()); $args[] = '-use_template'; $args[] = $dash->templated() ? '1' : '0'; $args[] = '-use_timeline'; @@ -146,13 +146,4 @@ protected function muxer(): array { return []; } - - protected static function seconds(float $value): string - { - if ((float) (int) $value === $value) { - return (string) (int) $value; - } - - return \rtrim(\rtrim(\sprintf('%.3F', $value), '0'), '.'); - } } diff --git a/src/Video/Arguments/Hls.php b/src/Video/Arguments/Hls.php index 5f7f624..d0a204b 100644 --- a/src/Video/Arguments/Hls.php +++ b/src/Video/Arguments/Hls.php @@ -32,7 +32,7 @@ public function build(): array $args[] = '-f'; $args[] = 'hls'; $args[] = '-hls_time'; - $args[] = self::seconds($hls->duration()); + $args[] = self::number($hls->duration()); $args[] = '-hls_list_size'; $args[] = '0'; $args[] = '-hls_playlist_type'; @@ -109,13 +109,4 @@ private function map(): string return \implode(' ', $entries); } - - private static function seconds(float $value): string - { - if ((float) (int) $value === $value) { - return (string) (int) $value; - } - - return \rtrim(\rtrim(\sprintf('%.3F', $value), '0'), '.'); - } } diff --git a/src/Video/Encoder.php b/src/Video/Encoder.php index f752b0e..eabb4ff 100644 --- a/src/Video/Encoder.php +++ b/src/Video/Encoder.php @@ -32,8 +32,15 @@ class Encoder protected readonly Probe $prober; - public function __construct(?EncoderAdapter $adapter = null, ?Probe $probe = null) - { + /** + * @param Reporter|null $reporter Where status lines go; null keeps them on + * the terminal. + */ + public function __construct( + ?EncoderAdapter $adapter = null, + ?Probe $probe = null, + ?Reporter $reporter = null, + ) { $this->adapter = $adapter ?? new FFmpeg(); $this->prober = $probe ?? new FFprobe(); @@ -42,6 +49,10 @@ public function __construct(?EncoderAdapter $adapter = null, ?Probe $probe = nul // a probe of its own and ignore the one asked for here. if ($this->adapter instanceof Adapter) { $this->adapter->setProbe($this->prober); + + if ($reporter !== null) { + $this->adapter->setReporter($reporter); + } } } diff --git a/src/Video/Format.php b/src/Video/Format.php index 397fb59..5e61831 100644 --- a/src/Video/Format.php +++ b/src/Video/Format.php @@ -9,12 +9,15 @@ * be packaged at all. * * Keyframe placement is the important one: segments can only be cut where a - * keyframe already exists, so callers force them on a fixed cadence. Bitrate - * deliberately lives on the representation instead, because it varies per rung - * while the codec choice does not. + * keyframe already exists, so they are forced on a fixed cadence. A packaging + * job that was not told which cadence to use takes the segment length, so the + * two cannot silently disagree. Bitrate deliberately lives on the representation + * instead, because it varies per rung while the codec choice does not. */ abstract class Format { + use Decimal; + protected string $video; protected string $audio; @@ -93,6 +96,10 @@ public function bframes(int $count): static /** * Force a keyframe every N seconds so segments can be cut cleanly. + * + * Packaging derives this from the segment length when it is left unset, so + * it only needs setting to ask for something other than one keyframe per + * segment. */ public function keyframe(float $seconds): static { @@ -121,9 +128,13 @@ public function interval(): ?float /** * Codec arguments for one job, without any stream index suffix. * + * @param float|null $cadence Keyframe interval to fall back on when none + * was configured. Packaging passes the segment + * length, because segments can only be cut + * where a keyframe already is. * @return list */ - public function build(bool $video = true, bool $audio = true): array + public function build(bool $video = true, bool $audio = true, ?float $cadence = null): array { $args = []; @@ -152,9 +163,11 @@ public function build(bool $video = true, bool $audio = true): array $args[] = (string) $this->bframes; } - if ($this->keyframe !== null) { + $keyframe = $this->keyframe ?? $cadence; + + if ($keyframe !== null) { $args[] = '-force_key_frames'; - $args[] = 'expr:gte(t,n_forced*'.self::number($this->keyframe).')'; + $args[] = 'expr:gte(t,n_forced*'.self::number($keyframe).')'; } } @@ -164,13 +177,4 @@ public function build(bool $video = true, bool $audio = true): array return $args; } - - protected static function number(float $value): string - { - if ((float) (int) $value === $value) { - return (string) (int) $value; - } - - return \rtrim(\rtrim(\sprintf('%.3F', $value), '0'), '.'); - } } diff --git a/src/Video/Format/Copy.php b/src/Video/Format/Copy.php index 8b7cc00..2e39942 100644 --- a/src/Video/Format/Copy.php +++ b/src/Video/Format/Copy.php @@ -32,7 +32,7 @@ public function defaults(): array /** * Quality knobs are meaningless without an encoder, so they are ignored. */ - public function build(bool $video = true, bool $audio = true): array + public function build(bool $video = true, bool $audio = true, ?float $cadence = null): array { $args = []; diff --git a/src/Video/Name.php b/src/Video/Name.php new file mode 100644 index 0000000..e16d0e9 --- /dev/null +++ b/src/Video/Name.php @@ -0,0 +1,57 @@ +name = $base; + $this->name = Name::label($base, 'Output name'); return $this; } @@ -100,13 +102,4 @@ public function extra(): array { return $this->params; } - - protected static function number(float $value): string - { - if ((float) (int) $value === $value) { - return (string) (int) $value; - } - - return \rtrim(\rtrim(\sprintf('%.3F', $value), '0'), '.'); - } } diff --git a/src/Video/Output/Cmaf.php b/src/Video/Output/Cmaf.php index e0d6a4b..e701703 100644 --- a/src/Video/Output/Cmaf.php +++ b/src/Video/Output/Cmaf.php @@ -4,6 +4,7 @@ namespace Utopia\Video\Output; +use Utopia\Video\Name; use Utopia\Video\Output; /** @@ -35,7 +36,7 @@ public function type(): string public function master(string $filename): static { - $this->master = $filename; + $this->master = Name::file($filename, 'Master playlist'); return $this; } diff --git a/src/Video/Output/Dash.php b/src/Video/Output/Dash.php index fda10e5..6aee1e6 100644 --- a/src/Video/Output/Dash.php +++ b/src/Video/Output/Dash.php @@ -4,6 +4,7 @@ namespace Utopia\Video\Output; +use Utopia\Video\Name; use Utopia\Video\Output; /** @@ -55,7 +56,7 @@ public function timeline(bool $enabled): static public function manifest(string $filename): static { - $this->manifest = $filename; + $this->manifest = Name::file($filename, 'Manifest'); return $this; } diff --git a/src/Video/Output/Hls.php b/src/Video/Output/Hls.php index 9098f8c..f11784b 100644 --- a/src/Video/Output/Hls.php +++ b/src/Video/Output/Hls.php @@ -4,6 +4,7 @@ namespace Utopia\Video\Output; +use Utopia\Video\Name; use Utopia\Video\Output; /** @@ -46,14 +47,14 @@ public function segments(string $type): static */ public function init(string $filename): static { - $this->init = $filename; + $this->init = Name::file($filename, 'Initialisation segment'); return $this; } public function master(string $filename): static { - $this->master = $filename; + $this->master = Name::file($filename, 'Master playlist'); return $this; } diff --git a/src/Video/Packager.php b/src/Video/Packager.php index 220a910..2eae271 100644 --- a/src/Video/Packager.php +++ b/src/Video/Packager.php @@ -57,10 +57,15 @@ class Packager /** @var array> */ protected array $listeners = []; + /** + * @param Reporter|null $reporter Where status lines go; null keeps them on + * the terminal. + */ public function __construct( ?PackagerAdapter $adapter = null, ?EncoderAdapter $encoder = null, ?Probe $probe = null, + ?Reporter $reporter = null, ) { $this->prober = $probe ?? new FFprobe(); $this->adapter = $adapter ?? new FFmpeg(); @@ -77,6 +82,10 @@ public function __construct( foreach ([$this->adapter, $this->encoder] as $backend) { if ($backend instanceof Adapter) { $backend->setProbe($this->prober); + + if ($reporter !== null) { + $backend->setReporter($reporter); + } } } } diff --git a/src/Video/Parser/Mpd.php b/src/Video/Parser/Mpd.php index de20661..3ebb885 100644 --- a/src/Video/Parser/Mpd.php +++ b/src/Video/Parser/Mpd.php @@ -86,7 +86,18 @@ private static function variant( $type = $contentType === 'audio' ? Track::AUDIO : Track::VIDEO; } - [$segments, $timescale, $start] = self::segments($set, $representation, $id, $dir, $duration); + // Segment names can be described by a formula that includes the + // bandwidth, so it has to be known before they can be resolved. + $bandwidth = (int) $representation->getAttribute('bandwidth'); + + [$segments, $timescale, $start] = self::segments( + $set, + $representation, + $id, + $dir, + $duration, + $bandwidth, + ); $language = $set->getAttribute('lang'); @@ -95,7 +106,7 @@ private static function variant( type: $type, mimeType: $mime !== '' ? $mime : null, codecs: self::text($representation->getAttribute('codecs')), - bandwidth: (int) $representation->getAttribute('bandwidth'), + bandwidth: $bandwidth, width: self::number($representation, $set, 'width'), height: self::number($representation, $set, 'height'), sar: self::text($representation->getAttribute('sar')), @@ -117,6 +128,7 @@ private static function segments( string $id, string $dir, float $duration, + int $bandwidth, ): array { $list = self::child($representation, 'SegmentList') ?? self::child($set, 'SegmentList'); @@ -127,7 +139,7 @@ private static function segments( $template = self::child($representation, 'SegmentTemplate') ?? self::child($set, 'SegmentTemplate'); if ($template !== null) { - return self::templated($template, $id, $dir, $duration); + return self::templated($template, $id, $dir, $duration, $bandwidth); } return [[], 0, 0]; @@ -173,8 +185,13 @@ private static function listed(DOMElement $list, string $id, string $dir): array * * @return array{0: list, 1: int, 2: int} */ - private static function templated(DOMElement $template, string $id, string $dir, float $duration): array - { + private static function templated( + DOMElement $template, + string $id, + string $dir, + float $duration, + int $bandwidth, + ): array { $timescale = (int) $template->getAttribute('timescale'); $timescale = $timescale > 0 ? $timescale : 1; $start = $template->hasAttribute('startNumber') @@ -185,7 +202,7 @@ private static function templated(DOMElement $template, string $id, string $dir, $init = $template->getAttribute('initialization'); if ($init !== '') { - $segments[] = self::segment($id, $dir, self::expand($init, $id, 0), 0.0, true, 0); + $segments[] = self::segment($id, $dir, self::expand($init, $id, 0, $bandwidth), 0.0, true, 0); } $timeline = self::child($template, 'SegmentTimeline'); @@ -206,7 +223,7 @@ private static function templated(DOMElement $template, string $id, string $dir, $segments[] = self::segment( $id, $dir, - self::expand($media, $id, $number), + self::expand($media, $id, $number, $bandwidth), $length, false, $number, @@ -231,7 +248,7 @@ private static function templated(DOMElement $template, string $id, string $dir, $segments[] = self::segment( $id, $dir, - self::expand($media, $id, $number), + self::expand($media, $id, $number, $bandwidth), $length, false, $number, @@ -243,18 +260,24 @@ private static function templated(DOMElement $template, string $id, string $dir, /** * Substitutes the identifiers DASH allows inside a segment name. + * + * $Number$ and $Bandwidth$ both take an optional printf width, which is how + * a manifest asks for zero padded names, and $$ is how it asks for a literal + * dollar sign. */ - public static function expand(string $pattern, string $id, int $number): string + public static function expand(string $pattern, string $id, int $number, int $bandwidth = 0): string { - $name = \str_replace(['$RepresentationID$', '$Bandwidth$'], [$id, ''], $pattern); - - $name = \preg_replace_callback( - '/\$Number(%0?\d*d)?\$/', - static fn (array $match): string => isset($match[1]) && $match[1] !== '' - ? \sprintf($match[1], $number) - : (string) $number, - $name, - ) ?? $name; + $name = \str_replace('$RepresentationID$', $id, $pattern); + + foreach (['Number' => $number, 'Bandwidth' => $bandwidth] as $identifier => $value) { + $name = \preg_replace_callback( + '/\$'.$identifier.'(%0?\d*d)?\$/', + static fn (array $match): string => isset($match[1]) && $match[1] !== '' + ? \sprintf($match[1], $value) + : (string) $value, + $name, + ) ?? $name; + } return \str_replace('$$', '$', $name); } diff --git a/src/Video/Process.php b/src/Video/Process.php index db22f6a..00ab977 100644 --- a/src/Video/Process.php +++ b/src/Video/Process.php @@ -19,6 +19,21 @@ final class Process { private const TAIL = 8192; + /** + * Longest run of interrupted waits to sit through before giving up. + * + * A signal arriving mid-wait makes stream_select report failure without + * anything actually being wrong, so a handful in a row are shrugged off; a + * stream that keeps reporting failure is a real one. + */ + private const RETRIES = 10; + + /** Bytes of one unbroken line to hold before emitting it anyway. */ + private const LINE = 65536; + + /** Seconds a killed command is given to explain itself. */ + private const GRACE = 2.0; + /** * @param list $command * @param callable(string):void|null $stdout Called once per line written to stdout. @@ -63,15 +78,41 @@ public static function run( $started = \microtime(true); $open = [1 => $pipes[1], 2 => $pipes[2]]; + $interrupted = 0; + while ($open !== []) { + // Checked before the wait rather than after the read, so a command + // that has gone quiet is still stopped on time. + if ($timeout > 0 && (\microtime(true) - $started) > $timeout) { + throw self::halt( + $process, + $open, + $errors, + 'Command "'.$command[0].'" timed out after '.$timeout.'s', + $command, + ); + } + $read = \array_values($open); $write = null; $except = null; if (@\stream_select($read, $write, $except, 0, 200000) === false) { - break; + if (++$interrupted <= self::RETRIES) { + continue; + } + + throw self::halt( + $process, + $open, + $errors, + 'Lost contact with "'.$command[0].'" while reading its output', + $command, + ); } + $interrupted = 0; + foreach ($open as $key => $pipe) { if (! \in_array($pipe, $read, true)) { continue; @@ -114,21 +155,14 @@ public static function run( self::emit($key, $line, $stdout, $stderr); } } - } - if ($timeout > 0 && (\microtime(true) - $started) > $timeout) { - foreach ($open as $pipe) { - \fclose($pipe); + // Nothing says a stream has to send line breaks, and a binary + // one never will, so a buffer that has grown past a plausible + // line is handed over as it stands rather than held forever. + if (\strlen($buffers[$key]) >= self::LINE) { + self::emit($key, $buffers[$key], $stdout, $stderr); + $buffers[$key] = ''; } - - \proc_terminate($process, 9); - \proc_close($process); - - throw new Runtime( - 'Command "'.$command[0].'" timed out after '.$timeout.'s', - $command, - $errors, - ); } } @@ -178,6 +212,58 @@ public static function exists(string $binary): bool } } + /** + * Ends a command that cannot be waited on any longer, and describes why. + * + * Asking it to stop before insisting is the point: a backend given a chance + * to exit writes the reason it was unhappy to stderr on the way out, and + * closing the pipes first would throw away the only useful part of the + * failure. Whatever it manages to say inside the grace period is kept, and + * only a process still running afterwards is killed outright. + * + * @param resource $process + * @param array $open + * @param list $command + */ + private static function halt( + $process, + array $open, + string $errors, + string $message, + array $command, + ): Runtime { + \proc_terminate($process, 15); + + $pipe = $open[2] ?? null; + $deadline = \microtime(true) + self::GRACE; + $status = \proc_get_status($process); + + while ($status['running'] && \microtime(true) < $deadline) { + if ($pipe !== null) { + $chunk = \fread($pipe, 8192); + + if (\is_string($chunk) && $chunk !== '') { + $errors = \substr($errors.$chunk, -self::TAIL); + } + } + + \usleep(20000); + $status = \proc_get_status($process); + } + + if ($status['running']) { + \proc_terminate($process, 9); + } + + foreach ($open as $handle) { + \fclose($handle); + } + + \proc_close($process); + + return new Runtime($message, $command, \trim($errors)); + } + /** * @param callable(string):void|null $stdout * @param callable(string):void|null $stderr diff --git a/src/Video/Reporter.php b/src/Video/Reporter.php new file mode 100644 index 0000000..628ce5c --- /dev/null +++ b/src/Video/Reporter.php @@ -0,0 +1,35 @@ +name = $name ?? ($height > 0 ? $height.'p' : 'audio'); + $this->name = $name === null + ? ($height > 0 ? $height.'p' : 'audio') + : Name::label($name, 'Representation name'); $this->maxrate = $maxrate ?? $video; $this->bufsize = $bufsize ?? ($this->maxrate * 2); } diff --git a/src/Video/Tile.php b/src/Video/Tile.php index f0e25f7..79cdec3 100644 --- a/src/Video/Tile.php +++ b/src/Video/Tile.php @@ -58,7 +58,7 @@ public function quality(int $quality): self public function name(string $base): self { - $this->name = $base; + $this->name = Name::label($base, 'Sprite sheet name'); return $this; } diff --git a/tests/Video/Unit/AdapterTest.php b/tests/Video/Unit/AdapterTest.php index 6b57803..0991625 100644 --- a/tests/Video/Unit/AdapterTest.php +++ b/tests/Video/Unit/AdapterTest.php @@ -21,6 +21,7 @@ use Utopia\Video\Info; use Utopia\Video\Output\Hls; use Utopia\Video\Packager; +use Utopia\Video\Reporter; use Utopia\Video\Representation; class AdapterTest extends TestCase @@ -323,100 +324,122 @@ public function testAQuietBackendStillReportsProgress(): void } /** - * Finished jobs print a status line unless the backend was told to stay quiet. + * Finished jobs report a status line unless the backend was told to stay quiet. */ public function testStatusLinesFollowTheDisplayLevel(): void { - $quiet = new class (level: Adapter::QUIET) extends Mock { - /** @var list */ - public array $printed = []; - - protected function reportSuccess(string $message): bool - { - return $this->printed[] = parent::reportSuccess($message); - } - }; + $reporter = new Recorder(); + $quiet = new Mock(level: Adapter::QUIET); + $quiet->setReporter($reporter); $quiet->open($this->file)->encode($this->dir.'/quiet-status.mp4'); - $this->assertSame([false], $quiet->printed); - - $loud = new class () extends Mock { - /** @var list */ - public array $printed = []; - - /** @var list */ - public array $messages = []; - - protected function reportSuccess(string $message): bool - { - $this->messages[] = $message; - - return $this->printed[] = parent::reportSuccess($message); - } - }; + $this->assertSame([], $reporter->successes, 'a quiet backend should say nothing'); $path = $this->dir.'/loud-status.mp4'; + $loud = new Mock(); + $loud->setReporter($reporter); $loud->open($this->file)->encode($path); - $this->assertSame([true], $loud->printed); - $this->assertSame(['mock: encoded '.$path], $loud->messages); + $this->assertSame(['mock: encoded '.$path], $reporter->successes); } /** - * A failed backend command prints before the exception leaves. + * A failed backend command says so before the exception leaves. */ public function testAFailedCommandReportsAnError(): void { - $adapter = new class () extends FFmpeg { - /** @var list */ - public array $errors = []; - - protected function reportError(string $message): bool - { - $this->errors[] = $message; - - return parent::reportError($message); - } + $reporter = new Recorder(); + $adapter = new class () extends FFmpeg { public function fail(): void { $this->process(['false']); } }; + $adapter->setReporter($reporter); + try { $adapter->fail(); $this->fail('a failing command should throw'); } catch (Runtime $e) { - $this->assertNotSame([], $adapter->errors); - $this->assertStringContainsString('failed with exit code', $adapter->errors[0]); - $this->assertSame($adapter->errors[0], $e->getMessage()); + $this->assertCount(1, $reporter->errors); + $this->assertStringContainsString('failed with exit code', $reporter->errors[0]); + $this->assertSame($reporter->errors[0], $e->getMessage()); } $quiet = new class (level: Adapter::QUIET) extends FFmpeg { - /** @var list */ - public array $printed = []; - - protected function reportError(string $message): bool - { - return $this->printed[] = parent::reportError($message); - } - public function fail(): void { $this->process(['false']); } }; + $quiet->setReporter($reporter); + try { $quiet->fail(); $this->fail('a failing command should throw'); } catch (Runtime) { - $this->assertSame([false], $quiet->printed); + $this->assertCount(1, $reporter->errors, 'a quiet backend should say nothing'); } } + /** + * Status lines go on the terminal unless told otherwise, because that is + * what a command line caller wants and it costs everyone else one argument + * to redirect. + */ + public function testStatusLinesGoToTheTerminalByDefault(): void + { + $adapter = new class () extends Mock { + public function destination(): Reporter + { + return $this->reporter(); + } + }; + + $this->assertInstanceOf(Reporter\Console::class, $adapter->destination()); + + // And a facade told to keep quiet replaces it before anything runs. + new Encoder(adapter: $adapter, probe: $adapter, reporter: new Reporter\Silent()); + + $this->assertInstanceOf(Reporter\Silent::class, $adapter->destination()); + } + + /** + * A worker that does not own stdout hands over somewhere else to report to, + * and the facade gives it to every backend so half a job cannot end up + * printing. + */ + public function testTheReporterGivenToAFacadeIsTheOneBackendsUse(): void + { + $reporter = new Recorder(); + $mock = new Mock(); + + $package = (new Packager(adapter: $mock, probe: $mock, reporter: $reporter)) + ->open($this->file) + ->add(new Representation(1280, 720, 2538)) + ->output(new Hls()) + ->pack($this->dir.'/reported'); + + $this->assertNotSame([], $package->variants()); + $this->assertSame(['mock: packed '.$this->dir.'/reported'], $reporter->successes); + + $encoded = $this->dir.'/reported.mp4'; + $silent = new Mock(); + + (new Encoder(adapter: $silent, probe: $silent, reporter: $reporter)) + ->open($this->file) + ->encode($encoded); + + $this->assertSame( + ['mock: packed '.$this->dir.'/reported', 'mock: encoded '.$encoded], + $reporter->successes, + ); + } + public function testAnUnknownNameIsRejected(): void { $this->expectException(Unsupported::class); diff --git a/tests/Video/Unit/ArgumentsDashTest.php b/tests/Video/Unit/ArgumentsDashTest.php index e7d537d..52b8f6c 100644 --- a/tests/Video/Unit/ArgumentsDashTest.php +++ b/tests/Video/Unit/ArgumentsDashTest.php @@ -151,6 +151,32 @@ public function testEachAudioLanguageGetsItsOwnAdaptationSet(): void ); } + /** + * Untagged tracks are alternatives of nothing in particular, but they are + * still separate tracks, so each needs a set of its own the same way a + * language does. + */ + public function testUntaggedTracksAlsoGetOneAdaptationSetEach(): void + { + $info = new Info( + duration: 60.0, + format: 'matroska', + hasVideo: true, + hasAudio: true, + width: 1920, + height: 1080, + audioTracks: [ + ['codec' => 'aac', 'language' => 'und'], + ['codec' => 'aac', 'language' => 'und'], + ], + ); + + $argv = $this->build($info, [new Representation(1280, 720, 2538)]); + + $this->assertSame(2, \substr_count($argv, '-map 0:a:')); + $this->assertStringContainsString('-adaptation_sets id=0,streams=v id=1,streams=1 id=2,streams=2', $argv); + } + public function testAdaptationSetsCanStillBeOverridden(): void { $argv = $this->build( diff --git a/tests/Video/Unit/ArgumentsHlsTest.php b/tests/Video/Unit/ArgumentsHlsTest.php index 51e63a6..305bca4 100644 --- a/tests/Video/Unit/ArgumentsHlsTest.php +++ b/tests/Video/Unit/ArgumentsHlsTest.php @@ -169,6 +169,104 @@ public function testUntaggedAudioBecomesOneAnonymousRendition(): void $this->assertStringContainsString('a:0,agroup:audio,name:audio_0,default:yes', $argv); } + /** + * A language tag is what tells one rendition from another, but a track + * without one is still a track: a file carrying four untagged dubs has to + * come out with four, not with the first one alone. + */ + public function testEveryUntaggedTrackIsStillCarried(): void + { + $info = $this->info(audioTracks: [ + ['codec' => 'aac', 'language' => 'und'], + ['codec' => 'aac', 'language' => 'und'], + ['codec' => 'aac', 'language' => ''], + ]); + + $argv = $this->build($info, [new Representation(1280, 720, 2538, 128)]); + + $this->assertSame(3, \substr_count($argv, '-map 0:a:')); + $this->assertStringContainsString('-map 0:a:0', $argv); + $this->assertStringContainsString('-map 0:a:1', $argv); + $this->assertStringContainsString('-map 0:a:2', $argv); + $this->assertStringContainsString('a:0,agroup:audio,name:audio_0,default:yes', $argv); + $this->assertStringContainsString('a:1,agroup:audio,name:audio_1', $argv); + $this->assertStringContainsString('a:2,agroup:audio,name:audio_2', $argv); + $this->assertStringNotContainsString('language:und', $argv); + } + + /** + * @testdox A partly tagged source keeps its untagged tracks too + */ + public function testTaggedAndUntaggedTracksTravelTogether(): void + { + $info = $this->info(audioTracks: [ + ['codec' => 'aac', 'language' => 'eng'], + ['codec' => 'aac', 'language' => 'und'], + ['codec' => 'aac', 'language' => 'spa'], + ]); + + $argv = $this->build($info, [new Representation(1280, 720, 2538, 128)]); + + $this->assertSame(3, \substr_count($argv, '-map 0:a:')); + $this->assertStringContainsString('a:0,agroup:audio,language:eng,name:audio_0,default:yes', $argv); + $this->assertStringContainsString('a:1,agroup:audio,name:audio_1', $argv); + $this->assertStringContainsString('a:2,agroup:audio,language:spa,name:audio_2', $argv); + } + + /** + * Segments are cut where a keyframe already is, so a job that never named a + * cadence takes the segment length rather than leaving the encoder to put + * keyframes wherever it liked. + */ + public function testKeyframesFollowTheSegmentLengthWhenNoneWasAskedFor(): void + { + $arguments = new Arguments( + $this->info(), + new X264(), + [new Representation(1280, 720, 2538)], + (new Hls())->segment(4), + '/out', + ); + + $argv = \implode(' ', $arguments->build()); + + $this->assertStringContainsString('-force_key_frames expr:gte(t,n_forced*4)', $argv); + $this->assertStringContainsString('-hls_time 4', $argv); + } + + public function testAnExplicitKeyframeIntervalStillWins(): void + { + $arguments = new Arguments( + $this->info(), + (new X264())->keyframe(2.0), + [new Representation(1280, 720, 2538)], + (new Hls())->segment(6), + '/out', + ); + + $argv = \implode(' ', $arguments->build()); + + $this->assertStringContainsString('-force_key_frames expr:gte(t,n_forced*2)', $argv); + $this->assertStringContainsString('-hls_time 6', $argv); + } + + /** + * @testdox Keyframes further apart than a segment are rejected + */ + public function testKeyframesFurtherApartThanASegmentAreRejected(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('A keyframe every 10s cannot cut 4s segments'); + + new Arguments( + $this->info(), + (new X264())->keyframe(10.0), + [new Representation(1280, 720, 2538)], + (new Hls())->segment(4), + '/out', + ); + } + public function testAudioOnlySourceDropsEveryVideoArgument(): void { $argv = $this->build( diff --git a/tests/Video/Unit/FormatTest.php b/tests/Video/Unit/FormatTest.php index 07f5fc4..2eac9e1 100644 --- a/tests/Video/Unit/FormatTest.php +++ b/tests/Video/Unit/FormatTest.php @@ -45,6 +45,21 @@ public function testFractionalKeyframeIntervalStaysReadable(): void $this->assertContains('expr:gte(t,n_forced*1.5)', $args); } + /** + * Packaging passes the segment length as the cadence to fall back on, since + * a segment can only start where a keyframe already is. An encode to a plain + * file has no segments and passes nothing. + */ + public function testAFallbackCadenceIsUsedOnlyWhenNoneWasSet(): void + { + $this->assertContains('expr:gte(t,n_forced*6)', (new X264())->build(cadence: 6.0)); + $this->assertContains( + 'expr:gte(t,n_forced*2)', + (new X264())->keyframe(2.0)->build(cadence: 6.0), + ); + $this->assertNotContains('-force_key_frames', (new X264())->build()); + } + public function testRawParametersAreAppendedLast(): void { $args = (new X264())->crf(22)->params(['-dn', '-sn'])->build(); diff --git a/tests/Video/Unit/OutputTest.php b/tests/Video/Unit/OutputTest.php index 8d192db..269a5f6 100644 --- a/tests/Video/Unit/OutputTest.php +++ b/tests/Video/Unit/OutputTest.php @@ -5,6 +5,7 @@ namespace Utopia\Tests\Unit; use PHPUnit\Framework\TestCase; +use Utopia\Video\Exception\Input; use Utopia\Video\Output; use Utopia\Video\Output\Cmaf; use Utopia\Video\Output\Dash; @@ -149,6 +150,51 @@ public function testManifestsCanBeTurnedOff(): void $this->assertFalse((new Hls())->manifests(false)->keeps()); } + /** + * The base name is joined onto the output directory to build every filename, + * so a name that walks back out of it is refused rather than followed. + * + * @testdox A base name that leaves the output directory is rejected + */ + public function testBaseNameCannotEscapeTheOutputDirectory(): void + { + foreach (['../escape', '/etc/passwd', 'sub/dir', '', 'with space', "null\0byte"] as $name) { + try { + (new Hls())->name($name); + $this->fail('expected "'.$name.'" to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as a name', $exception->getMessage()); + } + } + } + + public function testManifestAndPlaylistFilenamesHaveToBePlainNames(): void + { + foreach ([Hls::class, Cmaf::class] as $class) { + try { + /** @var Hls|Cmaf $output */ + $output = new $class(); + $output->master('../master.m3u8'); + $this->fail('expected a traversing master name to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as a filename', $exception->getMessage()); + } + } + + $this->expectException(Input::class); + + (new Dash())->manifest('/tmp/manifest.mpd'); + } + + public function testAnInitSegmentNameStillHasToBeAFilename(): void + { + $this->assertSame('start.mp4', (new Hls())->init('start.mp4')->initFile()); + + $this->expectException(Input::class); + + (new Hls())->init('../init.mp4'); + } + public function testThumbDefaults(): void { $thumb = new Thumb(); diff --git a/tests/Video/Unit/ParserMpdTest.php b/tests/Video/Unit/ParserMpdTest.php index c5742ec..6d78ea1 100644 --- a/tests/Video/Unit/ParserMpdTest.php +++ b/tests/Video/Unit/ParserMpdTest.php @@ -66,6 +66,61 @@ public function testExpandsSegmentNamePatterns(): void ); } + /** + * A manifest is free to address its segments by bitrate instead of by id, + * and a reader that dropped the identifier would look for the wrong file. + */ + public function testExpandsTheBandwidthIdentifier(): void + { + $this->assertSame( + 'chunk-2538000-4.m4s', + Mpd::expand('chunk-$Bandwidth$-$Number$.m4s', '0', 4, 2538000), + ); + + $this->assertSame( + 'seg-0002538000.m4s', + Mpd::expand('seg-$Bandwidth%010d$.m4s', '0', 1, 2538000), + ); + } + + /** + * @testdox A literal dollar sign survives expansion + */ + public function testLiteralDollarSignSurvives(): void + { + $this->assertSame('odd$name-3.m4s', Mpd::expand('odd$$name-$Number$.m4s', '0', 3, 800)); + } + + public function testReadsATemplatedManifestAddressedByBandwidth(): void + { + $this->write('init-800000.m4s', 'init'); + $this->write('chunk-800000-00001.m4s', 'a'); + $this->write('chunk-800000-00002.m4s', 'b'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + XML); + + $variant = Mpd::read($manifest, $this->dir)['variants'][0]; + + $this->assertSame(800000, $variant->bandwidth); + $this->assertCount(3, $variant->segments); + $this->assertSame('init-800000.m4s', $variant->segments[0]->file); + $this->assertSame('chunk-800000-00001.m4s', $variant->segments[1]->file); + $this->assertSame('chunk-800000-00002.m4s', $variant->segments[2]->file); + } + public function testReadsAListedManifest(): void { $this->write('init-0.m4s', 'init'); diff --git a/tests/Video/Unit/ProcessTest.php b/tests/Video/Unit/ProcessTest.php index 0a49c04..fe405ea 100644 --- a/tests/Video/Unit/ProcessTest.php +++ b/tests/Video/Unit/ProcessTest.php @@ -98,11 +98,48 @@ public function testStopsARunawayCommand(): void $this->fail('Expected the command to time out'); } catch (Runtime $exception) { $this->assertStringContainsString('timed out', $exception->getMessage()); + $this->assertSame('sleep', $exception->command()[0]); } $this->assertLessThan(10, \microtime(true) - $started); } + /** + * A command that has to be stopped is the one most in need of explaining, so + * whatever it complained about on the way out is kept rather than discarded + * along with its pipes. + */ + public function testATimedOutCommandKeepsItsComplaint(): void + { + try { + Process::run(['sh', '-c', 'echo "half way in" 1>&2; sleep 30'], timeout: 1); + $this->fail('Expected the command to time out'); + } catch (Runtime $exception) { + $this->assertStringContainsString('timed out', $exception->getMessage()); + $this->assertStringContainsString('half way in', $exception->output()); + } + } + + /** + * Nothing obliges a stream to send line breaks, and a backend writing + * something binary never will, so the buffer is handed over once it has + * grown past a plausible line rather than held until the command ends. + */ + public function testAnEndlessLineIsNotBufferedForever(): void + { + $lines = []; + + Process::run( + ['sh', '-c', 'printf "%0200000d" 1'], + function (string $line) use (&$lines): void { + $lines[] = $line; + }, + ); + + $this->assertGreaterThan(1, \count($lines), 'one unbroken line should arrive in pieces'); + $this->assertSame(200000, \array_sum(\array_map('strlen', $lines))); + } + public function testMissingBinaryIsAFailure(): void { $this->expectException(Runtime::class); diff --git a/tests/Video/Unit/Recorder.php b/tests/Video/Unit/Recorder.php new file mode 100644 index 0000000..abe537c --- /dev/null +++ b/tests/Video/Unit/Recorder.php @@ -0,0 +1,32 @@ + */ + public array $successes = []; + + /** @var list */ + public array $errors = []; + + public function success(string $message): void + { + $this->successes[] = $message; + } + + public function error(string $message): void + { + $this->errors[] = $message; + } +} diff --git a/tests/Video/Unit/RepresentationTest.php b/tests/Video/Unit/RepresentationTest.php index e35abb7..55b46d6 100644 --- a/tests/Video/Unit/RepresentationTest.php +++ b/tests/Video/Unit/RepresentationTest.php @@ -83,4 +83,46 @@ public function testRejectsUnusableAudioBitrate(): void new Representation(1280, 720, 2500, audio: 0); } + + /** + * The name becomes a filename while a ladder is being staged, so a name that + * climbs out of the working directory would write wherever it pointed. + * + * @testdox A name that leaves the working directory is rejected + */ + public function testRejectsANameThatEscapesTheWorkingDirectory(): void + { + foreach (['../../outside', 'sub/dir', '/absolute', '..'] as $name) { + try { + new Representation(1280, 720, 2500, name: $name); + $this->fail('expected "'.$name.'" to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as a name', $exception->getMessage()); + } + } + } + + /** + * The muxer is told which rendition is which in a comma separated list of + * space separated entries, so a name containing either would be read as the + * end of the entry rather than part of the name. + */ + public function testRejectsANameThatWouldBreakTheStreamMap(): void + { + foreach (['720p,name:evil', 'two words', ''] as $name) { + try { + new Representation(1280, 720, 2500, name: $name); + $this->fail('expected "'.$name.'" to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as a name', $exception->getMessage()); + } + } + } + + public function testAcceptsTheNamesLaddersActuallyUse(): void + { + foreach (['720p', 'low', 'audio_eng', 'rung-1', 'HD'] as $name) { + $this->assertSame($name, (new Representation(1280, 720, 2500, name: $name))->name); + } + } } diff --git a/tests/Video/Unit/SpritesheetTest.php b/tests/Video/Unit/SpritesheetTest.php index 6dd3fb0..6d35cfc 100644 --- a/tests/Video/Unit/SpritesheetTest.php +++ b/tests/Video/Unit/SpritesheetTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Utopia\Video\Cue; +use Utopia\Video\Exception\Input; use Utopia\Video\Spritesheet; use Utopia\Video\Tile; @@ -133,4 +134,15 @@ public function testTileIsConfigurable(): void $this->assertSame('thumbs', $tile->base()); $this->assertFalse($tile->writes()); } + + /** + * Sheet names become filenames in the directory they were given, so one that + * leaves it is refused. + */ + public function testASheetNameThatLeavesTheDirectoryIsRejected(): void + { + $this->expectException(Input::class); + + (new Tile())->name('../sheets'); + } } From f3eccbcb82d451d4d5876687723234c42d2446a7 Mon Sep 17 00:00:00 2001 From: shimon Date: Wed, 19 Aug 2026 10:36:49 +0300 Subject: [PATCH 08/12] Add test for segment cutting behavior in HLS packaging - Introduced a new test to verify that segments are cut at the requested length when no cadence is specified. - Ensured that segments do not exceed the specified duration, confirming keyframe placement functionality in the encoding process. --- tests/Video/E2E/HlsTest.php | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/Video/E2E/HlsTest.php b/tests/Video/E2E/HlsTest.php index 94fca26..82ffece 100644 --- a/tests/Video/E2E/HlsTest.php +++ b/tests/Video/E2E/HlsTest.php @@ -67,6 +67,43 @@ public function testOneMasterDescribesEveryRung(): void $this->assertSame([240, 480], [$video[0]->height, $video[1]->height]); } + /** + * A segment can only start on a keyframe, so a job that never named a + * cadence has to take the segment length as one. Left to itself the encoder + * places keyframes on its own schedule — every 250 frames by default, which + * is longer than this whole fixture — so this eight second source would come + * back as one long segment however short a segment was asked for. + */ + public function testDefaultsAloneStillCutSegmentsAtTheLengthAskedFor(): void + { + $package = (new Packager()) + ->open(self::video()) + ->format((new X264())->crf(30)->params(['-preset', 'ultrafast'])) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(3)) + ->pack($this->dir); + + $video = \array_values(\array_filter( + $package->variants(), + static fn ($variant): bool => $variant->type === Track::VIDEO, + )); + + $this->assertCount(1, $video); + $this->assertGreaterThan( + 1, + \count($video[0]->segments), + 'the source was not cut at all, so the keyframes were never forced', + ); + + foreach ($video[0]->segments as $segment) { + $this->assertLessThan( + 4.5, + $segment->duration, + 'segment '.$segment->file.' ran past the length that was asked for', + ); + } + } + public function testEachRungIsEncodedAtItsOwnSize(): void { $package = $this->pack( From 4a958bffcd1900136f8097e84cec5693955423cf Mon Sep 17 00:00:00 2001 From: shimon Date: Wed, 19 Aug 2026 18:27:00 +0300 Subject: [PATCH 09/12] Update dependencies and enhance documentation for video library - Upgraded phpstan/phpstan from version 1.* to ^2.0 in composer.json and updated composer.lock accordingly. - Improved README.md with additional details on listener behavior and coroutine safety. - Enhanced design.md to clarify data flow and immutability in the library's architecture. - Refactored Adapter class to include new methods for managing listeners, ensuring they persist across job executions. - Added input validation in various classes to enforce constraints on parameters, improving robustness. --- README.md | 48 +++++ composer.json | 2 +- composer.lock | 23 ++- docs/design.md | 38 +++- src/Video/Adapter.php | 53 ++++- src/Video/Adapter/FFmpeg.php | 30 ++- src/Video/Adapter/FFprobe.php | 71 +++++-- src/Video/Adapter/Mock.php | 44 +++- src/Video/Arguments.php | 21 +- src/Video/Encoder.php | 57 +++++- src/Video/Exception/Runtime.php | 4 +- src/Video/Format.php | 24 ++- src/Video/Format/Copy.php | 8 +- src/Video/Name.php | 50 +++++ src/Video/Output.php | 27 ++- src/Video/Output/Cmaf.php | 17 +- src/Video/Output/Dash.php | 36 ++-- src/Video/Output/Hls.php | 49 ++++- src/Video/Packager.php | 72 ++++++- src/Video/Parser/Mpd.php | 148 ++++++++++---- src/Video/Process.php | 190 ++++++++++------- src/Video/Thumb.php | 37 +++- src/Video/Tile.php | 63 ++++-- tests/Video/Adapters.php | 113 ++++++++-- tests/Video/E2E/CoroutineTest.php | 72 +++++++ tests/Video/Samples.php | 1 + tests/Video/Unit/AdapterTest.php | 13 +- tests/Video/Unit/ArgumentsHlsTest.php | 48 +++++ tests/Video/Unit/EncoderTest.php | 4 +- tests/Video/Unit/FormatTest.php | 18 ++ tests/Video/Unit/ImmutabilityTest.php | 284 ++++++++++++++++++++++++++ tests/Video/Unit/MockTest.php | 36 ++++ tests/Video/Unit/OutputTest.php | 45 ++++ tests/Video/Unit/PackagerTest.php | 90 ++++++++ tests/Video/Unit/ParserMpdTest.php | 131 ++++++++++++ tests/Video/Unit/ProcessTest.php | 33 +++ tests/Video/Unit/SpritesheetTest.php | 72 +++++++ 37 files changed, 1791 insertions(+), 281 deletions(-) create mode 100644 tests/Video/E2E/CoroutineTest.php create mode 100644 tests/Video/Unit/ImmutabilityTest.php diff --git a/README.md b/README.md index 2ca7078..d3eb17e 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,11 @@ $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. @@ -243,6 +248,49 @@ $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 diff --git a/composer.json b/composer.json index 63cb335..ec99c1b 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ }, "require-dev": { "phpunit/phpunit": "9.*", - "phpstan/phpstan": "1.*", + "phpstan/phpstan": "^2.0", "laravel/pint": "1.*" }, "config": { diff --git a/composer.lock b/composer.lock index 393e39c..4c5da68 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c0a016876c3fc8363bf165f62d35a3df", + "content-hash": "339fec9349e29f6c104eb43292336622", "packages": [ { "name": "utopia-php/console", @@ -463,15 +463,15 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.33", + "version": "2.2.8", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", - "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e285254e60f33c21902efef4a926ca0987c06804", + "reference": "e285254e60f33c21902efef4a926ca0987c06804", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^7.4|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -490,6 +490,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -512,7 +523,7 @@ "type": "github" } ], - "time": "2026-02-28T20:30:03+00:00" + "time": "2026-08-04T22:21:45+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/docs/design.md b/docs/design.md index bccb641..e36f05f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -83,9 +83,14 @@ Three rules keep the design honest: 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. 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 reusable across jobs, and progress always knows the duration. + 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 @@ -142,6 +147,15 @@ $package = $packager->open($in) 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 @@ -239,7 +253,7 @@ to be an encoder. Callers that hold an interface-typed backend and want it check | Class | Binary | Timeout | Notes | |---|---|---|---| -| `Adapter\FFmpeg` | `ffmpeg` | 0 | `Encoder`, `Packager`. Every invocation is prefixed `-y -hide_banner -loglevel {level}`. Fused encode+package, or `-c copy` remux with `Format\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\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 | @@ -284,7 +298,7 @@ abstract class Format Format\X264 // libx264 + aac; defaults bf 1, keyint_min 25, g 250, sc_threshold 40 Format\HEVC // libx265 + aac Format\VP9 // libvpx-vp9 + libopus (DASH-only packaging — see §4) -Format\Copy // -c copy: package/remux without re-encoding (input must be keyframe-aligned) +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) Output\Hls ->type(Hls::MPEGTS | Hls::FMP4) // fmp4 → init segment + EXT-X-MAP, playlist VERSION 7 @@ -567,7 +581,8 @@ Consumers migrate incrementally: `probe()` is drop-in first, `tile()`/`grab()` s - `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, `Format\Copy` remux, capped-CRF + 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). @@ -577,14 +592,21 @@ Consumers migrate incrementally: `probe()` is drop-in first, `tile()`/`grab()` s (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. The staged half runs against a **pure `Adapter\Packager` fake** — that is what keeps the v2 + 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). +- `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). A + Swoole-gated E2E (`CoroutineTest`, skipped without ext-swoole) runs three coroutines, one + `Encoder` each, off one shared `Thumb` under `SWOOLE_HOOK_ALL`. - `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. diff --git a/src/Video/Adapter.php b/src/Video/Adapter.php index 66ab0a8..2ebd2d4 100644 --- a/src/Video/Adapter.php +++ b/src/Video/Adapter.php @@ -61,13 +61,29 @@ abstract class Adapter self::DEBUG, ]; - /** The slug this backend answers to. Matches the factory keys. */ + /** + * 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. */ + /** + * 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. */ + /** + * Seconds a command may run. 0 disables the limit. + * + * @var int + */ protected const TIMEOUT = 0; /** @@ -76,6 +92,8 @@ abstract class Adapter * 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; @@ -108,6 +126,9 @@ abstract class Adapter /** @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. * @@ -246,10 +267,14 @@ public function on(string $event, callable $listener): static /** * 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->binary === '' || Process::exists($this->binary); + return $this->available ??= $this->binary === '' || Process::exists($this->binary); } /** @@ -268,6 +293,20 @@ 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) { @@ -280,7 +319,7 @@ protected function emit(string $event, mixed $payload): void */ protected function forget(): void { - $this->listeners = []; + $this->off(); } /** @@ -394,9 +433,7 @@ protected function capture(array $command, ?int $timeout = null): string { $output = ''; - $this->process($command, function (string $line) use (&$output): void { - $output .= $line."\n"; - }, null, $timeout); + $this->process($command, Process::collector($output), null, $timeout); return $output; } diff --git a/src/Video/Adapter/FFmpeg.php b/src/Video/Adapter/FFmpeg.php index 5949bca..fcf1b81 100644 --- a/src/Video/Adapter/FFmpeg.php +++ b/src/Video/Adapter/FFmpeg.php @@ -13,6 +13,7 @@ use Utopia\Video\Format\X264; use Utopia\Video\Info; use Utopia\Video\Manifest; +use Utopia\Video\Name; use Utopia\Video\Output; use Utopia\Video\Output\Cmaf; use Utopia\Video\Output\Dash; @@ -163,7 +164,7 @@ public function pack(string $dir): Package $builder = Arguments::for( $info, $this->encoding ?? new X264(), - \array_values($this->reps), + $this->reps, $output, $dir, \count($this->inputs), @@ -581,10 +582,10 @@ private static function spoken(string $master, Info $info): void return; } - $languages = \array_values(\array_map( - static fn (array $track): string => $track['language'], + $languages = \array_map( + static fn (array $track): string => Name::language($track['language']), $info->audioTracks, - )); + ); $position = 0; $lines = \explode("\n", $body); @@ -596,14 +597,14 @@ private static function spoken(string $master, Info $info): void $language = $languages[$position++] ?? null; - if ($language === null || $language === 'und' || \str_contains($line, 'LANGUAGE=')) { + if ($language === null || $language === '' || \str_contains($line, 'LANGUAGE=')) { continue; } $lines[$index] = $line.',LANGUAGE="'.$language.'"'; } - \file_put_contents($master, \implode("\n", $lines)); + self::rewrite($master, \implode("\n", $lines)); } /** @@ -643,6 +644,21 @@ private static function independent(string $master): void } \array_splice($lines, $at, 0, '#EXT-X-INDEPENDENT-SEGMENTS'); - \file_put_contents($master, \implode("\n", $lines)); + 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 index 244c70f..27525c9 100644 --- a/src/Video/Adapter/FFprobe.php +++ b/src/Video/Adapter/FFprobe.php @@ -52,6 +52,7 @@ public function read(string $path): Info 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); } @@ -71,15 +72,23 @@ protected function info(array $data): Info $tracks = []; $audioTracks = []; + /** @var array $videoTags */ + $videoTags = []; + foreach ($streams as $stream) { $type = \is_string($stream['codec_type'] ?? null) ? $stream['codec_type'] : Track::DATA; - if ($type === Track::VIDEO && $this->artwork($stream)) { - $cover ??= $this->integer($stream, 'index') ?? 0; - } + // 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 && $video === null && ! $this->artwork($stream)) { - $video = $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) { @@ -89,11 +98,11 @@ protected function info(array $data): Info $audioTracks[] = [ 'codec' => $this->string($stream, 'codec_name') ?? '', - 'language' => $this->tag($stream, 'language') ?? 'und', + 'language' => $tags['language'] ?? 'und', ]; } - $tracks[] = $this->track($stream, $type); + $tracks[] = $this->track($stream, $type, $tags); } $duration = (float) ($this->string($format, 'duration') ?? 0); @@ -126,7 +135,7 @@ protected function info(array $data): Info tags: $this->tags($format), tracks: $tracks, chapters: $this->chapters($data), - rotation: $video !== null ? $this->rotation($video) : null, + rotation: $video !== null ? $this->rotation($video, $videoTags) : null, cover: $cover, raw: $data, ); @@ -134,8 +143,9 @@ protected function info(array $data): Info /** * @param array $stream + * @param array $tags Already read from the stream. */ - private function track(array $stream, string $type): Track + private function track(array $stream, string $type, array $tags): Track { /** @var array $disposition */ $disposition = \is_array($stream['disposition'] ?? null) ? $stream['disposition'] : []; @@ -144,11 +154,11 @@ private function track(array $stream, string $type): Track index: $this->integer($stream, 'index') ?? 0, type: $type, codec: $this->string($stream, 'codec_name'), - language: $this->tag($stream, 'language'), - title: $this->tag($stream, 'title'), + language: $tags['language'] ?? null, + title: $tags['title'] ?? null, default: $this->integer($disposition, 'default') === 1, forced: $this->integer($disposition, 'forced') === 1, - tags: $this->tags($stream), + tags: $tags, ); } @@ -169,9 +179,10 @@ private function chapters(array $data): array continue; } + /** @var array $chapter */ $chapters[] = new Chapter( - start: (float) ($chapter['start_time'] ?? 0), - end: (float) ($chapter['end_time'] ?? 0), + start: (float) ($this->string($chapter, 'start_time') ?? 0), + end: (float) ($this->string($chapter, 'end_time') ?? 0), title: $this->tag($chapter, 'title'), ); } @@ -186,8 +197,14 @@ private function chapters(array $data): array */ private function artwork(array $stream): bool { - return \is_array($stream['disposition'] ?? null) - && (int) ($stream['disposition']['attached_pic'] ?? 0) === 1; + $disposition = $stream['disposition'] ?? null; + + if (! \is_array($disposition)) { + return false; + } + + /** @var array $disposition */ + return $this->integer($disposition, 'attached_pic') === 1; } /** @@ -227,20 +244,26 @@ private function mode(array $stream): ?string /** * @param array $stream + * @param array $tags Already read from the stream. */ - private function rotation(array $stream): ?int + 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) && isset($side['rotation'])) { - return (int) $side['rotation']; + if (! \is_array($side)) { + continue; + } + + /** @var array $side */ + $angle = $this->string($side, 'rotation'); + + if ($angle !== null) { + return (int) $angle; } } } - $tag = $this->tag($stream, 'rotate'); - - return $tag === null ? null : (int) $tag; + return isset($tags['rotate']) ? (int) $tags['rotate'] : null; } /** @@ -265,6 +288,10 @@ private function tags(array $source): array } /** + * 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 diff --git a/src/Video/Adapter/Mock.php b/src/Video/Adapter/Mock.php index cdc3d2f..e5e552d 100644 --- a/src/Video/Adapter/Mock.php +++ b/src/Video/Adapter/Mock.php @@ -10,6 +10,7 @@ use Utopia\Video\Exception\Unsupported; use Utopia\Video\Info; use Utopia\Video\Manifest; +use Utopia\Video\Output; use Utopia\Video\Package; use Utopia\Video\Progress; use Utopia\Video\Segment; @@ -102,11 +103,12 @@ public function encode(string $path): string } $this->directory(\dirname($path)); - $this->progress(); - - \file_put_contents($path, 'mock encode of '.\basename($source)); try { + $this->progress(); + + \file_put_contents($path, 'mock encode of '.\basename($source)); + $path = $this->wrote($path); $this->reportSuccess(self::NAME.': encoded '.$path); @@ -133,11 +135,26 @@ public function pack(string $dir): Package $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->inputs = []; + } + } + + /** + * 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 (\array_values($this->reps) as $position => $rep) { + foreach ($this->reps as $position => $rep) { $segments = []; foreach ([0, 1] as $number) { @@ -173,7 +190,6 @@ public function pack(string $dir): Package $manifests[] = new Manifest(Manifest::HLS, $master, true); } - $this->inputs = []; $this->reportSuccess(self::NAME.': packed '.$dir); return new Package( @@ -187,6 +203,11 @@ public function pack(string $dir): Package 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'); @@ -201,11 +222,22 @@ public function tile(string $path, string $dir, ?Tile $options = null): Spritesh { $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 / \max(1, $this->height))); + $height = (int) \round($width / ($this->width / $this->height)); $file = $options->base().'1.jpg'; \file_put_contents($dir.'/'.$file, 'mock sheet'); diff --git a/src/Video/Arguments.php b/src/Video/Arguments.php index ce8e0d4..83f9049 100644 --- a/src/Video/Arguments.php +++ b/src/Video/Arguments.php @@ -4,7 +4,9 @@ namespace Utopia\Video; +use Utopia\Video\Exception\Input; use Utopia\Video\Exception\Unsupported; +use Utopia\Video\Format\Copy; /** * Builds the ffmpeg arguments for one packaging job. @@ -37,6 +39,16 @@ public function __construct( throw new Unsupported('At least one representation is required'); } + $names = []; + + foreach ($reps as $rep) { + if (isset($names[$rep->name])) { + throw new Input('Representation name "'.$rep->name.'" is used more than once'); + } + + $names[$rep->name] = true; + } + if (! \in_array($output->type(), $format->supports(), true)) { throw new Unsupported( \strtoupper($output->type()).' cannot carry '.$format->codec() @@ -44,6 +56,13 @@ public function __construct( ); } + if ($format instanceof Copy) { + throw new Unsupported( + 'Stream copy cannot build an adaptive package: every video representation ' + .'is filtered to its requested size, and filtering cannot be combined with codec copy', + ); + } + $interval = $format->interval(); // A cut can only land on a keyframe, so keyframes further apart than a @@ -136,7 +155,7 @@ protected function sound(): array $tracks[] = [ 'index' => $index, - 'language' => $language === 'und' ? '' : $language, + 'language' => Name::language($language), ]; } diff --git a/src/Video/Encoder.php b/src/Video/Encoder.php index eabb4ff..7cd13c8 100644 --- a/src/Video/Encoder.php +++ b/src/Video/Encoder.php @@ -32,6 +32,14 @@ class Encoder protected readonly Probe $prober; + /** + * Registered on this object rather than on one job, so that open() can drop + * the adapter's listeners without dropping the caller's. + * + * @var array> + */ + protected array $listeners = []; + /** * @param Reporter|null $reporter Where status lines go; null keeps them on * the terminal. @@ -97,7 +105,8 @@ public function tile(string $path, string $dir, ?Tile $options = null): Spritesh } /** - * Start a job. Anything configured by a previous one is discarded. + * Start a job. Anything configured by a previous one is discarded, listeners + * excepted: those belong to this object and outlive the job. See off(). */ public function open(string $path): static { @@ -106,10 +115,19 @@ public function open(string $path): static } // Passed straight through: the adapter's own open() makes the same - // promise about dropping the last job's format, rungs and listeners, so - // there is nothing worth buffering here. + // promise about dropping the last job's format and rungs, so there is + // nothing worth buffering here. $this->adapter->open($path); + // Listeners are the exception. The adapter drops its own on open(), so + // this object's are handed over again afterwards; without that, on() + // before open() would read perfectly and be silently discarded. + foreach ($this->listeners as $event => $listeners) { + foreach ($listeners as $listener) { + $this->adapter->on($event, $listener); + } + } + return $this; } @@ -128,15 +146,48 @@ public function add(Representation ...$representations): static } /** + * Register a listener for PROGRESS or LOG. + * + * Order does not matter: a listener registered before open() survives it and + * hears the job that follows. + * * @param callable $listener */ public function on(string $event, callable $listener): static { + $this->listeners[$event][] = $listener; $this->adapter->on($event, $listener); return $this; } + /** + * Drop the listeners for one event, or every listener when given nothing. + * + * Listeners outlive a job, so this is how a reused Encoder stops reporting + * to the last job's listener before it registers the next one's. + * + * A backend that extends Adapter is detached immediately. One that only + * implements Adapter\Encoder has no off() to call — Observable declares just + * on(), so that a backend written elsewhere is not asked for more — and it + * lets go of its copy at the next open() instead. + */ + public function off(?string $event = null): static + { + if ($event === null) { + $this->listeners = []; + } else { + unset($this->listeners[$event]); + } + + // The same test the constructor makes before it pushes a probe. + if ($this->adapter instanceof Adapter) { + $this->adapter->off($event); + } + + return $this; + } + /** * Encode to a single file and return the path written. */ diff --git a/src/Video/Exception/Runtime.php b/src/Video/Exception/Runtime.php index 08c7229..6124749 100644 --- a/src/Video/Exception/Runtime.php +++ b/src/Video/Exception/Runtime.php @@ -4,6 +4,7 @@ namespace Utopia\Video\Exception; +use Throwable; use Utopia\Video\Exception; /** @@ -21,8 +22,9 @@ public function __construct( private readonly array $command = [], private readonly string $output = '', int $code = 0, + ?Throwable $previous = null, ) { - parent::__construct($message, $code); + parent::__construct($message, $code, $previous); } /** diff --git a/src/Video/Format.php b/src/Video/Format.php index 5e61831..b43a7c4 100644 --- a/src/Video/Format.php +++ b/src/Video/Format.php @@ -13,6 +13,10 @@ * job that was not told which cadence to use takes the segment length, so the * two cannot silently disagree. Bitrate deliberately lives on the representation * instead, because it varies per rung while the codec choice does not. + * + * Immutable: every setter returns a modified copy and leaves the receiver + * untouched, so one instance can be shared across jobs — and across coroutines + * — without one caller's settings bleeding into another's. */ abstract class Format { @@ -79,9 +83,10 @@ public function sound(): string */ public function crf(int $crf): static { - $this->crf = $crf; + $copy = clone $this; + $copy->crf = $crf; - return $this; + return $copy; } /** @@ -89,9 +94,10 @@ public function crf(int $crf): static */ public function bframes(int $count): static { - $this->bframes = $count; + $copy = clone $this; + $copy->bframes = $count; - return $this; + return $copy; } /** @@ -103,9 +109,10 @@ public function bframes(int $count): static */ public function keyframe(float $seconds): static { - $this->keyframe = $seconds; + $copy = clone $this; + $copy->keyframe = $seconds; - return $this; + return $copy; } /** @@ -115,9 +122,10 @@ public function keyframe(float $seconds): static */ public function params(array $params): static { - $this->params = $params; + $copy = clone $this; + $copy->params = $params; - return $this; + return $copy; } public function interval(): ?float diff --git a/src/Video/Format/Copy.php b/src/Video/Format/Copy.php index 2e39942..3accb3b 100644 --- a/src/Video/Format/Copy.php +++ b/src/Video/Format/Copy.php @@ -31,6 +31,10 @@ public function defaults(): array /** * Quality knobs are meaningless without an encoder, so they are ignored. + * + * The codecs themselves are not: a caller who named one in the constructor + * gets it, and codec() reports the same thing this writes. Hardcoding "copy" + * here would have made that pair disagree. */ public function build(bool $video = true, bool $audio = true, ?float $cadence = null): array { @@ -38,12 +42,12 @@ public function build(bool $video = true, bool $audio = true, ?float $cadence = if ($video) { $args[] = '-c:v'; - $args[] = 'copy'; + $args[] = $this->video; } if ($audio) { $args[] = '-c:a'; - $args[] = 'copy'; + $args[] = $this->audio; } foreach ($this->params as $param) { diff --git a/src/Video/Name.php b/src/Video/Name.php index e16d0e9..29c7647 100644 --- a/src/Video/Name.php +++ b/src/Video/Name.php @@ -25,6 +25,12 @@ final class Name /** A label carrying an extension, such as master.m3u8. */ private const FILE = '/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9]+)+$/'; + /** A plain filename carrying FFmpeg's DASH placeholders. */ + private const TEMPLATE = '/^[A-Za-z0-9_.%$-]+$/'; + + /** A conservative BCP-47 language tag, safe in a muxer list and playlist. */ + private const LANGUAGE = '/^[A-Za-z0-9]{1,8}(?:-[A-Za-z0-9]{1,8})*$/'; + /** * @throws Input */ @@ -54,4 +60,48 @@ public static function file(string $value, string $what): string return $value; } + + /** + * A DASH segment filename template. It may carry the placeholders FFmpeg + * understands, but never a directory component. + * + * @throws Input + */ + public static function template(string $value, string $what): string + { + $remaining = \preg_replace( + '/\$(?:RepresentationID|Number(?:%0?\d*d)?|Bandwidth(?:%0?\d*d)?|' + .'Time(?:%0?\d*d)?|SubNumber(?:%0?\d*d)?|ext)\$|\$\$/', + '', + $value, + ); + + if ( + $value === '' + || \preg_match(self::TEMPLATE, $value) !== 1 + || $remaining === null + || \str_contains($remaining, '$') + || $value === '.' + || $value === '..' + ) { + throw new Input( + $what.' "'.$value.'" is not usable as a DASH filename template; ' + .'expected a plain filename with supported $...$ placeholders', + ); + } + + return $value; + } + + /** + * Optional metadata is omitted when it cannot safely be represented. + */ + public static function language(string $value): string + { + if ($value === '' || \strcasecmp($value, 'und') === 0) { + return ''; + } + + return \preg_match(self::LANGUAGE, $value) === 1 ? $value : ''; + } } diff --git a/src/Video/Output.php b/src/Video/Output.php index d0f9774..602e55f 100644 --- a/src/Video/Output.php +++ b/src/Video/Output.php @@ -8,8 +8,9 @@ * How a package should be laid out on disk. * * These objects only ever carry what the caller asked for. Nothing discovered - * about the source is written back into them, so one instance can be reused - * across as many jobs as you like. + * about the source is written back into them, and every setter returns a + * modified copy rather than touching the receiver, so one instance can be + * shared across as many jobs — and coroutines — as you like. */ abstract class Output { @@ -40,9 +41,10 @@ abstract public function type(): string; */ public function segment(float $seconds): static { - $this->segment = $seconds; + $copy = clone $this; + $copy->segment = $seconds; - return $this; + return $copy; } /** @@ -53,9 +55,10 @@ public function segment(float $seconds): static */ public function manifests(bool $write): static { - $this->manifests = $write; + $copy = clone $this; + $copy->manifests = $write; - return $this; + return $copy; } /** @@ -63,9 +66,12 @@ public function manifests(bool $write): static */ public function name(string $base): static { - $this->name = Name::label($base, 'Output name'); + $name = Name::label($base, 'Output name'); - return $this; + $copy = clone $this; + $copy->name = $name; + + return $copy; } /** @@ -75,9 +81,10 @@ public function name(string $base): static */ public function params(array $params): static { - $this->params = $params; + $copy = clone $this; + $copy->params = $params; - return $this; + return $copy; } public function duration(): float diff --git a/src/Video/Output/Cmaf.php b/src/Video/Output/Cmaf.php index e701703..3827115 100644 --- a/src/Video/Output/Cmaf.php +++ b/src/Video/Output/Cmaf.php @@ -36,23 +36,16 @@ public function type(): string public function master(string $filename): static { - $this->master = Name::file($filename, 'Master playlist'); + $master = Name::file($filename, 'Master playlist'); - return $this; + $copy = clone $this; + $copy->master = $master; + + return $copy; } public function masterFile(): string { return $this->master; } - - public function initPattern(): string - { - return $this->initName ?? $this->name.'_init_$RepresentationID$.$ext$'; - } - - public function mediaPattern(): string - { - return $this->mediaName ?? $this->name.'_chunk_$RepresentationID$_$Number%05d$.$ext$'; - } } diff --git a/src/Video/Output/Dash.php b/src/Video/Output/Dash.php index 6aee1e6..f02e49f 100644 --- a/src/Video/Output/Dash.php +++ b/src/Video/Output/Dash.php @@ -39,9 +39,10 @@ public function type(): string */ public function template(bool $enabled): static { - $this->template = $enabled; + $copy = clone $this; + $copy->template = $enabled; - return $this; + return $copy; } /** @@ -49,16 +50,20 @@ public function template(bool $enabled): static */ public function timeline(bool $enabled): static { - $this->timeline = $enabled; + $copy = clone $this; + $copy->timeline = $enabled; - return $this; + return $copy; } public function manifest(string $filename): static { - $this->manifest = Name::file($filename, 'Manifest'); + $manifest = Name::file($filename, 'Manifest'); - return $this; + $copy = clone $this; + $copy->manifest = $manifest; + + return $copy; } /** @@ -66,9 +71,12 @@ public function manifest(string $filename): static */ public function init(string $pattern): static { - $this->initName = $pattern; + $pattern = Name::template($pattern, 'Initialisation segment pattern'); + + $copy = clone $this; + $copy->initName = $pattern; - return $this; + return $copy; } /** @@ -76,9 +84,12 @@ public function init(string $pattern): static */ public function media(string $pattern): static { - $this->mediaName = $pattern; + $pattern = Name::template($pattern, 'Media segment pattern'); + + $copy = clone $this; + $copy->mediaName = $pattern; - return $this; + return $copy; } /** @@ -86,9 +97,10 @@ public function media(string $pattern): static */ public function sets(string $definition): static { - $this->sets = $definition; + $copy = clone $this; + $copy->sets = $definition; - return $this; + return $copy; } public function templated(): bool diff --git a/src/Video/Output/Hls.php b/src/Video/Output/Hls.php index f11784b..58544ea 100644 --- a/src/Video/Output/Hls.php +++ b/src/Video/Output/Hls.php @@ -4,6 +4,7 @@ namespace Utopia\Video\Output; +use Utopia\Video\Exception\Unsupported; use Utopia\Video\Name; use Utopia\Video\Output; @@ -16,6 +17,13 @@ final class Hls extends Output public const FMP4 = 'fmp4'; + /** + * Every container the hls muxer can segment into. + * + * @var list + */ + public const CONTAINERS = [self::MPEGTS, self::FMP4]; + private string $segments = self::MPEGTS; private string $init = 'init.mp4'; @@ -34,12 +42,25 @@ public function type(): string /** * Transport stream segments, or fragmented MP4 for CMAF compatible output. + * + * Rejected here rather than by the muxer: a container it does not know is a + * typo, and a typo is worth catching before a ladder has been encoded. + * + * @throws Unsupported */ public function segments(string $type): static { - $this->segments = $type; + if (! \in_array($type, self::CONTAINERS, true)) { + throw new Unsupported( + 'No HLS segment container named "'.$type.'"; expected one of ' + .\implode(', ', self::CONTAINERS), + ); + } - return $this; + $copy = clone $this; + $copy->segments = $type; + + return $copy; } /** @@ -47,16 +68,22 @@ public function segments(string $type): static */ public function init(string $filename): static { - $this->init = Name::file($filename, 'Initialisation segment'); + $init = Name::file($filename, 'Initialisation segment'); + + $copy = clone $this; + $copy->init = $init; - return $this; + return $copy; } public function master(string $filename): static { - $this->master = Name::file($filename, 'Master playlist'); + $master = Name::file($filename, 'Master playlist'); + + $copy = clone $this; + $copy->master = $master; - return $this; + return $copy; } /** @@ -64,9 +91,10 @@ public function master(string $filename): static */ public function url(string $url): static { - $this->url = $url; + $copy = clone $this; + $copy->url = $url; - return $this; + return $copy; } /** @@ -74,9 +102,10 @@ public function url(string $url): static */ public function flags(array $flags): static { - $this->flags = $flags; + $copy = clone $this; + $copy->flags = $flags; - return $this; + return $copy; } public function fragmented(): bool diff --git a/src/Video/Packager.php b/src/Video/Packager.php index 2eae271..313250e 100644 --- a/src/Video/Packager.php +++ b/src/Video/Packager.php @@ -13,6 +13,8 @@ use Utopia\Video\Exception\Input; use Utopia\Video\Exception\Runtime; use Utopia\Video\Exception\Unsupported; +use Utopia\Video\Format\Copy; +use Utopia\Video\Format\X264; /** * Turns a source into an adaptive ladder: segments plus the manifests that @@ -115,7 +117,8 @@ public function valid(string $path): bool } /** - * Start a job. Anything configured by a previous one is discarded. + * Start a job. Anything configured by a previous one is discarded, listeners + * excepted: those belong to this object and outlive the job. See off(). */ public function open(string $path): static { @@ -128,10 +131,10 @@ public function open(string $path): static $this->target = null; $this->reps = []; - // Listeners belong to a job too, so a reused Packager does not report the - // previous job's listeners alongside this one's. The adapters make the - // same promise about open(). - $this->listeners = []; + // Listeners are deliberately kept. They were registered on this object, + // not on the job, and clearing them here made on() before open() read + // perfectly and do nothing. Adapters still drop theirs on open(), which + // is why attach() re-registers at the point the work starts. return $this; } @@ -160,6 +163,11 @@ public function output(Output $output): static } /** + * Register a listener for PROGRESS or LOG. + * + * Order does not matter: a listener registered before open() survives it and + * hears the job that follows. + * * @param callable $listener */ public function on(string $event, callable $listener): static @@ -169,6 +177,23 @@ public function on(string $event, callable $listener): static return $this; } + /** + * Drop the listeners for one event, or every listener when given nothing. + * + * Listeners outlive a job, so this is how a reused Packager stops reporting + * to the last job's listener before it registers the next one's. + */ + public function off(?string $event = null): static + { + if ($event === null) { + $this->listeners = []; + } else { + unset($this->listeners[$event]); + } + + return $this; + } + /** * Encode and package an adaptive ladder. */ @@ -184,6 +209,32 @@ public function pack(string $dir): Package throw new Unsupported('At least one representation is required'); } + $names = []; + + foreach ($this->reps as $rep) { + if (isset($names[$rep->name])) { + throw new Input('Representation name "'.$rep->name.'" is used more than once'); + } + + $names[$rep->name] = true; + } + + if ($this->encoding instanceof Copy) { + throw new Unsupported( + 'Stream copy cannot build an adaptive package: every video representation ' + .'is filtered to its requested size, and filtering cannot be combined with codec copy', + ); + } + + $interval = $this->encoding?->interval(); + + if ($interval !== null && $interval > $this->target->duration()) { + throw new Unsupported( + 'A keyframe every '.$interval.'s cannot cut '.$this->target->duration() + .'s segments; the keyframe interval has to be the segment length or a fraction of it', + ); + } + if ($this->adapter instanceof EncoderAdapter) { return $this->fused($this->adapter, $source, $this->target, $dir); } @@ -229,15 +280,16 @@ private function staged(string $source, Output $output, string $dir): Package $total = \count($this->reps); $files = []; + $format = $this->encoding ?? new X264(); + + if ($format->interval() === null) { + $format = $format->keyframe($output->duration()); + } try { foreach ($this->reps as $position => $rep) { $encoder = $this->encoder->open($source); - - if ($this->encoding !== null) { - $encoder->format($this->encoding); - } - + $encoder->format($format); $encoder->add($rep); // Encoding is the slow part, so it owns most of the progress bar. diff --git a/src/Video/Parser/Mpd.php b/src/Video/Parser/Mpd.php index 3ebb885..e3b1f93 100644 --- a/src/Video/Parser/Mpd.php +++ b/src/Video/Parser/Mpd.php @@ -38,6 +38,11 @@ public static function read(string $path, string $dir): array $duration = self::seconds($root->getAttribute('mediaPresentationDuration')); + // Segment length is declared once, on the root, as an ISO 8601 duration. + // Every variant shares it, so it is read here rather than looked for on + // an AdaptationSet that never carries it. + $target = self::seconds($root->getAttribute('maxSegmentDuration')); + $metadata = [ 'profiles' => $root->getAttribute('profiles'), 'type' => $root->getAttribute('type'), @@ -50,16 +55,15 @@ public static function read(string $path, string $dir): array $variants = []; foreach ($root->getElementsByTagName('AdaptationSet') as $index => $set) { - if (! $set instanceof DOMElement) { - continue; - } - foreach ($set->getElementsByTagName('Representation') as $representation) { - if (! $representation instanceof DOMElement) { - continue; - } - - $variants[] = self::variant($set, $representation, (string) $index, $dir, $duration); + $variants[] = self::variant( + $set, + $representation, + (string) $index, + $dir, + $duration, + $target, + ); } } @@ -72,6 +76,7 @@ private static function variant( string $group, string $dir, float $duration, + float $target, ): Variant { $id = $representation->getAttribute('id'); $id = $id !== '' ? $id : $group; @@ -99,6 +104,15 @@ private static function variant( $bandwidth, ); + $media = \array_filter( + $segments, + static fn (Segment $segment): bool => ! $segment->init, + ); + + if ($media === []) { + throw new Runtime('Representation "'.$id.'" contains no media segments'); + } + $language = $set->getAttribute('lang'); return new Variant( @@ -114,7 +128,7 @@ private static function variant( language: $language !== '' && $language !== 'und' ? $language : null, timescale: $timescale, startNumber: $start, - target: (float) $set->getAttribute('maxSegmentDuration'), + target: $target > 0.0 ? $target : self::longest($segments), segments: $segments, ); } @@ -130,16 +144,22 @@ private static function segments( float $duration, int $bandwidth, ): array { - $list = self::child($representation, 'SegmentList') ?? self::child($set, 'SegmentList'); - - if ($list !== null) { - return self::listed($list, $id, $dir); - } + // A Representation describes its own segments when it says anything at + // all, and only falls back to the AdaptationSet when it says nothing. + // Both of its own elements are therefore checked before either of the + // set's, or a rung carrying a template would be handed the set's list. + foreach ([$representation, $set] as $element) { + $list = self::child($element, 'SegmentList'); + + if ($list !== null) { + return self::listed($list, $id, $dir); + } - $template = self::child($representation, 'SegmentTemplate') ?? self::child($set, 'SegmentTemplate'); + $template = self::child($element, 'SegmentTemplate'); - if ($template !== null) { - return self::templated($template, $id, $dir, $duration, $bandwidth); + if ($template !== null) { + return self::templated($template, $id, $dir, $duration, $bandwidth); + } } return [[], 0, 0]; @@ -160,7 +180,9 @@ private static function listed(DOMElement $list, string $id, string $dir): array ? ((float) $list->getAttribute('duration')) / $timescale : 0.0; - $init = self::child($list, 'Initialization'); + // Scoped to the list itself, which holds nothing but its own segments, + // so reaching past a direct child cannot stray into another rung. + $init = self::child($list, 'Initialization') ?? self::descendant($list, 'Initialization'); if ($init !== null && $init->getAttribute('sourceURL') !== '') { $segments[] = self::segment($id, $dir, $init->getAttribute('sourceURL'), 0.0, true, 0); @@ -169,10 +191,6 @@ private static function listed(DOMElement $list, string $id, string $dir): array $number = $start; foreach ($list->getElementsByTagName('SegmentURL') as $url) { - if (! $url instanceof DOMElement) { - continue; - } - $segments[] = self::segment($id, $dir, $url->getAttribute('media'), $duration, false, $number); $number++; } @@ -212,10 +230,6 @@ private static function templated( $number = $start; foreach ($timeline->getElementsByTagName('S') as $entry) { - if (! $entry instanceof DOMElement) { - continue; - } - $length = ((float) $entry->getAttribute('d')) / $timescale; $repeat = $entry->hasAttribute('r') ? (int) $entry->getAttribute('r') : 0; @@ -272,7 +286,9 @@ public static function expand(string $pattern, string $id, int $number, int $ban foreach (['Number' => $number, 'Bandwidth' => $bandwidth] as $identifier => $value) { $name = \preg_replace_callback( '/\$'.$identifier.'(%0?\d*d)?\$/', - static fn (array $match): string => isset($match[1]) && $match[1] !== '' + // The width group needs at least "%d" to match, so it is either + // absent or usable — there is no empty case to guard against. + static fn (array $match): string => isset($match[1]) ? \sprintf($match[1], $value) : (string) $value, $name, @@ -308,8 +324,31 @@ private static function segment( ); } + /** + * The longest segment in a list, which is what a target duration means. + * + * Used only when the manifest declares no maximum of its own. + * + * @param list $segments + */ + private static function longest(array $segments): float + { + $longest = 0.0; + + foreach ($segments as $segment) { + $longest = \max($longest, $segment->duration); + } + + return $longest; + } + /** * Reads an ISO 8601 duration such as PT1M4.5S. + * + * The date half is accepted as well as the time half, because a manifest is + * free to write PT2.0S as P0Y0M0DT0H0M2.0S and mean the same thing. Years + * and months have no fixed length, so a duration using them is refused + * rather than guessed at. */ public static function seconds(string $value): float { @@ -317,15 +356,36 @@ public static function seconds(string $value): float return 0.0; } - if (\preg_match('/^PT(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?$/', $value, $match) !== 1) { + $pattern = '/^P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?' + .'(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/'; + + if (\preg_match($pattern, $value, $match) !== 1) { + return 0.0; + } + + // A bare P or PT matches, every group absent, and totals zero on its own. + $part = static fn (int $at): float => (float) ($match[$at] ?? 0); + + // Years and months are the two designators with no fixed length in + // seconds, so a duration built from them is refused, not approximated. + if ($part(1) > 0.0 || $part(2) > 0.0) { return 0.0; } - return ((float) ($match[1] ?? 0)) * 3600 - + ((float) ($match[2] ?? 0)) * 60 - + ((float) ($match[3] ?? 0)); + return $part(3) * 604800 + + $part(4) * 86400 + + $part(5) * 3600 + + $part(6) * 60 + + $part(7); } + /** + * The first direct child with this tag. + * + * Direct children only, deliberately. A descendant search run from an + * AdaptationSet walks into its Representations, so a rung that declared + * nothing would be handed whatever a sibling rung declared. + */ private static function child(DOMElement $element, string $tag): ?DOMElement { foreach ($element->childNodes as $node) { @@ -334,9 +394,17 @@ private static function child(DOMElement $element, string $tag): ?DOMElement } } - // Initialization can sit one level down inside a SegmentList. - $nodes = $element->getElementsByTagName($tag); - $first = $nodes->item(0); + return null; + } + + /** + * The first descendant with this tag, at any depth. + * + * Only safe on an element that cannot contain another rung's markup. + */ + private static function descendant(DOMElement $element, string $tag): ?DOMElement + { + $first = $element->getElementsByTagName($tag)->item(0); return $first instanceof DOMElement ? $first : null; } @@ -363,13 +431,19 @@ private static function text(string $value): ?string private static function load(string $path): DOMDocument { - if (! \is_file($path)) { + // Read first, parse second. The libxml error flag below is process + // global, and under a coroutine runtime the file read is where this + // function can be suspended — so the read happens before the flag is + // touched, and nothing between set and restore can yield. + $body = \is_file($path) ? \file_get_contents($path) : false; + + if ($body === false) { throw new Runtime('Unable to read manifest "'.$path.'"'); } $document = new DOMDocument(); $previous = \libxml_use_internal_errors(true); - $loaded = $document->load($path); + $loaded = $body !== '' && $document->loadXML($body); \libxml_clear_errors(); \libxml_use_internal_errors($previous); diff --git a/src/Video/Process.php b/src/Video/Process.php index 00ab977..9535a55 100644 --- a/src/Video/Process.php +++ b/src/Video/Process.php @@ -79,105 +79,128 @@ public static function run( $open = [1 => $pipes[1], 2 => $pipes[2]]; $interrupted = 0; + $closed = false; - while ($open !== []) { - // Checked before the wait rather than after the read, so a command - // that has gone quiet is still stopped on time. - if ($timeout > 0 && (\microtime(true) - $started) > $timeout) { - throw self::halt( - $process, - $open, - $errors, - 'Command "'.$command[0].'" timed out after '.$timeout.'s', - $command, - ); - } + try { + while ($open !== []) { + // Checked before the wait rather than after the read, so a command + // that has gone quiet is still stopped on time. + if ($timeout > 0 && (\microtime(true) - $started) > $timeout) { + $failure = self::halt( + $process, + $open, + $errors, + 'Command "'.$command[0].'" timed out after '.$timeout.'s', + $command, + ); + $closed = true; + + throw $failure; + } + + $read = \array_values($open); + $write = null; + $except = null; + + if (@\stream_select($read, $write, $except, 0, 200000) === false) { + if (++$interrupted <= self::RETRIES) { + continue; + } - $read = \array_values($open); - $write = null; - $except = null; + $failure = self::halt( + $process, + $open, + $errors, + 'Lost contact with "'.$command[0].'" while reading its output', + $command, + ); + $closed = true; - if (@\stream_select($read, $write, $except, 0, 200000) === false) { - if (++$interrupted <= self::RETRIES) { - continue; + throw $failure; } - throw self::halt( - $process, - $open, - $errors, - 'Lost contact with "'.$command[0].'" while reading its output', - $command, - ); - } + $interrupted = 0; - $interrupted = 0; + foreach ($open as $key => $pipe) { + if (! \in_array($pipe, $read, true)) { + continue; + } - foreach ($open as $key => $pipe) { - if (! \in_array($pipe, $read, true)) { - continue; - } + $chunk = \fread($pipe, 8192); - $chunk = \fread($pipe, 8192); + if ($chunk === false || $chunk === '') { + if (\feof($pipe)) { + if ($buffers[$key] !== '') { + self::emit($key, $buffers[$key], $stdout, $stderr); + $buffers[$key] = ''; + } - if ($chunk === false || $chunk === '') { - if (\feof($pipe)) { - if ($buffers[$key] !== '') { - self::emit($key, $buffers[$key], $stdout, $stderr); - $buffers[$key] = ''; + \fclose($pipe); + unset($open[$key]); } - \fclose($pipe); - unset($open[$key]); + continue; } - continue; - } - - if ($key === 2) { - $errors = \substr($errors.$chunk, -self::TAIL); - } + if ($key === 2) { + $errors = \substr($errors.$chunk, -self::TAIL); + } - $buffers[$key] .= $chunk; + $buffers[$key] .= $chunk; - while (($break = \strpos($buffers[$key], "\n")) !== false) { - $line = \substr($buffers[$key], 0, $break); - $buffers[$key] = \substr($buffers[$key], $break + 1); - self::emit($key, $line, $stdout, $stderr); - } + while (($break = \strpos($buffers[$key], "\n")) !== false) { + $line = \substr($buffers[$key], 0, $break); + $buffers[$key] = \substr($buffers[$key], $break + 1); + self::emit($key, $line, $stdout, $stderr); + } - // ffmpeg separates progress blocks with \r when writing to a terminal. - while (($break = \strpos($buffers[$key], "\r")) !== false) { - $line = \substr($buffers[$key], 0, $break); - $buffers[$key] = \substr($buffers[$key], $break + 1); + // ffmpeg separates progress blocks with \r when writing to a terminal. + while (($break = \strpos($buffers[$key], "\r")) !== false) { + $line = \substr($buffers[$key], 0, $break); + $buffers[$key] = \substr($buffers[$key], $break + 1); - if (\trim($line) !== '') { - self::emit($key, $line, $stdout, $stderr); + if (\trim($line) !== '') { + self::emit($key, $line, $stdout, $stderr); + } } - } - // Nothing says a stream has to send line breaks, and a binary - // one never will, so a buffer that has grown past a plausible - // line is handed over as it stands rather than held forever. - if (\strlen($buffers[$key]) >= self::LINE) { - self::emit($key, $buffers[$key], $stdout, $stderr); - $buffers[$key] = ''; + // Nothing says a stream has to send line breaks, and a binary + // one never will, so a buffer that has grown past a plausible + // line is handed over as it stands rather than held forever. + if (\strlen($buffers[$key]) >= self::LINE) { + self::emit($key, $buffers[$key], $stdout, $stderr); + $buffers[$key] = ''; + } } } - } - $status = \proc_close($process); + $status = \proc_close($process); + $closed = true; - if ($status !== 0) { - throw new Runtime( - 'Command "'.$command[0].'" failed with exit code '.$status, - $command, - \trim($errors), - $status, - ); - } + if ($status !== 0) { + throw new Runtime( + 'Command "'.$command[0].'" failed with exit code '.$status, + $command, + \trim($errors), + $status, + ); + } - return \trim($errors); + return \trim($errors); + } finally { + // A user listener is application code and may throw. It must not + // strand the child process merely because control left the read + // loop by a route other than our own timeout/error exceptions. + if (! $closed) { + self::halt( + $process, + $open, + $errors, + 'Command "'.$command[0].'" was interrupted while reading its output', + $command, + ); + } + } } /** @@ -191,13 +214,24 @@ public static function read(array $command, int $timeout = 0): string { $output = ''; - self::run($command, function (string $line) use (&$output): void { - $output .= $line."\n"; - }, null, $timeout); + self::run($command, self::collector($output), null, $timeout); return $output; } + /** + * A line listener that appends everything it is given to a string. + * + * @param string $into Filled in as lines arrive. + * @return callable(string):void + */ + public static function collector(string &$into): callable + { + return static function (string $line) use (&$into): void { + $into .= $line."\n"; + }; + } + /** * Reports whether a binary can be executed at all. */ diff --git a/src/Video/Thumb.php b/src/Video/Thumb.php index 07ee7d3..c59ada4 100644 --- a/src/Video/Thumb.php +++ b/src/Video/Thumb.php @@ -4,8 +4,16 @@ namespace Utopia\Video; +use Utopia\Video\Exception\Input; + /** * How to pick and size a single still frame. + * + * Checked as it is set, the way a Representation checks its own values, so a + * size ffmpeg cannot scale to is refused here rather than deep inside a filter. + * + * Immutable: every setter returns a modified copy and leaves the receiver + * untouched, so one instance can be shared across jobs and coroutines. */ final class Thumb { @@ -21,9 +29,14 @@ final class Thumb */ public function time(?float $seconds): self { - $this->at = $seconds; + if ($seconds !== null && $seconds < 0) { + throw new Input('Thumbnail time cannot be negative, got '.$seconds); + } + + $copy = clone $this; + $copy->at = $seconds; - return $this; + return $copy; } /** @@ -32,19 +45,29 @@ public function time(?float $seconds): self */ public function width(int $pixels): self { - $this->width = $pixels; + if ($pixels !== 0 && $pixels < 2) { + throw new Input('Thumbnail width must be 0 or at least 2 pixels, got '.$pixels); + } - return $this; + $copy = clone $this; + $copy->width = $pixels; + + return $copy; } /** - * Encoder quality scale, where lower is better. + * Encoder quality scale, where lower is better. ffmpeg's -qscale:v runs 1 to 31. */ public function quality(int $quality): self { - $this->quality = $quality; + if ($quality < 1 || $quality > 31) { + throw new Input('Thumbnail quality must be between 1 and 31, got '.$quality); + } + + $copy = clone $this; + $copy->quality = $quality; - return $this; + return $copy; } public function at(): ?float diff --git a/src/Video/Tile.php b/src/Video/Tile.php index 79cdec3..04c09ac 100644 --- a/src/Video/Tile.php +++ b/src/Video/Tile.php @@ -4,8 +4,18 @@ namespace Utopia\Video; +use Utopia\Video\Exception\Input; + /** * How to build sprite sheets for a scrubbing timeline. + * + * Every knob is checked as it is set, the way a Representation checks its own. + * These values become divisors and filter arguments, so a zero or a negative + * reaches ffmpeg as a broken filter or never reaches it at all, and the failure + * lands a long way from the call that caused it. + * + * Immutable: every setter returns a modified copy and leaves the receiver + * untouched, so one instance can be shared across jobs and coroutines. */ final class Tile { @@ -29,38 +39,64 @@ final class Tile */ public function interval(?float $seconds): self { - $this->interval = $seconds; + if ($seconds !== null && $seconds <= 0) { + throw new Input('Sprite interval must be greater than zero, got '.$seconds); + } + + $copy = clone $this; + $copy->interval = $seconds; - return $this; + return $copy; } public function width(int $pixels): self { - $this->width = $pixels; + if ($pixels < 2) { + throw new Input('Sprite width must be at least 2 pixels, got '.$pixels); + } + + $copy = clone $this; + $copy->width = $pixels; - return $this; + return $copy; } public function grid(int $columns, int $rows): self { - $this->columns = $columns; - $this->rows = $rows; + if ($columns < 1 || $rows < 1) { + throw new Input('Sprite grid must be at least 1x1, got '.$columns.'x'.$rows); + } - return $this; + $copy = clone $this; + $copy->columns = $columns; + $copy->rows = $rows; + + return $copy; } + /** + * Encoder quality scale, where lower is better. ffmpeg's -qscale:v runs 1 to 31. + */ public function quality(int $quality): self { - $this->quality = $quality; + if ($quality < 1 || $quality > 31) { + throw new Input('Sprite quality must be between 1 and 31, got '.$quality); + } + + $copy = clone $this; + $copy->quality = $quality; - return $this; + return $copy; } public function name(string $base): self { - $this->name = Name::label($base, 'Sprite sheet name'); + $name = Name::label($base, 'Sprite sheet name'); + + $copy = clone $this; + $copy->name = $name; - return $this; + return $copy; } /** @@ -68,9 +104,10 @@ public function name(string $base): self */ public function vtt(bool $write): self { - $this->vtt = $write; + $copy = clone $this; + $copy->vtt = $write; - return $this; + return $copy; } /** diff --git a/tests/Video/Adapters.php b/tests/Video/Adapters.php index f88d52e..fb8fe3f 100644 --- a/tests/Video/Adapters.php +++ b/tests/Video/Adapters.php @@ -223,36 +223,123 @@ public function testReportsProgressThatOnlyClimbs(): void } /** - * A finished job must not leak into the next one, on the facade or inside - * the adapter it delegates to. + * A listener registered once is heard by every job, the same number of times + * each — the adapter must not accumulate a fresh copy on each pack(). */ public function testReusingOnePackagerDoesNotRepeatEvents(): void { $packager = $this->packager(); - // A counter per round, so the two closures cannot share a variable and - // hide the very accumulation this is looking for. - $counts = [0, 0]; - - foreach ([0, 1] as $round) { + // A counter per round, so accumulation inside the adapter shows up as + // one round's tally outgrowing the other's rather than hiding in a sum. + $counts = [0, 0, 0]; + $round = 0; + + // LOG rather than PROGRESS because it is deterministic: a backend emits + // the same number of lines for the same job twice over, where progress + // blocks arrive on a timer and vary run to run. A backend that says + // nothing at the default level counts zero every round and passes here + // trivially; the Mock suite is where these tallies are non-zero. + $packager->on(Packager::LOG, function () use (&$counts, &$round): void { + $counts[$round]++; + }); + + for ($round = 0; $round < 3; $round++) { $packager ->open($this->source()) ->format($this->format()) ->add(new Representation(320, 240, 400, 64)) ->output((new Hls())->segment(2)) - ->on(Packager::LOG, function () use (&$counts, $round): void { - $counts[$round]++; - }) ->pack($this->dir.'/round'.$round); } + // Accumulation shows up as 1, 2, 3 rather than as one round being wrong. $this->assertSame( - $counts[1], - $counts[0], - 'the first job\'s listener should not still be firing during the second', + [$counts[0], $counts[0], $counts[0]], + $counts, + 'the listener should fire the same number of times for each job', ); } + /** + * on() reads naturally before open(), so it has to work there. + */ + public function testAListenerRegisteredBeforeOpenIsStillHeard(): void + { + $seen = 0; + + $this->packager() + ->on(Packager::PROGRESS, function () use (&$seen): void { + $seen++; + }) + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->dir); + + $this->assertGreaterThan(0, $seen); + } + + public function testAnEncoderListenerRegisteredBeforeOpenIsStillHeard(): void + { + $seen = 0; + + $this->encoder() + ->on(Encoder::PROGRESS, function () use (&$seen): void { + $seen++; + }) + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/early.mp4'); + + $this->assertGreaterThan(0, $seen); + } + + /** + * off() is what a reused facade uses to stop reporting to the last job's + * listener, now that listeners outlive a job. + */ + public function testListenersCanBeDropped(): void + { + $seen = 0; + + $packager = $this->packager()->on(Packager::PROGRESS, function () use (&$seen): void { + $seen++; + }); + + $packager->off(Packager::PROGRESS); + + $packager + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->output((new Hls())->segment(2)) + ->pack($this->dir); + + $this->assertSame(0, $seen); + } + + public function testEncoderListenersCanBeDropped(): void + { + $seen = 0; + + $encoder = $this->encoder()->on(Encoder::PROGRESS, function () use (&$seen): void { + $seen++; + }); + + $encoder->off(); + + $encoder + ->open($this->source()) + ->format($this->format()) + ->add(new Representation(320, 240, 400, 64)) + ->encode($this->dir.'/dropped.mp4'); + + $this->assertSame(0, $seen); + } + public function testOpeningAgainForgetsThePreviousJob(): void { $packager = $this->packager() diff --git a/tests/Video/E2E/CoroutineTest.php b/tests/Video/E2E/CoroutineTest.php new file mode 100644 index 0000000..add12ce --- /dev/null +++ b/tests/Video/E2E/CoroutineTest.php @@ -0,0 +1,72 @@ +markTestSkipped('ext-swoole is required'); + } + + parent::setUp(); + } + + public function testConcurrentCoroutinesEachWithTheirOwnEncoderDoNotInterfere(): void + { + \Swoole\Runtime::enableCoroutine(SWOOLE_HOOK_ALL); + + $source = self::video(); + $thumb = (new Thumb())->width(160); + $written = []; + $failed = []; + + \Swoole\Coroutine\run(function () use ($source, $thumb, &$written, &$failed): void { + foreach ([1.0, 3.0, 5.0] as $index => $second) { + \Swoole\Coroutine::create(function () use ($source, $thumb, $second, $index, &$written, &$failed): void { + try { + // One facade per coroutine; the Thumb is shared and + // configured per job without touching the original. + $encoder = new Encoder(reporter: new Silent()); + + $written[$index] = $encoder->grab( + $source, + $this->dir.'/still-'.$index.'.jpg', + $thumb->time($second), + ); + } catch (\Throwable $e) { + $failed[$index] = $e->getMessage(); + } + }); + } + }); + + $this->assertSame([], $failed); + $this->assertCount(3, $written); + + foreach ($written as $path) { + $this->assertWritten($path); + } + + // The shared Thumb was never written into by any of the three jobs. + $this->assertNull($thumb->at()); + } +} diff --git a/tests/Video/Samples.php b/tests/Video/Samples.php index 0fcd1bc..835329d 100644 --- a/tests/Video/Samples.php +++ b/tests/Video/Samples.php @@ -186,6 +186,7 @@ private static function master(): string */ private static function encoders(): array { + /** @var list|null $found */ static $found = null; if ($found !== null) { diff --git a/tests/Video/Unit/AdapterTest.php b/tests/Video/Unit/AdapterTest.php index 0991625..6aa6ab4 100644 --- a/tests/Video/Unit/AdapterTest.php +++ b/tests/Video/Unit/AdapterTest.php @@ -125,11 +125,14 @@ public function testOneAdapterCanServeSeveralCapabilities(): void */ public function testABackendDeclaresOnlyWhatItCanDo(): void { - $ffprobe = new FFprobe(); - - $this->assertInstanceOf(Probe::class, $ffprobe); - $this->assertNotInstanceOf(EncoderAdapter::class, $ffprobe); - $this->assertNotInstanceOf(PackagerAdapter::class, $ffprobe); + // Asked of the class rather than of an instance: the claim is about what + // FFprobe declares, and an instanceof against a known type is a claim + // about nothing that analysis will happily prove for you. + $implements = \class_implements(FFprobe::class); + + $this->assertContains(Probe::class, $implements); + $this->assertNotContains(EncoderAdapter::class, $implements); + $this->assertNotContains(PackagerAdapter::class, $implements); } /** diff --git a/tests/Video/Unit/ArgumentsHlsTest.php b/tests/Video/Unit/ArgumentsHlsTest.php index 305bca4..1c6b517 100644 --- a/tests/Video/Unit/ArgumentsHlsTest.php +++ b/tests/Video/Unit/ArgumentsHlsTest.php @@ -6,7 +6,9 @@ use PHPUnit\Framework\TestCase; use Utopia\Video\Arguments\Hls as Arguments; +use Utopia\Video\Exception\Input; use Utopia\Video\Exception\Unsupported; +use Utopia\Video\Format\Copy; use Utopia\Video\Format\VP9; use Utopia\Video\Format\X264; use Utopia\Video\Info; @@ -159,6 +161,21 @@ public function testLanguageTaggedTracksBecomeSeparateAudioRenditions(): void $this->assertStringNotContainsString('a:1,agroup:audio,language:spa,name:audio_1,default:yes', $argv); } + public function testUnsafeLanguageMetadataIsOmittedWithoutDroppingTheTrack(): void + { + $info = $this->info(audioTracks: [ + ['codec' => 'aac', 'language' => 'eng,default:yes'], + ['codec' => 'aac', 'language' => 'en-US'], + ]); + + $argv = $this->build($info, [new Representation(1280, 720, 2538, 128)]); + + $this->assertSame(2, \substr_count($argv, '-map 0:a:')); + $this->assertStringNotContainsString('eng,default:yes', $argv); + $this->assertStringContainsString('a:0,agroup:audio,name:audio_0,default:yes', $argv); + $this->assertStringContainsString('a:1,agroup:audio,language:en-US,name:audio_1', $argv); + } + public function testUntaggedAudioBecomesOneAnonymousRendition(): void { $info = $this->info(audioTracks: [['codec' => 'aac', 'language' => 'und']]); @@ -331,6 +348,37 @@ public function testCodecThatCannotBePackagedIsRejected(): void ); } + public function testStreamCopyIsRejectedBeforeFfmpegRuns(): void + { + $this->expectException(Unsupported::class); + $this->expectExceptionMessage('Stream copy cannot build an adaptive package'); + + new Arguments( + $this->info(), + new Copy(), + [new Representation(1280, 720, 2538)], + new Hls(), + '/out', + ); + } + + public function testRepresentationNamesHaveToBeUniqueWithinALadder(): void + { + $this->expectException(Input::class); + $this->expectExceptionMessage('Representation name "360p" is used more than once'); + + new Arguments( + $this->info(), + new X264(), + [ + new Representation(640, 360, 800), + new Representation(480, 360, 500), + ], + new Hls(), + '/out', + ); + } + public function testLadderCannotBeEmpty(): void { $this->expectException(Unsupported::class); diff --git a/tests/Video/Unit/EncoderTest.php b/tests/Video/Unit/EncoderTest.php index 3f2a08c..87b0ecc 100644 --- a/tests/Video/Unit/EncoderTest.php +++ b/tests/Video/Unit/EncoderTest.php @@ -8,6 +8,7 @@ use Utopia\Video\Encoder; use Utopia\Video\Exception\Input; use Utopia\Video\Format\X264; +use Utopia\Video\Progress; use Utopia\Video\Representation; class EncoderTest extends TestCase @@ -86,7 +87,8 @@ public function testListenersReachTheAdapter(): void $this->encoder($adapter) ->open($this->file) ->add(new Representation(1280, 720, 2538)) - ->on(Encoder::PROGRESS, function ($progress) use (&$seen): void { + ->on(Encoder::PROGRESS, function (mixed $progress) use (&$seen): void { + $this->assertInstanceOf(Progress::class, $progress); $seen[] = $progress->percent; }) ->encode($this->dir.'/video.mp4'); diff --git a/tests/Video/Unit/FormatTest.php b/tests/Video/Unit/FormatTest.php index 2eac9e1..f848b9b 100644 --- a/tests/Video/Unit/FormatTest.php +++ b/tests/Video/Unit/FormatTest.php @@ -132,4 +132,22 @@ public function testCodecsCanBeOverridden(): void $this->assertSame('h264_videotoolbox', $format->codec()); $this->assertSame('libfdk_aac', $format->sound()); } + + /** + * codec() and build() have to agree, or a caller inspecting the format is + * told one thing while ffmpeg is told another. + */ + public function testCopyEmitsTheCodecsItReports(): void + { + $copy = new Copy(); + + $this->assertSame('copy', $copy->codec()); + $this->assertSame(['-c:v', 'copy', '-c:a', 'copy'], $copy->build()); + + $named = new Copy('libx264', 'libopus'); + + $this->assertSame('libx264', $named->codec()); + $this->assertSame('libopus', $named->sound()); + $this->assertSame(['-c:v', 'libx264', '-c:a', 'libopus'], $named->build()); + } } diff --git a/tests/Video/Unit/ImmutabilityTest.php b/tests/Video/Unit/ImmutabilityTest.php new file mode 100644 index 0000000..c173ae0 --- /dev/null +++ b/tests/Video/Unit/ImmutabilityTest.php @@ -0,0 +1,284 @@ + + */ + public static function setters(): array + { + return [ + 'Format::crf' => [ + fn () => new X264(), + fn (X264 $it) => $it->crf(18), + fn (X264 $it) => \in_array('-crf', $it->build(), true), + false, + true, + ], + 'Format::bframes' => [ + fn () => new X264(), + fn (X264 $it) => $it->bframes(3), + fn (X264 $it) => \in_array('-bf', $it->build(), true), + false, + true, + ], + 'Format::keyframe' => [ + fn () => new X264(), + fn (X264 $it) => $it->keyframe(2.0), + fn (X264 $it) => $it->interval(), + null, + 2.0, + ], + 'Format::params' => [ + fn () => new X264(), + fn (X264 $it) => $it->params(['-movflags', '+faststart']), + fn (X264 $it) => \in_array('-movflags', $it->build(), true), + false, + true, + ], + 'Output::segment' => [ + fn () => new Hls(), + fn (Hls $it) => $it->segment(2.0), + fn (Hls $it) => $it->duration(), + 6.0, + 2.0, + ], + 'Output::manifests' => [ + fn () => new Hls(), + fn (Hls $it) => $it->manifests(false), + fn (Hls $it) => $it->keeps(), + true, + false, + ], + 'Output::name' => [ + fn () => new Hls(), + fn (Hls $it) => $it->name('ladder'), + fn (Hls $it) => $it->base(), + 'stream', + 'ladder', + ], + 'Output::params' => [ + fn () => new Hls(), + fn (Hls $it) => $it->params(['-max_muxing_queue_size', '1024']), + fn (Hls $it) => $it->extra(), + [], + ['-max_muxing_queue_size', '1024'], + ], + 'Hls::segments' => [ + fn () => new Hls(), + fn (Hls $it) => $it->segments(Hls::FMP4), + fn (Hls $it) => $it->container(), + Hls::MPEGTS, + Hls::FMP4, + ], + 'Hls::init' => [ + fn () => new Hls(), + fn (Hls $it) => $it->init('start.mp4'), + fn (Hls $it) => $it->initFile(), + 'init.mp4', + 'start.mp4', + ], + 'Hls::master' => [ + fn () => new Hls(), + fn (Hls $it) => $it->master('index.m3u8'), + fn (Hls $it) => $it->masterFile(), + 'master.m3u8', + 'index.m3u8', + ], + 'Hls::url' => [ + fn () => new Hls(), + fn (Hls $it) => $it->url('https://cdn.example/'), + fn (Hls $it) => $it->prefix(), + '', + 'https://cdn.example/', + ], + 'Hls::flags' => [ + fn () => new Hls(), + fn (Hls $it) => $it->flags(['temp_file']), + fn (Hls $it) => $it->hlsFlags(), + ['independent_segments'], + ['temp_file'], + ], + 'Dash::template' => [ + fn () => new Dash(), + fn (Dash $it) => $it->template(false), + fn (Dash $it) => $it->templated(), + true, + false, + ], + 'Dash::timeline' => [ + fn () => new Dash(), + fn (Dash $it) => $it->timeline(false), + fn (Dash $it) => $it->timelined(), + true, + false, + ], + 'Dash::manifest' => [ + fn () => new Dash(), + fn (Dash $it) => $it->manifest('video.mpd'), + fn (Dash $it) => $it->manifestFile(), + 'manifest.mpd', + 'video.mpd', + ], + 'Dash::init' => [ + fn () => new Dash(), + fn (Dash $it) => $it->init('i-$RepresentationID$.$ext$'), + fn (Dash $it) => $it->initPattern(), + 'stream_init_$RepresentationID$.$ext$', + 'i-$RepresentationID$.$ext$', + ], + 'Dash::media' => [ + fn () => new Dash(), + fn (Dash $it) => $it->media('m-$Number$.$ext$'), + fn (Dash $it) => $it->mediaPattern(), + 'stream_chunk_$RepresentationID$_$Number%05d$.$ext$', + 'm-$Number$.$ext$', + ], + 'Dash::sets' => [ + fn () => new Dash(), + fn (Dash $it) => $it->sets('id=0,streams=v'), + fn (Dash $it) => $it->adaptations(1, 1), + 'id=0,streams=v id=1,streams=a', + 'id=0,streams=v', + ], + 'Cmaf::master' => [ + fn () => new Cmaf(), + fn (Cmaf $it) => $it->master('index.m3u8'), + fn (Cmaf $it) => $it->masterFile(), + 'master.m3u8', + 'index.m3u8', + ], + 'Thumb::time' => [ + fn () => new Thumb(), + fn (Thumb $it) => $it->time(12.5), + fn (Thumb $it) => $it->at(), + null, + 12.5, + ], + 'Thumb::width' => [ + fn () => new Thumb(), + fn (Thumb $it) => $it->width(640), + fn (Thumb $it) => $it->size(), + 320, + 640, + ], + 'Thumb::quality' => [ + fn () => new Thumb(), + fn (Thumb $it) => $it->quality(4), + fn (Thumb $it) => $it->scale(), + 2, + 4, + ], + 'Tile::interval' => [ + fn () => new Tile(), + fn (Tile $it) => $it->interval(5.0), + fn (Tile $it) => $it->every(30.0), + 2.0, + 5.0, + ], + 'Tile::width' => [ + fn () => new Tile(), + fn (Tile $it) => $it->width(200), + fn (Tile $it) => $it->size(), + 160, + 200, + ], + 'Tile::grid' => [ + fn () => new Tile(), + fn (Tile $it) => $it->grid(4, 3), + fn (Tile $it) => $it->cells(), + 25, + 12, + ], + 'Tile::quality' => [ + fn () => new Tile(), + fn (Tile $it) => $it->quality(5), + fn (Tile $it) => $it->scale(), + 3, + 5, + ], + 'Tile::name' => [ + fn () => new Tile(), + fn (Tile $it) => $it->name('thumbs'), + fn (Tile $it) => $it->base(), + 'sprite', + 'thumbs', + ], + 'Tile::vtt' => [ + fn () => new Tile(), + fn (Tile $it) => $it->vtt(false), + fn (Tile $it) => $it->writes(), + true, + false, + ], + ]; + } + + /** + * @dataProvider setters + */ + public function testASetterReturnsACopyAndLeavesItsReceiverAlone( + callable $make, + callable $set, + callable $read, + mixed $default, + mixed $changed, + ): void { + $original = $make(); + $modified = $set($original); + + $this->assertNotSame($original, $modified, 'a setter has to return a new instance'); + $this->assertSame($default, $read($original), 'the receiver must keep its value'); + $this->assertSame($changed, $read($modified), 'the copy must carry the new value'); + } + + /** + * A chain accumulates across copies: each call starts from the last copy, + * so the end of the chain carries everything set along the way. + */ + public function testAChainAccumulatesAcrossCopies(): void + { + $format = (new X264())->crf(22)->bframes(3); + $args = $format->build(); + + $this->assertContains('-crf', $args); + $this->assertContains('-bf', $args); + } + + /** + * The sharing scenario itself: two jobs configure one shared preset and + * neither sees the other's settings. + */ + public function testTwoHoldersOfOneInstanceCannotAffectEachOther(): void + { + $shared = new X264(); + + $first = $shared->crf(18); + $second = $shared->crf(30); + + $this->assertContains('18', $first->build()); + $this->assertContains('30', $second->build()); + $this->assertNotContains('-crf', $shared->build()); + } +} diff --git a/tests/Video/Unit/MockTest.php b/tests/Video/Unit/MockTest.php index 4ccaf5d..d37aeb6 100644 --- a/tests/Video/Unit/MockTest.php +++ b/tests/Video/Unit/MockTest.php @@ -6,6 +6,7 @@ use Utopia\Video\Adapter\Mock; use Utopia\Video\Encoder; +use Utopia\Video\Exception\Input; use Utopia\Video\Packager; use Utopia\Tests\Adapters; @@ -77,4 +78,39 @@ public function testDescribesTheSourceItWasToldToPretend(): void $this->assertSame(1280, $info->width); $this->assertFalse($info->hasAudio); } + + /** + * A double that accepts what the real backend refuses is worse than a plain + * bug: it is what code gets proved against. FFmpeg raises Input for a source + * with no picture in it, so this has to as well — it used to divide by zero. + */ + public function testRefusesToTileASourceWithNoVideo(): void + { + $adapter = (new Mock())->pretend(duration: 8.0, width: 0, height: 0, audio: true); + + $this->expectException(Input::class); + $this->expectExceptionMessage('carries no video to tile'); + + (new Encoder($adapter, $adapter))->tile($this->source(), $this->dir.'/tiles'); + } + + public function testRefusesToGrabFromASourceWithNoPicture(): void + { + $adapter = (new Mock())->pretend(duration: 8.0, width: 0, height: 0, audio: true); + + $this->expectException(Input::class); + $this->expectExceptionMessage('carries no image to grab'); + + (new Encoder($adapter, $adapter))->grab($this->source(), $this->dir.'/still.jpg'); + } + + public function testRefusesToTileASourceWithNoDuration(): void + { + $adapter = (new Mock())->pretend(duration: 0.0); + + $this->expectException(Input::class); + $this->expectExceptionMessage('no measurable duration'); + + (new Encoder($adapter, $adapter))->tile($this->source(), $this->dir.'/tiles'); + } } diff --git a/tests/Video/Unit/OutputTest.php b/tests/Video/Unit/OutputTest.php index 269a5f6..938f70e 100644 --- a/tests/Video/Unit/OutputTest.php +++ b/tests/Video/Unit/OutputTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Utopia\Video\Exception\Input; +use Utopia\Video\Exception\Unsupported; use Utopia\Video\Output; use Utopia\Video\Output\Cmaf; use Utopia\Video\Output\Dash; @@ -195,6 +196,50 @@ public function testAnInitSegmentNameStillHasToBeAFilename(): void (new Hls())->init('../init.mp4'); } + public function testDashSegmentPatternsCannotLeaveTheOutputDirectory(): void + { + foreach ( + [ + '../chunk_$Number$.m4s', + 'nested/chunk_$Number$.m4s', + 'nested\\chunk_$Number$.m4s', + '/tmp/chunk_$Number$.m4s', + 'chunk_$Unknown$.m4s', + ] as $pattern + ) { + try { + (new Dash())->media($pattern); + $this->fail('expected "'.$pattern.'" to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as a DASH filename template', $exception->getMessage()); + } + } + + $output = (new Dash()) + ->init('init_$RepresentationID$.$ext$') + ->media('chunk_$RepresentationID$_$Number%05d$.$ext$'); + + $this->assertSame('init_$RepresentationID$.$ext$', $output->initPattern()); + $this->assertSame('chunk_$RepresentationID$_$Number%05d$.$ext$', $output->mediaPattern()); + } + + /** + * A container the muxer does not know is a typo, and a typo found after an + * hour of encoding is a typo found too late. + */ + public function testAnUnknownHlsSegmentContainerIsRejected(): void + { + $this->expectException(Unsupported::class); + + (new Hls())->segments('mp4'); + } + + public function testBothHlsSegmentContainersAreAccepted(): void + { + $this->assertSame('ts', (new Hls())->segments(Hls::MPEGTS)->extension()); + $this->assertSame('m4s', (new Hls())->segments(Hls::FMP4)->extension()); + } + public function testThumbDefaults(): void { $thumb = new Thumb(); diff --git a/tests/Video/Unit/PackagerTest.php b/tests/Video/Unit/PackagerTest.php index b5f47c3..19bde3e 100644 --- a/tests/Video/Unit/PackagerTest.php +++ b/tests/Video/Unit/PackagerTest.php @@ -9,6 +9,7 @@ use Utopia\Video\Adapter\Packager as PackagerAdapter; use Utopia\Video\Exception\Input; use Utopia\Video\Exception\Unsupported; +use Utopia\Video\Format\Copy; use Utopia\Video\Format\X264; use Utopia\Video\Output\Cmaf; use Utopia\Video\Packager; @@ -131,6 +132,95 @@ public function testStagedIntermediatesAreCleanedUp(): void $this->assertDirectoryDoesNotExist($this->dir.'/out/.staging'); } + public function testStagedEncodingUsesTheSegmentLengthAsItsKeyframeCadence(): void + { + $encoder = new FakeEncoder(); + + $this->packager(encoder: $encoder) + ->open($this->file) + ->format(new X264()) + ->add(new Representation(640, 360, 800)) + ->output((new Cmaf())->segment(4)) + ->pack($this->dir.'/out'); + + $this->assertNotNull($encoder->format); + $this->assertSame(4.0, $encoder->format->interval()); + } + + public function testAnExplicitStagedKeyframeCadenceStillWins(): void + { + $encoder = new FakeEncoder(); + + $this->packager(encoder: $encoder) + ->open($this->file) + ->format((new X264())->keyframe(2)) + ->add(new Representation(640, 360, 800)) + ->output((new Cmaf())->segment(6)) + ->pack($this->dir.'/out'); + + $this->assertNotNull($encoder->format); + $this->assertSame(2.0, $encoder->format->interval()); + } + + public function testAnImpossibleStagedKeyframeCadenceIsRejectedBeforeEncoding(): void + { + $encoder = new FakeEncoder(); + + try { + $this->packager(encoder: $encoder) + ->open($this->file) + ->format((new X264())->keyframe(10)) + ->add(new Representation(640, 360, 800)) + ->output((new Cmaf())->segment(4)) + ->pack($this->dir.'/out'); + $this->fail('Expected the keyframe cadence to be rejected'); + } catch (Unsupported $exception) { + $this->assertStringContainsString('A keyframe every 10s cannot cut 4s segments', $exception->getMessage()); + } + + $this->assertSame(0, $encoder->encoded); + } + + public function testStreamCopyIsRejectedBeforeStagedEncodingStarts(): void + { + $encoder = new FakeEncoder(); + + try { + $this->packager(encoder: $encoder) + ->open($this->file) + ->format(new Copy()) + ->add(new Representation(640, 360, 800)) + ->output(new Cmaf()) + ->pack($this->dir.'/out'); + $this->fail('Expected stream copy to be rejected'); + } catch (Unsupported $exception) { + $this->assertStringContainsString('Stream copy cannot build an adaptive package', $exception->getMessage()); + } + + $this->assertSame(0, $encoder->encoded); + } + + public function testDuplicateNamesAreRejectedBeforeStagedEncodingStarts(): void + { + $encoder = new FakeEncoder(); + + try { + $this->packager(encoder: $encoder) + ->open($this->file) + ->add( + new Representation(640, 360, 800), + new Representation(480, 360, 500), + ) + ->output(new Cmaf()) + ->pack($this->dir.'/out'); + $this->fail('Expected duplicate names to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('Representation name "360p" is used more than once', $exception->getMessage()); + } + + $this->assertSame(0, $encoder->encoded); + } + public function testStagedProgressClimbsOnceAcrossEveryRung(): void { $seen = []; diff --git a/tests/Video/Unit/ParserMpdTest.php b/tests/Video/Unit/ParserMpdTest.php index 6d78ea1..a70da3b 100644 --- a/tests/Video/Unit/ParserMpdTest.php +++ b/tests/Video/Unit/ParserMpdTest.php @@ -46,6 +46,18 @@ public function testReadsIsoDurations(): void $this->assertSame(64.5, Mpd::seconds('PT1M4.5S')); $this->assertSame(3725.0, Mpd::seconds('PT1H2M5.0S')); $this->assertSame(6.0, Mpd::seconds('PT6S')); + + // The date half is legal too, and means the same thing. + $this->assertSame(64.5, Mpd::seconds('P0Y0M0DT0H1M4.50S')); + $this->assertSame(93784.0, Mpd::seconds('P1DT2H3M4S')); + $this->assertSame(604800.0, Mpd::seconds('P1W')); + + // Years and months have no fixed length, so they are refused, not guessed. + $this->assertSame(0.0, Mpd::seconds('P1Y')); + $this->assertSame(0.0, Mpd::seconds('P1M')); + + $this->assertSame(0.0, Mpd::seconds('P')); + $this->assertSame(0.0, Mpd::seconds('nonsense')); } public function testExpandsSegmentNamePatterns(): void @@ -121,6 +133,106 @@ public function testReadsATemplatedManifestAddressedByBandwidth(): void $this->assertSame('chunk-800000-00002.m4s', $variant->segments[2]->file); } + /** + * maxSegmentDuration is an ISO 8601 duration, and it is declared once on the + * MPD root. Read off an AdaptationSet, or cast with (float), it is silently 0. + */ + public function testAVariantCarriesTheSegmentLengthTheManifestDeclares(): void + { + $this->write('init-0.m4s', 'init'); + $this->write('chunk-0-1.m4s', 'aaaa'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + XML); + + $read = Mpd::read($manifest, $this->dir); + + $this->assertSame(6.0, $read['variants'][0]->target); + } + + /** + * With no maximum declared, the longest segment is what a target means. + */ + public function testAVariantFallsBackToItsLongestSegment(): void + { + $this->write('chunk-0-1.m4s', 'aaaa'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + XML); + + $read = Mpd::read($manifest, $this->dir); + + $this->assertSame(4.0, $read['variants'][0]->target); + } + + /** + * A descendant search run from an AdaptationSet walks into its Representations, + * so a rung that declares its own addressing must not be handed a sibling's. + */ + public function testARungKeepsItsOwnSegmentsWhenASiblingAddressesThemDifferently(): void + { + foreach (['a_init.m4s', 'a_1.m4s', 'b_init.m4s', 'b_1.m4s'] as $file) { + $this->write($file, 'x'); + } + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + + + + XML); + + $read = Mpd::read($manifest, $this->dir); + + $this->assertCount(2, $read['variants']); + $this->assertSame( + ['a_init.m4s', 'a_1.m4s'], + \array_map(static fn ($segment) => $segment->file, $read['variants'][0]->segments), + ); + $this->assertSame( + ['b_init.m4s', 'b_1.m4s'], + \array_map(static fn ($segment) => $segment->file, $read['variants'][1]->segments), + ); + } + public function testReadsAListedManifest(): void { $this->write('init-0.m4s', 'init'); @@ -274,6 +386,25 @@ public function testAudioAdaptationSetIsRecognised(): void $this->assertNull($variant->resolution()); } + public function testARepresentationWithoutMediaSegmentsIsAFailure(): void + { + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + XML); + + $this->expectException(Runtime::class); + $this->expectExceptionMessage('Representation "empty" contains no media segments'); + + Mpd::read($manifest, $this->dir); + } + public function testMissingSegmentIsAFailure(): void { $manifest = $this->write('manifest.mpd', <<<'XML' diff --git a/tests/Video/Unit/ProcessTest.php b/tests/Video/Unit/ProcessTest.php index fe405ea..1f4b522 100644 --- a/tests/Video/Unit/ProcessTest.php +++ b/tests/Video/Unit/ProcessTest.php @@ -140,6 +140,39 @@ function (string $line) use (&$lines): void { $this->assertSame(200000, \array_sum(\array_map('strlen', $lines))); } + public function testAThrowingListenerDoesNotLeaveTheChildRunning(): void + { + $marker = \sys_get_temp_dir().'/utopia-process-'.\bin2hex(\random_bytes(6)); + + try { + $failure = null; + + try { + Process::run( + [ + PHP_BINARY, + '-r', + 'echo "ready\n"; flush(); usleep(500000); file_put_contents($argv[1], "alive");', + $marker, + ], + static function (): void { + throw new \LogicException('listener stopped'); + }, + ); + } catch (\Throwable $exception) { + $failure = $exception; + } + + $this->assertInstanceOf(\LogicException::class, $failure); + $this->assertSame('listener stopped', $failure->getMessage()); + + \usleep(700000); + $this->assertFileDoesNotExist($marker, 'the child survived after its listener threw'); + } finally { + @\unlink($marker); + } + } + public function testMissingBinaryIsAFailure(): void { $this->expectException(Runtime::class); diff --git a/tests/Video/Unit/SpritesheetTest.php b/tests/Video/Unit/SpritesheetTest.php index 6d35cfc..8ae1b4c 100644 --- a/tests/Video/Unit/SpritesheetTest.php +++ b/tests/Video/Unit/SpritesheetTest.php @@ -8,6 +8,7 @@ use Utopia\Video\Cue; use Utopia\Video\Exception\Input; use Utopia\Video\Spritesheet; +use Utopia\Video\Thumb; use Utopia\Video\Tile; class SpritesheetTest extends TestCase @@ -145,4 +146,75 @@ public function testASheetNameThatLeavesTheDirectoryIsRejected(): void (new Tile())->name('../sheets'); } + + /** + * The interval is a divisor. Zero used to reach ffmpeg as a DivisionByZeroError + * rather than as anything a caller could catch. + * + * @dataProvider brokenTiles + */ + public function testATileSettingThatCannotWorkIsRejected(callable $configure): void + { + $this->expectException(Input::class); + + $configure(new Tile()); + } + + /** + * @return array + */ + public static function brokenTiles(): array + { + return [ + 'zero interval' => [static fn (Tile $tile) => $tile->interval(0.0)], + 'negative interval' => [static fn (Tile $tile) => $tile->interval(-1.0)], + 'zero width' => [static fn (Tile $tile) => $tile->width(0)], + 'negative width' => [static fn (Tile $tile) => $tile->width(-160)], + 'empty grid' => [static fn (Tile $tile) => $tile->grid(0, 0)], + 'negative grid' => [static fn (Tile $tile) => $tile->grid(5, -1)], + 'quality below scale' => [static fn (Tile $tile) => $tile->quality(0)], + 'quality above scale' => [static fn (Tile $tile) => $tile->quality(32)], + ]; + } + + /** + * An unset interval is how a caller asks for the duration-scaled default, + * so null stays legal. + */ + public function testAnUnsetIntervalIsStillAllowed(): void + { + $this->assertSame(2.0, (new Tile())->interval(null)->every(8.0)); + } + + /** + * @dataProvider brokenThumbs + */ + public function testAThumbSettingThatCannotWorkIsRejected(callable $configure): void + { + $this->expectException(Input::class); + + $configure(new Thumb()); + } + + /** + * @return array + */ + public static function brokenThumbs(): array + { + return [ + 'negative time' => [static fn (Thumb $thumb) => $thumb->time(-1.0)], + 'unscalable width' => [static fn (Thumb $thumb) => $thumb->width(1)], + 'negative width' => [static fn (Thumb $thumb) => $thumb->width(-320)], + 'quality below scale' => [static fn (Thumb $thumb) => $thumb->quality(0)], + 'quality above scale' => [static fn (Thumb $thumb) => $thumb->quality(32)], + ]; + } + + /** + * Zero width is how a caller asks to keep the source size, so it stays legal. + */ + public function testAThumbWidthOfZeroKeepsTheSourceSize(): void + { + $this->assertSame(0, (new Thumb())->width(0)->size()); + } } From b5f71dc06c139acf49e0a33ea74c03277303bac8 Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 23 Aug 2026 14:20:32 +0300 Subject: [PATCH 10/12] Enhance video library with Swoole integration and Docker improvements - Added `swoole/ide-helper` as a development dependency in composer.json and updated composer.lock. - Updated Dockerfile to install Swoole from source, ensuring compatibility with the latest version. - Enhanced README.md to document the necessity of Swoole for coroutine tests and clarified Docker usage. - Improved validation in the Name class for various input formats, ensuring robustness in segment naming. - Refactored job management in the Adapter classes to ensure proper cleanup and state management between jobs. --- Dockerfile | 39 ++++- README.md | 6 +- composer.json | 6 +- composer.lock | 40 ++++- docs/design.md | 245 +++++++++++++++++++++-------- src/Video/Adapter/FFmpeg.php | 37 ++++- src/Video/Adapter/Job.php | 33 ++-- src/Video/Adapter/Mock.php | 4 +- src/Video/Name.php | 89 ++++++++++- src/Video/Output/Dash.php | 2 + src/Video/Output/Hls.php | 6 + src/Video/Parser/Mpd.php | 44 ++++-- src/Video/Process.php | 55 ++++++- tests/Video/E2E/FrameTest.php | 30 ++++ tests/Video/Unit/AdapterTest.php | 44 ++++++ tests/Video/Unit/OutputTest.php | 64 ++++++++ tests/Video/Unit/ParserMpdTest.php | 74 +++++++++ tests/Video/Unit/ProcessTest.php | 47 ++++++ 18 files changed, 761 insertions(+), 104 deletions(-) diff --git a/Dockerfile b/Dockerfile index 79fd58a..fba6a23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ -ARG FFMPEG_VERSION=7.1.1 +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 @@ -30,6 +31,26 @@ RUN curl -fsSL "https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz" | t && /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 @@ -58,6 +79,16 @@ RUN apk add --no-cache \ 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 @@ -70,6 +101,10 @@ COPY ./phpunit.xml /usr/src/code/phpunit.xml COPY ./phpstan.neon /usr/src/code/phpstan.neon COPY ./pint.json /usr/src/code/pint.json -RUN ffmpeg -version && ffprobe -version +# 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/README.md b/README.md index d3eb17e..bdc3b38 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,8 @@ needing a flag. Test method names are written to read as sentences; where a name `@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: +`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 @@ -424,7 +425,8 @@ extension. 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. +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 diff --git a/composer.json b/composer.json index ec99c1b..e6f8ac9 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,11 @@ "require-dev": { "phpunit/phpunit": "9.*", "phpstan/phpstan": "^2.0", - "laravel/pint": "1.*" + "laravel/pint": "1.*", + "swoole/ide-helper": "6.2.2" + }, + "suggest": { + "ext-swoole": "Needed to run the library inside Swoole coroutines." }, "config": { "platform": { diff --git a/composer.lock b/composer.lock index 4c5da68..aed8d7d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "339fec9349e29f6c104eb43292336622", + "content-hash": "b569159b6ddcf97be66d3f7df58dc3ba", "packages": [ { "name": "utopia-php/console", @@ -1950,6 +1950,38 @@ ], "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", @@ -2003,7 +2035,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -2011,9 +2043,9 @@ "ext-dom": "*", "ext-json": "*" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.9.0" } diff --git a/docs/design.md b/docs/design.md index e36f05f..8d333e1 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,6 +1,6 @@ # Utopia\Video v2 — Design -> Status: **implemented** · last updated 2026-07-31 · targets ffmpeg 7.1 LTS (pinned in the repo image) +> 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, @@ -29,7 +29,7 @@ Boundaries — the library: external binaries. Style: one-word public methods (`open`, `probe`, `encode`, `pack`, `grab`, `tile`, `valid`), -short class names with no redundant context (`Encoder\FFmpeg`, not `FFmpegEncoderAdapter`), +short class names with no redundant context (`Adapter\FFmpeg`, not `FFmpegEncoderAdapter`), config objects in, readonly results out. ## 1. Architecture @@ -52,11 +52,14 @@ Consumer (worker / CLI / app) └─ 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) · trait Job · trait Reads · trait Decimal +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 @@ -101,14 +104,16 @@ Three rules keep the design honest: ### Facades -Both take the same shape: an adapter and a probe, each defaulted, each only ever known by its -interface. Adapter names below are written relative to `Utopia\Video\Adapter`. +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) +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 + ?Adapter\Probe $probe = null, // adapter itself when it can encode + ?Reporter $reporter = null) // null keeps Reporter\Console (§10.12) ``` ```php @@ -132,7 +137,7 @@ $ok = $packager->valid($in); // bool $poster = $encoder->grab($in, $jpg, new Thumb()); // written path — thumbnail / poster frame $sheet = $encoder->tile($in, $dir, new Tile()); // Spritesheet -// Job chain — open() restarts, encode()/pack() are terminal +// 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) @@ -206,8 +211,12 @@ interface Probe extends Named `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 that the first `open()` of a job forgets the previous one. `trait Reads` holds the one -`valid()` body the probes share; `trait Decimal` the one number formatter four hierarchies wanted. +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 @@ -238,9 +247,9 @@ 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 `Console::success` line, and a failed backend command prints -`Console::error` before throwing — so a consumer sees the outcome without a -listener. Probe stays quiet on success. `QUIET` silences both the status lines +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 — @@ -276,37 +285,52 @@ which is how a backend chosen from config stays identifiable. // 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" 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. +// 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 × video (the smoothing window, -bufsize:v:N) — busy scenes can no longer spike +// 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 + 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 audio(string $codec): static; // override audio codec 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 bf 1, keyint_min 25, g 250, sc_threshold 40 +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) -Output\Hls ->type(Hls::MPEGTS | Hls::FMP4) // fmp4 → init segment + EXT-X-MAP, playlist VERSION 7 - ->init('init.mp4')->master('master.m3u8')->base($url)->flags([...]); +// 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'); + ->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) @@ -334,7 +358,12 @@ Info // probe result — container + primary video/audio stream + full m 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 [] @@ -348,25 +377,36 @@ Package // pack() result — the structured source of truth (see §5) 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 · timescale · startNumber · target · segments[] · playlist (path|null) + // 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[] -Cue // start · end · file · x · y · w · h (structured #xywh — consumers rewrite URLs freely) + // 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 +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) -Exception\Output // Output combo unsupported by the chosen adapter (e.g. SegmentList on Shaka) -Exception\Process // backend command failed — command(): full argv, output(): stderr tail +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 @@ -426,9 +466,10 @@ the same `.m4s` files**. Default backend: the FFmpeg dash muxer. ``` Verified behavior (ffmpeg `dashenc.c` + formats docs, and reproduced live against the repo image's -ffmpeg 7.1.1 build: one command over a test source produced a `SegmentList`/`SegmentURL`-only MPD, +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): +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` @@ -436,14 +477,15 @@ referencing one shared `.m4s` segment set): - 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 — `Packager\FFmpeg` throws `Exception\Output` for `Format\VP9` + `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 library targets the most mature LTS line — **7.1**, exactly what the - repo image builds and pins, and what CI therefore tests against. 4.1 remains the documented floor; - older lines are not exercised by CI. +- 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 @@ -453,7 +495,7 @@ URLs from Shaka would have to rely on `Package::segments()` parsed from its HLS always enumerate concrete files) rather than its MPD. That is exactly the dynamic-manifest mode described next. -Standalone HLS fMP4 (`Output\Hls::type(Hls::FMP4)`) for completeness: `-hls_segment_type fmp4`, +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). @@ -477,13 +519,22 @@ The internal parsers (`Parser\M3u8`, `Parser\Mpd`) are the single source of `seg `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$`, iterating `SegmentTimeline` - runs) so `segments()` is complete regardless of addressing mode. -- **Integrity check**: after parsing, every referenced segment is `stat()`-ed (existence + byte - size). Any mismatch fails `pack()` with `Exception\Process` — a truncated run can never return a - plausible-looking `Package`. - -The same pattern applies to sprites: `Spritesheet::cues()` (structured `start/end/file/x/y/w/h`) is + 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. @@ -531,6 +582,9 @@ src/Video/ ├── 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 @@ -548,7 +602,11 @@ Exception names avoid colliding with the config and runner classes they sat besi `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. The `php-ffmpeg/php-ffmpeg` dependency is +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 @@ -559,7 +617,7 @@ listeners. `ffmpeg -progress pipe:1 -nostats` emits stable machine-readable `key | Today | v2 | |---|---| -| `aminyazdanpanah/php-ffmpeg-video-streaming` fork: `$media->hls()/dash()` + `setAdditionalParams([...])` | `Packager\FFmpeg` fused pass; extra args → `Format::params()`; segment duration → `Output::segment()` | +| `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 | @@ -570,7 +628,7 @@ listeners. `ffmpeg -progress pipe:1 -nostats` emits stable machine-readable `key | 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\Process` with command + stderr tail | +| 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`). @@ -587,7 +645,8 @@ Consumers migrate incrementally: `probe()` is drop-in first, `tile()`/`grab()` s 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$`, `SegmentTimeline` `S@t/@d/@r` runs). + `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 @@ -604,14 +663,24 @@ Consumers migrate incrementally: `probe()` is drop-in first, `tile()`/`grab()` s 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). A - Swoole-gated E2E (`CoroutineTest`, skipped without ext-swoole) runs three coroutines, one - `Encoder` each, off one shared `Thumb` under `SWOOLE_HOOK_ALL`. + 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. + 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()`). + (`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 @@ -626,11 +695,13 @@ Consumers migrate incrementally: `probe()` is drop-in first, `tile()`/`grab()` s - 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\Output`. -- `Package::segments()` sizes match `stat()`; `manifests(false)` leaves no playlist files; + 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. + 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 @@ -655,13 +726,17 @@ written to read as sentences; where a name cannot (a leading acronym, or a digit 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 7.1.1 the image builds), phpstan +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. Building with `TESTING=true` installs dev dependencies so `phpunit` runs in-image. +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 @@ -710,19 +785,30 @@ All former open questions, resolved with the maintainer (2026-07-26): "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\Output` instead of degrading. + 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 × video`) when unset (§2). + 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**: target the most mature LTS line — 7.1, pinned in the repo image via the - `FFMPEG_VERSION` build arg, and the only line CI builds against; 4.1 is the documented floor. +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 @@ -731,8 +817,6 @@ All former open questions, resolved with the maintainer (2026-07-26): 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. -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. 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 @@ -741,11 +825,44 @@ All former open questions, resolved with the maintainer (2026-07-26): 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. + `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 diff --git a/src/Video/Adapter/FFmpeg.php b/src/Video/Adapter/FFmpeg.php index fcf1b81..943cd7d 100644 --- a/src/Video/Adapter/FFmpeg.php +++ b/src/Video/Adapter/FFmpeg.php @@ -54,7 +54,8 @@ class FFmpeg extends Adapter implements Encoder, Packager public function open(string $path, ?Representation $as = null): static { - $first = $this->inputs === []; + // Asked before registering, which is what starts the job. + $first = ! $this->started; $this->register($path, $as); @@ -142,7 +143,7 @@ public function encode(string $path): string } finally { // Both terminals end the job, so the next open() starts a new one // instead of adding a second input to this one. - $this->inputs = []; + $this->done(); } } @@ -192,7 +193,7 @@ public function pack(string $dir): Package return $package; } finally { // The job is over, so the next open() starts a new one. - $this->inputs = []; + $this->done(); } } @@ -292,6 +293,12 @@ public function tile(string $path, string $dir, ?Tile $options = null): Spritesh $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); @@ -414,6 +421,30 @@ protected function ready(): array 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. */ diff --git a/src/Video/Adapter/Job.php b/src/Video/Adapter/Job.php index 04c2a04..c1de157 100644 --- a/src/Video/Adapter/Job.php +++ b/src/Video/Adapter/Job.php @@ -32,12 +32,15 @@ trait Job 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 a new one, dropping everything the last - * job left behind; later calls add another already encoded rendition. Tag - * an input with the rung it represents when packaging several at once. + * 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 { @@ -52,11 +55,20 @@ public function open(string $path, ?Representation $as = null): static */ protected function register(string $path, ?Representation $as = null): void { - if ($this->inputs === []) { - $this->restart(); + // 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[] = ['path' => $this->source($path), 'rep' => $as]; + $this->inputs[] = $input; if ($as !== null) { $this->reps[] = $as; @@ -87,15 +99,18 @@ public function format(Format $format): static } /** - * Forget the previous job entirely, listeners included. + * 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 restart(): void + protected function done(): void { $this->inputs = []; $this->reps = []; $this->target = null; $this->encoding = null; - $this->forget(); + $this->started = false; } /** diff --git a/src/Video/Adapter/Mock.php b/src/Video/Adapter/Mock.php index e5e552d..7da010e 100644 --- a/src/Video/Adapter/Mock.php +++ b/src/Video/Adapter/Mock.php @@ -115,7 +115,7 @@ public function encode(string $path): string return $path; } finally { // Both terminals end the job, as in the real adapters. - $this->inputs = []; + $this->done(); } } @@ -140,7 +140,7 @@ public function pack(string $dir): Package return $this->packed($dir, $output); } finally { // The job is over, so the next open() starts a new one. - $this->inputs = []; + $this->done(); } } diff --git a/src/Video/Name.php b/src/Video/Name.php index 29c7647..56014ae 100644 --- a/src/Video/Name.php +++ b/src/Video/Name.php @@ -31,6 +31,15 @@ final class Name /** A conservative BCP-47 language tag, safe in a muxer list and playlist. */ private const LANGUAGE = '/^[A-Za-z0-9]{1,8}(?:-[A-Za-z0-9]{1,8})*$/'; + /** One word of muxer vocabulary, such as an hls_flags entry. */ + private const WORD = '/^[A-Za-z0-9_]+$/'; + + /** Anything but whitespace and the control characters, which end a URI. */ + private const PREFIX = '/^[^\s\x00-\x1f\x7f]+$/D'; + + /** One argv token: free-form, but nothing that ends or hides an argument. */ + private const ARGUMENT = '/^[^\x00-\x1f\x7f]+$/D'; + /** * @throws Input */ @@ -65,13 +74,21 @@ public static function file(string $value, string $what): string * A DASH segment filename template. It may carry the placeholders FFmpeg * understands, but never a directory component. * + * Only the identifiers that can be resolved again are allowed. A name is + * written by the muxer and read back by Parser\Mpd, so one that only half + * the pair understands would produce a package whose own manifest points at + * filenames nothing on disk is called. $ext$ is the exception that proves + * the rule: it is accepted because the muxer resolves it while writing the + * manifest, so the parser never meets it. $SubNumber$ is refused because + * subsegment addressing is neither written nor read here. + * * @throws Input */ public static function template(string $value, string $what): string { $remaining = \preg_replace( '/\$(?:RepresentationID|Number(?:%0?\d*d)?|Bandwidth(?:%0?\d*d)?|' - .'Time(?:%0?\d*d)?|SubNumber(?:%0?\d*d)?|ext)\$|\$\$/', + .'Time(?:%0?\d*d)?|ext)\$|\$\$/', '', $value, ); @@ -93,6 +110,76 @@ public static function template(string $value, string $what): string return $value; } + /** + * A prefix written in front of every segment reference in a playlist. + * + * Free to be a URL or a relative path, so separators are expected. It may + * not carry whitespace, which would end the URI it is prepended to, and it + * may not begin with a dash, which would make it read as an option when the + * muxer is handed it. Empty means no prefix at all, which is the default. + * + * @throws Input + */ + public static function prefix(string $value, string $what): string + { + if ($value === '') { + return $value; + } + + if (\preg_match(self::PREFIX, $value) !== 1 || \str_starts_with($value, '-')) { + throw new Input( + $what.' "'.$value.'" is not usable as a URL prefix; ' + .'expected a URL or path carrying no whitespace', + ); + } + + return $value; + } + + /** + * One word of a backend's own vocabulary, such as a muxer flag. + * + * @throws Input + */ + public static function word(string $value, string $what): string + { + if (\preg_match(self::WORD, $value) !== 1) { + throw new Input( + $what.' "'.$value.'" is not usable as a single word; ' + .'letters, digits and underscores only', + ); + } + + return $value; + } + + /** + * A value handed to a backend as a single argv token. + * + * Looser than the rules above on purpose: an option's own syntax may need + * separators, spaces and punctuation, so the shape of the value belongs to + * whoever documented the option. What is checked is only what would break + * the argument list itself — an empty value, a control character, or a + * leading dash that would make the value read as another option. + * + * @throws Input + */ + public static function argument(string $value, string $what): string + { + if ( + $value === '' + || \preg_match(self::ARGUMENT, $value) !== 1 + || \str_starts_with($value, '-') + ) { + throw new Input( + $what.' "'.$value.'" is not usable as an argument; ' + .'expected a value carrying no control characters and not beginning with "-"', + ); + } + + return $value; + } + /** * Optional metadata is omitted when it cannot safely be represented. */ diff --git a/src/Video/Output/Dash.php b/src/Video/Output/Dash.php index f02e49f..c4a84bd 100644 --- a/src/Video/Output/Dash.php +++ b/src/Video/Output/Dash.php @@ -97,6 +97,8 @@ public function media(string $pattern): static */ public function sets(string $definition): static { + $definition = Name::argument($definition, 'Adaptation set definition'); + $copy = clone $this; $copy->sets = $definition; diff --git a/src/Video/Output/Hls.php b/src/Video/Output/Hls.php index 58544ea..553051b 100644 --- a/src/Video/Output/Hls.php +++ b/src/Video/Output/Hls.php @@ -91,6 +91,8 @@ public function master(string $filename): static */ public function url(string $url): static { + $url = Name::prefix($url, 'Segment URL prefix'); + $copy = clone $this; $copy->url = $url; @@ -102,6 +104,10 @@ public function url(string $url): static */ public function flags(array $flags): static { + foreach ($flags as $flag) { + Name::word($flag, 'HLS flag'); + } + $copy = clone $this; $copy->flags = $flags; diff --git a/src/Video/Parser/Mpd.php b/src/Video/Parser/Mpd.php index e3b1f93..d382266 100644 --- a/src/Video/Parser/Mpd.php +++ b/src/Video/Parser/Mpd.php @@ -229,27 +229,42 @@ private static function templated( if ($timeline !== null) { $number = $start; + // Media time, in timescale units, of the segment about to be read. + // A name built from $Time$ is the running total of the durations + // declared ahead of it, so it has to be carried across entries. + $time = 0; + foreach ($timeline->getElementsByTagName('S') as $entry) { - $length = ((float) $entry->getAttribute('d')) / $timescale; + $units = (int) $entry->getAttribute('d'); + $length = $units / $timescale; $repeat = $entry->hasAttribute('r') ? (int) $entry->getAttribute('r') : 0; + // An entry is free to say where it begins, which is how a + // timeline records a gap or starts its clock somewhere other + // than zero. Only when it says nothing does the total carry on. + if ($entry->hasAttribute('t')) { + $time = (int) $entry->getAttribute('t'); + } + for ($i = 0; $i <= $repeat; $i++) { $segments[] = self::segment( $id, $dir, - self::expand($media, $id, $number, $bandwidth), + self::expand($media, $id, $number, $bandwidth, $time), $length, false, $number, ); $number++; + $time += $units; } } return [$segments, $timescale, $start]; } - $length = ((float) $template->getAttribute('duration')) / $timescale; + $units = (int) $template->getAttribute('duration'); + $length = $units / $timescale; if ($length <= 0 || $duration <= 0) { return [$segments, $timescale, $start]; @@ -262,7 +277,7 @@ private static function templated( $segments[] = self::segment( $id, $dir, - self::expand($media, $id, $number, $bandwidth), + self::expand($media, $id, $number, $bandwidth, $i * $units), $length, false, $number, @@ -275,15 +290,24 @@ private static function templated( /** * Substitutes the identifiers DASH allows inside a segment name. * - * $Number$ and $Bandwidth$ both take an optional printf width, which is how - * a manifest asks for zero padded names, and $$ is how it asks for a literal - * dollar sign. + * $Number$, $Bandwidth$ and $Time$ each take an optional printf width, which + * is how a manifest asks for zero padded names, and $$ is how it asks for a + * literal dollar sign. Every identifier a name is allowed to carry is + * resolved here: one left behind would be looked for on disk verbatim, and + * reported as a missing segment rather than as a name nothing understood. + * + * @param int $time Media time of this segment, in timescale units. */ - public static function expand(string $pattern, string $id, int $number, int $bandwidth = 0): string - { + public static function expand( + string $pattern, + string $id, + int $number, + int $bandwidth = 0, + int $time = 0, + ): string { $name = \str_replace('$RepresentationID$', $id, $pattern); - foreach (['Number' => $number, 'Bandwidth' => $bandwidth] as $identifier => $value) { + foreach (['Number' => $number, 'Bandwidth' => $bandwidth, 'Time' => $time] as $identifier => $value) { $name = \preg_replace_callback( '/\$'.$identifier.'(%0?\d*d)?\$/', // The width group needs at least "%d" to match, so it is either diff --git a/src/Video/Process.php b/src/Video/Process.php index 9535a55..ceb5a05 100644 --- a/src/Video/Process.php +++ b/src/Video/Process.php @@ -34,6 +34,9 @@ final class Process /** Seconds a killed command is given to explain itself. */ private const GRACE = 2.0; + /** Bytes of collected output to hold before giving up on a command. */ + private const OUTPUT = 8388608; + /** * @param list $command * @param callable(string):void|null $stdout Called once per line written to stdout. @@ -222,12 +225,22 @@ public static function read(array $command, int $timeout = 0): string /** * A line listener that appends everything it is given to a string. * + * Collected output is held in memory, so a ceiling is part of the bargain: + * a command that will not stop talking is given up on rather than allowed + * to exhaust the process that ran it. The tail of stderr is bounded the + * same way inside the read loop. Pass 0 to collect without a limit. + * * @param string $into Filled in as lines arrive. + * @param int $limit Bytes to hold at most; 0 for no limit. * @return callable(string):void */ - public static function collector(string &$into): callable + public static function collector(string &$into, int $limit = self::OUTPUT): callable { - return static function (string $line) use (&$into): void { + return static function (string $line) use (&$into, $limit): void { + if ($limit > 0 && \strlen($into) + \strlen($line) + 1 > $limit) { + throw new Runtime('Command produced more than '.$limit.' bytes of output'); + } + $into .= $line."\n"; }; } @@ -268,15 +281,17 @@ private static function halt( ): Runtime { \proc_terminate($process, 15); - $pipe = $open[2] ?? null; $deadline = \microtime(true) + self::GRACE; $status = \proc_get_status($process); while ($status['running'] && \microtime(true) < $deadline) { - if ($pipe !== null) { + // Every pipe, not stderr alone: a backend blocked writing to a pipe + // nobody is draining cannot reach its own exit, and would have to be + // killed for it rather than being allowed to take the hint. + foreach ($open as $key => $pipe) { $chunk = \fread($pipe, 8192); - if (\is_string($chunk) && $chunk !== '') { + if ($key === 2 && \is_string($chunk) && $chunk !== '') { $errors = \substr($errors.$chunk, -self::TAIL); } } @@ -287,6 +302,14 @@ private static function halt( if ($status['running']) { \proc_terminate($process, 9); + + // Being reaped is what fills in how it ended, and a killed process + // is gone almost at once. Without waiting for that the status still + // reads "running" and the failure carries no code at all. + for ($attempt = 0; $attempt < 50 && $status['running']; $attempt++) { + \usleep(2000); + $status = \proc_get_status($process); + } } foreach ($open as $handle) { @@ -295,7 +318,27 @@ private static function halt( \proc_close($process); - return new Runtime($message, $command, \trim($errors)); + return new Runtime($message, $command, \trim($errors), self::ended($status)); + } + + /** + * How a command that had to be stopped ended, as an exit code. + * + * proc_close() reports -1 once proc_get_status() has reaped the child, so + * the status is the only thing still holding the truth. A signalled process + * has no exit code of its own, so the shell's convention of 128 plus the + * signal is borrowed: it tells a command that took the hint (143) from one + * that had to be killed (137) from one that failed on its own. + * + * @param array{running: bool, signaled: bool, termsig: int, exitcode: int} $status + */ + private static function ended(array $status): int + { + if ($status['signaled'] && $status['termsig'] > 0) { + return 128 + $status['termsig']; + } + + return \max(0, $status['exitcode']); } /** diff --git a/tests/Video/E2E/FrameTest.php b/tests/Video/E2E/FrameTest.php index b12025f..5839466 100644 --- a/tests/Video/E2E/FrameTest.php +++ b/tests/Video/E2E/FrameTest.php @@ -181,6 +181,36 @@ public function testSpillsOntoASecondSheetWhenTheGridFills(): void $this->assertGreaterThanOrEqual(2, \count($files)); } + /** + * Sheets are read back by walking the numbered files upward until one is + * missing, so tiling a shorter source into a directory a longer one has + * already used must not pick up the tail it left behind: those sheets show + * another timeline, and a consumer uploads whatever files() lists. + */ + public function testTilingAgainDoesNotReportTheLastRunsSheets(): void + { + $options = (new Tile())->interval(2.0)->grid(2, 1); + + // Eight seconds over two-cell sheets fills two of them; four seconds + // fills one, leaving the second behind. + $long = (new Encoder())->tile(self::video(), $this->dir, $options); + $short = (new Encoder())->tile(self::silent(), $this->dir, $options); + + $this->assertCount(2, $long->images()); + $this->assertCount(1, $short->images()); + + // The invariant behind the count: every sheet reported has to be one + // this timeline actually has cues for. + $this->assertLessThanOrEqual( + \count($short->images()) * $options->cells(), + \count($short->cues()), + ); + + foreach ($short->files() as $file) { + $this->assertWritten($file); + } + } + public function testWritesAWebvttTimeline(): void { $sheet = (new Encoder())->tile(self::video(), $this->dir, (new Tile())->interval(2.0)); diff --git a/tests/Video/Unit/AdapterTest.php b/tests/Video/Unit/AdapterTest.php index 6aa6ab4..751e842 100644 --- a/tests/Video/Unit/AdapterTest.php +++ b/tests/Video/Unit/AdapterTest.php @@ -160,6 +160,50 @@ public function testAFinishedJobDoesNotLeakIntoTheNext(): void $adapter->pack($this->dir); } + /** + * The chain reads as well in one order as the other, so a ladder described + * before the source was opened has to survive the opening. It is the promise + * open() already makes about listeners: what was set on the adapter belongs + * to the caller, and only the previous job's leftovers are swept away. + */ + public function testConfigurationSetBeforeOpeningSurvivesIt(): void + { + $adapter = new Mock(); + + $package = $adapter + ->add(new Representation(320, 240, 400)) + ->output(new Hls()) + ->open($this->file) + ->pack($this->dir); + + $this->assertCount(1, $package->variants()); + $this->assertNotEmpty($package->segments()); + } + + /** + * The other half of the same promise: configuring the next job before + * opening it must not inherit the finished one's ladder. + */ + public function testANewJobConfiguredBeforeOpeningStartsClean(): void + { + $adapter = new Mock(); + + $adapter + ->open($this->file) + ->add(new Representation(320, 240, 400)) + ->output(new Hls()) + ->pack($this->dir); + + $package = $adapter + ->add(new Representation(640, 480, 800)) + ->output(new Hls()) + ->open($this->file) + ->pack($this->dir); + + $this->assertCount(1, $package->variants()); + $this->assertSame(640, $package->variants()[0]->width); + } + /** * Packaging several already encoded renditions is what repeated open() calls * are for, so within one job they add rather than reset. diff --git a/tests/Video/Unit/OutputTest.php b/tests/Video/Unit/OutputTest.php index 938f70e..c2efa1a 100644 --- a/tests/Video/Unit/OutputTest.php +++ b/tests/Video/Unit/OutputTest.php @@ -223,6 +223,70 @@ public function testDashSegmentPatternsCannotLeaveTheOutputDirectory(): void $this->assertSame('chunk_$RepresentationID$_$Number%05d$.$ext$', $output->mediaPattern()); } + /** + * The prefix is written in front of every segment reference in a playlist, + * where a line break or a space would end the URI, and it is handed to the + * muxer as one argument, where a leading dash would pass for an option. + */ + public function testASegmentUrlPrefixHasToBeUsableInAPlaylist(): void + { + foreach (['has space', "two\nlines", '-hls_flags', "null\0byte"] as $prefix) { + try { + (new Hls())->url($prefix); + $this->fail('expected "'.$prefix.'" to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as a URL prefix', $exception->getMessage()); + } + } + + $this->assertSame('https://cdn.example.com/v/', (new Hls())->url('https://cdn.example.com/v/')->prefix()); + $this->assertSame('', (new Hls())->url('')->prefix()); + } + + /** + * Flags are muxer vocabulary, so anything that is not one word of it would + * arrive as a stray argument rather than as the flag it was meant to be. + */ + public function testHlsFlagsHaveToBeSingleWords(): void + { + foreach ([['independent_segments', 'two words'], ['-flag'], [''], ["null\0byte"]] as $flags) { + try { + (new Hls())->flags($flags); + $this->fail('expected '.\json_encode($flags).' to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('HLS flag', $exception->getMessage()); + $this->assertStringContainsString('not usable as a single word', $exception->getMessage()); + } + } + + $this->assertSame( + ['independent_segments', 'delete_segments'], + (new Hls())->flags(['independent_segments', 'delete_segments'])->hlsFlags(), + ); + } + + /** + * An adaptation set definition carries the option's own syntax, so spaces + * and separators are part of it. What it may not do is pass for an option + * itself, which is the one way a value breaks the argument list it sits in. + */ + public function testAnAdaptationSetDefinitionCannotPassForAnOption(): void + { + foreach (['-adaptation_sets', '', "id=0,streams=v\nid=1", "null\0byte"] as $definition) { + try { + (new Dash())->sets($definition); + $this->fail('expected "'.$definition.'" to be rejected'); + } catch (Input $exception) { + $this->assertStringContainsString('not usable as an argument', $exception->getMessage()); + } + } + + $this->assertSame( + 'id=0,streams=v id=1,streams=a', + (new Dash())->sets('id=0,streams=v id=1,streams=a')->adaptations(), + ); + } + /** * A container the muxer does not know is a typo, and a typo found after an * hour of encoding is a typo found too late. diff --git a/tests/Video/Unit/ParserMpdTest.php b/tests/Video/Unit/ParserMpdTest.php index a70da3b..a72f5a4 100644 --- a/tests/Video/Unit/ParserMpdTest.php +++ b/tests/Video/Unit/ParserMpdTest.php @@ -355,6 +355,80 @@ public function testExpandsATemplatedManifestWithoutATimeline(): void $this->assertSame('chunk-stream0-00002.m4s', $variant->segments[2]->file); } + /** + * $Time$ is the one identifier only a timeline can resolve: a segment is + * named for the media time it starts at, which is the running total of the + * durations declared before it rather than anything on the segment itself. + */ + public function testExpandsATemplateNamedByTime(): void + { + $this->write('init-stream0.m4s', 'init'); + $this->write('chunk-stream0-0.m4s', 'a'); + $this->write('chunk-stream0-6000.m4s', 'b'); + $this->write('chunk-stream0-12000.m4s', 'c'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + + XML); + + $variant = Mpd::read($manifest, $this->dir)['variants'][0]; + + $this->assertCount(4, $variant->segments); + $this->assertSame('chunk-stream0-0.m4s', $variant->segments[1]->file); + $this->assertSame('chunk-stream0-6000.m4s', $variant->segments[2]->file); + $this->assertSame('chunk-stream0-12000.m4s', $variant->segments[3]->file); + } + + /** + * A timeline may restart its clock, and a segment named for its start time + * has to follow the declaration rather than counting on from the last run. + */ + public function testATimelineRestartingItsClockIsFollowed(): void + { + $this->write('chunk-0-0.m4s', 'a'); + $this->write('chunk-0-9000.m4s', 'b'); + + $manifest = $this->write('manifest.mpd', <<<'XML' + + + + + + + + + + + + + + + + XML); + + $variant = Mpd::read($manifest, $this->dir)['variants'][0]; + + $this->assertCount(2, $variant->segments); + $this->assertSame('chunk-0-0.m4s', $variant->segments[0]->file); + $this->assertSame('chunk-0-9000.m4s', $variant->segments[1]->file); + } + public function testAudioAdaptationSetIsRecognised(): void { $this->write('init-1.m4s', 'init'); diff --git a/tests/Video/Unit/ProcessTest.php b/tests/Video/Unit/ProcessTest.php index 1f4b522..df6d68b 100644 --- a/tests/Video/Unit/ProcessTest.php +++ b/tests/Video/Unit/ProcessTest.php @@ -120,6 +120,53 @@ public function testATimedOutCommandKeepsItsComplaint(): void } } + /** + * A command that had to be stopped is worth telling apart from one that + * failed on its own. proc_close() has nothing left to report once the child + * has been reaped, so the signal it died of is what the failure carries, by + * the shell's convention of 128 plus the signal number. + */ + public function testAStoppedCommandReportsHowItWasStopped(): void + { + try { + Process::run(['sleep', '30'], timeout: 1); + $this->fail('Expected the command to time out'); + } catch (Runtime $exception) { + $this->assertSame(143, $exception->getCode(), 'expected SIGTERM to be reported'); + } + } + + /** + * Asking is not the same as insisting. A command that will not take the + * hint is killed outright once its grace period is up, and says so. + */ + public function testACommandThatWillNotStopIsKilled(): void + { + try { + Process::run(['sh', '-c', 'trap "" TERM; sleep 30'], timeout: 1); + $this->fail('Expected the command to time out'); + } catch (Runtime $exception) { + $this->assertSame(137, $exception->getCode(), 'expected SIGKILL to be reported'); + } + } + + /** + * Collected output is held in memory, so a command that never stops talking + * has to be cut off rather than allowed to exhaust the process running it. + * The tail of stderr has always been bounded; this is the other half. + */ + public function testACollectorRefusesMoreThanItWasAllowed(): void + { + $output = ''; + + try { + Process::run(['sh', '-c', 'yes utopia'], Process::collector($output, 64)); + $this->fail('Expected the collector to give up'); + } catch (Runtime $exception) { + $this->assertStringContainsString('more than 64 bytes', $exception->getMessage()); + } + } + /** * Nothing obliges a stream to send line breaks, and a backend writing * something binary never will, so the buffer is handed over once it has From 577ae1df8a191dd3c773351c046619df46401498 Mon Sep 17 00:00:00 2001 From: shimon Date: Sun, 23 Aug 2026 18:54:12 +0300 Subject: [PATCH 11/12] fix(deps): allow utopia-php/console 0.1.x Only Console::success() and Console::error() are used, both of which exist in 0.1.x. Requiring ^0.2 forced consumers that pin console 0.1.* through utopia-php/database into an inline version alias to resolve at all. Co-authored-by: Cursor --- composer.json | 2 +- composer.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index e6f8ac9..07e7bcd 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "php": ">=8.2", "ext-dom": "*", "ext-json": "*", - "utopia-php/console": "^0.2" + "utopia-php/console": "0.1.* || ^0.2" }, "require-dev": { "phpunit/phpunit": "9.*", diff --git a/composer.lock b/composer.lock index aed8d7d..717524f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b569159b6ddcf97be66d3f7df58dc3ba", + "content-hash": "fd123665d381e37cb9364461b0a83277", "packages": [ { "name": "utopia-php/console", From 8a017f27722adaa4b883f9a971228686aadda7cd Mon Sep 17 00:00:00 2001 From: shimon Date: Tue, 25 Aug 2026 16:52:36 +0300 Subject: [PATCH 12/12] Remove static analysis workflow from GitHub Actions --- .github/workflows/codeql-analysis.yml | 20 ++++++++++++++++++++ .github/workflows/static-analysis.yml | 23 ----------------------- 2 files changed, 20 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/codeql-analysis.yml delete mode 100644 .github/workflows/static-analysis.yml 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/static-analysis.yml b/.github/workflows/static-analysis.yml deleted file mode 100644 index 321106d..0000000 --- a/.github/workflows/static-analysis.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: "Static analysis" - -on: - pull_request: - push: - branches: [ main ] - workflow_dispatch: - -jobs: - phpstan: - name: PHPStan - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Run PHPStan - run: | - docker run --rm -v $PWD:/app composer:2 sh -c \ - "composer install --profile && composer check"