diff --git a/app/Actions/Photo/Convert/HeifToJpeg.php b/app/Actions/Photo/Convert/HeifToJpeg.php new file mode 100644 index 00000000000..b77ee9c8c22 --- /dev/null +++ b/app/Actions/Photo/Convert/HeifToJpeg.php @@ -0,0 +1,104 @@ +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: ' . $e->getMessage()); + } + } + + /** + * @throws \Exception + */ + public function deleteOldFile(string $path): void + { + try { + unlink($path); + } catch (FilesystemException $e) { + throw new \Exception('Failed to delete old file: ' . $e->getMessage()); + } + } + + /** + * 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()); + } + } + } +} \ No newline at end of file diff --git a/app/Actions/Photo/Convert/ImageTypeFactory.php b/app/Actions/Photo/Convert/ImageTypeFactory.php new file mode 100644 index 00000000000..3701d11aa55 --- /dev/null +++ b/app/Actions/Photo/Convert/ImageTypeFactory.php @@ -0,0 +1,47 @@ +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; + } +} \ No newline at end of file diff --git a/app/Actions/Photo/Create.php b/app/Actions/Photo/Create.php index 4c7f9d39a57..d073e2b5634 100644 --- a/app/Actions/Photo/Create.php +++ b/app/Actions/Photo/Create.php @@ -80,6 +80,7 @@ public function add(NativeLocalFile $source_file, ?AbstractAlbum $album, ?int $f ); $pre_pipes = [ + Init\ConvertUnsupportedMedia::class, Init\AssertSupportedMedia::class, Init\FetchLastModifiedTime::class, Init\MayLoadFileMetadata::class, diff --git a/app/Actions/Photo/Pipes/Init/ConvertUnsupportedMedia.php b/app/Actions/Photo/Pipes/Init/ConvertUnsupportedMedia.php new file mode 100644 index 00000000000..6d94115dc37 --- /dev/null +++ b/app/Actions/Photo/Pipes/Init/ConvertUnsupportedMedia.php @@ -0,0 +1,41 @@ +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); + } +} \ No newline at end of file diff --git a/app/Contracts/Image/ConvertMediaFileInterface.php b/app/Contracts/Image/ConvertMediaFileInterface.php new file mode 100644 index 00000000000..24d2e085742 --- /dev/null +++ b/app/Contracts/Image/ConvertMediaFileInterface.php @@ -0,0 +1,22 @@ +lower()->toString(); + + return in_array($extension, [ + self::HEIC->value, + self::HEIF->value, + ], true); + } +} \ No newline at end of file diff --git a/app/Enum/ImageType.php b/app/Enum/ImageType.php new file mode 100644 index 00000000000..ef5303b0c1e --- /dev/null +++ b/app/Enum/ImageType.php @@ -0,0 +1,19 @@ +=7.4" + }, + "require-dev": { + "pestphp/pest": "2.x-dev" + }, + "bin": [ + "bin/heicToJpg", + "bin/heicToJpgWin" + ], + "type": "library", + "autoload": { + "psr-4": { + "Maestroerror\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "maestroerror", + "email": "revaz.gh@gmail.com" + } + ], + "description": "Converts HEIC/HEIF image to JPG type, without any dependencies", + "support": { + "issues": "https://github.com/MaestroError/php-heic-to-jpg/issues", + "source": "https://github.com/MaestroError/php-heic-to-jpg/tree/v1.0.8" + }, + "time": "2025-06-04T15:17:54+00:00" + }, { "name": "mavinoo/laravel-batch", "version": "v2.4.1", @@ -9097,16 +9182,16 @@ }, { "name": "symfony/cache", - "version": "v7.4.1", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "21e0755783bbbab58f2bb6a7a57896d21d27a366" + "reference": "642117d18bc56832e74b68235359ccefab03dd11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/21e0755783bbbab58f2bb6a7a57896d21d27a366", - "reference": "21e0755783bbbab58f2bb6a7a57896d21d27a366", + "url": "https://api.github.com/repos/symfony/cache/zipball/642117d18bc56832e74b68235359ccefab03dd11", + "reference": "642117d18bc56832e74b68235359ccefab03dd11", "shasum": "" }, "require": { @@ -9177,7 +9262,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.4.1" + "source": "https://github.com/symfony/cache/tree/v7.4.3" }, "funding": [ { @@ -9197,7 +9282,7 @@ "type": "tidelift" } ], - "time": "2025-12-04T18:11:45+00:00" + "time": "2025-12-28T10:45:24+00:00" }, { "name": "symfony/cache-contracts", @@ -9969,16 +10054,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.1", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "26cc224ea7103dda90e9694d9e139a389092d007" + "reference": "d01dfac1e0dc99f18da48b18101c23ce57929616" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/26cc224ea7103dda90e9694d9e139a389092d007", - "reference": "26cc224ea7103dda90e9694d9e139a389092d007", + "url": "https://api.github.com/repos/symfony/http-client/zipball/d01dfac1e0dc99f18da48b18101c23ce57929616", + "reference": "d01dfac1e0dc99f18da48b18101c23ce57929616", "shasum": "" }, "require": { @@ -10046,7 +10131,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.1" + "source": "https://github.com/symfony/http-client/tree/v7.4.3" }, "funding": [ { @@ -10066,7 +10151,7 @@ "type": "tidelift" } ], - "time": "2025-12-04T21:12:57+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/http-client-contracts", @@ -14112,16 +14197,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.6.3", + "version": "6.6.4", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "134e98916fa2f663afa623970af345cd788e8967" + "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/134e98916fa2f663afa623970af345cd788e8967", - "reference": "134e98916fa2f663afa623970af345cd788e8967", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2eeb75d21cf73211335888e7f5e6fd7440723ec7", + "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7", "shasum": "" }, "require": { @@ -14181,9 +14266,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.3" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.4" }, - "time": "2025-12-02T10:21:33+00:00" + "time": "2025-12-19T15:01:32+00:00" }, { "name": "laracraft-tech/laravel-xhprof", @@ -17786,23 +17871,23 @@ }, { "name": "symplify/phpstan-rules", - "version": "14.9.5", + "version": "14.9.11", "source": { "type": "git", "url": "https://github.com/symplify/phpstan-rules.git", - "reference": "8557736b5e7b8c10f68e6c6f0692b5dce305d307" + "reference": "5ea4bbd9357cba253aada506dd96d37d7069ac3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symplify/phpstan-rules/zipball/8557736b5e7b8c10f68e6c6f0692b5dce305d307", - "reference": "8557736b5e7b8c10f68e6c6f0692b5dce305d307", + "url": "https://api.github.com/repos/symplify/phpstan-rules/zipball/5ea4bbd9357cba253aada506dd96d37d7069ac3b", + "reference": "5ea4bbd9357cba253aada506dd96d37d7069ac3b", "shasum": "" }, "require": { "nette/utils": "^3.2|^4.0", "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.22", - "webmozart/assert": "^1.11" + "phpstan/phpstan": "^2.1.33", + "webmozart/assert": "^1.12 || ^2.0" }, "type": "phpstan-extension", "extra": { @@ -17827,7 +17912,7 @@ "description": "Set of Symplify rules for PHPStan", "support": { "issues": "https://github.com/symplify/phpstan-rules/issues", - "source": "https://github.com/symplify/phpstan-rules/tree/14.9.5" + "source": "https://github.com/symplify/phpstan-rules/tree/14.9.11" }, "funding": [ { @@ -17839,7 +17924,7 @@ "type": "github" } ], - "time": "2025-12-08T15:34:48+00:00" + "time": "2026-01-05T13:53:59+00:00" }, { "name": "thecodingmachine/phpstan-safe-rule", diff --git a/database/migrations/2025_12_22_105523_change_title_to_text_on_photos_table.php b/database/migrations/2025_12_22_105523_change_title_to_text_on_photos_table.php new file mode 100644 index 00000000000..fa09e09c74e --- /dev/null +++ b/database/migrations/2025_12_22_105523_change_title_to_text_on_photos_table.php @@ -0,0 +1,58 @@ +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 + $driver = DB::getDriverName(); + if ($driver === 'mysql') { + $table->index(['old_album_id', 'is_starred', DB::raw('title(100)')], 'photos_album_id_is_starred_title_index'); + } elseif ($driver === 'pgsql') { + DB::statement('CREATE INDEX photos_album_id_is_starred_title_index ON photos (old_album_id, is_starred, LEFT(title, 100))'); + } elseif ($driver === 'sqlite') { + $table->index(['old_album_id', 'is_starred', 'title'], '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(['old_album_id', 'is_starred', 'title'], 'photos_album_id_is_starred_title_index'); + }); + } +}; diff --git a/tests/Unit/Actions/Photo/Pipes/Init/ConvertUnsupportedMediaTest.php b/tests/Unit/Actions/Photo/Pipes/Init/ConvertUnsupportedMediaTest.php new file mode 100644 index 00000000000..d2f3a30bb1b --- /dev/null +++ b/tests/Unit/Actions/Photo/Pipes/Init/ConvertUnsupportedMediaTest.php @@ -0,0 +1,258 @@ +pipe = new ConvertUnsupportedMedia(); + } + + protected function tearDown(): void + { + \Mockery::close(); + parent::tearDown(); + } + + public function testHandleWithJpegExtensionPassesThrough(): void + { + $originalFile = \Mockery::mock(NativeLocalFile::class); + + $originalFile->shouldReceive('getOriginalExtension') + ->once() + ->andReturn('.jpg'); + + $state = $this->createInitDTO($originalFile); + + $nextCalled = false; + $next = function (InitDTO $state) use (&$nextCalled, $originalFile): InitDTO { + $nextCalled = true; + self::assertSame($originalFile, $state->source_file); + + return $state; + }; + + $result = $this->pipe->handle($state, $next); + + $this->assertTrue($nextCalled); + $this->assertSame($originalFile, $result->source_file); + } + + public function testHandleWithPngExtensionPassesThrough(): void + { + $originalFile = \Mockery::mock(NativeLocalFile::class); + + $originalFile->shouldReceive('getOriginalExtension') + ->once() + ->andReturn('.png'); + + $state = $this->createInitDTO($originalFile); + + $nextCalled = false; + $next = function (InitDTO $state) use (&$nextCalled, $originalFile): InitDTO { + $nextCalled = true; + self::assertSame($originalFile, $state->source_file); + + return $state; + }; + + $result = $this->pipe->handle($state, $next); + + $this->assertTrue($nextCalled); + $this->assertSame($originalFile, $result->source_file); + } + + public function testHandleWithExtensionWithoutDotPassesThrough(): void + { + $originalFile = \Mockery::mock(NativeLocalFile::class); + + $originalFile->shouldReceive('getOriginalExtension') + ->once() + ->andReturn('jpg'); + + $state = $this->createInitDTO($originalFile); + + $nextCalled = false; + $next = function (InitDTO $state) use (&$nextCalled, $originalFile): InitDTO { + $nextCalled = true; + self::assertSame($originalFile, $state->source_file); + + return $state; + }; + + $result = $this->pipe->handle($state, $next); + + $this->assertTrue($nextCalled); + $this->assertSame($originalFile, $result->source_file); + } + + public function testHandleWithHeifExtensionTriggersConversion(): void + { + $originalFile = \Mockery::mock(NativeLocalFile::class); + $convertedFile = \Mockery::mock(TemporaryJobFile::class); + + $originalFile->shouldReceive('getOriginalExtension') + ->once() + ->andReturn('.heif'); + + $originalFile->shouldReceive('getRealPath') + ->andReturn('/tmp/test.heif'); + + $state = $this->createInitDTO($originalFile); + + $nextCalled = false; + $next = function (InitDTO $state) use (&$nextCalled): InitDTO { + $nextCalled = true; + + return $state; + }; + + try { + $result = $this->pipe->handle($state, $next); + $this->assertTrue($nextCalled); + $this->assertInstanceOf(TemporaryJobFile::class, $result->source_file); + } catch (\Exception $e) { + if ($e instanceof CannotConvertMediaFileException || str_contains($e->getMessage(), 'convert')) { + $this->markTestSkipped('HEIF conversion not available in test environment: ' . $e->getMessage()); + } + throw $e; + } + } + + public function testHandleWithHeicExtensionTriggersConversion(): void + { + $originalFile = \Mockery::mock(NativeLocalFile::class); + + $originalFile->shouldReceive('getOriginalExtension') + ->once() + ->andReturn('.heic'); + + $originalFile->shouldReceive('getRealPath') + ->andReturn('/tmp/test.heic'); + + $state = $this->createInitDTO($originalFile); + + $nextCalled = false; + $next = function (InitDTO $state) use (&$nextCalled): InitDTO { + $nextCalled = true; + + return $state; + }; + + try { + $result = $this->pipe->handle($state, $next); + $this->assertTrue($nextCalled); + $this->assertInstanceOf(TemporaryJobFile::class, $result->source_file); + } catch (\Exception $e) { + if ($e instanceof CannotConvertMediaFileException || str_contains($e->getMessage(), 'convert')) { + $this->markTestSkipped('HEIC conversion not available in test environment: ' . $e->getMessage()); + } + throw $e; + } + } + + public function testHandleWithConversionExceptionWrapsInCannotConvertMediaFileException(): void + { + $originalFile = \Mockery::mock(NativeLocalFile::class); + $originalException = new \Exception('Conversion failed'); + + $originalFile->shouldReceive('getOriginalExtension') + ->once() + ->andReturn('.heif'); + + $originalFile->shouldReceive('getRealPath') + ->andThrow($originalException); + + $state = $this->createInitDTO($originalFile); + + $next = function (InitDTO $state): InitDTO { + return $state; + }; + + $this->expectException(CannotConvertMediaFileException::class); + $this->expectExceptionMessageMatches('/Failed to convert HEIC\/HEIF to JPEG.*Conversion failed/'); + + try { + $this->pipe->handle($state, $next); + } catch (CannotConvertMediaFileException $e) { + $this->assertNotNull($e->getPrevious()); + $this->assertInstanceOf(\Exception::class, $e->getPrevious()); + $this->assertStringContainsString('Failed to convert HEIC/HEIF to JPEG', $e->getMessage()); + throw $e; + } + } + + public function testHandleWithRuntimeExceptionWrapsInCannotConvertMediaFileException(): void + { + $originalFile = \Mockery::mock(NativeLocalFile::class); + $originalException = new \RuntimeException('Runtime error during conversion'); + + $originalFile->shouldReceive('getOriginalExtension') + ->once() + ->andReturn('.heic'); + + $originalFile->shouldReceive('getRealPath') + ->andThrow($originalException); + + $state = $this->createInitDTO($originalFile); + + $next = function (InitDTO $state): InitDTO { + return $state; + }; + + $this->expectException(CannotConvertMediaFileException::class); + $this->expectExceptionMessageMatches('/Failed to convert HEIC\/HEIF to JPEG.*Runtime error during conversion/'); + + try { + $this->pipe->handle($state, $next); + } catch (CannotConvertMediaFileException $e) { + $this->assertNotNull($e->getPrevious()); + $this->assertInstanceOf(\Exception::class, $e->getPrevious()); + $this->assertStringContainsString('Failed to convert HEIC/HEIF to JPEG', $e->getMessage()); + throw $e; + } + } + + private function createInitDTO(NativeLocalFile $sourceFile): InitDTO + { + $importParam = new ImportParam( + import_mode: new ImportMode(), + intended_owner_id: 1, + ); + + return new InitDTO( + parameters: $importParam, + source_file: $sourceFile, + album: null, + ); + } +}