Completed
Push — master ( f72769...eb1d3e )
by Sébastien
08:28 queued 06:05
created

VideoThumbGenerator::__construct()   A

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