Generate app icons, Top Shelf images, splash screens and App Store listing art for Apple TV and iOS apps, from your icon art, a background and a color.
tvos-assets writes the Images.xcassets catalog Xcode expects: tvOS parallax app icons (home screen and App Store), both Top Shelf images, the iOS app icon with its dark and tinted variants, and the splash screen logo and color. With --app-store it also writes the iOS 27 and iPadOS 27 App Store creative assets: header, search results, universal and In-App Event images, plus looping videos.
Run it as a CLI, from Node, or as an Expo config plugin on every expo prebuild.
npx tvos-assets --icon ./icon.png --background ./bg.png --color "#F39C12"That writes a timestamped zip to your Desktop:
| File | What it is |
|---|---|
Images.xcassets/ |
42 files: tvOS brand assets, the iOS app icon, the splash logo and color |
icon.png |
The flattened 1024x1024 icon |
preview.html |
A contact sheet of everything generated. Open it first: see preview.html |
Each run gets its own zip, so nothing is overwritten.
npm install --save-dev tvos-assets # in a project, for scripts or the Expo plugin
npm install -g tvos-assets # global CLI
npx tvos-assets --help # no installNeeds Node.js 20.9 or later. sharp installs with it. App Store videos also need ffmpeg on PATH (or at FFMPEG_PATH).
tvos-assets --icon <path> --background <path> --color <hex> [options]Inputs can come from flags or from a config file. With a tvos-assets.config.json in the current directory, the command is just tvos-assets; tvos-assets --init writes a starter one.
Later sources win:
built-in defaults -> config file -> --set -> named flags -> --icon / --background / --color
By default the output is a zip in ~/Desktop (or ~ without a Desktop). --out-dir writes Images.xcassets/ and icon.png straight into a folder instead; folders tvos-assets owns are rewritten and everything else in the catalog is left alone.
SVG inputs are rasterized at the density each size needs, so a small viewBox still makes a sharp 4K Top Shelf image.
Config key is the path in tvos-assets.config.json, and the path --set takes. Plugin is the Expo plugin prop; "via config" means the plugin reaches it through its config prop, a path to a JSON config file.
| Option | Config key | Plugin | Type | Default | Description |
|---|---|---|---|---|---|
--icon <path> |
inputs.iconImage |
icon |
path | required* | Icon PNG or SVG on transparency. *Not needed when --layer-front and --layer-middle are both given: the icon is assembled from them. |
--background <path> |
inputs.backgroundImage |
background |
path | required | Background PNG or SVG. |
--color <hex> |
inputs.backgroundColor |
color |
#RRGGBB |
required | Splash background, light mode. |
--dark-color <hex> |
inputs.darkBackgroundColor |
darkColor |
#RRGGBB |
derived | Splash background, dark mode. Derived from --color at half the HSL lightness. |
--icon-dark <path> |
inputs.iconDarkImage |
iconDark |
path | derived | iOS dark-appearance icon. |
--icon-tinted <path> |
inputs.iconTintedImage |
iconTinted |
path | derived | iOS tinted-appearance icon. |
--icon-border-radius <px> |
inputs.iconBorderRadius |
iconBorderRadius |
number | 0 |
Icon corner radius; half the icon width or more makes a circle. Not applied to custom layer art. |
--output <path> |
output.directory |
fixed | path | ~/Desktop |
Where the zip goes, or the catalog in dir mode. |
--out-dir <path> |
output.directory |
fixed | path | none | Write into this folder instead of a zip. Implies --mode dir. |
--mode <zip|dir> |
output.mode |
always dir |
zip | dir |
zip |
Output mode. |
--platforms <list> |
not a config key | EXPO_TV=1 |
tvos, ios |
both | Icon families to write. Splash assets are written either way. |
--preview / --no-preview |
not a config key | not written | boolean | on | Write preview.html. |
--brand-name <name> |
brandAssets.name |
via config |
string | AppIcon |
The .brandassets name. Must match ASSETCATALOG_COMPILER_APPICON_NAME on the tvOS target. |
--set brandAssets.appIconSmall.enabled= |
brandAssets.appIconSmall.enabled |
via config |
boolean | true |
Home screen imagestack on or off. |
--set brandAssets.appIconSmall.name= |
brandAssets.appIconSmall.name |
via config |
string | App Icon |
Folder name. Must match CFBundleIcons > CFBundlePrimaryIcon. |
--set brandAssets.appIconSmall.size.width= |
brandAssets.appIconSmall.size |
via config |
{width,height} |
400x240 |
Size in points, multiplied by each scale. |
--set brandAssets.appIconSmall.scales= |
brandAssets.appIconSmall.scales |
via config |
string[] | 1x,2x |
Scales to write. |
--set brandAssets.appIconLarge.*= |
brandAssets.appIconLarge.* |
via config |
same keys | App Icon - App Store, 1280x768, 1x |
The App Store imagestack, same keys as appIconSmall. |
--ios-icon-scale <0-1> |
iosIcon.iconScale |
iosIconScale |
number | 0.8 |
How much of the iOS icon the mark covers. See Sizing the mark. |
--tv-icon-scale <0-1> |
brandAssets.iconScale |
tvIconScale |
number | 0.75 |
How much of the shorter tvOS side the mark covers, on every imagestack and Top Shelf image. |
--layer-front, --layer-middle, --layer-back |
brandAssets.<stack>.layers.<layer>.imagePath |
layers |
path | icon, icon, background | Art per parallax layer; the flags apply to both imagestacks. See Parallax layers. |
--set brandAssets.<stack>.layers.<layer>.source= |
brandAssets.<stack>.layers.<layer>.source |
via config |
icon | background |
front/middle icon, back background |
icon is centred on transparency, background fills the layer. |
--no-top-shelf |
brandAssets.topShelfImage(Wide).enabled |
via config |
boolean | true |
Both Top Shelf images on or off. |
--set brandAssets.topShelfImage.name= |
brandAssets.topShelfImage(Wide).name |
via config |
string | Top Shelf Image / Top Shelf Image Wide |
Folder name. Must match the TVTopShelfImage keys in Info.plist. |
--set brandAssets.topShelfImage.size.width= |
brandAssets.topShelfImage(Wide).size |
via config |
{width,height} |
1920x720 / 2320x720 |
Size in points. |
--set brandAssets.topShelfImage.scales= |
brandAssets.topShelfImage(Wide).scales |
via config |
string[] | 1x,2x |
Scales to write. |
--set brandAssets.topShelfImage.filePrefix= |
brandAssets.topShelfImage(Wide).filePrefix |
via config |
string | top / wide |
File name prefix. |
--no-ios-icon |
iosIcon.enabled |
via config |
boolean | true |
iOS AppIcon.appiconset on or off. |
--ios-icon-name <name> |
iosIcon.name |
via config |
string | AppIcon |
The .appiconset name. Must match ASSETCATALOG_COMPILER_APPICON_NAME on the iOS target. |
--no-splash |
splashScreen.logo.enabled, splashScreen.background.enabled |
via config |
boolean | true |
Splash logo and color on or off. |
--splash-logo-name <name> |
splashScreen.logo.name |
via config |
string | SplashScreenLogo |
Imageset name. Must match your launch screen. |
--splash-logo-size <px> |
splashScreen.logo.baseSize |
via config |
number | 200 |
Logo size in px, multiplied by each scale. |
--set splashScreen.logo.filePrefix= |
splashScreen.logo.filePrefix |
via config |
string | 200-icon |
File name prefix. |
--set splashScreen.logo.universal.scales= |
splashScreen.logo.universal.scales |
via config |
string[] | 1x,2x,3x |
Logo scales for iPhone and iPad. |
--set splashScreen.logo.tv.scales= |
splashScreen.logo.tv.scales |
via config |
string[] | 1x,2x |
Logo scales for Apple TV. |
--splash-background-name <name> |
splashScreen.background.name |
via config |
string | SplashScreenBackground |
Colorset name. Must match your launch screen. |
--set splashScreen.background.tv.dark= |
splashScreen.background.{universal,tv}.{light,dark} |
via config |
#RRGGBB |
--color / --dark-color |
Splash colors per device and appearance. |
--app-store |
appStore.enabled |
appStore |
boolean | false |
Write the App Store creative assets into AppStore/ (plugin: appStore.outDir). |
--app-store-background <path> |
appStore.backgroundImage |
appStore.background |
path | --background |
Backdrop for the App Store images. |
--app-store-center <path> |
appStore.centerImage |
appStore.centerImage |
path | the icon | Art centred in each safe area, such as a wordmark. |
--set appStore.iconScale= |
appStore.iconScale |
appStore.iconScale |
number | 0.8 |
How much of each safe area the centred art covers. |
--set appStore.header.source= |
appStore.<placement>.source |
same keys | path | none | Finished art (SVG or PNG) for a placement. Required for eventCard and eventDetails. See Row artwork. |
--set appStore.header.animate.rows=20 |
appStore.<placement>.animate |
same keys | { rows: seconds } or { rise: seconds } |
none | A looping video from row artwork, its rows sliding or its cards rising: 5-30 s, or 15-30 s for In-App Events. Not for universal. |
--set appStore.searchResults.video= |
appStore.<placement>.video |
same keys | path | none | A recording cut into the placement's looping video. Not for universal. |
--set appStore.universal.enabled=false |
appStore.<placement>.enabled |
same keys | boolean | on; In-App Events off | Write a placement or skip it: header, searchResults, universal, eventCard, eventDetails. |
--set appStore.searchResults.center=false |
appStore.<placement>.{backgroundImage,centerImage,center} |
same keys | per placement | center: true |
Per-placement backdrop and centred art; center: false writes the backdrop alone. |
--set appStore.video.fps=60 |
appStore.video.fps, appStore.video.codec |
same keys | 30|60, h264|prores |
30, h264 |
Video frame rate and codec. |
--set appStore.video.audio= |
appStore.video.audio |
same key | path | none | Music on every video, looped with a 1 s crossfade. Silent without it. |
--set appStore.video.audioStart= |
appStore.video.audioStart, appStore.video.audioEnd |
same keys | seconds | first sound, end of track | The part of the track the loop is picked from. |
--set xcassetsMeta.author= |
xcassetsMeta.author, xcassetsMeta.version |
via config |
string, integer | xcode, 1 |
Written into every Contents.json. |
--config <path> |
n/a | config |
path | ./tvos-assets.config.json if present |
Config file. |
--set <path=value> |
n/a | n/a | repeatable | none | Set any config key. See below. |
--dry-run |
n/a | n/a | flag | off | List what would be written, then exit. |
--print-config |
n/a | n/a | flag | off | Print the merged config as JSON, then exit. |
--init [path] |
n/a | n/a | flag | off | Write a starter config with $schema set, then exit. Never overwrites. |
--quiet |
n/a | n/a | flag | off | Print only errors and the output path. |
--version, --help |
n/a | n/a | flag | off | Version; help with a --set cheatsheet. |
Every config key is reachable from the command line. Values take the key's type (true/false, numbers, comma-separated lists):
tvos-assets --icon icon.svg --background bg.png --color "#1C1C1E" \
--set brandAssets.appIconSmall.size.width=500 \
--set brandAssets.appIconLarge.enabled=false \
--set splashScreen.background.tv.dark=#000000A misspelled path fails and lists the keys that exist at that level. --print-config shows what a mix of config file, --set and flags resolves to.
Every run writes a preview.html beside your assets. Open it to check the whole catalog in a browser before you touch Xcode.
It's one file with every image embedded, so it works offline and can go straight to a designer. It shows:
- Where everything came from: each input with its role, the exact command, and the merged config. Paths are relative or start with
~, so a shared page never exposes your machine's paths. - Every generated file with its real name and pixel size, transparency on a checkerboard, and the splash colors as light and dark swatches. Click a thumbnail to open the full-size file.
- Live parallax: point at an imagestack and its layers separate the way tvOS moves them on focus.
- The App Store assets with
--app-store: each image with its safe area outlined, and the videos playing (they're too big to embed, so they play from the files beside the page).
examples/tomotv/output/preview.html is a real one, from the TomoTV app.
With --out-dir it's written beside Images.xcassets, so Xcode never compiles it in. --no-preview skips it.
Regenerate everything on each expo prebuild, for tvOS (EXPO_TV=1) and iOS:
"plugins": [
["tvos-assets/plugin", {
"background": "./assets/brand/background.png",
"color": "#1C1C1E",
"layers": { "front": "./assets/brand/layer-front.svg", "middle": "./assets/brand/layer-middle.svg" }
}]
]There's no icon prop here: with art for both layers, the plugin assembles the icon. Props are the Plugin column of the options table, with paths relative to the project root. Anything else goes through config, a JSON config file merged under the props.
Install it as a dev dependency (npm i -D tvos-assets) and list it after expo-splash-screen and any TV config plugin (such as @react-native-tvos/config-tv), so its splash assets win.
EXPO_TV=1 expo prebuildwritesAppIcon.brandassets(both imagestacks and both Top Shelf images) intoios/<project>/Images.xcassets/and sets the tvOSInfo.plisticon and Top Shelf keys.expo prebuildwrites the iOSAppIcon.appiconsetwith its light, dark and tinted icons.- Both write the splash logo and color.
- With an
appStoreprop, both also write the App Store assets into itsoutDir(default./AppStore), outsideios/, so prebuild never wipes them. Only files whose inputs changed are rendered again.
"appStore": {
"outDir": "./AppStore",
"header": { "source": "./store/artwork.svg", "animate": { "rows": 20 } },
"searchResults": { "source": "./store/artwork.svg", "animate": { "rows": 20 } },
"universal": { "source": "./store/artwork.svg" },
"video": { "audio": "./store/music.mp3" }
}Each prebuild rewrites the folders it owns (AppIcon.brandassets, AppIcon.appiconset, SplashScreenLogo.imageset, SplashScreenBackground.colorset) and leaves the rest of the catalog alone. To change your icon, replace the source files and run prebuild.
@expo/config-plugins is an optional peer dependency; the plugin uses your app's copy, and works when tvos-assets is linked with file: or link:.
AppIcon.appiconset holds three 1024x1024 icons:
- Light: the icon on the background, opaque.
- Dark: the icon on transparency (
--icon-dark, or derived); iOS draws the dark backdrop. - Tinted: a grayscale icon on transparency (
--icon-tinted, or derived); iOS applies the user's tint.
The derived ones suit most marks. Supply your own when the icon loses contrast in grayscale or needs a brighter dark version.
By default the Front and Middle layers of each tvOS imagestack both show the whole icon, and Back shows the background. For real depth, give each layer its own art:
tvos-assets --icon icon.svg --background bg.png --color "#1C1C1E" \
--layer-front ./layer-front.svg --layer-middle ./layer-middle.svgOr per stack in the config file (brandAssets.<stack>.layers.<layer>.imagePath), or with the plugin's layers prop. Export every layer from the same square artboard as the icon and they stay aligned: every layer gets the same placement and scale. A common split is highlights on Front, the main shape on Middle, and the background on Back. Point at the imagestack in preview.html to check the depth before you build.
The scale applies to your visible artwork, not to the artboard around it. tvos-assets first trims the icon to the square its artwork fills, so --ios-icon-scale 0.8 means the mark covers 80% of the icon whatever padding your file has.
| Default | Why | |
|---|---|---|
--ios-icon-scale |
0.8 |
Apple's icon grid puts the main shape at about 80% of the canvas. |
--tv-icon-scale |
0.75 |
tvOS wants a 10-15% safe margin on each layer, because a focused icon grows and its layers shift. 0.75 leaves 12.5% a side. |
One trim box is measured per run and used for every size and every layer, which keeps the layers registered. The splash logo isn't affected.
When both the Front and Middle layers have their own art, --icon is optional: the icon is built from those layers, back to front, and used everywhere a flat icon is needed (iOS icon, Top Shelf, splash logo, icon.png).
tvos-assets --background bg.png --color "#1C1C1E" \
--layer-front ./layer-front.svg --layer-middle ./layer-middle.svg- Both layers need art; with only
--layer-front,--iconis still required. - When the two imagestacks have different art, the App Store one (
appIconLarge) is used. iconBorderRadiusapplies to the assembled icon.
With --app-store, tvos-assets also writes the creative assets App Store Connect takes for iOS 27 and iPadOS 27 apps into AppStore/:
| Placement | Image | Video | Default |
|---|---|---|---|
| Product page header | header.png 3840x1646 |
header.mp4, 5-30 s |
on |
| Search results | search-results.png 3840x2560 |
search-results.mp4, 5-30 s |
on |
| Universal (header and search results in one) | universal.png 5244x2950 |
none | on |
| In-App Event card | event-card.png 3840x2160 |
event-card.mp4, 15-30 s |
off, needs source |
| In-App Event details | event-details.png 2160x3840 |
event-details.mp4, 15-30 s |
off, needs source |
Each placement in App Store Connect takes one image or one video. Images are opaque PNGs. Videos are written when a placement has animate or a recording.
Out of the box, each image is your backdrop (--app-store-background, else --background) with the icon, or --app-store-center art such as a wordmark, centred in the safe area from Apple's templates. Each placement can have its own:
"appStore": {
"enabled": true,
"backgroundImage": "./brand/scene.png",
"centerImage": "./brand/wordmark.svg",
"searchResults": { "backgroundImage": "./shots/library.png", "center": false }
}Apple publishes no safe area for In-App Event media, so those two need a source.
Give a placement a source (SVG or PNG) to use finished art instead. A row artwork SVG goes further: a wall of repeated cards (a Figma or Sketch export, say) that tvos-assets re-tiles to every canvas, so one file serves every placement, and that animate turns into a looping video where the rows slide one card per loop in alternating directions.
"appStore": {
"enabled": true,
"header": { "source": "./store/artwork.svg", "animate": { "rows": 20 } },
"searchResults": { "source": "./store/artwork.svg", "animate": { "rows": 20 } },
"universal": { "source": "./store/artwork.svg" }
}That writes header.png and header.mp4, search-results.png and search-results.mp4, and universal.png. The art is centred in each safe area; if the card under your logo wouldn't fit one whole, the art shifts until it does. A record in .tvos-assets-store.json skips files whose inputs haven't changed, so videos only render again when the art or settings change.
What moves and what stays:
- Drawn through the mask (the fill behind the cards, type seen through them): stays put while the cards slide over it.
- Drawn on top, inside one card (a live dot, say): rides with that card. A row moves one card per loop, so in the last half second it hands over to the next card and the loop closes.
- Everything else drawn on top (a vignette, a logo across cards, anything in a transformed group): stays put.
- Texture that should move with the cards, such as scan lines: put it in the cards' fill as a
patternwithpatternUnits="userSpaceOnUse".
Design at 3840x1646 (the header) and export SVG. Each rule is checked, and a file that breaks one fails with a message naming it:
| Rule | Why |
|---|---|
<svg> has numeric width and height in px |
Everything is measured against them. |
Exactly one <mask>, used by the fill behind the cards (mask="url(#id)") |
The mask's children are the cards. |
Cards are path, rect, circle, ellipse or polygon: at least two, all one size, no transform |
Each row is redrawn from one card; flatten transforms before export. |
| Cards in a row are evenly spaced, the same spacing in every row | That spacing is how far a row slides per loop. |
| Rows are evenly spaced and repeat every two (row 3 lines up with row 1) | Extra rows on taller canvases continue the pattern. |
| Full-canvas fills cover exactly 0,0 to width,height | Those stretch to bigger canvases; everything else stays centred. |
No <text>, and no <image> linking outside the file |
Outline text and embed images so every machine renders the same. |
A shape may hold one <animate attributeName values dur> whose dur divides the loop |
Videos play it; stills show its first value. |
examples/row-artwork/ has six files that pass, one per card shape: circles, rounded rects, hexagons, path tiles, 16:9 screens under a vignette, and diamonds.
Row-motion videos are fast: only the cards move, so each frame is blended from layers drawn once, several frames at a time. A 20 s 4K header takes about 30 s on an M1 Max. Art the blend can't reproduce exactly is drawn in full each frame instead.
animate with rise in place of rows makes the other video row artwork can become. The rows stay where they are. The surface is empty, and cards come up out of it and sink back a few at a time, so the only cards on screen are the ones on their way in or out. The still shows every card up.
"appStore": {
"enabled": true,
"header": { "source": "./store/artwork.svg", "animate": { "rise": 20 } },
"searchResults": { "source": "./store/artwork.svg", "animate": { "rise": 20 } }
}How a card rises comes from your SVG:
- What is drawn through the mask fades in with its card, after a moment as a faint relief.
- Filters grow with the card:
feOffsetandfeDropShadowoffsets from zero, blurs from a fifth of their size. Put a drop shadow on a group around the masked art and it lengthens as the card lifts. - Drawn on top, inside one card: comes up with that card.
- Everything else: the surface, which stays put.
<rect width="3840" height="1646" fill="#FFC312"/>
<mask id="cards">…the cards…</mask>
<g filter="url(#lift)">
<g mask="url(#cards)">…what each card shows…</g>
</g>A card comes up every 10 s in a 20 or 30 s loop (once per loop in shorter ones), and cards next to each other come up at different moments. rise adds three rules:
| Rule | Why |
|---|---|
One element draws through the mask; wrap several in one <g mask="url(#id)"> |
That element is the card. |
No <animate> |
The rise is the motion. |
| A card's shadows stay clear of the next card | Each card is drawn by itself; with every card up, the frame has to match the still. |
To put the same art on every card (a logo pressed into each one, say), fill a full-canvas shape with a pattern whose tile is one card spacing wide and two rows tall.
Cards that draw the same share their frames, so this is fast too: a 20 s 4K header takes about 40 s on an M1 Max.
Point header.video or searchResults.video at a recording (.mov, .mp4 or .m4v), such as a simulator capture (xcrun simctl io <udid> recordVideo) of a tour that ends where it starts. tvos-assets fits it to the canvas, keeps a constant frame rate, blends the last 0.5 s into the start so the loop has no cut, and caps it at 30 s.
"appStore": { "enabled": true, "searchResults": { "video": "./store/tour.mov" } }Videos are H.264 High (libx264, steady quality across the loop point) with AAC audio, or ProRes 422 HQ in .mov with video.codec: "prores" (VideoToolbox on a Mac).
video.audio adds music (.mp3, .m4a, .aac, .wav or .aiff) to every video, looped so it never cuts: the last second crossfades into the first. The loop is picked from the part of the track between video.audioStart and video.audioEnd (by default from the first sound to the end), where the beat lines up across the loop and the level never drops out.
"appStore": { "enabled": true, "video": { "audio": "./store/music.mp3", "audioStart": 30, "audioEnd": 90 } }App Store videos play muted; people can unmute the product page header, but not search results.
Upload the files in App Store Connect under Header and Search Results, or to the Asset Library.
import { resolveConfig, generateAssets, planAssets } from "tvos-assets";
const config = resolveConfig({
icon: "./icon.svg", // the same inputs as the CLI flags
background: "./bg.png",
color: "#1C1C1E",
config: "./assets.config.json", // optional, like --config
overrides: { appStore: { enabled: true } }, // optional, like --set
});
// Count what a run would write, without writing it.
const plan = planAssets(config, { platforms: ["ios"] });
console.log(plan.total, plan.directories);
const { warnings } = await generateAssets(config, "./out/Images.xcassets", {
platforms: ["tvos", "ios"], // default: both
standaloneIconPath: "./out/icon.png", // optional flat icon
previewPath: "./out/preview.html", // optional contact sheet
appStoreDir: "./out/AppStore", // where App Store assets go
onStep: (message) => console.log(message),
});generateAssets writes into the catalog folder (creating it if needed) and resolves to { warnings, xcassetsDir }. resolveConfig throws on bad input (missing files, bad colors, wrong formats). Also exported: discoverConfigPath, configShapeTemplate, CONFIG_FILENAME and validateInputImages.
# A dark mode color and a circular icon
tvos-assets --icon ./icon.png --background ./bg.png --color "#F39C12" \
--dark-color "#7A4E09" --icon-border-radius 512
# Straight into an Xcode project, tvOS only
tvos-assets --icon ./icon.svg --background ./bg.png --color "#1C1C1E" \
--out-dir ios/MyApp --platforms tvos --brand-name AppIconTV
# Start a config file, then run with no flags
tvos-assets --init && tvos-assets
# See what a run would do
tvos-assets --config ./brand.json --dry-run
tvos-assets --config ./brand.json --print-configexamples/tomotv is a complete real project: TomoTV's art and config, with everything it generates committed.
- Icon: PNG or SVG on transparency, at least 1024x1024 if raster. Its size on each output is set by
--ios-icon-scaleand--tv-icon-scale. - Background: PNG or SVG, cover-fit and centre-cropped. At least 2320x720 if raster; 4640x1440 (Top Shelf @2x) avoids upscaling, and anything smaller gets a warning.
- Color:
#RRGGBB.
SVGs are exempt from the minimums. You also get a warning for inputs over 50 MB, over 8192 px on a side, or a non-square icon.
Generated names have to match what your project references. The defaults fit a stock Expo or React Native tvOS project; if you rename something, change it in both places.
| Generated | Referenced by | Default |
|---|---|---|
<name>.brandassets |
ASSETCATALOG_COMPILER_APPICON_NAME on the tvOS target |
AppIcon |
<name>.appiconset |
ASSETCATALOG_COMPILER_APPICON_NAME on the iOS target |
AppIcon |
| App Icon imagestack | CFBundleIcons > CFBundlePrimaryIcon in the tvOS Info.plist |
App Icon |
| Top Shelf Image | TVTopShelfImage > TVTopShelfPrimaryImage |
Top Shelf Image |
| Top Shelf Image Wide | TVTopShelfImage > TVTopShelfPrimaryImageWide |
Top Shelf Image Wide |
| Splash logo imageset | Your launch screen's image view | SplashScreenLogo |
| Splash colorset | Your launch screen's background color | SplashScreenBackground |
The Expo plugin sets the Info.plist keys for you. In a plain Xcode project, set them yourself, add the generated Images.xcassets to your target (or write into the existing one with --out-dir), and check its target membership.
Generated files (44 in a default run)
21 Contents.json, 21 PNGs, icon.png and preview.html:
tvos-assets-YYYYMMDD-HHmmss.zip
├── icon.png (1024x1024, icon on background)
├── preview.html (contact sheet)
└── Images.xcassets/
├── Contents.json
├── AppIcon.brandassets/
│ ├── Contents.json
│ ├── App Icon.imagestack/
│ │ ├── Contents.json
│ │ ├── Front.imagestacklayer/
│ │ │ ├── Contents.json
│ │ │ └── Content.imageset/
│ │ │ ├── Contents.json
│ │ │ ├── front@1x.png (400x240)
│ │ │ └── front@2x.png (800x480)
│ │ ├── Middle.imagestacklayer/
│ │ │ ├── Contents.json
│ │ │ └── Content.imageset/
│ │ │ ├── Contents.json
│ │ │ ├── middle@1x.png (400x240)
│ │ │ └── middle@2x.png (800x480)
│ │ └── Back.imagestacklayer/
│ │ ├── Contents.json
│ │ └── Content.imageset/
│ │ ├── Contents.json
│ │ ├── back@1x.png (400x240, opaque)
│ │ └── back@2x.png (800x480, opaque)
│ ├── App Icon - App Store.imagestack/
│ │ ├── Contents.json
│ │ ├── Front.imagestacklayer/…/front@1x.png (1280x768)
│ │ ├── Middle.imagestacklayer/…/middle@1x.png (1280x768)
│ │ └── Back.imagestacklayer/…/back.png (1280x768, opaque)
│ ├── Top Shelf Image.imageset/
│ │ ├── Contents.json
│ │ ├── top@1x.png (1920x720, opaque)
│ │ └── top@2x.png (3840x1440, opaque)
│ └── Top Shelf Image Wide.imageset/
│ ├── Contents.json
│ ├── wide@1x.png (2320x720, opaque)
│ └── wide@2x.png (4640x1440, opaque)
├── AppIcon.appiconset/
│ ├── Contents.json
│ ├── icon-1024.png (1024x1024, opaque, light)
│ ├── icon-1024-dark.png (1024x1024, transparent, dark)
│ └── icon-1024-tinted.png (1024x1024, grayscale, tinted)
├── SplashScreenLogo.imageset/
│ ├── Contents.json
│ ├── 200-icon@1x.png (200px)
│ ├── 200-icon@2x.png (400px)
│ ├── 200-icon@3x.png (600px)
│ ├── 200-icon-tv@1x.png (200px, tv)
│ └── 200-icon-tv@2x.png (400px, tv)
└── SplashScreenBackground.colorset/
└── Contents.json (light and dark colors)
Front and Middle are the icon on transparency; Back and the Top Shelf images are opaque, as tvOS requires. --platforms ios drops AppIcon.brandassets, --platforms tvos drops AppIcon.appiconset, and --no-splash drops the splash assets. --dry-run lists the exact set.
Every section is optional. Name the file tvos-assets.config.json in your project root and the CLI finds it. Keys and defaults are in the options table; examples/tvos-assets.config.json is a complete annotated example.
{
"$schema": "./node_modules/tvos-assets/schema.json",
"inputs": {
"iconImage": "./icon.png",
"backgroundImage": "./background.png",
"backgroundColor": "#B43939"
}
}tvos-assets --init writes this for you, with $schema set for autocompletion and validation in your editor.
Every key
{
"$schema": "./node_modules/tvos-assets/schema.json",
"inputs": {
"iconImage": "./icon.png",
"backgroundImage": "./background.png",
"backgroundColor": "#B43939",
"darkBackgroundColor": "#5A1C1C",
"iconBorderRadius": 80,
"iconDarkImage": "./icon-dark.svg",
"iconTintedImage": "./icon-tinted.svg"
},
"output": {
"directory": "./output",
"mode": "zip"
},
"brandAssets": {
"name": "AppIcon",
"iconScale": 0.75,
"appIconSmall": {
"enabled": true,
"name": "App Icon",
"size": { "width": 400, "height": 240 },
"scales": ["1x", "2x"],
"layers": {
"front": { "source": "icon", "imagePath": "./layer-front.svg" },
"middle": { "source": "icon", "imagePath": "./layer-middle.svg" },
"back": { "source": "background" }
}
},
"appIconLarge": {
"enabled": true,
"name": "App Icon - App Store",
"size": { "width": 1280, "height": 768 },
"scales": ["1x"],
"layers": {
"front": { "source": "icon" },
"middle": { "source": "icon" },
"back": { "source": "background" }
}
},
"topShelfImage": {
"enabled": true,
"name": "Top Shelf Image",
"size": { "width": 1920, "height": 720 },
"scales": ["1x", "2x"],
"filePrefix": "top"
},
"topShelfImageWide": {
"enabled": true,
"name": "Top Shelf Image Wide",
"size": { "width": 2320, "height": 720 },
"scales": ["1x", "2x"],
"filePrefix": "wide"
}
},
"iosIcon": {
"enabled": true,
"name": "AppIcon",
"iconScale": 0.8
},
"splashScreen": {
"logo": {
"enabled": true,
"name": "SplashScreenLogo",
"baseSize": 200,
"filePrefix": "200-icon",
"universal": { "scales": ["1x", "2x", "3x"] },
"tv": { "scales": ["1x", "2x"] }
},
"background": {
"enabled": true,
"name": "SplashScreenBackground",
"universal": { "light": "#B43939", "dark": "#5A1C1C" },
"tv": { "light": "#B43939", "dark": "#5A1C1C" }
}
},
"appStore": {
"enabled": true,
"iconScale": 0.8,
"backgroundImage": "./store-bg.png",
"centerImage": "./wordmark.svg",
"header": { "enabled": true, "center": true, "source": "./artwork.svg", "animate": { "rows": 20 } },
"searchResults": { "enabled": true, "center": true, "video": "./tour.mov" },
"universal": { "enabled": true, "center": true },
"eventCard": { "enabled": false, "center": true },
"eventDetails": { "enabled": false, "center": true },
"video": { "fps": 30, "codec": "h264", "audio": "./music.mp3", "audioStart": 30, "audioEnd": 90 }
},
"xcassetsMeta": {
"author": "xcode",
"version": 1
}
}schema.json ships in the package, so $schema always points at a local file: it matches the version you installed and works offline. --init picks the path for how you installed tvos-assets:
| Installed as | $schema |
Why |
|---|---|---|
| Project dependency | ./node_modules/tvos-assets/schema.json |
Works for everyone who clones the repo and follows upgrades. In a monorepo it walks up to find it. |
Global or npx |
./tvos-assets.schema.json |
There's no stable local copy, so --init copies the schema next to your config. |
The tool itself never reads $schema; it's for your editor.
--quiet prints only errors and the output path:
"scripts": {
"icons": "tvos-assets --out-dir ios/MyApp --quiet --no-preview"
}With a config file in the repo, the script needs no input flags. Commit the config and the source art, not the generated catalog.
To check a config without writing anything:
tvos-assets --print-config > /dev/null # fails on an invalid config
tvos-assets --dry-run # lists what would be writtenIn Expo projects, use the config plugin instead: it runs inside expo prebuild and sets the Info.plist keys.
Troubleshooting
"Icon image is too small." Raster icons need 1024x1024. Export a bigger PNG or use an SVG.
Top Shelf looks soft. Top Shelf Wide @2x is 4640x1440; a smaller background gets upscaled. Use a bigger background or an SVG.
A config value seems ignored. Run tvos-assets --print-config. The config file loses to --set, which loses to named flags, which lose to --icon, --background and --color.
A --set path is rejected. The path doesn't exist; the error lists the keys that do.
Xcode doesn't show the icon. The bundle name must match ASSETCATALOG_COMPILER_APPICON_NAME on that target, and the catalog must be in the target. See Wiring the assets up in Xcode.
Prebuild overwrites the splash assets. List tvos-assets/plugin after expo-splash-screen and any TV config plugin; the last plugin wins.
The plugin made iOS icons instead of tvOS ones. Run EXPO_TV=1 expo prebuild.
App Store videos fail. Install ffmpeg (brew install ffmpeg) or set FFMPEG_PATH.
sharp fails to install. See the sharp installation guide.
The output folder isn't writable. It's checked before any work starts; check the permissions of its nearest existing parent.
git clone https://github.com/keiver/tvos-assets.git && cd tvos-assets && npm install| Script | What it does |
|---|---|
npm run dev |
Run from the TypeScript source |
npm run build |
Compile to dist/ |
npm start |
Run the compiled build |
npm test |
Run the tests |
npm run test:coverage |
Tests with coverage |
dist/ isn't committed; prepare builds it on install, before publishing, and for git installs (npm i github:keiver/tvos-assets).
Testing a change the way users get it
Install the packed tarball into a scratch project, which checks files, exports and bin:
npm pack --pack-destination /tmp/consumer
cd /tmp/consumer && npm init -y && npm i ./tvos-assets-*.tgz
npx tvos-assets --version # bin
node -e 'import("tvos-assets").then(m => console.log(Object.keys(m)))' # ESM library
node -e 'console.log(typeof require("tvos-assets/plugin"))' # CJS pluginfile: and link: installs don't run prepare, so build first when testing the plugin from a linked checkout.
MIT. The demo icons and backgrounds in the screenshots come from the poster generator on keiver.dev.


