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',
]) ])

@ -11,123 +11,124 @@ use App\Jobs\MediaPipeline\MediaFixLocalFilesystemCleanupPipeline;
class FixMediaDriver extends Command class FixMediaDriver extends Command
{ {
/** /**
* The name and signature of the console command. * The name and signature of the console command.
* *
* @var string * @var string
*/ */
protected $signature = 'media:fix-nonlocal-driver'; protected $signature = 'media:fix-nonlocal-driver';
/** /**
* The console command description. * The console command description.
* *
* @var string * @var string
*/ */
protected $description = 'Fix filesystem when FILESYSTEM_DRIVER not set to local'; protected $description = 'Fix filesystem when FILESYSTEM_DRIVER not set to local';
/** /**
* Execute the console command. * Execute the console command.
* *
* @return int * @return int
*/ */
public function handle() public function handle()
{ {
if(config('filesystems.default') !== 'local') { if(config('filesystems.default') !== 'local') {
$this->error('Invalid default filesystem, set FILESYSTEM_DRIVER=local to proceed'); $this->error('Invalid default filesystem, set FILESYSTEM_DRIVER=local to proceed');
return Command::SUCCESS; return Command::SUCCESS;
} }
if((bool) config_cache('pixelfed.cloud_storage') == false) { if((bool) config_cache('pixelfed.cloud_storage') == false) {
$this->error('Cloud storage not enabled, exiting...'); $this->error('Cloud storage not enabled, exiting...');
return Command::SUCCESS; return Command::SUCCESS;
} }
$this->info(' ____ _ ______ __ '); $this->info(' ____ _ ______ __ ');
$this->info(' / __ \(_) _____ / / __/__ ____/ / '); $this->info(' / __ \(_) _____ / / __/__ ____/ / ');
$this->info(' / /_/ / / |/_/ _ \/ / /_/ _ \/ __ / '); $this->info(' / /_/ / / |/_/ _ \/ / /_/ _ \/ __ / ');
$this->info(' / ____/ /> </ __/ / __/ __/ /_/ / '); $this->info(' / ____/ /> </ __/ / __/ __/ /_/ / ');
$this->info(' /_/ /_/_/|_|\___/_/_/ \___/\__,_/ '); $this->info(' /_/ /_/_/|_|\___/_/_/ \___/\__,_/ ');
$this->info(' '); $this->info(' ');
$this->info(' Media Filesystem Fix'); $this->info(' Media Filesystem Fix');
$this->info(' ====================='); $this->info(' =====================');
$this->info(' Fix media that was created when FILESYSTEM_DRIVER=local'); $this->info(' Fix media that was created when FILESYSTEM_DRIVER=local');
$this->info(' was not properly set. This command will fix media urls'); $this->info(' was not properly set. This command will fix media urls');
$this->info(' and optionally optimize/generate thumbnails when applicable,'); $this->info(' and optionally optimize/generate thumbnails when applicable,');
$this->info(' clean up temporary local media files and clear the app cache'); $this->info(' clean up temporary local media files and clear the app cache');
$this->info(' to fix media paths/urls.'); $this->info(' to fix media paths/urls.');
$this->info(' '); $this->info(' ');
$this->error(' Remember, FILESYSTEM_DRIVER=local must remain set or you will break things!'); $this->error(' Remember, FILESYSTEM_DRIVER=local must remain set or you will break things!');
if(!$this->confirm('Are you sure you want to perform this command?')) { if(!$this->confirm('Are you sure you want to perform this command?')) {
$this->info('Exiting...'); $this->info('Exiting...');
return Command::SUCCESS; return Command::SUCCESS;
} }
$optimize = $this->choice( $optimize = $this->choice(
'Do you want to optimize media and generate thumbnails? This will store s3 locally and re-upload optimized versions.', 'Do you want to optimize media and generate thumbnails? This will store s3 locally and re-upload optimized versions.',
['no', 'yes'], ['no', 'yes'],
1 1
); );
$cloud = Storage::disk(config('filesystems.cloud')); $cloud = Storage::disk(config('filesystems.cloud'));
$mountManager = new MountManager([ $mountManager = new MountManager([
's3' => $cloud->getDriver(), 's3' => $cloud->getDriver(),
'local' => Storage::disk('local')->getDriver(), 'local' => Storage::disk('local')->getDriver(),
]); ]);
$this->info('Fixing media, this may take a while...'); $this->info('Fixing media, this may take a while...');
$this->line(' '); $this->line(' ');
$bar = $this->output->createProgressBar(Media::whereNotNull('status_id')->whereNull('cdn_url')->count()); $bar = $this->output->createProgressBar(Media::whereNotNull('status_id')->whereNull('cdn_url')->count());
$bar->start(); $bar->start();
foreach(Media::whereNotNull('status_id')->whereNull('cdn_url')->lazyById(20) as $media) { foreach(Media::whereNotNull('status_id')->whereNull('cdn_url')->lazyById(20) as $media) {
if($cloud->exists($media->media_path)) { if($cloud->exists($media->media_path)) {
if($optimize === 'yes') { if($optimize === 'yes') {
$mountManager->copy( $mountManager->copy(
's3://' . $media->media_path, 's3://' . $media->media_path,
'local://' . $media->media_path 'local://' . $media->media_path
); );
sleep(1); sleep(1);
if(empty($media->original_sha256)) { if(empty($media->original_sha256)) {
$hash = \hash_file('sha256', Storage::disk('local')->path($media->media_path)); $hash = \hash_file('sha256', Storage::disk('local')->path($media->media_path));
$media->original_sha256 = $hash; $media->original_sha256 = $hash;
$media->save(); $media->save();
sleep(1); sleep(1);
} }
if( if(
$media->mime && $media->mime &&
in_array($media->mime, [ in_array($media->mime, [
'image/jpeg', 'image/jpg',
'image/png', 'image/jpeg',
'image/webp' 'image/png',
]) 'image/webp'
) { ])
ImageOptimize::dispatch($media); ) {
sleep(3); ImageOptimize::dispatch($media);
} sleep(3);
} else { }
$media->cdn_url = $cloud->url($media->media_path); } else {
$media->save(); $media->cdn_url = $cloud->url($media->media_path);
} $media->save();
} }
$bar->advance(); }
} $bar->advance();
}
$bar->finish();
$this->line(' '); $bar->finish();
$this->line(' '); $this->line(' ');
$this->line(' ');
$this->callSilently('cache:clear');
$this->callSilently('cache:clear');
$this->info('Successfully fixed media paths and cleared cached!');
$this->info('Successfully fixed media paths and cleared cached!');
if($optimize === 'yes') {
MediaFixLocalFilesystemCleanupPipeline::dispatch()->delay(now()->addMinutes(15))->onQueue('default'); if($optimize === 'yes') {
$this->line(' '); MediaFixLocalFilesystemCleanupPipeline::dispatch()->delay(now()->addMinutes(15))->onQueue('default');
$this->info('A cleanup job has been dispatched to delete media stored locally, it may take a few minutes to process!'); $this->line(' ');
} $this->info('A cleanup job has been dispatched to delete media stored locally, it may take a few minutes to process!');
}
$this->line(' ');
return Command::SUCCESS; $this->line(' ');
} return Command::SUCCESS;
}
} }

