From 44c7bd26e3a5e2adcea8d74a29861ed461b5ba1f Mon Sep 17 00:00:00 2001 From: Keiver Date: Sun, 6 Sep 2026 22:14:15 -0500 Subject: [PATCH] feat(icons): size the mark to Apple's proportions, assemble it from layer art Two fixes to how the mark is placed and where it comes from. Sizing. Every centred surface used a hardcoded 0.6 of the shorter side, with an iconScale option no caller ever passed. Worse, the scale sized the *artboard*, so a designer's own transparent margin shrank the mark again: Tomo TV's art fills 67.6% of its 1024 artboard, which put the iOS mark at 40.6% of the icon against the ~80% Apple's grid calls for. The icon is now normalised to the square its visible artwork occupies before scaling, so the number means "the mark covers N% of the canvas" whatever padding the source carries. Defaults follow Apple: 0.8 on iOS (icon grid, ~10% margin) and 0.75 on tvOS (inside the 10-15% parallax safe margin, since a focused icon scales ~1.05-1.1x and its layers slide against each other). Both are settable per project with --ios-icon-scale and --tv-icon-scale, in a config file, or through the plugin. One content box is measured per run, from the flat icon, and applied to every surface and every parallax layer. Sharing one transform is what keeps the layers registered; normalising each layer to its own bounds would slide the front layer off the middle one. The splash logo is untouched, it already fills its square. Assembly. --icon is now optional when every icon layer carries its own art: the flat icon is composited from that art back to front and used for the appiconset, Top Shelf, splash logo and icon.png. Tomo TV's icon.svg was byte-for-byte layer-middle.svg plus layer-front.svg, a third file kept in sync by hand. Verified against Tomo TV's real art: iOS lands at 80.0% and the tvOS middle layer at 75.0%, with the front layer still registered inside it. --- CHANGELOG.md | 12 ++ README.md | 60 ++++++++- plugin/index.cjs | 7 ++ schema.json | 90 +++++++++++--- src/config.ts | 97 ++++++++++++++- src/generators/appiconset.ts | 4 + src/generators/brand-assets.ts | 11 +- src/generators/icon.ts | 5 +- src/generators/imageset.ts | 4 + src/generators/imagestack.ts | 14 ++- src/generators/preview-html.ts | 8 +- src/generators/preview.ts | 43 +++++-- src/index.ts | 16 ++- src/lib.ts | 42 ++++++- src/types.ts | 19 +++ src/utils/image-processing.ts | 195 ++++++++++++++++++++++++++++-- tests/config-v13.test.ts | 2 +- tests/config.test.ts | 141 +++++++++++++++++++++ tests/fixtures/create-fixtures.ts | 16 +++ tests/lib.test.ts | 105 +++++++++++++++- 20 files changed, 827 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6507694..5de8c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # Changelog ## [Unreleased] +- feat(icons): the mark is sized to Apple's proportions instead of a hardcoded + 0.6 of the shorter side. The icon is normalized to the square its visible + artwork occupies before scaling, so the number means "the mark covers N% of + the canvas" no matter how much transparent margin the source carries. New + `--ios-icon-scale` (default 0.8, Apple's icon grid) and `--tv-icon-scale` + (default 0.75, inside Apple's 10-15% parallax safe margin). One content box is + measured per run and shared by every surface and every parallax layer, which + keeps the layers registered. +- feat(config): `--icon` is optional when every icon layer carries its own art. + The flat icon is assembled from that art back to front and used for the iOS + appiconset, Top Shelf images, splash logo, and `icon.png`, so a project with + parallax layers no longer maintains a hand-flattened third copy of them. ## [1.4.0] - 2026-08-05 - feat(cli): full config parity, preview.html, and DX pass (#7) diff --git a/README.md b/README.md index 4276b7c..0f4159c 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Every option, on every surface. **Config key** is the dotted path in `tvos-asset | Option | Config key | Plugin | Type | Default | Description | |---|---|---|---|---|---| -| `--icon ` | `inputs.iconImage` | `icon` | path | **required** | Icon PNG or SVG with a transparent background. | +| `--icon ` | `inputs.iconImage` | `icon` | path | **required\*** | Icon PNG or SVG with a transparent background. \*Optional when `--layer-front` and `--layer-middle` are both given: the icon is then assembled from them. See [Assembling the icon from layer art](#assembling-the-icon-from-layer-art). | | `--background ` | `inputs.backgroundImage` | `background` | path | **required** | Background PNG or SVG. | | `--color ` | `inputs.backgroundColor` | `color` | `#RRGGBB` | **required** | Splash background color, light mode. | | `--dark-color ` | `inputs.darkBackgroundColor` | `darkColor` | `#RRGGBB` | auto | Dark mode splash background. Auto-darkened from `--color` (50% HSL lightness reduction). | @@ -80,6 +80,8 @@ Every option, on every surface. **Config key** is the dotted path in `tvos-asset | `--set brandAssets.appIconSmall.size.width=` | `brandAssets.appIconSmall.size` | via `config` | `{width,height}` | `400x240` | Base size in points, multiplied by each scale. | | `--set brandAssets.appIconSmall.scales=` | `brandAssets.appIconSmall.scales` | via `config` | string[] | `1x,2x` | Scale factors to generate. | | `--set brandAssets.appIconLarge.*=` | `brandAssets.appIconLarge.*` | via `config` | same four keys | `App Icon - App Store`, `1280x768`, `1x` | App Store imagestack. Same structure as `appIconSmall`. | +| `--ios-icon-scale <0-1>` | `iosIcon.iconScale` | `iosIconScale` | number | `0.8` | How much of the 1024 canvas the mark covers. Apple's icon grid centres the primary shape at about 80%. See [Sizing the mark](#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 both imagestacks and both Top Shelf images. Apple asks for a 10-15% safe margin per layer. | | `--layer-front`, `--layer-middle`, `--layer-back` | `brandAssets..layers..imagePath` | `layers` | path | icon, icon, background | Custom parallax art per layer. The CLI flags apply to both imagestacks. See [Per-layer parallax art](#per-layer-parallax-art). | | `--set brandAssets..layers..source=` | `brandAssets..layers..source` | via `config` | `icon` \| `background` | front/middle `icon`, back `background` | How the layer renders: `icon` is centered on transparency, `background` is an opaque cover fill. | | `--no-top-shelf` | `brandAssets.topShelfImage(Wide).enabled` | via `config` | boolean | `true` | Both Top Shelf imagesets on/off. | @@ -143,6 +145,8 @@ Every capability, and how to reach it from each surface. "via `config`" means th | Home screen icon size | `--set brandAssets.appIconSmall.size.width=` | `brandAssets.appIconSmall.size` | via `config` | | Home screen icon scales | `--set brandAssets.appIconSmall.scales=` | `brandAssets.appIconSmall.scales` | via `config` | | App Store icon (all of the above) | `--set brandAssets.appIconLarge.*=` | `brandAssets.appIconLarge.*` | via `config` | +| Mark size on iOS | `--ios-icon-scale` | `iosIcon.iconScale` | `iosIconScale` | +| Mark size on tvOS | `--tv-icon-scale` | `brandAssets.iconScale` | `tvIconScale` | | Per-layer parallax art | `--layer-front`, `--layer-middle`, `--layer-back` | `brandAssets.*.layers.*.imagePath` | `layers` | | Per-layer source | `--set brandAssets.*.layers.*.source=` | `brandAssets.*.layers.*.source` | via `config` | | Top Shelf on/off | `--no-top-shelf` | `brandAssets.topShelfImage(Wide).enabled` | via `config` | @@ -187,7 +191,6 @@ Regenerate all assets automatically on every `expo prebuild`, for both tvOS (`EX ```json "plugins": [ ["tvos-assets/plugin", { - "icon": "./assets/brand/icon.svg", "background": "./assets/brand/background.png", "color": "#1C1C1E", "iconBorderRadius": 0, @@ -196,7 +199,7 @@ Regenerate all assets automatically on every `expo prebuild`, for both tvOS (`EX ] ``` -Props are the **Plugin** column of the [options table](#options); all paths resolve relative to the project root. Anything without a dedicated prop is reachable through `config`, a path to a full JSON config file deep-merged under the props above. +No `icon` prop above: with art for both icon layers, the plugin assembles one. Props are the **Plugin** column of the [options table](#options); all paths resolve relative to the project root. Anything without a dedicated prop is reachable through `config`, a path to a full JSON config file deep-merged under the props above. Install as a devDependency (`npm i -D tvos-assets`) and list the plugin **after** `expo-splash-screen` (and any TV config plugin, such as `@react-native-tvos/config-tv`) so the generated splash imagesets overwrite their single-icon output. @@ -235,6 +238,57 @@ Registration matters. Icon-sourced layers are all placed identically (centered, Open the generated `preview.html` and point at the imagestack to check your layer separation before building. +## Sizing the mark + +Scale alone does not place a mark, because it sizes the *artboard* and your own +transparent margin then shrinks the mark inside it. Tomo TV's art fills 67.6% of +its 1024 artboard, so an 0.8 scale drew it at 54% of the icon. + +So the icon is first normalised to the square its visible artwork occupies, and +the scale is applied to that. `--ios-icon-scale 0.8` means the mark covers 80% of +the icon, whatever padding your file carries: + +| | Default | Why | +| --- | --- | --- | +| `--ios-icon-scale` | `0.8` | Apple's icon grid centres the primary shape at roughly 80% of the canvas, leaving about a 10% margin. | +| `--tv-icon-scale` | `0.75` | tvOS wants 10-15% of safe margin on every parallax layer: a focused icon scales up ~1.05-1.1x and its layers slide against each other, so content near an edge clips. 0.75 leaves 12.5% a side. | + +One box is measured per run, from the flat icon, and applied to every surface +*and* every parallax layer. Sharing one transform is what keeps the layers +registered: normalising each layer to its own bounds would slide the front layer +off the middle one. + +The splash screen logo is not affected. It already fills its own square. + +## Assembling the icon from layer art + +Because every icon-sourced layer sits on that same square artboard, the flat icon +is just those layers stacked. Supplying it separately means keeping a third file +in sync with two you already ship, so when you give art for **every** icon layer, +`--icon` becomes optional: + +```bash +tvos-assets --background bg.png --color "#1C1C1E" \ + --layer-front ./layer-front.svg --layer-middle ./layer-middle.svg +``` + +The icon is composited back to front from that art (the Back layer is the +background, so it is left out) and used everywhere a flat icon is needed: the iOS +`AppIcon.appiconset`, both Top Shelf images, the splash screen logo, and +`icon.png`. The imagestacks keep reading your layer files directly, so vector art +stays vector there. + +Details worth knowing: + +- **Every** icon layer needs art. With only `--layer-front`, the Middle layer + would still fall back to the icon being derived, so `--icon` stays required. +- When the two imagestacks carry different art, the icon is assembled from + `appIconLarge`. +- `iconBorderRadius` applies to the assembled icon, in the units of your layer + artboard, exactly as it would to a supplied one. +- `preview.html` lists the assembled icon after the layers it came from, so you + can check the composite against its parts. + ## Programmatic API ```js diff --git a/plugin/index.cjs b/plugin/index.cjs index b1086e4..a5935c8 100644 --- a/plugin/index.cjs +++ b/plugin/index.cjs @@ -4,12 +4,17 @@ * Usage in app.json (list AFTER expo-splash-screen and any TV config plugin so * the generated assets overwrite their single-icon output): * + * `icon` is optional when `layers` supplies art for both front and middle: the + * icon is then assembled from that art. + * * ["tvos-assets/plugin", { * "icon": "./assets/brand/icon.svg", * "background": "./assets/brand/background.png", * "color": "#1C1C1E", * "darkColor": "#1C1C1E", * "iconBorderRadius": 0, + * "iosIconScale": 0.8, + * "tvIconScale": 0.75, * "iconDark": "./assets/brand/icon-dark.svg", * "iconTinted": "./assets/brand/icon-tinted.svg", * "layers": { "front": "./assets/brand/layer-front.svg", "middle": "./assets/brand/layer-middle.svg" }, @@ -80,6 +85,8 @@ function buildResolveArgs(projectRoot, props) { iconTinted: resolveInput(projectRoot, props.iconTinted), config: resolveInput(projectRoot, props.config), iconBorderRadius: props.iconBorderRadius != null ? String(props.iconBorderRadius) : undefined, + iosIconScale: props.iosIconScale != null ? String(props.iosIconScale) : undefined, + tvIconScale: props.tvIconScale != null ? String(props.tvIconScale) : undefined, // Not used for output (assets go straight into the xcassets catalog), but // keeps resolveConfig's output-dir writability check pointed somewhere real. outDir: path.join(projectRoot, "ios"), diff --git a/schema.json b/schema.json index a9d9ec4..b7df8e9 100644 --- a/schema.json +++ b/schema.json @@ -15,7 +15,7 @@ "properties": { "iconImage": { "type": "string", - "description": "Path to the app icon PNG or SVG. Must have a transparent background. The icon is composited onto the background image for app icons and Top Shelf images, and rendered standalone for the splash screen logo. SVGs are rasterized at the density each output size requires." + "description": "Path to the app icon PNG or SVG. Must have a transparent background. The icon is composited onto the background image for app icons and Top Shelf images, and rendered standalone for the splash screen logo. SVGs are rasterized at the density each output size requires. Optional when every icon layer of an imagestack carries its own art (brandAssets..layers.front/middle.imagePath): the icon is then assembled from those layers, back to front." }, "backgroundImage": { "type": "string", @@ -46,7 +46,10 @@ "description": "Optional PNG or SVG for the iOS tinted-appearance icon. When omitted, a grayscale version of the main icon is generated." } }, - "required": ["iconImage", "backgroundImage", "backgroundColor"], + "required": [ + "backgroundImage", + "backgroundColor" + ], "additionalProperties": false }, "output": { @@ -60,13 +63,16 @@ }, "mode": { "type": "string", - "enum": ["zip", "dir"], + "enum": [ + "zip", + "dir" + ], "default": "zip", "description": "\"zip\" writes a timestamped archive. \"dir\" writes Images.xcassets directly into the output directory (what the Expo config plugin uses)." }, "cleanBeforeGenerate": { "type": "boolean", - "description": "Deprecated — ignored. Output is always generated in a fresh temp directory before zipping.", + "description": "Deprecated \u2014 ignored. Output is always generated in a fresh temp directory before zipping.", "default": true } }, @@ -96,6 +102,13 @@ "topShelfImageWide": { "$ref": "#/$defs/imageSetAsset", "description": "Wide variant of the Top Shelf banner. Same compositing as topShelfImage. Default: 2320x720 at 1x and 2x." + }, + "iconScale": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1, + "default": 0.75, + "description": "How much of the shorter tvOS output side the mark covers on the imagestacks and Top Shelf images, measured after the icon is normalized to its own artwork. Apple asks for a 10-15% safe margin on every parallax layer, because a focused icon scales up and its layers shift against each other; 0.75 leaves 12.5% a side." } }, "additionalProperties": false @@ -113,6 +126,13 @@ "type": "string", "description": "Folder name for the .appiconset bundle. Must match the ASSETCATALOG_COMPILER_APPICON_NAME build setting for the iOS target.", "default": "AppIcon" + }, + "iconScale": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1, + "default": 0.8, + "description": "How much of the 1024 canvas the mark covers, measured after the icon is normalized to its own artwork so a designer's transparent margin does not shrink it. Apple's icon grid centers the primary shape at roughly 80% of the canvas, with about a 10% margin." } }, "additionalProperties": false @@ -153,7 +173,11 @@ "scales": { "$ref": "#/$defs/scaleFactorArray", "description": "Scale factors for universal idiom.", - "default": ["1x", "2x", "3x"] + "default": [ + "1x", + "2x", + "3x" + ] } }, "additionalProperties": false @@ -165,7 +189,10 @@ "scales": { "$ref": "#/$defs/scaleFactorArray", "description": "Scale factors for tv idiom.", - "default": ["1x", "2x"] + "default": [ + "1x", + "2x" + ] } }, "additionalProperties": false @@ -224,12 +251,18 @@ "$defs": { "scaleFactor": { "type": "string", - "enum": ["1x", "2x", "3x"], + "enum": [ + "1x", + "2x", + "3x" + ], "description": "Image scale factor. 1x = base resolution, 2x = retina, 3x = super retina." }, "scaleFactorArray": { "type": "array", - "items": { "$ref": "#/$defs/scaleFactor" }, + "items": { + "$ref": "#/$defs/scaleFactor" + }, "minItems": 1, "uniqueItems": true }, @@ -248,12 +281,18 @@ "description": "Height in points." } }, - "required": ["width", "height"], + "required": [ + "width", + "height" + ], "additionalProperties": false }, "imageSource": { "type": "string", - "enum": ["icon", "background"], + "enum": [ + "icon", + "background" + ], "description": "Which input image to use. \"icon\" composites the icon on the background. \"background\" uses the background image only." }, "layerConfig": { @@ -269,7 +308,9 @@ "description": "Optional PNG or SVG that replaces the default source file for this layer, enabling true per-layer parallax art. The source field still controls rendering: icon layers sit centered on transparency, background layers cover-fill opaque." } }, - "required": ["source"], + "required": [ + "source" + ], "additionalProperties": false }, "layers": { @@ -289,7 +330,11 @@ "description": "Background layer (farthest from viewer). Typically the background image. Always rendered opaque." } }, - "required": ["front", "middle", "back"], + "required": [ + "front", + "middle", + "back" + ], "additionalProperties": false }, "imageStackAsset": { @@ -318,7 +363,13 @@ "description": "Layer configuration for the parallax imagestack." } }, - "required": ["enabled", "name", "size", "scales", "layers"], + "required": [ + "enabled", + "name", + "size", + "scales", + "layers" + ], "additionalProperties": false }, "imageSetAsset": { @@ -347,7 +398,13 @@ "description": "Filename prefix for generated PNGs. Produces files like {filePrefix}@1x.png." } }, - "required": ["enabled", "name", "size", "scales", "filePrefix"], + "required": [ + "enabled", + "name", + "size", + "scales", + "filePrefix" + ], "additionalProperties": false }, "colorEntry": { @@ -365,7 +422,10 @@ "description": "Color for dark mode (#RRGGBB)." } }, - "required": ["light", "dark"], + "required": [ + "light", + "dark" + ], "additionalProperties": false } } diff --git a/src/config.ts b/src/config.ts index 34f68cd..a8c8e97 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,7 @@ import { readFileSync, existsSync, lstatSync, statSync, accessSync, constants } import { resolve, join, extname, dirname } from "node:path"; import { homedir } from "node:os"; import sharp from "sharp"; -import type { TvOSImageCreatorConfig } from "./types.js"; +import type { ImageStackAssetConfig, TvOSImageCreatorConfig } from "./types.js"; import { darkenHex } from "./utils/color.js"; const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); @@ -23,6 +23,10 @@ export interface CLIArgs { iconBorderRadius?: string; iconDark?: string; iconTinted?: string; + /** Fraction of the 1024 canvas the mark covers on the iOS app icon. */ + iosIconScale?: string; + /** Fraction of the shorter output side the mark covers on tvOS. */ + tvIconScale?: string; /** Programmatic config overrides; higher precedence than a config file, lower than explicit args. */ overrides?: DeepPartial; } @@ -52,6 +56,7 @@ function getDefaultConfig( }, brandAssets: { name: "AppIcon", + iconScale: TV_ICON_SCALE, appIconSmall: { enabled: true, name: "App Icon", @@ -92,6 +97,7 @@ function getDefaultConfig( iosIcon: { enabled: true, name: "AppIcon", + iconScale: IOS_ICON_SCALE, }, splashScreen: { logo: { @@ -212,6 +218,64 @@ function validateAssetName(name: string, label: string): void { } } +const MISSING_ICON_MESSAGE = + "Icon image is required. Use --icon, set inputs.iconImage in config, or supply art for " + + "every icon layer (--layer-front and --layer-middle) so the icon can be assembled from it."; + +/** + * Whether any icon layer art was supplied at all, anywhere in the incoming + * config. Deliberately permissive: a false positive only defers the missing-icon + * error to the post-merge check, which throws the same message once every layer + * is resolved. It exists so a run with no inputs still names the icon first. + */ +function mayAssembleIcon(...sources: (DeepPartial | undefined)[]): boolean { + return sources.some((source) => { + const brand = source?.brandAssets; + return [brand?.appIconSmall, brand?.appIconLarge].some( + (stack) => stack?.layers?.front?.imagePath ?? stack?.layers?.middle?.imagePath, + ); + }); +} + +/** Apple stacks imagestack layers back to front, and so does an assembled icon. */ +const STACKING_ORDER = ["back", "middle", "front"] as const; + +/** + * The layer art this stack's icon can be assembled from, in stacking order, or + * undefined when it cannot. + * + * Only `source: "icon"` layers count: a `background` layer is the backdrop, not + * part of the icon. Every one of them must carry its own `imagePath`, because a + * layer without one falls back to `inputs.iconImage` — the very thing being + * derived. + */ +function assemblyLayers(stack: ImageStackAssetConfig): string[] | undefined { + const paths = STACKING_ORDER.map((key) => stack.layers[key]) + .filter((layer) => layer.source === "icon") + .map((layer) => layer.imagePath); + + if (paths.length === 0) return undefined; + return paths.every((path): path is string => Boolean(path)) ? paths : undefined; +} + +/** + * Apple's iOS icon grid centres the primary shape at about 80% of the canvas, + * with roughly a 10% margin. tvOS wants more room: the guidance is a 10-15% + * safe margin on each layer, since a focused icon scales up and its layers + * slide against each other, so 0.75 sits inside that band at 12.5% a side. + */ +const IOS_ICON_SCALE = 0.8; +const TV_ICON_SCALE = 0.75; + +function resolveIconScale(raw: unknown, label: string, fallback: number): number { + if (raw === undefined || raw === null || raw === "") return fallback; + const scale = Number(raw); + if (!Number.isFinite(scale) || scale <= 0 || scale > 1) { + throw new Error(`Invalid ${label}: "${String(raw)}". Use a number greater than 0 and at most 1.`); + } + return scale; +} + const MAX_CONFIG_SIZE = 1024 * 1024; // 1 MB export function resolveConfig(cliArgs: CLIArgs): TvOSImageCreatorConfig { @@ -256,8 +320,11 @@ export function resolveConfig(cliArgs: CLIArgs): TvOSImageCreatorConfig { "" ).trim(); - if (!iconImage) { - throw new Error("Icon image is required. Use --icon or set inputs.iconImage in config."); + // An icon is required unless parallax layer art can be assembled into one. + // Whether it actually can is only knowable after the merge resolves every + // layer, so this reports the clear-cut case and defers the rest. + if (!iconImage && !mayAssembleIcon(fileConfig, cliArgs.overrides)) { + throw new Error(MISSING_ICON_MESSAGE); } if (!backgroundImage) { throw new Error("Background image is required. Use --background or set inputs.backgroundImage in config."); @@ -266,7 +333,7 @@ export function resolveConfig(cliArgs: CLIArgs): TvOSImageCreatorConfig { throw new Error("Background color is required. Use --color or set inputs.backgroundColor in config."); } - const resolvedIcon = validateImagePath(iconImage, "Icon image"); + const resolvedIcon = iconImage ? validateImagePath(iconImage, "Icon image") : ""; const resolvedBg = validateImagePath(backgroundImage, "Background image"); // Validate color format @@ -371,6 +438,28 @@ export function resolveConfig(cliArgs: CLIArgs): TvOSImageCreatorConfig { } } + merged.iosIcon.iconScale = resolveIconScale( + cliArgs.iosIconScale ?? merged.iosIcon.iconScale, + "iosIcon.iconScale", + IOS_ICON_SCALE, + ); + merged.brandAssets.iconScale = resolveIconScale( + cliArgs.tvIconScale ?? merged.brandAssets.iconScale, + "brandAssets.iconScale", + TV_ICON_SCALE, + ); + + // With no icon input, assemble one from the parallax layer art. Assigned + // unconditionally so a config file cannot inject a derived value. + merged.inputs.iconAssembledFrom = resolvedIcon + ? undefined + : assemblyLayers(merged.brandAssets.appIconLarge) ?? + assemblyLayers(merged.brandAssets.appIconSmall); + + if (!resolvedIcon && !merged.inputs.iconAssembledFrom) { + throw new Error(MISSING_ICON_MESSAGE); + } + // Apply CLI output overrides; --out-dir wins and switches to direct-directory mode if (cliArgs.output) { merged.output.directory = cliArgs.output; diff --git a/src/generators/appiconset.ts b/src/generators/appiconset.ts index dbd0924..11003ac 100644 --- a/src/generators/appiconset.ts +++ b/src/generators/appiconset.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; import type { TvOSImageCreatorConfig } from "../types.js"; +import type { ContentBox } from "../utils/image-processing.js"; import { ensureDir, writeContentsJson, safeWriteFile } from "../utils/fs.js"; import { compositeIconOnBackground, @@ -26,6 +27,7 @@ export async function generateAppIconSet( parentDir: string, config: TvOSImageCreatorConfig, iconSourceSize?: number, + content?: ContentBox, ): Promise { if (!config.iosIcon.enabled) return; @@ -38,6 +40,8 @@ export async function generateAppIconSet( const borderOpts = { borderRadius: config.inputs.iconBorderRadius, sourceIconSize: iconSourceSize, + iconScale: config.iosIcon.iconScale, + content, }; const lightBuffer = await compositeIconOnBackground( diff --git a/src/generators/brand-assets.ts b/src/generators/brand-assets.ts index 8cd6b49..ba66317 100644 --- a/src/generators/brand-assets.ts +++ b/src/generators/brand-assets.ts @@ -4,8 +4,9 @@ import { ensureDir, writeContentsJson } from "../utils/fs.js"; import { brandAssetsContentsJson } from "./contents-json.js"; import { generateImageStack } from "./imagestack.js"; import { generateTopShelfImageSet } from "./imageset.js"; +import type { ContentBox } from "../utils/image-processing.js"; -export async function generateBrandAssets(outputDir: string, config: TvOSImageCreatorConfig, iconSourceSize?: number): Promise { +export async function generateBrandAssets(outputDir: string, config: TvOSImageCreatorConfig, iconSourceSize?: number, content?: ContentBox): Promise { const brandDir = join(outputDir, `${config.brandAssets.name}.brandassets`); ensureDir(brandDir); @@ -61,9 +62,9 @@ export async function generateBrandAssets(outputDir: string, config: TvOSImageCr } await Promise.all([ - withContext(brandAssets.appIconSmall.name, generateImageStack(brandDir, brandAssets.appIconSmall, config, iconSourceSize)), - withContext(brandAssets.appIconLarge.name, generateImageStack(brandDir, brandAssets.appIconLarge, config, iconSourceSize)), - withContext(brandAssets.topShelfImage.name, generateTopShelfImageSet(brandDir, brandAssets.topShelfImage, config, iconSourceSize)), - withContext(brandAssets.topShelfImageWide.name, generateTopShelfImageSet(brandDir, brandAssets.topShelfImageWide, config, iconSourceSize)), + withContext(brandAssets.appIconSmall.name, generateImageStack(brandDir, brandAssets.appIconSmall, config, iconSourceSize, content)), + withContext(brandAssets.appIconLarge.name, generateImageStack(brandDir, brandAssets.appIconLarge, config, iconSourceSize, content)), + withContext(brandAssets.topShelfImage.name, generateTopShelfImageSet(brandDir, brandAssets.topShelfImage, config, iconSourceSize, content)), + withContext(brandAssets.topShelfImageWide.name, generateTopShelfImageSet(brandDir, brandAssets.topShelfImageWide, config, iconSourceSize, content)), ]); } diff --git a/src/generators/icon.ts b/src/generators/icon.ts index 111728a..c9e6e31 100644 --- a/src/generators/icon.ts +++ b/src/generators/icon.ts @@ -1,8 +1,9 @@ import type { TvOSImageCreatorConfig } from "../types.js"; import { safeWriteFile } from "../utils/fs.js"; import { compositeIconOnBackground } from "../utils/image-processing.js"; +import type { ContentBox } from "../utils/image-processing.js"; -export async function generateIcon(config: TvOSImageCreatorConfig, iconOutputPath: string, iconSourceSize?: number): Promise { +export async function generateIcon(config: TvOSImageCreatorConfig, iconOutputPath: string, iconSourceSize?: number, content?: ContentBox): Promise { const buffer = await compositeIconOnBackground( config.inputs.backgroundImage, config.inputs.iconImage, @@ -12,6 +13,8 @@ export async function generateIcon(config: TvOSImageCreatorConfig, iconOutputPat opaque: true, borderRadius: config.inputs.iconBorderRadius, sourceIconSize: iconSourceSize, + iconScale: config.iosIcon.iconScale, + content, }, ); safeWriteFile(iconOutputPath, buffer); diff --git a/src/generators/imageset.ts b/src/generators/imageset.ts index 68b9db8..679a19a 100644 --- a/src/generators/imageset.ts +++ b/src/generators/imageset.ts @@ -11,6 +11,7 @@ import { scaleMultiplier, validateOutputDimensions, } from "../utils/image-processing.js"; +import type { ContentBox } from "../utils/image-processing.js"; import { imageSetContentsJson, buildTopShelfImageEntries, @@ -23,6 +24,7 @@ export async function generateTopShelfImageSet( asset: ImageSetAssetConfig, config: TvOSImageCreatorConfig, iconSourceSize?: number, + content?: ContentBox, ): Promise { if (!asset.enabled) return; @@ -51,6 +53,8 @@ export async function generateTopShelfImageSet( opaque: true, borderRadius: config.inputs.iconBorderRadius, sourceIconSize: iconSourceSize, + iconScale: config.brandAssets.iconScale, + content, }, ); diff --git a/src/generators/imagestack.ts b/src/generators/imagestack.ts index 9ca72c9..37e67b8 100644 --- a/src/generators/imagestack.ts +++ b/src/generators/imagestack.ts @@ -7,6 +7,7 @@ import { scaleMultiplier, validateOutputDimensions, } from "../utils/image-processing.js"; +import type { ContentBox } from "../utils/image-processing.js"; import { imageStackContentsJson, imageStackLayerContentsJson, @@ -24,14 +25,18 @@ async function generateLayerImages( imagesetDir: string, isAppStore: boolean, iconSourceSize?: number, + content?: ContentBox, ): Promise { const layerKey = layerName.toLowerCase() as "front" | "middle" | "back"; const layerConfig = asset.layers[layerKey]; const borderRadius = config.inputs.iconBorderRadius; // Border radius applies only to the shared icon input — per-layer art is used as-is - const borderOpts = layerConfig.source !== "background" && !layerConfig.imagePath && borderRadius > 0 && iconSourceSize + const border = layerConfig.source !== "background" && !layerConfig.imagePath && borderRadius > 0 && iconSourceSize ? { borderRadius, sourceIconSize: iconSourceSize } : undefined; + // Every icon layer shares the run's one content box and one scale, which is + // what keeps front registered against middle once they are normalised. + const iconOpts = { ...border, iconScale: config.brandAssets.iconScale, content }; // imagePath overrides which file feeds the layer; `source` still decides rendering: // background layers cover-fill opaque, icon layers sit centered on transparency. @@ -47,7 +52,7 @@ async function generateLayerImages( const buffer = layerConfig.source === "background" ? await resizeImageOpaque(backgroundFile, w, h) - : await renderIconOnTransparentCanvas(iconFile, w, h, borderOpts); + : await renderIconOnTransparentCanvas(iconFile, w, h, iconOpts); safeWriteFile(join(imagesetDir, filename), buffer); return; @@ -62,7 +67,7 @@ async function generateLayerImages( const buffer = layerConfig.source === "background" ? await resizeImageOpaque(backgroundFile, w, h) - : await renderIconOnTransparentCanvas(iconFile, w, h, borderOpts); + : await renderIconOnTransparentCanvas(iconFile, w, h, iconOpts); safeWriteFile(join(imagesetDir, filename), buffer); } @@ -73,6 +78,7 @@ export async function generateImageStack( asset: ImageStackAssetConfig, config: TvOSImageCreatorConfig, iconSourceSize?: number, + content?: ContentBox, ): Promise { if (!asset.enabled) return; @@ -106,6 +112,6 @@ export async function generateImageStack( writeContentsJson(join(imagesetDir, "Contents.json"), imagesetContents); // Generate actual PNG files - await generateLayerImages(layerName, asset, config, imagesetDir, isAppStore, iconSourceSize); + await generateLayerImages(layerName, asset, config, imagesetDir, isAppStore, iconSourceSize, content); } } diff --git a/src/generators/preview-html.ts b/src/generators/preview-html.ts index 68e690d..967b03a 100644 --- a/src/generators/preview-html.ts +++ b/src/generators/preview-html.ts @@ -435,7 +435,13 @@ export function renderPreviewHtml(options: RenderPreviewOptions): string { // config here is already the display copy: paths rewritten relative or tildified. const meta: [string, string][] = [ - ["Icon", esc(config.inputs.iconImage)], + [ + "Icon", + // No icon was supplied: name the layer art it was assembled from instead. + config.inputs.iconAssembledFrom + ? esc(`${config.inputs.iconAssembledFrom.join(" + ")} (assembled)`) + : esc(config.inputs.iconImage), + ], ["Background", esc(config.inputs.backgroundImage)], ["Color", `${esc(config.inputs.backgroundColor)}`], [ diff --git a/src/generators/preview.ts b/src/generators/preview.ts index f1d36b3..e8cc9c3 100644 --- a/src/generators/preview.ts +++ b/src/generators/preview.ts @@ -105,7 +105,10 @@ function displayConfig( const show = (path: string): string => displayPath(path, previewDir, outside === "relative"); const copy = JSON.parse(JSON.stringify(config)) as TvOSImageCreatorConfig; - copy.inputs.iconImage = show(copy.inputs.iconImage); + // An assembled icon lives in a scratch dir that is gone by the time anyone + // reads the page, so the layer art it came from is what gets shown instead. + copy.inputs.iconImage = copy.inputs.iconAssembledFrom ? "" : show(copy.inputs.iconImage); + copy.inputs.iconAssembledFrom = copy.inputs.iconAssembledFrom?.map(show); copy.inputs.backgroundImage = show(copy.inputs.backgroundImage); if (copy.inputs.iconDarkImage) copy.inputs.iconDarkImage = show(copy.inputs.iconDarkImage); if (copy.inputs.iconTintedImage) copy.inputs.iconTintedImage = show(copy.inputs.iconTintedImage); @@ -401,9 +404,19 @@ export interface PreviewInput { href?: string; } +interface InputCandidate { + role: string; + path?: string; + /** Written to a scratch dir this run deletes, so a link to it would dangle. */ + ephemeral?: boolean; +} + /** * Every source file the run reads, in the order they matter. Layer art is * deduplicated: the same file applied to both imagestacks is one input. + * + * A supplied icon leads the list. An assembled one is a product of the layer + * art, not a source, so it follows the layers it was built from. */ async function collectInputs( config: TvOSImageCreatorConfig, @@ -411,22 +424,32 @@ async function collectInputs( previewDir: string, outside: OutsideLinkStyle, ): Promise { - const candidates: { role: string; path?: string }[] = [ - { role: "icon", path: config.inputs.iconImage }, - { role: "background", path: config.inputs.backgroundImage }, - { role: "icon dark", path: config.inputs.iconDarkImage }, - { role: "icon tinted", path: config.inputs.iconTintedImage }, - ]; + const assembled = Boolean(config.inputs.iconAssembledFrom); + const icon: InputCandidate = { + role: assembled ? "icon (assembled)" : "icon", + path: config.inputs.iconImage, + ephemeral: assembled, + }; + + const layers: InputCandidate[] = []; for (const stack of [config.brandAssets.appIconSmall, config.brandAssets.appIconLarge]) { for (const layer of ["front", "middle", "back"] as const) { - candidates.push({ role: `layer ${layer}`, path: stack.layers[layer].imagePath }); + layers.push({ role: `layer ${layer}`, path: stack.layers[layer].imagePath }); } } + const rest: InputCandidate[] = [ + { role: "background", path: config.inputs.backgroundImage }, + { role: "icon dark", path: config.inputs.iconDarkImage }, + { role: "icon tinted", path: config.inputs.iconTintedImage }, + ]; + + const candidates = assembled ? [...layers, icon, ...rest] : [icon, ...rest, ...layers]; + const inputs: PreviewInput[] = []; const seen = new Set(); - for (const { role, path } of candidates) { + for (const { role, path, ephemeral } of candidates) { if (!path || seen.has(path) || !existsSync(path)) continue; seen.add(path); const { key, width, height, hasAlpha } = await table.add(path); @@ -438,7 +461,7 @@ async function collectInputs( imageKey: key, hasAlpha, vector: extname(path).toLowerCase() === ".svg", - href: fileHref(path, previewDir, outside), + href: ephemeral ? undefined : fileHref(path, previewDir, outside), }); } return inputs; diff --git a/src/index.ts b/src/index.ts index 64e7bed..df89fb9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -118,13 +118,18 @@ program .description("Generate tvOS and iOS Images.xcassets from icon and background images") .version(version) // Inputs - .option("--icon ", "Path to icon PNG or SVG (transparent background)") + .option( + "--icon ", + "Path to icon PNG or SVG (transparent background; optional when --layer-front and --layer-middle are given)", + ) .option("--background ", "Path to background PNG or SVG") .option("--color ", 'Background color hex (e.g. "#B43939")') .option("--dark-color ", "Dark mode background color hex (default: auto-darkened from --color)") .option("--icon-dark ", "iOS dark-appearance icon override (default: derived from --icon)") .option("--icon-tinted ", "iOS tinted-appearance icon override (default: grayscale of --icon)") .option("--icon-border-radius ", "Border radius for icon in pixels (0 = square, large value = circle)") + .option("--ios-icon-scale <0-1>", "Fraction of the iOS 1024 canvas the mark covers (default 0.8)") + .option("--tv-icon-scale <0-1>", "Fraction of the shorter tvOS side the mark covers (default 0.75)") .option("--layer-front ", "Custom front parallax layer art (both imagestacks)") .option("--layer-middle ", "Custom middle parallax layer art (both imagestacks)") .option("--layer-back ", "Custom back parallax layer art (both imagestacks)") @@ -238,6 +243,8 @@ Examples: output: options.output, outDir: options.outDir, iconBorderRadius: options.iconBorderRadius, + iosIconScale: options.iosIconScale as string | undefined, + tvIconScale: options.tvIconScale as string | undefined, overrides, }); @@ -259,7 +266,12 @@ Examples: const shown = relative(process.cwd(), configPath) || configPath; log(` Config: ${pc.cyan(shown)}${discoveredConfig ? pc.dim(" (auto-detected)") : ""}`); } - log(` Icon: ${pc.cyan(config.inputs.iconImage)}`); + const assembledFrom = config.inputs.iconAssembledFrom; + log( + assembledFrom + ? ` Icon: ${pc.cyan(assembledFrom.join(" + "))}${pc.dim(" (assembled)")}` + : ` Icon: ${pc.cyan(config.inputs.iconImage)}`, + ); log(` Background: ${pc.cyan(config.inputs.backgroundImage)}`); log(` Color: ${pc.cyan(config.inputs.backgroundColor)}`); const darkColorAuto = diff --git a/src/lib.ts b/src/lib.ts index 1ae05d5..943dc72 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -1,3 +1,5 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveConfig, @@ -16,7 +18,8 @@ import { generateIcon } from "./generators/icon.js"; import { generatePreview } from "./generators/preview.js"; import type { OutsideLinkStyle } from "./generators/preview.js"; import type { TvOSImageCreatorConfig } from "./types.js"; -import { ensureDir, cleanDir, writeContentsJson } from "./utils/fs.js"; +import { ensureDir, cleanDir, writeContentsJson, safeWriteFile } from "./utils/fs.js"; +import { assembleIconFromLayers, contentBox } from "./utils/image-processing.js"; export type TargetPlatform = "tvos" | "ios"; @@ -139,17 +142,48 @@ export function planAssets(config: TvOSImageCreatorConfig, options: PlanOptions * The catalog may already exist (e.g. an Expo-generated ios//Images.xcassets); * asset directories owned by this tool are cleaned and rewritten, everything else * in the catalog is left untouched. + * + * When the icon is assembled from layer art rather than supplied, the assembled + * PNG is written to a scratch directory that is removed once the run is done: it + * is an intermediate, and every asset derived from it is already a real output. */ export async function generateAssets( config: TvOSImageCreatorConfig, xcassetsDir: string, options: GenerateOptions = {}, +): Promise { + if (!config.inputs.iconAssembledFrom) { + if (!config.inputs.iconImage) { + throw new Error("Config has no icon image and no layer art to assemble one from."); + } + return generateResolved(config, xcassetsDir, options); + } + + const scratch = mkdtempSync(join(tmpdir(), "tvos-assets-")); + try { + const iconPath = join(scratch, "assembled-icon.png"); + safeWriteFile(iconPath, await assembleIconFromLayers(config.inputs.iconAssembledFrom)); + const resolved = { ...config, inputs: { ...config.inputs, iconImage: iconPath } }; + return await generateResolved(resolved, xcassetsDir, options); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +async function generateResolved( + config: TvOSImageCreatorConfig, + xcassetsDir: string, + options: GenerateOptions, ): Promise { const platforms = options.platforms ?? ["tvos", "ios"]; const step = options.onStep ?? (() => {}); const { warnings, iconSourceSize } = await validateInputImages(config); + // Measured once from the flat icon and reused for every surface, including + // each parallax layer, so one transform keeps the layers registered. + const content = await contentBox(config.inputs.iconImage); + step("Creating xcassets directory..."); ensureDir(xcassetsDir); writeContentsJson(join(xcassetsDir, "Contents.json"), rootContentsJson(config.xcassetsMeta)); @@ -157,13 +191,13 @@ export async function generateAssets( if (platforms.includes("tvos")) { step("Generating tvOS brand assets (app icons + top shelf)..."); cleanDir(join(xcassetsDir, `${config.brandAssets.name}.brandassets`)); - await generateBrandAssets(xcassetsDir, config, iconSourceSize); + await generateBrandAssets(xcassetsDir, config, iconSourceSize, content); } if (platforms.includes("ios") && config.iosIcon.enabled) { step("Generating iOS app icon (light + dark + tinted)..."); cleanDir(join(xcassetsDir, `${config.iosIcon.name}.appiconset`)); - await generateAppIconSet(xcassetsDir, config, iconSourceSize); + await generateAppIconSet(xcassetsDir, config, iconSourceSize, content); } if (config.splashScreen.logo.enabled) { @@ -180,7 +214,7 @@ export async function generateAssets( if (options.standaloneIconPath) { step("Generating icon.png (1024x1024)..."); - await generateIcon(config, options.standaloneIconPath, iconSourceSize); + await generateIcon(config, options.standaloneIconPath, iconSourceSize, content); } if (options.previewPath) { diff --git a/src/types.ts b/src/types.ts index f94316b..cb0170f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,13 @@ export interface ImageSetAssetConfig { export interface BrandAssetsConfig { name: string; + /** + * How much of the shorter output side the mark covers on tvOS, after the + * icon is normalised to its own artwork. Apple asks for a 10-15% safe margin + * on every layer, because a focused icon scales ~1.05-1.1x and the layers + * shift against each other, so content near an edge clips. + */ + iconScale: number; appIconSmall: ImageStackAssetConfig; appIconLarge: ImageStackAssetConfig; topShelfImage: ImageSetAssetConfig; @@ -92,6 +99,12 @@ export interface InputConfig { iconDarkImage?: string; /** Optional tinted-appearance iOS icon override; auto-derived (grayscale) when omitted. */ iconTintedImage?: string; + /** + * Derived, never authored: when no icon input is supplied, the parallax layer + * art the flat icon is assembled from, in back-to-front stacking order. + * `resolveConfig` sets it and `generateAssets` turns it into `iconImage`. + */ + iconAssembledFrom?: string[]; } export type OutputMode = "zip" | "dir"; @@ -105,6 +118,12 @@ export interface OutputConfig { export interface IosIconConfig { enabled: boolean; name: string; + /** + * How much of the 1024 canvas the mark covers, after the icon is normalised + * to its own artwork. Apple's icon grid centres the primary shape at roughly + * 80% of the canvas, leaving about a 10% margin. + */ + iconScale: number; } export interface XcassetsMetaConfig { diff --git a/src/utils/image-processing.ts b/src/utils/image-processing.ts index caee4b4..4174bb2 100644 --- a/src/utils/image-processing.ts +++ b/src/utils/image-processing.ts @@ -4,6 +4,9 @@ const TRANSPARENT = { r: 0, g: 0, b: 0, alpha: 0 } as const; const MAX_SVG_DENSITY = 9600; +/** Only a fallback: every generator passes an explicit, configured scale. */ +const DEFAULT_ICON_SCALE = 0.8; + /** * Open an input image for a target output size. SVGs are rasterized at a density * scaled to the target, capped at MAX_SVG_DENSITY — so bitmap upscaling in the @@ -81,14 +84,135 @@ export async function resizeImageOpaque( } } +/** + * The square of source pixels an icon's visible artwork occupies, centred on + * that artwork. + * + * Scale alone is not enough to place a mark: it sizes the *artboard*, and a + * designer's own transparent margin then shrinks the mark inside it. Tomo TV's + * art fills 67.6% of its 1024 artboard, so a 0.8 scale drew it at 54% of the + * icon rather than the 80% Apple's grid calls for. Normalising to this box + * first makes the scale mean "the mark covers N% of the canvas". + * + * One box is computed per run and applied to the flat icon *and* to every + * parallax layer. Layers are authored on the icon's own artboard, so sharing a + * single transform is what keeps them registered — trimming each layer to its + * own bounds would slide the front layer off the middle one. + */ +export interface ContentBox { + left: number; + top: number; + size: number; +} + +const ALPHA_FLOOR = 8; + +/** The square the content box is measured against; every use scales from it. */ +const BOX_SPACE = 1024; +const MAX_WORK_SIZE = 8192; + +/** Undefined when the image is fully transparent or fully opaque edge to edge. */ +export async function contentBox(iconPath: string): Promise { + try { + const { data, info } = await (await inputImage(iconPath, BOX_SPACE, BOX_SPACE)) + .resize(BOX_SPACE, BOX_SPACE, { fit: "contain", background: TRANSPARENT }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + let left = info.width; + let top = info.height; + let right = -1; + let bottom = -1; + + for (let y = 0; y < info.height; y++) { + for (let x = 0; x < info.width; x++) { + if (data[(y * info.width + x) * 4 + 3] <= ALPHA_FLOOR) continue; + if (x < left) left = x; + if (x > right) right = x; + if (y < top) top = y; + if (y > bottom) bottom = y; + } + } + + if (right < 0) return undefined; + + const boxWidth = right - left + 1; + const boxHeight = bottom - top + 1; + // Square it around the artwork's centre so a wide or tall mark keeps its + // aspect ratio and stays centred once it is scaled onto the canvas. + const size = Math.min(Math.max(boxWidth, boxHeight), info.width, info.height); + if (size >= info.width && size >= info.height) return undefined; + + const clamp = (value: number, max: number): number => + Math.max(0, Math.min(Math.round(value), max - size)); + + return { + left: clamp(left + boxWidth / 2 - size / 2, info.width), + top: clamp(top + boxHeight / 2 - size / 2, info.height), + size, + }; + } catch (err) { + wrapSharpError(err, `measuring icon content bounds of ${iconPath}`); + } +} + +/** + * Render the icon at `iconSize`, cropped to its content box when one is given. + * + * Two passes, not one chain: sharp keeps a single resize per pipeline, so a + * `resize().extract().resize()` chain silently drops the first resize and the + * crop lands outside the source. The first pass also renders at whatever + * resolution makes the *cropped* square come out at least `iconSize`, so an + * SVG is rasterised big enough instead of being upscaled after the crop. + */ +async function iconAtSize( + iconPath: string, + iconSize: number, + content: ContentBox | undefined, +): Promise { + if (!content) { + return (await inputImage(iconPath, iconSize, iconSize)) + .resize(iconSize, iconSize, { fit: "contain", background: TRANSPARENT }) + .png() + .toBuffer(); + } + + const work = Math.min( + Math.max(Math.ceil((iconSize * BOX_SPACE) / content.size), BOX_SPACE), + MAX_WORK_SIZE, + ); + const k = work / BOX_SPACE; + const size = Math.max(1, Math.round(content.size * k)); + const left = Math.max(0, Math.min(Math.round(content.left * k), work - size)); + const top = Math.max(0, Math.min(Math.round(content.top * k), work - size)); + + const cropped = await (await inputImage(iconPath, work, work)) + .resize(work, work, { fit: "contain", background: TRANSPARENT }) + .extract({ left, top, width: size, height: size }) + .png() + .toBuffer(); + + return sharp(cropped) + .resize(iconSize, iconSize, { fit: "contain", background: TRANSPARENT }) + .png() + .toBuffer(); +} + export async function compositeIconOnBackground( bgPath: string, iconPath: string, width: number, height: number, - options?: { iconScale?: number; opaque?: boolean; borderRadius?: number; sourceIconSize?: number }, + options?: { + iconScale?: number; + opaque?: boolean; + borderRadius?: number; + sourceIconSize?: number; + content?: ContentBox; + }, ): Promise { - const iconScale = options?.iconScale ?? 0.6; + const iconScale = options?.iconScale ?? DEFAULT_ICON_SCALE; const opaque = options?.opaque ?? false; const borderRadius = options?.borderRadius ?? 0; const sourceIconSize = options?.sourceIconSize ?? 0; @@ -98,11 +222,7 @@ export async function compositeIconOnBackground( const shortSide = Math.min(width, height); const iconSize = Math.round(shortSide * iconScale); - // Resize icon preserving transparency - let iconBuffer = await (await inputImage(iconPath, iconSize, iconSize)) - .resize(iconSize, iconSize, { fit: "contain", background: TRANSPARENT }) - .png() - .toBuffer(); + let iconBuffer = await iconAtSize(iconPath, iconSize, options?.content); if (borderRadius > 0 && sourceIconSize > 0) { const scaledRadius = Math.round((borderRadius / sourceIconSize) * iconSize); @@ -158,19 +278,21 @@ export async function renderIconOnTransparentCanvas( iconPath: string, width: number, height: number, - options?: { iconScale?: number; borderRadius?: number; sourceIconSize?: number }, + options?: { + iconScale?: number; + borderRadius?: number; + sourceIconSize?: number; + content?: ContentBox; + }, ): Promise { - const iconScale = options?.iconScale ?? 0.6; + const iconScale = options?.iconScale ?? DEFAULT_ICON_SCALE; const borderRadius = options?.borderRadius ?? 0; const sourceIconSize = options?.sourceIconSize ?? 0; const shortSide = Math.min(width, height); const iconSize = Math.round(shortSide * iconScale); try { - let iconBuffer = await (await inputImage(iconPath, iconSize, iconSize)) - .resize(iconSize, iconSize, { fit: "contain", background: TRANSPARENT }) - .png() - .toBuffer(); + let iconBuffer = await iconAtSize(iconPath, iconSize, options?.content); if (borderRadius > 0 && sourceIconSize > 0) { const scaledRadius = Math.round((borderRadius / sourceIconSize) * iconSize); @@ -188,6 +310,53 @@ export async function renderIconOnTransparentCanvas( } } +/** + * Assembled icons render at the largest layer's own resolution, floored at the + * 1024px icon minimum every consumer needs and capped so an oversized input + * cannot blow up memory. Nothing downstream asks for more than 1024px: the + * iOS appiconset is 1024 square and Top Shelf @2x scales the icon to 864. + */ +const ASSEMBLED_ICON_MIN = 1024; +const ASSEMBLED_ICON_MAX = 4096; + +/** + * Flatten parallax layer art into one square icon, drawn back to front. + * + * Each layer is contained and centered exactly as `renderIconOnTransparentCanvas` + * places it inside an imagestack, so the result is that stack seen head-on with + * its background layer left out — the same artwork the icon input would carry, + * without asking for a hand-maintained third copy of it. + */ +export async function assembleIconFromLayers(paths: string[]): Promise { + if (paths.length === 0) { + throw new Error("Cannot assemble an icon from zero layers."); + } + + try { + const metas = await Promise.all(paths.map((p) => sharp(p).metadata())); + const largest = Math.max(...metas.map((m) => Math.max(m.width ?? 0, m.height ?? 0))); + const size = Math.min(Math.max(largest, ASSEMBLED_ICON_MIN), ASSEMBLED_ICON_MAX); + + const layers = await Promise.all( + paths.map(async (path) => + (await inputImage(path, size, size)) + .resize(size, size, { fit: "contain", background: TRANSPARENT }) + .png() + .toBuffer(), + ), + ); + + return await sharp({ + create: { width: size, height: size, channels: 4, background: TRANSPARENT }, + }) + .composite(layers.map((input) => ({ input, gravity: "center" as const }))) + .png() + .toBuffer(); + } catch (err) { + wrapSharpError(err, `assembling icon from ${paths.length} layer${paths.length === 1 ? "" : "s"}`); + } +} + /** Convert an image buffer to grayscale, preserving alpha (iOS tinted icon variant). */ export async function toGrayscale(buffer: Buffer): Promise { try { diff --git a/tests/config-v13.test.ts b/tests/config-v13.test.ts index 3a635fb..309b112 100644 --- a/tests/config-v13.test.ts +++ b/tests/config-v13.test.ts @@ -28,7 +28,7 @@ describe("resolveConfig — v1.3 additions", () => { const bg = await createTestBackground(TMP); const config = resolveConfig({ icon, background: bg, color: "#FF0000" }); expect(config.output.mode).toBe("zip"); - expect(config.iosIcon).toEqual({ enabled: true, name: "AppIcon" }); + expect(config.iosIcon).toEqual({ enabled: true, name: "AppIcon", iconScale: 0.8 }); }); it("--out-dir switches to dir mode and sets the directory", async () => { diff --git a/tests/config.test.ts b/tests/config.test.ts index 650175c..1570d06 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -13,6 +13,7 @@ import { createTextFile, createBadJsonConfig, createValidConfig, + createTestSvgLayer, } from "./fixtures/create-fixtures"; const TMP = join(__dirname, "../.test-tmp-config"); @@ -344,6 +345,146 @@ describe("resolveConfig", () => { ).toThrow(/Background color is required/); }); + // --- Icon scale --- + + describe("icon scale", () => { + it("defaults to Apple's proportions: 0.8 on iOS, 0.75 on tvOS", async () => { + const { icon, bg } = await createStandardInputs(); + const config = resolveConfig({ icon, background: bg, color: "#FF0000" }); + + expect(config.iosIcon.iconScale).toBe(0.8); + expect(config.brandAssets.iconScale).toBe(0.75); + }); + + it("CLI flags override both", async () => { + const { icon, bg } = await createStandardInputs(); + const config = resolveConfig({ + icon, + background: bg, + color: "#FF0000", + iosIconScale: "0.9", + tvIconScale: "0.7", + }); + + expect(config.iosIcon.iconScale).toBe(0.9); + expect(config.brandAssets.iconScale).toBe(0.7); + }); + + it("a config file sets them when no flag is given", async () => { + const { icon, bg } = await createStandardInputs(); + const configPath = join(TMP, "scale.json"); + writeFileSync( + configPath, + JSON.stringify({ iosIcon: { iconScale: 0.65 }, brandAssets: { iconScale: 0.6 } }), + ); + + const config = resolveConfig({ icon, background: bg, color: "#FF0000", config: configPath }); + + expect(config.iosIcon.iconScale).toBe(0.65); + expect(config.brandAssets.iconScale).toBe(0.6); + }); + + it.each([["0"], ["-0.5"], ["1.5"], ["abc"]])("rejects %s", async (value) => { + const { icon, bg } = await createStandardInputs(); + expect(() => + resolveConfig({ icon, background: bg, color: "#FF0000", iosIconScale: value }), + ).toThrow(/Invalid iosIcon.iconScale/); + }); + }); + + // --- Assembling the icon from parallax layer art --- + + describe("assembled icon", () => { + // --layer-front / --layer-middle write imagePath onto both imagestacks. + function layerOverrides(front: string, middle: string) { + const layers = { front: { imagePath: front }, middle: { imagePath: middle } }; + return { brandAssets: { appIconSmall: { layers }, appIconLarge: { layers } } }; + } + + it("accepts a missing icon when every icon layer carries art, in back-to-front order", async () => { + const bg = await createTestBackground(TMP); + const front = createTestSvgLayer(TMP, "front.svg", "#FF0000", 200); + const middle = createTestSvgLayer(TMP, "middle.svg", "#0000FF", 400); + + const config = resolveConfig({ + background: bg, + color: "#FF0000", + overrides: layerOverrides(front, middle), + }); + + expect(config.inputs.iconImage).toBe(""); + expect(config.inputs.iconAssembledFrom).toEqual([resolve(middle), resolve(front)]); + }); + + it("still requires an icon when only one icon layer carries art", async () => { + const bg = await createTestBackground(TMP); + const front = createTestSvgLayer(TMP, "front.svg", "#FF0000", 200); + const layers = { front: { imagePath: front } }; + + expect(() => + resolveConfig({ + background: bg, + color: "#FF0000", + overrides: { brandAssets: { appIconSmall: { layers }, appIconLarge: { layers } } }, + }), + ).toThrow(/Icon image is required/); + }); + + it("a supplied icon wins: nothing is assembled", async () => { + const { icon, bg } = await createStandardInputs(); + const front = createTestSvgLayer(TMP, "front.svg", "#FF0000", 200); + const middle = createTestSvgLayer(TMP, "middle.svg", "#0000FF", 400); + + const config = resolveConfig({ + icon, + background: bg, + color: "#FF0000", + overrides: layerOverrides(front, middle), + }); + + expect(config.inputs.iconImage).toBe(resolve(icon)); + expect(config.inputs.iconAssembledFrom).toBeUndefined(); + }); + + it("assembles from appIconLarge when the two stacks carry different art", async () => { + const bg = await createTestBackground(TMP); + const smallFront = createTestSvgLayer(TMP, "small-front.svg", "#FF0000", 200); + const smallMiddle = createTestSvgLayer(TMP, "small-middle.svg", "#0000FF", 400); + const largeFront = createTestSvgLayer(TMP, "large-front.svg", "#00FF00", 250); + const largeMiddle = createTestSvgLayer(TMP, "large-middle.svg", "#FFFF00", 450); + + const config = resolveConfig({ + background: bg, + color: "#FF0000", + overrides: { + brandAssets: { + appIconSmall: { + layers: { front: { imagePath: smallFront }, middle: { imagePath: smallMiddle } }, + }, + appIconLarge: { + layers: { front: { imagePath: largeFront }, middle: { imagePath: largeMiddle } }, + }, + }, + }, + }); + + expect(config.inputs.iconAssembledFrom).toEqual([resolve(largeMiddle), resolve(largeFront)]); + }); + + it("a config file cannot inject iconAssembledFrom", async () => { + const { icon, bg } = await createStandardInputs(); + const configPath = join(TMP, "injected.json"); + writeFileSync( + configPath, + JSON.stringify({ inputs: { iconAssembledFrom: ["/etc/passwd"] } }), + ); + + const config = resolveConfig({ icon, background: bg, color: "#FF0000", config: configPath }); + + expect(config.inputs.iconAssembledFrom).toBeUndefined(); + }); + }); + // --- Whitespace-only inputs (Phase 3 fix) --- it("rejects whitespace-only icon path", () => { diff --git a/tests/fixtures/create-fixtures.ts b/tests/fixtures/create-fixtures.ts index 8e0cf0b..65139cc 100644 --- a/tests/fixtures/create-fixtures.ts +++ b/tests/fixtures/create-fixtures.ts @@ -70,6 +70,22 @@ export function createTestSvgIcon(dir: string, name = "icon.svg"): string { return filePath; } +/** + * Create one piece of 1024x1024 parallax layer art: a solid circle of `fill` at + * radius `r`, centered, on transparency. Two layers with different radii overlap, + * so a composite reveals which one was painted on top. + */ +export function createTestSvgLayer(dir: string, name: string, fill: string, r: number): string { + const filePath = join(dir, name); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync( + filePath, + `` + + ``, + ); + return filePath; +} + /** Create a symlink to a PNG */ export function createSymlink(target: string, linkPath: string): void { symlinkSync(target, linkPath); diff --git a/tests/lib.test.ts b/tests/lib.test.ts index 67bd5d7..f0cad7e 100644 --- a/tests/lib.test.ts +++ b/tests/lib.test.ts @@ -5,7 +5,12 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { resolveConfig } from "../src/config"; import { generateAssets } from "../src/lib"; -import { createTestIcon, createTestBackground, createTestSvgIcon } from "./fixtures/create-fixtures"; +import { + createTestIcon, + createTestBackground, + createTestSvgIcon, + createTestSvgLayer, +} from "./fixtures/create-fixtures"; import type { TvOSImageCreatorConfig } from "../src/types"; const TMP = join(__dirname, "../.test-tmp-lib"); @@ -26,6 +31,104 @@ async function makeConfig(): Promise { return resolveConfig({ icon, background: bg, color: "#FF0000", output: join(TMP, "out") }); } +describe("generateAssets with an assembled icon", () => { + /** Middle is a wide blue disc, front a narrow red one, so the paint order shows. */ + async function makeAssembledConfig(): Promise { + const bg = await createTestBackground(TMP); + const front = createTestSvgLayer(TMP, "front.svg", "#FF0000", 200); + const middle = createTestSvgLayer(TMP, "middle.svg", "#0000FF", 400); + const layers = { front: { imagePath: front }, middle: { imagePath: middle } }; + + return resolveConfig({ + background: bg, + color: "#FF0000", + output: join(TMP, "out"), + overrides: { brandAssets: { appIconSmall: { layers }, appIconLarge: { layers } } }, + }); + } + + /** RGB of one pixel, as a #rrggbb string. */ + async function pixelAt(file: string, x: number, y: number): Promise { + const { data, info } = await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + const i = (y * info.width + x) * 4; + return "#" + [data[i], data[i + 1], data[i + 2]].map((c) => c.toString(16).padStart(2, "0")).join(""); + } + + it("generates every icon-derived asset without an icon input", async () => { + const config = await makeAssembledConfig(); + const xcassetsDir = join(TMP, "Images.xcassets"); + + await generateAssets(config, xcassetsDir); + + expect(existsSync(join(xcassetsDir, "AppIcon.appiconset", "icon-1024.png"))).toBe(true); + expect(existsSync(join(xcassetsDir, "AppIcon.appiconset", "icon-1024-dark.png"))).toBe(true); + expect(existsSync(join(xcassetsDir, "AppIcon.brandassets", "Top Shelf Image.imageset"))).toBe(true); + expect(existsSync(join(xcassetsDir, "SplashScreenLogo.imageset", "200-icon@3x.png"))).toBe(true); + }); + + it("paints the layers back to front: the front layer wins the overlap", async () => { + const config = await makeAssembledConfig(); + const xcassetsDir = join(TMP, "Images.xcassets"); + + await generateAssets(config, xcassetsDir); + + // The dark variant is the bare icon on transparency at 1024. The content box + // is the middle disc (the larger one), so it renders at the full iOS scale + // and the front disc at half of it. A point past the front disc's edge but + // inside the middle one must be blue; reversed order would paint it red. + const middleRadius = (1024 * config.iosIcon.iconScale) / 2; + const frontRadius = middleRadius / 2; + const probe = Math.round((frontRadius + middleRadius) / 2); + expect(probe).toBeGreaterThan(frontRadius); + expect(probe).toBeLessThan(middleRadius); + + const dark = join(xcassetsDir, "AppIcon.appiconset", "icon-1024-dark.png"); + expect(await pixelAt(dark, 512, 512)).toBe("#ff0000"); + expect(await pixelAt(dark, 512, 512 - probe)).toBe("#0000ff"); + }); + + it("sizes the mark to the configured share of the canvas, not the artboard", async () => { + const config = await makeAssembledConfig(); + const xcassetsDir = join(TMP, "Images.xcassets"); + + await generateAssets(config, xcassetsDir); + + // The layer art is a disc on a mostly empty 1024 artboard. Without content + // normalisation that padding would shrink the mark; with it, the mark lands + // at exactly the configured share of the canvas. + const dark = join(xcassetsDir, "AppIcon.appiconset", "icon-1024-dark.png"); + const { data, info } = await sharp(dark).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + let left = info.width; + let right = -1; + for (let x = 0; x < info.width; x++) { + if (data[(512 * info.width + x) * 4 + 3] > 8) { + if (x < left) left = x; + if (x > right) right = x; + } + } + const covered = (right - left + 1) / info.width; + expect(covered).toBeCloseTo(config.iosIcon.iconScale, 1); + }); + + it("leaves the caller's config untouched", async () => { + const config = await makeAssembledConfig(); + + await generateAssets(config, join(TMP, "Images.xcassets")); + + expect(config.inputs.iconImage).toBe(""); + expect(config.inputs.iconAssembledFrom).toHaveLength(2); + }); + + it("refuses a config with neither an icon nor layer art", async () => { + const config = await makeAssembledConfig(); + const broken = { ...config, inputs: { ...config.inputs, iconAssembledFrom: undefined } }; + + await expect(generateAssets(broken, join(TMP, "Images.xcassets"))).rejects.toThrow( + /no icon image and no layer art/, + ); + }); +}); + describe("generateAssets", () => { it("generates tvOS + iOS + splash assets by default", async () => { const config = await makeConfig();