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('media_path')
->whereIn('mime', [
'image/jpg',
'image/jpeg',
'image/png',
])

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

@ -110,7 +110,7 @@ class ImportEmojis extends Command
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);
return in_array($mimeType, $allowedMimeTypes);

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

@ -243,7 +243,7 @@ class ApiV1Controller extends Controller
}
$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',
'note' => 'nullable|string|max:200',
'locked' => 'nullable',
@ -1907,6 +1907,7 @@ class ApiV1Controller extends Controller
$media->save();
switch ($media->mime) {
case 'image/jpg':
case 'image/jpeg':
case 'image/png':
case 'image/webp':
@ -2137,6 +2138,7 @@ class ApiV1Controller extends Controller
$media->save();
switch ($media->mime) {
case 'image/jpg':
case 'image/jpeg':
case 'image/png':
case 'image/webp':

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

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

@ -201,7 +201,7 @@ class ImportPostController extends Controller
$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')) {
$allowedMimeTypes[] = 'image/webp';

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

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

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

@ -64,7 +64,7 @@ class Media extends Model
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') ?
$this->media_path :
url(Storage::url($this->media_path));

@ -138,6 +138,7 @@ class MediaStorageService
}
$mimes = [
'image/jpg',
'image/jpeg',
'image/png',
'video/mp4',
@ -166,6 +167,7 @@ class MediaStorageService
$ext = '.gif';
break;
case 'image/jpg':
case 'image/jpeg':
$ext = '.jpg';
break;
@ -219,6 +221,7 @@ class MediaStorageService
$mimes = [
'application/octet-stream',
'image/jpg',
'image/jpeg',
'image/png',
];
@ -249,7 +252,7 @@ class MediaStorageService
}
$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;
$tmpBase = storage_path('app/remcache/');
$tmpPath = 'avatar_'.$avatar->profile_id.'-'.$path;
@ -262,7 +265,7 @@ class MediaStorageService
$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->save();
unlink($tmpName);

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

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

@ -3,223 +3,281 @@
namespace App\Util\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 App\Util\Media\Blurhash;
class Image
{
public $square;
public $landscape;
public $portrait;
public $thumbnail;
public $orientation;
public $acceptedMimes = [
'image/png',
'image/jpeg',
'image/webp',
'image/avif',
];
public function __construct()
{
ini_set('memory_limit', config('pixelfed.memory_limit', '1024M'));
$this->square = $this->orientations()['square'];
$this->landscape = $this->orientations()['landscape'];
$this->portrait = $this->orientations()['portrait'];
$this->thumbnail = [
'width' => 640,
'height' => 640,
];
$this->orientation = null;
}
public function orientations()
{
return [
'square' => [
'width' => 1080,
'height' => 1080,
],
'landscape' => [
'width' => 1920,
'height' => 1080,
],
'portrait' => [
'width' => 1080,
'height' => 1350,
],
];
}
public function getAspectRatio($mediaPath, $thumbnail = false)
{
if (!is_file($mediaPath)) {
throw new \Exception('Invalid Media Path');
}
if ($thumbnail) {
return [
'dimensions' => $this->thumbnail,
'orientation' => 'thumbnail',
];
}
list($width, $height) = getimagesize($mediaPath);
$aspect = $width / $height;
$orientation = $aspect === 1 ? 'square' :
($aspect > 1 ? 'landscape' : 'portrait');
$this->orientation = $orientation;
return [
'dimensions' => $this->orientations()[$orientation],
'orientation' => $orientation,
'width_original' => $width,
'height_original' => $height,
];
}
public function resizeImage(Media $media)
{
$basePath = storage_path('app/'.$media->media_path);
$this->handleResizeImage($media);
}
public function resizeThumbnail(Media $media)
{
$basePath = storage_path('app/'.$media->media_path);
$this->handleThumbnailImage($media);
}
public function handleResizeImage(Media $media)
{
$this->handleImageTransform($media, false);
}
public function handleThumbnailImage(Media $media)
{
$this->handleImageTransform($media, true);
}
public function handleImageTransform(Media $media, $thumbnail = false)
{
$path = $media->media_path;
$file = storage_path('app/'.$path);
if (!in_array($media->mime, $this->acceptedMimes)) {
return;
}
$ratio = $this->getAspectRatio($file, $thumbnail);
$aspect = $ratio['dimensions'];
$orientation = $ratio['orientation'];
try {
$img = Intervention::make($file);
$metadata = $img->exif();
$img->orientate();
if($thumbnail) {
$img->resize($aspect['width'], $aspect['height'], function ($constraint) {
$constraint->aspectRatio();
});
} else {
if(config('media.exif.database', false) == true && $metadata) {
$meta = [];
$keys = [
"COMPUTED",
"FileName",
"FileSize",
"FileType",
"Make",
"Model",
"MimeType",
"ColorSpace",
"ExifVersion",
"Orientation",
"UserComment",
"XResolution",
"YResolution",
"FileDateTime",
"SectionsFound",
"ExifImageWidth",
"ResolutionUnit",
"ExifImageLength",
"FlashPixVersion",
"Exif_IFD_Pointer",
"YCbCrPositioning",
"ComponentsConfiguration",
"ExposureTime",
"FNumber",
"ISOSpeedRatings",
"ShutterSpeedValue"
];
foreach ($metadata as $k => $v) {
if(in_array($k, $keys)) {
$meta[$k] = $v;
}
}
$media->metadata = json_encode($meta);
}
if (
($ratio['width_original'] > $aspect['width'])
|| ($ratio['height_original'] > $aspect['height'])
) {
$img->resize($aspect['width'], $aspect['height'], function ($constraint) {
$constraint->aspectRatio();
});
}
}
$converted = $this->setBaseName($path, $thumbnail, $img->extension);
$newPath = storage_path('app/'.$converted['path']);
$quality = config_cache('pixelfed.image_quality');
$img->save($newPath, $quality);
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 = $img->mime;
}
$img->destroy();
$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: 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();
}
}
public $square;
public $landscape;
public $portrait;
public $thumbnail;
public $orientation;
public $acceptedMimes = [
'image/png',
'image/jpeg',
'image/jpg',
'image/webp',
'image/avif',
'image/heic',
];
protected $imageManager;
public function __construct()
{
ini_set('memory_limit', config('pixelfed.memory_limit', '1024M'));
$this->square = $this->orientations()['square'];
$this->landscape = $this->orientations()['landscape'];
$this->portrait = $this->orientations()['portrait'];
$this->thumbnail = [
'width' => 640,
'height' => 640,
];
$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()
{
return [
'square' => [
'width' => 1080,
'height' => 1080,
],
'landscape' => [
'width' => 1920,
'height' => 1080,
],
'portrait' => [
'width' => 1080,
'height' => 1350,
],
];
}
public function getAspectRatio($mediaPath, $thumbnail = false)
{
if ($thumbnail) {
return [
'dimensions' => $this->thumbnail,
'orientation' => 'thumbnail',
];
}
if (!is_file($mediaPath)) {
throw new \Exception('Invalid Media Path');
}
list($width, $height) = getimagesize($mediaPath);
$aspect = $width / $height;
$orientation = $aspect === 1 ? 'square' :
($aspect > 1 ? 'landscape' : 'portrait');
$this->orientation = $orientation;
return [
'dimensions' => $this->orientations()[$orientation],
'orientation' => $orientation,
'width_original' => $width,
'height_original' => $height,
];
}
public function resizeImage(Media $media)
{
$this->handleResizeImage($media);
}
public function resizeThumbnail(Media $media)
{
$this->handleThumbnailImage($media);
}
public function handleResizeImage(Media $media)
{
$this->handleImageTransform($media, false);
}
public function handleThumbnailImage(Media $media)
{
$this->handleImageTransform($media, true);
}
public function handleImageTransform(Media $media, $thumbnail = false)
{
$path = $media->media_path;
$file = storage_path('app/'.$path);
if (!in_array($media->mime, $this->acceptedMimes)) {
return;
}
$ratio = $this->getAspectRatio($file, $thumbnail);
$aspect = $ratio['dimensions'];
$orientation = $ratio['orientation'];
try {
$fileInfo = pathinfo($file);
$extension = strtolower($fileInfo['extension'] ?? 'jpg');
$metadata = null;
if (!$thumbnail && config('media.exif.database', false) == true) {
try {
$exif = @exif_read_data($file);
if ($exif) {
$meta = [];
$keys = [
"FileName",
"FileSize",
"FileType",
"Make",
"Model",
"MimeType",
"ColorSpace",
"ExifVersion",
"Orientation",
"UserComment",
"XResolution",
"YResolution",
"FileDateTime",
"SectionsFound",
"ExifImageWidth",
"ResolutionUnit",
"ExifImageLength",
"FlashPixVersion",
"Exif_IFD_Pointer",
"YCbCrPositioning",
"ComponentsConfiguration",
"ExposureTime",
"FNumber",
"ISOSpeedRatings",
"ShutterSpeedValue"
];
foreach ($exif as $k => $v) {
if (in_array($k, $keys)) {
$meta[$k] = $v;
}
}
$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 (
($ratio['width_original'] > $aspect['width'])
|| ($ratio['height_original'] > $aspect['height'])
) {
$img = $img->scaleDown(
$aspect['width'],
$aspect['height']
);
}
}
$converted = $this->setBaseName($path, $thumbnail, $extension);
$newPath = storage_path('app/'.$converted['path']);
$quality = config_cache('pixelfed.image_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) {
$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",
"doctrine/dbal": "^3.0",
"endroid/qr-code": "^6.0",
"intervention/image": "^2.4",
"intervention/image": "^3.11.2",
"jenssegers/agent": "^2.6",
"laravel-notification-channels/expo": "^2.0.0",
"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
|--------------------------------------------------------------------------
|
| Intervention Image supports "GD Library" and "Imagick" to process images
| internally. You may choose one of them according to your PHP
| Intervention Image supports "GD Library", "Imagick" and "libvips" to process
| images internally. You may choose one of them according to your PHP
| configuration. By default PHP's "GD Library" implementation is used.
|
| Supported: "gd", "imagick"
| Supported: "gd", "imagick", "libvips"
|
*/
'driver' => env('IMAGE_DRIVER', 'gd'),
];

Loading…
Cancel
Save