Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
196 lines
8.2 KiB
Python
196 lines
8.2 KiB
Python
"""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("HaloTV/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")
|