-
-
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
Merged
ildyria
merged 26 commits into
LycheeOrg:master
from
lifeguardmedia:feature/#298-HEIC-HEIF
Jan 14, 2026
Merged
Changes from 13 commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
d61848f
Add support for HEIC/HEIF to JPEG conversion
NikolayBalkandzhiyski ec8755f
Normalize image extension input in ConvertableImageType enum
NikolayBalkandzhiyski 6cdac9d
-Add SPDX license headers to multiple files and normalize variable names
NikolayBalkandzhiyski 0c7cc69
Add migration to change 'title' column type to TEXT in photos table b…
NikolayBalkandzhiyski 14ccea0
Update migration to change 'title' column type from TEXT to VARCHAR(1…
NikolayBalkandzhiyski 70e7d01
Refactor migration to change 'title' column type from VARCHAR(1000) t…
NikolayBalkandzhiyski 74d7567
Enhance migration for 'title' column in photos table by adding index …
NikolayBalkandzhiyski 303ee3f
Add support for HEIC to JPEG conversion using maestroerror/php-heic-t…
NikolayBalkandzhiyski 30e0553
Improve error handling in HEIC to JPEG conversion by adding nested ex…
NikolayBalkandzhiyski f24e705
added comment
NikolayBalkandzhiyski 7e0b9f6
Update ConvertMediaFileInterface to support both Imagick and HeicToJp…
NikolayBalkandzhiyski 25cfca0
Merge remote-tracking branch 'upstream/master' into feature/#298-HEIC…
NikolayBalkandzhiyski 584deef
chore: Update HEIC to JPEG conversion logic and improve error handling
NikolayBalkandzhiyski 3fad818
chore: Update composer.lock and add new dependencies
NikolayBalkandzhiyski a64fbc4
Improve error messages in HEIF to JPEG conversion and update migratio…
NikolayBalkandzhiyski 142f145
Fix formatting of SPDX license comment in migration file for photos t…
NikolayBalkandzhiyski f8f91e4
chore: update copyright year in multiple files to 2026
NikolayBalkandzhiyski c2d5a1f
Refactor ImageTypeFactory and ConvertUnsupportedMedia for improved er…
NikolayBalkandzhiyski 3213960
Update index creation logic for title column in photos table based on…
NikolayBalkandzhiyski b3dbcf1
Merge branch 'master' into feature/#298-HEIC-HEIF
ildyria 105c7cd
simplify
ildyria 1e74f8c
Merge branch 'master' into feature/#298-HEIC-HEIF
ildyria 192e567
simplify
ildyria 80e847e
Wrap up
ildyria e7b83f6
improve indexing
ildyria 9b5c877
Formatting
ildyria File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
| $path = $tmp_file->getRealPath(); | ||
| $pathinfo = pathinfo($path); | ||
| $file_name = $pathinfo['filename']; | ||
| $new_path = $pathinfo['dirname'] . '/' . $file_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|\Exception $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.
Outdated
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()); | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| <?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\Enum\ConvertableImageType; | ||
|
|
||
| class ImageTypeFactory | ||
| { | ||
| public ?string $conversionClass = null; | ||
|
|
||
| public function __construct(string $extension) | ||
| { | ||
| $this->conversionClass = match (true) { | ||
| ConvertableImageType::isHeifImageType($extension) => 'HeifToJpeg', | ||
| // TODO: Add more convertion types/classes | ||
| default => null, | ||
| }; | ||
| } | ||
|
|
||
| public function make(): ConvertMediaFileInterface | ||
| { | ||
| if ($this->conversionClass === null) { | ||
| throw new \RuntimeException('No conversion class available for this file type'); | ||
| } | ||
|
|
||
| $class = 'App\Actions\Photo\Convert\\' . $this->conversionClass; | ||
|
|
||
| if (!class_exists($class)) { | ||
| throw new \RuntimeException("Converter class {$class} does not exist"); | ||
| } | ||
|
|
||
| $instance = new $class(); | ||
|
|
||
| if (!$instance instanceof ConvertMediaFileInterface) { | ||
| throw new \RuntimeException("Converter class {$class} must implement ConvertMediaFileInterface"); | ||
| } | ||
|
|
||
| return $instance; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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->conversionClass === null) { | ||
| return $next($state); | ||
| } | ||
|
|
||
| try { | ||
| $state->source_file = $factory->make()->handle($state->source_file); | ||
| } catch (\Exception $exception) { | ||
| throw new CannotConvertMediaFileException($exception->getMessage(), $exception); | ||
| } | ||
|
|
||
| return $next($state); | ||
| } | ||
ildyria marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
ildyria marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| public function deleteOldFile(string $path); | ||
ildyria marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
46 changes: 46 additions & 0 deletions
46
database/migrations/2025_12_22_105523_change_title_to_text_on_photos_table.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| <?php | ||
|
|
||
| use Illuminate\Database\Migrations\Migration; | ||
| use Illuminate\Database\Schema\Blueprint; | ||
| use Illuminate\Support\Facades\Schema; | ||
| use Illuminate\Support\Facades\DB; | ||
|
|
||
| return new class() extends Migration { | ||
|
|
||
| /** | ||
| * 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')->nullable()->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'); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * 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'); | ||
| } | ||
|
|
||
| // Change to varchar | ||
| $table->string('title', 100)->nullable()->change(); | ||
|
|
||
| $table->index(['odl_album_id', 'is_starred', 'title'], 'photos_album_id_is_starred_title_index'); | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }); | ||
| } | ||
| }; | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.