Skip to main content

bern_target_api/
brand_assets.rs

1//! tvOS application icons. A tvOS app shows a layered icon that tilts
2//! under the focus, made of image stacks in a `.brandassets` folder of an
3//! asset catalog: the home screen icon at 400 × 240 points, the App Store
4//! icon at 1280 × 768, and the Top Shelf images shown when the app sits in
5//! the top row. A declared `.brandassets` folder ships as it is; from a
6//! square PNG (or the Bern logo) this module draws one: the icon on a back
7//! layer of its own background color, so the art floats over its ground
8//! when the icon tilts.
9
10use std::path::{Path, PathBuf};
11
12/// The name of the brand assets in the asset catalog, which `actool`
13/// takes as the application icon.
14pub const NAME: &str = "App Icon & Top Shelf Image";
15
16/// An image in 8-bit RGBA, with straight alpha.
17#[derive(Clone, Debug, PartialEq)]
18pub struct Raster {
19    pub width: u32,
20    pub height: u32,
21    pub pixels: Vec<u8>,
22}
23
24/// Decode a PNG to 8-bit RGBA.
25pub fn decode(bytes: &[u8]) -> Result<Raster, String> {
26    let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
27    decoder.set_transformations(png::Transformations::normalize_to_color8());
28    let mut reader = decoder
29        .read_info()
30        .map_err(|error| format!("is not a PNG image: {error}"))?;
31    let size = reader
32        .output_buffer_size()
33        .ok_or_else(|| "is too large".to_string())?;
34    let mut buffer = vec![0; size];
35    let info = reader
36        .next_frame(&mut buffer)
37        .map_err(|error| format!("cannot be decoded: {error}"))?;
38    buffer.truncate(info.buffer_size());
39    let pixels = match info.color_type {
40        png::ColorType::Rgba => buffer,
41        png::ColorType::Rgb => buffer
42            .as_chunks::<3>()
43            .0
44            .iter()
45            .flat_map(|&[r, g, b]| [r, g, b, 255])
46            .collect(),
47        png::ColorType::GrayscaleAlpha => buffer
48            .as_chunks::<2>()
49            .0
50            .iter()
51            .flat_map(|&[g, a]| [g, g, g, a])
52            .collect(),
53        png::ColorType::Grayscale => buffer.iter().flat_map(|&g| [g, g, g, 255]).collect(),
54        png::ColorType::Indexed => return Err("kept its palette after decoding".into()),
55    };
56    Ok(Raster {
57        width: info.width,
58        height: info.height,
59        pixels,
60    })
61}
62
63/// Encode RGBA as a PNG.
64pub fn encode(raster: &Raster) -> Vec<u8> {
65    let mut out = Vec::new();
66    let mut encoder = png::Encoder::new(&mut out, raster.width, raster.height);
67    encoder.set_color(png::ColorType::Rgba);
68    encoder.set_depth(png::BitDepth::Eight);
69    let mut writer = encoder.write_header().expect("PNG header");
70    writer
71        .write_image_data(&raster.pixels)
72        .expect("PNG image data");
73    writer.finish().expect("PNG end");
74    out
75}
76
77/// Premultiplied RGBA in 0…1, for resampling without dark fringes.
78struct Linear {
79    width: usize,
80    height: usize,
81    pixels: Vec<[f32; 4]>,
82}
83
84impl Linear {
85    fn from(raster: &Raster) -> Linear {
86        let pixels = raster
87            .pixels
88            .as_chunks::<4>()
89            .0
90            .iter()
91            .map(|p| {
92                let a = p[3] as f32 / 255.0;
93                [
94                    p[0] as f32 / 255.0 * a,
95                    p[1] as f32 / 255.0 * a,
96                    p[2] as f32 / 255.0 * a,
97                    a,
98                ]
99            })
100            .collect();
101        Linear {
102            width: raster.width as usize,
103            height: raster.height as usize,
104            pixels,
105        }
106    }
107
108    /// Resample one axis to `size`: the average of the covered source
109    /// pixels when shrinking, linear between pixel centers when growing.
110    fn resize_axis(&self, size: usize, horizontal: bool) -> Linear {
111        let (from, other) = if horizontal {
112            (self.width, self.height)
113        } else {
114            (self.height, self.width)
115        };
116        let at = |along: usize, across: usize| {
117            if horizontal {
118                self.pixels[across * self.width + along]
119            } else {
120                self.pixels[along * self.width + across]
121            }
122        };
123        let scale = from as f32 / size as f32;
124        // Weights of the source pixels for each output pixel.
125        let weights: Vec<Vec<(usize, f32)>> = (0..size)
126            .map(|i| {
127                if scale >= 1.0 {
128                    let start = i as f32 * scale;
129                    let end = start + scale;
130                    let mut list = Vec::new();
131                    let mut x = start.floor() as usize;
132                    while (x as f32) < end && x < from {
133                        let cover = (end.min(x as f32 + 1.0) - start.max(x as f32)).max(0.0);
134                        if cover > 0.0 {
135                            list.push((x, cover / scale));
136                        }
137                        x += 1;
138                    }
139                    list
140                } else {
141                    let center = (i as f32 + 0.5) * scale - 0.5;
142                    let left = center.floor().clamp(0.0, (from - 1) as f32) as usize;
143                    let right = (left + 1).min(from - 1);
144                    let t = (center - left as f32).clamp(0.0, 1.0);
145                    vec![(left, 1.0 - t), (right, t)]
146                }
147            })
148            .collect();
149        let (width, height) = if horizontal {
150            (size, other)
151        } else {
152            (other, size)
153        };
154        let mut pixels = vec![[0.0; 4]; width * height];
155        for across in 0..other {
156            for (i, list) in weights.iter().enumerate() {
157                let mut sum = [0.0f32; 4];
158                for &(x, w) in list {
159                    let p = at(x, across);
160                    for c in 0..4 {
161                        sum[c] += p[c] * w;
162                    }
163                }
164                let index = if horizontal {
165                    across * width + i
166                } else {
167                    i * width + across
168                };
169                pixels[index] = sum;
170            }
171        }
172        Linear {
173            width,
174            height,
175            pixels,
176        }
177    }
178
179    fn resize(&self, width: usize, height: usize) -> Linear {
180        self.resize_axis(width, true).resize_axis(height, false)
181    }
182}
183
184/// The icon's ground: the color most of its opaque pixels have (to five
185/// bits a channel), and whether its edge is opaque, so that the icon is a
186/// full square rather than a shape on transparency.
187fn ground(raster: &Raster) -> ([u8; 3], bool) {
188    let mut buckets = std::collections::HashMap::<u16, (u32, [u64; 3])>::new();
189    for &[r, g, b, a] in raster.pixels.as_chunks::<4>().0 {
190        if a < 230 {
191            continue;
192        }
193        let key = ((r as u16 >> 3) << 10) | ((g as u16 >> 3) << 5) | (b as u16 >> 3);
194        let entry = buckets.entry(key).or_insert((0, [0; 3]));
195        entry.0 += 1;
196        for (sum, channel) in entry.1.iter_mut().zip([r, g, b]) {
197            *sum += channel as u64;
198        }
199    }
200    let color = buckets
201        .values()
202        .max_by_key(|(count, _)| *count)
203        .map(|(count, sum)| sum.map(|c| (c / *count as u64) as u8))
204        .unwrap_or([0x2C, 0x2C, 0x2E]);
205    let (w, h) = (raster.width as usize, raster.height as usize);
206    let ring = (w.min(h) / 50).max(1);
207    let (mut opaque, mut total) = (0u64, 0u64);
208    for y in 0..h {
209        for x in 0..w {
210            if x < ring || y < ring || x >= w - ring || y >= h - ring {
211                opaque += raster.pixels[(y * w + x) * 4 + 3] as u64;
212                total += 255;
213            }
214        }
215    }
216    (color, total > 0 && opaque * 100 >= total * 95)
217}
218
219/// A layer: the icon `art` scaled to `side` pixels, centered on a
220/// transparent `width` × `height` canvas, or the plain `fill`.
221fn layer(art: Option<(&Linear, usize)>, fill: Option<[u8; 3]>, width: u32, height: u32) -> Raster {
222    let (w, h) = (width as usize, height as usize);
223    let mut pixels = vec![0u8; w * h * 4];
224    if let Some(color) = fill {
225        for p in pixels.as_chunks_mut::<4>().0 {
226            *p = [color[0], color[1], color[2], 255];
227        }
228    }
229    if let Some((art, side)) = art {
230        let scaled = art.resize(side, side);
231        let left = (w as isize - side as isize) / 2;
232        let top = (h as isize - side as isize) / 2;
233        for y in 0..side {
234            for x in 0..side {
235                let (tx, ty) = (left + x as isize, top + y as isize);
236                if tx < 0 || ty < 0 || tx >= w as isize || ty >= h as isize {
237                    continue;
238                }
239                let s = scaled.pixels[y * side + x];
240                let index = (ty as usize * w + tx as usize) * 4;
241                let d = &mut pixels[index..index + 4];
242                // Source over the canvas, in premultiplied terms.
243                let da = d[3] as f32 / 255.0;
244                let out_a = s[3] + da * (1.0 - s[3]);
245                for c in 0..3 {
246                    let dc = d[c] as f32 / 255.0 * da;
247                    let oc = s[c] + dc * (1.0 - s[3]);
248                    d[c] = if out_a > 0.0 {
249                        (oc / out_a * 255.0).round().clamp(0.0, 255.0) as u8
250                    } else {
251                        0
252                    };
253                }
254                d[3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
255            }
256        }
257    }
258    Raster {
259        width,
260        height,
261        pixels,
262    }
263}
264
265const INFO: &str = "\"info\" : {\n    \"author\" : \"bern\",\n    \"version\" : 1\n  }";
266
267/// An image set of `images` (file name, scale).
268fn image_set(images: &[(&str, &str)]) -> String {
269    let list: Vec<String> = images
270        .iter()
271        .map(|(file, scale)| {
272            format!(
273                "    {{\n      \"filename\" : \"{file}\",\n      \"idiom\" : \"tv\",\n      \"scale\" : \"{scale}\"\n    }}"
274            )
275        })
276        .collect();
277    format!(
278        "{{\n  \"images\" : [\n{}\n  ],\n  {INFO}\n}}\n",
279        list.join(",\n")
280    )
281}
282
283/// The files of a `.brandassets` folder drawn from a square PNG icon,
284/// relative to the folder: two image stacks of a back and a front layer,
285/// and the two Top Shelf images.
286pub fn generate(icon: &[u8]) -> Result<Vec<(PathBuf, Vec<u8>)>, String> {
287    let raster = decode(icon)?;
288    if raster.width != raster.height {
289        return Err(format!(
290            "is {} × {} pixels; an icon is square",
291            raster.width, raster.height
292        ));
293    }
294    let (color, full) = ground(&raster);
295    let art = Linear::from(&raster);
296    // A full square fills the height, its edge meeting the ground; a shape
297    // on transparency keeps a margin, as the safe zone of the icon asks.
298    let share = if full { 1.0 } else { 0.8 };
299    let mut files: Vec<(PathBuf, Vec<u8>)> = Vec::new();
300    let text = |path: &str, contents: String| (PathBuf::from(path), contents.into_bytes());
301    files.push(text(
302        "Contents.json",
303        format!(
304            "{{\n  \"assets\" : [\n    {{\n      \"filename\" : \"App Icon - App Store.imagestack\",\n      \"idiom\" : \"tv\",\n      \"role\" : \"primary-app-icon\",\n      \"size\" : \"1280x768\"\n    }},\n    {{\n      \"filename\" : \"App Icon.imagestack\",\n      \"idiom\" : \"tv\",\n      \"role\" : \"primary-app-icon\",\n      \"size\" : \"400x240\"\n    }},\n    {{\n      \"filename\" : \"Top Shelf Image Wide.imageset\",\n      \"idiom\" : \"tv\",\n      \"role\" : \"top-shelf-image-wide\",\n      \"size\" : \"2320x720\"\n    }},\n    {{\n      \"filename\" : \"Top Shelf Image.imageset\",\n      \"idiom\" : \"tv\",\n      \"role\" : \"top-shelf-image\",\n      \"size\" : \"1920x720\"\n    }}\n  ],\n  {INFO}\n}}\n"
305        ),
306    ));
307    // Image stacks: the home screen icon at 1x and 2x, the App Store one
308    // at 1x.
309    for (stack, width, height, scales) in [
310        ("App Icon.imagestack", 400u32, 240u32, &[1u32, 2][..]),
311        ("App Icon - App Store.imagestack", 1280, 768, &[1][..]),
312    ] {
313        files.push(text(
314            &format!("{stack}/Contents.json"),
315            format!(
316                "{{\n  {INFO},\n  \"layers\" : [\n    {{\n      \"filename\" : \"Front.imagestacklayer\"\n    }},\n    {{\n      \"filename\" : \"Back.imagestacklayer\"\n    }}\n  ]\n}}\n"
317            ),
318        ));
319        for name in ["Front", "Back"] {
320            let dir = format!("{stack}/{name}.imagestacklayer");
321            files.push(text(
322                &format!("{dir}/Contents.json"),
323                format!("{{\n  {INFO}\n}}\n"),
324            ));
325            let mut images = Vec::new();
326            for &scale in scales {
327                let (w, h) = (width * scale, height * scale);
328                let file = if scale == 1 {
329                    format!("{}.png", name.to_lowercase())
330                } else {
331                    format!("{}@{scale}x.png", name.to_lowercase())
332                };
333                let raster = if name == "Front" {
334                    let side = (h as f32 * share).round() as usize;
335                    layer(Some((&art, side)), None, w, h)
336                } else {
337                    layer(None, Some(color), w, h)
338                };
339                files.push((
340                    PathBuf::from(format!("{dir}/Content.imageset/{file}")),
341                    encode(&raster),
342                ));
343                images.push((file, format!("{scale}x")));
344            }
345            let images: Vec<(&str, &str)> = images
346                .iter()
347                .map(|(file, scale)| (file.as_str(), scale.as_str()))
348                .collect();
349            files.push(text(
350                &format!("{dir}/Content.imageset/Contents.json"),
351                image_set(&images),
352            ));
353        }
354    }
355    // Top Shelf images: the icon at half the height on its ground.
356    for (set, width, height) in [
357        ("Top Shelf Image.imageset", 1920u32, 720u32),
358        ("Top Shelf Image Wide.imageset", 2320, 720),
359    ] {
360        let mut images = Vec::new();
361        for scale in [1u32, 2] {
362            let (w, h) = (width * scale, height * scale);
363            let file = if scale == 1 {
364                "top-shelf.png".to_string()
365            } else {
366                format!("top-shelf@{scale}x.png")
367            };
368            let side = (h as f32 * 0.5 * share).round() as usize;
369            files.push((
370                PathBuf::from(format!("{set}/{file}")),
371                encode(&layer(Some((&art, side)), Some(color), w, h)),
372            ));
373            images.push((file, format!("{scale}x")));
374        }
375        let images: Vec<(&str, &str)> = images
376            .iter()
377            .map(|(file, scale)| (file.as_str(), scale.as_str()))
378            .collect();
379        files.push(text(&format!("{set}/Contents.json"), image_set(&images)));
380    }
381    Ok(files)
382}
383
384/// Read a `Contents.json` below the brand assets.
385fn contents(dir: &Path, relative: &str) -> Result<serde_json::Value, String> {
386    let path = dir.join(relative).join("Contents.json");
387    let bytes = std::fs::read(&path).map_err(|_| {
388        format!(
389            "has no {}/Contents.json",
390            relative.trim_end_matches('/').trim_start_matches("./")
391        )
392    })?;
393    serde_json::from_slice(&bytes)
394        .map_err(|error| format!("has a {relative}/Contents.json that is not JSON: {error}"))
395}
396
397/// The images an image set names, each checked to be there.
398fn check_image_set(dir: &Path, set: &str) -> Result<(), String> {
399    let contents = contents(dir, set)?;
400    let images = contents
401        .get("images")
402        .and_then(|images| images.as_array())
403        .ok_or_else(|| format!("has a {set}/Contents.json without an `images` list"))?;
404    let mut named = 0;
405    for image in images {
406        if let Some(name) = image.get("filename").and_then(|name| name.as_str()) {
407            if !dir.join(set).join(name).is_file() {
408                return Err(format!("names {set}/{name}, which is not there"));
409            }
410            named += 1;
411        }
412    }
413    if named == 0 {
414        return Err(format!("has no images in {set}"));
415    }
416    Ok(())
417}
418
419/// Check a declared `.brandassets` folder: its `Contents.json` lists the
420/// home screen icon (a `primary-app-icon` image stack of 400 × 240) and
421/// names only folders that are there; each image stack has two to five
422/// layers with their images, each image set its images. Returns its files,
423/// relative to the folder.
424pub fn check(dir: &Path) -> Result<Vec<PathBuf>, String> {
425    let files = crate::icon::files_in(dir)?;
426    let top = contents(dir, ".")?;
427    let assets = top
428        .get("assets")
429        .and_then(|assets| assets.as_array())
430        .ok_or_else(|| "has a Contents.json without an `assets` list".to_string())?;
431    let mut home = false;
432    for asset in assets {
433        let Some(name) = asset.get("filename").and_then(|name| name.as_str()) else {
434            continue;
435        };
436        if !dir.join(name).is_dir() {
437            return Err(format!("names {name} in Contents.json, which is not there"));
438        }
439        let role = asset.get("role").and_then(|role| role.as_str());
440        let size = asset.get("size").and_then(|size| size.as_str());
441        if name.ends_with(".imagestack") {
442            let stack = contents(dir, name)?;
443            let layers = stack
444                .get("layers")
445                .and_then(|layers| layers.as_array())
446                .ok_or_else(|| format!("has a {name}/Contents.json without `layers`"))?;
447            if !(2..=5).contains(&layers.len()) {
448                return Err(format!(
449                    "has {} layers in {name}; an image stack has two to five",
450                    layers.len()
451                ));
452            }
453            for layer in layers {
454                let file = layer
455                    .get("filename")
456                    .and_then(|file| file.as_str())
457                    .ok_or_else(|| format!("has a layer without a file name in {name}"))?;
458                check_image_set(dir, &format!("{name}/{file}/Content.imageset"))?;
459            }
460            if role == Some("primary-app-icon") && size == Some("400x240") {
461                home = true;
462            }
463        } else if name.ends_with(".imageset") {
464            check_image_set(dir, name)?;
465        }
466    }
467    if !home {
468        return Err(
469            "lists no home screen icon: a `primary-app-icon` image stack of size 400x240".into(),
470        );
471    }
472    Ok(files)
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    fn square(size: u32, color: [u8; 4], disc: Option<[u8; 4]>) -> Vec<u8> {
480        let mut pixels = Vec::new();
481        let r = size as f32 / 2.0;
482        for y in 0..size {
483            for x in 0..size {
484                let inside =
485                    ((x as f32 + 0.5 - r).powi(2) + (y as f32 + 0.5 - r).powi(2)).sqrt() < r * 0.5;
486                pixels.extend_from_slice(&match disc {
487                    Some(disc) if inside => disc,
488                    _ => color,
489                });
490            }
491        }
492        encode(&Raster {
493            width: size,
494            height: size,
495            pixels,
496        })
497    }
498
499    #[test]
500    fn a_full_square_floats_over_its_own_ground() {
501        let icon = square(256, [10, 120, 200, 255], Some([255, 255, 255, 255]));
502        let files = generate(&icon).unwrap();
503        let back = files
504            .iter()
505            .find(|(path, _)| {
506                path.ends_with("App Icon.imagestack/Back.imagestacklayer/Content.imageset/back.png")
507            })
508            .unwrap();
509        let back = decode(&back.1).unwrap();
510        assert_eq!((back.width, back.height), (400, 240));
511        // The ground is the square's own color, to the five bits a channel
512        // it is counted in.
513        assert_eq!(back.pixels[3], 255);
514        assert!(back.pixels[0].abs_diff(10) <= 8 && back.pixels[2].abs_diff(200) <= 8);
515        let front = files
516            .iter()
517            .find(|(path, _)| {
518                path.ends_with(
519                    "App Icon.imagestack/Front.imagestacklayer/Content.imageset/front@2x.png",
520                )
521            })
522            .unwrap();
523        let front = decode(&front.1).unwrap();
524        assert_eq!((front.width, front.height), (800, 480));
525        // Transparent beside the square, white at its center.
526        assert_eq!(front.pixels[3], 0);
527        let center = ((240 * 800 + 400) * 4) as usize;
528        assert_eq!(&front.pixels[center..center + 4], &[255, 255, 255, 255]);
529        let store = files
530            .iter()
531            .find(|(path, _)| path.ends_with("App Icon - App Store.imagestack/Front.imagestacklayer/Content.imageset/front.png"))
532            .unwrap();
533        assert_eq!(png_size(&store.1), (1280, 768));
534        for (set, size) in [
535            ("Top Shelf Image.imageset/top-shelf@2x.png", (3840, 1440)),
536            ("Top Shelf Image Wide.imageset/top-shelf.png", (2320, 720)),
537        ] {
538            let file = files.iter().find(|(path, _)| path.ends_with(set)).unwrap();
539            assert_eq!(png_size(&file.1), size);
540        }
541    }
542
543    fn png_size(bytes: &[u8]) -> (u32, u32) {
544        crate::icon::png_size(bytes).unwrap()
545    }
546
547    #[test]
548    fn a_shape_on_transparency_keeps_a_margin() {
549        let icon = square(256, [0, 0, 0, 0], Some([200, 40, 60, 255]));
550        let files = generate(&icon).unwrap();
551        let back = files
552            .iter()
553            .find(|(path, _)| {
554                path.ends_with("App Icon.imagestack/Back.imagestacklayer/Content.imageset/back.png")
555            })
556            .unwrap();
557        let back = decode(&back.1).unwrap();
558        // The disc's color is the ground.
559        assert_eq!(&back.pixels[..4], &[200, 40, 60, 255]);
560    }
561
562    #[test]
563    fn generated_brand_assets_pass_the_check() {
564        let dir = std::env::temp_dir().join(format!("bern-brandassets-{}", std::process::id()));
565        let _ = std::fs::remove_dir_all(&dir);
566        for (path, contents) in generate(&square(256, [10, 120, 200, 255], None)).unwrap() {
567            std::fs::create_dir_all(dir.join(&path).parent().unwrap()).unwrap();
568            std::fs::write(dir.join(&path), contents).unwrap();
569        }
570        let files = check(&dir).unwrap();
571        assert!(files.iter().any(|file| file.ends_with("Contents.json")));
572        std::fs::remove_file(dir.join("Top Shelf Image.imageset/top-shelf.png")).unwrap();
573        assert!(check(&dir).unwrap_err().contains("not there"));
574        std::fs::remove_dir_all(&dir).unwrap();
575    }
576}