Files
noo/distribution/store-assets/tools/compose.py
T
Ayushya AmitabhandClaude Sonnet 5.5 660d0bc869
Check PR / check (pull_request) Successful in 59s
Store assets: App Store header, search results, iPhone 6.3 and foldable sizes
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>
2026-10-07 22:58:49 -04:00

378 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.')