Skip to content

Commit 2ad5454

Browse files
authored
Feature: Extract image metadata on upload (LQIP, width, height and avg colour) (#947)
1 parent 576950e commit 2ad5454

File tree

18 files changed

+729
-18
lines changed

18 files changed

+729
-18
lines changed

backend/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ RUN echo "" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf && \
99
echo "user = www-data" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf && \
1010
echo "group = www-data" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf
1111

12-
RUN install-php-extensions intl
12+
RUN install-php-extensions intl imagick
1313

1414
COPY --chown=www-data:www-data . .
1515

backend/Dockerfile.dev

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ COPY --chown=www-data:www-data . /var/www/html
1212

1313
# Switch to root user to install PHP extensions
1414
USER root
15-
RUN install-php-extensions intl
15+
RUN install-php-extensions intl imagick
1616
USER www-data
1717

1818
RUN chmod -R 755 /var/www/html/storage \
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
<?php
2+
3+
namespace HiEvents\Console\Commands;
4+
5+
use HiEvents\DomainObjects\Generated\ImageDomainObjectAbstract;
6+
use HiEvents\Models\Image;
7+
use HiEvents\Services\Infrastructure\Image\DTO\ImageMetadataDTO;
8+
use Illuminate\Console\Command;
9+
use Illuminate\Filesystem\FilesystemManager;
10+
use Imagick;
11+
use Psr\Log\LoggerInterface;
12+
use Throwable;
13+
14+
class BackfillImageMetadataCommand extends Command
15+
{
16+
protected $signature = 'images:backfill-metadata
17+
{--limit=100 : Maximum number of images to process per batch}
18+
{--batch-size=50 : Number of images to process before clearing memory}
19+
{--dry-run : Show what would be done without actually doing it}
20+
{--force : Re-process images that already have metadata}';
21+
22+
protected $description = 'Backfill image metadata (dimensions, average colour, LQIP) for existing images';
23+
24+
private const LQIP_MAX_DIMENSION = 16;
25+
private const LQIP_QUALITY = 60;
26+
27+
public function __construct(
28+
private readonly FilesystemManager $filesystemManager,
29+
private readonly LoggerInterface $logger,
30+
) {
31+
parent::__construct();
32+
}
33+
34+
public function handle(): int
35+
{
36+
if (!$this->isImagickAvailable()) {
37+
$this->error('Imagick extension is not available. Please install it first.');
38+
return self::FAILURE;
39+
}
40+
41+
$this->info('Starting image metadata backfill...');
42+
43+
$limit = (int)$this->option('limit');
44+
$batchSize = (int)$this->option('batch-size');
45+
$dryRun = $this->option('dry-run');
46+
$force = $this->option('force');
47+
48+
if ($dryRun) {
49+
$this->warn('DRY RUN MODE - No changes will be made');
50+
}
51+
52+
$query = Image::query()
53+
->whereNull('deleted_at');
54+
55+
if (!$force) {
56+
$query->where(function ($q) {
57+
$q->whereNull(ImageDomainObjectAbstract::WIDTH)
58+
->orWhereNull(ImageDomainObjectAbstract::HEIGHT)
59+
->orWhereNull(ImageDomainObjectAbstract::AVG_COLOUR)
60+
->orWhereNull(ImageDomainObjectAbstract::LQIP_BASE64);
61+
});
62+
}
63+
64+
$totalCount = $query->count();
65+
66+
if ($totalCount === 0) {
67+
$this->info('No images found that need metadata backfill.');
68+
return self::SUCCESS;
69+
}
70+
71+
$toProcess = min($totalCount, $limit);
72+
$this->info("Found {$totalCount} images without complete metadata. Processing {$toProcess}...");
73+
74+
$progressBar = $this->output->createProgressBar($toProcess);
75+
$progressBar->start();
76+
77+
$successCount = 0;
78+
$errorCount = 0;
79+
$skippedCount = 0;
80+
$processedInBatch = 0;
81+
82+
$query->take($limit)
83+
->orderBy('id')
84+
->chunk($batchSize, function ($images) use (
85+
&$successCount,
86+
&$errorCount,
87+
&$skippedCount,
88+
&$processedInBatch,
89+
$progressBar,
90+
$dryRun,
91+
$batchSize,
92+
) {
93+
foreach ($images as $image) {
94+
try {
95+
$result = $this->processImage($image, $dryRun);
96+
97+
if ($result === 'success') {
98+
$successCount++;
99+
} elseif ($result === 'skipped') {
100+
$skippedCount++;
101+
} else {
102+
$errorCount++;
103+
}
104+
} catch (Throwable $e) {
105+
$this->newLine();
106+
$this->error("Failed to process image #{$image->id}: {$e->getMessage()}");
107+
$errorCount++;
108+
}
109+
110+
$progressBar->advance();
111+
$processedInBatch++;
112+
113+
if ($processedInBatch >= $batchSize) {
114+
gc_collect_cycles();
115+
$processedInBatch = 0;
116+
}
117+
}
118+
});
119+
120+
$progressBar->finish();
121+
$this->newLine(2);
122+
123+
$this->info('Backfill complete!');
124+
$this->table(
125+
['Status', 'Count'],
126+
[
127+
['Success', $successCount],
128+
['Errors', $errorCount],
129+
['Skipped', $skippedCount],
130+
['Total Processed', $successCount + $errorCount + $skippedCount],
131+
]
132+
);
133+
134+
return $errorCount > 0 ? self::FAILURE : self::SUCCESS;
135+
}
136+
137+
private function processImage(Image $image, bool $dryRun): string
138+
{
139+
$disk = $image->disk;
140+
$path = $image->path;
141+
142+
if (!$disk || !$path) {
143+
$this->logger->warning("Image #{$image->id} has no disk or path");
144+
return 'skipped';
145+
}
146+
147+
$filesystem = $this->filesystemManager->disk($disk);
148+
149+
if (!$filesystem->exists($path)) {
150+
$this->logger->warning("Image file not found for image #{$image->id}: {$path}");
151+
return 'skipped';
152+
}
153+
154+
if ($dryRun) {
155+
$this->newLine();
156+
$this->line("Would process: Image #{$image->id}, Path: {$path}");
157+
return 'success';
158+
}
159+
160+
$tempFile = null;
161+
$imagick = null;
162+
163+
try {
164+
$tempFile = tempnam(sys_get_temp_dir(), 'img_backfill_');
165+
file_put_contents($tempFile, $filesystem->get($path));
166+
167+
$imagick = new Imagick($tempFile);
168+
169+
$metadata = $this->extractMetadata($imagick);
170+
171+
$image->update([
172+
ImageDomainObjectAbstract::WIDTH => $metadata->width,
173+
ImageDomainObjectAbstract::HEIGHT => $metadata->height,
174+
ImageDomainObjectAbstract::AVG_COLOUR => $metadata->avg_colour,
175+
ImageDomainObjectAbstract::LQIP_BASE64 => $metadata->lqip_base64,
176+
]);
177+
178+
return 'success';
179+
} finally {
180+
if ($imagick !== null) {
181+
$imagick->clear();
182+
$imagick->destroy();
183+
}
184+
185+
if ($tempFile !== null && file_exists($tempFile)) {
186+
unlink($tempFile);
187+
}
188+
}
189+
}
190+
191+
private function extractMetadata(Imagick $imagick): ImageMetadataDTO
192+
{
193+
$width = $imagick->getImageWidth();
194+
$height = $imagick->getImageHeight();
195+
$avgColour = $this->extractAverageColour($imagick);
196+
$lqipBase64 = $this->generateLqip($imagick);
197+
198+
return new ImageMetadataDTO(
199+
width: $width,
200+
height: $height,
201+
avg_colour: $avgColour,
202+
lqip_base64: $lqipBase64,
203+
);
204+
}
205+
206+
private function extractAverageColour(Imagick $imagick): string
207+
{
208+
$clone = clone $imagick;
209+
$clone->resizeImage(1, 1, Imagick::FILTER_LANCZOS, 1);
210+
$pixel = $clone->getImagePixelColor(0, 0);
211+
$rgb = $pixel->getColor();
212+
$clone->clear();
213+
$clone->destroy();
214+
215+
return sprintf('#%02x%02x%02x', $rgb['r'], $rgb['g'], $rgb['b']);
216+
}
217+
218+
private function generateLqip(Imagick $imagick): string
219+
{
220+
$clone = clone $imagick;
221+
222+
$width = $clone->getImageWidth();
223+
$height = $clone->getImageHeight();
224+
225+
if ($width > $height) {
226+
$newWidth = self::LQIP_MAX_DIMENSION;
227+
$newHeight = (int)round($height * (self::LQIP_MAX_DIMENSION / $width));
228+
} else {
229+
$newHeight = self::LQIP_MAX_DIMENSION;
230+
$newWidth = (int)round($width * (self::LQIP_MAX_DIMENSION / $height));
231+
}
232+
233+
$newWidth = max(1, $newWidth);
234+
$newHeight = max(1, $newHeight);
235+
236+
$clone->resizeImage($newWidth, $newHeight, Imagick::FILTER_LANCZOS, 1);
237+
$clone->setImageFormat('webp');
238+
$clone->setImageCompressionQuality(self::LQIP_QUALITY);
239+
$clone->stripImage();
240+
241+
$blob = $clone->getImageBlob();
242+
$clone->clear();
243+
$clone->destroy();
244+
245+
return 'data:image/webp;base64,' . base64_encode($blob);
246+
}
247+
248+
private function isImagickAvailable(): bool
249+
{
250+
return extension_loaded('imagick') && class_exists(Imagick::class);
251+
}
252+
}

backend/app/DomainObjects/Generated/ImageDomainObjectAbstract.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ abstract class ImageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac
2323
final public const CREATED_AT = 'created_at';
2424
final public const UPDATED_AT = 'updated_at';
2525
final public const DELETED_AT = 'deleted_at';
26+
final public const WIDTH = 'width';
27+
final public const HEIGHT = 'height';
28+
final public const AVG_COLOUR = 'avg_colour';
29+
final public const LQIP_BASE64 = 'lqip_base64';
2630

2731
protected int $id;
2832
protected ?int $account_id = null;
@@ -37,6 +41,10 @@ abstract class ImageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac
3741
protected ?string $created_at = 'CURRENT_TIMESTAMP';
3842
protected ?string $updated_at = 'CURRENT_TIMESTAMP';
3943
protected ?string $deleted_at = null;
44+
protected ?int $width = null;
45+
protected ?int $height = null;
46+
protected ?string $avg_colour = null;
47+
protected ?string $lqip_base64 = null;
4048

4149
public function toArray(): array
4250
{
@@ -54,6 +62,10 @@ public function toArray(): array
5462
'created_at' => $this->created_at ?? null,
5563
'updated_at' => $this->updated_at ?? null,
5664
'deleted_at' => $this->deleted_at ?? null,
65+
'width' => $this->width ?? null,
66+
'height' => $this->height ?? null,
67+
'avg_colour' => $this->avg_colour ?? null,
68+
'lqip_base64' => $this->lqip_base64 ?? null,
5769
];
5870
}
5971

