From 78b1a2ff6f2c59b274c01763e9262d4991b41828 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 25 Sep 2026 23:50:12 -0400 Subject: [PATCH] feat(decorations): 530 new avatar decorations, thumbnail-first picker Adds 530 decorations in 19 new categories (631 total). Decorations that contain Discord branding (the Clyde-visor helmets) are excluded. The decorations are ~1 MB animated PNGs, so a picker that rendered every one would pull ~590 MB while scrolling. The picker now: - shows static 144px WebP thumbnails (~9.5 KB each, `thumbs/` on the CDN) and loads the animated file only on hover, focus or selection, with a fallback to the full file if a thumbnail is missing; - mounts one category at a time behind tabs, plus a name search across all categories. scripts/makeDecorationThumbs.py builds the thumbnails from the busiest frame of each animation (many start on an empty frame). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- scripts/makeDecorationThumbs.py | 48 ++ src/app/features/lotus/avatarDecorations.ts | 655 ++++++++++++++++++ .../settings/account/ProfileDecoration.tsx | 131 +++- 3 files changed, 799 insertions(+), 35 deletions(-) create mode 100644 scripts/makeDecorationThumbs.py diff --git a/scripts/makeDecorationThumbs.py b/scripts/makeDecorationThumbs.py new file mode 100644 index 000000000..3587988bb --- /dev/null +++ b/scripts/makeDecorationThumbs.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Build static picker thumbnails for avatar decorations. + +The decorations are animated APNGs (~1 MB each). The settings picker shows +static WebP thumbnails instead and only loads the animated file on hover, +focus or selection, so browsing the catalog costs a few MB, not hundreds. + +Usage: python3 scripts/makeDecorationThumbs.py +Then upload /*.webp to `${DECORATION_CDN}/thumbs/`. +A missing thumbnail is harmless: the picker falls back to the full PNG. + +Requires Pillow (pip install pillow). +""" +import sys +from pathlib import Path + +from PIL import Image, ImageSequence + +SIZE = 144 # 2x the 72px picker cell + + +def best_frame(im: Image.Image) -> Image.Image: + # Many animations start (or loop through) an empty frame, so pick the frame + # with the most visible pixels rather than frame 0. + best, best_score = None, -1 + frames = list(ImageSequence.Iterator(im)) + step = max(1, len(frames) // 24) + for frame in frames[::step]: + rgba = frame.convert('RGBA') + score = sum(rgba.getchannel('A').histogram()[33:]) + if score > best_score: + best, best_score = rgba, score + return best + + +def main(src: str, out: str) -> None: + out_dir = Path(out) + out_dir.mkdir(parents=True, exist_ok=True) + for png in sorted(Path(src).glob('*.png')): + with Image.open(png) as im: + thumb = best_frame(im).resize((SIZE, SIZE), Image.LANCZOS) + thumb.save(out_dir / f'{png.stem}.webp', 'WEBP', quality=82, method=6) + + +if __name__ == '__main__': + if len(sys.argv) != 3: + sys.exit(__doc__) + main(sys.argv[1], sys.argv[2]) diff --git a/src/app/features/lotus/avatarDecorations.ts b/src/app/features/lotus/avatarDecorations.ts index 34b23cc2d..82cd5d957 100644 --- a/src/app/features/lotus/avatarDecorations.ts +++ b/src/app/features/lotus/avatarDecorations.ts @@ -182,6 +182,650 @@ export const DECORATION_CATEGORIES: DecorationCategory[] = [ { slug: 'polar_bear_hat', name: 'Polar Bear Hat' }, ], }, + { + id: 'reactions', + label: 'Reactions', + decorations: [ + { slug: 'shy', name: 'Shy' }, + { slug: 'shy_in_love', name: 'Shy (In Love)' }, + { slug: 'rumbling', name: 'Rumbling' }, + { slug: 'angry', name: 'Angry' }, + { slug: 'angry_pink', name: 'Angry (Pink)' }, + { slug: 'angry_yellow', name: 'Angry (Yellow)' }, + { slug: 'radiating_energy', name: 'Radiating Energy' }, + { slug: 'radiating_energy_yellow', name: 'Radiating Energy (Yellow)' }, + { slug: 'radiating_energy_blue', name: 'Radiating Energy (Blue)' }, + { slug: 'sweat_drops', name: 'Sweat Drops' }, + { slug: 'sweat_drops_cyan', name: 'Sweat Drops (Cyan)' }, + { slug: 'sweat_drops_pink', name: 'Sweat Drops (Pink)' }, + { slug: 'soul_leaving_body', name: 'Soul Leaving Body' }, + { slug: 'soul_leaving_body_blue', name: 'Soul Leaving Body (Blue)' }, + { slug: 'starry_eyed', name: 'Starry Eyed' }, + { slug: 'starry_eyed_green', name: 'Starry Eyed (Green)' }, + { slug: 'starry_eyed_pink', name: 'Starry Eyed (Pink)' }, + { slug: 'shocked', name: 'Shocked' }, + { slug: 'shocked_green', name: 'Shocked (Green)' }, + { slug: 'shocked_yellow', name: 'Shocked (Yellow)' }, + { slug: 'in_love', name: 'In Love' }, + { slug: 'in_love_blue', name: 'In Love (Blue)' }, + { slug: 'in_love_green', name: 'In Love (Green)' }, + { slug: 'ki_energy_green', name: 'Ki Energy (Green)' }, + { slug: 'ki_energy_cyan', name: 'Ki Energy (Cyan)' }, + { slug: 'ki_energy_blue', name: 'Ki Energy (Blue)' }, + { slug: 'ki_energy_fuchsia', name: 'Ki Energy (Fuchsia)' }, + { slug: 'heartbloom', name: 'Heartbloom' }, + { slug: 'heartbloom_purple', name: 'Heartbloom (Purple)' }, + { slug: 'heartbloom_blue', name: 'Heartbloom (Blue)' }, + { slug: 'heartbloom_yellow', name: 'Heartbloom (Yellow)' }, + { slug: 'heartbloom_green', name: 'Heartbloom (Green)' }, + { slug: 'dismay', name: 'Dismay' }, + { slug: 'dismay_purple', name: 'Dismay (Purple)' }, + { slug: 'dismay_pink', name: 'Dismay (Pink)' }, + { slug: 'dismay_yellow', name: 'Dismay (Yellow)' }, + { slug: 'dismay_green', name: 'Dismay (Green)' }, + { slug: 'in_tears', name: 'In Tears' }, + { slug: 'in_tears_purple', name: 'In Tears (Purple)' }, + { slug: 'in_tears_pink', name: 'In Tears (Pink)' }, + { slug: 'in_tears_yellow', name: 'In Tears (Yellow)' }, + { slug: 'in_tears_green', name: 'In Tears (Green)' }, + { slug: 'rage', name: 'Rage' }, + { slug: 'rage_green', name: 'Rage (Green)' }, + { slug: 'rage_blue', name: 'Rage (Blue)' }, + { slug: 'rage_purple', name: 'Rage (Purple)' }, + { slug: 'rage_red', name: 'Rage (Red)' }, + { slug: 'copium', name: 'Copium' }, + { slug: 'trash', name: 'Trash' }, + { slug: 'grass_toucher', name: 'Grass Toucher' }, + { slug: 'touch_grass', name: 'Touch Grass' }, + { slug: 'press_f_black', name: 'Press F (Black)' }, + { slug: 'press_f_white', name: 'Press F (White)' }, + { slug: 'git_gud', name: 'Git Gud' }, + { slug: 'ggez', name: 'GGEZ' }, + { slug: 'questionable', name: 'Questionable' }, + { slug: 'buffering', name: 'Buffering' }, + { slug: 'toilet_lid', name: 'Toilet Lid' }, + { slug: 'feeling_cute', name: 'Feeling Cute' }, + { slug: 'im_a_clown', name: "I'm a Clown" }, + ], + }, + { + id: 'critters', + label: 'Critters', + decorations: [ + { slug: 'cat_1', name: 'Lazy Cat (Ginger)' }, + { slug: 'cat_2', name: 'Lazy Cat (Tabby)' }, + { slug: 'cat_3', name: 'Lazy Cat (Calico)' }, + { slug: 'cat_4', name: 'Lazy Cat (Siamese)' }, + { slug: 'dog_1', name: 'Dogs (Tan)' }, + { slug: 'dog_2', name: 'Dogs (Spotted)' }, + { slug: 'dog_3', name: 'Dogs (Brown)' }, + { slug: 'frog_1', name: 'Frog' }, + { slug: 'frog_derpy', name: 'Derpy Frog' }, + { slug: 'frog_3', name: 'Sleepy Frog' }, + { slug: 'frog_angry', name: 'Angry Frog' }, + { slug: 'mushroom_1', name: 'Mushroom (Moss)' }, + { slug: 'mushroom_2', name: 'Mushroom (Amber)' }, + { slug: 'mushroom_3', name: 'Mushroom (Rust)' }, + { slug: 'mushroom_4', name: 'Mushroom (Neon)' }, + { slug: 'hoppy_day', name: 'Hoppy Day' }, + { slug: 'hoppy_boi', name: 'Hoppy Boi' }, + { slug: 'cow_glider', name: 'Cow Glider' }, + { slug: 'moomoo_hood', name: 'Moomoo Hood' }, + { slug: 'cosy_deer', name: 'Cosy Deer' }, + { slug: 'berry_bunny', name: 'Berry Bunny' }, + { slug: 'mochi_bunny_pink', name: 'Mochi Bunny (Pink)' }, + { slug: 'mochi_bunny_blue', name: 'Mochi Bunny (Blue)' }, + { slug: 'nibbles', name: 'Nibbles' }, + { slug: 'hammy', name: 'Hammy' }, + { slug: 'hammy_blue', name: 'Hammy (Blue)' }, + { slug: 'hammy_green', name: 'Hammy (Green)' }, + { slug: 'hammy_pink', name: 'Hammy (Pink)' }, + { slug: 'shroomling', name: 'Shroomling' }, + { slug: 'shroomling_blue', name: 'Shroomling (Blue)' }, + { slug: 'shroomling_brown', name: 'Shroomling (Brown)' }, + { slug: 'shroomling_pink', name: 'Shroomling (Pink)' }, + { slug: 'shroomling_purple', name: 'Shroomling (Purple)' }, + { slug: 'sleepy_bee', name: 'Sleepy Bee' }, + { slug: 'baker_bear', name: 'Baker Bear' }, + { slug: 'dewdrop', name: 'Dewdrop' }, + { slug: 'gone_fishin', name: "Gone Fishin'" }, + { slug: 'happy_harvest', name: 'Happy Harvest' }, + { slug: 'cat_onesie', name: 'Cat Onesie' }, + { slug: 'cat_onesie_pink', name: 'Cat Onesie (Pink)' }, + { slug: 'cat_onesie_black', name: 'Cat Onesie (Black)' }, + { slug: 'cat_ears_yellow', name: 'Cat Ears (Yellow)' }, + { slug: 'cat_ears_green', name: 'Cat Ears (Green)' }, + { slug: 'cat_ears_blue', name: 'Cat Ears (Blue)' }, + { slug: 'cat_ears_purple', name: 'Cat Ears (Purple)' }, + { slug: 'greenhouse_cat', name: 'Greenhouse Cat' }, + { slug: 'graveyard_cat', name: 'Graveyard Cat' }, + { slug: 'forest_frolic', name: 'Forest Frolic' }, + { slug: 'a_real_fungi', name: 'A Real Fungi' }, + { slug: 'mokoko', name: 'Mokoko' }, + ], + }, + { + id: 'monsters', + label: 'Little Monsters', + decorations: [ + { slug: 'beamchop', name: 'Beamchop' }, + { slug: 'chuck', name: 'Chuck' }, + { slug: 'winkle', name: 'Winkle' }, + { slug: 'chewbert', name: 'Chewbert' }, + { slug: 'doodlezard', name: 'Doodlezard' }, + { slug: 'glop', name: 'Glop' }, + { slug: 'gawblehop', name: 'Gawblehop' }, + { slug: 'stinkums', name: 'Stinkums' }, + { slug: 'goblin_stinkums', name: 'Goblin Stinkums' }, + { slug: 'chomp_chomp', name: 'Chomp Chomp' }, + ], + }, + { + id: 'snacks', + label: 'Snacks', + decorations: [ + { slug: 'pancakes', name: 'Pancakes' }, + { slug: 'blueberry_jam', name: 'Blueberry Jam' }, + { slug: 'donut', name: 'Donut' }, + { slug: 'fried_egg', name: 'Fried Egg' }, + { slug: 'green_fried_egg', name: 'Green Fried Egg' }, + { slug: 'toast', name: 'Toast' }, + { slug: 'burnt_toast', name: 'Burnt Toast' }, + { slug: 'morning_coffee', name: 'Morning Coffee' }, + { slug: 'sushi_roll', name: 'Sushi Roll' }, + { slug: 'ramen_bowl', name: 'Ramen Bowl' }, + { slug: 'ramen_bowl_toppings', name: 'Ramen Bowl (Toppings)' }, + { slug: 'donut_cow', name: 'Donut Cow' }, + { slug: 'donut_cat', name: 'Donut Cat' }, + { slug: 'donut_bear', name: 'Donut Bear' }, + { slug: 'bread_doggy', name: 'Bread Doggy' }, + { slug: 'ice_cream_frog', name: 'Ice Cream Frog' }, + { slug: 'jelly_bear', name: 'Jelly Bear' }, + { slug: 'nom_kitty', name: 'Nom Kitty' }, + { slug: 'egg', name: 'Egg' }, + { slug: 'chicken_nugget', name: 'Chicken Nugget' }, + { slug: 'sippy_juice', name: 'Sippy Juice' }, + { slug: 'bubble_tea', name: 'Bubble Tea' }, + { slug: 'freshly_picked', name: 'Freshly Picked' }, + { slug: 'strawberry_vine', name: 'Strawberry Vine' }, + { slug: 'a_hint_of_clove', name: 'A Hint of Clove' }, + ], + }, + { + id: 'scene', + label: 'Scene', + decorations: [ + { slug: 'neon_cat_hoodie', name: 'Neon Cat Hoodie' }, + { slug: 'neon_cat_hoodie_blue', name: 'Neon Cat Hoodie (Blue)' }, + { slug: 'neon_cat_hoodie_pink', name: 'Neon Cat Hoodie (Pink)' }, + { slug: 'neon_cat_hoodie_black', name: 'Neon Cat Hoodie (Black)' }, + { slug: 'neon_decora_hair', name: 'Neon Decora Hair' }, + { slug: 'neon_spike_choker', name: 'Neon Spike Choker' }, + { slug: 'neon_spike_choker_black', name: 'Neon Spike Choker (Black)' }, + { slug: 'mischievous_kitties', name: 'Mischievous Kitties' }, + { slug: 'mischievous_kitties_hearts', name: 'Mischievous Kitties (Hearts)' }, + { slug: 'mischievous_kitties_blue', name: 'Mischievous Kitties (Blue)' }, + { slug: 'skully_charms', name: 'Skully Charms' }, + { slug: 'rawr_xd', name: 'Rawr xD' }, + { slug: 'magical_girl', name: 'Magical Girl' }, + { slug: 'sakura_scholar', name: 'Sakura Scholar' }, + ], + }, + { + id: 'dreamy', + label: 'Dreamy', + decorations: [ + { slug: 'jet_ring', name: 'Jet Ring' }, + { slug: 'pondering_portal', name: 'Pondering Portal' }, + { slug: 'magic_mists', name: 'Magic Mists' }, + { slug: 'infinite_swirl', name: 'Infinite Swirl' }, + { slug: 'blue_gyroscope', name: 'Blue Gyroscope' }, + { slug: 'pink_gyroscope', name: 'Pink Gyroscope' }, + { slug: 'green_gyroscope', name: 'Green Gyroscope' }, + { slug: 'saw', name: 'Saw' }, + { slug: 'blue_smoke', name: 'Blue Smoke' }, + { slug: 'pink_smoke', name: 'Pink Smoke' }, + { slug: 'green_smoke', name: 'Green Smoke' }, + { slug: 'starlight_whales', name: 'Starlight Whales' }, + { slug: 'head_in_the_clouds', name: 'Head In the Clouds' }, + { slug: 'head_in_the_clouds_sunset', name: 'Head In the Clouds (Sunset)' }, + { slug: 'mirage', name: 'Mirage' }, + { slug: 'mirage_void', name: 'Mirage (Void)' }, + { slug: 'mirage_twilight', name: 'Mirage (Twilight)' }, + { slug: 'mirage_nightshade', name: 'Mirage (Nightshade)' }, + { slug: 'lava_blobs', name: 'Lava Blobs' }, + { slug: 'lava_blobs_pink', name: 'Lava Blobs (Pink)' }, + { slug: 'lava_blobs_blue', name: 'Lava Blobs (Blue)' }, + { slug: 'lava_blobs_slime', name: 'Lava Blobs (Slime)' }, + { slug: 'luminescent_lotus', name: 'Luminescent Lotus' }, + { slug: 'woolgathering', name: 'Woolgathering' }, + { slug: 'travel_ring', name: 'Travel Ring' }, + { slug: 'dream_dive_stars', name: 'Dream Dive Stars' }, + { slug: 'rift_butterfly', name: 'Rift Butterfly' }, + ], + }, + { + id: 'celestial', + label: 'Celestial', + decorations: [ + { slug: 'lunar_flowers', name: 'Lunar Flowers' }, + { slug: 'starlight_tiara', name: 'Starlight Tiara' }, + { slug: 'astral_aura', name: 'Astral Aura' }, + { slug: 'luna_moths', name: 'Luna Moths' }, + { slug: 'moonlit_charms', name: 'Moonlit Charms' }, + { slug: 'liquid_moon', name: 'Liquid Moon' }, + ], + }, + { + id: 'garden', + label: 'Garden', + decorations: [ + { slug: 'terrarium', name: 'Terrarium' }, + { slug: 'mushcaps', name: 'Mushcaps' }, + { slug: 'blossomburst', name: 'Blossomburst' }, + { slug: 'rainy_canopy', name: 'Rainy Canopy' }, + { slug: 'snowdrops', name: 'Snowdrops' }, + { slug: 'afternoon_breeze', name: 'Afternoon Breeze' }, + { slug: 'shower_stroll', name: 'Shower Stroll' }, + { slug: 'sakura_gyoiko', name: 'Gyoiko Sakura' }, + { slug: 'sakura_ukon', name: 'Ukon Sakura' }, + { slug: 'forest', name: 'Forest' }, + { slug: 'fall_leaves_woodland', name: 'Woodland Leaves' }, + { slug: 'autumn_foliage', name: 'Autumn Foliage' }, + { slug: 'flower_clouds', name: 'Flower Clouds' }, + { slug: 'the_petal_pack', name: 'The Petal Pack' }, + { slug: 'chrysanthemums_morning', name: 'Morning Chrysanthemums' }, + { slug: 'chrysanthemums_twilight', name: 'Twilight Chrysanthemums' }, + { slug: 'dusk_and_dawn', name: 'Dusk and Dawn' }, + { slug: 'floral_harmony', name: 'Floral Harmony' }, + { slug: 'floral_harmony_sunburst', name: 'Floral Harmony (Sunburst)' }, + { slug: 'autumns_arbor', name: "Autumn's Arbor" }, + { slug: 'autumns_arbor_aurora', name: "Autumn's Arbor (Aurora)" }, + { slug: 'autumn_crown', name: 'Autumn Crown' }, + { slug: 'faces_of_the_moon', name: 'Faces of the Moon' }, + { slug: 'lover_girl_autumn', name: 'Lover Girl Autumn' }, + ], + }, + { + id: 'summer', + label: 'Summer', + decorations: [ + { slug: 'sunflowers', name: 'Sunflowers' }, + { slug: 'beach_hat', name: 'Beach Hat' }, + { slug: 'a_duck', name: 'A Duck' }, + { slug: 'bubble', name: 'Bubble' }, + { slug: 'fireflies', name: 'Fireflies' }, + ], + }, + { + id: 'love', + label: 'Love', + decorations: [ + { slug: 'rose_bearer_pink', name: 'Rose Bearer (Pink)' }, + { slug: 'rose_bearer_blue', name: 'Rose Bearer (Blue)' }, + { slug: 'angel', name: 'Angel' }, + { slug: 'devil', name: 'Devil' }, + { slug: 'aim_for_love', name: 'Aim for Love' }, + { slug: 'lovestruck', name: 'Lovestruck' }, + { slug: 'ring_of_roses_red', name: 'Ring of Roses (Red)' }, + { slug: 'ring_of_roses_blue', name: 'Ring of Roses (Blue)' }, + { slug: 'heartstrings_red', name: 'Heartstrings (Red)' }, + { slug: 'heartstrings_blue', name: 'Heartstrings (Blue)' }, + { slug: 'heart_to_heart', name: 'Heart-to-Heart' }, + { slug: 'ruby_hearts', name: 'Ruby Hearts' }, + ], + }, + { + id: 'winter', + label: 'Winter', + decorations: [ + { slug: 'snowfall', name: 'Snowfall' }, + { slug: 'santa_cat_ears', name: 'Holiday Cat Ears' }, + { slug: 'string_lights', name: 'String Lights' }, + { slug: 'string_lights_aurora', name: 'String Lights (Aurora)' }, + { slug: 'string_lights_ember', name: 'String Lights (Ember)' }, + { slug: 'string_lights_dusk', name: 'String Lights (Dusk)' }, + { slug: 'string_lights_mix', name: 'String Lights (Mix)' }, + { slug: 'snowglobe', name: 'Snowglobe' }, + { slug: 'snowglobe_wood', name: 'Snowglobe (Wood)' }, + { slug: 'snowglobe_pink', name: 'Snowglobe (Pink)' }, + { slug: 'snowglobe_blue', name: 'Snowglobe (Blue)' }, + { slug: 'snowglobe_green', name: 'Snowglobe (Green)' }, + { slug: 'fresh_pine', name: 'Fresh Pine' }, + { slug: 'fresh_pine_cinnamon', name: 'Fresh Pine (Cinnamon)' }, + { slug: 'fresh_pine_ribbon', name: 'Fresh Pine (Ribbon)' }, + { slug: 'blanket_green', name: 'Green Blanket' }, + { slug: 'blanket_orange', name: 'Orange Blanket' }, + { slug: 'blanket_pink', name: 'Pink Blanket' }, + { slug: 'blanket_purple', name: 'Purple Blanket' }, + { slug: 'box_white_blue', name: 'Gift Box (White)' }, + { slug: 'box_blue_yellow', name: 'Gift Box (Blue)' }, + { slug: 'box_green_red', name: 'Gift Box (Green)' }, + { slug: 'box_red_white', name: 'Gift Box (Red)' }, + { slug: 'box_red_white_blue', name: 'Gift Box (Red, White & Blue)' }, + { slug: 'ice_cube', name: 'Ice Cube' }, + { slug: 'icicle_gleaming', name: 'Gleaming Icicle' }, + { slug: 'icicle_snowing', name: 'Snowing Icicle' }, + { slug: 'teacup_blue', name: 'Blue Teacup' }, + { slug: 'teacup_orange', name: 'Orange Teacup' }, + { slug: 'teacup_pink', name: 'Pink Teacup' }, + { slug: 'teacup_red', name: 'Red Teacup' }, + ], + }, + { + id: 'celebrate', + label: 'Celebrate', + decorations: [ + { slug: 'new_year_2024', name: '2024 Balloons' }, + { slug: 'new_year_2025', name: '2025 Balloons' }, + { slug: 'confetti_festive', name: 'Festive Confetti' }, + { slug: 'confetti_fire', name: 'Fire Confetti' }, + { slug: 'confetti_ice', name: 'Ice Confetti' }, + { slug: 'confetti_mint', name: 'Mint Confetti' }, + { slug: 'confetti_star', name: 'Star Confetti' }, + { slug: 'confetti_vaporwave', name: 'Vaporwave Confetti' }, + { slug: 'snakes_hug', name: "Snake's Hug" }, + { slug: 'red_lantern', name: 'Red Lantern' }, + { slug: 'fan_flourish', name: 'Fan Flourish' }, + { slug: 'lucky_envelopes', name: 'Lucky Envelopes' }, + ], + }, + { + id: 'steampunk', + label: 'Steampunk', + decorations: [ + { slug: 'steampunk_cat_ears', name: 'Steampunk Cat Ears' }, + { slug: 'mech_flora', name: 'Mech Flora' }, + { slug: 'bowler_hat', name: 'Bowler Hat' }, + { slug: 'brass_beats', name: 'Brass Beats' }, + { slug: 'timekeepers_clock', name: "Timekeeper's Clock" }, + { slug: 'flux_alchemy', name: 'Flux Alchemy' }, + { slug: 'magic_portal_purple', name: 'Magic Portal (Purple)' }, + { slug: 'magic_portal_blue', name: 'Magic Portal (Blue)' }, + { slug: 'fairy_sprites', name: 'Fairy Sprites' }, + { slug: 'fairy_sprites_pink', name: 'Fairy Sprites (Pink)' }, + { slug: 'fairy_sprites_blue', name: 'Fairy Sprites (Blue)' }, + { slug: 'wizard_hat_purple', name: 'Wizard Hat (Purple)' }, + { slug: 'wizard_hat_blue', name: 'Wizard Hat (Blue)' }, + { slug: 'magical_wand_purple', name: 'Magical Wand (Purple)' }, + { slug: 'magical_wand_green', name: 'Magical Wand (Green)' }, + { slug: 'mooncaps_pink', name: 'Mooncaps (Pink)' }, + { slug: 'mooncaps_blue', name: 'Mooncaps (Blue)' }, + { slug: 'cottage_home', name: 'Cottage Home' }, + { slug: 'treasure_and_key', name: 'Treasure and Key' }, + ], + }, + { + id: 'pirates', + label: 'Pirates', + decorations: [ + { slug: 'pirate_captain', name: 'Pirate Captain' }, + { slug: 'scallywag', name: 'Scallywag' }, + { slug: 'good_ol_pepper', name: "Good Ol' Pepper" }, + { slug: 'crossbones', name: 'Crossbones' }, + { slug: 'cannon_fire', name: 'Cannon Fire' }, + { slug: 'helmsman', name: 'Helmsman' }, + ], + }, + { + id: 'neo_tokyo', + label: 'Neo Tokyo', + decorations: [ + { slug: 'bonsai', name: 'Bonsai' }, + { slug: 'sakura_city', name: 'Sakura City' }, + { slug: 'hologram_dragon', name: 'Hologram Dragon' }, + { slug: 'cyber_katana', name: 'Cyber Katana' }, + { slug: 'o_n_i', name: 'O.N.I' }, + { slug: 'jingasa', name: 'Jingasa' }, + { slug: 'white_mana', name: 'White Mana' }, + { slug: 'blue_mana', name: 'Blue Mana' }, + { slug: 'black_mana', name: 'Black Mana' }, + { slug: 'red_mana', name: 'Red Mana' }, + { slug: 'green_mana', name: 'Green Mana' }, + ], + }, + { + id: 'lofi', + label: 'Lofi', + decorations: [ + { slug: 'lofi_girl_outfit', name: 'Lofi Girl Outfit' }, + { slug: 'playful_lofi_cat', name: 'Playful Lofi Cat' }, + { slug: 'sleepy_chilledcow', name: 'Sleepy ChilledCow' }, + { slug: 'study_session', name: 'Study Session' }, + { slug: 'group_hug', name: 'Group Hug' }, + { slug: 'cozy_post_it', name: 'Cozy Post-It' }, + { slug: 'cozy_post_it_festive', name: 'Cozy Post-It (Festive)' }, + { slug: 'cat_ear_headset', name: 'Cat Ear Headset' }, + { slug: 'bunny_zzzs', name: 'Bunny Zzzs' }, + { slug: 'sproutling', name: 'Sproutling' }, + { slug: 'bloomling', name: 'Bloomling' }, + { slug: 'neon_nibbles', name: 'Neon Nibbles' }, + { slug: 'uwu_xp', name: 'UwU XP' }, + { slug: 'dancing_fairies', name: 'Dancing Fairies' }, + { slug: 'crystal_elk', name: 'Crystal Elk' }, + { slug: 'mermaid_serenade', name: 'Mermaid Serenade' }, + ], + }, + { + id: 'game_night', + label: 'Game Night', + decorations: [ + { slug: 'hugh_the_rainbow', name: 'Hugh the Rainbow' }, + { slug: 'imagination', name: 'Imagination' }, + { slug: 'depresso', name: 'Depresso' }, + { slug: 'selyne', name: 'Selyne' }, + { slug: 'cattiva', name: 'Cattiva' }, + { slug: 'gold_laurel_wreath', name: 'Gold Laurel Wreath' }, + { slug: 'golden_hex', name: 'Golden Hex' }, + { slug: 'terrain_tiles', name: 'Terrain Tiles' }, + { slug: 'hex_tiles', name: 'Hex Tiles' }, + { slug: 'city_walls', name: 'City Walls' }, + { slug: 'next_turn_button', name: 'Next Turn Button' }, + { slug: 'armamenter', name: 'Armamenter' }, + { slug: 'e_d_hacker', name: 'E.D Hacker' }, + { slug: 'got_xenoglossy', name: 'Got Xenoglossy' }, + { slug: 'port_of_soul', name: 'Port of Soul' }, + { slug: 'rocket_puncher', name: 'Rocket Puncher' }, + { slug: 'lights_camera_action_orange', name: 'Lights, Camera, Action! (Orange)' }, + { slug: 'lights_camera_action_teal', name: 'Lights, Camera, Action! (Teal)' }, + { slug: 'victory_crown', name: 'Fortnite Victory Crown' }, + { slug: 'shield_potion', name: 'Shield Potion' }, + { slug: 'slurp_barrel', name: 'Slurp Barrel' }, + { slug: 'supply_llama', name: 'Supply Llama' }, + { slug: 'bush_camper', name: 'Bush Camper' }, + ], + }, + { + id: 'crossover_screen', + label: 'Movies & TV', + decorations: [ + { slug: 'lightsabers_blue_and_red', name: 'Lightsabers (Blue and Red)' }, + { slug: 'lightsabers_green_and_red', name: 'Lightsabers (Green and Red)' }, + { slug: 'r2_d2_on_tatooine', name: 'R2-D2 on Tatooine' }, + { slug: 'curious_bb_8', name: 'Curious BB-8' }, + { slug: 'yoda_on_dagobah', name: 'Yoda on Dagobah' }, + { slug: 'millennium_falcon_hyperdrive', name: 'Millennium Falcon Hyperdrive' }, + { slug: 'space_battle', name: 'Space Battle' }, + { slug: 'the_thing', name: 'The Thing' }, + { slug: 'invisible_woman', name: 'Invisible Woman' }, + { slug: 'mister_fantastic', name: 'Mister Fantastic' }, + { slug: 'human_torch', name: 'Human Torch' }, + { slug: 'silver_surfer', name: 'Silver Surfer' }, + { slug: 'the_fantastic_four', name: 'The Fantastic Four' }, + { slug: 'dragon_balls', name: 'Dragon Balls' }, + { slug: 'gomah', name: 'Gomah' }, + { slug: 'shenron', name: 'Shenron' }, + { slug: 'mini_goku', name: 'Mini Goku' }, + { slug: 'mini_vegeta', name: 'Mini Vegeta' }, + { slug: 'glorio', name: 'Glorio' }, + { slug: 'mini_supreme_kai', name: 'Mini Supreme Kai' }, + { slug: 'mini_piccolo', name: 'Mini Piccolo' }, + { slug: 'panzy', name: 'Panzy' }, + { slug: 'izuku_midoriya', name: 'Izuku Midoriya' }, + { slug: 'katsuki_bakugo', name: 'Katsuki Bakugo' }, + { slug: 'ochaco_uraraka', name: 'Ochaco Uraraka' }, + { slug: 'shoto_todoroki', name: 'Shoto Todoroki' }, + { slug: 'endeavor', name: 'Endeavor' }, + { slug: 'hawks', name: 'Hawks' }, + { slug: 'all_might', name: 'All Might' }, + { slug: 'tomura_shigaraki', name: 'Tomura Shigaraki' }, + { slug: 'spongebob', name: 'SpongeBob' }, + { slug: 'patrick_star', name: 'Patrick Star' }, + { slug: 'gary_the_snail', name: 'Gary the Snail' }, + { slug: 'sandy_cheeks', name: 'Sandy Cheeks' }, + { slug: 'musclebob', name: 'MuscleBob' }, + { slug: 'skibidi_toilet', name: 'Skibidi Toilet' }, + { slug: 'g_toilet', name: 'G-Toilet' }, + { slug: 'cameraman_glitch', name: 'Cameraman Glitch' }, + { slug: 'speakerwoman', name: 'Speakerwoman' }, + { slug: 'tv_woman', name: 'TV Woman' }, + { slug: 'juggernaut_astro', name: 'Juggernaut Astro' }, + { slug: 'the_anomaly', name: 'The Anomaly' }, + { slug: 'the_mark', name: 'The Mark' }, + { slug: 'the_monster_you_created', name: 'The Monster You Created' }, + { slug: 'the_atlas_gauntlets', name: 'The Atlas Gauntlets' }, + { slug: 'flame_chompers', name: 'Flame Chompers' }, + { slug: 'fishbones', name: 'FISHBONES!' }, + { slug: 'the_hexcore', name: 'The Hexcore' }, + { slug: 'powered_by_shimmer', name: 'Powered by Shimmer' }, + { slug: 'minions', name: 'Minions' }, + { slug: 'daredevil', name: 'Daredevil' }, + { slug: 'jean_grey_phoenix', name: 'Phoenix' }, + { slug: 'emma_frost', name: 'Emma Frost' }, + { slug: 'ultron', name: 'Ultron' }, + { slug: 'marvel_snap_venom', name: 'Marvel Snap Venom' }, + { slug: 'jeff_the_land_shark', name: 'Jeff the Land Shark' }, + { slug: 'hank_hill', name: 'Hank Hill' }, + { slug: 'how_to_train_your_dragon', name: 'How to Train Your Dragon' }, + { slug: 'jurassic_world_rebirth', name: 'Jurassic World Rebirth Trailer' }, + { slug: 'mission_impossible', name: 'Mission: Impossible' }, + { slug: 'bad_guys_2', name: 'The Bad Guys 2 Trailer' }, + { slug: 'm3gan_2_0', name: 'M3GAN 2.0' }, + { slug: '28_years_later', name: '28 Years Later' }, + { slug: 'black_phone_2', name: 'Black Phone 2' }, + { slug: 'the_conjuring_last_rites', name: 'The Conjuring: Last Rites' }, + { slug: 'ballerina', name: 'Ballerina' }, + { slug: 'wendys_x_wednesday', name: "Wendy's x Wednesday" }, + { slug: 'batarang', name: 'Batarang' }, + { slug: 'espn', name: 'ESPN' }, + { slug: 'aespa_fanlight', name: 'aespa Fanlight' }, + { slug: 'bing_bong', name: 'Bing Bong' }, + ], + }, + { + id: 'crossover_games', + label: 'Game Crossovers', + decorations: [ + { slug: 'trick_pumpkin', name: 'Trick Pumpkin' }, + { slug: 'treat_pumpkin', name: 'Treat Pumpkin' }, + { slug: 'trick_spider', name: 'Trick Spider' }, + { slug: 'treat_spider', name: 'Treat Spider' }, + { slug: 'trick_skull', name: 'Trick Skull' }, + { slug: 'treat_skull', name: 'Treat Skull' }, + { slug: 'trick_ghost', name: 'Trick Ghost' }, + { slug: 'treat_ghost', name: 'Treat Ghost' }, + { slug: 'the_final_peel', name: 'The Final Peel' }, + { slug: 'warframe_clem', name: 'Warframe Clem' }, + { slug: 'dart_monkey', name: 'Dart Monkey' }, + { slug: 'kom', name: 'KOM' }, + { slug: 'mr_m', name: 'Mr. M' }, + { slug: 'fc_26_icon', name: 'FC 26 Icon' }, + { slug: 'skate', name: 'Skate' }, + { slug: 'airona_fireworks', name: 'Airona Fireworks' }, + { slug: 'champions_tactibear', name: 'Champions Tactibear' }, + { slug: 'cab_monster', name: 'Cab Monster' }, + { slug: 'atsus_mask', name: "Atsu's Mask" }, + { slug: 'tactibear_flex', name: 'Tactibear Flex' }, + { slug: 'angela_avatar', name: 'Angela Avatar' }, + { slug: 'echo_4', name: 'Echo 4' }, + { slug: 'nba_2k26', name: 'NBA 2K26' }, + { slug: 'eye_of_prophesy', name: 'Eye of Prophesy' }, + { slug: 'inzoi_psycat', name: 'inZOI Psycat' }, + { slug: 'bf_soldier_helmet', name: 'BF Soldier Helmet' }, + { slug: 'the_entity', name: 'The Entity' }, + { slug: 'descendant', name: 'Descendant' }, + { slug: 'sweet_tooth', name: 'Sweet Tooth' }, + { slug: 'call_of_duty_mobile', name: 'Call of Duty: Mobile' }, + { slug: 'thps_half_pipe', name: 'THPS Half Pipe' }, + { slug: 'mecha_break', name: 'Mecha BREAK' }, + { slug: 'valorant_summer_kickoff', name: 'VALORANT Summer Kickoff' }, + { slug: 'palia', name: 'Palia' }, + { slug: 'supercell', name: 'SuperCell' }, + { slug: 'i_love_repo', name: 'I Love R.E.P.O.' }, + { slug: 'lego_fortnite', name: 'LEGO® Fortnite' }, + { slug: 'r6_siege_x_avatar', name: 'R6 Siege X Avatar' }, + { slug: 'towerborne_play', name: 'Towerborne Play' }, + { slug: 'starlight_revolver', name: 'Starlight Revolver' }, + { slug: 'open_beta', name: 'Open Beta' }, + { slug: 'fortnite_galactic_battle', name: 'Fortnite Galactic Battle' }, + { slug: 'shield_saw', name: 'Shield Saw' }, + { slug: 'friend_of_dex', name: 'Friend of Dex' }, + { slug: 'hackclaw', name: 'Hackclaw' }, + { slug: 'signal_from_tau_ceti', name: 'Signal from Tau Ceti' }, + { slug: 'face_of_corruption', name: 'Face of Corruption' }, + { slug: 'clicker', name: 'Clicker' }, + { slug: 'gallica', name: 'Gallica' }, + { slug: 'khazan', name: 'Khazan' }, + { slug: 'split', name: 'Split' }, + { slug: 'pathojen', name: 'Pathojen' }, + { slug: 'big_dill_chain', name: 'Big Dill Chain' }, + { slug: 'exoborne', name: 'Exoborne' }, + { slug: 'scout', name: 'Scout' }, + { slug: 'fortnite_boogie_bomb', name: 'Fortnite Boogie Bomb' }, + { slug: 'fuchsia_agent', name: 'Fuchsia Agent' }, + { slug: 'wingmans_got_it', name: "WINGMAN'S GOT IT" }, + { slug: 'rec_room_lightning', name: 'Rec Room Lightning' }, + { slug: 'shadow', name: 'Shadow' }, + { slug: 'tga_controller', name: 'TGA Controller' }, + { slug: 'wallach_spaceport', name: 'Wallach IX Spaceport' }, + { slug: 'wolf_morph', name: 'Wolf Morph' }, + { slug: 'street_fighter_6_battle_field', name: 'Street Fighter 6 Battle Field' }, + { slug: 'torgal_puppy', name: 'Torgal Puppy' }, + { slug: 'bunny', name: 'Bunny Mech' }, + { slug: 'hailey', name: 'Hailey' }, + { slug: 'los_santos', name: 'Los Santos' }, + { slug: 'wingman_boba', name: 'Wingman Boba' }, + { slug: 'freezer_bunny_lovebug', name: 'Freezer Bunny Lovebug' }, + { slug: 'im_dancin', name: "I'm Dancin'!" }, + { slug: 'finger_gun', name: 'Finger Gun' }, + { slug: 'bandit_mask', name: 'Bandit Mask' }, + { slug: 'mad_moxxi', name: 'Mad Moxxi' }, + { slug: 'elpis', name: 'Elpis' }, + { slug: 'loot_lightshow', name: 'Loot Lightshow' }, + { slug: 'spirit_blossom_zed', name: 'Spirit Blossom Zed' }, + { slug: 'spirit_blossom_springs_sett', name: 'Spirit Blossom Springs Sett' }, + { slug: 'spirit_blossom_morgana', name: 'Spirit Blossom Morgana' }, + { slug: 'spirit_blossom_karma', name: 'Spirit Blossom Karma' }, + { slug: 'spirit_blossom_springs', name: 'Spirit Blossom Springs' }, + { slug: 'spirit_blossom_springs_ahri', name: 'Spirit Blossom Springs Ahri' }, + { slug: 'yunara', name: 'Yunara' }, + { slug: 'gelatinous_cube_green', name: 'Green Gelatinous Cube' }, + { slug: 'gelatinous_cube_blue', name: 'Blue Gelatinous Cube' }, + { slug: 'spooky_cat_ears_midnight', name: 'Spooky Cat Ears (Midnight)' }, + { slug: 'candlelight_dark', name: 'Dark Candlelight' }, + { slug: 'zombie_food_purple', name: 'Zombie Food (Purple)' }, + { slug: 'bloodthirsty_green', name: 'Bloodthirsty (Green)' }, + { slug: 'ryu', name: 'Ryu' }, + { slug: 'chun_li', name: 'Chun-Li' }, + { slug: 'ken', name: 'Ken' }, + { slug: 'akuma', name: 'Akuma' }, + { slug: 'cammy', name: 'Cammy' }, + { slug: 'guile', name: 'Guile' }, + { slug: 'juri', name: 'Juri' }, + { slug: 'm_bison', name: 'M. Bison' }, + { slug: 'valorant_champions_2024', name: 'VALORANT Champions 2024' }, + { slug: 'yoru_dimensional_drift', name: 'Yoru Dimensional Drift' }, + { slug: 'viper_poison_cloud', name: 'Viper Poison Cloud' }, + { slug: 'cypher_neural_theft', name: 'Cypher Neural Theft' }, + { slug: 'omens_cowl', name: "Omen's Cowl" }, + { slug: 'reynas_leer', name: "Reyna's Leer" }, + { slug: 'frag_out', name: 'FRAG OUT' }, + { slug: 'blade_storm', name: 'Blade Storm' }, + { slug: 'chillet', name: 'Chillet' }, + { slug: 'pal_sphere', name: 'Pal Sphere' }, + { slug: 'lamball', name: 'Lamball' }, + ], + }, ]; export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap( @@ -204,3 +848,14 @@ export function isValidDecorationSlug(slug: string): boolean { export function decorationUrl(slug: string): string { return `${RESOLVED_DECORATION_CDN}/${slug}.png`; } + +/** + * Small static WebP preview (first busy frame, 144px) for the picker. The full + * decorations are ~1 MB animated PNGs, so the picker shows these and only + * loads `decorationUrl` on hover/focus/selection. Built by + * scripts/makeDecorationThumbs.py; callers fall back to `decorationUrl` if a + * thumbnail is missing. + */ +export function decorationThumbUrl(slug: string): string { + return `${RESOLVED_DECORATION_CDN}/thumbs/${slug}.webp`; +} diff --git a/src/app/features/settings/account/ProfileDecoration.tsx b/src/app/features/settings/account/ProfileDecoration.tsx index 2a93a2ed6..333fe1273 100644 --- a/src/app/features/settings/account/ProfileDecoration.tsx +++ b/src/app/features/settings/account/ProfileDecoration.tsx @@ -1,10 +1,15 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Box, Button, Text, Spinner, color } from 'folds'; +import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds'; import { Method } from 'matrix-js-sdk'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { SettingTile } from '../../../components/setting-tile'; -import { DECORATION_CATEGORIES, decorationUrl } from '../../lotus/avatarDecorations'; +import { + ALL_DECORATIONS, + DECORATION_CATEGORIES, + decorationThumbUrl, + decorationUrl, +} from '../../lotus/avatarDecorations'; import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration'; const PROFILE_FIELD = 'io.lotus.avatar_decoration'; @@ -21,6 +26,12 @@ function DecorationPreviewCell({ selected: boolean; onSelect: (slug: string) => void; }) { + // The full decorations are ~1 MB animated PNGs. Show the small static + // thumbnail and only pull the animated file while the cell is hovered, + // focused or selected. A missing thumbnail falls back to the full file. + const [active, setActive] = useState(false); + const [thumbFailed, setThumbFailed] = useState(false); + const animate = active || selected || thumbFailed; return (