Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add image aspect ratio for Bluesky #844

Merged
merged 7 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ Standardize function and method names in all modules to `to_as1`, `from_as`, etc
* When a `flag` has multiple objects, use the first one that's an ATProto record.
* Handle URLs more carefully, don't add link facets with invalid `uri`s.
* Populate `blobs` into external embed `thumb`s.
* Parse image blobs and add `aspectRatio` to image record.
* Bug fix: handle HTML links with `title` in `content` correctly.
* Bug fix: handle attachments with no `id` or `url`.
* `to_as1`:
Expand Down
37 changes: 29 additions & 8 deletions granary/bluesky.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
from oauth_dropins.webutil import util
from oauth_dropins.webutil.util import trim_nulls
import requests
from PIL import Image
from io import BytesIO

from . import as1
from .as2 import QUOTE_RE_SUFFIX
Expand All @@ -46,6 +48,8 @@
HANDLE_PATTERN = re.compile(r'^' + HANDLE_REGEX)
DID_WEB_PATTERN = re.compile(r'^did:web:' + HANDLE_REGEX)

MAX_MEDIA_SIZE_BYTES = 5_000_000

# at:// URI regexp
# https://atproto.com/specs/at-uri-scheme#full-at-uri-syntax
# https://atproto.com/specs/record-key#record-key-syntax
Expand Down Expand Up @@ -422,7 +426,7 @@ def base_object(obj):
return {}