@ -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);

@ -15,104 +15,104 @@ use Illuminate\Support\Str;
class Status extends Model class Status extends Model
{ {
use HasSnowflakePrimary, SoftDeletes; use HasSnowflakePrimary, SoftDeletes;
/** /**
* Indicates if the IDs are auto-incrementing. * Indicates if the IDs are auto-incrementing.
* *
* @var bool * @var bool
*/ */
public $incrementing = false; public $incrementing = false;
/** /**
* The attributes that should be mutated to dates. * The attributes that should be mutated to dates.
* *
* @var array * @var array
*/ */
protected $casts = [ protected $casts = [
'deleted_at' => 'datetime', 'deleted_at' => 'datetime',
'edited_at' => 'datetime' 'edited_at' => 'datetime'
]; ];
protected $guarded = []; protected $guarded = [];
const STATUS_TYPES = [ const STATUS_TYPES = [
'text', 'text',
'photo', 'photo',
'photo:album', 'photo:album',
'video', 'video',
'video:album', 'video:album',
'photo:video:album', 'photo:video:album',
'share', 'share',
'reply', 'reply',
'story', 'story',
'story:reply', 'story:reply',
'story:reaction', 'story:reaction',
'story:live', 'story:live',
'loop' 'loop'
]; ];
const MAX_MENTIONS = 20; const MAX_MENTIONS = 20;
const MAX_HASHTAGS = 60; const MAX_HASHTAGS = 60;
const MAX_LINKS = 5; const MAX_LINKS = 5;
public function profile() public function profile()
{ {
return $this->belongsTo(Profile::class); return $this->belongsTo(Profile::class);
} }
public function media() public function media()
{ {
return $this->hasMany(Media::class); return $this->hasMany(Media::class);
} }
public function firstMedia() public function firstMedia()
{ {
return $this->hasMany(Media::class)->orderBy('order', 'asc')->first(); return $this->hasMany(Media::class)->orderBy('order', 'asc')->first();
} }
public function viewType() public function viewType()
{ {
if($this->type) { if($this->type) {
return $this->type; return $this->type;
} }
return $this->setType(); return $this->setType();
} }
public function setType() public function setType()
{ {
if(in_array($this->type, self::STATUS_TYPES)) { if(in_array($this->type, self::STATUS_TYPES)) {
return $this->type; return $this->type;
} }
$mimes = $this->media->pluck('mime')->toArray(); $mimes = $this->media->pluck('mime')->toArray();
$type = StatusController::mimeTypeCheck($mimes); $type = StatusController::mimeTypeCheck($mimes);
if($type) { if($type) {
$this->type = $type; $this->type = $type;
$this->save(); $this->save();
return $type; return $type;
} }
} }
public function thumb($showNsfw = false) public function thumb($showNsfw = false)
{ {
$entity = StatusService::get($this->id, false); $entity = StatusService::get($this->id, false);
if(!$entity || !isset($entity['media_attachments']) || empty($entity['media_attachments'])) { if(!$entity || !isset($entity['media_attachments']) || empty($entity['media_attachments'])) {
return url(Storage::url('public/no-preview.png')); return url(Storage::url('public/no-preview.png'));
} }
if((!isset($entity['sensitive']) || $entity['sensitive']) && !$showNsfw) { if((!isset($entity['sensitive']) || $entity['sensitive']) && !$showNsfw) {
return url(Storage::url('public/no-preview.png')); return url(Storage::url('public/no-preview.png'));
} }
if(!isset($entity['visibility']) || !in_array($entity['visibility'], ['public', 'unlisted'])) { if(!isset($entity['visibility']) || !in_array($entity['visibility'], ['public', 'unlisted'])) {
return url(Storage::url('public/no-preview.png')); return url(Storage::url('public/no-preview.png'));
} }
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'];
@ -121,259 +121,259 @@ class Status extends Model
return $media['url']; return $media['url'];
}) })
->first() ?? url(Storage::url('public/no-preview.png')); ->first() ?? url(Storage::url('public/no-preview.png'));
} }
public function url($forceLocal = false) public function url($forceLocal = false)
{ {
if($this->uri) { if($this->uri) {
return $forceLocal ? "/i/web/post/_/{$this->profile_id}/{$this->id}" : $this->uri; return $forceLocal ? "/i/web/post/_/{$this->profile_id}/{$this->id}" : $this->uri;
} else { } else {
$id = $this->id; $id = $this->id;
$account = AccountService::get($this->profile_id, true); $account = AccountService::get($this->profile_id, true);
if(!$account || !isset($account['username'])) { if(!$account || !isset($account['username'])) {
return '/404'; return '/404';
} }
$path = url(config('app.url')."/p/{$account['username']}/{$id}"); $path = url(config('app.url')."/p/{$account['username']}/{$id}");
return $path; return $path;
} }
} }
public function permalink($suffix = '/activity') public function permalink($suffix = '/activity')
{ {
$id = $this->id; $id = $this->id;
$username = $this->profile->username; $username = $this->profile->username;
$path = config('app.url')."/p/{$username}/{$id}{$suffix}"; $path = config('app.url')."/p/{$username}/{$id}{$suffix}";
return url($path); return url($path);
} }
public function editUrl() public function editUrl()
{ {
return $this->url().'/edit'; return $this->url().'/edit';
} }
public function mediaUrl() public function mediaUrl()
{ {
$media = $this->firstMedia(); $media = $this->firstMedia();
$path = $media->media_path; $path = $media->media_path;
$hash = is_null($media->processed_at) ? md5('unprocessed') : md5($media->created_at); $hash = is_null($media->processed_at) ? md5('unprocessed') : md5($media->created_at);
$url = $media->cdn_url ? $media->cdn_url . "?v={$hash}" : url(Storage::url($path)."?v={$hash}"); $url = $media->cdn_url ? $media->cdn_url . "?v={$hash}" : url(Storage::url($path)."?v={$hash}");
return $url; return $url;
} }
public function likes() public function likes()
{ {
return $this->hasMany(Like::class); return $this->hasMany(Like::class);
} }
public function liked() : bool public function liked() : bool
{ {
if(!Auth::check()) { if(!Auth::check()) {
return false; return false;
} }
$pid = Auth::user()->profile_id; $pid = Auth::user()->profile_id;
return Like::select('status_id', 'profile_id') return Like::select('status_id', 'profile_id')
->whereStatusId($this->id) ->whereStatusId($this->id)
->whereProfileId($pid) ->whereProfileId($pid)
->exists(); ->exists();
} }
public function likedBy() public function likedBy()
{ {
return $this->hasManyThrough( return $this->hasManyThrough(
Profile::class, Profile::class,
Like::class, Like::class,
'status_id', 'status_id',
'id', 'id',
'id', 'id',
'profile_id' 'profile_id'
); );
} }
public function comments() public function comments()
{ {
return $this->hasMany(self::class, 'in_reply_to_id'); return $this->hasMany(self::class, 'in_reply_to_id');
} }
public function bookmarked() public function bookmarked()
{ {
if (!Auth::check()) { if (!Auth::check()) {
return false; return false;
} }
$profile = Auth::user()->profile; $profile = Auth::user()->profile;
return Bookmark::whereProfileId($profile->id)->whereStatusId($this->id)->count(); return Bookmark::whereProfileId($profile->id)->whereStatusId($this->id)->count();
} }
public function shares() public function shares()
{ {
return $this->hasMany(self::class, 'reblog_of_id'); return $this->hasMany(self::class, 'reblog_of_id');
} }
public function shared() : bool public function shared() : bool
{ {
if(!Auth::check()) { if(!Auth::check()) {
return false; return false;
} }
$pid = Auth::user()->profile_id; $pid = Auth::user()->profile_id;
return $this->select('profile_id', 'reblog_of_id') return $this->select('profile_id', 'reblog_of_id')
->whereProfileId($pid) ->whereProfileId($pid)
->whereReblogOfId($this->id) ->whereReblogOfId($this->id)
->exists(); ->exists();
} }
public function sharedBy() public function sharedBy()
{ {
return $this->hasManyThrough( return $this->hasManyThrough(
Profile::class, Profile::class,
Status::class, Status::class,
'reblog_of_id', 'reblog_of_id',
'id', 'id',
'id', 'id',
'profile_id' 'profile_id'
); );
} }
public function parent() public function parent()
{ {
$parent = $this->in_reply_to_id ?? $this->reblog_of_id; $parent = $this->in_reply_to_id ?? $this->reblog_of_id;
if (!empty($parent)) { if (!empty($parent)) {
return $this->findOrFail($parent); return $this->findOrFail($parent);
} else { } else {
return false; return false;
} }
} }
public function conversation() public function conversation()
{ {
return $this->hasOne(Conversation::class); return $this->hasOne(Conversation::class);
} }
public function hashtags() public function hashtags()
{ {
return $this->hasManyThrough( return $this->hasManyThrough(
Hashtag::class, Hashtag::class,
StatusHashtag::class, StatusHashtag::class,
'status_id', 'status_id',
'id', 'id',
'id', 'id',
'hashtag_id' 'hashtag_id'
); );
} }
public function mentions() public function mentions()
{ {
return $this->hasManyThrough( return $this->hasManyThrough(
Profile::class, Profile::class,
Mention::class, Mention::class,
'status_id', 'status_id',
'id', 'id',
'id', 'id',
'profile_id' 'profile_id'
); );
} }
public function reportUrl() public function reportUrl()
{ {
return route('report.form')."?type=post&id={$this->id}"; return route('report.form')."?type=post&id={$this->id}";
} }
public function toActivityStream() public function toActivityStream()
{ {
$media = $this->media; $media = $this->media;
$mediaCollection = []; $mediaCollection = [];
foreach ($media as $image) { foreach ($media as $image) {
$mediaCollection[] = [ $mediaCollection[] = [
'type' => 'Link', 'type' => 'Link',
'href' => $image->url(), 'href' => $image->url(),
'mediaType' => $image->mime, 'mediaType' => $image->mime,
]; ];
} }
$obj = [ $obj = [
'@context' => 'https://www.w3.org/ns/activitystreams', '@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Image', 'type' => 'Image',
'name' => null, 'name' => null,
'url' => $mediaCollection, 'url' => $mediaCollection,
]; ];
return $obj; return $obj;
} }
public function recentComments() public function recentComments()
{ {
return $this->comments()->orderBy('created_at', 'desc')->take(3); return $this->comments()->orderBy('created_at', 'desc')->take(3);
} }
public function scopeToAudience($audience) public function scopeToAudience($audience)
{ {
if(!in_array($audience, ['to', 'cc']) || $this->local == false) { if(!in_array($audience, ['to', 'cc']) || $this->local == false) {
return; return;
} }
$res = []; $res = [];
$res['to'] = []; $res['to'] = [];
$res['cc'] = []; $res['cc'] = [];
$scope = $this->scope; $scope = $this->scope;
$mentions = $this->mentions->map(function ($mention) { $mentions = $this->mentions->map(function ($mention) {
return $mention->permalink(); return $mention->permalink();
})->toArray(); })->toArray();
if($this->in_reply_to_id != null) { if($this->in_reply_to_id != null) {
$parent = $this->parent(); $parent = $this->parent();
if($parent) { if($parent) {
$mentions = array_merge([$parent->profile->permalink()], $mentions); $mentions = array_merge([$parent->profile->permalink()], $mentions);
} }
} }
switch ($scope) { switch ($scope) {
case 'public': case 'public':
$res['to'] = [ $res['to'] = [
"https://www.w3.org/ns/activitystreams#Public" "https://www.w3.org/ns/activitystreams#Public"
]; ];
$res['cc'] = array_merge([$this->profile->permalink('/followers')], $mentions); $res['cc'] = array_merge([$this->profile->permalink('/followers')], $mentions);
break; break;
case 'unlisted': case 'unlisted':
$res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions); $res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions);
$res['cc'] = [ $res['cc'] = [
"https://www.w3.org/ns/activitystreams#Public" "https://www.w3.org/ns/activitystreams#Public"
]; ];
break; break;
case 'private': case 'private':
$res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions); $res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions);
$res['cc'] = []; $res['cc'] = [];
break; break;
// TODO: Update scope when DMs are supported // TODO: Update scope when DMs are supported
case 'direct': case 'direct':
$res['to'] = []; $res['to'] = [];
$res['cc'] = []; $res['cc'] = [];
break; break;
} }
return $res[$audience]; return $res[$audience];
} }
public function place() public function place()
{ {
return $this->belongsTo(Place::class); return $this->belongsTo(Place::class);
} }
public function directMessage() public function directMessage()
{ {
return $this->hasOne(DirectMessage::class); return $this->hasOne(DirectMessage::class);
} }
public function poll() public function poll()
{ {
return $this->hasOne(Poll::class); return $this->hasOne(Poll::class);
} }
public function edits() public function edits()
{ {
return $this->hasMany(StatusEdit::class); return $this->hasMany(StatusEdit::class);
} }
} }