@@ -199,4 +211,48 @@ public function getDeletedAt(): ?string
199211
{
200212
return $this->deleted_at;
201213
}
214+
215+
public function setWidth(?int $width): self
216+
{
217+
$this->width = $width;
218+
return $this;
219+
}
220+
221+
public function getWidth(): ?int
222+
{
223+
return $this->width;
224+
}
225+
226+
public function setHeight(?int $height): self
227+
{
228+
$this->height = $height;
229+
return $this;
230+
}
231+
232+
public function getHeight(): ?int
233+
{
234+
return $this->height;
235+
}
236+
237+
public function setAvgColour(?string $avg_colour): self
238+
{
239+
$this->avg_colour = $avg_colour;
240+
return $this;
241+
}
242+
243+
public function getAvgColour(): ?string
244+
{
245+
return $this->avg_colour;
246+
}
247+
248+
public function setLqipBase64(?string $lqip_base64): self
249+
{
250+
$this->lqip_base64 = $lqip_base64;
251+
return $this;
252+
}
253+
254+
public function getLqipBase64(): ?string
255+
{
256+
return $this->lqip_base64;
257+
}
202258
}

backend/app/Resources/Image/ImageResource.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ public function toArray($request): array
2020
'size' => $this->getSize(),
2121
'file_name' => $this->getFileName(),
2222
'mime_type' => $this->getMimeType(),
23-
'type' => $this->getType()
23+
'type' => $this->getType(),
24+
'width' => $this->getWidth(),
25+
'height' => $this->getHeight(),
26+
'avg_colour' => $this->getAvgColour(),
27+
'lqip_base64' => $this->getLqipBase64(),
2428
];
2529
}
2630
}

0 commit comments

Comments
 (0)