Issues (306)

src/services/Transcode.php (3 issues)

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