Coverage for functions \ flipdare \ core \ hash_generator.py: 95%
55 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
1#!/usr/bin/env python
2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved.
3#
4# This file is part of Flipdare's proprietary software and contains
5# confidential and copyrighted material. Unauthorised copying,
6# modification, distribution, or use of this file is strictly
7# prohibited without prior written permission from Flipdare Pty Ltd.
8#
9# This software includes third-party components licensed under MIT,
10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details.
11#
14from io import BytesIO
15from pathlib import Path
17from google.cloud.storage.bucket import Bucket as StorageBucket
18import blurhash as hasher
19from PIL import Image
21from flipdare.app_log import LOG
22from flipdare.core.storage_file_type import StorageFileType
23from flipdare.util.file_util import FileUtil
26class HashGenerator:
28 def __init__(self, bucket: StorageBucket | None, url: str, width: int, height: int) -> None:
29 self._bucket = bucket
30 self._width = width
31 self._height = height
32 self._url = url
33 self._is_local = url.startswith(("/tmp/", "file://")) # noqa: S108
35 @classmethod
36 def from_bucket(
37 cls,
38 bucket: StorageBucket,
39 url: str,
40 width: int,
41 height: int,
42 ) -> "HashGenerator":
43 return cls(bucket=bucket, url=url, width=width, height=height)
45 @classmethod
46 def from_local_file(cls, local_file_path: str, width: int, height: int) -> "HashGenerator":
47 return cls(bucket=None, url=local_file_path, width=width, height=height)
49 @staticmethod
50 def calculate_ac_components(w: int, h: int) -> tuple[int, int]:
51 # calculate the number of components for blurhash based on aspect ratio
52 # between 1 and 9 for x and y (9 means more detail)
53 # baseline 83x83 pixels = 3x3 components
54 scaled_width = max(1, min(9, round(w / 83)))
55 scaled_height = max(1, min(9, round(h / 83)))
56 return scaled_width, scaled_height
58 def generate_hash(self) -> str | None:
59 image_bytes: bytes | None = None
61 if self._is_local:
62 LOG().debug(f"Loading local image file for hash generation: {self._url}")
63 with Path(self._url.replace("file://", "")).open("rb") as f:
64 image_bytes = f.read()
65 else:
66 LOG().debug(f"Downloading image from URL for hash generation: {self._url}")
67 if self._bucket is None:
68 LOG().error(f"Bucket is None, cannot download image from URL: {self._url}")
69 return None
71 from flipdare.backend.app_storage_client import AppStorageClient
73 sfs = FileUtil.create_file(
74 gs_url=self._url,
75 uid="hash_generator",
76 file_type=StorageFileType.IMAGE,
77 )
78 storage_util = AppStorageClient(self._bucket)
79 image_bytes = storage_util.download_to_memory(sfs)
81 if image_bytes is None:
82 LOG().error(f"Failed to download image data for URL: {self._url}")
83 return None
85 image_stream = BytesIO(image_bytes)
86 w = self._width
87 h = self._height
89 img = Image.open(image_stream)
90 img_copy = img.copy()
91 img_copy = img_copy.convert("RGB") # Ensure image is in RGB format
92 thumbnail_size = (w, h)
93 img_copy.thumbnail(thumbnail_size)
95 x_comp, y_comp = self.calculate_ac_components(w, h)
96 hash_value = hasher.encode(img_copy, x_comp, y_comp)
98 LOG().info(f"Generated blurhash: {hash_value} IMG: {w}x{h} AR: {x_comp}:{y_comp}")
99 return str(hash_value)