Passed
Branch release (e4b72f)
by Henry
02:55
created

Media::getThumbnail()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 30
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 12
c 1
b 0
f 0
nc 12
nop 4
dl 0
loc 30
ccs 0
cts 13
cp 0
crap 30
rs 9.5555
1
<?php
2
/**
3
 * This file is part of the Divergence package.
4
 *
5
 * (c) Henry Paradiz <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
11
namespace Divergence\Models\Media;
12
13
use Exception;
14
use Divergence\App;
15
use Divergence\Models\Model;
16
use Divergence\Models\Mapping\Column;
17
18
/**
19
 * Media Model
20
 *
21
 * @author Henry Paradiz <[email protected]>
22
 * @author  Chris Alfano <[email protected]>
23
 *
24
 * {@inheritDoc}
25
 * @property int        $CreatorID A standard user ID field for use by your login & authentication system. Part of Divergence\Models\Model but used in this file as a default.
26
 *
27
 * @property-read array $Data           JSON object about the media
28
 * @property-read array $SummaryData    JSON object about the media
29
 * @property-read array $JsonTranslation  JSON object about the media
30
 *
31
 * @property-read string $Filename Filename without path but appended
32
 * @property-read string $ThumbnailMIMEType The mimetype for the thumbnail
33
 * @property-read string $WebPath      Uses static::$webPathFormat to generate a URL path for you.
34
 * @property-read string $getFilesystemPath Full filesystem path
35
 *
36
 * @method static void _defineRelationships()
37
 * @method static void _initRelationships()
38
 */