def from_as1(obj, out_type=None, blobs=None, client=None,
def from_as1(obj, out_type=None, blobs=None, aspects=None, client=None,
original_fields_prefix=None, as_embed=False):
"""Converts an AS1 object to a Bluesky object.

Expand All @@ -443,6 +447,8 @@ def from_as1(obj, out_type=None, blobs=None, client=None,
``ref``, ``mimeType``, and ``size``) to use in the returned object. If not
provided, or if this doesn't have an ``image`` or similar URL in the input
object, its output blob will be omitted.
aspects (dict): optional mapping from str URL to int (width,height) tuple.
Used to provide aspect ratio in image embeds.
client (Bluesky or lexrpc.Client): optional; if provided, this will be used
to make API calls to PDSes to fetch and populate CIDs for records
referenced by replies, likes, reposts, etc.
Expand Down Expand Up @@ -474,6 +480,8 @@ def from_as1(obj, out_type=None, blobs=None, client=None,
actor = as1.get_object(activity, 'actor')
if blobs is None:
blobs = {}
if aspects is None:
aspects = {}

# validate out_type
if out_type:
Expand Down Expand Up @@ -652,11 +660,17 @@ def from_as1(obj, out_type=None, blobs=None, client=None,
'$type': 'app.bsky.embed.images',
'images': [],
}
images_record_embed['images'].append({
image_record = {
'$type': 'app.bsky.embed.images#image',
'image': blob,
'alt': alt,
})
}
if aspect := aspects.get(url):
image_record['aspectRatio'] = {
'width': aspect[0],
'height': aspect[1]
}
images_record_embed['images'].append(image_record)

# first video => embed
attachments = util.get_list(obj, 'attachments')
Expand Down Expand Up @@ -2172,8 +2186,8 @@ def _create(self, obj, preview=None, include_link=OMIT_LINK, ignore_formatting=F
description=preview_description)

else:
blobs = self.upload_media(images)
post_atp = from_as1(obj, blobs=blobs, client=self)
blobs, aspects = self.upload_media(images)
post_atp = from_as1(obj, blobs=blobs, aspects=aspects, client=self)
post_atp['text'] = content

# facet for link to original post, if any
Expand Down Expand Up @@ -2249,6 +2263,7 @@ def base_object(self, obj):

def upload_media(self, media):
blobs = {}
aspects = {}

for obj in media:
url = util.get_url(obj, key='stream') or util.get_url(obj)
Expand All @@ -2257,14 +2272,20 @@ def upload_media(self, media):

with util.requests_get(url, stream=True) as fetch:
fetch.raise_for_status()
data = BytesIO(util.FileLimiter(fetch.raw, MAX_MEDIA_SIZE_BYTES).read())
content_type = fetch.headers.get('Content-Type', '')
if content_type.startswith("image/"):
with Image.open(data) as image:
aspects[url] = image.size
data.seek(0)
upload = self.client.com.atproto.repo.uploadBlob(
input=fetch.raw,
headers={'Content-Type': fetch.headers['Content-Type']}
input=data,
headers={'Content-Type': content_type}
)

blobs[url] = upload['blob']

return blobs
return blobs, aspects

def truncate(self, *args, type=None, **kwargs):
"""Thin wrapper around :meth:`Source.truncate` that sets default kwargs."""
Expand Down
52 changes: 50 additions & 2 deletions granary/tests/test_bluesky.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Most tests are via files in testdata/.
"""
import copy
import os
from io import BytesIO
from unittest import skip
from unittest.mock import ANY, patch
Expand Down Expand Up @@ -3481,8 +3482,55 @@ def test_preview_with_media(self):
@patch('requests.post')
@patch('requests.get')
def test_create_with_media(self, mock_get, mock_post):
image_path = os.path.join(os.path.dirname(__file__), 'testdata/image.jpg')
with open(image_path, 'rb') as f:
mock_get.return_value = requests_response(
f.read(), headers={'Content-Type': 'image/jpeg'})

at_uri = 'at://did:plc:me/app.bsky.feed.post/abc123'
mock_post.side_effect = [
requests_response({'blob': NEW_BLOB}),
requests_response({'uri': at_uri, 'cid': 'sydddddd'}),
]

self.assert_equals({
'id': at_uri,
'url': 'https://bsky.app/profile/handull/post/abc123',
}, self.bs.create(POST_AS_IMAGES['object']).content)

mock_get.assert_called_with(NEW_BLOB_URL, stream=True, timeout=HTTP_TIMEOUT,
headers={'User-Agent': util.user_agent})
mock_post.assert_any_call(
'https://bsky.social/xrpc/com.atproto.repo.uploadBlob',
json=None,
data=ANY,
headers={
'Authorization': 'Bearer towkin',
'Content-Type': 'image/jpeg',
'User-Agent': util.user_agent,
})
# lexrpc.Client passes a BytesIO as data. sadly requests reads from that
# buffer and then closes it, so we can't check its contents.
# self.assertEqual(b'pic data', repr(mock_post.call_args_list[0][1]['data']))

expected = copy.deepcopy(POST_BSKY_IMAGES)
del expected['fooOriginalText']
del expected['fooOriginalUrl']
expected['embed']['images'][0]['aspectRatio'] = {
'width': 480,
'height': 640
}
self.assert_call(mock_post, 'com.atproto.repo.createRecord', json={
'repo': self.bs.did,
'collection': 'app.bsky.feed.post',
'record': expected,
})

@patch('requests.post')
@patch('requests.get')
def test_create_with_non_image_media(self, mock_get, mock_post):
mock_get.return_value = requests_response(
'pic data', headers={'Content-Type': 'my/pic'})
b'something', headers={'Content-Type': 'video/mpeg'})

at_uri = 'at://did:plc:me/app.bsky.feed.post/abc123'
mock_post.side_effect = [
Expand All @@ -3503,7 +3551,7 @@ def test_create_with_media(self, mock_get, mock_post):
data=ANY,
headers={
'Authorization': 'Bearer towkin',
'Content-Type': 'my/pic',
'Content-Type': 'video/mpeg',
'User-Agent': util.user_agent,
})
# lexrpc.Client passes a BytesIO as data. sadly requests reads from that
Expand Down
Binary file added granary/tests/testdata/image.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ multiformats==0.3.1.post4
multiformats-config==0.3.1
oauthlib==3.2.2
packaging==24.2
Pillow==11.0.0
snarfed marked this conversation as resolved.
Show resolved Hide resolved
pkce==1.0.3
praw==7.8.1
prawcore==2.4.0
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
'mf2util>=0.5.0',
'multiformats>=0.3.1',
'oauth-dropins>=6.4',
'pillow',
'praw>=7.3.0',
'python-dateutil>=2.8',
'requests>=2.22',
Expand Down