Check PR / check (pull_request) Successful in 59s
Adds ios/header, ios/search-results, ios/iphone-6.3in and ios/iphone-fold-* to compose.py and the README upload table. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
378 lines
18 KiB
Python
378 lines
18 KiB
Python
"""Compose store artwork from production Flutter widget captures.
|
||
|
||
Run from the repository root with Python and Pillow installed (see README).
|
||
Colors and fonts are the app's own design tokens (warm neutrals, one violet
|
||
accent, Schibsted Grotesk / Instrument Sans) - flat shapes, no gradients.
|
||
"""
|
||
from pathlib import Path
|
||
import json
|
||
import textwrap
|
||
import zipfile
|
||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||
|
||
ROOT = Path(__file__).resolve().parents[3]
|
||
OUT = ROOT / 'distribution/store-assets'
|
||
FONTS = ROOT / 'assets/google_fonts'
|
||
|
||
THEMES = {
|
||
'light': dict(bg='#F3EFE6', fg='#0E0D0B', fg2='#5A5852', accent='#8D0DE3',
|
||
accent_text='#8D0DE3', soft='#ECDFFF', line='#D9D5CA', shadow=70),
|
||
# Dark slides use the `surface` token as the page color so the app's own
|
||
# near-black `bg` reads as a distinct device screen on top of it.
|
||
'dark': dict(bg='#25241F', fg='#F3EFE6', fg2='#BDBAB1', accent='#8D0DE3',
|
||
accent_text='#CBA5FD', soft='#2F0454', line='#5A5852', shadow=150),
|
||
}
|
||
|
||
# The five features on the website's Features section, in the same order.
|
||
# `back`/`front`: a full-screen capture with an optional settings card
|
||
# layered over it. Copy sticks to what the app does; no store-specific claims.
|
||
STORIES = [
|
||
dict(key='accounts', theme='light', back='accounts', front=None,
|
||
eyebrow='MULTIPLE ACCOUNTS',
|
||
lines=('Every cloud,', 'one tap apart.'),
|
||
sub='Personal, work and family servers together.'),
|
||
dict(key='locks', theme='dark', back='lock', front='security', card_at=.66, card_at_l=.66,
|
||
eyebrow='LOCKS',
|
||
lines=('Locks where', 'you want them.'),
|
||
sub='Protect opening Noo, switching accounts and hidden files with your device PIN or biometrics.'),
|
||
dict(key='sync', theme='light', back='offline', front='sync', card_at=.50,
|
||
eyebrow='OFFLINE SYNC',
|
||
lines=('Your files,', 'even offline.'),
|
||
sub='Mirror folders to your device and keep them up to date in the background.'),
|
||
dict(key='sharing', theme='dark', back='sharing', front=None,
|
||
eyebrow='SHARING',
|
||
lines=('Sharing that feels', 'like Nextcloud.'),
|
||
sub='People, groups and public links in one sheet, or send the file itself.'),
|
||
dict(key='external', theme='light', back='external', front=None,
|
||
eyebrow='EXTERNAL STORAGE',
|
||
lines=('Your NAS, S3 and', 'SFTP, built in.'),
|
||
sub='Mounted storage shows up in Files like any other folder.'),
|
||
]
|
||
|
||
# (output folder, capture device, canvas size, kind)
|
||
TARGETS = [
|
||
('android/phone', 'android', (1080, 1920), 'phone'),
|
||
('android/tablet-7in', 'atablet', (1200, 1920), 'tablet'),
|
||
('android/tablet-10in', 'atablet', (1600, 2560), 'tablet'),
|
||
('ios/iphone', 'iphone', (1290, 2796), 'phone'),
|
||
('ios/ipad', 'ipad', (2064, 2752), 'tablet'),
|
||
# Landscape tablets show the app's real sidebar layout.
|
||
('android/tablet-7in-landscape', 'atabletl', (1920, 1200), 'landscape'),
|
||
('android/tablet-10in-landscape', 'atabletl', (2560, 1600), 'landscape'),
|
||
('ios/ipad-landscape', 'ipadl', (2752, 2064), 'landscape'),
|
||
# Newer App Store slots. iPad 13" (2064x2752, 2752x2064) is the two above.
|
||
('ios/iphone-6.3in', 'iphone', (1206, 2622), 'phone'),
|
||
('ios/iphone-fold-portrait', 'ipad', (2007, 2853), 'tablet'),
|
||
('ios/iphone-fold-landscape', 'ipadl', (2853, 2007), 'landscape'),
|
||
]
|
||
|
||
# App Store product-page banners: (folder, file name, size).
|
||
HEROES = [
|
||
('ios/header', 'header-5244x2950.png', (5244, 2950)),
|
||
('ios/header', 'header-3840x1646.png', (3840, 1646)),
|
||
('ios/search-results', 'search-5244x2950.png', (5244, 2950)),
|
||
('ios/search-results', 'search-3840x2560.png', (3840, 2560)),
|
||
('ios/search-results', 'search-1920x1280.png', (1920, 1280)),
|
||
]
|
||
|
||
|
||
def font(size, weight='SemiBold', family='SchibstedGrotesk'):
|
||
return ImageFont.truetype(str(FONTS / f'{family}-{weight}.ttf'), size)
|
||
|
||
|
||
def body_font(size, weight='Medium'):
|
||
return font(size, weight, 'InstrumentSans')
|
||
|
||
|
||
def spaced(draw, xy, text, fnt, fill, tracking):
|
||
x, y = xy
|
||
for ch in text:
|
||
draw.text((x, y), ch, font=fnt, fill=fill)
|
||
x += draw.textlength(ch, font=fnt) + tracking
|
||
|
||
|
||
def fit(draw, text, make_font, size, max_width):
|
||
while size > 8 and draw.textlength(text, font=make_font(size)) > max_width:
|
||
size -= 1
|
||
return make_font(size), size
|
||
|
||
|
||
def background(size, t):
|
||
w, h = size
|
||
im = Image.new('RGB', size, t['bg'])
|
||
d = ImageDraw.Draw(im)
|
||
d.ellipse((w * .42, h * .40, w * 1.62, h * 1.22), fill=t['soft'])
|
||
d.ellipse((-w * .75, h * .70, w * .55, h * 1.40), outline=t['line'], width=max(2, w // 400))
|
||
return im
|
||
|
||
|
||
def rounded_mask(size, radius):
|
||
mask = Image.new('L', size, 0)
|
||
ImageDraw.Draw(mask).rounded_rectangle((0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255)
|
||
return mask
|
||
|
||
|
||
def content_crop(path, pad=14):
|
||
"""Crop a settings-card capture to its content (the capture fills the viewport)."""
|
||
image = Image.open(path).convert('RGB')
|
||
background = image.getpixel((0, image.height - 1))
|
||
rows = [y for y in range(image.height)
|
||
if any(image.getpixel((x, y)) != background for x in range(0, image.width, 6))]
|
||
return image.crop((0, 0, image.width, min(image.height, rows[-1] + pad)))
|
||
|
||
|
||
def paste_panel(canvas, source, box, radius, t, bleed=False):
|
||
"""Paste a capture inside box (x, y, w, h), contained unless `bleed`.
|
||
|
||
Contained captures are never cropped or distorted. `bleed` scales to the
|
||
box width and lets the canvas edge clip the bottom (feature graphic only).
|
||
"""
|
||
x, y, w, h = box
|
||
ui = source if isinstance(source, Image.Image) else Image.open(source).convert('RGB')
|
||
factor = w / ui.width if bleed else min(w / ui.width, h / ui.height)
|
||
sw, sh = round(ui.width * factor), round(ui.height * factor)
|
||
x += (w - sw) // 2
|
||
ui = ui.resize((sw, sh), Image.Resampling.LANCZOS)
|
||
shadow = Image.new('RGBA', canvas.size, (0, 0, 0, 0))
|
||
ImageDraw.Draw(shadow).rounded_rectangle(
|
||
(x, y + radius // 3, x + sw, y + sh + radius // 3), radius=radius, fill=(0, 0, 0, t['shadow']))
|
||
shadow = shadow.filter(ImageFilter.GaussianBlur(max(10, canvas.width // 60)))
|
||
canvas.paste(shadow, (0, 0), shadow)
|
||
ImageDraw.Draw(canvas).rounded_rectangle(
|
||
(x - 2, y - 2, x + sw + 1, y + sh + 1), radius=radius + 2, fill=t['line'])
|
||
canvas.paste(ui, (x, y), rounded_mask((sw, sh), radius))
|
||
return x, y, sw, sh
|
||
|
||
|
||
def landscape_canvas(story, size, device):
|
||
"""Text column on the left, the real tablet UI on the right."""
|
||
t = THEMES[story['theme']]
|
||
w, h = size
|
||
im = background(size, t)
|
||
d = ImageDraw.Draw(im)
|
||
margin = round(h * .07)
|
||
colw = round(w * .30)
|
||
y = round(h * .27)
|
||
spaced(d, (margin, y), story['eyebrow'], font(round(h * .022), 'Bold', 'InstrumentSans'),
|
||
t['accent_text'], h * .004)
|
||
y += round(h * .06)
|
||
head = round(h * .068)
|
||
for index, line in enumerate(story['lines']):
|
||
fnt, _ = fit(d, line, font, head, colw)
|
||
d.text((margin, y), line, font=fnt, fill=t['accent_text'] if index else t['fg'])
|
||
y += round(head * 1.12)
|
||
y += round(head * .2)
|
||
sub_size = round(h * .030)
|
||
fnt = body_font(sub_size, 'Regular')
|
||
for line in textwrap.wrap(story['sub'], max(16, int(colw / (sub_size * .52)))):
|
||
d.text((margin, y), line, font=fnt, fill=t['fg2'])
|
||
y += round(sub_size * 1.4)
|
||
x0 = margin + colw + round(w * .035)
|
||
src = lambda name: OUT / f'source/{device}/{name}.png'
|
||
back = Image.open(src(story['back']))
|
||
aw, ah = w - x0 - margin, h - margin * 2
|
||
factor = min(aw / back.width, ah / back.height)
|
||
# Centered vertically; a layered card hangs below the panel's bottom edge.
|
||
area = (x0, round((h - back.height * factor) / 2) - (round(h * .03) if story['front'] else 0), aw, ah)
|
||
radius = round(h * .03)
|
||
bx, by, bw, bh = paste_panel(im, src(story['back']), area, radius, t)
|
||
if story['front']:
|
||
card = content_crop(src(story['front']))
|
||
scale = bw / Image.open(src(story['back'])).width
|
||
cw, ch = round(card.width * scale), round(card.height * scale)
|
||
cx = bx + round(bw * .40)
|
||
cy = by + round(bh * story.get('card_at_l', story['card_at']))
|
||
paste_panel(im, card, (cx, cy, cw, ch), round(radius * .6), t)
|
||
return im
|
||
|
||
|
||
def story_canvas(story, size, kind, device):
|
||
if kind == 'landscape':
|
||
return landscape_canvas(story, size, device)
|
||
t = THEMES[story['theme']]
|
||
w, h = size
|
||
im = background(size, t)
|
||
d = ImageDraw.Draw(im)
|
||
margin = round(w * .075)
|
||
wide = w / h > .64
|
||
head = round(w * (.060 if kind == 'tablet' and wide else .066 if kind == 'tablet' else .078))
|
||
y = round(h * .045)
|
||
spaced(d, (margin, y), story['eyebrow'], font(round(w * .022), 'Bold', 'InstrumentSans'),
|
||
t['accent_text'], w * .004)
|
||
y += round(w * .06)
|
||
for index, line in enumerate(story['lines']):
|
||
fnt, used = fit(d, line, font, head, w - margin * 2)
|
||
d.text((margin, y), line, font=fnt, fill=t['accent_text'] if index else t['fg'])
|
||
y += round(head * 1.12)
|
||
y += round(head * .18)
|
||
sub_size = round(w * (.026 if kind == 'tablet' else .033))
|
||
fnt = body_font(sub_size, 'Regular')
|
||
chars = max(20, int((w - margin * 2) / (sub_size * .52)))
|
||
for line in textwrap.wrap(story['sub'], chars):
|
||
d.text((margin, y), line, font=fnt, fill=t['fg2'])
|
||
y += round(sub_size * 1.4)
|
||
area = (margin, y + round(h * .03), w - margin * 2, h - y - round(h * .03) - round(h * .03))
|
||
src = lambda name: OUT / f'source/{device}/{name}.png'
|
||
radius = round(w * (.04 if kind == 'tablet' else .052))
|
||
if story['front']:
|
||
ax, ay, aw, ah = area
|
||
bx, by, bw, bh = paste_panel(im, src(story['back']), (ax, ay, int(aw * .82), ah), radius, t)
|
||
card = content_crop(src(story['front']))
|
||
cw = round(bw * .94)
|
||
ch = round(card.height * cw / card.width)
|
||
cx = min(w - margin - cw, bx + round(bw * .30))
|
||
cy = by + round(bh * story['card_at'])
|
||
paste_panel(im, card, (cx, cy, cw, ch), round(radius * .55), t)
|
||
else:
|
||
paste_panel(im, src(story['back']), area, radius, t)
|
||
return im
|
||
|
||
|
||
def hero_canvas(size):
|
||
"""Brand banner: name and tagline left, iPad (landscape) and iPhone right."""
|
||
t = THEMES['light']
|
||
w, h = size
|
||
im = background(size, t)
|
||
d = ImageDraw.Draw(im)
|
||
margin = round(min(w * .06, h * .13))
|
||
colw = round(w * (.30 if w / h > 2 else .34))
|
||
# Text column, vertically centered as a block.
|
||
icon_px = round(h * .105)
|
||
head = round(h * .092)
|
||
sub_size = round(h * .034)
|
||
block = icon_px + round(h * .05) + round(head * 2.35) + round(sub_size * 4.4)
|
||
y = (h - block) // 2
|
||
icon = Image.open(ROOT / 'assets/icon/app_icon.png').convert('RGBA').resize((icon_px, icon_px), Image.Resampling.LANCZOS)
|
||
im.paste(icon, (margin, y), rounded_mask((icon_px, icon_px), round(icon_px * .24)))
|
||
spaced(d, (margin + icon_px + round(h * .025), y + icon_px // 2 - round(h * .02)), 'NOOCLOUD',
|
||
font(round(h * .034), 'Bold', 'InstrumentSans'), t['accent_text'], h * .006)
|
||
y += icon_px + round(h * .05)
|
||
for index, line in enumerate(('The Nextcloud app', 'that just works.')):
|
||
fnt, _ = fit(d, line, font, head, colw)
|
||
d.text((margin, y), line, font=fnt, fill=t['accent_text'] if index else t['fg'])
|
||
y += round(head * 1.12)
|
||
y += round(head * .2)
|
||
fnt = body_font(sub_size, 'Regular')
|
||
chars = max(16, int(colw / (sub_size * .52)))
|
||
for line in textwrap.wrap('Multiple accounts, locks, offline sync, sharing and external storage.', chars):
|
||
d.text((margin, y), line, font=fnt, fill=t['fg2'])
|
||
y += round(sub_size * 1.4)
|
||
# Devices: iPad landscape with the iPhone overlapping its bottom-right corner.
|
||
x0 = margin + colw + round(w * .03)
|
||
region_w = w - x0 - margin
|
||
ipad_h = round(h * .72)
|
||
ipad_w = round(ipad_h * 4 / 3)
|
||
phone_h = round(ipad_h * 1.02)
|
||
phone_w = round(phone_h * 0.4614)
|
||
total = ipad_w + round(phone_w * .7)
|
||
if total > region_w:
|
||
k = region_w / total
|
||
ipad_h, ipad_w, phone_h, phone_w = (round(v * k) for v in (ipad_h, ipad_w, phone_h, phone_w))
|
||
total = ipad_w + round(phone_w * .7)
|
||
x = x0 + (region_w - total) // 2
|
||
top = (h - ipad_h) // 2 - round(h * .03)
|
||
paste_panel(im, OUT / 'source/ipadl/external.png', (x, top, ipad_w, ipad_h), round(h * .028), t)
|
||
paste_panel(im, OUT / 'source/iphone/offline.png',
|
||
(x + ipad_w - round(phone_w * .3), top + ipad_h - phone_h + round(h * .07), phone_w, phone_h),
|
||
round(h * .035), t)
|
||
return im
|
||
|
||
|
||
def check(path, size, mode):
|
||
image = Image.open(path)
|
||
assert image.size == tuple(size) and image.mode == mode, path
|
||
assert path.stat().st_size < 8 * 1024 * 1024, path
|
||
|
||
|
||
manifest = []
|
||
for folder, device, size, kind in TARGETS:
|
||
dest = OUT / folder
|
||
dest.mkdir(parents=True, exist_ok=True)
|
||
for previous in dest.glob('*.png'):
|
||
previous.unlink()
|
||
for index, story in enumerate(STORIES, 1):
|
||
im = story_canvas(story, size, kind, device)
|
||
path = dest / f'{index:02d}-{story["key"]}.png'
|
||
im.save(path, optimize=True)
|
||
manifest.append({'file': str(path.relative_to(OUT)), 'size': list(size), 'mode': 'RGB',
|
||
'story': story['key']})
|
||
|
||
for folder, name, size in HEROES:
|
||
dest = OUT / folder
|
||
dest.mkdir(parents=True, exist_ok=True)
|
||
path = dest / name
|
||
hero_canvas(size).save(path, optimize=True)
|
||
manifest.append({'file': str(path.relative_to(OUT)), 'size': list(size), 'mode': 'RGB', 'story': 'banner'})
|
||
|
||
# Store icons: Play wants a 512 px PNG, App Store Connect takes the icon from
|
||
# the uploaded build (this 1024 px copy is only a reference).
|
||
for platform, px, source in [
|
||
('android', 512, ROOT / 'assets/icon/app_icon.png'),
|
||
('ios', 1024, ROOT / 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png'),
|
||
]:
|
||
mode = 'RGBA' if platform == 'android' else 'RGB'
|
||
path = OUT / f'{platform}/icon-{px}.png'
|
||
Image.open(source).convert(mode).resize((px, px), Image.Resampling.LANCZOS).save(path, optimize=True)
|
||
manifest.append({'file': str(path.relative_to(OUT)), 'size': [px, px], 'mode': mode})
|
||
|
||
# Play feature graphic (1024 x 500): headline left, two real captures right.
|
||
t = THEMES['light']
|
||
fg = background((1024, 500), t)
|
||
d = ImageDraw.Draw(fg)
|
||
icon = Image.open(ROOT / 'assets/icon/app_icon.png').convert('RGBA').resize((64, 64), Image.Resampling.LANCZOS)
|
||
fg.paste(icon, (56, 52), rounded_mask((64, 64), 16))
|
||
spaced(d, (136, 76), 'NOO', font(22, 'Bold', 'InstrumentSans'), t['accent_text'], 3)
|
||
d.text((56, 160), 'The Nextcloud app', font=font(54), fill=t['fg'])
|
||
d.text((56, 222), 'that just works.', font=font(54), fill=t['accent_text'])
|
||
d.text((58, 312), 'Multiple accounts, locks, offline sync,', font=body_font(22, 'Regular'), fill=t['fg2'])
|
||
d.text((58, 344), 'sharing and external storage.', font=body_font(22, 'Regular'), fill=t['fg2'])
|
||
paste_panel(fg, OUT / 'source/android/accounts.png', (560, 70, 220, 560), 26, t, bleed=True)
|
||
paste_panel(fg, OUT / 'source/android/sharing.png', (770, 120, 220, 560), 26, t, bleed=True)
|
||
fg.save(OUT / 'android/feature-graphic-1024x500.png', optimize=True)
|
||
manifest.append({'file': 'android/feature-graphic-1024x500.png', 'size': [1024, 500], 'mode': 'RGB'})
|
||
|
||
for asset in manifest:
|
||
check(OUT / asset['file'], asset['size'], asset['mode'])
|
||
(OUT / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||
|
||
# Review materials (not for upload): contact sheet and a gallery page.
|
||
contact = Image.new('RGB', (1920, 2160), '#1c1b18')
|
||
cd = ImageDraw.Draw(contact)
|
||
cd.text((32, 20), 'Noo / store listing artwork', font=font(28), fill='#F3EFE6')
|
||
rows = [('ANDROID PHONE', 'android/phone'), ('ANDROID 10" TABLET', 'android/tablet-10in'),
|
||
('IPHONE', 'ios/iphone'), ('IPAD', 'ios/ipad'),
|
||
('ANDROID 10" LANDSCAPE', 'android/tablet-10in-landscape'), ('IPAD LANDSCAPE', 'ios/ipad-landscape')]
|
||
for row, (label, folder) in enumerate(rows):
|
||
cd.text((32, 75 + row * 360), label, font=font(15, 'Bold', 'InstrumentSans'), fill='#CBA5FD')
|
||
for col, path in enumerate(sorted((OUT / folder).glob('*.png'))):
|
||
thumb = Image.open(path)
|
||
thumb.thumbnail((330, 330), Image.Resampling.LANCZOS)
|
||
contact.paste(thumb, (30 + col * 372 + (330 - thumb.width) // 2, 100 + row * 360))
|
||
contact.save(OUT / 'contact-sheet.jpg', quality=92)
|
||
|
||
cards = ''.join(
|
||
f'<figure><img src="{a["file"]}" loading="lazy"><figcaption>{a["file"]} · {a["size"][0]} × {a["size"][1]}</figcaption></figure>'
|
||
for a in manifest)
|
||
(OUT / 'index.html').write_text(
|
||
'<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||
'<title>Noo store assets</title><style>body{margin:0;padding:40px;background:#F3EFE6;color:#0E0D0B;font:16px system-ui}'
|
||
'h1{font-size:36px}p{color:#5A5852}main{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:24px}'
|
||
'figure{margin:0}img{width:100%;max-height:680px;object-fit:contain;object-position:top;background:#ECDFFF;border-radius:12px}'
|
||
'figcaption{padding:12px 0;font-size:13px;color:#8D0DE3}</style><h1>Noo / store listing assets</h1>'
|
||
'<p>English · production Flutter UI · fictional offline demo data.</p><main>' + cards + '</main></html>')
|
||
|
||
# Validate copy limits and package only the assets intended for upload.
|
||
copy = json.loads((OUT / 'listing-copy.json').read_text())
|
||
for platform, field, limit in [
|
||
('google_play', 'title', 30), ('google_play', 'short_description', 80),
|
||
('google_play', 'description', 4000), ('app_store', 'name', 30),
|
||
('app_store', 'subtitle', 30), ('app_store', 'promotional_text', 170),
|
||
('app_store', 'keywords', 100), ('app_store', 'description', 4000)]:
|
||
assert len(copy[platform][field]) <= limit, (platform, field, len(copy[platform][field]))
|
||
with zipfile.ZipFile(OUT / 'store-listing-assets.zip', 'w', zipfile.ZIP_DEFLATED) as bundle:
|
||
for asset in manifest:
|
||
bundle.write(OUT / asset['file'], asset['file'])
|
||
for name in ['README.md', 'manifest.json', 'listing-copy.json']:
|
||
bundle.write(OUT / name, name)
|
||
print(f'Created and validated {len(manifest)} upload assets; copy limits verified.')
|