Skip to content

Commit c68b28b

Browse files
authored
Create a preview endpoint for watchfaces and watchapps (#54)
Signed-off-by: Stasia Michalska <[email protected]>
1 parent 9df1e65 commit c68b28b

24 files changed

+2882
-3
lines changed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
FROM python:3.6-alpine
2-
RUN apk add --update build-base libffi-dev zlib-dev jpeg-dev
2+
RUN apk add --update build-base libffi-dev zlib-dev jpeg-dev freetype-dev
33
RUN apk add --update postgresql-dev
44
COPY requirements.txt requirements.txt
55
RUN pip install -r requirements.txt

appstore/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from .developer_portal_api import init_app as init_developer_portal_api
2121
from .commands import init_app as init_commands
2222
from .utils import init_app as init_utils
23+
from .image import init_app as init_image
24+
from .locker import locker
2325

2426
app = Flask(__name__)
2527
app.config.update(**config)
@@ -37,6 +39,7 @@
3739
init_dev_portal_api(app)
3840
init_developer_portal_api(app)
3941
init_commands(app)
42+
init_image(app)
4043

4144
@app.route('/heartbeat')
4245
@app.route('/appstore-api/heartbeat')

appstore/api.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import urllib.parse
22

3-
from flask import Blueprint, request, jsonify, abort, url_for
3+
from flask import Blueprint, request, jsonify, abort, url_for, make_response
44
from flask_cors import CORS
55
from sqlalchemy import and_
66

77
from sqlalchemy.orm.exc import NoResultFound
88

9-
from appstore.utils import jsonify_app, asset_fallback, generate_image_url, get_access_token, HARDWARE_SUPPORT
9+
from appstore.utils import jsonify_app, asset_fallback, generate_image_url, get_access_token, plat_dimensions, HARDWARE_SUPPORT
1010
from .models import App, Collection, HomeBanners, Category, db, Release
1111
from .settings import config
12+
from .image import generate_preview_image
1213

1314
parent_app = None
1415
api = Blueprint('api', __name__)
@@ -67,6 +68,25 @@ def apps_by_id(key):
6768
return generate_app_response(app)
6869

6970

71+
@api.route('/apps/id/<key>/preview')
72+
def app_image_by_id(key):
73+
app = App.query.filter_by(id=key).one_or_none()
74+
screenshots = {}
75+
for hw in ['aplite', 'basalt', 'chalk', 'diorite', 'emery', 'flint']:
76+
if hw in app.asset_collections:
77+
screenshot = app.asset_collections[hw].screenshots[0]
78+
if screenshot:
79+
screenshots[hw] = generate_image_url(screenshot, *plat_dimensions[hw], True)
80+
81+
icon = None
82+
if app.type == 'watchapp':
83+
icon = generate_image_url(app.icon_large, 80, 80, True)
84+
png = generate_preview_image(title=app.title, developer=app.developer.name, icon=icon, screenshots=screenshots)
85+
response = make_response(png)
86+
response.headers.set('Content-Type', 'image/png')
87+
return response
88+
89+
7090
@api.route('/apps/dev/<dev>')
7191
def apps_by_dev(dev):
7292
hw = request.args.get('hardware', 'basalt')

appstore/image.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import os
2+
import io
3+
import requests
4+
5+
from flask import Flask
6+
from PIL import Image, ImageDraw, ImageFont, ImageChops
7+
from math import ceil
8+
9+
from .utils import valid_platforms
10+
11+
parent_app = None
12+
13+
canvas_size = (780, 520)
14+
15+
background_color=(255, 71, 0)
16+
overlay_color=(55, 58, 60)
17+
text_color=(255, 255, 255)
18+
19+
overlay_box=(0, 392, 780, 520)
20+
icon_position=(24, 416)
21+
base_title_position=(36, 404)
22+
base_author_position=(36, 460)
23+
base_text_space=530
24+
logo_position=(576, 418)
25+
26+
def platform_borders(platform):
27+
if platform == 'emery':
28+
return {
29+
'image': Image.open(os.path.join(parent_app.static_folder, 'emery-border.png')).convert("RGBA"),
30+
'fallback': Image.open(os.path.join(parent_app.static_folder, 'fallback-emery.png')).convert("RGBA"),
31+
'offset': (65, 79)
32+
}
33+
34+
if platform == 'diorite' or platform == 'flint':
35+
return {
36+
'image': Image.open(os.path.join(parent_app.static_folder, 'diorite-border.png')).convert("RGBA"),
37+
'fallback': Image.open(os.path.join(parent_app.static_folder, 'fallback-bw.png')).convert("RGBA"),
38+
'offset': (54, 110)
39+
}
40+
41+
if platform == 'basalt':
42+
return {
43+
'image': Image.open(os.path.join(parent_app.static_folder, 'basalt-border.png')).convert("RGBA"),
44+
'fallback': Image.open(os.path.join(parent_app.static_folder, 'fallback-basalt.png')).convert("RGBA"),
45+
'offset': (88,111)
46+
}
47+
48+
if platform == 'chalk':
49+
return {
50+
'image': Image.open(os.path.join(parent_app.static_folder, 'chalk-border.png')).convert("RGBA"),
51+
'fallback': Image.open(os.path.join(parent_app.static_folder, 'fallback-chalk.png')).convert("RGBA"),
52+
'offset': (71,105)
53+
}
54+
55+
return {
56+
'image': Image.open(os.path.join(parent_app.static_folder, 'aplite-border.png')).convert("RGBA"),
57+
'fallback': Image.open(os.path.join(parent_app.static_folder, 'fallback-bw.png')).convert("RGBA"),
58+
'offset': (68,106)
59+
}
60+
61+
def preferred_grouping(platforms):
62+
order = [['diorite', 'emery'], ['flint', 'emery'], ['basalt', 'emery'], ['chalk', 'emery'],
63+
['basalt', 'diorite'], ['basalt', 'flint'], ['basalt', 'chalk'], ['basalt', 'aplite'],
64+
['flint'], ['emery'], ['diorite'], ['chalk'], ['basalt'], ['aplite']]
65+
for selection in order:
66+
if len(selection) == len(selection & platforms):
67+
return selection
68+
69+
def load_image_from_url(url, fallback):
70+
try:
71+
resp = requests.get(url, timeout=5)
72+
resp.raise_for_status()
73+
return Image.open(io.BytesIO(resp.content)).convert("RGBA")
74+
except Exception as e:
75+
if fallback:
76+
return fallback
77+
raise e
78+
79+
def draw_text_ellipsized(draw, text, font, xy, max_width):
80+
if draw.textlength(text, font=font) <= max_width:
81+
draw.text(xy, text, font=font, fill=text_color)
82+
return
83+
84+
ellipsis = "…"
85+
ellipsis_width = draw.textlength(ellipsis, font=font)
86+
87+
trimmed = ""
88+
for char in text:
89+
if draw.textlength(trimmed + char, font=font) + ellipsis_width <= max_width:
90+
trimmed += char
91+
else:
92+
break
93+
94+
draw.text(xy, trimmed + ellipsis, font=font, fill=text_color)
95+
96+
def platform_image_in_border(canvas, image_url, top_left, platform):
97+
border = platform_borders(platform)
98+
img = load_image_from_url(image_url, border['fallback'])
99+
100+
if platform == 'chalk':
101+
chalk_mask = Image.open(os.path.join(parent_app.static_folder, 'chalk-mask.png')).convert('L')
102+
img.putalpha(chalk_mask)
103+
104+
ix = top_left[0] + border['offset'][0]
105+
iy = top_left[1] + border['offset'][1]
106+
107+
canvas.alpha_composite(img, (ix, iy))
108+
109+
canvas.alpha_composite(border['image'], top_left)
110+
111+
def generate_preview_image(title, developer, icon, screenshots):
112+
canvas = Image.new("RGBA", canvas_size, background_color)
113+
draw = ImageDraw.Draw(canvas)
114+
115+
logo=Image.open(os.path.join(parent_app.static_folder, 'rebble-appstore-logo.png')).convert('RGBA')
116+
117+
draw.rectangle(overlay_box, fill=overlay_color)
118+
canvas.alpha_composite(logo, logo_position)
119+
120+
platforms = preferred_grouping(screenshots.keys())
121+
start_x = ceil((canvas.width - sum(platform_borders(platform)['image'].width for platform in platforms)) / 2)
122+
123+
for platform in platforms:
124+
platform_image_in_border(
125+
canvas=canvas,
126+
image_url=screenshots[platform],
127+
top_left=(start_x, 0),
128+
platform=platform
129+
)
130+
start_x += platform_borders(platform)['image'].width
131+
132+
title_position = base_title_position
133+
author_position = base_author_position
134+
text_space = base_text_space
135+
136+
icon_image = None
137+
try:
138+
icon_image = load_image_from_url(icon, None)
139+
except Exception as e:
140+
icon_image = None
141+
142+
if icon_image:
143+
icon_mask = Image.open(os.path.join(parent_app.static_folder, 'icon-mask.png')).convert('L')
144+
145+
icon_image.putalpha(ImageChops.multiply(icon_mask, icon_image.split()[3]))
146+
canvas.alpha_composite(icon_image, icon_position)
147+
title_position = (title_position[0] + 88, title_position[1])
148+
author_position = (author_position[0] + 88, author_position[1])
149+
text_space -= 88
150+
151+
font_large = ImageFont.truetype(os.path.join(parent_app.static_folder, 'Lato-Bold.ttf'), 48)
152+
font_small = ImageFont.truetype(os.path.join(parent_app.static_folder, 'Lato-Regular.ttf'), 32)
153+
154+
draw_text_ellipsized(draw, title, font_large, title_position, text_space)
155+
draw_text_ellipsized(draw, developer, font_small, author_position, text_space)
156+
157+
buffer = io.BytesIO()
158+
canvas.save(buffer, format='PNG')
159+
buffer.seek(0)
160+
return buffer.getvalue()
161+
162+
def init_app(app):
163+
global parent_app
164+
parent_app = app

appstore/static/Lato-Bold.ttf

71.6 KB
Binary file not shown.

appstore/static/Lato-Regular.ttf

73.4 KB
Binary file not shown.

appstore/static/aplite-border.png

8.23 KB
Loading

0 commit comments

Comments
 (0)