tvOS: Layered app icon and Top Shelf images

Generated from the iOS icon art by ios/generate_tvos_icons.py: the logo floats
over the background for the parallax effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Henrik RydgårdandClaude Opus 5.5 committed 2026-10-13 02:11:55 +02:00
1 parent 7315c0392c
commit 5ce0eb58fd
26 files changed
+324 -5

No files matched your search

+6 -5
View File
@@ -1505,7 +1505,8 @@ if(TargetBin)
endif()
if(TVOS)
add_executable(${TargetBin} MACOSX_BUNDLE ${NativeAssets} ${BigFontAssets} ${UIImages} ${SHADER_FILES} ${THEME_FILE} ${DEBUGGER_FILES} ${WEB_UPLOAD_FILES} ${FLASH0_FILES} ${LANG_FILES} ${NativeAppSource})
set(AssetCatalog "${CMAKE_SOURCE_DIR}/ios/tvOS-assets.xcassets")
add_executable(${TargetBin} MACOSX_BUNDLE ${NativeAssets} ${BigFontAssets} ${UIImages} ${AssetCatalog} ${SHADER_FILES} ${THEME_FILE} ${DEBUGGER_FILES} ${WEB_UPLOAD_FILES} ${FLASH0_FILES} ${LANG_FILES} ${NativeAppSource})
elseif(IOS)
set(AssetCatalog "${CMAKE_SOURCE_DIR}/ios/assets.xcassets")
add_executable(${TargetBin} MACOSX_BUNDLE ${NativeAssets} ${BigFontAssets} ${UIImages} ${AssetCatalog} ${SHADER_FILES} ${THEME_FILE} ${DEBUGGER_FILES} ${WEB_UPLOAD_FILES} ${FLASH0_FILES} ${LANG_FILES} ${NativeAppSource} "ios/Launch Screen.storyboard")
@@ -1658,11 +1659,11 @@ if(IOS AND NOT LIBRETRO)
XCODE_ATTRIBUTE_INSTALL_PATH "$(LOCAL_APPS_DIR)"
)
if(TVOS)
# The launch storyboard and the asset catalog's icons are iPhone/iPad only. tvOS needs a
# layered App Icon & Top Shelf brand asset, which we don't have yet.
# The launch storyboard and the iOS asset catalog are iPhone/iPad only. tvOS has a layered
# icon and Top Shelf images of its own, made by ios/generate_tvos_icons.py.
set_target_properties(${TargetBin} PROPERTIES
RESOURCE ""
XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME ""
RESOURCE "ios/tvOS-assets.xcassets"
XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon"
XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET ""
XCODE_ATTRIBUTE_TVOS_DEPLOYMENT_TARGET ${DEPLOYMENT_TARGET}
XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "3"
+130
View File
@@ -0,0 +1,130 @@
# Generates the tvOS app icon and Top Shelf images in tvOS-assets.xcassets from the iOS icon art.
# Run from the ios directory: python3 generate_tvos_icons.py
#
# tvOS icons are layered for the parallax effect when focused: the logo floats over the background.
import json
import os
import shutil
from PIL import Image, ImageDraw, ImageFilter, ImageFont
LOGO = "assets.xcassets/AppIcon.appiconset/dark.png" # The logo on transparency.
FONT = "../assets/Roboto_Condensed-Bold.ttf"
OUTPUT = "tvOS-assets.xcassets"
BRAND = "AppIcon.brandassets"
# The background of the iOS icon, lightened a bit toward the top.
BG_TOP = (61, 92, 110)
BG_BOTTOM = (45, 69, 83)
TEXT = (226, 240, 246)
INFO = {"author": "xcode", "version": 1}
def write_json(path, data):
with open(os.path.join(path, "Contents.json"), "w", newline="\n") as f:
json.dump(data, f, indent=2)
f.write("\n")
def background(w, h):
img = Image.new("RGB", (w, h))
draw = ImageDraw.Draw(img)
for y in range(h):
t = y / max(1, h - 1)
color = tuple(round(a + (b - a) * t) for a, b in zip(BG_TOP, BG_BOTTOM))
draw.line([(0, y), (w, y)], fill=color)
return img
def logo(size):
src = Image.open(LOGO).convert("RGBA")
src = src.crop(src.getchannel("A").getbbox())
return src.resize((size, size), Image.LANCZOS)
def with_shadow(layer, radius):
# A soft shadow under whatever is opaque in the layer, slightly offset down.
alpha = layer.getchannel("A")
shadow = Image.new("RGBA", layer.size, (0, 0, 0, 0))
shadow.putalpha(alpha.point(lambda a: a * 0.55))
shadow = shadow.filter(ImageFilter.GaussianBlur(radius))
out = Image.new("RGBA", layer.size, (0, 0, 0, 0))
out.alpha_composite(shadow, (0, int(radius * 0.6)))
out.alpha_composite(layer)
return out
def front_layer(w, h):
# tvOS crops the edges as the focused icon moves, so keep the logo well inside.
size = round(h * 0.72)
layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
layer.alpha_composite(logo(size), ((w - size) // 2, (h - size) // 2))
return with_shadow(layer, h / 60)
def top_shelf(w, h):
img = background(w, h).convert("RGBA")
size = round(h * 0.6)
font = ImageFont.truetype(FONT, round(h * 0.3))
text = "PPSSPP"
draw = ImageDraw.Draw(img)
left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
gap = round(h * 0.08)
total = size + gap + (right - left)
x = (w - total) // 2
fg = Image.new("RGBA", (w, h), (0, 0, 0, 0))
fg.alpha_composite(logo(size), (x, (h - size) // 2))
ImageDraw.Draw(fg).text((x + size + gap - left, (h - (bottom - top)) // 2 - top), text, font=font, fill=TEXT)
img.alpha_composite(with_shadow(fg, h / 80))
return img.convert("RGB")
def image_stack(name, w, h, scales):
stack = os.path.join(OUTPUT, BRAND, name + ".imagestack")
layers = [("Front", lambda sw, sh: front_layer(sw, sh)), ("Back", lambda sw, sh: background(sw, sh))]
for layer_name, make in layers:
layer_dir = os.path.join(stack, layer_name + ".imagestacklayer")
content = os.path.join(layer_dir, "Content.imageset")
os.makedirs(content)
images = []
for scale in scales:
filename = "%s%s.png" % (layer_name.lower(), "" if scale == 1 else "@%dx" % scale)
make(w * scale, h * scale).save(os.path.join(content, filename))
images.append({"filename": filename, "idiom": "tv", "scale": "%dx" % scale})
write_json(content, {"images": images, "info": INFO})
write_json(layer_dir, {"info": INFO})
write_json(stack, {"info": INFO, "layers": [{"filename": n + ".imagestacklayer"} for n, _ in layers]})
def image_set(name, w, h):
path = os.path.join(OUTPUT, BRAND, name + ".imageset")
os.makedirs(path)
images = []
for scale in (1, 2):
filename = "%s%s.png" % (name.replace(" ", "_").lower(), "" if scale == 1 else "@2x")
top_shelf(w * scale, h * scale).save(os.path.join(path, filename))
images.append({"filename": filename, "idiom": "tv", "scale": "%dx" % scale})
write_json(path, {"images": images, "info": INFO})
shutil.rmtree(OUTPUT, ignore_errors=True)
os.makedirs(os.path.join(OUTPUT, BRAND))
write_json(OUTPUT, {"info": INFO})
image_stack("App Icon", 400, 240, (1, 2))
image_stack("App Icon - App Store", 1280, 768, (1,))
image_set("Top Shelf Image", 1920, 720)
image_set("Top Shelf Image Wide", 2320, 720)
write_json(os.path.join(OUTPUT, BRAND), {
"assets": [
{"filename": "App Icon - App Store.imagestack", "idiom": "tv", "role": "primary-app-icon", "size": "1280x768"},
{"filename": "App Icon.imagestack", "idiom": "tv", "role": "primary-app-icon", "size": "400x240"},
{"filename": "Top Shelf Image Wide.imageset", "idiom": "tv", "role": "top-shelf-image-wide", "size": "2320x720"},
{"filename": "Top Shelf Image.imageset", "idiom": "tv", "role": "top-shelf-image", "size": "1920x720"},
],
"info": INFO,
})
print("Wrote", OUTPUT)
@@ -0,0 +1,13 @@
{
"images": [
{
"filename": "back.png",
"idiom": "tv",
"scale": "1x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,14 @@
{
"info": {
"author": "xcode",
"version": 1
},
"layers": [
{
"filename": "Front.imagestacklayer"
},
{
"filename": "Back.imagestacklayer"
}
]
}
@@ -0,0 +1,13 @@
{
"images": [
{
"filename": "front.png",
"idiom": "tv",
"scale": "1x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "back.png",
"idiom": "tv",
"scale": "1x"
},
{
"filename": "back@2x.png",
"idiom": "tv",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,14 @@
{
"info": {
"author": "xcode",
"version": 1
},
"layers": [
{
"filename": "Front.imagestacklayer"
},
{
"filename": "Back.imagestacklayer"
}
]
}
@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "front.png",
"idiom": "tv",
"scale": "1x"
},
{
"filename": "front@2x.png",
"idiom": "tv",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,32 @@
{
"assets": [
{
"filename": "App Icon - App Store.imagestack",
"idiom": "tv",
"role": "primary-app-icon",
"size": "1280x768"
},
{
"filename": "App Icon.imagestack",
"idiom": "tv",
"role": "primary-app-icon",
"size": "400x240"
},
{
"filename": "Top Shelf Image Wide.imageset",
"idiom": "tv",
"role": "top-shelf-image-wide",
"size": "2320x720"
},
{
"filename": "Top Shelf Image.imageset",
"idiom": "tv",
"role": "top-shelf-image",
"size": "1920x720"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "top_shelf_image_wide.png",
"idiom": "tv",
"scale": "1x"
},
{
"filename": "top_shelf_image_wide@2x.png",
"idiom": "tv",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

@@ -0,0 +1,18 @@
{
"images": [
{
"filename": "top_shelf_image.png",
"idiom": "tv",
"scale": "1x"
},
{
"filename": "top_shelf_image@2x.png",
"idiom": "tv",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}