From 5681a0e364913d1e91aa67867d20248b6ed77331 Mon Sep 17 00:00:00 2001 From: ildyria Date: Thu, 5 Jun 2025 22:03:32 +0200 Subject: [PATCH 1/2] add database and models for Colour extraction --- app/Models/Colour.php | 41 +++++++++++++ app/Models/Palette.php | 59 +++++++++++++++++++ app/Models/Photo.php | 19 ++++-- ...2025_06_03_201052_create_colours_table.php | 35 +++++++++++ ...025_06_03_201058_create_palettes_table.php | 43 ++++++++++++++ 5 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 app/Models/Colour.php create mode 100644 app/Models/Palette.php create mode 100644 database/migrations/2025_06_03_201052_create_colours_table.php create mode 100644 database/migrations/2025_06_03_201058_create_palettes_table.php diff --git a/app/Models/Colour.php b/app/Models/Colour.php new file mode 100644 index 00000000000..a875ec77a4e --- /dev/null +++ b/app/Models/Colour.php @@ -0,0 +1,41 @@ +R, $this->G, $this->B); + } +} diff --git a/app/Models/Palette.php b/app/Models/Palette.php new file mode 100644 index 00000000000..2ad18b03e8c --- /dev/null +++ b/app/Models/Palette.php @@ -0,0 +1,59 @@ + self::toHex($this->colour_1), + 'colour_2' => self::toHex($this->colour_2), + 'colour_3' => self::toHex($this->colour_3), + 'colour_4' => self::toHex($this->colour_4), + 'colour_5' => self::toHex($this->colour_5), + ]; + } + + public static function toHex(int $colour) + { + $b = $colour & 0xFF; // Extract the blue component + $g = ($colour >> 8) & 0xFF; // Extract the green component + $r = ($colour >> 16) & 0xFF; // Extract the red component + + return sprintf('#%02x%02x%02x', $r, $g, $b); + } +} diff --git a/app/Models/Photo.php b/app/Models/Photo.php index caabc2206e2..36b76084946 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -78,6 +78,7 @@ * @property User $owner * @property SizeVariants $size_variants * @property int $filesize + * @property Palette|null $palette * * @method static PhotoBuilder|Photo addSelect($column) * @method static PhotoBuilder|Photo join(string $table, string $first, string $operator = null, string $second = null, string $type = 'inner', string $where = false) @@ -175,10 +176,6 @@ class Photo extends Model implements HasUTCBasedTimes 'live_photo_short_path', // serialize live_photo_url instead ]; - // protected $with = [ - // 'statistics', - // ]; - public function newEloquentBuilder($query): PhotoBuilder { return new PhotoBuilder($query); @@ -219,6 +216,20 @@ public function statistics(): HasOne return $this->hasOne(Statistics::class, 'photo_id', 'id'); } + /** + * Returns the relationship between a photo and its associated color palette. + * + * This is a one-to-one relationship where each photo can have one palette + * associated with it, which contains color information derived from the + * photo. + * + * @return HasOne + */ + public function palette(): HasOne + { + return $this->hasOne(Palette::class, 'photo_id', 'id'); + } + /** * Accessor for attribute {@link Photo::$shutter}. * diff --git a/database/migrations/2025_06_03_201052_create_colours_table.php b/database/migrations/2025_06_03_201052_create_colours_table.php new file mode 100644 index 00000000000..8fb9ef8cf5f --- /dev/null +++ b/database/migrations/2025_06_03_201052_create_colours_table.php @@ -0,0 +1,35 @@ +unsignedMediumInteger('id')->primary(); + $table->unsignedTinyInteger('R')->default(0); + $table->unsignedTinyInteger('G')->default(0); + $table->unsignedTinyInteger('B')->default(0); + $table->unique(['R', 'G', 'B']); // Ensure that the combination of R, G, and B is unique + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('colours'); + } +}; diff --git a/database/migrations/2025_06_03_201058_create_palettes_table.php b/database/migrations/2025_06_03_201058_create_palettes_table.php new file mode 100644 index 00000000000..35901b801ff --- /dev/null +++ b/database/migrations/2025_06_03_201058_create_palettes_table.php @@ -0,0 +1,43 @@ +id(); + $table->char(self::PHOTO_ID, self::RANDOM_ID_LENGTH)->nullable(false); + $table->unsignedMediumInteger('colour_1')->nullable(false); + $table->unsignedMediumInteger('colour_2')->nullable(false); + $table->unsignedMediumInteger('colour_3')->nullable(false); + $table->unsignedMediumInteger('colour_4')->nullable(false); + $table->unsignedMediumInteger('colour_5')->nullable(false); + + $table->index('id'); + $table->index([self::PHOTO_ID]); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('palettes'); + } +}; From f9f67699315ce9f5fc91c8905de8cebb78fb9d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Viguier?= Date: Sat, 7 Jun 2025 23:38:06 +0200 Subject: [PATCH 2/2] Add colour extraction Job (#3411) --- app/Actions/Album/PositionData.php | 1 + app/Actions/Albums/PositionData.php | 1 + app/Actions/Photo/Create.php | 2 + app/Actions/Photo/Delete.php | 16 + .../Pipes/Shared/ExtractColourPalette.php | 41 ++ app/Actions/Search/PhotoSearch.php | 2 +- .../ImageProcessing/ExtractColourPalette.php | 71 ++++ .../Image/ColourPaletteExtractorInterface.php | 24 ++ app/Factories/AlbumFactory.php | 2 +- .../Admin/Maintenance/MissingPalettes.php | 62 +++ .../Controllers/Gallery/FrameController.php | 4 +- app/Http/Middleware/ConfigIntegrity.php | 2 + .../Models/ColourPaletteResource.php | 41 ++ app/Http/Resources/Models/PhotoResource.php | 2 + app/Image/ColourExtractor/FarzaiExtractor.php | 56 +++ .../ImageFactoryForColourExtraction.php | 137 +++++++ app/Image/ColourExtractor/LeagueExtractor.php | 32 ++ app/Jobs/ExtractColoursJob.php | 123 ++++++ app/Metadata/Cache/RouteCacheManager.php | 1 + app/Models/Colour.php | 31 ++ app/Models/Palette.php | 39 +- app/Relations/BaseHasManyPhotos.php | 2 +- app/SmartAlbums/BaseSmartAlbum.php | 2 +- app/SmartAlbums/UnsortedAlbum.php | 2 +- composer.json | 2 + composer.lock | 386 +++++++++++++++++- database/factories/PhotoFactory.php | 1 + ...06_04_193132_optional_color_extraction.php | 43 ++ lang/ar/maintenance.php | 5 + lang/cz/maintenance.php | 5 + lang/de/maintenance.php | 5 + lang/el/maintenance.php | 5 + lang/en/maintenance.php | 5 + lang/es/maintenance.php | 5 + lang/fr/maintenance.php | 5 + lang/hu/maintenance.php | 5 + lang/it/maintenance.php | 5 + lang/ja/maintenance.php | 5 + lang/nl/maintenance.php | 5 + lang/no/maintenance.php | 5 + lang/pl/maintenance.php | 5 + lang/pt/maintenance.php | 5 + lang/ru/maintenance.php | 5 + lang/sk/maintenance.php | 5 + lang/sv/maintenance.php | 5 + lang/vi/maintenance.php | 5 + lang/zh_CN/maintenance.php | 5 + lang/zh_TW/maintenance.php | 5 + .../js/components/drawers/PhotoDetails.vue | 23 +- .../gallery/photoModule/ColourSquare.vue | 18 + .../MaintenanceMissingPalettes.vue | 79 ++++ resources/js/lychee.d.ts | 8 + resources/js/services/maintenance-service.ts | 7 + resources/js/views/Maintenance.vue | 2 + routes/api_v2.php | 2 + tests/Feature_v2/Base/BaseApiWithDataTest.php | 4 + .../Feature_v2/Jobs/ExtractColoursJobTest.php | 90 ++++ tests/Traits/RequiresEmptyColourPalettes.php | 38 ++ 58 files changed, 1465 insertions(+), 34 deletions(-) create mode 100644 app/Actions/Photo/Pipes/Shared/ExtractColourPalette.php create mode 100644 app/Console/Commands/ImageProcessing/ExtractColourPalette.php create mode 100644 app/Contracts/Image/ColourPaletteExtractorInterface.php create mode 100644 app/Http/Controllers/Admin/Maintenance/MissingPalettes.php create mode 100644 app/Http/Resources/Models/ColourPaletteResource.php create mode 100644 app/Image/ColourExtractor/FarzaiExtractor.php create mode 100644 app/Image/ColourExtractor/ImageFactoryForColourExtraction.php create mode 100644 app/Image/ColourExtractor/LeagueExtractor.php create mode 100644 app/Jobs/ExtractColoursJob.php create mode 100644 database/migrations/2025_06_04_193132_optional_color_extraction.php create mode 100644 resources/js/components/gallery/photoModule/ColourSquare.vue create mode 100644 resources/js/components/maintenance/MaintenanceMissingPalettes.vue create mode 100644 tests/Feature_v2/Jobs/ExtractColoursJobTest.php create mode 100644 tests/Traits/RequiresEmptyColourPalettes.php diff --git a/app/Actions/Album/PositionData.php b/app/Actions/Album/PositionData.php index 1dc4d39e573..c9ca169b087 100644 --- a/app/Actions/Album/PositionData.php +++ b/app/Actions/Album/PositionData.php @@ -42,6 +42,7 @@ public function get(AbstractAlbum $album, bool $include_sub_albums = false): Pos // variants per photo $r->whereBetween('type', [SizeVariantType::SMALL2X, SizeVariantType::THUMB]); }, + 'palette', ]) ->whereNotNull('latitude') ->whereNotNull('longitude'); diff --git a/app/Actions/Albums/PositionData.php b/app/Actions/Albums/PositionData.php index e546eb709b3..d33eadeec4f 100644 --- a/app/Actions/Albums/PositionData.php +++ b/app/Actions/Albums/PositionData.php @@ -55,6 +55,7 @@ public function do(): PositionDataResource // variants per photo $r->whereBetween('type', [SizeVariantType::SMALL2X, SizeVariantType::THUMB]); }, + 'palette', ]) ->whereNotNull('latitude') ->whereNotNull('longitude'), diff --git a/app/Actions/Photo/Create.php b/app/Actions/Photo/Create.php index 39dbf762d07..81f0078c54a 100644 --- a/app/Actions/Photo/Create.php +++ b/app/Actions/Photo/Create.php @@ -181,6 +181,7 @@ private function handleStandalone(InitDTO $init_dto): Photo Standalone\EncodePlaceholder::class, Standalone\ReplaceOriginalWithBackup::class, Shared\UploadSizeVariantsToS3::class, + Shared\ExtractColourPalette::class, ]; return $this->executePipeOnDTO($pipes, $dto)->getPhoto(); @@ -270,6 +271,7 @@ private function handlePhotoLivePartner(InitDTO $init_dto): Photo Standalone\EncodePlaceholder::class, Standalone\ReplaceOriginalWithBackup::class, Shared\UploadSizeVariantsToS3::class, + Shared\ExtractColourPalette::class, ]; $stand_alone_dto = $this->executePipeOnDTO($stand_alone_pipes, $stand_alone_dto); diff --git a/app/Actions/Photo/Delete.php b/app/Actions/Photo/Delete.php index 58809e08908..6724339e1a0 100644 --- a/app/Actions/Photo/Delete.php +++ b/app/Actions/Photo/Delete.php @@ -14,6 +14,7 @@ use App\Exceptions\ModelDBException; use App\Image\FileDeleter; use App\Models\Album; +use App\Models\Palette; use App\Models\Photo; use App\Models\SizeVariant; use App\Models\Statistics; @@ -320,6 +321,21 @@ private function deleteDBRecords(array $photo_ids, array $album_ids): void }) ->delete(); } + if (count($photo_ids) !== 0) { + Palette::query() + ->whereIn('photo_id', $photo_ids) + ->delete(); + } + if (count($album_ids) !== 0) { + Palette::query() + ->whereExists(function (BaseBuilder $query) use ($album_ids): void { + $query + ->from('photos', 'p') + ->whereColumn('p.id', '=', 'palettes.photo_id') + ->whereIn('p.album_id', $album_ids); + }) + ->delete(); + } if (count($photo_ids) !== 0) { Photo::query()->whereIn('id', $photo_ids)->delete(); } diff --git a/app/Actions/Photo/Pipes/Shared/ExtractColourPalette.php b/app/Actions/Photo/Pipes/Shared/ExtractColourPalette.php new file mode 100644 index 00000000000..6eacaefc521 --- /dev/null +++ b/app/Actions/Photo/Pipes/Shared/ExtractColourPalette.php @@ -0,0 +1,41 @@ +getPhoto()); + + return $next($state); + } + + $job = new ExtractColoursJob($state->getPhoto()); + $job->handle(); + + return $next($state); + // @codeCoverageIgnoreEnd + } +} diff --git a/app/Actions/Search/PhotoSearch.php b/app/Actions/Search/PhotoSearch.php index d873ac6a2d0..706531df9ef 100644 --- a/app/Actions/Search/PhotoSearch.php +++ b/app/Actions/Search/PhotoSearch.php @@ -57,7 +57,7 @@ public function query(array $terms): Collection public function sqlQuery(array $terms, ?Album $album = null): Builder { $query = $this->photoQueryPolicy->applySearchabilityFilter( - query: Photo::query()->with(['album', 'statistics', 'size_variants']), + query: Photo::query()->with(['album', 'statistics', 'size_variants', 'palette']), origin: $album, include_nsfw: !Configs::getValueAsBool('hide_nsfw_in_search') ); diff --git a/app/Console/Commands/ImageProcessing/ExtractColourPalette.php b/app/Console/Commands/ImageProcessing/ExtractColourPalette.php new file mode 100644 index 00000000000..0dda0becb4c --- /dev/null +++ b/app/Console/Commands/ImageProcessing/ExtractColourPalette.php @@ -0,0 +1,71 @@ +argument('limit'); + $timeout = (int) $this->argument('tm'); + + try { + set_time_limit($timeout); + } catch (InfoException) { + // Silently do nothing, if `set_time_limit` is denied. + } + + $photos = Photo::with(['size_variants']) + ->whereDoesntHave('palette') + ->where('type', 'like', 'image/%') + ->orderBy('id') + ->lazyById($limit); + + if (count($photos) === 0) { + $this->line('No photos require palette extraction.'); + + return 0; + } + + foreach ($photos as $photo) { + $this->line(sprintf('Extracting Color Palette for %s [%s].', $photo->title, $photo->id)); + ExtractColoursJob::dispatchSync($photo); + } + + return 0; + } catch (\Throwable $e) { + throw new UnexpectedException($e); + } + } +} diff --git a/app/Contracts/Image/ColourPaletteExtractorInterface.php b/app/Contracts/Image/ColourPaletteExtractorInterface.php new file mode 100644 index 00000000000..51d2103178a --- /dev/null +++ b/app/Contracts/Image/ColourPaletteExtractorInterface.php @@ -0,0 +1,24 @@ +with(['access_permissions', 'photos', 'children', 'children.owner', 'photos.size_variants', 'photos.statistics']); + $album_query->with(['access_permissions', 'photos', 'children', 'children.owner', 'photos.size_variants', 'photos.statistics', 'photos.palette']); $tag_album_query->with(['photos']); } diff --git a/app/Http/Controllers/Admin/Maintenance/MissingPalettes.php b/app/Http/Controllers/Admin/Maintenance/MissingPalettes.php new file mode 100644 index 00000000000..111ea80d10d --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/MissingPalettes.php @@ -0,0 +1,62 @@ +check() || !Configs::getValueAsBool('enable_colour_extractions')) { + return 0; + } + + return Photo::query() + ->where('type', 'like', 'image/%') + ->whereDoesntHave('palette') + ->count(); + } + + /** + * Generate missing palettes for photos in chunks. + * + * @return void + */ + public function do(MaintenanceRequest $request): void + { + if (!resolve(Verify::class)->check() || !Configs::getValueAsBool('enable_colour_extractions')) { + return; + } + + $limit = Configs::getValueAsInt('maintenance_processing_limit'); + $photos = Photo::with(['size_variants']) + ->whereDoesntHave('palette') + ->where('type', 'like', 'image/%') + ->orderBy('id') + ->lazyById($limit); + + foreach ($photos as $photo) { + ExtractColoursJob::dispatchSync($photo); + } + } +} diff --git a/app/Http/Controllers/Gallery/FrameController.php b/app/Http/Controllers/Gallery/FrameController.php index eab64b34dc2..8f924d6bc75 100644 --- a/app/Http/Controllers/Gallery/FrameController.php +++ b/app/Http/Controllers/Gallery/FrameController.php @@ -86,12 +86,12 @@ private function loadPhoto(AbstractAlbum|null $album, int $retries = 5): ?Photo // default query if ($album === null) { $query = $this->photo_query_policy->applySearchabilityFilter( - query: Photo::query()->with(['album', 'size_variants']), + query: Photo::query()->with(['album', 'size_variants', 'palette']), origin: null, include_nsfw: !Configs::getValueAsBool('hide_nsfw_in_frame') ); } else { - $query = $album->photos()->with(['album', 'size_variants']); + $query = $album->photos()->with(['album', 'size_variants', 'palette']); } /** @var ?Photo $photo */ diff --git a/app/Http/Middleware/ConfigIntegrity.php b/app/Http/Middleware/ConfigIntegrity.php index bcd95b186df..23cbf793acf 100644 --- a/app/Http/Middleware/ConfigIntegrity.php +++ b/app/Http/Middleware/ConfigIntegrity.php @@ -48,6 +48,8 @@ class ConfigIntegrity 'live_metrics_enabled', 'live_metrics_access', 'live_metrics_max_time', + 'enable_colour_extractions', + 'colour_extraction_driver', ]; /** diff --git a/app/Http/Resources/Models/ColourPaletteResource.php b/app/Http/Resources/Models/ColourPaletteResource.php new file mode 100644 index 00000000000..a1a9ab5197b --- /dev/null +++ b/app/Http/Resources/Models/ColourPaletteResource.php @@ -0,0 +1,41 @@ +colour_1 = Palette::toHex($palette->colour_1); + $this->colour_2 = Palette::toHex($palette->colour_2); + $this->colour_3 = Palette::toHex($palette->colour_3); + $this->colour_4 = Palette::toHex($palette->colour_4); + $this->colour_5 = Palette::toHex($palette->colour_5); + } + + public static function fromModel(?Palette $p): ?ColourPaletteResource + { + if ($p === null) { + return null; + } + + return new self($p); + } +} diff --git a/app/Http/Resources/Models/PhotoResource.php b/app/Http/Resources/Models/PhotoResource.php index cbf97e12a5c..49b1a43a38c 100644 --- a/app/Http/Resources/Models/PhotoResource.php +++ b/app/Http/Resources/Models/PhotoResource.php @@ -61,6 +61,7 @@ class PhotoResource extends Data public PreformattedPhotoData $preformatted; public PreComputedPhotoData $precomputed; public ?TimelineData $timeline = null; + public ?ColourPaletteResource $palette = null; private Carbon $timeline_data_carbon; @@ -102,6 +103,7 @@ public function __construct(Photo $photo) $this->previous_photo_id = null; $this->preformatted = new PreformattedPhotoData($photo, $this->size_variants->original); $this->precomputed = new PreComputedPhotoData($photo); + $this->palette = ColourPaletteResource::fromModel($photo->palette); $this->timeline_data_carbon = $photo->taken_at ?? $photo->created_at; diff --git a/app/Image/ColourExtractor/FarzaiExtractor.php b/app/Image/ColourExtractor/FarzaiExtractor.php new file mode 100644 index 00000000000..df549f6fcd3 --- /dev/null +++ b/app/Image/ColourExtractor/FarzaiExtractor.php @@ -0,0 +1,56 @@ +getImageHandler(); + // Create an image instance + $image = ImageFactoryForColourExtraction::createFromFile($file, $image_handler); + + // Extract colours + $extractor_factory = new ColorExtractorFactory(); + $extractor = $extractor_factory->make($image_handler); + + /** @var ColorPaletteInterface $colour_palette */ + $colour_palette = $extractor->extract($image, 5); // Extract 5 dominant colours + + /** @var array $colours */ + $colours = $colour_palette->getColors(); + + return array_map( + fn (ColorInterface $colour): string => $colour->toHex(), + $colours + ); + } +} \ No newline at end of file diff --git a/app/Image/ColourExtractor/ImageFactoryForColourExtraction.php b/app/Image/ColourExtractor/ImageFactoryForColourExtraction.php new file mode 100644 index 00000000000..5b748ff73ed --- /dev/null +++ b/app/Image/ColourExtractor/ImageFactoryForColourExtraction.php @@ -0,0 +1,137 @@ + self::createGdImage($file), + 'imagick' => self::createImagickImage($file), + default => throw new \InvalidArgumentException("Unsupported driver: {$driver}"), + }; + } + + /** + * Create a GD image resource from a FlysystemFile. + * This is used by the LeagueExtractor. + * + * @param FlysystemFile $file + * + * @return GlobalGdImage + */ + public static function createGdResourceFromFile(FlysystemFile $file): \GdImage + { + if (!extension_loaded('gd')) { + throw new \RuntimeException('GD extension is not available'); + } + + try { + $in_memory_buffer = new InMemoryBuffer(); + + $original_stream = $file->read(); + if (stream_get_meta_data($original_stream)['seekable']) { + $input_stream = $original_stream; + } else { + // We make an in-memory copy of the provided stream, + // because we must be able to seek/rewind the stream. + // For example, a readable stream from a remote location (i.e. + // a "download" stream) is only forward readable once. + $in_memory_buffer->write($original_stream); + $input_stream = $in_memory_buffer->read(); + } + + $img_binary = stream_get_contents($input_stream); + rewind($input_stream); + + error_clear_last(); + $img = imagecreatefromstring($img_binary); + if ($img === false) { + throw ImageException::createFromPhpError(); + } + + return $img; + } catch (\ErrorException $e) { + throw new \InvalidArgumentException('Failed to create GD image resource'); + } finally { + $in_memory_buffer->close(); + $file->close(); + } + } + + private static function createGdImage(FlysystemFile $file): GdImage + { + return new GdImage(self::createGdResourceFromFile($file)); + } + + /** + * @throws \RuntimeException + * @throws \InvalidArgumentException + */ + private static function createImagickImage(FlysystemFile $file): ImagickImage + { + if (!extension_loaded('imagick')) { + throw new \RuntimeException('Imagick extension is not available'); + } + + try { + $in_memory_buffer = null; + $original_stream = $file->read(); + if (stream_get_meta_data($original_stream)['seekable']) { + $input_stream = $original_stream; + } else { + // We make an in-memory copy of the provided stream, + // because we must be able to seek/rewind the stream. + // For example, a readable stream from a remote location (i.e. + // a "download" stream) is only forward readable once. + $in_memory_buffer = new InMemoryBuffer(); + $in_memory_buffer->write($original_stream); + $input_stream = $in_memory_buffer->read(); + } + + /** @var \Imagick $image */ + $image = new \Imagick(); + $image->readImageFile($input_stream); + + // If the file is a PDF and the user has chosen to support PDF files then try to create an image from the first page + if ($file->getExtension() === '.pdf' && BaseMediaFile::isSupportedOrAcceptedFileExtension($file->getExtension())) { + $image->setIteratorIndex(0); + } + + return new ImagickImage($image); + } catch (\ImagickException $e) { + throw new MediaFileOperationException('Failed to load image', $e); + } finally { + $in_memory_buffer?->close(); + $file->close(); + } + } +} diff --git a/app/Image/ColourExtractor/LeagueExtractor.php b/app/Image/ColourExtractor/LeagueExtractor.php new file mode 100644 index 00000000000..bbad1d7204f --- /dev/null +++ b/app/Image/ColourExtractor/LeagueExtractor.php @@ -0,0 +1,32 @@ +extract(5); + + return array_map(fn ($c) => Color::fromIntToHex($c), $colors); + } +} \ No newline at end of file diff --git a/app/Jobs/ExtractColoursJob.php b/app/Jobs/ExtractColoursJob.php new file mode 100644 index 00000000000..c100e86c71e --- /dev/null +++ b/app/Jobs/ExtractColoursJob.php @@ -0,0 +1,123 @@ +photo_id = $photo->id; + + // Set up our new history record. + $this->history = new JobHistory(); + $this->history->owner_id = $photo->owner_id; + $this->history->job = Str::limit(sprintf('Extraction Color Palette for %s [%s].', $photo->title, $this->photo_id), 200); + $this->history->status = JobStatus::READY; + + $this->history->save(); + } + + /** + * Execute the job. + */ + public function handle(): Photo + { + $this->history->status = JobStatus::STARTED; + $this->history->save(); + + $photo = Photo::with(['size_variants', 'palette'])->findOrFail($this->photo_id); + if ($photo->palette !== null || $photo->isPhoto() === false) { + // If the photo already has a palette, we don't need to extract colours again. + $this->history->status = JobStatus::SUCCESS; + $this->history->save(); + + return $photo; + } + + $file = $photo->size_variants->getOriginal()->getFile(); + + $extractor = match (Configs::getValueAsString('colour_extraction_driver')) { + 'league' => new LeagueExtractor(), + 'farzai' => new FarzaiExtractor(), + default => throw new LycheeLogicException('Unsupported colour extraction driver.'), + }; + $colours = $extractor->extract($file); + + // Creates the colours if they don't exists yet. + $colour_1 = Colour::fromHex($colours[0]); + $colour_2 = Colour::fromHex($colours[1]); + $colour_3 = Colour::fromHex($colours[2]); + $colour_4 = Colour::fromHex($colours[3]); + $colour_5 = Colour::fromHex($colours[4]); + + $palette = Palette::create([ + 'photo_id' => $photo->id, + 'colour_1' => $colour_1->id, + 'colour_2' => $colour_2->id, + 'colour_3' => $colour_3->id, + 'colour_4' => $colour_4->id, + 'colour_5' => $colour_5->id, + ]); + $palette->save(); + + // Once the job has finished, set history status to 1. + $this->history->status = JobStatus::SUCCESS; + $this->history->save(); + + return $photo; + } + + /** + * Catch failures. + */ + public function failed(\Throwable $th): void + { + $this->history->status = JobStatus::FAILURE; + $this->history->save(); + + if ($th->getCode() === 999) { + $this->release(); + } else { + Log::error(__LINE__ . ':' . __FILE__ . ' ' . $th->getMessage(), $th->getTrace()); + } + } +} \ No newline at end of file diff --git a/app/Metadata/Cache/RouteCacheManager.php b/app/Metadata/Cache/RouteCacheManager.php index e2c45cba4ee..1ebf9cd7e7f 100644 --- a/app/Metadata/Cache/RouteCacheManager.php +++ b/app/Metadata/Cache/RouteCacheManager.php @@ -72,6 +72,7 @@ public function __construct() 'api/v2/Maintenance::countDuplicates' => false, 'api/v2/Maintenance::searchDuplicates' => false, 'api/v2/Maintenance::statisticsIntegrity' => false, + 'api/v2/Maintenance::missingPalettes' => false, 'api/v2/Map' => new RouteCacheConfig(tag: CacheTag::GALLERY, user_dependant: true, extra: [RequestAttribute::ALBUM_ID_ATTRIBUTE]), 'api/v2/Map::provider' => new RouteCacheConfig(tag: CacheTag::SETTINGS), diff --git a/app/Models/Colour.php b/app/Models/Colour.php index a875ec77a4e..d3ab5165f52 100644 --- a/app/Models/Colour.php +++ b/app/Models/Colour.php @@ -38,4 +38,35 @@ public function toHex(): string { return sprintf('#%02x%02x%02x', $this->R, $this->G, $this->B); } + + /** + * Create or update a Colour instance from a hexadecimal string. + * + * @param string $hex + * + * @return Colour + * + * @throws \InvalidArgumentException + */ + public static function fromHex(string $hex): self + { + // Remove the '#' character if it exists + $hex = ltrim($hex, '#'); + + if (strlen($hex) !== 6) { + throw new \InvalidArgumentException('Hex string must be 6 characters long.'); + } + + $id = hexdec($hex); // Use the hex value as the ID + + return Colour::updateOrCreate([ + 'id' => $id, + ], + [ + 'id' => $id, + 'R' => hexdec(substr($hex, 0, 2)), + 'G' => hexdec(substr($hex, 2, 2)), + 'B' => hexdec(substr($hex, 4, 2)), + ]); + } } diff --git a/app/Models/Palette.php b/app/Models/Palette.php index 2ad18b03e8c..d0ce880d4fe 100644 --- a/app/Models/Palette.php +++ b/app/Models/Palette.php @@ -35,19 +35,12 @@ class Palette extends Model ]; /** - * @return array{colour_1:string,colour_2:string,colour_3:string,colour_4:string,colour_5:string} + * Convert a colour integer to a hex string. + * + * @param int $colour The colour in integer format (0xRRGGBB) + * + * @return string The hex representation of the colour */ - public function toHexColours(): array - { - return [ - 'colour_1' => self::toHex($this->colour_1), - 'colour_2' => self::toHex($this->colour_2), - 'colour_3' => self::toHex($this->colour_3), - 'colour_4' => self::toHex($this->colour_4), - 'colour_5' => self::toHex($this->colour_5), - ]; - } - public static function toHex(int $colour) { $b = $colour & 0xFF; // Extract the blue component @@ -56,4 +49,26 @@ public static function toHex(int $colour) return sprintf('#%02x%02x%02x', $r, $g, $b); } + + /** + * Convert a hex color string to an integer. + * + * @param string $hex The hex color string (e.g., '#ff0000' or 'ff0000') + * + * @return int The integer representation of the color (0xRRGGBB) + * + * @throws \InvalidArgumentException If the hex format is invalid + */ + public static function fromHex(string $hex): int + { + // Remove the '#' character if it exists + $hex = ltrim($hex, '#'); + + // Ensure the hex string is 6 characters long + if (strlen($hex) !== 6) { + throw new \InvalidArgumentException('Invalid hex color format.'); + } + + return hexdec($hex); + } } diff --git a/app/Relations/BaseHasManyPhotos.php b/app/Relations/BaseHasManyPhotos.php index 4437d72b607..cdae629a060 100644 --- a/app/Relations/BaseHasManyPhotos.php +++ b/app/Relations/BaseHasManyPhotos.php @@ -65,7 +65,7 @@ public function __construct(TagAlbum|Album $owning_album) // indirect condition. // Hence, the actually owning albums of the photos are not // necessarily loaded. - Photo::query()->with(['album', 'size_variants']), + Photo::query()->with(['album', 'size_variants', 'palette']), $owning_album ); } diff --git a/app/SmartAlbums/BaseSmartAlbum.php b/app/SmartAlbums/BaseSmartAlbum.php index d4c9a5fa636..43c68ac4f30 100644 --- a/app/SmartAlbums/BaseSmartAlbum.php +++ b/app/SmartAlbums/BaseSmartAlbum.php @@ -115,7 +115,7 @@ public function photos(): Builder { $query = $this->photo_query_policy ->applySearchabilityFilter( - query: Photo::query()->with(['album', 'size_variants', 'statistics']), + query: Photo::query()->with(['album', 'size_variants', 'statistics', 'palette']), origin: null, include_nsfw: !Configs::getValueAsBool('hide_nsfw_in_smart_albums') )->where($this->smart_photo_condition); diff --git a/app/SmartAlbums/UnsortedAlbum.php b/app/SmartAlbums/UnsortedAlbum.php index 5ea4b5b5d14..b44ce46e5ad 100644 --- a/app/SmartAlbums/UnsortedAlbum.php +++ b/app/SmartAlbums/UnsortedAlbum.php @@ -45,7 +45,7 @@ public static function getInstance(): self public function photos(): Builder { if ($this->public_permissions !== null) { - return Photo::query()->with(['album', 'size_variants', 'statistics']) + return Photo::query()->with(['album', 'size_variants', 'statistics', 'palette']) ->where($this->smart_photo_condition); } diff --git a/composer.json b/composer.json index afc774ae6ec..f6662905140 100644 --- a/composer.json +++ b/composer.json @@ -53,12 +53,14 @@ "composer/semver": "^3.4", "dedoc/scramble": "^0.12.16", "doctrine/dbal": "^3.9", + "farzai/color-palette": "^1.0", "geocoder-php/cache-provider": "^4.4", "geocoder-php/nominatim-provider": "^5.7", "graham-campbell/markdown": "^16.0", "laragear/webauthn": "^4.0", "laravel/framework": "^11.0", "laravel/socialite": "^5.18", + "league/color-extractor": "^0.4.0", "league/flysystem-aws-s3-v3": "^3.29", "lychee-org/lycheeverify": "^1.0.2", "lychee-org/nestedset": "^10.0.2", diff --git a/composer.lock b/composer.lock index 9b1c2baa295..888f350a0de 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "021f4d46c0c94c5967bd68053e760303", + "content-hash": "d06ff05ee78e43296b5de3a907d6f2e0", "packages": [ { "name": "amphp/amp", @@ -870,16 +870,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.344.0", + "version": "3.344.1", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "787a8ec6301657d9cbdb389db4fa92243c68666a" + "reference": "7ff4b73f2d6550949be14b8822e100963e068705" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/787a8ec6301657d9cbdb389db4fa92243c68666a", - "reference": "787a8ec6301657d9cbdb389db4fa92243c68666a", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7ff4b73f2d6550949be14b8822e100963e068705", + "reference": "7ff4b73f2d6550949be14b8822e100963e068705", "shasum": "" }, "require": { @@ -961,9 +961,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.344.0" + "source": "https://github.com/aws/aws-sdk-php/tree/3.344.1" }, - "time": "2025-06-04T18:36:41+00:00" + "time": "2025-06-05T18:09:40+00:00" }, { "name": "bepsvpt/secure-headers", @@ -2209,6 +2209,68 @@ }, "time": "2023-08-08T05:53:35+00:00" }, + { + "name": "farzai/color-palette", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/parsilver/color-palette-php.git", + "reference": "abe72b7a0f3d835085aef90537f9a4a2ac170ae3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/parsilver/color-palette-php/zipball/abe72b7a0f3d835085aef90537f9a4a2ac170ae3", + "reference": "abe72b7a0f3d835085aef90537f9a4a2ac170ae3", + "shasum": "" + }, + "require": { + "nyholm/psr7": "^1.8", + "php": "^8.1", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "symfony/http-client": "^6.4" + }, + "require-dev": { + "laravel/pint": "^1.0", + "mockery/mockery": "^1.6", + "pestphp/pest": "^2.20", + "spatie/ray": "^1.28" + }, + "type": "library", + "autoload": { + "psr-4": { + "Farzai\\ColorPalette\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "parsilver", + "role": "Developer" + } + ], + "description": "A robust PHP library for extracting, analyzing, and managing color palettes from images", + "homepage": "https://github.com/parsilver/color-palette-php", + "keywords": [ + "color-palette", + "farzai" + ], + "support": { + "issues": "https://github.com/parsilver/color-palette-php/issues", + "source": "https://github.com/parsilver/color-palette-php/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/parsilver", + "type": "github" + } + ], + "time": "2024-11-28T05:14:08+00:00" + }, { "name": "firebase/php-jwt", "version": "v6.11.1", @@ -3884,6 +3946,67 @@ ], "time": "2025-01-26T21:29:45+00:00" }, + { + "name": "league/color-extractor", + "version": "0.4.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/color-extractor.git", + "reference": "21fcac6249c5ef7d00eb83e128743ee6678fe505" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/color-extractor/zipball/21fcac6249c5ef7d00eb83e128743ee6678fe505", + "reference": "21fcac6249c5ef7d00eb83e128743ee6678fe505", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "php": "^7.3 || ^8.0" + }, + "replace": { + "matthecat/colorextractor": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "ext-curl": "To download images from remote URLs if allow_url_fopen is disabled for security reasons" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\ColorExtractor\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathieu Lechat", + "email": "math.lechat@gmail.com", + "homepage": "http://matthecat.com", + "role": "Developer" + } + ], + "description": "Extract colors from an image as a human would do.", + "homepage": "https://github.com/thephpleague/color-extractor", + "keywords": [ + "color", + "extract", + "human", + "image", + "palette" + ], + "support": { + "issues": "https://github.com/thephpleague/color-extractor/issues", + "source": "https://github.com/thephpleague/color-extractor/tree/0.4.0" + }, + "time": "2022-09-24T15:57:16+00:00" + }, { "name": "league/commonmark", "version": "2.7.0", @@ -5573,6 +5696,84 @@ ], "time": "2025-05-08T08:14:37+00:00" }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, { "name": "opcodesio/log-viewer", "version": "dev-lycheeOrg", @@ -9631,6 +9832,177 @@ ], "time": "2024-12-30T19:00:26+00:00" }, + { + "name": "symfony/http-client", + "version": "v6.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "3294a433fc9d12ae58128174896b5b1822c28dad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/3294a433fc9d12ae58128174896b5b1822c28dad", + "reference": "3294a433fc9d12ae58128174896b5b1822c28dad", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.3" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/amp": "^2.5", + "amphp/http-client": "^4.2.1", + "amphp/http-tunnel": "^1.0", + "amphp/socket": "^1.1", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v6.4.19" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-02-13T09:55:13+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "75d7043853a42837e68111812f4d964b01e5101c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-29T11:18:49+00:00" + }, { "name": "symfony/http-foundation", "version": "v7.3.0", diff --git a/database/factories/PhotoFactory.php b/database/factories/PhotoFactory.php index 6761707da49..a194e11e040 100644 --- a/database/factories/PhotoFactory.php +++ b/database/factories/PhotoFactory.php @@ -181,6 +181,7 @@ public function configure(): static $photo->load('size_variants'); } + $photo->load('palette'); $photo->load('statistics'); // Reset the value if it was disabled. diff --git a/database/migrations/2025_06_04_193132_optional_color_extraction.php b/database/migrations/2025_06_04_193132_optional_color_extraction.php new file mode 100644 index 00000000000..ad83314432b --- /dev/null +++ b/database/migrations/2025_06_04_193132_optional_color_extraction.php @@ -0,0 +1,43 @@ + 'enable_colour_extractions', + 'value' => '0', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Extract the 5 most used colours from the image.', + 'details' => '', + 'is_expert' => false, + 'is_secret' => false, + 'level' => 1, + 'order' => 15, + ], + [ + 'key' => 'colour_extraction_driver', + 'value' => 'farzai', + 'cat' => self::CAT, + 'type_range' => 'league|farzai', + 'description' => 'Driver for colour extraction.', + 'details' => 'Slower: league does a full sampling and use ciede2000DeltaE for colour distance calculation.
Faster: farzai uses spot sampling and k-mean distance.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 16, + ], + ]; + } +}; \ No newline at end of file diff --git a/lang/ar/maintenance.php b/lang/ar/maintenance.php index 71746e9d1b7..d38e04996cd 100644 --- a/lang/ar/maintenance.php +++ b/lang/ar/maintenance.php @@ -57,6 +57,11 @@ 'update-button' => 'تحديث', 'no-pending-updates' => 'لا توجد تحديثات معلقة.', ], + 'missing-palettes' => [ + 'title' => 'لوحات الألوان المفقودة', + 'description' => 'تم العثور على %d لوحة ألوان مفقودة.', + 'button' => 'إنشاء المفقود', + ], 'statistics-check' => [ 'title' => 'فحص سلامة الإحصائيات', 'missing_photos' => 'إحصائيات %d صورة مفقودة.', diff --git a/lang/cz/maintenance.php b/lang/cz/maintenance.php index a1ceea32a13..73499a0e0bb 100644 --- a/lang/cz/maintenance.php +++ b/lang/cz/maintenance.php @@ -59,6 +59,11 @@ 'update-button' => 'Update', 'no-pending-updates' => 'No pending update.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/de/maintenance.php b/lang/de/maintenance.php index 7147a41e4d6..92f2d1b530b 100644 --- a/lang/de/maintenance.php +++ b/lang/de/maintenance.php @@ -57,6 +57,11 @@ 'update-button' => 'Update', 'no-pending-updates' => 'Keine Updates verfügbar.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Integritätsprüfung der Statistik', 'missing_photos' => '%d Fotostatistiken fehlen.', diff --git a/lang/el/maintenance.php b/lang/el/maintenance.php index a1ceea32a13..73499a0e0bb 100644 --- a/lang/el/maintenance.php +++ b/lang/el/maintenance.php @@ -59,6 +59,11 @@ 'update-button' => 'Update', 'no-pending-updates' => 'No pending update.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/en/maintenance.php b/lang/en/maintenance.php index cf8aca6d150..8bfedfb501c 100644 --- a/lang/en/maintenance.php +++ b/lang/en/maintenance.php @@ -57,6 +57,11 @@ 'update-button' => 'Update', 'no-pending-updates' => 'No pending update.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/es/maintenance.php b/lang/es/maintenance.php index a1ceea32a13..73499a0e0bb 100644 --- a/lang/es/maintenance.php +++ b/lang/es/maintenance.php @@ -59,6 +59,11 @@ 'update-button' => 'Update', 'no-pending-updates' => 'No pending update.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/fr/maintenance.php b/lang/fr/maintenance.php index 5c30890fbda..d4a836efcd9 100644 --- a/lang/fr/maintenance.php +++ b/lang/fr/maintenance.php @@ -58,6 +58,11 @@ 'update-button' => 'Mettre à jour', 'no-pending-updates' => 'Aucune mise à jour en attente.', ], + 'missing-palettes' => [ + 'title' => 'Palettes manquantes', + 'description' => '%d palettes manquantes trouvées.', + 'button' => 'Créer les palettes manquantes', + ], 'statistics-check' => [ 'title' => 'Check de l’intégrité des Statistiques', 'missing_photos' => '%d statistiques de photos manquantes.', diff --git a/lang/hu/maintenance.php b/lang/hu/maintenance.php index a1ceea32a13..73499a0e0bb 100644 --- a/lang/hu/maintenance.php +++ b/lang/hu/maintenance.php @@ -59,6 +59,11 @@ 'update-button' => 'Update', 'no-pending-updates' => 'No pending update.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/it/maintenance.php b/lang/it/maintenance.php index a1ceea32a13..73499a0e0bb 100644 --- a/lang/it/maintenance.php +++ b/lang/it/maintenance.php @@ -59,6 +59,11 @@ 'update-button' => 'Update', 'no-pending-updates' => 'No pending update.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/ja/maintenance.php b/lang/ja/maintenance.php index c056179bfe0..1749c35ac93 100644 --- a/lang/ja/maintenance.php +++ b/lang/ja/maintenance.php @@ -58,6 +58,11 @@ 'update-button' => '更新', 'no-pending-updates' => '保留中の更新はありません', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/nl/maintenance.php b/lang/nl/maintenance.php index 1fb507f1354..9797edab075 100644 --- a/lang/nl/maintenance.php +++ b/lang/nl/maintenance.php @@ -57,6 +57,11 @@ 'update-button' => 'Bijwerken', 'no-pending-updates' => 'Geen updates in behandeling.', ], + 'missing-palettes' => [ + 'title' => 'Ontbrekende paletten', + 'description' => '%d ontbrekende paletten gevonden.', + 'button' => 'Ontbrekende aanmaken', + ], 'statistics-check' => [ 'title' => 'Controle op statistische integriteit', 'missing_photos' => '%d fotostatistieken ontbreken.', diff --git a/lang/no/maintenance.php b/lang/no/maintenance.php index ae1f3dec9e2..d267d8d97dd 100644 --- a/lang/no/maintenance.php +++ b/lang/no/maintenance.php @@ -57,6 +57,11 @@ 'update-button' => 'Oppdater', 'no-pending-updates' => 'Ingen ventende oppdateringer.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistikk integritetskontroll', 'missing_photos' => '%d fotostatistikk mangler.', diff --git a/lang/pl/maintenance.php b/lang/pl/maintenance.php index d0dcfe0e70a..bf0762c094b 100644 --- a/lang/pl/maintenance.php +++ b/lang/pl/maintenance.php @@ -59,6 +59,11 @@ 'update-button' => 'Aktualizacja', 'no-pending-updates' => 'Brak oczekujących aktualizacji.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/pt/maintenance.php b/lang/pt/maintenance.php index a1ceea32a13..73499a0e0bb 100644 --- a/lang/pt/maintenance.php +++ b/lang/pt/maintenance.php @@ -59,6 +59,11 @@ 'update-button' => 'Update', 'no-pending-updates' => 'No pending update.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/ru/maintenance.php b/lang/ru/maintenance.php index a4113a617b2..5422a5f9d64 100644 --- a/lang/ru/maintenance.php +++ b/lang/ru/maintenance.php @@ -58,6 +58,11 @@ 'update-button' => 'Обновить', 'no-pending-updates' => 'Нет ожидающих обновлений.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/sk/maintenance.php b/lang/sk/maintenance.php index a1ceea32a13..73499a0e0bb 100644 --- a/lang/sk/maintenance.php +++ b/lang/sk/maintenance.php @@ -59,6 +59,11 @@ 'update-button' => 'Update', 'no-pending-updates' => 'No pending update.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/sv/maintenance.php b/lang/sv/maintenance.php index a1ceea32a13..73499a0e0bb 100644 --- a/lang/sv/maintenance.php +++ b/lang/sv/maintenance.php @@ -59,6 +59,11 @@ 'update-button' => 'Update', 'no-pending-updates' => 'No pending update.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/vi/maintenance.php b/lang/vi/maintenance.php index a1ceea32a13..73499a0e0bb 100644 --- a/lang/vi/maintenance.php +++ b/lang/vi/maintenance.php @@ -59,6 +59,11 @@ 'update-button' => 'Update', 'no-pending-updates' => 'No pending update.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/zh_CN/maintenance.php b/lang/zh_CN/maintenance.php index ab16be1079f..9d43e2b3b8c 100644 --- a/lang/zh_CN/maintenance.php +++ b/lang/zh_CN/maintenance.php @@ -58,6 +58,11 @@ 'update-button' => '更新', 'no-pending-updates' => '没有待处理的更新。', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/lang/zh_TW/maintenance.php b/lang/zh_TW/maintenance.php index a1ceea32a13..73499a0e0bb 100644 --- a/lang/zh_TW/maintenance.php +++ b/lang/zh_TW/maintenance.php @@ -59,6 +59,11 @@ 'update-button' => 'Update', 'no-pending-updates' => 'No pending update.', ], + 'missing-palettes' => [ + 'title' => 'Missing Palettes', + 'description' => 'Found %d missing palettes.', + 'button' => 'Create missing', + ], 'statistics-check' => [ 'title' => 'Statistics integrity Check', 'missing_photos' => '%d photo statistics missing.', diff --git a/resources/js/components/drawers/PhotoDetails.vue b/resources/js/components/drawers/PhotoDetails.vue index a1662dff0b3..2546c6b9cf0 100644 --- a/resources/js/components/drawers/PhotoDetails.vue +++ b/resources/js/components/drawers/PhotoDetails.vue @@ -14,7 +14,7 @@ {{ $t("gallery.photo.details.about") }} -
+
@@ -32,6 +32,13 @@
+
+ + + + + +
@@ -53,7 +60,7 @@