@ -7,53 +7,52 @@ use App\Media;
class Blurhash { class Blurhash {
const DEFAULT_HASH = 'U4Rfzst8?bt7ogayj[j[~pfQ9Goe%Mj[WBay'; const DEFAULT_HASH = 'U4Rfzst8?bt7ogayj[j[~pfQ9Goe%Mj[WBay';
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;
} }
if($media->thumbnail_path == null) { if($media->thumbnail_path == null) {
return self::DEFAULT_HASH; return self::DEFAULT_HASH;
} }
$file = storage_path('app/' . $media->thumbnail_path); $file = storage_path('app/' . $media->thumbnail_path);
if(!is_file($file)) { if(!is_file($file)) {
return self::DEFAULT_HASH; return self::DEFAULT_HASH;
} }
$image = imagecreatefromstring(file_get_contents($file)); $image = imagecreatefromstring(file_get_contents($file));
if(!$image) { if(!$image) {
return self::DEFAULT_HASH; return self::DEFAULT_HASH;
} }
$width = imagesx($image); $width = imagesx($image);
$height = imagesy($image); $height = imagesy($image);
$pixels = []; $pixels = [];
for ($y = 0; $y < $height; ++$y) { for ($y = 0; $y < $height; ++$y) {
$row = []; $row = [];
for ($x = 0; $x < $width; ++$x) { for ($x = 0; $x < $width; ++$x) {
$index = imagecolorat($image, $x, $y); $index = imagecolorat($image, $x, $y);
$colors = imagecolorsforindex($image, $index); $colors = imagecolorsforindex($image, $index);
$row[] = [$colors['red'], $colors['green'], $colors['blue']]; $row[] = [$colors['red'], $colors['green'], $colors['blue']];
} }
$pixels[] = $row; $pixels[] = $row;
} }
// Free the allocated GdImage object from memory: imagedestroy($image);
imagedestroy($image);
$components_x = 4;
$components_x = 4; $components_y = 4;
$components_y = 4; $blurhash = BlurhashEngine::encode($pixels, $components_x, $components_y);
$blurhash = BlurhashEngine::encode($pixels, $components_x, $components_y); if(strlen($blurhash) > 191) {
if(strlen($blurhash) > 191) { return self::DEFAULT_HASH;
return self::DEFAULT_HASH; }
} return $blurhash;
return $blurhash; }
}
} }

