mirror of
https://github.com/AnimeThemes/animethemes-server.git
synced 2026-07-11 01:24:46 +02:00
feat: automatically downscale small covers (#1012)
This commit is contained in:
@@ -19,30 +19,48 @@ use Illuminate\Support\Str;
|
||||
|
||||
class OptimizeImageAction
|
||||
{
|
||||
public function __construct(protected readonly Image $image, protected readonly string $extension = 'avif') {}
|
||||
public function __construct(
|
||||
protected readonly Image $image,
|
||||
protected readonly ?string $extension = null,
|
||||
protected readonly ?int $width = null,
|
||||
protected readonly ?int $height = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(): ActionResult
|
||||
{
|
||||
if ($this->extension === null && $this->width === null && $this->height === null) {
|
||||
return new ActionResult(
|
||||
ActionStatus::SKIPPED,
|
||||
'No optimization parameters provided, nothing to process.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$directory = File::dirname($this->image->path);
|
||||
|
||||
$optimizedImage = $this->convertImage();
|
||||
$optimizedImage = $this->handleFFmpeg();
|
||||
|
||||
if ($optimizedImage === null) {
|
||||
DB::rollBack();
|
||||
|
||||
return new ActionResult(
|
||||
ActionStatus::FAILED,
|
||||
"Failed to convert image '{$this->image->path}' to '{$this->extension}'.",
|
||||
"Failed to optimize image '{$this->image->path}'.",
|
||||
);
|
||||
}
|
||||
|
||||
$path = $this->uploadImage($optimizedImage, $directory);
|
||||
$path = $this->uploadImage(
|
||||
Storage::disk('local')->path($optimizedImage),
|
||||
$directory
|
||||
);
|
||||
|
||||
// Delete the old image from the bucket.
|
||||
Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED))->delete($this->image->path);
|
||||
|
||||
$this->image->update([
|
||||
Image::ATTRIBUTE_PATH => $path,
|
||||
@@ -60,7 +78,7 @@ class OptimizeImageAction
|
||||
return new ActionResult(ActionStatus::PASSED);
|
||||
}
|
||||
|
||||
protected function convertImage(): ?string
|
||||
protected function handleFFmpeg(): ?string
|
||||
{
|
||||
try {
|
||||
Storage::disk('local')->put(
|
||||
@@ -68,15 +86,15 @@ class OptimizeImageAction
|
||||
Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED))->get($this->image->path),
|
||||
);
|
||||
|
||||
[$command, $imagePath] = match ($this->extension) {
|
||||
'avif' => static::getAvifCommand($this->image),
|
||||
default => throw new Exception("Unsupported image extension: {$this->extension}"),
|
||||
};
|
||||
$imagePath = $this->image->path;
|
||||
|
||||
Process::run($command)->throw();
|
||||
if ($this->extension !== null) {
|
||||
$imagePath = $this->convertImage($imagePath);
|
||||
}
|
||||
|
||||
// Delete the old image from the bucket.
|
||||
Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED))->delete($this->image->path);
|
||||
if ($this->width !== null && $this->height !== null) {
|
||||
$imagePath = $this->downscaleImage($imagePath);
|
||||
}
|
||||
|
||||
return $imagePath;
|
||||
} catch (Exception $e) {
|
||||
@@ -88,6 +106,40 @@ class OptimizeImageAction
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the image to given extension.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function convertImage(string $path): string
|
||||
{
|
||||
[$command, $imagePath] = match ($this->extension) {
|
||||
'avif' => static::getAvifCommand($path),
|
||||
default => throw new Exception("Unsupported image extension: {$this->extension}"),
|
||||
};
|
||||
|
||||
Process::run($command)->throw();
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downscale the image to given width and height.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function downscaleImage(string $path): string
|
||||
{
|
||||
[$command, $imagePath] = static::getDownscaleCommand($path, $this->width, $this->height);
|
||||
|
||||
Process::run($command)->throw();
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the image to the bucket.
|
||||
*/
|
||||
protected function uploadImage(string $image, string $directory): string
|
||||
{
|
||||
/** @var \Illuminate\Filesystem\FilesystemAdapter $fs */
|
||||
@@ -103,17 +155,17 @@ class OptimizeImageAction
|
||||
/**
|
||||
* @return array{0:array<int, string>, 1:string}
|
||||
*/
|
||||
public static function getAvifCommand(Image $image): array
|
||||
public static function getAvifCommand(string $path): array
|
||||
{
|
||||
$imagePath = Storage::disk('local')->path(
|
||||
Str::replaceLast(File::extension($image->path), 'avif', $image->path)
|
||||
$newPath = Str::replaceLast(File::extension($path), 'avif', $path)
|
||||
);
|
||||
|
||||
return [
|
||||
[
|
||||
'ffmpeg',
|
||||
'-i',
|
||||
Storage::disk('local')->path($image->path),
|
||||
Storage::disk('local')->path($path),
|
||||
'-c:v',
|
||||
'libaom-av1',
|
||||
'-crf',
|
||||
@@ -122,7 +174,30 @@ class OptimizeImageAction
|
||||
'yuv420p',
|
||||
$imagePath,
|
||||
],
|
||||
$imagePath,
|
||||
$newPath,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:array<int, string>, 1:string}
|
||||
*/
|
||||
public static function getDownscaleCommand(string $path, int $width = 100, int $height = 150): array
|
||||
{
|
||||
$tempPath = Storage::disk('local')->path(
|
||||
$newPath = Str::replaceLast('.', '_scaled.', $path)
|
||||
);
|
||||
|
||||
return [
|
||||
[
|
||||
'ffmpeg',
|
||||
'-y',
|
||||
'-i',
|
||||
Storage::disk('local')->path($path),
|
||||
'-vf',
|
||||
"scale={$width}:{$height}",
|
||||
$tempPath,
|
||||
],
|
||||
$newPath,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ trait CanCreateImage
|
||||
protected function optimize(Image $image): void
|
||||
{
|
||||
if ($image->facet === ImageFacet::SMALL_COVER) {
|
||||
OptimizeImageJob::dispatch($image)
|
||||
OptimizeImageJob::dispatch($image, 'avif', 100, 150)
|
||||
->onQueue('optimize-image')
|
||||
->afterCommit();
|
||||
}
|
||||
|
||||
@@ -6,8 +6,12 @@ namespace App\Filament\Actions\Models\Wiki\Image;
|
||||
|
||||
use App\Actions\Models\Wiki\Image\OptimizeImageAction as OptimizeImage;
|
||||
use App\Filament\Actions\BaseAction;
|
||||
use App\Filament\Components\Fields\Select;
|
||||
use App\Filament\Components\Fields\TextInput;
|
||||
use App\Models\Wiki\Image;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class OptimizeImageAction extends BaseAction
|
||||
@@ -27,12 +31,16 @@ class OptimizeImageAction extends BaseAction
|
||||
|
||||
$this->visible(fn (Image $record) => Auth::user()->can('update', $record));
|
||||
|
||||
$this->action(fn (Image $record) => $this->handle($record));
|
||||
$this->action(fn (Image $record, array $data) => $this->handle($record, $data));
|
||||
}
|
||||
|
||||
public function handle(Image $image): void
|
||||
public function handle(Image $image, array $fields): void
|
||||
{
|
||||
$action = new OptimizeImage($image);
|
||||
$extension = Arr::get($fields, 'extension');
|
||||
$width = ($value = Arr::get($fields, 'width')) !== null ? (int) $value : null;
|
||||
$height = ($value = Arr::get($fields, 'height')) !== null ? (int) $value : null;
|
||||
|
||||
$action = new OptimizeImage($image, $extension, $width, $height);
|
||||
|
||||
$actionResult = $action->handle();
|
||||
|
||||
@@ -40,4 +48,29 @@ class OptimizeImageAction extends BaseAction
|
||||
$this->failedLog($actionResult->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function getSchema(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Select::make('extension')
|
||||
->label(__('filament.actions.models.wiki.optimize_image.extension.name'))
|
||||
->helperText(__('filament.actions.models.wiki.optimize_image.extension.help'))
|
||||
->options([
|
||||
'avif' => 'avif',
|
||||
])
|
||||
->default('avif'),
|
||||
|
||||
TextInput::make('width')
|
||||
->label(__('filament.actions.models.wiki.optimize_image.width.name'))
|
||||
->helperText(__('filament.actions.models.wiki.optimize_image.width.help'))
|
||||
->integer(),
|
||||
|
||||
TextInput::make('height')
|
||||
->label(__('filament.actions.models.wiki.optimize_image.height.name'))
|
||||
->helperText(__('filament.actions.models.wiki.optimize_image.height.help'))
|
||||
->integer(),
|
||||
])
|
||||
->columns(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,16 @@ class OptimizeImageJob implements ShouldQueue
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public readonly Image $image) {}
|
||||
public function __construct(
|
||||
public readonly Image $image,
|
||||
public readonly string $extension = 'avif',
|
||||
public readonly ?int $width = null,
|
||||
public readonly ?int $height = null,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$action = new OptimizeImageAction($this->image, 'avif');
|
||||
$action = new OptimizeImageAction($this->image, $this->extension, $this->width, $this->height);
|
||||
|
||||
$action->handle();
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders\Wiki\Image;
|
||||
|
||||
use App\Actions\Models\Wiki\Image\OptimizeImageAction;
|
||||
use App\Enums\Models\Wiki\ImageFacet;
|
||||
use App\Models\Wiki\Image;
|
||||
use Exception;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ConvertImagesSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
Image::query()
|
||||
->where(Image::ATTRIBUTE_FACET, ImageFacet::SMALL_COVER->value)
|
||||
->where(Image::ATTRIBUTE_PATH, 'not like', '%.avif')
|
||||
->chunkById(100, fn (Collection $images) => $images->each(function (Image $image): void {
|
||||
$action = new OptimizeImageAction($image, 'avif');
|
||||
|
||||
$action->handle();
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,18 @@ return [
|
||||
],
|
||||
'optimize_image' => [
|
||||
'name' => 'Optimize Image',
|
||||
'extension' => [
|
||||
'help' => 'The output image format to convert to.',
|
||||
'name' => 'Extension',
|
||||
],
|
||||
'height' => [
|
||||
'help' => 'The target height in pixels when downscaling the image.',
|
||||
'name' => 'Height',
|
||||
],
|
||||
'width' => [
|
||||
'help' => 'The target width in pixels when downscaling the image.',
|
||||
'name' => 'Width',
|
||||
],
|
||||
],
|
||||
'upload_image' => [
|
||||
'name' => 'Upload Image',
|
||||
|
||||
@@ -13,6 +13,24 @@ use Illuminate\Support\Str;
|
||||
|
||||
uses(Illuminate\Foundation\Testing\WithFaker::class);
|
||||
|
||||
test('skipped', function () {
|
||||
$fs = Storage::fake(Config::get(ImageConstants::DISKS_QUALIFIED));
|
||||
$file = File::fake()->image(fake()->word().'.jpg');
|
||||
$fsFile = $fs->putFile('', $file);
|
||||
|
||||
$image = Image::factory()->createOne([
|
||||
Image::ATTRIBUTE_PATH => $fsFile,
|
||||
]);
|
||||
|
||||
$action = new OptimizeImageAction($image);
|
||||
|
||||
$result = $action->handle();
|
||||
|
||||
$this->assertTrue($result->getStatus() === ActionStatus::SKIPPED);
|
||||
$this->assertDatabaseCount(Image::class, 1);
|
||||
$this->assertTrue($image->exists());
|
||||
});
|
||||
|
||||
test('converts to avif', function () {
|
||||
$fs = Storage::fake(Config::get(ImageConstants::DISKS_QUALIFIED));
|
||||
$file = File::fake()->image(fake()->word().'.jpg');
|
||||
@@ -32,7 +50,7 @@ test('converts to avif', function () {
|
||||
$this->assertTrue($image->exists());
|
||||
});
|
||||
|
||||
test('passes', function () {
|
||||
test('downscale', function () {
|
||||
$fs = Storage::fake(Config::get(ImageConstants::DISKS_QUALIFIED));
|
||||
$file = File::fake()->image(fake()->word().'.jpg');
|
||||
$fsFile = $fs->putFile('', $file);
|
||||
@@ -41,7 +59,7 @@ test('passes', function () {
|
||||
Image::ATTRIBUTE_PATH => $fsFile,
|
||||
]);
|
||||
|
||||
$action = new OptimizeImageAction($image);
|
||||
$action = new OptimizeImageAction($image, null, fake()->randomDigitNotNull(), fake()->randomDigitNotNull());
|
||||
|
||||
$result = $action->handle();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user