Skip to content

Commit 5497c99

Browse files
authored
Adding aspect ratio handling to images and video objects. Aspect rat… (#19)
* Adding aspect ratio handling to images and video objects. Aspect ratio can now be provided manually or will be automatically determined
1 parent 11d00b0 commit 5497c99

File tree

10 files changed

+547
-14
lines changed

10 files changed

+547
-14
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ name = "blueskysocial"
88

99

1010
dependencies = [
11-
"requests","BeautifulSoup4"
11+
"requests","BeautifulSoup4","pillow==11.2.1","opencv-python==4.11.0.86"
1212
]
1313
[project.optional-dependencies]
1414
dev = [

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ python_requires = >=3.11
2323
install_requires =
2424
requests
2525
BeautifulSoup4
26+
pillow==11.2.1
27+
opencv-python==4.11.0.86
2628

2729
[options.extras_require]
2830
dev =

src/blueskysocial/errors.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,15 @@ class InvalidAttachmentsError(Exception):
113113
Attributes:
114114
None
115115
"""
116+
117+
118+
class UnknownAspectRatioError(Exception):
119+
"""
120+
Exception raised when the aspect ratio of an image is unknown.
121+
122+
This error is intended to be used when an operation requires a known aspect ratio,
123+
but the aspect ratio of the provided image cannot be determined.
124+
125+
Attributes:
126+
None
127+
"""

src/blueskysocial/image.py

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,16 @@
3939
post = Post("Check this out!", with_attachments=image)
4040
"""
4141

42-
from typing import Union, Optional, List, cast
42+
from typing import Union, List, cast, Optional, Tuple, Callable, Dict
4343
from io import BytesIO
4444
import requests
4545
from blueskysocial.api_endpoints import UPLOAD_BLOB, RPC_SLUG, IMAGES_TYPE
4646
from blueskysocial.post_attachment import PostAttachment
47-
from blueskysocial.utils import get_auth_header
47+
from blueskysocial.utils import (
48+
get_auth_header,
49+
get_image_aspect_ratio_spec,
50+
provide_aspect_ratio,
51+
)
4852
from blueskysocial.errors import ImageIsTooLargeError
4953
from blueskysocial.typedefs import ApiPayloadType, PostProtocol, as_str
5054

@@ -120,7 +124,13 @@ class Image(PostAttachment):
120124
post = Post("Photo gallery!", with_attachments=images)
121125
"""
122126

123-
def __init__(self, image: Union[str, BytesIO], alt_text: str) -> None:
127+
def __init__(
128+
self,
129+
image: Union[str, BytesIO],
130+
alt_text: str,
131+
aspect_ratio: Optional[Tuple[int, int]] = None,
132+
require_aspect_ratio: bool = False,
133+
) -> None:
124134
"""
125135
Initialize an Image attachment with source and alternative text.
126136
@@ -159,7 +169,9 @@ def __init__(self, image: Union[str, BytesIO], alt_text: str) -> None:
159169
image = Image(buffer, alt_text="Chart showing sales data")
160170
"""
161171
self._alt_text = alt_text
162-
self._image: Optional[bytes] = self._set_image(image)
172+
self._image = self._set_image(image)
173+
self._aspect_ratio = aspect_ratio
174+
self._require_aspect_ratio = require_aspect_ratio
163175

164176
@property
165177
def alt_text(self) -> str:
@@ -380,7 +392,10 @@ def attach_to_post(self, post: PostProtocol, session: ApiPayloadType) -> None:
380392
# ]
381393
# }
382394
"""
395+
aspect_ratio = provide_aspect_ratio(self)
383396
img_dict: ApiPayloadType = {"alt": self.alt_text, "image": self.build(session)}
397+
if aspect_ratio:
398+
img_dict["aspectRatio"] = aspect_ratio
384399
post.embed["$type"] = IMAGES_TYPE
385400
if "images" not in post.embed:
386401
post.embed["images"] = [img_dict]
@@ -444,7 +459,66 @@ def build(self, session: ApiPayloadType) -> ApiPayloadType:
444459
resp = requests.post(
445460
RPC_SLUG + UPLOAD_BLOB,
446461
headers=headers,
447-
data=self._image,
462+
data=self.data_accessor,
448463
)
449464
resp.raise_for_status()
450465
return cast(ApiPayloadType, resp.json()["blob"])
466+
467+
@property
468+
def data_accessor(self) -> bytes:
469+
"""
470+
Get the raw image data in bytes.
471+
472+
473+
Returns:
474+
bytes: The raw image data in PNG format ready for upload
475+
476+
Examples:
477+
image = Image("photo.jpg", alt_text="A beautiful sunset")
478+
raw_data = image.data_accessor
479+
# raw_data contains the bytes of the image
480+
"""
481+
return self._image
482+
483+
@property
484+
def aspect_ratio(self) -> Optional[Tuple[int, int]]:
485+
"""
486+
Get the aspect ratio of the image if available.
487+
488+
Returns the aspect ratio tuple (width, height) if it was provided during
489+
initialization or calculated. If no aspect ratio is set, returns None.
490+
491+
Returns:
492+
Optional[Tuple[int, int]]: The aspect ratio of the image as a tuple
493+
of (width, height) or None if not set.
494+
495+
"""
496+
return self._aspect_ratio
497+
498+
@property
499+
def require_aspect_ratio(self) -> bool:
500+
"""
501+
Check if the image requires an aspect ratio.
502+
503+
Returns True if the image requires an aspect ratio to be provided,
504+
otherwise returns False. This is used to determine if aspect ratio
505+
validation should be enforced during post attachment.
506+
507+
Returns:
508+
bool: True if aspect ratio is required, False otherwise.
509+
"""
510+
return self._require_aspect_ratio
511+
512+
@property
513+
def aspect_ratio_function(self) -> Callable[[bytes], Optional[Dict[str, int]]]:
514+
"""
515+
Get the function to provide aspect ratio for the image.
516+
517+
Returns a callable that returns the aspect ratio of the image if available.
518+
This allows dynamic aspect ratio retrieval based on the current image state.
519+
520+
Returns:
521+
callable: A function that returns the aspect ratio tuple (width, height)
522+
or None if not set.
523+
"""
524+
return get_image_aspect_ratio_spec

src/blueskysocial/typedefs/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
Typing protocols and type definitions for BlueSky Social.
33
"""
44

5-
from .protocols import ConvoProtocol, DirectMessageProtocol, PostProtocol
5+
from .protocols import (
6+
ConvoProtocol,
7+
DirectMessageProtocol,
8+
PostProtocol,
9+
AspectRatioConsumerProtocol,
10+
)
611
from ._types import RecursiveStrDict, DictStrOrInt, ApiPayloadType
712
from .typecheck import as_str, as_bs4_tag, as_bool, as_int
813

@@ -17,4 +22,5 @@
1722
"as_bs4_tag",
1823
"as_bool",
1924
"as_int",
25+
"AspectRatioConsumerProtocol",
2026
]

src/blueskysocial/typedefs/protocols.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Protocol definitions for BlueSky Social types.
33
"""
44

5-
from typing import Protocol
5+
from typing import Protocol, Callable, Dict, Optional, Tuple, Any
66
import datetime as dt
77
from typing_extensions import runtime_checkable
88
from blueskysocial.typedefs._types import ApiPayloadType
@@ -146,3 +146,53 @@ def embed(self) -> ApiPayloadType:
146146
Dict[str, str]: The embedded data.
147147
"""
148148
...
149+
150+
151+
class AspectRatioConsumerProtocol(Protocol):
152+
"""
153+
Protocol defining the interface for an object that consumes aspect ratio data.
154+
155+
This protocol defines the expected attributes and methods that an object
156+
should have to be compatible with aspect ratio specifications.
157+
"""
158+
159+
@property
160+
def data_accessor(self) -> str | bytes:
161+
"""
162+
The data accessor for the aspect ratio consumer. Provides the data that can be used to determine the aspect ratio.
163+
164+
Returns:
165+
str|bytes: The data accessor, which can be a string or bytes.
166+
"""
167+
...
168+
169+
@property
170+
def aspect_ratio_function(self) -> Callable[[Any], Optional[Dict[str, int]]]:
171+
"""
172+
The function used to calculate the aspect ratio from the data accessor.
173+
This function should take the data accessor as input and return a dictionary
174+
containing the width and height of the aspect ratio.
175+
Returns:
176+
Callable[[str|bytes], Dict[str, int]]: A function that takes the data accessor and returns a dictionary with width and height.
177+
"""
178+
...
179+
180+
@property
181+
def aspect_ratio(self) -> Optional[Tuple[int, int]]:
182+
"""
183+
The aspect ratio of the data accessor.
184+
185+
Returns:
186+
Optional[Tuple[int, int]]: A tuple containing the width and height of the aspect ratio, or None if not applicable.
187+
"""
188+
...
189+
190+
@property
191+
def require_aspect_ratio(self) -> bool:
192+
"""
193+
Whether the aspect ratio is required for the consumer.
194+
195+
Returns:
196+
bool: True if the aspect ratio is required, False otherwise.
197+
"""
198+
...

src/blueskysocial/utils.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
from typing import Dict, Optional, Union
66
from bs4 import Tag
77
from bs4.element import NavigableString
8+
from PIL import Image
9+
from io import BytesIO
10+
import cv2
11+
from blueskysocial.typedefs import AspectRatioConsumerProtocol
12+
from blueskysocial.errors import UnknownAspectRatioError
813

914

1015
def parse_uri(uri: str) -> Dict[str, str]:
@@ -60,3 +65,94 @@ def bs4_tag_extract_content(tag: Optional[Union[Tag, NavigableString]]) -> str:
6065
content = tag.get("content")
6166
return str(content) if content is not None else ""
6267
return ""
68+
69+
70+
def get_image_aspect_ratio_spec(image: bytes) -> Optional[Dict[str, int]]:
71+
"""
72+
Returns the width and height of an image from its byte content.
73+
Args:
74+
image (bytes): The image data in bytes.
75+
Returns:
76+
Optional[Dict[str, int]]: A dictionary containing the width and height of the image,
77+
or None if the image cannot be opened or processed.
78+
"""
79+
80+
try:
81+
with Image.open(BytesIO(image)) as img:
82+
width, height = img.size
83+
return {
84+
"width": width,
85+
"height": height,
86+
}
87+
except Exception:
88+
return None
89+
90+
91+
def get_video_aspect_ratio_spec(path: str) -> Optional[Dict[str, int]]:
92+
"""
93+
Get the aspect ratio specification (width and height) of a video file.
94+
Args:
95+
path (str): The file path to the video file.
96+
Returns:
97+
Optional[Dict[str, int]]: A dictionary containing 'width' and 'height' keys
98+
with integer values representing the video dimensions,
99+
or None if the video cannot be opened or an error occurs.
100+
Raises:
101+
ValueError: If the video file cannot be opened (caught internally and returns None).
102+
Example:
103+
>>> specs = get_video_aspect_ratio_spec("/path/to/video.mp4")
104+
>>> if specs:
105+
... print(f"Video dimensions: {specs['width']}x{specs['height']}")
106+
"""
107+
108+
try:
109+
cap = cv2.VideoCapture(path)
110+
if not cap.isOpened():
111+
raise ValueError(f"Cannot open video: {path}")
112+
width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
113+
height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
114+
cap.release()
115+
return {
116+
"width": int(width),
117+
"height": int(height),
118+
}
119+
except Exception:
120+
return None
121+
122+
123+
def provide_aspect_ratio(
124+
consumer: AspectRatioConsumerProtocol,
125+
) -> Optional[Dict[str, int]]:
126+
"""
127+
Provide aspect ratio information for a consumer object.
128+
This function attempts to retrieve aspect ratio information from a consumer object
129+
by first checking if the consumer has a predefined aspect ratio, and if not,
130+
calling the consumer's aspect ratio function to compute it dynamically.
131+
Args:
132+
consumer (AspectRatioConsumerProtocol): An object that implements the
133+
AspectRatioConsumerProtocol, containing aspect ratio information or
134+
methods to compute it.
135+
Returns:
136+
Optional[Dict[str, int]]: A dictionary containing 'width' and 'height'
137+
keys with integer values representing the aspect ratio, or None if
138+
the aspect ratio cannot be determined and is not required.
139+
Raises:
140+
UnknownAspectRatioError: If the aspect ratio cannot be determined and
141+
the consumer requires an aspect ratio (consumer.require_aspect_ratio
142+
is True).
143+
Note:
144+
The function prioritizes the consumer's predefined aspect_ratio attribute
145+
over the computed aspect ratio from the aspect_ratio_function.
146+
"""
147+
148+
if consumer.aspect_ratio is not None:
149+
return {"width": consumer.aspect_ratio[0], "height": consumer.aspect_ratio[1]}
150+
151+
aspect_ratio = consumer.aspect_ratio_function(consumer.data_accessor)
152+
if aspect_ratio is None and consumer.require_aspect_ratio:
153+
raise UnknownAspectRatioError(
154+
f"{consumer.__class__.__name__} aspect ratio could not be determined. "
155+
"Please provide a valid aspect ratio function or data accessor."
156+
"or provide a valid aspect ratio at construction time using the aspect_ratio parameter."
157+
)
158+
return aspect_ratio

0 commit comments

Comments
 (0)