39
class Media extends Model
40
{
41
    public static $useCache = true;
42
    public static $singularNoun = 'media item';
43
    public static $pluralNoun = 'media items';
44
45
    // support subclassing
46
    public static $rootClass = __CLASS__;
47
    public static $defaultClass = __CLASS__;
48
    public static $subClasses = [__CLASS__, Image::class, PDF::class, Video::class, Audio::class];
49
    public static $collectionRoute = '/media';
50
51
    public static $tableName = 'media';
52
53
    #[Column(notnull: false, default:null)]
54
    protected $ContextClass;
55
56
    #[Column(type:'int', notnull: false, default:null)]
57
    protected $ContextID;
58
59
    protected $MIMEType;
60
61
    #[Column(type:'int', unsigned: true, notnull:false)]
62
    protected $Width;
63
64
    #[Column(type:'int', unsigned: true, notnull:false)]
65
    protected $Height;
66
67
    #[Column(type: 'decimal', notnull: false, precision: 12, scale: 6, default: 0)]
68
    protected $Duration;
69
70
    #[Column(notnull:false)]
71
    protected $Caption;
72
73
74
    public static $relationships = [
75
        'Creator' => [
76
            'type' => 'one-one',
77
            'class' => 'Person',
78
            'local' => 'CreatorID',
79
        ],
80
        'Context' => [
81
            'type' => 'context-parent',
82
        ],
83
    ];
84
85
    public static $searchConditions = [
86
        'Caption' => [
87
            'qualifiers' => ['any','caption'],
88
            'points' => 2,
89
            'sql' => 'Caption LIKE "%%%s%%"',
90
        ],
91
        'CaptionLike' => [
92
            'qualifiers' => ['caption-like'],
93
            'points' => 2,
94
            'sql' => 'Caption LIKE "%s"',
95
        ],
96
        'CaptionNot' => [
97
            'qualifiers' => ['caption-not'],
98
            'points' => 2,
99
            'sql' => 'Caption NOT LIKE "%%%s%%"',
100
        ],
101
        'CaptionNotLike' => [
102
            'qualifiers' => ['caption-not-like'],
103
            'points' => 2,
104
            'sql' => 'Caption NOT LIKE "%s"',
105
        ],
106
    ];
107
108
    public static $webPathFormat = '/media/open/%u'; // 1=mediaID
109
    public static $thumbnailRequestFormat = '/thumbnail/%1$u/%2$ux%3$u%4$s'; // 1=media_id 2=width 3=height 4=fill_color
110
    #public static $blankThumbnailRequestFormat = '/thumbnail/%1$s/%2$ux%3$u%4$s'; // 1=class 2=width 3=height 4=fill_color
111
    public static $thumbnailJPEGCompression = 90;
112
    public static $thumbnailPNGCompression = 9;
113
    public static $defaultFilenameFormat = 'default.%s.jpg';
114
    public static $newDirectoryPermissions = 0775;
115
    public static $newFilePermissions = 0664;
116
    public static $magicPath = null;//'/usr/share/misc/magic.mgc';
117
    public static $useFaceDetection = true;
118
    public static $faceDetectionTimeLimit = 10;
119
120
    public static $mimeHandlers = [
121
        'image/gif' => Image::class,
122
        'image/jpeg' => Image::class,
123
        'image/png' => Image::class,
124
        'image/tiff' => Image::class,
125
        'application/psd' => Image::class,
126
        'audio/mpeg' => Audio::class,
127
        'application/pdf' => PDF::class,
128
        'application/postscript' => PDF::class,
129
        'image/svg+xml' => PDF::class,
130
        'video/x-flv' => Video::class,
131
        'video/mp4' => Video::class,
132
        'video/quicktime' => Video::class,
133
    ];
134
135
    public static $mimeRewrites = [
136
        'image/photoshop'              => 'application/psd',
137
        'image/x-photoshop'            => 'application/psd',
138
        'image/psd'                    => 'application/psd',
139
        'application/photoshop'        => 'application/psd',
140
        'image/vnd.adobe.photoshop'    => 'application/psd',
141
    ];
142
143 19
    public function getValue($name)
144
    {
145
        switch ($name) {
146 19
            case 'Data':
147 19
            case 'SummaryData':
148 19
            case 'JsonTranslation':
149
                return [
150
                    'ID' => $this->getValue('ID'),
151
                    'Class' => $this->getValue('Class'),
152
                    'ContextClass' => $this->getValue('ContextClass'),
153
                    'ContextID' => $this->getValue('ContextID'),
154
                    'MIMEType' => $this->getValue('MIMEType'),
155
                    'Width' => $this->getValue('Width'),
156
                    'Height' => $this->getValue('Height'),
157
                    'Duration' => $this->getValue('Duration'),
158
                ];
159
160 19
            case 'Filename':
161 3
                return $this->getFilename();
162
163 19
            case 'ThumbnailMIMEType':
164
                return $this->getValue('MIMEType');
165
166 19
            case 'Extension':
167
                throw new Exception('Unable to find extension for mime-type: '.$this->getValue('MIMEType'));
168
169 19
            case 'WebPath':
170
                return sprintf(
171
                    static::$webPathFormat,
172
                    $this->getValue('ID')
173
                );
174
175 19
            case 'FilesystemPath':
176 6
                return $this->getFilesystemPath();
177
178
179
            default:
180 19
                return parent::getValue($name);
181
        }
182
    }
183
184
    /**
185
     * Builds a string for the correct path to the resource with all the options provided
186
     *
187
     * @param int|string $width
188
     * @param int|string $height
189
     * @param int|string $fillColor
190
     * @param bool $cropped
191
     * @return string
192
     * @throws Exception
193
     */
194
    public function buildThumbnailRequest($width, $height = null, $fillColor = null, $cropped = false)
195
    {
196
        return sprintf(
197
            static::$thumbnailRequestFormat,
198
            $this->getValue('ID'),
199
            $width,
200
            $height ?: $width,
201
            (is_string($fillColor) ? 'x'.$fillColor : '')
202
        ).($cropped ? '/cropped' : '');
203
    }
204
205
    /**
206
     * Creates a GdImage object from a file
207
     *
208
     *  Supports psd, tiff, pdf, postscript, and anything else that gd supports natively
209
     *
210
     * @param ?string $sourceFile If not provided will try to figure it out from $this->FilesystemPath
211
     * @throws Exception
212
     * @return \GdImage|false
213
     */
214 3
    public function getImage($sourceFile = null): \GdImage|false
215
    {
216 3
        if (!isset($sourceFile)) {
217 3
            $sourceFile = $this->getValue('FilesystemPath') ? $this->getValue('FilesystemPath') : $this->getValue('BlankPath');
218
        }
219
220 3
        switch ($this->getValue('MIMEType')) {
221 3
            case 'application/psd':
222 3
            case 'image/tiff':
223
224
                //Converts PSD to PNG temporarily on the real file system.
225
                $tempFile = tempnam('/tmp', 'media_convert');
226
                exec("convert -density 100 ".$this->getValue('FilesystemPath')."[0] -flatten $tempFile.png");
227
228
                return imagecreatefrompng("$tempFile.png");
229
230 3
            case 'application/postscript':
231
232
                return imagecreatefromstring(shell_exec("gs -r150 -dEPSCrop -dNOPAUSE -dBATCH -sDEVICE=png48 -sOutputFile=- -q $this->getValue('FilesystemPath')"));
233
234
            default:
235
236 3
                if (!$fileData = file_get_contents($sourceFile)) {
237
                    throw new Exception('Could not load media source: '.$sourceFile);
238
                }
239
240 3
                $image = imagecreatefromstring($fileData);
241
242 3
                if ($this->getValue('MIMEType') == 'image/jpeg' && ($exifData = exif_read_data($sourceFile)) && !empty($exifData['Orientation'])) {
243
                    switch ($exifData['Orientation']) {
244
                        case 1: // nothing
245
                            break;
246
                        case 2: // horizontal flip
247
                            imageflip($image, IMG_FLIP_HORIZONTAL);
248
                            break;
249
                        case 3: // 180 rotate left
250
                            $image = imagerotate($image, 180, 0);
251
                            break;
252
                        case 4: // vertical flip
253
                            imageflip($image, IMG_FLIP_VERTICAL);
254
                            break;
255
                        case 5: // vertical flip + 90 rotate right
256
                            imageflip($image, IMG_FLIP_VERTICAL);
257
                            $image = imagerotate($image, -90, 0);
258
                            break;
259
                        case 6: // 90 rotate right
260
                            $image = imagerotate($image, -90, 0);
261
                            break;
262
                        case 7: // horizontal flip + 90 rotate right
263
                            imageflip($image, IMG_FLIP_HORIZONTAL);
264
                            $image = imagerotate($image, -90, 0);
265
                            break;
266
                        case 8: // 90 rotate left
267
                            $image = imagerotate($image, 90, 0);
268
                            break;
269
                    }
270
                }
271
272 3
                return $image;
273
        }
274
    }
275
276
    /**
277
     * Gives us the path to a thumbnail and if it doesn't exist creates it
278
     *
279
     * @param int $maxWidth
280
     * @param int $maxHeight
281
     * @param string|bool $fillColor
282
     * @param boolean $cropped
283
     * @return string
284
     */
285 3
    public function getThumbnail($maxWidth, $maxHeight, $fillColor = false, $cropped = false)
286
    {
287 3
        $thumbFormat = sprintf('%ux%u', $maxWidth, $maxHeight);
288
289 3
        if ($fillColor) {
290
            $thumbFormat .= 'x'.strtoupper($fillColor);
291
        }
292
293 3
        if ($cropped) {
294
            $thumbFormat .= '.cropped';
295
        }
296
297 3
        $thumbPath = App::$App->ApplicationPath.'/media/'.$thumbFormat.'/'.$this->getValue('Filename');
298
299
        // look for cached thumbnail
300 3
        if (!file_exists($thumbPath)) {
301
            // ensure directory exists
302 3
            $thumbDir = dirname($thumbPath);
303 3
            if (!is_dir($thumbDir)) {
304 3
                mkdir($thumbDir, static::$newDirectoryPermissions, true);
305
            }
306
307
            // create new thumbnail
308 3
            $this->createThumbnailImage($thumbPath, $maxWidth, $maxHeight, $fillColor, $cropped);
309
        }
310
311 3
        return $thumbPath;
312
    }
313
314
    /**
315
     * Creates a thumbnail using the gd php extension.
316
     * Keeps aspect ratio.
317
     *
318
     * @param string $thumbPath
319
     * @param int $maxWidth
320
     * @param int $maxHeight
321
     * @param boolean|int $fillColor Background canvas color. See gd documentation.
322
     * @param boolean $cropped If cropped is enable the image will instead fill the smaller of width or height and cut the edges off.
323
     * @return void
324
     */
325 3
    public function createThumbnailImage($thumbPath, $maxWidth, $maxHeight, $fillColor = false, $cropped = false): void
326
    {
327 3
        $thumbWidth = $maxWidth;
328 3
        $thumbHeight = $maxHeight;
329
330
        // load source image
331 3
        $srcImage = $this->getImage();
332 3
        if (!$srcImage) {
333
            throw new Exception('This type of media file can not create a thumbnail image.');
334
        }
335 3
        $srcWidth = imagesx($srcImage);
336 3
        $srcHeight = imagesy($srcImage);
337
338
        // calculate
339 3
        if ($srcWidth && $srcHeight) {
340 3
            $widthRatio = ($srcWidth > $maxWidth) ? ($maxWidth / $srcWidth) : 1;
341 3
            $heightRatio = ($srcHeight > $maxHeight) ? ($maxHeight / $srcHeight) : 1;
342
343
            // crop width/height to scale size if fill disabled
344 3
            if ($cropped) {
345
                $ratio = max($widthRatio, $heightRatio);
346
            } else {
347 3
                $ratio = min($widthRatio, $heightRatio);
348
            }
349
350 3
            $scaledWidth = round($srcWidth * $ratio);
351 3
            $scaledHeight = round($srcHeight * $ratio);
352
        } else {
353
            $scaledWidth = $maxWidth;
354
            $scaledHeight = $maxHeight;
355
        }
356
357 3
        if (!$fillColor && !$cropped) {
358 3
            $thumbWidth = $scaledWidth;
359 3
            $thumbHeight = $scaledHeight;
360
        }
361
362
        // create thumbnail images
363 3
        $image = imagecreatetruecolor($thumbWidth, $thumbHeight);
364
365
        // paint fill color
366 3
        if ($fillColor) {
367
            // extract decimal values from hex triplet
368
            $fillColor = sscanf($fillColor, '%2x%2x%2x');
369
370
            // convert to color index
371
            $fillColor = imagecolorallocate($image, $fillColor[0], $fillColor[1], $fillColor[2]);
372
373
            // fill background
374
            imagefill($image, 0, 0, $fillColor);
375 3
        } elseif (($this->getValue('MIMEType') == 'image/gif') || ($this->getValue('MIMEType') == 'image/png')) {
376 3
            $trans_index = imagecolortransparent($srcImage);
377
378
            // check if there is a specific transparent color
379 3
            if ($trans_index >= 0 && $trans_index < imagecolorstotal($srcImage)) {
380
                $trans_color = imagecolorsforindex($srcImage, $trans_index);
381
382
                // allocate in thumbnail
383
                $trans_index = imagecolorallocate($image, $trans_color['red'], $trans_color['green'], $trans_color['blue']);
384
385
                // fill background
386
                imagefill($image, 0, 0, $trans_index);
387
                imagecolortransparent($image, $trans_index);
388 3
            } elseif ($this->getValue('MIMEType') == 'image/png') {
389 3
                imagealphablending($image, false);
390 3
                $trans_color = imagecolorallocatealpha($image, 0, 0, 0, 127);
391 3
                imagefill($image, 0, 0, $trans_color);
392 3
                imagesavealpha($image, true);
393
            }
394
        }
395
396
        // resize photo to thumbnail
397 3
        if ($cropped) {
398
            imagecopyresampled(
399
                $image,
400
                $srcImage,
401
                ($thumbWidth - $scaledWidth) / 2,
402
                ($thumbHeight - $scaledHeight) / 2,
403
                0,
404
                0,
405
                $scaledWidth,
406
                $scaledHeight,
407
                $srcWidth,
408
                $srcHeight
409
            );
410
        } else {
411 3
            imagecopyresampled(
412 3
                $image,
413 3
                $srcImage,
414 3
                round(($thumbWidth - $scaledWidth) / 2),
415 3
                round(($thumbHeight - $scaledHeight) / 2),
416 3
                0,
417 3
                0,
418 3
                $scaledWidth,
419 3
                $scaledHeight,
420 3
                $srcWidth,
421 3
                $srcHeight
422 3
            );
423
        }
424
425
        // save thumbnail to disk
426 3
        switch ($this->getValue('ThumbnailMIMEType')) {
427 3
            case 'image/gif':
428
                imagegif($image, $thumbPath);
429
                break;
430
431 3
            case 'image/jpeg':
432
                imagejpeg($image, $thumbPath, static::$thumbnailJPEGCompression);
433
                break;
434
435 3
            case 'image/png':
436 3
                imagepng($image, $thumbPath, static::$thumbnailPNGCompression);
437 3
                break;
438
439
            default:
440
                throw new Exception('Unhandled thumbnail format');
441
        }
442
443 3
        chmod($thumbPath, static::$newFilePermissions);
444
    }
445
446
    // static methods
447
    public static function createFromUpload($uploadedFile, $fieldValues = []): static | false
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected '|', expecting '{' or ';' on line 447 at column 86
Loading history...
448
    {
449
        // handle recieving a field array from $_FILES
450
        if (is_array($uploadedFile)) {
451
            if (isset($uploadedFile['error'])) {
452
                return false;
453
            }
454
455
            if (!empty($uploadedFile['name']) && empty($fieldValues['Caption'])) {
456
                $fieldValues['Caption'] = preg_replace('/\.[^.]+$/', '', $uploadedFile['name']);
457
            }
458
459
            $uploadedFile = $uploadedFile['tmp_name'];
460
        }
461
462
        // sanity check
463
        if (!is_uploaded_file($uploadedFile)) {
464
            throw new Exception('Supplied file is not a valid upload');
465
        }
466
467
        return static::createFromFile($uploadedFile, $fieldValues);
468
    }
469
470 3
    public static function createFromFile($file, $fieldValues = []): static | false
471
    {
472
        try {
473
            // handle url input
474 3
            if (filter_var($file, FILTER_VALIDATE_URL)) {
475
                $tempName = tempnam('/tmp', 'remote_media');
476
                copy($file, $tempName);
477
                $file = $tempName;
478
            }
479
480
            // analyze file
481 3
            $mediaInfo = static::analyzeFile($file);
482
483
            // create media object
484
            /**
485
             * @var static
486
             */
487 3
            $Media = $mediaInfo['className']::create($fieldValues);
488
489
            // init media
490 3
            $Media->initializeFromAnalysis($mediaInfo);
491
492
            // save media
493 3
            $Media->save();
494
495
            // write file
496 3
            $Media->writeFile($file);
497
498 3
            return $Media;
499
        } catch (Exception $e) {
500
            throw $e;
501
        }
502
503
        // remove photo record
504
        if ($Media) {
505
            $Media->destroy();
506
        }
507
508
        return false;
509
    }
510
511 3
    public function initializeFromAnalysis($mediaInfo)
512
    {
513 3
        $this->setValue('MIMEType', $mediaInfo['mimeType']);
514 3
        $this->setValue('Width', $mediaInfo['width']);
515 3
        $this->setValue('Height', $mediaInfo['height']);
516 3
        $this->setValue('Duration', $mediaInfo['duration']);
517
    }
518
519
520 3
    public static function analyzeFile($filename)
521
    {
522
        // DO NOT CALL FROM decendent's override, parent calls child
523
524
        // check file
525 3
        if (!is_readable($filename)) {
526
            throw new Exception('Unable to read media file for analysis: "'.$filename.'"');
527
        }
528
529
        // get mime type
530 3
        $finfo = finfo_open(FILEINFO_MIME_TYPE, static::$magicPath);
531
532 3
        if (!$finfo || !($mimeType = finfo_file($finfo, $filename))) {
533
            throw new Exception('Unable to load media file info');
534
        }
535
536 3
        finfo_close($finfo);
537
538
        // dig deeper if only generic mimetype returned
539 3
        if ($mimeType == 'application/octet-stream') {
540
            $finfo = finfo_open(FILEINFO_NONE, static::$magicPath);
541
542
            if (!$finfo || !($fileInfo = finfo_file($finfo, $filename))) {
543
                throw new Exception('Unable to load media file info');
544
            }
545
546
            finfo_close($finfo);
547
548
            // detect EPS
549
            if (preg_match('/^DOS EPS/i', $fileInfo)) {
550
                $mimeType = 'application/postscript';
551
            }
552 3
        } elseif (array_key_exists($mimeType, static::$mimeRewrites)) {
553
            $mimeType = static::$mimeRewrites[$mimeType];
554
        }
555
556
        // compile mime data
557 3
        $mediaInfo = [
558 3
            'mimeType' => $mimeType,
559 3
        ];
560
561
        // determine handler
562 3
        $staticClass = get_called_class();
563 3
        if (!isset(static::$mimeHandlers[$mediaInfo['mimeType']]) || $staticClass != __CLASS__) {
564
            throw new Exception('No class registered for mime type "' . $mediaInfo['mimeType'] . '"');
565
        } else {
566 3
            $className = $mediaInfo['className'] = static::$mimeHandlers[$mediaInfo['mimeType']];
567
568
            // call registered type's analyzer
569 3
            $mediaInfo = $className::analyzeFile($filename, $mediaInfo);
570
        }
571
572 3
        return $mediaInfo;
573
    }
574
575
    public static function getSupportedTypes(): array
576
    {
577
        return array_unique(array_merge(array_keys(static::$mimeHandlers), array_keys(static::$mimeRewrites)));
578
    }
579
580 17
    public function getFilesystemPath($variant = 'original', $filename = null): ?string
581
    {
582 17
        if ($this->isPhantom) {
583
            return null;
584
        }
585
586 17
        return App::$App->ApplicationPath.'/media/'.$variant.'/'.($filename ?: $this->getFilename($variant));
587
    }
588
589 17
    public function getFilename(): string
590
    {
591 17
        if ($this->isPhantom) {
592
            return 'default.'.$this->getValue('Extension');
593
        }
594
595 17
        return $this->getValue('ID').'.'.$this->getValue('Extension');
596
    }
597
598
    public function getMIMEType(): string
599
    {
600
        return $this->getValue('MIMEType');
601
    }
602
603 3
    public function writeFile($sourceFile): bool
604
    {
605 3
        $targetDirectory = dirname($this->getValue('FilesystemPath'));
606
607
        // create target directory if needed
608 3
        if (!is_dir($targetDirectory)) {
609 1
            mkdir($targetDirectory, static::$newDirectoryPermissions, true);
610
        }
611
612
        // move source file to target path
613 3
        if (!rename($sourceFile, $this->getValue('FilesystemPath'))) {
614
            throw new \Exception('Failed to move source file to destination');
615
        }
616
617
        // set file permissions
618 3
        return chmod($this->getValue('FilesystemPath'), static::$newFilePermissions);
619
    }
620
}
621