-
-
Notifications
You must be signed in to change notification settings - Fork 362
feat: ability to upload and work with HEIF images by convert them to JPEG #3946
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 12 commits
d61848f
ec8755f
6cdac9d
0c7cc69
14ccea0
70e7d01
74d7567
303ee3f
30e0553
f24e705
7e0b9f6
25cfca0
584deef
3fad818
a64fbc4
142f145
f8f91e4
c2d5a1f
3213960
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * SPDX-License-Identifier: MIT | ||
| * Copyright (c) 2017-2018 Tobias Reich | ||
| * Copyright (c) 2018-2025 LycheeOrg. | ||
| */ | ||
|
|
||
| namespace App\Actions\Photo\Convert; | ||
|
|
||
| use App\Contracts\Image\ConvertMediaFileInterface; | ||
| use App\Image\Files\NativeLocalFile; | ||
| use App\Image\Files\TemporaryJobFile; | ||
| use Http\Client\Exception; | ||
| use Maestroerror\HeicToJpg; | ||
| use Safe\Exceptions\FilesystemException; | ||
| use function Safe\unlink; | ||
|
|
||
| class HeifToJpeg implements ConvertMediaFileInterface | ||
| { | ||
| /** | ||
| * @throws \Exception | ||
| */ | ||
| public function handle(NativeLocalFile $tmp_file): TemporaryJobFile | ||
| { | ||
| try { | ||
| $base_name = $tmp_file->getBasename(); | ||
| $path = $tmp_file->getRealPath(); | ||
| $pathinfo = pathinfo($path); | ||
| $new_path = $pathinfo['dirname'] . '/' . $base_name . '.jpg'; | ||
|
|
||
| // Convert to Jpeg | ||
| $imagick_converted = $this->convertToJpeg($path); | ||
|
|
||
| // Store converted image | ||
| $this->storeNewImage($imagick_converted, $new_path); | ||
|
|
||
| // Delete old file | ||
| $this->deleteOldFile($path); | ||
|
|
||
| return new TemporaryJobFile($new_path); | ||
| } catch (\Exception $e) { | ||
| throw new \Exception('Failed to convert HEIC/HEIF to JPEG. ' . $e->getMessage()); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @throws \Exception | ||
| */ | ||
| public function storeNewImage(\Imagick|HeicToJpg $image_instance, string $store_to_path): void | ||
| { | ||
| try { | ||
| if ($image_instance instanceof \Imagick) { | ||
| $image_instance->writeImage($store_to_path); | ||
| } elseif ($image_instance instanceof HeicToJpg) { | ||
| $image_instance->saveAs($store_to_path); | ||
| } | ||
| } catch (\ImagickException $e) { | ||
| throw new \Exception('Failed to store converted image'); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @throws \Exception | ||
| */ | ||
| public function deleteOldFile(string $path): void | ||
| { | ||
| try { | ||
| unlink($path); | ||
| } catch (FilesystemException $e) { | ||
| throw new \Exception('Failed to delete old file'); | ||
| } | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * Try ImageMagick, if fails try php-heic-to-jpg package because ImageMagick fails to convert newer IPhone images. | ||
| * | ||
| * @throws \Exception | ||
| */ | ||
| private function convertToJpeg(string $path): \Imagick|HeicToJpg | ||
| { | ||
| try { | ||
| $img = new \Imagick($path); | ||
|
|
||
| if ($img->getNumberImages() > 1) { | ||
| $img->setIteratorIndex(0); | ||
| } | ||
|
|
||
| $img->setImageFormat('jpeg'); | ||
| $img->setImageCompression(\Imagick::COMPRESSION_JPEG); | ||
| $img->setImageCompressionQuality(92); | ||
|
|
||
| $img->autoOrient(); | ||
|
|
||
| return $img; | ||
| } catch (\ImagickException $e) { | ||
| try { | ||
| return HeicToJpg::convert($path); | ||
| } catch (Exception $exception) { | ||
| throw new \Exception('Failed to convert HEIC/HEIF to JPEG. ' . $e->getMessage() . ' ' . $exception->getMessage()); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * SPDX-License-Identifier: MIT | ||
| * Copyright (c) 2017-2018 Tobias Reich | ||
| * Copyright (c) 2018-2025 LycheeOrg. | ||
| */ | ||
|
|
||
| namespace App\Actions\Photo\Convert; | ||
|
|
||
| use App\Enum\ConvertableImageType; | ||
|
|
||
| class ImageTypeFactory | ||
| { | ||
| public ?string $convertionClass = null; | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| public function __construct(string $extension) | ||
| { | ||
| $this->convertionClass = match (true) { | ||
| ConvertableImageType::isHeifImageType($extension) => 'HeifToJpeg', | ||
| // TODO: Add more convertion types/classes | ||
| default => null, | ||
| }; | ||
| } | ||
|
|
||
| public function make(): mixed | ||
| { | ||
| $class = 'App\Actions\Photo\Convert\\' . $this->convertionClass; | ||
|
|
||
| return new $class(); | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * SPDX-License-Identifier: MIT | ||
| * Copyright (c) 2017-2018 Tobias Reich | ||
| * Copyright (c) 2018-2025 LycheeOrg. | ||
| */ | ||
|
|
||
| namespace App\Actions\Photo\Pipes\Init; | ||
|
|
||
| use App\Actions\Photo\Convert\ImageTypeFactory; | ||
| use App\Contracts\PhotoCreate\InitPipe; | ||
| use App\DTO\PhotoCreate\InitDTO; | ||
| use App\Exceptions\CannotConvertMediaFileException; | ||
|
|
||
| class ConvertUnsupportedMedia implements InitPipe | ||
| { | ||
| /** | ||
| * Tries to convert the file to a supported format. | ||
| * | ||
| * @throws \Exception | ||
| */ | ||
| public function handle(InitDTO $state, \Closure $next): InitDTO | ||
| { | ||
| $ext = ltrim($state->source_file->getOriginalExtension(), '.'); | ||
|
|
||
| $factory = new ImageTypeFactory($ext); | ||
|
|
||
| if ($factory->convertionClass === null) { | ||
| return $next($state); | ||
| } | ||
|
|
||
| try { | ||
| $state->source_file = $factory->make()->handle($state->source_file); | ||
| } catch (\Exception $exception) { | ||
| throw new CannotConvertMediaFileException($exception->getMessage()); | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return $next($state); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,22 @@ | ||||||
| <?php | ||||||
|
|
||||||
| /** | ||||||
| * SPDX-License-Identifier: MIT | ||||||
| * Copyright (c) 2017-2018 Tobias Reich | ||||||
| * Copyright (c) 2018-2025 LycheeOrg. | ||||||
| */ | ||||||
|
|
||||||
| namespace App\Contracts\Image; | ||||||
|
|
||||||
| use App\Image\Files\NativeLocalFile; | ||||||
| use App\Image\Files\TemporaryJobFile; | ||||||
| use Maestroerror\HeicToJpg; | ||||||
|
|
||||||
| interface ConvertMediaFileInterface | ||||||
| { | ||||||
| public function handle(NativeLocalFile $tmp_file): TemporaryJobFile; | ||||||
|
|
||||||
| public function storeNewImage(\Imagick|HeicToJpg $image_instance, string $store_to_path); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Add explicit return type and reconsider the tight coupling. The 🔎 Suggested refactor- public function storeNewImage(\Imagick|HeicToJpg $image_instance, string $store_to_path);
+ public function storeNewImage(\Imagick|HeicToJpg $image_instance, string $store_to_path): void;For better design, consider creating an adapter interface: interface ImageResourceInterface {
public function save(string $path): void;
}Then change the signature to: public function storeNewImage(ImageResourceInterface $image_instance, string $store_to_path): void;As per coding guidelines, all methods should have explicit return types. |
||||||
|
|
||||||
| public function deleteOldFile(string $path); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Add explicit return type. The 🔎 Suggested fix- public function deleteOldFile(string $path);
+ public function deleteOldFile(string $path): void;As per coding guidelines, all methods should have explicit return types. 📝 Committable suggestion
Suggested change
|
||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * SPDX-License-Identifier: MIT | ||
| * Copyright (c) 2017-2018 Tobias Reich | ||
| * Copyright (c) 2018-2025 LycheeOrg. | ||
| */ | ||
|
|
||
| namespace App\Enum; | ||
|
|
||
| enum ConvertableImageType: string | ||
| { | ||
| case HEIC = 'heic'; | ||
| case HEIF = 'heif'; | ||
|
|
||
| public static function isHeifImageType(string $extension): bool | ||
| { | ||
| $extension = str($extension)->lower()->toString(); | ||
|
|
||
| return in_array($extension, [ | ||
| self::HEIC->value, | ||
| self::HEIF->value, | ||
| ], true); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * SPDX-License-Identifier: MIT | ||
| * Copyright (c) 2017-2018 Tobias Reich | ||
| * Copyright (c) 2018-2025 LycheeOrg. | ||
| */ | ||
|
|
||
| namespace App\Enum; | ||
|
|
||
| enum ImageType: int | ||
| { | ||
| case JPEG = IMAGETYPE_JPEG; | ||
| case JPEG2000 = IMAGETYPE_JPEG2000; | ||
| case PNG = IMAGETYPE_PNG; | ||
| case GIF = IMAGETYPE_GIF; | ||
| case WEBP = IMAGETYPE_WEBP; | ||
| } | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * SPDX-License-Identifier: MIT | ||
| * Copyright (c) 2017-2018 Tobias Reich | ||
| * Copyright (c) 2018-2025 LycheeOrg. | ||
| */ | ||
|
|
||
| namespace App\Exceptions; | ||
|
|
||
| use Symfony\Component\HttpFoundation\Response; | ||
|
|
||
| /** | ||
| * CannotConvertMediaFileException. | ||
| * | ||
| * Indicates that a media file cannot be converted to another format. | ||
| */ | ||
| class CannotConvertMediaFileException extends BaseLycheeException | ||
| { | ||
| public const string DEFAULT_MESSAGE = 'Cannot convert media file to another format'; | ||
|
|
||
| public function __construct(string $msg = self::DEFAULT_MESSAGE, ?\Throwable $previous = null) | ||
| { | ||
| parent::__construct(Response::HTTP_UNPROCESSABLE_ENTITY, $msg, $previous); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| <?php | ||
|
|
||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| use Illuminate\Database\Migrations\Migration; | ||
| use Illuminate\Database\Schema\Blueprint; | ||
| use Illuminate\Support\Facades\Schema; | ||
|
|
||
| require_once 'TemporaryModels/OptimizeTables.php'; | ||
|
|
||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return new class() extends Migration { | ||
| private OptimizeTables $optimize; | ||
|
|
||
| public function __construct() | ||
| { | ||
| $this->optimize = new OptimizeTables(); | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * Run the migrations. | ||
| */ | ||
| public function up(): void | ||
| { | ||
| Schema::table('photos', function (Blueprint $table) { | ||
| // Drop the existing index | ||
| if (Schema::hasIndex('photos', 'photos_album_id_is_starred_title_index')) { | ||
| $table->dropIndex('photos_album_id_is_starred_title_index'); | ||
| } | ||
|
|
||
| // Change to text | ||
| $table->text('title')->change(); | ||
|
|
||
| // Recreate the index with a key length for the TEXT column | ||
| $table->index(['old_album_id', 'is_starred', DB::raw('title(100)')], 'photos_album_id_is_starred_title_index'); | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Reverse the migrations. | ||
| */ | ||
| public function down(): void | ||
| { | ||
| Schema::table('photos', function (Blueprint $table) { | ||
| // Drop the existing index | ||
| if (Schema::hasIndex('photos', 'photos_album_id_is_starred_title_index')) { | ||
| $table->dropIndex('photos_album_id_is_starred_title_index'); | ||
| } | ||
|
|
||
| $table->string('title', 100)->nullable()->change(); | ||
|
|
||
| $table->index(['album_id', 'is_starred', 'title']); | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }); | ||
| } | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.