Skip to content

Commit 1cbbe62

Browse files
committed
blurhash listener
Signed-off-by: Maxence Lange <maxence@artificial-owl.com>
1 parent c89df97 commit 1cbbe62

File tree

7 files changed

+354
-0
lines changed

7 files changed

+354
-0
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OC\Blurhash\Listener;
6+
7+
use GdImage;
8+
use OC\Blurhash\PhpBlurhash\Blurhash;
9+
use OC\Files\Node\File;
10+
use OCP\EventDispatcher\Event;
11+
use OCP\EventDispatcher\IEventDispatcher;
12+
use OCP\EventDispatcher\IEventListener;
13+
use OCP\FilesMetadata\Event\MetadataBackgroundEvent;
14+
use OCP\FilesMetadata\Event\MetadataLiveEvent;
15+
16+
class GenerateBlurhashMetadata implements IEventListener {
17+
private const RESIZE_BOXSIZE = 300;
18+
19+
private const COMPONENTS_X = 4;
20+
private const COMPONENTS_Y = 3;
21+
22+
public function __construct() {
23+
}
24+
25+
public function handle(Event $event): void {
26+
if (!($event instanceof MetadataLiveEvent)
27+
&& !($event instanceof MetadataBackgroundEvent)) {
28+
return;
29+
}
30+
31+
$file = $event->getNode();
32+
if (!($file instanceof File) || !str_starts_with($file->getMimetype(), 'image/')) {
33+
return;
34+
}
35+
36+
if ($event instanceof MetadataLiveEvent) {
37+
$event->requestBackgroundJob();
38+
39+
return;
40+
}
41+
42+
$image = $this->resizedImageFromFile($file);
43+
$metadata = $event->getMetadata();
44+
$metadata->set('blurhash', $this->generateBlurHash($image));
45+
}
46+
47+
private function resizedImageFromFile(File $file): GdImage {
48+
$image = imagecreatefromstring($file->getContent());
49+
$currX = imagesx($image);
50+
$currY = imagesy($image);
51+
52+
if ($currX > $currY) {
53+
$newX = self::RESIZE_BOXSIZE;
54+
$newY = intval($currY * $newX / $currX);
55+
} else {
56+
$newY = self::RESIZE_BOXSIZE;
57+
$newX = intval($currX * $newY / $currY);
58+
}
59+
60+
$newImage = imagescale($image, $newX, $newY);
61+
if (false !== $newImage) {
62+
$image = $newImage;
63+
}
64+
65+
return $image;
66+
}
67+
68+
public function generateBlurHash(GdImage $image): string {
69+
$width = imagesx($image);
70+
$height = imagesy($image);
71+
72+
$pixels = [];
73+
for ($y = 0; $y < $height; ++$y) {
74+
$row = [];
75+
for ($x = 0; $x < $width; ++$x) {
76+
$index = imagecolorat($image, $x, $y);
77+
$colors = imagecolorsforindex($image, $index);
78+
$row[] = [$colors['red'], $colors['green'], $colors['blue']];
79+
}
80+
81+
$pixels[] = $row;
82+
}
83+
84+
return Blurhash::encode($pixels, self::COMPONENTS_X, self::COMPONENTS_Y);
85+
}
86+
87+
/**
88+
* @param IEventDispatcher $eventDispatcher
89+
*
90+
* @return void
91+
*/
92+
public static function loadListeners(IEventDispatcher $eventDispatcher): void {
93+
$eventDispatcher->addServiceListener(MetadataLiveEvent::class, self::class);
94+
$eventDispatcher->addServiceListener(MetadataBackgroundEvent::class, self::class);
95+
}
96+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace OC\Blurhash\PhpBlurhash;
4+
5+
final class AC {
6+
7+
public static function encode(array $value, float $max_value): float {
8+
$quant_r = static::quantise($value[0] / $max_value);
9+
$quant_g = static::quantise($value[1] / $max_value);
10+
$quant_b = static::quantise($value[2] / $max_value);
11+
return $quant_r * 19 * 19 + $quant_g * 19 + $quant_b;
12+
}
13+
14+
public static function decode(int $value, float $max_value): array {
15+
$quant_r = intdiv($value, 19 * 19);
16+
$quant_g = intdiv($value, 19) % 19;
17+
$quant_b = $value % 19;
18+
19+
return [
20+
static::signPow(($quant_r - 9) / 9, 2) * $max_value,
21+
static::signPow(($quant_g - 9) / 9, 2) * $max_value,
22+
static::signPow(($quant_b - 9) / 9, 2) * $max_value
23+
];
24+
}
25+
26+
private static function quantise(float $value): float {
27+
return floor(max(0, min(18, floor(static::signPow($value, 0.5) * 9 + 9.5))));
28+
}
29+
30+
private static function signPow(float $base, float $exp): float {
31+
$sign = $base <=> 0;
32+
return $sign * pow(abs($base), $exp);
33+
}
34+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace OC\Blurhash\PhpBlurhash;
4+
5+
use InvalidArgumentException;
6+
7+
class Base83 {
8+
private const ALPHABET = [
9+
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D',
10+
'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R',
11+
'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f',
12+
'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
13+
'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', '-', '.',
14+
':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
15+
];
16+
17+
private const BASE = 83;
18+
19+
public static function encode(int $value, int $length): string {
20+
if (intdiv($value, self::BASE ** $length) != 0) {
21+
throw new InvalidArgumentException('Specified length is too short to encode given value.');
22+
}
23+
24+
$result = '';
25+
for ($i = 1; $i <= $length; $i++) {
26+
$digit = intdiv($value, self::BASE ** ($length - $i)) % self::BASE;
27+
$result .= self::ALPHABET[$digit];
28+
}
29+
return $result;
30+
}
31+
32+
public static function decode(string $hash): int {
33+
$result = 0;
34+
foreach (str_split($hash) as $char) {
35+
$result = $result * self::BASE + (int) array_search($char, self::ALPHABET, true);
36+
}
37+
return $result;
38+
}
39+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
namespace OC\Blurhash\PhpBlurhash;
4+
5+
use InvalidArgumentException;
6+
7+
class Blurhash {
8+
9+
public static function encode(array $image, int $components_x = 4, int $components_y = 4, bool $linear = false): string {
10+
if (($components_x < 1 || $components_x > 9) || ($components_y < 1 || $components_y > 9)) {
11+
throw new InvalidArgumentException("x and y component counts must be between 1 and 9 inclusive.");
12+
}
13+
$height = count($image);
14+
$width = count($image[0]);
15+
16+
$image_linear = $image;
17+
if (!$linear) {
18+
$image_linear = [];
19+
for ($y = 0; $y < $height; $y++) {
20+
$line = [];
21+
for ($x = 0; $x < $width; $x++) {
22+
$pixel = $image[$y][$x];
23+
$line[] = [
24+
Color::toLinear($pixel[0]),
25+
Color::toLinear($pixel[1]),
26+
Color::toLinear($pixel[2])
27+
];
28+
}
29+
$image_linear[] = $line;
30+
}
31+
}
32+
33+
$components = [];
34+
$scale = 1 / ($width * $height);
35+
for ($y = 0; $y < $components_y; $y++) {
36+
for ($x = 0; $x < $components_x; $x++) {
37+
$normalisation = $x == 0 && $y == 0 ? 1 : 2;
38+
$r = $g = $b = 0;
39+
for ($i = 0; $i < $width; $i++) {
40+
for ($j = 0; $j < $height; $j++) {
41+
$color = $image_linear[$j][$i];
42+
$basis = $normalisation
43+
* cos(M_PI * $i * $x / $width)
44+
* cos(M_PI * $j * $y / $height);
45+
46+
$r += $basis * $color[0];
47+
$g += $basis * $color[1];
48+
$b += $basis * $color[2];
49+
}
50+
}
51+
52+
$components[] = [
53+
$r * $scale,
54+
$g * $scale,
55+
$b * $scale
56+
];
57+
}
58+
}
59+
60+
$dc_value = DC::encode(array_shift($components) ?: []);
61+
62+
$max_ac_component = 0;
63+
foreach ($components as $component) {
64+
$component[] = $max_ac_component;
65+
$max_ac_component = max ($component);
66+
}
67+
68+
$quant_max_ac_component = (int) max(0, min(82, floor($max_ac_component * 166 - 0.5)));
69+
$ac_component_norm_factor = ($quant_max_ac_component + 1) / 166;
70+
71+
$ac_values = [];
72+
foreach ($components as $component) {
73+
$ac_values[] = AC::encode($component, $ac_component_norm_factor);
74+
}
75+
76+
$blurhash = Base83::encode($components_x - 1 + ($components_y - 1) * 9, 1);
77+
$blurhash .= Base83::encode($quant_max_ac_component, 1);
78+
$blurhash .= Base83::encode($dc_value, 4);
79+
foreach ($ac_values as $ac_value) {
80+
$blurhash .= Base83::encode((int) $ac_value, 2);
81+
}
82+
83+
return $blurhash;
84+
}
85+
86+
public static function decode (string $blurhash, int $width, int $height, float $punch = 1.0, bool $linear = false): array {
87+
if (empty($blurhash) || strlen($blurhash) < 6) {
88+
throw new InvalidArgumentException("Blurhash string must be at least 6 characters");
89+
}
90+
91+
$size_info = Base83::decode($blurhash[0]);
92+
$size_y = intdiv($size_info, 9) + 1;
93+
$size_x = ($size_info % 9) + 1;
94+
95+
$length = strlen($blurhash);
96+
$expected_length = (int) (4 + (2 * $size_y * $size_x));
97+
if ($length !== $expected_length) {
98+
throw new InvalidArgumentException("Blurhash length mismatch: length is {$length} but it should be {$expected_length}");
99+
}
100+
101+
$colors = [DC::decode(Base83::decode(substr($blurhash, 2, 4)))];
102+
103+
$quant_max_ac_component = Base83::decode($blurhash[1]);
104+
$max_value = ($quant_max_ac_component + 1) / 166;
105+
for ($i = 1; $i < $size_x * $size_y; $i++) {
106+
$value = Base83::decode(substr($blurhash, 4 + $i * 2, 2));
107+
$colors[$i] = AC::decode($value, $max_value * $punch);
108+
}
109+
110+
$pixels = [];
111+
for ($y = 0; $y < $height; $y++) {
112+
$row = [];
113+
for ($x = 0; $x < $width; $x++) {
114+
$r = $g = $b = 0;
115+
for ($j = 0; $j < $size_y; $j++) {
116+
for ($i = 0; $i < $size_x; $i++) {
117+
$color = $colors[$i + $j * $size_x];
118+
$basis =
119+
cos((M_PI * $x * $i) / $width) *
120+
cos((M_PI * $y * $j) / $height);
121+
122+
$r += $color[0] * $basis;
123+
$g += $color[1] * $basis;
124+
$b += $color[2] * $basis;
125+
}
126+
}
127+
128+
$row[] = $linear ? [$r, $g, $b] : [
129+
Color::toSRGB($r),
130+
Color::toSRGB($g),
131+
Color::toSRGB($b)
132+
];
133+
}
134+
$pixels[] = $row;
135+
}
136+
137+
return $pixels;
138+
}
139+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace OC\Blurhash\PhpBlurhash;
4+
5+
final class Color {
6+
public static function toLinear(int $value): float {
7+
$value = $value / 255;
8+
return ($value <= 0.04045)
9+
? $value / 12.92
10+
: pow(($value + 0.055) / 1.055, 2.4);
11+
}
12+
13+
public static function tosRGB(float $value): int {
14+
$normalized = max(0, min(1, $value));
15+
$result = ($normalized <= 0.0031308)
16+
? (int) round($normalized * 12.92 * 255 + 0.5)
17+
: (int) round((1.055 * pow($normalized, 1 / 2.4) - 0.055) * 255 + 0.5);
18+
return max(0, min($result, 255));
19+
}
20+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace OC\Blurhash\PhpBlurhash;
4+
5+
final class DC {
6+
7+
public static function encode(array $value): int {
8+
$rounded_r = Color::tosRGB($value[0]);
9+
$rounded_g = Color::tosRGB($value[1]);
10+
$rounded_b = Color::tosRGB($value[2]);
11+
return ($rounded_r << 16) + ($rounded_g << 8) + $rounded_b;
12+
}
13+
14+
public static function decode(int $value): array {
15+
$r = $value >> 16;
16+
$g = ($value >> 8) & 255;
17+
$b = $value & 255;
18+
return [
19+
Color::toLinear($r),
20+
Color::toLinear($g),
21+
Color::toLinear($b)
22+
];
23+
}
24+
}

lib/private/Server.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
use OC\Authentication\LoginCredentials\Store;
6969
use OC\Authentication\Token\IProvider;
7070
use OC\Avatar\AvatarManager;
71+
use OC\Blurhash\Listener\GenerateBlurhashMetadata;
7172
use OC\Collaboration\Collaborators\GroupPlugin;
7273
use OC\Collaboration\Collaborators\MailPlugin;
7374
use OC\Collaboration\Collaborators\RemoteGroupPlugin;
@@ -1479,6 +1480,7 @@ private function connectDispatcher(): void {
14791480
$eventDispatcher->addServiceListener(BeforeUserDeletedEvent::class, BeforeUserDeletedListener::class);
14801481

14811482
FilesMetadataManager::loadListeners($eventDispatcher);
1483+
GenerateBlurhashMetadata::loadListeners($eventDispatcher);
14821484
}
14831485

14841486
/**

0 commit comments

Comments
 (0)