Dasselbe Icon, nur breit
Die Markenbilder für den Fernseher waren nachempfunden: ein Dreieck, das ungefähr aussah wie das im App-Icon, mit Farben aus icon.json geraten. Jetzt sind es dieselben. **Die Form aus play.svg gerechnet, nicht nachgezeichnet.** Der Pfad beschreibt ein gleichseitiges Dreieck mit 592,76 Punkten Seite und 54,7 Punkten Eckenradius. Aus diesen Zahlen entsteht die Silhouette — und zwar über ein um den Radius eingerücktes Dreieck mit rundem Strich, was nicht eine Annäherung an die Bögen ist, sondern genau sie. **Die Farben gemessen, nicht geschätzt.** In icon.json stehen Ausgangsfarben für einen automatischen Verlauf, und wie Icon Composer den aufspannt, steht nirgends. Also aus dem gerenderten iOS-Icon abgelesen: Grund 160,112,255 nach 120,69,255, Zeichen 224,152,255 nach 170,97,255, dazu der helle Saum und der weiche Schatten aus der Gruppe. **Zwei Dinge, die erst im Vergleich auffielen.** Den optischen Versatz nach rechts hatte ich auf die Leinwandbreite bezogen — auf dem quadratischen iOS-Icon dasselbe, auf der breiten Fernseherleinwand sichtbar zu viel. Er bezieht sich jetzt auf das Zeichen. Und der helle Saum fehlte ganz; ohne ihn wirkt die Fläche aufgeklebt statt aufliegend. Das Skript liegt als Tools/TVBrandAssets.py bei. Ändert sich die Marke, läuft es einmal und alle fünf Grössen stimmen wieder — von Hand wären es zehn Dateien. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1012 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1,195 @@
|
||||
"""Baut die tvOS-Markenbilder aus derselben Vorlage wie das iOS-Icon.
|
||||
|
||||
**Warum von Hand und nicht mit Icon Composer.** Das Werkzeug deckt in
|
||||
Xcode 26 iOS, macOS und watchOS ab, tvOS nicht: ein Fernseher verlangt
|
||||
gestapelte Bilder, damit die Parallaxe beim Wischen etwas zu bewegen hat.
|
||||
|
||||
**Was hier nicht geraten ist.** Die Form kommt aus `play.svg` — dasselbe
|
||||
gleichseitige Dreieck mit denselben runden Ecken, aus den Koordinaten des
|
||||
Pfades gerechnet statt nachempfunden. Die Farben sind aus dem **gerenderten**
|
||||
iOS-Icon gemessen (1024er Fassung im Bau, hier über die 152er Ausgabe), nicht
|
||||
aus icon.json geschätzt: dort stehen Ausgangsfarben für einen automatischen
|
||||
Verlauf, und wie Icon Composer den aufspannt, steht nirgends.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
WURZEL = Path("FlixyTV/Resources/TVAssets.xcassets")
|
||||
MARKE = WURZEL / "App Icon & Top Shelf Image.brandassets"
|
||||
|
||||
# Gemessen am gerenderten iOS-Icon, Spalte neben dem Dreieck bzw. durch es.
|
||||
GRUND_OBEN = (160, 112, 255)
|
||||
GRUND_UNTEN = (120, 69, 255)
|
||||
ZEICHEN_OBEN = (224, 152, 255)
|
||||
ZEICHEN_UNTEN = (170, 97, 255)
|
||||
|
||||
# Aus play.svg gerechnet. Der Pfad beschreibt ein gleichseitiges Dreieck mit
|
||||
# 592,76 Punkten Seite und 54,7 Punkten Eckenradius; sein sichtbarer Kasten ist
|
||||
# dadurch 0,8385 mal so hoch und 0,8923 mal so breit wie das ideale Dreieck.
|
||||
ECKENRADIUS_ZU_SEITE = 54.706 / 592.76
|
||||
KASTEN_ZU_SEITE_HOCH = 497.03 / 592.76
|
||||
# Wie hoch das Zeichen im Verhältnis zur kürzeren Kante steht — am iOS-Icon
|
||||
# gemessen: 87 von 152.
|
||||
ZEICHEN_ANTEIL = 87 / 152
|
||||
# Und wie weit es aus der Mitte nach rechts rückt. Ein Dreieck wirkt sonst
|
||||
# linkslastig.
|
||||
#
|
||||
# **Bezogen auf das Zeichen, nicht auf die Leinwand.** Am quadratischen
|
||||
# iOS-Icon war das dasselbe; auf der breiten Fernseherleinwand nicht mehr, und
|
||||
# als Anteil der Breite gerechnet rutschte das Dreieck sichtbar nach rechts.
|
||||
VERSATZ_ANTEIL = 6.5 / 79
|
||||
|
||||
SS = 4 # Überabtastung gegen Treppen
|
||||
|
||||
|
||||
def verlauf(größe, oben, unten):
|
||||
breite, höhe = größe
|
||||
bild = Image.new("RGB", (breite, höhe))
|
||||
zeichner = ImageDraw.Draw(bild)
|
||||
for y in range(höhe):
|
||||
t = y / max(höhe - 1, 1)
|
||||
farbe = tuple(round(oben[k] + (unten[k] - oben[k]) * t) for k in range(3))
|
||||
zeichner.line([(0, y), (breite, y)], fill=farbe)
|
||||
return bild
|
||||
|
||||
|
||||
def zeichen_maske(größe):
|
||||
"""Die Silhouette aus play.svg, in der Grösse dieses Bildes."""
|
||||
breite, höhe = größe
|
||||
bild = Image.new("L", (breite * SS, höhe * SS), 0)
|
||||
zeichner = ImageDraw.Draw(bild)
|
||||
|
||||
kasten_höhe = höhe * ZEICHEN_ANTEIL * SS
|
||||
seite = kasten_höhe / KASTEN_ZU_SEITE_HOCH
|
||||
radius = seite * ECKENRADIUS_ZU_SEITE
|
||||
umkreis = seite / math.sqrt(3)
|
||||
inkreis = seite / (2 * math.sqrt(3))
|
||||
|
||||
mitte_x = breite * SS / 2 + kasten_höhe * 0.908 * VERSATZ_ANTEIL
|
||||
mitte_y = höhe * SS / 2
|
||||
|
||||
# **Eingerücktes Dreieck plus runder Strich.** Das ist nicht eine
|
||||
# Annäherung an die runden Ecken, sondern genau sie: ein Strich der Breite
|
||||
# 2r mit runden Gehrungen um das um r eingerückte Dreieck ergibt dieselbe
|
||||
# Figur wie die Bögen im Pfad.
|
||||
einzug = 1 - radius / inkreis
|
||||
ecken = []
|
||||
for winkel in (0, 120, 240):
|
||||
rad = math.radians(winkel)
|
||||
ecken.append((
|
||||
mitte_x + umkreis * einzug * math.cos(rad),
|
||||
mitte_y + umkreis * einzug * math.sin(rad),
|
||||
))
|
||||
|
||||
zeichner.polygon(ecken, fill=255)
|
||||
# Zwei Punkte über den Ring hinaus: `joint="curve"` rundet nur **innere**
|
||||
# Ecken. Endete die Linie beim ersten Punkt, bliebe genau dort eine spitze
|
||||
# Kerbe stehen — und zwar nur an einer, was wie ein Zufall aussieht.
|
||||
zeichner.line(ecken + [ecken[0], ecken[1]], fill=255,
|
||||
width=max(round(radius * 2), 1), joint="curve")
|
||||
|
||||
return bild.resize((breite, höhe), Image.LANCZOS)
|
||||
|
||||
|
||||
def hinten(größe):
|
||||
return verlauf(größe, GRUND_OBEN, GRUND_UNTEN).convert("RGBA")
|
||||
|
||||
|
||||
def vorn(größe):
|
||||
maske = zeichen_maske(größe)
|
||||
bild = verlauf(größe, ZEICHEN_OBEN, ZEICHEN_UNTEN).convert("RGBA")
|
||||
bild.putalpha(maske)
|
||||
|
||||
# **Der helle Saum.** Am iOS-Icon zieht sich eine dünne, hellere Kante um
|
||||
# das Zeichen — sie kommt dort aus der Durchscheinlichkeit der Gruppe. Ohne
|
||||
# sie wirkt die Fläche flach aufgeklebt statt aufliegend. Nachgebaut als
|
||||
# Differenz zwischen der Maske und einer leicht geschrumpften: das ist
|
||||
# genau die Randzone.
|
||||
saumbreite = max(round(größe[1] * 0.004), 1)
|
||||
innen = maske.filter(ImageFilter.MinFilter(saumbreite * 2 + 1))
|
||||
saum = Image.new("RGBA", größe, (255, 232, 255, 0))
|
||||
saum.putalpha(Image.eval(
|
||||
Image.merge("L", (maske,)).point(lambda v: v),
|
||||
lambda v: v
|
||||
).point(lambda v: v))
|
||||
saum.putalpha(Image.composite(
|
||||
maske.point(lambda v: min(v, 150)), Image.new("L", größe, 0), innen.point(lambda v: 255 - v)
|
||||
))
|
||||
bild.alpha_composite(saum)
|
||||
|
||||
# Der weiche Schatten aus icon.json (neutral, halbe Deckkraft). Er gehört
|
||||
# auf dieselbe Ebene wie das Zeichen — eine eigene dritte Ebene würde beim
|
||||
# Neigen gegen das Zeichen verrutschen, und ein Schatten, der sich von
|
||||
# seinem Gegenstand löst, sieht nach Fehler aus.
|
||||
weich = max(round(größe[1] * 0.02), 1)
|
||||
schatten = Image.new("RGBA", größe, (0, 0, 0, 0))
|
||||
schatten.putalpha(maske.filter(ImageFilter.GaussianBlur(weich)).point(lambda v: v // 2))
|
||||
schatten = Image.alpha_composite(
|
||||
Image.new("RGBA", größe, (0, 0, 0, 0)),
|
||||
Image.merge("RGBA", (*Image.new("RGB", größe, (0, 0, 0)).split(), schatten.split()[3])),
|
||||
)
|
||||
versatz = Image.new("RGBA", größe, (0, 0, 0, 0))
|
||||
versatz.paste(schatten, (0, weich))
|
||||
versatz.alpha_composite(bild)
|
||||
return versatz
|
||||
|
||||
|
||||
def schreibe_imageset(ordner, name, bilder):
|
||||
ordner.mkdir(parents=True, exist_ok=True)
|
||||
einträge = []
|
||||
for skala, bild in bilder:
|
||||
datei = name + ("" if skala == 1 else "@%dx" % skala) + ".png"
|
||||
bild.save(ordner / datei)
|
||||
einträge.append({"idiom": "tv", "filename": datei, "scale": "%dx" % skala})
|
||||
(ordner / "Contents.json").write_text(json.dumps(
|
||||
{"images": einträge, "info": {"author": "xcode", "version": 1}}, indent=2) + "\n")
|
||||
|
||||
|
||||
def schreibe_stapel(ordner, größe, skalen):
|
||||
ordner.mkdir(parents=True, exist_ok=True)
|
||||
ebenen = [("Back", hinten), ("Front", vorn)]
|
||||
for name, macher in ebenen:
|
||||
ebene = ordner / (name + ".imagestacklayer")
|
||||
ebene.mkdir(parents=True, exist_ok=True)
|
||||
(ebene / "Contents.json").write_text(json.dumps(
|
||||
{"info": {"author": "xcode", "version": 1}}, indent=2) + "\n")
|
||||
schreibe_imageset(ebene / "Content.imageset", name.lower(),
|
||||
[(s, macher((größe[0] * s, größe[1] * s))) for s in skalen])
|
||||
(ordner / "Contents.json").write_text(json.dumps(
|
||||
{"layers": [{"filename": n + ".imagestacklayer"} for n, _ in reversed(ebenen)],
|
||||
"info": {"author": "xcode", "version": 1}}, indent=2) + "\n")
|
||||
|
||||
|
||||
schreibe_stapel(MARKE / "App Icon.imagestack", (400, 240), [1, 2])
|
||||
schreibe_stapel(MARKE / "App Icon - App Store.imagestack", (1280, 768), [1])
|
||||
|
||||
|
||||
def regal(größe):
|
||||
"""Das Top-Shelf-Bild — ein Bild, kein Stapel."""
|
||||
bild = hinten(größe)
|
||||
bild.alpha_composite(vorn(größe))
|
||||
return bild
|
||||
|
||||
|
||||
schreibe_imageset(MARKE / "Top Shelf Image.imageset", "topshelf",
|
||||
[(1, regal((1920, 720))), (2, regal((3840, 1440)))])
|
||||
schreibe_imageset(MARKE / "Top Shelf Image Wide.imageset", "topshelfwide",
|
||||
[(1, regal((2320, 720))), (2, regal((4640, 1440)))])
|
||||
|
||||
(MARKE / "Contents.json").write_text(json.dumps({
|
||||
"assets": [
|
||||
{"filename": "App Icon.imagestack", "idiom": "tv",
|
||||
"role": "primary-app-icon", "size": "400x240"},
|
||||
{"filename": "App Icon - App Store.imagestack", "idiom": "tv",
|
||||
"role": "primary-app-icon", "size": "1280x768"},
|
||||
{"filename": "Top Shelf Image.imageset", "idiom": "tv",
|
||||
"role": "top-shelf-image", "size": "1920x720"},
|
||||
{"filename": "Top Shelf Image Wide.imageset", "idiom": "tv",
|
||||
"role": "top-shelf-image-wide", "size": "2320x720"},
|
||||
],
|
||||
"info": {"author": "xcode", "version": 1}}, indent=2) + "\n")
|
||||
(WURZEL / "Contents.json").write_text(json.dumps(
|
||||
{"info": {"author": "xcode", "version": 1}}, indent=2) + "\n")
|
||||
print("gebaut")
|
||||