New supported formats, Preserve ICC Color Profiles, libvips support

Update image pipeline to handle avif, heic and webp and preserve ICC color profiles and added libvips support.
pull/5978/head
Daniel Supernault 6 months ago
parent c5e1f5fe5b
commit ab9c13fe0d
No known key found for this signature in database
GPG Key ID: 23740873EE6F76A1

@ -48,6 +48,7 @@ class CatchUnoptimizedMedia extends Command
->whereNotNull('status_id') ->whereNotNull('status_id')
->whereNotNull('media_path') ->whereNotNull('media_path')
->whereIn('mime', [ ->whereIn('mime', [
'image/jpg',
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
]) ])

@ -97,6 +97,7 @@ class FixMediaDriver extends Command
if( if(
$media->mime && $media->mime &&
in_array($media->mime, [ in_array($media->mime, [
'image/jpg',
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
'image/webp' 'image/webp'

@ -110,7 +110,7 @@ class ImportEmojis extends Command
private function isEmoji($filename) private function isEmoji($filename)
{ {
$allowedMimeTypes = ['image/png', 'image/jpeg', 'image/webp']; $allowedMimeTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/jpg'];
$mimeType = mime_content_type($filename); $mimeType = mime_content_type($filename);
return in_array($mimeType, $allowedMimeTypes); return in_array($mimeType, $allowedMimeTypes);

@ -40,7 +40,7 @@ class RegenerateThumbnails extends Command
public function handle() public function handle()
{ {
DB::transaction(function() { DB::transaction(function() {
Media::whereIn('mime', ['image/jpeg', 'image/png']) Media::whereIn('mime', ['image/jpeg', 'image/png', 'image/jpg'])
->chunk(50, function($medias) { ->chunk(50, function($medias) {
foreach($medias as $media) { foreach($medias as $media) {
\App\Jobs\ImageOptimizePipeline\ImageThumbnail::dispatch($media); \App\Jobs\ImageOptimizePipeline\ImageThumbnail::dispatch($media);

@ -243,7 +243,7 @@ class ApiV1Controller extends Controller
} }
$this->validate($request, [ $this->validate($request, [
'avatar' => 'sometimes|mimetypes:image/jpeg,image/png|max:'.config('pixelfed.max_avatar_size'), 'avatar' => 'sometimes|mimetypes:image/jpeg,image/jpg,image/png|max:'.config('pixelfed.max_avatar_size'),
'display_name' => 'nullable|string|max:30', 'display_name' => 'nullable|string|max:30',
'note' => 'nullable|string|max:200', 'note' => 'nullable|string|max:200',
'locked' => 'nullable', 'locked' => 'nullable',
@ -1907,6 +1907,7 @@ class ApiV1Controller extends Controller
$media->save(); $media->save();
switch ($media->mime) { switch ($media->mime) {
case 'image/jpg':
case 'image/jpeg': case 'image/jpeg':
case 'image/png': case 'image/png':
case 'image/webp': case 'image/webp':
@ -2137,6 +2138,7 @@ class ApiV1Controller extends Controller
$media->save(); $media->save();
switch ($media->mime) { switch ($media->mime) {
case 'image/jpg':
case 'image/jpeg': case 'image/jpeg':
case 'image/png': case 'image/png':
case 'image/webp': case 'image/webp':

@ -1307,6 +1307,7 @@ class ApiV1Dot1Controller extends Controller
$media->save(); $media->save();
switch ($media->mime) { switch ($media->mime) {
case 'image/jpg':
case 'image/jpeg': case 'image/jpeg':
case 'image/png': case 'image/png':
ImageOptimize::dispatch($media)->onQueue('mmo'); ImageOptimize::dispatch($media)->onQueue('mmo');

@ -310,6 +310,7 @@ class ApiV2Controller extends Controller
switch ($media->mime) { switch ($media->mime) {
case 'image/jpeg': case 'image/jpeg':
case 'image/jpg':
case 'image/png': case 'image/png':
ImageOptimize::dispatch($media)->onQueue('mmo'); ImageOptimize::dispatch($media)->onQueue('mmo');
break; break;

@ -201,7 +201,7 @@ class ImportPostController extends Controller
$this->checkPermissions($request); $this->checkPermissions($request);
$allowedMimeTypes = ['image/png', 'image/jpeg']; $allowedMimeTypes = ['image/png', 'image/jpeg', 'image/jpg'];
if (config('import.instagram.allow_image_webp') && str_contains(config_cache('pixelfed.media_types'), 'image/webp')) { if (config('import.instagram.allow_image_webp') && str_contains(config_cache('pixelfed.media_types'), 'image/webp')) {
$allowedMimeTypes[] = 'image/webp'; $allowedMimeTypes[] = 'image/webp';

@ -260,7 +260,7 @@ class StoryApiV1Controller extends Controller
'file' => function () { 'file' => function () {
return [ return [
'required', 'required',
'mimetypes:image/jpeg,image/png,video/mp4', 'mimetypes:image/jpeg,image/jpg,image/png,video/mp4',
'max:'.config_cache('pixelfed.max_photo_size'), 'max:'.config_cache('pixelfed.max_photo_size'),
]; ];
}, },

@ -34,7 +34,7 @@ class StoryComposeController extends Controller
'file' => function () { 'file' => function () {
return [ return [
'required', 'required',
'mimetypes:image/jpeg,image/png,video/mp4', 'mimetypes:image/jpeg,image/png,video/mp4,image/jpg',
'max:'.config_cache('pixelfed.max_photo_size'), 'max:'.config_cache('pixelfed.max_photo_size'),
]; ];
}, },

@ -40,6 +40,9 @@ class ImageOptimize implements ShouldQueue
public function handle() public function handle()
{ {
$media = $this->media; $media = $this->media;
if(!$media) {
return;
}
$path = storage_path('app/'.$media->media_path); $path = storage_path('app/'.$media->media_path);
if (!is_file($path) || $media->skip_optimize) { if (!is_file($path) || $media->skip_optimize) {
return; return;

@ -64,7 +64,7 @@ class Media extends Model
return $this->cdn_url; return $this->cdn_url;
} }
if ($this->media_path && $this->mime && in_array($this->mime, ['image/jpeg', 'image/png'])) { if ($this->media_path && $this->mime && in_array($this->mime, ['image/jpeg', 'image/png', 'image/jpg'])) {
return $this->remote_media || Str::startsWith($this->media_path, 'http') ? return $this->remote_media || Str::startsWith($this->media_path, 'http') ?
$this->media_path : $this->media_path :
url(Storage::url($this->media_path)); url(Storage::url($this->media_path));

@ -138,6 +138,7 @@ class MediaStorageService
} }
$mimes = [ $mimes = [
'image/jpg',
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
'video/mp4', 'video/mp4',
@ -166,6 +167,7 @@ class MediaStorageService
$ext = '.gif'; $ext = '.gif';
break; break;
case 'image/jpg':
case 'image/jpeg': case 'image/jpeg':
$ext = '.jpg'; $ext = '.jpg';
break; break;
@ -219,6 +221,7 @@ class MediaStorageService
$mimes = [ $mimes = [
'application/octet-stream', 'application/octet-stream',
'image/jpg',
'image/jpeg', 'image/jpeg',
'image/png', 'image/png',
]; ];
@ -249,7 +252,7 @@ class MediaStorageService
} }
$base = ($local ? 'public/cache/' : 'cache/').'avatars/'.$avatar->profile_id; $base = ($local ? 'public/cache/' : 'cache/').'avatars/'.$avatar->profile_id;
$ext = $head['mime'] == 'image/jpeg' ? 'jpg' : 'png'; $ext = ($head['mime'] == 'image/png') ? 'png' : 'jpg';
$path = 'avatar_'.strtolower(Str::random(random_int(3, 6))).'.'.$ext; $path = 'avatar_'.strtolower(Str::random(random_int(3, 6))).'.'.$ext;
$tmpBase = storage_path('app/remcache/'); $tmpBase = storage_path('app/remcache/');
$tmpPath = 'avatar_'.$avatar->profile_id.'-'.$path; $tmpPath = 'avatar_'.$avatar->profile_id.'-'.$path;
@ -262,7 +265,7 @@ class MediaStorageService
$mimeCheck = Storage::mimeType('remcache/'.$tmpPath); $mimeCheck = Storage::mimeType('remcache/'.$tmpPath);
if (! $mimeCheck || ! in_array($mimeCheck, ['image/png', 'image/jpeg'])) { if (! $mimeCheck || ! in_array($mimeCheck, ['image/png', 'image/jpeg', 'image/jpg'])) {
$avatar->last_fetched_at = now(); $avatar->last_fetched_at = now();
$avatar->save(); $avatar->save();
unlink($tmpName); unlink($tmpName);

@ -112,7 +112,7 @@ class Status extends Model
} }
return collect($entity['media_attachments']) return collect($entity['media_attachments'])
->filter(fn($media) => $media['type'] == 'image' && in_array($media['mime'], ['image/jpeg', 'image/png'])) ->filter(fn($media) => $media['type'] == 'image' && in_array($media['mime'], ['image/jpeg', 'image/png', 'image/jpg']))
->map(function($media) { ->map(function($media) {
if(!Str::endsWith($media['preview_url'], ['no-preview.png', 'no-preview.jpg'])) { if(!Str::endsWith($media['preview_url'], ['no-preview.png', 'no-preview.jpg'])) {
return $media['preview_url']; return $media['preview_url'];

@ -11,7 +11,7 @@ class Blurhash {
public static function generate(Media $media) public static function generate(Media $media)
{ {
if(!in_array($media->mime, ['image/png', 'image/jpeg', 'video/mp4'])) { if(!in_array($media->mime, ['image/png', 'image/jpeg', 'image/jpg', 'video/mp4'])) {
return self::DEFAULT_HASH; return self::DEFAULT_HASH;
} }
@ -44,7 +44,6 @@ class Blurhash {
$pixels[] = $row; $pixels[] = $row;
} }
// Free the allocated GdImage object from memory:
imagedestroy($image); imagedestroy($image);
$components_x = 4; $components_x = 4;

@ -3,8 +3,13 @@
namespace App\Util\Media; namespace App\Util\Media;
use App\Media; use App\Media;
use Image as Intervention; use Intervention\Image\ImageManager;
use Intervention\Image\Encoders\JpegEncoder;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\Encoders\AvifEncoder;
use Intervention\Image\Encoders\PngEncoder;
use Cache, Log, Storage; use Cache, Log, Storage;
use App\Util\Media\Blurhash;
class Image class Image
{ {
@ -16,10 +21,14 @@ class Image
public $acceptedMimes = [ public $acceptedMimes = [
'image/png', 'image/png',
'image/jpeg', 'image/jpeg',
'image/jpg',
'image/webp', 'image/webp',
'image/avif', 'image/avif',
'image/heic',
]; ];
protected $imageManager;
public function __construct() public function __construct()
{ {
ini_set('memory_limit', config('pixelfed.memory_limit', '1024M')); ini_set('memory_limit', config('pixelfed.memory_limit', '1024M'));
@ -32,6 +41,20 @@ class Image
'height' => 640, 'height' => 640,
]; ];
$this->orientation = null; $this->orientation = null;
$driver = match(config('image.driver')) {
'imagick' => new \Intervention\Image\Drivers\Imagick\Driver(),
'vips' => new \Intervention\Image\Drivers\Vips\Driver(),
default => new \Intervention\Image\Drivers\Gd\Driver()
};
$this->imageManager = new ImageManager(
$driver,
autoOrientation: true,
decodeAnimation: true,
blendingColor: 'ffffff',
strip: true
);
} }
public function orientations() public function orientations()
@ -54,9 +77,6 @@ class Image
public function getAspectRatio($mediaPath, $thumbnail = false) public function getAspectRatio($mediaPath, $thumbnail = false)
{ {
if (!is_file($mediaPath)) {
throw new \Exception('Invalid Media Path');
}
if ($thumbnail) { if ($thumbnail) {
return [ return [
'dimensions' => $this->thumbnail, 'dimensions' => $this->thumbnail,
@ -64,6 +84,10 @@ class Image
]; ];
} }
if (!is_file($mediaPath)) {
throw new \Exception('Invalid Media Path');
}
list($width, $height) = getimagesize($mediaPath); list($width, $height) = getimagesize($mediaPath);
$aspect = $width / $height; $aspect = $width / $height;
$orientation = $aspect === 1 ? 'square' : $orientation = $aspect === 1 ? 'square' :
@ -80,15 +104,11 @@ class Image
public function resizeImage(Media $media) public function resizeImage(Media $media)
{ {
$basePath = storage_path('app/'.$media->media_path);
$this->handleResizeImage($media); $this->handleResizeImage($media);
} }
public function resizeThumbnail(Media $media) public function resizeThumbnail(Media $media)
{ {
$basePath = storage_path('app/'.$media->media_path);
$this->handleThumbnailImage($media); $this->handleThumbnailImage($media);
} }
@ -114,18 +134,16 @@ class Image
$orientation = $ratio['orientation']; $orientation = $ratio['orientation'];
try { try {
$img = Intervention::make($file); $fileInfo = pathinfo($file);
$metadata = $img->exif(); $extension = strtolower($fileInfo['extension'] ?? 'jpg');
$img->orientate();
if($thumbnail) { $metadata = null;
$img->resize($aspect['width'], $aspect['height'], function ($constraint) { if (!$thumbnail && config('media.exif.database', false) == true) {
$constraint->aspectRatio(); try {
}); $exif = @exif_read_data($file);
} else { if ($exif) {
if(config('media.exif.database', false) == true && $metadata) {
$meta = []; $meta = [];
$keys = [ $keys = [
"COMPUTED",
"FileName", "FileName",
"FileSize", "FileSize",
"FileType", "FileType",
@ -152,28 +170,69 @@ class Image
"ISOSpeedRatings", "ISOSpeedRatings",
"ShutterSpeedValue" "ShutterSpeedValue"
]; ];
foreach ($metadata as $k => $v) { foreach ($exif as $k => $v) {
if(in_array($k, $keys)) { if (in_array($k, $keys)) {
$meta[$k] = $v; $meta[$k] = $v;
} }
} }
$media->metadata = json_encode($meta); $media->metadata = json_encode($meta);
} }
} catch (\Exception $e) {
Log::info('EXIF extraction failed: ' . $e->getMessage());
}
}
$img = $this->imageManager->read($file);
if ($thumbnail) {
$img = $img->coverDown(
$aspect['width'],
$aspect['height']
);
} else {
if ( if (
($ratio['width_original'] > $aspect['width']) ($ratio['width_original'] > $aspect['width'])
|| ($ratio['height_original'] > $aspect['height']) || ($ratio['height_original'] > $aspect['height'])
) { ) {
$img->resize($aspect['width'], $aspect['height'], function ($constraint) { $img = $img->scaleDown(
$constraint->aspectRatio(); $aspect['width'],
}); $aspect['height']
);
} }
} }
$converted = $this->setBaseName($path, $thumbnail, $img->extension);
$converted = $this->setBaseName($path, $thumbnail, $extension);
$newPath = storage_path('app/'.$converted['path']); $newPath = storage_path('app/'.$converted['path']);
$quality = config_cache('pixelfed.image_quality'); $quality = config_cache('pixelfed.image_quality');
$img->save($newPath, $quality);
$encoder = null;
switch ($extension) {
case 'jpeg':
case 'jpg':
$encoder = new JpegEncoder($quality);
break;
case 'png':
$encoder = new PngEncoder();
break;
case 'webp':
$encoder = new WebpEncoder($quality);
break;
case 'avif':
$encoder = new AvifEncoder($quality);
break;
case 'heic':
$encoder = new JpegEncoder($quality);
$extension = 'jpg';
break;
default:
$encoder = new JpegEncoder($quality);
$extension = 'jpg';
}
$encoded = $encoder->encode($img);
file_put_contents($newPath, $encoded->toString());
if ($thumbnail == true) { if ($thumbnail == true) {
$media->thumbnail_path = $converted['path']; $media->thumbnail_path = $converted['path'];
@ -183,23 +242,22 @@ class Image
$media->height = $img->height(); $media->height = $img->height();
$media->orientation = $orientation; $media->orientation = $orientation;
$media->media_path = $converted['path']; $media->media_path = $converted['path'];
$media->mime = $img->mime; $media->mime = 'image/' . $extension;
} }
$img->destroy();
$media->save(); $media->save();
if($thumbnail) { if ($thumbnail) {
$this->generateBlurhash($media); $this->generateBlurhash($media);
} }
Cache::forget('status:transformer:media:attachments:'.$media->status_id); Cache::forget('status:transformer:media:attachments:'.$media->status_id);
Cache::forget('status:thumb:'.$media->status_id); Cache::forget('status:thumb:'.$media->status_id);
} catch (Exception $e) { } catch (\Exception $e) {
$media->processed_at = now(); $media->processed_at = now();
$media->save(); $media->save();
Log::info('MediaResizeException: Could not process media id: ' . $media->id); Log::info('MediaResizeException: ' . $e->getMessage() . ' | Could not process media id: ' . $media->id);
} }
} }
@ -217,7 +275,7 @@ class Image
protected function generateBlurhash($media) protected function generateBlurhash($media)
{ {
$blurhash = Blurhash::generate($media); $blurhash = Blurhash::generate($media);
if($blurhash) { if ($blurhash) {
$media->blurhash = $blurhash; $media->blurhash = $blurhash;
$media->save(); $media->save();
} }

@ -18,7 +18,7 @@
"buzz/laravel-h-captcha": "^1.0.4", "buzz/laravel-h-captcha": "^1.0.4",
"doctrine/dbal": "^3.0", "doctrine/dbal": "^3.0",
"endroid/qr-code": "^6.0", "endroid/qr-code": "^6.0",
"intervention/image": "^2.4", "intervention/image": "^3.11.2",
"jenssegers/agent": "^2.6", "jenssegers/agent": "^2.6",
"laravel-notification-channels/expo": "^2.0.0", "laravel-notification-channels/expo": "^2.0.0",
"laravel-notification-channels/webpush": "^10.2", "laravel-notification-channels/webpush": "^10.2",

876
composer.lock generated

File diff suppressed because it is too large Load Diff

@ -7,14 +7,12 @@ return [
| Image Driver | Image Driver
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Intervention Image supports "GD Library" and "Imagick" to process images | Intervention Image supports "GD Library", "Imagick" and "libvips" to process
| internally. You may choose one of them according to your PHP | images internally. You may choose one of them according to your PHP
| configuration. By default PHP's "GD Library" implementation is used. | configuration. By default PHP's "GD Library" implementation is used.
| |
| Supported: "gd", "imagick" | Supported: "gd", "imagick", "libvips"
| |
*/ */
'driver' => env('IMAGE_DRIVER', 'gd'), 'driver' => env('IMAGE_DRIVER', 'gd'),
]; ];

Loading…
Cancel
Save