@ -3,223 +3,281 @@
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
{ {
public $square; public $square;
public $landscape; public $landscape;
public $portrait; public $portrait;
public $thumbnail; public $thumbnail;
public $orientation; public $orientation;
public $acceptedMimes = [ public $acceptedMimes = [
'image/png', 'image/png',
'image/jpeg', 'image/jpeg',
'image/webp', 'image/jpg',
'image/avif', 'image/webp',
]; 'image/avif',
'image/heic',
public function __construct() ];
{
ini_set('memory_limit', config('pixelfed.memory_limit', '1024M')); protected $imageManager;
$this->square = $this->orientations()['square']; public function __construct()
$this->landscape = $this->orientations()['landscape']; {
$this->portrait = $this->orientations()['portrait']; ini_set('memory_limit', config('pixelfed.memory_limit', '1024M'));
$this->thumbnail = [
'width' => 640, $this->square = $this->orientations()['square'];
'height' => 640, $this->landscape = $this->orientations()['landscape'];
]; $this->portrait = $this->orientations()['portrait'];
$this->orientation = null; $this->thumbnail = [
} 'width' => 640,
'height' => 640,
public function orientations() ];
{ $this->orientation = null;
return [
'square' => [ $driver = match(config('image.driver')) {
'width' => 1080, 'imagick' => new \Intervention\Image\Drivers\Imagick\Driver(),
'height' => 1080, 'vips' => new \Intervention\Image\Drivers\Vips\Driver(),
], default => new \Intervention\Image\Drivers\Gd\Driver()
'landscape' => [ };
'width' => 1920,
'height' => 1080, $this->imageManager = new ImageManager(
], $driver,
'portrait' => [ autoOrientation: true,
'width' => 1080, decodeAnimation: true,
'height' => 1350, blendingColor: 'ffffff',
], strip: true
]; );
} }
public function getAspectRatio($mediaPath, $thumbnail = false) public function orientations()
{ {
if (!is_file($mediaPath)) { return [
throw new \Exception('Invalid Media Path'); 'square' => [
} 'width' => 1080,
if ($thumbnail) { 'height' => 1080,
return [ ],
'dimensions' => $this->thumbnail, 'landscape' => [
'orientation' => 'thumbnail', 'width' => 1920,
]; 'height' => 1080,
} ],
'portrait' => [
list($width, $height) = getimagesize($mediaPath); 'width' => 1080,
$aspect = $width / $height; 'height' => 1350,
$orientation = $aspect === 1 ? 'square' : ],
($aspect > 1 ? 'landscape' : 'portrait'); ];
$this->orientation = $orientation; }
return [ public function getAspectRatio($mediaPath, $thumbnail = false)
'dimensions' => $this->orientations()[$orientation], {
'orientation' => $orientation, if ($thumbnail) {
'width_original' => $width, return [
'height_original' => $height, 'dimensions' => $this->thumbnail,
]; 'orientation' => 'thumbnail',
} ];
}
public function resizeImage(Media $media)
{ if (!is_file($mediaPath)) {
$basePath = storage_path('app/'.$media->media_path); throw new \Exception('Invalid Media Path');
}
$this->handleResizeImage($media);
} list($width, $height) = getimagesize($mediaPath);
$aspect = $width / $height;
public function resizeThumbnail(Media $media) $orientation = $aspect === 1 ? 'square' :
{ ($aspect > 1 ? 'landscape' : 'portrait');
$basePath = storage_path('app/'.$media->media_path); $this->orientation = $orientation;
$this->handleThumbnailImage($media); return [
} 'dimensions' => $this->orientations()[$orientation],
'orientation' => $orientation,
public function handleResizeImage(Media $media) 'width_original' => $width,
{ 'height_original' => $height,
$this->handleImageTransform($media, false); ];
} }
public function handleThumbnailImage(Media $media) public function resizeImage(Media $media)
{ {
$this->handleImageTransform($media, true); $this->handleResizeImage($media);
} }
public function handleImageTransform(Media $media, $thumbnail = false) public function resizeThumbnail(Media $media)
{ {
$path = $media->media_path; $this->handleThumbnailImage($media);
$file = storage_path('app/'.$path); }
if (!in_array($media->mime, $this->acceptedMimes)) {
return; public function handleResizeImage(Media $media)
} {
$ratio = $this->getAspectRatio($file, $thumbnail); $this->handleImageTransform($media, false);
$aspect = $ratio['dimensions']; }
$orientation = $ratio['orientation'];
public function handleThumbnailImage(Media $media)
try { {
$img = Intervention::make($file); $this->handleImageTransform($media, true);
$metadata = $img->exif(); }
$img->orientate();
if($thumbnail) { public function handleImageTransform(Media $media, $thumbnail = false)
$img->resize($aspect['width'], $aspect['height'], function ($constraint) { {
$constraint->aspectRatio(); $path = $media->media_path;
}); $file = storage_path('app/'.$path);
} else { if (!in_array($media->mime, $this->acceptedMimes)) {
if(config('media.exif.database', false) == true && $metadata) { return;
$meta = []; }
$keys = [ $ratio = $this->getAspectRatio($file, $thumbnail);
"COMPUTED", $aspect = $ratio['dimensions'];
"FileName", $orientation = $ratio['orientation'];
"FileSize",
"FileType", try {
"Make", $fileInfo = pathinfo($file);
"Model", $extension = strtolower($fileInfo['extension'] ?? 'jpg');
"MimeType",
"ColorSpace", $metadata = null;
"ExifVersion", if (!$thumbnail && config('media.exif.database', false) == true) {
"Orientation", try {
"UserComment", $exif = @exif_read_data($file);
"XResolution", if ($exif) {
"YResolution", $meta = [];
"FileDateTime", $keys = [
"SectionsFound", "FileName",
"ExifImageWidth", "FileSize",
"ResolutionUnit", "FileType",
"ExifImageLength", "Make",
"FlashPixVersion", "Model",
"Exif_IFD_Pointer", "MimeType",
"YCbCrPositioning", "ColorSpace",
"ComponentsConfiguration", "ExifVersion",
"ExposureTime", "Orientation",
"FNumber", "UserComment",
"ISOSpeedRatings", "XResolution",
"ShutterSpeedValue" "YResolution",
]; "FileDateTime",
foreach ($metadata as $k => $v) { "SectionsFound",
if(in_array($k, $keys)) { "ExifImageWidth",
$meta[$k] = $v; "ResolutionUnit",
} "ExifImageLength",
} "FlashPixVersion",
$media->metadata = json_encode($meta); "Exif_IFD_Pointer",
} "YCbCrPositioning",
"ComponentsConfiguration",
if ( "ExposureTime",
($ratio['width_original'] > $aspect['width']) "FNumber",
|| ($ratio['height_original'] > $aspect['height']) "ISOSpeedRatings",
) { "ShutterSpeedValue"
$img->resize($aspect['width'], $aspect['height'], function ($constraint) { ];
$constraint->aspectRatio(); foreach ($exif as $k => $v) {
}); if (in_array($k, $keys)) {
} $meta[$k] = $v;
} }
$converted = $this->setBaseName($path, $thumbnail, $img->extension); }
$newPath = storage_path('app/'.$converted['path']); $media->metadata = json_encode($meta);
}
$quality = config_cache('pixelfed.image_quality'); } catch (\Exception $e) {
$img->save($newPath, $quality); Log::info('EXIF extraction failed: ' . $e->getMessage());
}
if ($thumbnail == true) { }
$media->thumbnail_path = $converted['path'];
$media->thumbnail_url = url(Storage::url($converted['path'])); $img = $this->imageManager->read($file);
} else {
$media->width = $img->width(); if ($thumbnail) {
$media->height = $img->height(); $img = $img->coverDown(
$media->orientation = $orientation; $aspect['width'],
$media->media_path = $converted['path']; $aspect['height']
$media->mime = $img->mime; );
} } else {
if (
$img->destroy(); ($ratio['width_original'] > $aspect['width'])
$media->save(); || ($ratio['height_original'] > $aspect['height'])
) {
if($thumbnail) { $img = $img->scaleDown(
$this->generateBlurhash($media); $aspect['width'],
} $aspect['height']
);
Cache::forget('status:transformer:media:attachments:'.$media->status_id); }
Cache::forget('status:thumb:'.$media->status_id); }
} catch (Exception $e) { $converted = $this->setBaseName($path, $thumbnail, $extension);
$media->processed_at = now(); $newPath = storage_path('app/'.$converted['path']);
$media->save();
Log::info('MediaResizeException: Could not process media id: ' . $media->id); $quality = config_cache('pixelfed.image_quality');
}
} $encoder = null;
switch ($extension) {
public function setBaseName($basePath, $thumbnail, $extension) case 'jpeg':
{ case 'jpg':
$png = false; $encoder = new JpegEncoder($quality);
$path = explode('.', $basePath); break;
$name = ($thumbnail == true) ? $path[0].'_thumb' : $path[0]; case 'png':
$ext = last($path); $encoder = new PngEncoder();
$basePath = "{$name}.{$ext}"; break;
case 'webp':
return ['path' => $basePath, 'png' => $png]; $encoder = new WebpEncoder($quality);
} break;
case 'avif':
protected function generateBlurhash($media) $encoder = new AvifEncoder($quality);
{ break;
$blurhash = Blurhash::generate($media); case 'heic':
if($blurhash) { $encoder = new JpegEncoder($quality);
$media->blurhash = $blurhash; $extension = 'jpg';
$media->save(); break;
} default:
} $encoder = new JpegEncoder($quality);
$extension = 'jpg';
}
$encoded = $encoder->encode($img);
file_put_contents($newPath, $encoded->toString());
if ($thumbnail == true) {
$media->thumbnail_path = $converted['path'];
$media->thumbnail_url = url(Storage::url($converted['path']));
} else {
$media->width = $img->width();
$media->height = $img->height();
$media->orientation = $orientation;
$media->media_path = $converted['path'];
$media->mime = 'image/' . $extension;
}
$media->save();
if ($thumbnail) {
$this->generateBlurhash($media);
}
Cache::forget('status:transformer:media:attachments:'.$media->status_id);
Cache::forget('status:thumb:'.$media->status_id);
} catch (\Exception $e) {
$media->processed_at = now();
$media->save();
Log::info('MediaResizeException: ' . $e->getMessage() . ' | Could not process media id: ' . $media->id);
}
}
public function setBaseName($basePath, $thumbnail, $extension)
{
$png = false;
$path = explode('.', $basePath);
$name = ($thumbnail == true) ? $path[0].'_thumb' : $path[0];
$ext = last($path);
$basePath = "{$name}.{$ext}";
return ['path' => $basePath, 'png' => $png];
}
protected function generateBlurhash($media)
{
$blurhash = Blurhash::generate($media);
if ($blurhash) {
$media->blurhash = $blurhash;
$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