Test Failed
Push — master ( 7e7df5...676302 )
by Mostafa
07:11 queued 03:20
created

FFMpeg::run()   B

Complexity

Conditions 6
Paths 6

Size

Total Lines 42
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 5
Bugs 0 Features 0
Metric Value
cc 6
eloc 27
c 5
b 0
f 0
nc 6
nop 4
dl 0
loc 42
rs 8.8657
1
<?php
2
3
namespace Mostafaznv\Larupload\Storage;
4
5
use Exception;
6
use Illuminate\Http\UploadedFile;
7
use Mostafaznv\Larupload\Helpers\LaraTools;
8
use Mostafaznv\Larupload\LaruploadEnum;
9
use Symfony\Component\HttpFoundation\File\File;
10
use Illuminate\Support\Facades\Storage;
11
use Symfony\Component\Process\Process;
12
13
class FFMpeg
14
{
15
    use LaraTools;
0 ignored issues
show
introduced by
The trait Mostafaznv\Larupload\Helpers\LaraTools requires some properties which are not provided by Mostafaznv\Larupload\Storage\FFMpeg: $mode, $folder, $nameKebab
Loading history...
16
17
    /**
18
     * Attached file
19
     *
20
     * @var UploadedFile
21
     */
22
    protected UploadedFile $file;
23
24
    /**
25
     * Storage Disk
26
     *
27
     * @var string
28
     */
29
    protected string $disk;
30
31
    /**
32
     * Storage local disk
33
     *
34
     * @var string
35
     */
36
    protected string $localDisk;
37
38
    /**
39
     * Video Metadata
40
     *
41
     * @var array
42
     */
43
    protected array $meta = [];
44
45
    /**
46
     * FFMPEG binary address
47
     *
48
     * @var string
49
     */
50
    protected string $ffmpeg;
51
52
    /**
53
     * FFProbe binary address
54
     *
55
     * @var string
56
     */
57
    protected string $ffprobe;
58
59
    /**
60
     * Timeout
61
     *
62
     * @var ?int
63
     */
64
    protected ?int $timeout = null;
65
66
    /**
67
     * Default scale size
68
     * we use this value if width and height both were undefined
69
     *
70
     * @var integer
71
     */
72
    const DEFAULT_SCALE = 850;
73
74
    /**
75
     * FFMpeg constructor
76
     *
77
     * @param UploadedFile $file
78
     * @param string $disk
79
     * @param string $localDisk
80
     */
81
    public function __construct(UploadedFile $file, string $disk, string $localDisk)
82
    {
83
        $this->file = $file;
84
        $this->disk = $disk;
85
        $this->localDisk = $localDisk;
86
87
        $config = config('larupload.ffmpeg');
88
89
        $this->ffmpeg = $config['ffmpeg-binaries'];
90
        $this->ffprobe = $config['ffprobe-binaries'];
91
        $this->timeout = $config['timeout'];
92
    }
93
94
    /**
95
     * Get Metadata from media file using ffprobe
96
     *
97
     * @return array
98
     * @throws Exception
99
     */
100
    public function getMeta(): array
101
    {
102
        if (empty($this->meta)) {
103
            $path = $this->file->getRealPath();
104
            $meta = [
105
                'width'    => null,
106
                'height'   => null,
107
                'duration' => 0,
108
            ];
109
110
            $cmd = $this->cmd("$this->ffprobe -i $path -loglevel quiet -show_format -show_streams -print_format json");
111
112
            $process = new Process($cmd);
113
            $process->setTimeout($this->timeout);
114
            $process->setIdleTimeout($this->timeout);
115
            $process->run();
116
            $output = $process->getOutput();
117
118
            if ($process->isSuccessful()) {
119
                $output = json_decode($output);
120
121
                if ($output !== null) {
122
                    $rotate = 0;
123
124
                    foreach ($output->streams as $stream) {
125
                        if (isset($stream->width)) {
126
                            $meta['width'] = (int)$stream->width;
127
                        }
128
129
                        if (isset($stream->height)) {
130
                            $meta['height'] = (int)$stream->height;
131
                        }
132
133
                        if (isset($stream->duration)) {
134
                            $meta['duration'] = (int)$stream->duration;
135
                        }
136
137
                        if (isset($stream->tags->rotate)) {
138
                            $rotate = $stream->tags->rotate;
139
                        }
140
                    }
141
142
                    if ($rotate == 90 or $rotate == 270) {
143
                        list($meta['height'], $meta['width']) = array($meta['width'], $meta['height']);
144
                    }
145
                }
146
                else {
147
                    $process->addErrorOutput('ffprobe output is null');
148
                    throw new Exception($process->getErrorOutput());
149
                }
150
            }
151
            else {
152
                throw new Exception($process->getErrorOutput());
153
            }
154
155
            $this->meta = $meta;
156
        }
157
158
        return $this->meta;
159
    }
160
161
    /**
162
     * Capture screen shot from video file
163
     *
164
     * @param $fromSecond
165
     * @param array $style
166
     * @param string $saveTo
167
     * @param bool $withDominantColor
168
     * @return string|null
169
     * @throws Exception
170
     */
171
    public function capture($fromSecond, array $style, string $saveTo, bool $withDominantColor): ?string
172
    {
173
        $meta = $this->getMeta();
174
        $width = $style['width'] ?? null;
175
        $height = $style['height'] ?? null;
176
        $scale = $width ?: ($height ?: self::DEFAULT_SCALE);
177
        $scaleType = $meta['width'] >= $meta['height'] ? "-1:$scale" : "$scale:-1";
178
        $mode = $style['mode'] ?? null;
179
        $path = $this->file->getRealPath();
180
        $saveTo = Storage::disk($this->disk)->path($saveTo);
181
182
        if (is_null($fromSecond)) {
183
            $fromSecond = floor($meta['duration'] / 2);
184
            $fromSecond = number_format($fromSecond, 1);
185
        }
186
187
        if ($mode == LaruploadEnum::CROP_STYLE_MODE) {
188
            if ($width and $height) {
189
                $cmd = escapeshellcmd("$this->ffmpeg -ss $fromSecond -i $path -vframes 1 -filter scale=$scaleType,crop=$width:$height");
190
            }
191
            else {
192
                $cmd = escapeshellcmd("$this->ffmpeg -ss $fromSecond -i $path -vframes 1 -filter scale=$scaleType,crop=$scale:$scale");
193
            }
194
        }
195
        else {
196
            $cmd = escapeshellcmd("$this->ffmpeg -ss $fromSecond -i $path -vframes 1 -filter scale=$scaleType");
197
        }
198
199
        return $this->run($cmd, $saveTo, null, $withDominantColor);
200
    }
201
202
    /**
203
     * Manipulate original video file to crop/resize
204
     *
205
     * @param array $style
206
     * @param string $saveTo
207
     * @throws Exception
208
     */
209
    public function manipulate(array $style, string $saveTo): void
210
    {
211
        $width = $style['width'] ?? null;
212
        $height = $style['height'] ?? null;
213
        $mode = $style['mode'] ?? null;
214
        $scale = $this->calculateScale($mode, $width, $height);
215
        $path = $this->file->getRealPath();
216
        $saveTo = Storage::disk($this->disk)->path($saveTo);
217
218
        if ($mode == LaruploadEnum::CROP_STYLE_MODE) {
219
            if ($scale) {
220
                $cmd = escapeshellcmd("$this->ffmpeg -i $path -vf scale=$scale,crop=$width:$height,setsar=1");
221
            }
222
            else {
223
                $cmd = escapeshellcmd("$this->ffmpeg -i $path -vf crop=$width:$height,setsar=1");
224
            }
225
        }
226
        else {
227
            $cmd = escapeshellcmd("$this->ffmpeg -i $path -vf scale=$scale,setsar=1");
228
        }
229
230
        $this->run($cmd, $saveTo);
231
    }
232
233
    /**
234
     * Stream - Generate HLS video from source file
235
     *
236
     * @param array $styles
237
     * @param string $basePath
238
     * @param string $fileName
239
     * @return bool
240
     * @throws Exception
241
     */
242
    public function stream(array $styles, string $basePath, string $fileName): bool
243
    {
244
        $playlist = "#EXTM3U\n#EXT-X-VERSION:3\n";
245
        $converted = [];
246
        $driverIsLocal = $this->diskDriverIsLocal($this->disk);
247
        $disk = $driverIsLocal ? $this->disk : $this->localDisk;
248
249
        // generate multiple video qualities from uploaded video.
250
        foreach ($styles as $name => $style) {
251
            $width = $style['width'];
252
            $height = $style['height'];
253
            $path = $this->file->getRealPath();
254
            $audioBitRate = $style['bitrate']['audio'];
255
            $videoBitRate = $style['bitrate']['video'];
256
            $styleBasePath = "$basePath/$name-convert";
257
258
            Storage::disk($disk)->makeDirectory($styleBasePath);
259
            $saveTo = Storage::disk($disk)->path("$styleBasePath/$name.mp4");
260
261
            $cmd = escapeshellcmd("$this->ffmpeg -y -i $path -s {$width}x$height -y -strict experimental -acodec aac -b:a $audioBitRate -ac 2 -ar 48000 -vcodec libx264 -vprofile main -g 48 -b:v $videoBitRate -threads 64");
262
            $this->run($cmd, $saveTo, $disk);
263
264
            $converted[$name] = [
265
                'path'      => $styleBasePath,
266
                'file'      => $saveTo,
267
                'bandwidth' => $videoBitRate,
268
                'width'     => $width,
269
                'height'    => $height,
270
            ];
271
        }
272
273
        // convert generated videos to ts
274
        foreach ($converted as $name => $value) {
275
            $m3u8 = 'chunk-list.m3u8';
276
            $streamBasePath = "$basePath/$name";
277
            Storage::disk($this->disk)->makeDirectory($streamBasePath);
278
            $streamBasePath = Storage::disk($this->disk)->path($streamBasePath);
279
280
            $cmd = escapeshellcmd("$this->ffmpeg -y -i {$value['file']} -hls_time 9 -hls_segment_filename :stream-path/file-sequence-%d.ts -hls_playlist_type vod :stream-path/$m3u8");
281
            $this->streamRun($cmd, $streamBasePath);
282
283
            $playlist .= "#EXT-X-STREAM-INF:BANDWIDTH={$value['bandwidth']},RESOLUTION={$value['width']}x{$value['height']}\n";
284
            $playlist .= "$name/$m3u8\n";
285
286
            Storage::disk($disk)->deleteDirectory($value['path']);
287
        }
288
289
        if (count($converted)) {
290
            Storage::disk($this->disk)->put("$basePath/$fileName", $playlist);
291
            return true;
292
        }
293
294
        return false;
295
    }
296
297
    /**
298
     * Calculate scale
299
     *
300
     * @param string|null $mode
301
     * @param int|null $width
302
     * @param int|null $height
303
     * @return string
304
     * @throws Exception
305
     */
306
    protected function calculateScale(string $mode = null, int $width = null, int $height = null): string
307
    {
308
        $meta = $this->getMeta();
309
310
        if ($mode == LaruploadEnum::CROP_STYLE_MODE) {
311
            if ($width >= $meta['width'] or $height >= $meta['height']) {
312
                if ($meta['width'] >= $meta['height']) {
313
                    $scale = ceil(($meta['width'] * $height) / $meta['height']);
314
315
                    if ($scale < $width) {
316
                        $scale = $width;
317
                    }
318
319
                    $scale = "$scale:-2";
320
                }
321
                else {
322
                    $scale = ceil(($meta['height'] * $width) / $meta['width']);
323
324
                    if ($scale < $height) {
325
                        $scale = $height;
326
                    }
327
328
                    $scale = "-2:$scale";
329
                }
330
            }
331
            else {
332
                $scale = '';
333
            }
334
        }
335
        else {
336
            $scale = $width ? "$width:-2" : ($height ? "-2:$height" : (self::DEFAULT_SCALE . ':-2'));
337
        }
338
339
        return $scale;
340
    }
341
342
    /**
343
     * Run ffmpeg command.
344
     * Handle local/non-local drivers
345
     *
346
     * @param string $cmd
347
     * @param string $saveTo
348
     * @param string|null $disk
349
     * @param bool $withDominantColor
350
     * @return string|null
351
     * @throws Exception
352
     */
353
    protected function run(string $cmd, string $saveTo, string $disk = null, bool $withDominantColor = false): ?string
354
    {
355
        $disk = $disk ?? $this->disk;
356
357
        if ($this->diskDriverIsLocal($disk)) {
358
            $cmd = $this->cmd("$cmd $saveTo");
359
            $process = new Process($cmd);
360
            $process->setTimeout($this->timeout);
361
            $process->setIdleTimeout($this->timeout);
362
            $process->run();
363
364
            if ($process->isSuccessful()) {
365
                return $withDominantColor ? $this->calcDominantColor($saveTo) : null;
366
            }
367
368
            throw new Exception($process->getErrorOutput());
369
        }
370
        else {
371
            list($path, $name) = $this->splitPath($saveTo);
372
373
            $tempDir = $this->tempDir();
374
            $tempName = time() . '-' . $name;
0 ignored issues
show
Bug introduced by
Are you sure $name of type array|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

374
            $tempName = time() . '-' . /** @scrutinizer ignore-type */ $name;
Loading history...
375
            $temp = "$tempDir/$tempName";
376
377
            $cmd = $this->cmd("$cmd $temp");
378
            $process = new Process($cmd);
379
            $process->setTimeout($this->timeout);
380
            $process->setIdleTimeout($this->timeout);
381
            $process->run();
382
383
            if ($process->isSuccessful()) {
384
                $file = new File($temp);
385
386
                $dominantColor = $withDominantColor ? $this->calcDominantColor($temp) : null;
387
                Storage::disk($disk)->putFileAs($path, $file, $name);
0 ignored issues
show
Bug introduced by
It seems like $name can also be of type array; however, parameter $name of Illuminate\Filesystem\Fi...temAdapter::putFileAs() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

387
                Storage::disk($disk)->putFileAs($path, $file, /** @scrutinizer ignore-type */ $name);
Loading history...
388
389
                @unlink($temp);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

389
                /** @scrutinizer ignore-unhandled */ @unlink($temp);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
390
391
                return $dominantColor;
392
            }
393
394
            throw new Exception($process->getErrorOutput());
395
        }
396
    }
397
398
    /**
399
     * Run ffmpeg command for stream videos
400
     * Handle local/non-local drivers
401
     *
402
     * @param string $cmd
403
     * @param string $streamPath
404
     * @throws Exception
405
     */
406
    protected function streamRun(string $cmd, string $streamPath): void
407
    {
408
        if ($this->diskDriverIsLocal($this->disk)) {
409
            $cmd = $this->cmd(str_replace(':stream-path', $streamPath, $cmd));
410
411
            $process = new Process($cmd);
412
            $process->setTimeout($this->timeout);
413
            $process->setIdleTimeout($this->timeout);
414
            $process->run();
415
416
            if ($process->isSuccessful()) {
417
                return;
418
            }
419
420
            throw new Exception($process->getErrorOutput());
421
        }
422
        else {
423
            $name = basename($streamPath);
424
            $temp = $name . '-' . time();
425
426
            Storage::disk($this->localDisk)->makeDirectory($temp);
427
            $path = Storage::disk($this->localDisk)->path($temp);
428
429
            $cmd = $this->cmd(str_replace(':stream-path', $path, $cmd));
430
431
            $process = new Process($cmd);
432
            $process->setTimeout($this->timeout);
433
            $process->setIdleTimeout($this->timeout);
434
            $process->run();
435
436
            if ($process->isSuccessful()) {
437
                $files = Storage::disk($this->localDisk)->files($temp);
438
                $path = Storage::disk($this->localDisk)->path('');
439
440
                foreach ($files as $file) {
441
                    $fileObject = new File("$path/$file");
442
443
                    Storage::disk($this->disk)->putFileAs($streamPath, $fileObject, $fileObject->getFilename());
444
445
                    unset($fileObject);
446
                }
447
448
                Storage::disk($this->localDisk)->deleteDirectory($temp);
449
450
                return;
451
            }
452
453
            throw new Exception($process->getErrorOutput());
454
        }
455
    }
456
457
    /**
458
     * Make Normal CMD
459
     *
460
     * @param string $cmd
461
     * @return array
462
     */
463
    protected function cmd(string $cmd): array
464
    {
465
        $cmd = str_replace('\\', '/', $cmd);
466
467
        return explode(' ', escapeshellcmd($cmd));
468
    }
469
470
    /**
471
     * Calculate Dominant Color
472
     *
473
     * @param $path
474
     * @return string|null
475
     */
476
    protected function calcDominantColor($path): ?string
477
    {
478
        $file = new UploadedFile($path, basename($path));
479
480
        return (new Image($file, $this->disk, $this->localDisk, LaruploadEnum::GD_IMAGE_LIBRARY))->getDominantColor();
481
    }
482
}
483