VideoThumbGenerator::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 3
dl 0
loc 5
ccs 4
cts 4
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * @see       https://github.com/soluble-io/soluble-mediatools for the canonical repository
7
 *
8
 * @copyright Copyright (c) 2018-2020 Sébastien Vanvelthem. (https://github.com/belgattitude)
9
 * @license   https://github.com/soluble-io/soluble-mediatools/blob/master/LICENSE.md MIT
10
 */
11
12
namespace Soluble\MediaTools\Video;
13
14
use Psr\Log\LoggerInterface;
15
use Psr\Log\LogLevel;
16
use Psr\Log\NullLogger;
17
use Soluble\MediaTools\Common\Assert\PathAssertionsTrait;
18
use Soluble\MediaTools\Common\Exception\FileEmptyException;
19
use Soluble\MediaTools\Common\Exception\FileNotFoundException;
20
use Soluble\MediaTools\Common\Exception\FileNotReadableException;
21
use Soluble\MediaTools\Common\Exception\UnsupportedParamException;
22
use Soluble\MediaTools\Common\Exception\UnsupportedParamValueException;
23
use Soluble\MediaTools\Common\Process\ProcessFactory;
24
use Soluble\MediaTools\Common\Process\ProcessParamsInterface;
25
use Soluble\MediaTools\Video\Config\FFMpegConfigInterface;
26
use Soluble\MediaTools\Video\Exception\ConverterExceptionInterface;
27
use Soluble\MediaTools\Video\Exception\ConverterProcessExceptionInterface;
28
use Soluble\MediaTools\Video\Exception\InvalidParamException;
29
use Soluble\MediaTools\Video\Exception\MissingFFMpegBinaryException;
30
use Soluble\MediaTools\Video\Exception\MissingInputFileException;
31
use Soluble\MediaTools\Video\Exception\MissingTimeException;
32
use Soluble\MediaTools\Video\Exception\NoOutputGeneratedException;
33
use Soluble\MediaTools\Video\Exception\ProcessFailedException;
34
use Soluble\MediaTools\Video\Exception\ProcessSignaledException;
35
use Soluble\MediaTools\Video\Exception\ProcessTimedOutException;
36
use Soluble\MediaTools\Video\Exception\RuntimeReaderException;
37
use Soluble\MediaTools\Video\Filter\SelectFilter;
38
use Soluble\MediaTools\Video\Filter\VideoFilterChain;
39
use Symfony\Component\Process\Exception as SPException;
40
use Symfony\Component\Process\Process;
41
42
final class VideoThumbGenerator implements VideoThumbGeneratorInterface
43
{
44
    public const DEFAULT_QUALITY_SCALE = 2;
45
46
    use PathAssertionsTrait;
47
48
    /** @var FFMpegConfigInterface */
49
    private $ffmpegConfig;
50
51
    /** @var int */
52
    private $defaultQualityScale;
53
54
    /** @var LoggerInterface */
55
    private $logger;
56
57 17
    public function __construct(FFMpegConfigInterface $ffmpegConfig, ?LoggerInterface $logger = null, int $defaultQualityScale = self::DEFAULT_QUALITY_SCALE)
58
    {
59 17
        $this->ffmpegConfig        = $ffmpegConfig;
60 17
        $this->defaultQualityScale = $defaultQualityScale;
61 17
        $this->logger              = $logger ?? new NullLogger();
62 17
    }
63
64
    /**
65
     * Return ready-to-run symfony process object that you can use
66
     * to `run()` or `start()` programmatically. Useful if you want
67
     * handle the process your way...
68
     *
69
     * @see https://symfony.com/doc/current/components/process.html
70
     *
71
     * @throws UnsupportedParamException
72
     * @throws UnsupportedParamValueException
73
     * @throws MissingTimeException
74
     */
75 13
    public function getSymfonyProcess(string $videoFile, string $thumbnailFile, VideoThumbParamsInterface $thumbParams, ?ProcessParamsInterface $processParams = null): Process
76
    {
77 13
        $adapter = $this->ffmpegConfig->getAdapter();
78
79 13
        $conversionParams = (new VideoConvertParams());
80
81 13
        if (!$thumbParams->hasParam(VideoThumbParamsInterface::PARAM_SEEK_TIME)
82 13
         && !$thumbParams->hasParam(VideoThumbParamsInterface::PARAM_WITH_FRAME)) {
83 1
            throw new MissingTimeException('Missing seekTime/time or frame selection parameter');
84
        }
85
86 12
        if ($thumbParams->hasParam(VideoThumbParamsInterface::PARAM_SEEK_TIME)) {
87
            // TIME params are separated from the rest, so we can inject them
88
            // before input file
89 8
            $timeParams = (new VideoConvertParams())->withSeekStart(
90 8
                $thumbParams->getParam(VideoThumbParamsInterface::PARAM_SEEK_TIME)
91
            );
92
        } else {
93 4
            $timeParams = null;
94
        }
95
96 12
        if ($adapter->getDefaultThreads() !== null) {
97
            $conversionParams = $conversionParams->withThreads($adapter->getDefaultThreads());
98
        }
99
100
        // Only one frame for thumbnails :)
101
        $conversionParams = $conversionParams
102 12
            ->withVideoFrames(1)
103 12
            ->withVideoFilter($this->getThumbFilters($thumbParams));
104
105 12
        if ($thumbParams->hasParam(VideoThumbParamsInterface::PARAM_QUALITY_SCALE)) {
106 2
            $conversionParams = $conversionParams->withVideoQualityScale(
107 2
                $thumbParams->getParam(VideoThumbParamsInterface::PARAM_QUALITY_SCALE)
108
            );
109
        } else {
110 10
            $conversionParams = $conversionParams->withVideoQualityScale(
111 10
                $this->defaultQualityScale
112
            );
113
        }
114
115 12
        $arguments = $adapter->getMappedConversionParams($conversionParams);
116
117 12
        $ffmpegCmd = $adapter->getCliCommand(
118 12
            $arguments,
119
            $videoFile,
120
            $thumbnailFile,
121 12
            $timeParams !== null ? $adapter->getMappedConversionParams($timeParams) : []
122
        );
123
124 12
        $pp = $processParams ?? $this->ffmpegConfig->getProcessParams();
125
126 12
        return (new ProcessFactory($ffmpegCmd, $pp))();
127
    }
128
129
    /**
130
     * @throws ConverterExceptionInterface        Base exception class for conversion exceptions
131
     * @throws ConverterProcessExceptionInterface Base exception class for process conversion exceptions
132
     * @throws MissingInputFileException
133
     * @throws MissingTimeException
134
     * @throws ProcessTimedOutException
135
     * @throws ProcessFailedException
136
     * @throws ProcessSignaledException
137
     * @throws RuntimeReaderException
138
     * @throws InvalidParamException
139
     * @throws NoOutputGeneratedException
140
     */
141 10
    public function makeThumbnail(string $videoFile, string $thumbnailFile, VideoThumbParamsInterface $thumbParams, ?callable $callback = null, ?ProcessParamsInterface $processParams = null): void
142
    {
143
        try {
144
            try {
145 10
                $this->ensureFileReadable($videoFile, true);
146 3
            } catch (FileNotFoundException | FileNotReadableException | FileEmptyException $e) {
147 3
                throw new MissingInputFileException($e->getMessage());
148
            }
149
150
            try {
151 7
                $process = $this->getSymfonyProcess($videoFile, $thumbnailFile, $thumbParams, $processParams);
152 7
                $process->mustRun($callback);
153 3
            } catch (UnsupportedParamValueException | UnsupportedParamException $e) {
154
                throw new InvalidParamException($e->getMessage());
155 3
            } catch (SPException\ProcessSignaledException $e) {
156
                throw new ProcessSignaledException($e->getProcess(), $e);
157 3
            } catch (SPException\ProcessTimedOutException $e) {
158 1
                throw new ProcessTimedOutException($e->getProcess(), $e);
159 2
            } catch (SPException\ProcessFailedException $e) {
160 2
                $process = $e->getProcess();
161 2
                if ($process->getExitCode() === 127 ||
162 2
                    mb_strpos(mb_strtolower($process->getExitCodeText()), 'command not found') !== false) {
163 1
                    throw new MissingFFMpegBinaryException($process, $e);
164
                }
165 1
                throw new ProcessFailedException($process, $e);
166
            } catch (SPException\RuntimeException $e) {
167
                throw new RuntimeReaderException($e->getMessage());
168
            }
169
170 4
            if (!file_exists($thumbnailFile) || filesize($thumbnailFile) === 0) {
171 1
                $stdErr        = array_filter(explode("\n", trim($process->getErrorOutput())));
172 1
                $lastErrorLine = count($stdErr) > 0 ? $stdErr[count($stdErr) - 1] : 'no error message';
173 1
                throw new NoOutputGeneratedException(sprintf(
174 4
                    'Thumbnail was not generated, probably an invalid time/frame selection (ffmpeg: %s)',
175
                    $lastErrorLine
176
                ));
177
            }
178 7
        } catch (\Throwable $e) {
179 7
            $exceptionNs = explode('\\', get_class($e));
180 7
            $this->logger->log(
181 7
                ($e instanceof MissingInputFileException) ? LogLevel::WARNING : LogLevel::ERROR,
182 7
                sprintf(
183 7
                    'VideoThumbGenerator %s: file \'%s\', thumbnail \'%s\'. (%s)',
184 7
                    $exceptionNs[count($exceptionNs) - 1],
185
                    $videoFile,
186
                    $thumbnailFile,
187 7
                    $e->getMessage()
188
                )
189
            );
190 7
            throw $e;
191
        }
192 3
    }
193
194 12
    private function getThumbFilters(VideoThumbParamsInterface $thumbParams): VideoFilterChain
195
    {
196 12
        $videoFilters = new VideoFilterChain();
197
198
        // Let's choose a frame
199 12
        if ($thumbParams->hasParam(VideoThumbParamsInterface::PARAM_WITH_FRAME)) {
200 4
            $frame      = $thumbParams->getParam(VideoThumbParamsInterface::PARAM_WITH_FRAME);
201 4
            $expression = sprintf('eq(n\,%d)', max(0, $frame - 1));
202 4
            $videoFilters->addFilter(new SelectFilter($expression));
203
        }
204
205
        // Let's add the remaning filters
206 12
        if ($thumbParams->hasParam(VideoThumbParamsInterface::PARAM_VIDEO_FILTER)) {
207 4
            $videoFilters->addFilter(
208 4
                $thumbParams->getParam(VideoThumbParamsInterface::PARAM_VIDEO_FILTER)
209
            );
210
        }
211
212 12
        return $videoFilters;
213
    }
214
}
215