Passed
Push — v1 ( 4ff485...58f920 )
by Andrew
11:45 queued 05:27
created

src/services/Transcode.php (2 issues)

1
<?php
2
/**
3
 * Transcoder plugin for Craft CMS 3.x
4
 *
5
 * Transcode videos to various formats, and provide thumbnails of the video
6
 *
7
 * @link      https://nystudio107.com
8
 * @copyright Copyright (c) 2017 nystudio107
9
 */
10
11
namespace nystudio107\transcoder\services;
12
13
use nystudio107\transcoder\Transcoder;
14
15
use Craft;
16
use craft\base\Component;
17
use craft\elements\Asset;
18
use craft\events\AssetThumbEvent;
19
use craft\helpers\FileHelper;
20
use craft\helpers\Json as JsonHelper;
21
use craft\volumes\Local;
22
23
use yii\base\Exception;
24
use yii\validators\UrlValidator;
25
26
use mikehaertl\shellcommand\Command as ShellCommand;
27
use yii\base\InvalidConfigException;
28
29
/**
30
 * @author    nystudio107
31
 * @package   Transcode
32
 * @since     1.0.0
33
 */
34
class Transcode extends Component
35
{
36
    // Constants
37
    // =========================================================================
38
39
    // Suffixes to add to the generated filename params
40
    const SUFFIX_MAP = [
41
        'videoFrameRate' => 'fps',
42
        'videoBitRate' => 'bps',
43
        'audioBitRate' => 'bps',
44
        'audioChannels' => 'c',
45
        'height' => 'h',
46
        'width' => 'w',
47
        'timeInSecs' => 's',
48
    ];
49
50
    // Params that should be excluded from being part of the generated filename
51
    const EXCLUDE_PARAMS = [
52
        'videoEncoder',
53
        'audioEncoder',
54
        'fileSuffix',
55
        'sharpen',
56
    ];
57
58
    // Mappings for getFileInfo() summary values
59
    const INFO_SUMMARY = [
60
        'format' => [
61
            'filename' => 'filename',
62
            'duration' => 'duration',
63
            'size' => 'size',
64
        ],
65
        'audio' => [
66
            'codec_name' => 'audioEncoder',
67
            'bit_rate' => 'audioBitRate',
68
            'sample_rate' => 'audioSampleRate',
69
            'channels' => 'audioChannels',
70
        ],
71
        'video' => [
72
            'codec_name' => 'videoEncoder',
73
            'bit_rate' => 'videoBitRate',
74
            'avg_frame_rate' => 'videoFrameRate',
75
            'height' => 'height',
76
            'width' => 'width',
77
        ],
78
    ];
79
80
    // Public Methods
81
    // =========================================================================
82
83
    /**
84
     * Returns a URL to the transcoded video or "" if it doesn't exist (at
85
     * which
86
     * time it will create it).
87
     *
88
     * @param      $filePath     string  path to the original video -OR- an
89
     *                           Asset
90
     * @param      $videoOptions array   of options for the video
91
     * @param bool $generate     whether the video should be encoded
92
     *
93
     * @return string       URL of the transcoded video or ""
94
     */
95
    public function getVideoUrl($filePath, $videoOptions, $generate = true): string
96
    {
97
        $result = '';
98
        $settings = Transcoder::$plugin->getSettings();
99
        $subfolder = '';
100
101
        // sub folder check
102
        if (\is_object($filePath) && ($filePath instanceof Asset) && $settings['createSubfolders']) {
103
            $subfolder = $filePath->folderPath;
104
        }
105
106
        // file path
107
        $filePath = $this->getAssetPath($filePath);
108
109
        if (!empty($filePath)) {
110
            $destVideoPath = $settings['transcoderPaths']['video'].$subfolder ?? $settings['transcoderPaths']['default'];
111
            $destVideoPath = Craft::parseEnv($destVideoPath);
112
            $videoOptions = $this->coalesceOptions('defaultVideoOptions', $videoOptions);
113
114
            // Get the video encoder presets to use
115
            $videoEncoders = $settings['videoEncoders'];
116
            $thisEncoder = $videoEncoders[$videoOptions['videoEncoder']];
117
118
            $videoOptions['fileSuffix'] = $thisEncoder['fileSuffix'];
119
120
            // Build the basic command for ffmpeg
121
            $ffmpegCmd = $settings['ffmpegPath']
122
                .' -i '.escapeshellarg($filePath)
123
                .' -vcodec '.$thisEncoder['videoCodec']
124
                .' '.$thisEncoder['videoCodecOptions']
125
                .' -bufsize 1000k'
126
                .' -threads '.$thisEncoder['threads'];
127
128
            // Set the framerate if desired
129
            if (!empty($videoOptions['videoFrameRate'])) {
130
                $ffmpegCmd .= ' -r '.$videoOptions['videoFrameRate'];
131
            }
132
133
            // Set the bitrate if desired
134
            if (!empty($videoOptions['videoBitRate'])) {
135
                $ffmpegCmd .= ' -b:v '.$videoOptions['videoBitRate'].' -maxrate '.$videoOptions['videoBitRate'];
136
            }
137
138
            // Adjust the scaling if desired
139
            $ffmpegCmd = $this->addScalingFfmpegArgs(
140
                $videoOptions,
141
                $ffmpegCmd
142
            );
143
144
            // Handle any audio transcoding
145
            if (empty($videoOptions['audioBitRate'])
146
                && empty($videoOptions['audioSampleRate'])
147
                && empty($videoOptions['audioChannels'])
148
            ) {
149
                // Just copy the audio if no options are provided
150
                $ffmpegCmd .= ' -c:a copy';
151
            } else {
152
                // Do audio transcoding based on the settings
153
                $ffmpegCmd .= ' -acodec '.$thisEncoder['audioCodec'];
154
                if (!empty($videoOptions['audioBitRate'])) {
155
                    $ffmpegCmd .= ' -b:a '.$videoOptions['audioBitRate'];
156
                }
157
                if (!empty($videoOptions['audioSampleRate'])) {
158
                    $ffmpegCmd .= ' -ar '.$videoOptions['audioSampleRate'];
159
                }
160
                if (!empty($videoOptions['audioChannels'])) {
161
                    $ffmpegCmd .= ' -ac '.$videoOptions['audioChannels'];
162
                }
163
                $ffmpegCmd .= ' '.$thisEncoder['audioCodecOptions'];
164
            }
165
166
            // Create the directory if it isn't there already
167
            if (!is_dir($destVideoPath)) {
168
                try {
169
                    FileHelper::createDirectory($destVideoPath);
170
                } catch (Exception $e) {
171
                    Craft::error($e->getMessage(), __METHOD__);
172
                }
173
            }
174
175
            $destVideoFile = $this->getFilename($filePath, $videoOptions);
176
177
            // File to store the video encoding progress in
178
            $progressFile = sys_get_temp_dir().DIRECTORY_SEPARATOR.$destVideoFile.'.progress';
179
180
            // Assemble the destination path and final ffmpeg command
181
            $destVideoPath .= $destVideoFile;
182
            $ffmpegCmd .= ' -f '
183
                .$thisEncoder['fileFormat']
184
                .' -y '.escapeshellarg($destVideoPath)
185
                .' 1> '.$progressFile.' 2>&1 & echo $!';
186
187
            // Make sure there isn't a lockfile for this video already
188
            $lockFile = sys_get_temp_dir().DIRECTORY_SEPARATOR.$destVideoFile.'.lock';
189
            $oldPid = @file_get_contents($lockFile);
190
            if ($oldPid !== false) {
191
                exec("ps $oldPid", $ProcessState);
192
                if (\count($ProcessState) >= 2) {
193
                    return $result;
194
                }
195
                // It's finished transcoding, so delete the lockfile and progress file
196
                @unlink($lockFile);
197
                @unlink($progressFile);
198
            }
199
200
            // If the video file already exists and hasn't been modified, return it.  Otherwise, start it transcoding
201
            if (file_exists($destVideoPath) && (@filemtime($destVideoPath) >= @filemtime($filePath))) {
202
                $url = $settings['transcoderUrls']['video'].$subfolder ?? $settings['transcoderUrls']['default'];
203
                $result = Craft::parseEnv($url).$destVideoFile;
204
                // skip encoding
205
            } elseif (!$generate) {
206
                $result = "";
207
            } else {
208
                // Kick off the transcoding
209
                $pid = $this->executeShellCommand($ffmpegCmd);
210
                Craft::info($ffmpegCmd."\nffmpeg PID: ".$pid, __METHOD__);
211
212
                // Create a lockfile in tmp
213
                file_put_contents($lockFile, $pid);
214
            }
215
        }
216
217
        return $result;
218
    }
219
220
    /**
221
     * Returns a URL to a video thumbnail
222
     *
223
     * @param string $filePath         path to the original video or an Asset
224
     * @param array  $thumbnailOptions of options for the thumbnail
225
     * @param bool   $generate         whether the thumbnail should be
226
     *                                 generated if it doesn't exists
227
     * @param bool   $asPath           Whether we should return a path or not
228
     *
229
     * @return string|false|null URL or path of the video thumbnail
230
     */
231
    public function getVideoThumbnailUrl($filePath, $thumbnailOptions, $generate = true, $asPath = false)
232
    {
233
        $result = null;
234
        $settings = Transcoder::$plugin->getSettings();
235
        $subfolder = '';
236
237
        // sub folder check
238
        if (\is_object($filePath) && ($filePath instanceof Asset) && $settings['createSubfolders']) {
239
            $subfolder = $filePath->folderPath;
240
        }
241
242
        $filePath = $this->getAssetPath($filePath);
243
244
        if (!empty($filePath)) {
245
            $destThumbnailPath = $settings['transcoderPaths']['thumbnail'].$subfolder ?? $settings['transcoderPaths']['default'];
246
            $destThumbnailPath = Craft::parseEnv($destThumbnailPath);
247
248
            $thumbnailOptions = $this->coalesceOptions('defaultThumbnailOptions', $thumbnailOptions);
249
250
            // Build the basic command for ffmpeg
251
            $ffmpegCmd = $settings['ffmpegPath']
252
                .' -i '.escapeshellarg($filePath)
253
                .' -vcodec mjpeg'
254
                .' -vframes 1';
255
256
            // Adjust the scaling if desired
257
            $ffmpegCmd = $this->addScalingFfmpegArgs(
258
                $thumbnailOptions,
259
                $ffmpegCmd
260
            );
261
262
            // Set the timecode to get the thumbnail from if desired
263
            if (!empty($thumbnailOptions['timeInSecs'])) {
264
                $timeCode = gmdate('H:i:s', $thumbnailOptions['timeInSecs']);
265
                $ffmpegCmd .= ' -ss '.$timeCode.'.00';
266
            }
267
268
            // Create the directory if it isn't there already
269
            if (!is_dir($destThumbnailPath)) {
270
                try {
271
                    FileHelper::createDirectory($destThumbnailPath);
272
                } catch (Exception $e) {
273
                    Craft::error($e->getMessage(), __METHOD__);
274
                }
275
            }
276
277
            $destThumbnailFile = $this->getFilename($filePath, $thumbnailOptions);
278
279
            // Assemble the destination path and final ffmpeg command
280
            $destThumbnailPath .= $destThumbnailFile;
281
            $ffmpegCmd .= ' -f image2 -y '.escapeshellarg($destThumbnailPath).' >/dev/null 2>/dev/null &';
282
283
            // If the thumbnail file already exists, return it.  Otherwise, generate it and return it
284
            if (!file_exists($destThumbnailPath)) {
285
                if ($generate) {
286
                    /** @noinspection PhpUnusedLocalVariableInspection */
287
                    $shellOutput = $this->executeShellCommand($ffmpegCmd);
288
                    Craft::info($ffmpegCmd, __METHOD__);
289
290
                    // if ffmpeg fails which we can't check because the process is ran in the background
291
                    // dont return the future path of the image or else we can't check this in the front end
292
293
                    return false;
294
                } else {
295
                    Craft::info('Thumbnail does not exist, but not asked to generate it: '.$filePath, __METHOD__);
296
297
                    // The file doesn't exist, and we weren't asked to generate it
298
                    return false;
299
                }
300
            }
301
            // Return either a path or a URL
302
            if ($asPath) {
303
                $result = $destThumbnailPath;
304
            } else {
305
                $url = $settings['transcoderUrls']['thumbnail'].$subfolder ?? $settings['transcoderUrls']['default'];
306
                $result = Craft::parseEnv($url).$destThumbnailFile;
307
            }
308
        }
309
310
        return $result;
311
    }
312
313
    /**
314
     * Returns a URL to the transcoded audio file or "" if it doesn't exist
315
     * (at which time it will create it).
316
     *
317
     * @param $filePath     string path to the original audio file -OR- an Asset
318
     * @param $audioOptions array of options for the audio file
319
     *
320
     * @return string       URL of the transcoded audio file or ""
321
     */
322
    public function getAudioUrl($filePath, $audioOptions): string
323
    {
324
        $result = '';
325
        $settings = Transcoder::$plugin->getSettings();
326
        $subfolder = '';
327
328
        // sub folder check
329
        if (\is_object($filePath) && ($filePath instanceof Asset) && $settings['createSubfolders']) {
330
            $subfolder = $filePath->folderPath;
331
        }
332
333
        $filePath = $this->getAssetPath($filePath);
334
335
        if (!empty($filePath)) {
336
            $destAudioPath = $settings['transcoderPaths']['audio'].$subfolder ?? $settings['transcoderPaths']['default'];
337
            $destAudioPath = Craft::parseEnv($destAudioPath);
338
339
            $audioOptions = $this->coalesceOptions('defaultAudioOptions', $audioOptions);
340
341
            // Get the audio encoder presets to use
342
            $audioEncoders = $settings['audioEncoders'];
343
            $thisEncoder = $audioEncoders[$audioOptions['audioEncoder']];
344
345
            $audioOptions['fileSuffix'] = $thisEncoder['fileSuffix'];
346
347
            // Build the basic command for ffmpeg
348
            $ffmpegCmd = $settings['ffmpegPath']
349
                .' -i '.escapeshellarg($filePath)
350
                .' -acodec '.$thisEncoder['audioCodec']
351
                .' '.$thisEncoder['audioCodecOptions']
352
                .' -bufsize 1000k'
353
                .' -vn'
354
                .' -threads '.$thisEncoder['threads'];
355
356
            // Set the bitrate if desired
357
            if (!empty($audioOptions['audioBitRate'])) {
358
                $ffmpegCmd .= ' -b:a '.$audioOptions['audioBitRate'];
359
            }
360
            // Set the sample rate if desired
361
            if (!empty($audioOptions['audioSampleRate'])) {
362
                $ffmpegCmd .= ' -ar '.$audioOptions['audioSampleRate'];
363
            }
364
            // Set the audio channels if desired
365
            if (!empty($audioOptions['audioChannels'])) {
366
                $ffmpegCmd .= ' -ac '.$audioOptions['audioChannels'];
367
            }
368
            $ffmpegCmd .= ' '.$thisEncoder['audioCodecOptions'];
369
370
            if (!empty($audioOptions['seekInSecs'])) {
371
                $ffmpegCmd .= ' -ss '.$audioOptions['seekInSecs'];
372
            }
373
374
            if (!empty($audioOptions['timeInSecs'])) {
375
                $ffmpegCmd .= ' -t '.$audioOptions['timeInSecs'];
376
            }
377
378
            // Create the directory if it isn't there already
379
            if (!is_dir($destAudioPath)) {
380
                try {
381
                    FileHelper::createDirectory($destAudioPath);
382
                } catch (Exception $e) {
383
                    Craft::error($e->getMessage(), __METHOD__);
384
                }
385
            }
386
387
            $destAudioFile = $this->getFilename($filePath, $audioOptions);
388
389
            // File to store the audio encoding progress in
390
            $progressFile = sys_get_temp_dir().DIRECTORY_SEPARATOR.$destAudioFile.'.progress';
391
392
            // Assemble the destination path and final ffmpeg command
393
            $destAudioPath .= $destAudioFile;
394
            // Handle the `stripMetadata` setting
395
            $stripMetadata = false;
396
            if (!empty($audioOptions['stripMetadata'])) {
397
                $stripMetadata = $audioOptions['stripMetadata'];
398
            }
399
            if ($stripMetadata) {
400
                $ffmpegCmd .= ' -map_metadata -1 ';
401
            }
402
            // Add the file format
403
            $ffmpegCmd .= ' -f '
404
                .$thisEncoder['fileFormat']
405
                .' -y '.escapeshellarg($destAudioPath);
406
            // Handle the `synchronous` setting
407
            $synchronous = false;
408
            if (!empty($audioOptions['synchronous'])) {
409
                $synchronous = $audioOptions['synchronous'];
410
            }
411
            if (!$synchronous) {
412
                $ffmpegCmd .=' 1> '.$progressFile.' 2>&1 & echo $!';
413
                // Make sure there isn't a lockfile for this audio file already
414
                $lockFile = sys_get_temp_dir().DIRECTORY_SEPARATOR.$destAudioFile.'.lock';
415
                $oldPid = @file_get_contents($lockFile);
416
                if ($oldPid !== false) {
417
                    exec("ps $oldPid", $ProcessState);
418
                    if (\count($ProcessState) >= 2) {
419
                        return $result;
420
                    }
421
                    // It's finished transcoding, so delete the lockfile and progress file
422
                    @unlink($lockFile);
423
                    @unlink($progressFile);
424
                }
425
            }
426
427
            // If the audio file already exists and hasn't been modified, return it.  Otherwise, start it transcoding
428
            if (file_exists($destAudioPath) && (@filemtime($destAudioPath) >= @filemtime($filePath))) {
429
                $url = $settings['transcoderUrls']['audio'].$subfolder ?? $settings['transcoderUrls']['default'];
430
                $result = Craft::parseEnv($url).$destAudioFile;
431
            } else {
432
                // Kick off the transcoding
433
                $pid = $this->executeShellCommand($ffmpegCmd);
434
435
                if ($synchronous) {
436
                    Craft::info($ffmpegCmd, __METHOD__);
437
                    $url = $settings['transcoderUrls']['audio'] . $subfolder ?? $settings['transcoderUrls']['default'];
438
                    $result = Craft::parseEnv($url).$destAudioFile;
439
                } else {
440
                    Craft::info($ffmpegCmd."\nffmpeg PID: ".$pid, __METHOD__);
441
                    // Create a lockfile in tmp
442
                    file_put_contents($lockFile, $pid);
443
                }
444
            }
445
        }
446
447
        return $result;
448
    }
449
450
    /**
451
     * Extract information from a video/audio file
452
     *
453
     * @param      $filePath
454
     * @param bool $summary
455
     *
456
     * @return null|array
457
     */
458
    public function getFileInfo($filePath, $summary = false)
459
    {
460
        $result = null;
461
        $settings = Transcoder::$plugin->getSettings();
462
        $filePath = $this->getAssetPath($filePath);
463
464
        if (!empty($filePath)) {
465
            // Build the basic command for ffprobe
466
            $ffprobeOptions = $settings['ffprobeOptions'];
467
            $ffprobeCmd = $settings['ffprobePath']
468
                .' '.$ffprobeOptions
469
                .' '.escapeshellarg($filePath);
470
471
            $shellOutput = $this->executeShellCommand($ffprobeCmd);
472
            Craft::info($ffprobeCmd, __METHOD__);
473
            $result = JsonHelper::decodeIfJson($shellOutput, true);
474
            Craft::info(print_r($result, true), __METHOD__);
475
            // Handle the case it not being JSON
476
            if (!is_array($result)) {
477
                $result = [];
478
            }
479
            // Trim down the arrays to just a summary
480
            if ($summary && !empty($result)) {
481
                $summaryResult = [];
482
                foreach ($result as $topLevelKey => $topLevelValue) {
483
                    switch ($topLevelKey) {
484
                        // Format info
485
                        case 'format':
486
                            foreach (self::INFO_SUMMARY['format'] as $settingKey => $settingValue) {
487
                                if (!empty($topLevelValue[$settingKey])) {
488
                                    $summaryResult[$settingValue] = $topLevelValue[$settingKey];
489
                                }
490
                            }
491
                            break;
492
                        // Stream info
493
                        case 'streams':
494
                            foreach ($topLevelValue as $stream) {
495
                                $infoSummaryType = $stream['codec_type'];
496
                                if (in_array($infoSummaryType, self::INFO_SUMMARY, false)) {
497
                                    foreach (self::INFO_SUMMARY[$infoSummaryType] as $settingKey => $settingValue) {
498
                                        if (!empty($stream[$settingKey])) {
499
                                            $summaryResult[$settingValue] = $stream[$settingKey];
500
                                        }
501
                                    }
502
                                }
503
                            }
504
                            break;
505
                        // Unknown info
506
                        default:
507
                            break;
508
                    }
509
                }
510
                // Handle cases where the framerate is returned as XX/YY
511
                if (!empty($summaryResult['videoFrameRate'])
512
                    && (strpos($summaryResult['videoFrameRate'], '/') !== false)
513
                ) {
514
                    $parts = explode('/', $summaryResult['videoFrameRate']);
515
                    $summaryResult['videoFrameRate'] = (float)$parts[0] / (float)$parts[1];
516
                }
517
                $result = $summaryResult;
518
            }
519
        }
520
521
        return $result;
522
    }
523
524
    /**
525
     * Get the name of a video file from a path and options
526
     *
527
     * @param $filePath
528
     * @param $videoOptions
529
     *
530
     * @return string
531
     */
532
    public function getVideoFilename($filePath, $videoOptions): string
533
    {
534
        $settings = Transcoder::$plugin->getSettings();
535
        $videoOptions = $this->coalesceOptions('defaultVideoOptions', $videoOptions);
536
537
        // Get the video encoder presets to use
538
        $videoEncoders = $settings['videoEncoders'];
539
        $thisEncoder = $videoEncoders[$videoOptions['videoEncoder']];
540
541
        $videoOptions['fileSuffix'] = $thisEncoder['fileSuffix'];
542
543
        return $this->getFilename($filePath, $videoOptions);
544
    }
545
546
    /**
547
     * Get the name of an audio file from a path and options
548
     *
549
     * @param $filePath
550
     * @param $audioOptions
551
     *
552
     * @return string
553
     */
554
    public function getAudioFilename($filePath, $audioOptions): string
555
    {
556
        $settings = Transcoder::$plugin->getSettings();
557
        $audioOptions = $this->coalesceOptions('defaultAudioOptions', $audioOptions);
558
559
        // Get the video encoder presets to use
560
        $audioEncoders = $settings['audioEncoders'];
561
        $thisEncoder = $audioEncoders[$audioOptions['audioEncoder']];
562
563
        $audioOptions['fileSuffix'] = $thisEncoder['fileSuffix'];
564
565
        return $this->getFilename($filePath, $audioOptions);
566
    }
567
568
    /**
569
     * Get the name of a gif video file from a path and options
570
     *
571
     * @param $filePath
572
     * @param $gifOptions
573
     *
574
     * @return string
575
     */
576
    public function getGifFilename($filePath, $gifOptions): string
577
    {
578
        $settings = Transcoder::$plugin->getSettings();
579
        $gifOptions = $this->coalesceOptions('defaultGifOptions', $gifOptions);
580
581
        // Get the video encoder presets to use
582
        $videoEncoders = $settings['videoEncoders'];
583
        $thisEncoder = $videoEncoders[$gifOptions['videoEncoder']];
584
585
        $gifOptions['fileSuffix'] = $thisEncoder['fileSuffix'];
586
587
        return $this->getFilename($filePath, $gifOptions);
588
    }
589
590
    /**
591
     * Handle generated a thumbnail for the Control Panel
592
     *
593
     * @param AssetThumbEvent $event
594
     *
595
     * @return null|false|string
596
     */
597
    public function handleGetAssetThumbPath(AssetThumbEvent $event)
598
    {
599
        $options = [
600
            'width' => $event->width,
601
            'height' => $event->height,
602
        ];
603
        $thumbPath = $this->getVideoThumbnailUrl($event->asset, $options, $event->generate, true);
604
605
        return $thumbPath;
606
    }
607
608
    // Protected Methods
609
    // =========================================================================
610
611
    /**
612
     * Returns a URL to a encoded GIF file (mp4)
613
     *
614
     * @param string $filePath   path to the original video or an Asset
615
     * @param array  $gifOptions of options for the GIF file
616
     *
617
     * @return string|false|null URL or path of the GIF file
618
     */
619
620
    public function getGifUrl($filePath, $gifOptions): string
621
    {
622
        $result = '';
623
        $settings = Transcoder::$plugin->getSettings();
624
        $subfolder = '';
625
626
        // sub folder check
627
        if (\is_object($filePath) && ($filePath instanceof Asset) && $settings['createSubfolders']) {
628
            $subfolder = $filePath->folderPath;
629
        }
630
631
        $filePath = $this->getAssetPath($filePath);
632
633
        if (!empty($filePath)) {
634
            // Dest path
635
            $destVideoPath = $settings['transcoderPaths']['gif'].$subfolder ?? $settings['transcoderPaths']['default'];
636
            $destVideoPath = Craft::parseEnv($destVideoPath);
637
638
            // Options
639
            $gifOptions = $this->coalesceOptions('defaultGifOptions', $gifOptions);
640
641
            // Get the video encoder presets to use
642
            $videoEncoders = $settings['videoEncoders'];
643
            $thisEncoder = $videoEncoders[$gifOptions['videoEncoder']];
644
            $gifOptions['fileSuffix'] = $thisEncoder['fileSuffix'];
645
646
            // Build the basic command for ffmpeg
647
            $ffmpegCmd = $settings['ffmpegPath']
648
                .' -f gif'
649
                .' -i '.escapeshellarg($filePath)
650
                .' -vcodec '.$thisEncoder['videoCodec']
651
                .' '.$thisEncoder['videoCodecOptions'];
652
653
654
            // Create the directory if it isn't there already
655
            if (!is_dir($destVideoPath)) {
656
                try {
657
                    FileHelper::createDirectory($destVideoPath);
658
                } catch (Exception $e) {
659
                    Craft::error($e->getMessage(), __METHOD__);
660
                }
661
            }
662
663
            $destVideoFile = $this->getFilename($filePath, $gifOptions);
664
665
            // File to store the video encoding progress in
666
            $progressFile = sys_get_temp_dir().DIRECTORY_SEPARATOR.$destVideoFile.'.progress';
667
668
            // Assemble the destination path and final ffmpeg command
669
            $destVideoPath .= $destVideoFile;
670
            $ffmpegCmd .= ' '
671
                .' -y '.escapeshellarg($destVideoPath)
672
                .' 1> '.$progressFile.' 2>&1 & echo $!';
673
674
            // Make sure there isn't a lockfile for this video already
675
            $lockFile = sys_get_temp_dir().DIRECTORY_SEPARATOR.$destVideoFile.'.lock';
676
            $oldPid = @file_get_contents($lockFile);
677
            if ($oldPid !== false) {
678
                exec("ps $oldPid", $ProcessState);
679
                if (\count($ProcessState) >= 2) {
680
                    return $result;
681
                }
682
                // It's finished transcoding, so delete the lockfile and progress file
683
                @unlink($lockFile);
684
                @unlink($progressFile);
685
            }
686
687
            // If the video file already exists and hasn't been modified, return it.  Otherwise, start it transcoding
688
            if (file_exists($destVideoPath) && (@filemtime($destVideoPath) >= @filemtime($filePath))) {
689
                $url = $settings['transcoderUrls']['gif'].$subfolder ?? $settings['transcoderUrls']['default'];
690
                $result = Craft::parseEnv($url).$destVideoFile;
691
            } else {
692
                // Kick off the transcoding
693
                $pid = $this->executeShellCommand($ffmpegCmd);
694
                Craft::info($ffmpegCmd."\nffmpeg PID: ".$pid, __METHOD__);
695
696
                // Create a lockfile in tmp
697
                file_put_contents($lockFile, $pid);
698
            }
699
        }
700
701
        return $result;
702
    }
703
704
    /**
705
     * Get the name of a file from a path and options
706
     *
707
     * @param $filePath
708
     * @param $options
709
     *
710
     * @return string
711
     */
712
    protected function getFilename($filePath, $options): string
713
    {
714
        $settings = Transcoder::$plugin->getSettings();
715
        $filePath = $this->getAssetPath($filePath);
716
717
        $validator = new UrlValidator();
718
        $error = '';
719
        if ($validator->validate($filePath, $error)) {
720
            $urlParts = parse_url($filePath);
721
            $pathParts = pathinfo($urlParts['path']);
722
        } else {
723
            $pathParts = pathinfo($filePath);
724
        }
725
        $fileName = $pathParts['filename'];
726
727
        // Add our options to the file name
728
        foreach ($options as $key => $value) {
729
            if (!empty($value)) {
730
                $suffix = '';
731
                if (!empty(self::SUFFIX_MAP[$key])) {
732
                    $suffix = self::SUFFIX_MAP[$key];
733
                }
734
                if (\is_bool($value)) {
735
                    $value = $value ? $key : 'no'.$key;
736
                }
737
                if (!\in_array($key, self::EXCLUDE_PARAMS, true)) {
738
                    $fileName .= '_'.$value.$suffix;
739
                }
740
            }
741
        }
742
        // See if we should use a hash instead
743
        if ($settings['useHashedNames']) {
744
            $fileName = $pathParts['filename'].md5($fileName);
745
        }
746
        $fileName .= $options['fileSuffix'];
747
748
        return $fileName;
749
    }
750
751
    /**
752
     * Extract a file system path if $filePath is an Asset object
753
     *
754
     * @param $filePath
755
     *
756
     * @return string
757
     */
758
    protected function getAssetPath($filePath): string
759
    {
760
        // If we're passed an Asset, extract the path from it
761
        if (\is_object($filePath) && ($filePath instanceof Asset)) {
762
            /** @var Asset $asset */
763
            $asset = $filePath;
764
            $assetVolume = null;
0 ignored issues
show
The assignment to $assetVolume is dead and can be removed.
Loading history...
765
            try {
766
                $assetVolume = $asset->getVolume();
767
            } catch (InvalidConfigException $e) {
768
                Craft::error($e->getMessage(), __METHOD__);
769
            }
770
771
            if ($assetVolume) {
772
                // If it's local, get a path to the file
773
                if ($assetVolume instanceof Local) {
774
                    $sourcePath = rtrim($assetVolume->path, DIRECTORY_SEPARATOR);
775
                    $sourcePath .= '' === $sourcePath ? '' : DIRECTORY_SEPARATOR;
776
                    $folderPath = '';
0 ignored issues
show
The assignment to $folderPath is dead and can be removed.
Loading history...
777
                    try {
778
                        $folderPath = rtrim($asset->getFolder()->path, DIRECTORY_SEPARATOR);
779
                    } catch (InvalidConfigException $e) {
780
                        Craft::error($e->getMessage(), __METHOD__);
781
                    }
782
                    $folderPath .= '' === $folderPath ? '' : DIRECTORY_SEPARATOR;
783
784
                    $filePath = $sourcePath.$folderPath.$asset->filename;
785
                } else {
786
                    // Otherwise, get a URL
787
                    $filePath = $asset->getUrl();
788
                }
789
            }
790
        }
791
792
        $filePath = Craft::parseEnv($filePath);
793
794
        // Make sure that $filePath is either an existing file, or a valid URL
795
        if (!file_exists($filePath)) {
796
            $validator = new UrlValidator();
797
            $error = '';
798
            if (!$validator->validate($filePath, $error)) {
799
                Craft::error($error, __METHOD__);
800
                $filePath = '';
801
            }
802
        }
803
804
        return $filePath;
805
    }
806
807
    /**
808
     * Set the width & height if desired
809
     *
810
     * @param $options
811
     * @param $ffmpegCmd
812
     *
813
     * @return string
814
     */
815
    protected function addScalingFfmpegArgs($options, $ffmpegCmd): string
816
    {
817
        if (!empty($options['width']) && !empty($options['height'])) {
818
            // Handle "none", "crop", and "letterbox" aspectRatios
819
            $aspectRatio = '';
820
            if (!empty($options['aspectRatio'])) {
821
                switch ($options['aspectRatio']) {
822
                    // Scale to the appropriate aspect ratio, padding
823
                    case 'letterbox':
824
                        $letterboxColor = '';
825
                        if (!empty($options['letterboxColor'])) {
826
                            $letterboxColor = ':color='.$options['letterboxColor'];
827
                        }
828
                        $aspectRatio = ':force_original_aspect_ratio=decrease'
829
                            .',pad='.$options['width'].':'.$options['height'].':(ow-iw)/2:(oh-ih)/2'
830
                            .$letterboxColor;
831
                        break;
832
                    // Scale to the appropriate aspect ratio, cropping
833
                    case 'crop':
834
                        $aspectRatio = ':force_original_aspect_ratio=increase'
835
                            .',crop='.$options['width'].':'.$options['height'];
836
                        break;
837
                    // No aspect ratio scaling at all
838
                    default:
839
                        $aspectRatio = ':force_original_aspect_ratio=disable';
840
                        $options['aspectRatio'] = 'none';
841
                        break;
842
                }
843
            }
844
            $sharpen = '';
845
            if (!empty($options['sharpen']) && ($options['sharpen'] !== false)) {
846
                $sharpen = ',unsharp=5:5:1.0:5:5:0.0';
847
            }
848
            $ffmpegCmd .= ' -vf "scale='
849
                .$options['width'].':'.$options['height']
850
                .$aspectRatio
851
                .$sharpen
852
                .'"';
853
        }
854
855
        return $ffmpegCmd;
856
    }
857
858
    // Protected Methods
859
    // =========================================================================
860
861
    /**
862
     * Combine the options arrays
863
     *
864
     * @param $defaultName
865
     * @param $options
866
     *
867
     * @return array
868
     */
869
    protected function coalesceOptions($defaultName, $options): array
870
    {
871
        // Default options
872
        $settings = Transcoder::$plugin->getSettings();
873
        $defaultOptions = $settings[$defaultName];
874
875
        // Coalesce the passed in $options with the $defaultOptions
876
        $options = array_merge($defaultOptions, $options);
877
878
        return $options;
879
    }
880
881
    /**
882
     * Execute a shell command
883
     *
884
     * @param string $command
885
     *
886
     * @return string
887
     */
888
    protected function executeShellCommand(string $command): string
889
    {
890
        // Create the shell command
891
        $shellCommand = new ShellCommand();
892
        $shellCommand->setCommand($command);
893
894
        // If we don't have proc_open, maybe we've got exec
895
        if (!\function_exists('proc_open') && \function_exists('exec')) {
896
            $shellCommand->useExec = true;
897
        }
898
899
        // Return the result of the command's output or error
900
        if ($shellCommand->execute()) {
901
            $result = $shellCommand->getOutput();
902
        } else {
903
            $result = $shellCommand->getError();
904
        }
905
906
        return $result;
907
    }
908
}
909