diff --git a/README.md b/README.md index 9bb8482..0c95086 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,118 @@

- ZyperOS: Zyper, a miniature schnauzer with his head cocked and his tongue out, beside the name + ZyperOS: Zyper, a miniature schnauzer with his head cocked and his tongue out, beside the name

+

A fast, light firmware for the GKD Pixel 2 that plays your whole retro library.

+ +

+ On in about 2 seconds. The stock firmware takes 30 to 40.
+ Turn it off mid-game; turn it on, and 2 seconds later you're back where you were.
+ Forty systems with their box art, PortMaster ports, RetroAchievements, and your music and videos. Free and open source. +

+

- A fast, light firmware for the GKD Pixel 2 that plays your whole retro library.
- Forty systems, PortMaster ports, and your music and videos, from a card you can take out at any time. + Download the latest release of ZyperOS +   + Install in three steps +   + Join the ZyperOS Discord

zyperos.com  ·  - The name  ·  + Why ZyperOS  ·  + From the stock OS  ·  + Features  ·  Compare  ·  - Install  ·  - Light and dark  ·  Systems  ·  Controls  ·  - FAQ + FAQ  ·  + Discord +

+ +

+ The Nintendo 64 shelf: Super Mario 64's box large in the middle, Mario Kart 64 and Ocarina of Time beside it, a play mark on each cover for its video snap

+## Why ZyperOS + +- **Your library looks the part.** Every game shows its real box art, found on the handheld itself from libretro, ScreenScraper and SteamGridDB, and a few seconds of video when you rest on it. +- **On in about 2 seconds.** The stock firmware takes 30 to 40 (my test, on my Pixel 2). Turn it off mid-game, and the next start puts you back where you were, 2 seconds later. Games start the moment you press A. +- **Plays more, smoothly.** Forty systems and PortMaster's ports. Mario Kart DS keeps 60 frames a second and Super Mario 64 its 30, on the Pixel 2. +- **No more taking the card out.** Add games, music and saves from any browser over Wi-Fi or the USB cable, and update ZyperOS the same way. +- **Made to be picked up.** A first start that teaches every button, eight languages, light and dark, and a menu that never makes you wait. +- **Free and open source,** under GPL-3.0, with nothing locked behind a payment. + +## Coming from the stock OS? + +In my test on my Pixel 2, the stock firmware takes over 30 seconds to start, usually close to 40. ZyperOS is on in about 2, and **POWER** in a game saves it, so the next start puts you back in that game. Beyond that, ZyperOS has what the stock column of [the comparison](#how-it-compares) doesn't show: + +- **Box art on the handheld,** from three sources, with no computer and no Skraper +- **RetroAchievements,** signed in on the device +- **Games over the USB cable** or from any browser, not only Samba +- **System updates** over Wi-Fi or the cable, with roll back +- **Charge limit,** battery **time left** and a **charging screen** while it's off +- **A first-start tutorial** for every button +- **Eight languages** and **menu music** +- **Storage**, by part and by game, and **side lights** for the battery, volume and more + +Keep the stock card as your way back: ZyperOS goes on a second card, and swapping cards switches systems ([keeping the stock card safe](https://zyperos.com/blog/gkd-pixel-2-stock-firmware/)). Your games come over as files, each into its console's folder under `Roms/` ([the folders](#supported-systems)). + +## Install in three steps + +1. **Download ZyperOS Installer** for macOS, Windows or Linux from [the latest release](https://github.com/tarikbc/ZyperOS/releases/latest). +2. **Write the card.** Put the microSD card in the computer, pick the GKD Pixel 2, the latest release and the card, and choose **Fresh install**. +3. **Play.** Put the card in the Pixel 2 and turn it on. The first start sets the card up in about a minute, then shows you each button. + +Then add games over Wi-Fi, the USB cable or the card itself. [The full install](#install) covers the card image, updates and the Wi-Fi adapter. + +Questions, ideas, or want to help build it? Come say hi on [the ZyperOS Discord](https://discord.gg/Ust8sE22Tm). + + +

+ Watch the ZyperOS tour: a green GKD Pixel 2 showing a shelf of PlayStation covers, beside the ZyperOS name
+ The tour, 105 seconds on zyperos.com +

+ + +## Screens +
- - - + + + - - - + + + - - - + + + + + - - - + + +
The Spotlight home: PlayStation's console large, the other systems in a row below
Every system, in pixel art
The Gallery home: PlayStation's console centered between its neighbors
Or the Gallery home
A system's games as a row of cards, a heart before a favorite's name
Your games, cover by cover
The PlayStation shelf: Metal Gear Solid's box large, Crash Bandicoot and Spyro the Dragon beside it, a heart before the focused game's name
Your games, cover by cover
Favorites: Sonic The Hedgehog 2 between Metroid Fusion and Super Mario 64, Genesis under its name
Favorites from every system
The SNES shelf: Super Mario World's box between Donkey Kong Country and Super Metroid
Box art the handheld finds itself
A game's details
A game's details, one button away
An achievements list
RetroAchievements on the device
The ZyperOS menu, Search Games at the top
The device's settings in one menu
The Spotlight home: PlayStation's console large, the other systems in a row below
Every system, in pixel art
The Gallery home: PlayStation's console centered between its neighbors
Or the Gallery home
Metal Gear Solid's Cover and Video page: its cover now, the boxes, title screen and screenshots the sources offer, and its video
Pick a cover, and see it first
MP4's shelf of an album, a film and a book, each marked with what it is, the clock at the top left
Your albums and films, in MP4
MP4's Now Playing
Now Playing, on SELECT from anywhere
MENU > Scraper: Fetch Missing Art, Video Snaps, and the covers ScreenScraper, libretro and SteamGridDB found
Box art from three sources
An achievement's unlock notice: its badge, 'Unlocked · 10 points' over its name, and Zyper at the right
RetroAchievements, and Zyper cheers
The in-game menu over a paused Spyro the Dragon, on its Quick Slot row
A quick slot for each system
Controls: Hotkeys. SELECT with R1 and L1 quick save and load, with R2 and L2 faster and slower
SELECT with a shoulder: the hotkeys
Install PortMaster: its crane icon, PortMaster's version, what it is, and the download's bar at 9.8 of 24 MB
PortMaster with one press
MENU > Storage: the card in one bar by part, two recommendations, then every system by size
What fills the card
MENU > Battery Icon set to Time Left: the corner reads 3 h 25 min beside the battery
The battery, as time left
The in-game menu on its Quick Slot row, slot 1
A quick slot for each system
Controls: Hotkeys. SELECT with R1 and L1 quick save and load, with R2 and L2 faster and slower
SELECT with a shoulder: the hotkeys
Controls: PlayStation. A, B, X and Y beside the circle, cross, triangle and square they are
Every button, as its pad draws it
MP4's shelf of an album, a film and a book, each marked with what it is
Your albums, books and films, in MP4
Metal Gear Solid's details: Synopsis, Year, Genre, Achievements, Favorite, Cover and Video
A game's details, one button away
The ZyperOS menu, Search Games at the top
The device's settings in one menu
-
- -## The name +The shots show real box art to show what the Scraper does. The games and their art belong to their owners: ZyperOS comes with no games and no art. -Zyper, a grey miniature schnauzer, sitting on the grass with his tongue out - -ZyperOS is named after **Zyper**, my dog, who passed away. He was quick, light on his feet and full of energy, and that is what this system tries to be. - -The name is also two words in one: **zippy** and **hyper**. Games start the moment you press A, the menus never make you wait, and the whole system is small enough to come up in about two seconds. + ## Features @@ -97,8 +152,17 @@ The name is also two words in one: **zippy** and **hyper**. Games start the mome - **Battery care:** a battery too low to start says so and charges first; a charger plugged in while the Pixel 2 is off shows the charge and the time to full (hold POWER to start); **Getting hot** warns before the handheld slows down to cool, over a game or a port; and **MENU > Charge Limit** stops charging at 80, 85 or 90% to keep the battery young, and holds it within 2 points of the limit on the cable (78 to 80% at 80%). - **About,** besides the version and the card, tells [the story of the name](#the-name) beside a photo of Zyper, and thanks the projects ZyperOS is built on. - **Play Time:** how long you've played each game or console, today, this week, this month, this year or ever. +- **A way out of anything:** hold **MENU + START** for a second and whatever runs ends, a stuck game or port included, and the home comes back. After a crash, the Pixel 2 starts on the home rather than back in the game. In the in-game menu, **X** quits from any row. - **Continue or from the start, favorites and sorting:** A continues a game where you left it and Y starts it from the start; Y on a game's details (X) favorites it, and a heart before its name says so on its console's shelf; each console sorts by name, play time, last played or recently added. +## The name + +Zyper, a grey miniature schnauzer, sitting on the grass with his tongue out + +ZyperOS is named after **Zyper**, my dog, who passed away. He was quick, light on his feet and full of energy, and that is what this system tries to be. + +The name is also two words in one: **zippy** and **hyper**. Games start the moment you press A, the menus never make you wait, and the whole system is small enough to come up in about two seconds. + ## How it compares The GKD Pixel 2 has several operating systems to choose from. This is ZyperOS beside the one it comes with and four others, from what each one's own pages, code and releases say in November 2026. ROGUE OS set the bar for this handheld, and ZyperOS set out to match it. @@ -124,8 +188,10 @@ The GKD Pixel 2 has several operating systems to choose from. This is ZyperOS be | **Pick a game's cover on the device** | From three sources, a big look first, the old one kept to put back | — | — | — | — | — | | **Video previews on the shelf** | Yes | — | — | — | — | — | | **Charge limit** | 80, 85 or 90% | — | — | — | — | — | +| **Battery time left** | Time left, or time to full on the cable | — | — | — | — | — | +| **A DS game's language** | Picked per game, whatever `firmware.bin` says | — | — | — | — | — | | **Charging screen when off** | Yes | — | — | — | — | — | -| **Boot time** | 1.6 s4 | — | — | 2.2 s4 | — | — | +| **Boot time** | 1.6 s4, about 2 s from power on5 | 30 to 40 s from power on5 | — | 2.2 s4 | — | — | — means none of the sources below says either way. The Pixel 2 has no Wi-Fi of its own, so on every system a Wi-Fi feature needs a USB Wi-Fi adapter. Knulli, muOS, twigUI (spruceOS) and a MinUI port run on the Pixel 2 too. @@ -133,12 +199,13 @@ The GKD Pixel 2 has several operating systems to choose from. This is ZyperOS be 2. No count is published, so this is the systems in its own list, counted without tools, media players and ports. Hacks and regions can be entries of their own. 3. In builds made with ScreenScraper's developer key. 4. Each project's own number, and not measured the same way: ZyperOS's from the kernel's start to the home screen, on the Pixel 2 (mean of 10 starts), and TortOS's from power on to its shelf. +5. My own count on my Pixel 2, from power on until it can be used, with the stock card it came with and with ZyperOS: the stock firmware takes over 30 seconds, usually close to 40; ZyperOS about 2.
Sources -- **ZyperOS:** this README and its code, [v1.0.2](https://github.com/tarikbc/ZyperOS/releases/tag/v1.0.2); the boot time I measured on my Pixel 2 (note 4) and [#83](https://github.com/tarikbc/ZyperOS/pull/83). -- **Stock:** [Retro Game Corps, February 2025](https://retrogamecorps.com/2025/02/01/how-to-make-the-gkd-pixel-ii-software-suck-less/). +- **ZyperOS:** this README and its code, [v1.0.2](https://github.com/tarikbc/ZyperOS/releases/tag/v1.0.2); the boot times I measured on my Pixel 2 (notes 4 and 5) and [#83](https://github.com/tarikbc/ZyperOS/pull/83). +- **Stock:** [Retro Game Corps, February 2025](https://retrogamecorps.com/2025/02/01/how-to-make-the-gkd-pixel-ii-software-suck-less/); the boot time is my own count (note 5). - **ROGUE OS:** [README](https://github.com/Ninoh-FOX/PIXEL2-ROGUE-OS/blob/f567073e9dd9d5f688a23728067d043af2fbdc20/README.md), [v1.22](https://github.com/Ninoh-FOX/PIXEL2-ROGUE-OS/releases/tag/GKDPIXELII_1.22), [system list](https://github.com/Ninoh-FOX/PIXEL2-ROGUE-OS/blob/f567073e9dd9d5f688a23728067d043af2fbdc20/EMUELEC/SYSTEM/usr/local/share/emulationstation/es_systems.cfg), [launcher skins](https://github.com/Ninoh-FOX/PIXEL2-ROGUE-OS/tree/f567073e9dd9d5f688a23728067d043af2fbdc20/EMUELEC/SYSTEM/usr/local/share/iux/skins), [launcher languages](https://github.com/Ninoh-FOX/PIXEL2-ROGUE-OS/tree/f567073e9dd9d5f688a23728067d043af2fbdc20/EMUELEC/SYSTEM/usr/local/share/iux/translations). - **TortOS:** [README](https://github.com/ericreinsmidt/TortOS/blob/aafe6dee0d8221642e68d8b17fb17c6c5a39f8e1/README.md), [guide](https://github.com/ericreinsmidt/TortOS/blob/aafe6dee0d8221642e68d8b17fb17c6c5a39f8e1/docs/guide.md), [plastron's README](https://github.com/ericreinsmidt/plastron/blob/b67f5ec86a601f878f1bc8452d16af7112950f3d/README.md), [v2.1.1](https://github.com/ericreinsmidt/TortOS/releases/tag/v2.1.1). - **ROCKNIX:** [README](https://github.com/ROCKNIX/distribution/blob/13d241a7d35fbe9b895ef04519f62bdb3a57d31f/README.md), [features](https://rocknix.org/), [adding games](https://rocknix.org/play/add-games/), [scraper](https://rocknix.org/configure/scraper/), [RetroAchievements](https://rocknix.org/play/retro-achievements/), [updates](https://rocknix.org/play/update/), [themes](https://rocknix.org/configure/themes/), [RK3326 systems](https://github.com/ROCKNIX/distribution/blob/13d241a7d35fbe9b895ef04519f62bdb3a57d31f/documentation/PER_DEVICE_DOCUMENTATION/RK3326/SUPPORTED_EMULATORS_AND_CORES.md), [Pixel 2 device tree](https://github.com/ROCKNIX/distribution/blob/13d241a7d35fbe9b895ef04519f62bdb3a57d31f/projects/ROCKNIX/devices/RK3326/linux/dts/rockchip/rk3326s-gkd-pixel2.dts) and [settings](https://github.com/ROCKNIX/distribution/tree/13d241a7d35fbe9b895ef04519f62bdb3a57d31f/projects/ROCKNIX/packages/hardware/quirks/devices/GameKiddy%20GKD%20Pixel2), [menu music](https://github.com/ROCKNIX/emulationstation-next/blob/f6c542872e3850c5f432929ead219547a897cd4a/es-app/src/guis/GuiMenu.cpp), [languages](https://github.com/ROCKNIX/emulationstation-next/blob/f6c542872e3850c5f432929ead219547a897cd4a/es-app/src/guis/GuiMenu.cpp#L1627-L1670), [20261101](https://github.com/ROCKNIX/distribution/releases/tag/20261101). @@ -159,12 +226,12 @@ ZyperOS has two themes, Beard in light and Salt & Pepper in dark. **MENU > Appea Dark (Salt & Pepper) - The Spotlight home in the light theme - The Spotlight home in the dark theme + The Nintendo 64 shelf in the light theme, Super Mario 64's box in the middle + The Nintendo 64 shelf in the dark theme, Super Mario 64's box in the middle - The ZyperOS menu in the light theme - The ZyperOS menu in the dark theme + The Spotlight home in the light theme + The Spotlight home in the dark theme A game's Cover and Video page in the light theme diff --git a/docs/building.md b/docs/building.md index 4b211f1..68d9782 100644 --- a/docs/building.md +++ b/docs/building.md @@ -300,7 +300,8 @@ Every shot names the screen and the focused item on stderr, so a sequence of them reads back as a list of what was actually drawn. `tools/readme-shots.py` captures the README and website gallery in both themes, -with an isolated demonstration library and optional local artwork. See +with an isolated demonstration library; `--art` gives its games their real box +art from libretro-thumbnails, cached outside the repository. See [`res/readme/README.md`](../res/readme/README.md) for capture provenance and the refresh steps. diff --git a/mk/site.sh b/mk/site.sh index 3c8a367..656b640 100755 --- a/mk/site.sh +++ b/mk/site.sh @@ -28,11 +28,11 @@ done cp "$ROOT/site/models/pixel2.glb" "$ROOT/site/models/pixel2-poster.png" "$OUT/models/" cp "$ROOT/res/web/icon.svg" "$ROOT/res/web/zyper.svg" "$OUT/img/" # Every screenshot in the light theme; dark/ only for the light-and-dark section -for f in lockup lockup-light systems gallery shelf info cheevos cheevo-notice menu scraper mp4 storage nowplaying portal \ - battery gamemenu controls-hotkeys media; do +for f in lockup lockup-light systems gallery shelf shelf-n64 shelf-snes favorites info cheevos cheevo-notice menu scraper \ + mp4 storage nowplaying portal portmaster battery gamemenu controls-hotkeys media; do cp "$ROOT/res/readme/$f.png" "$OUT/img/" done -for f in systems menu; do +for f in shelf-n64 systems; do cp "$ROOT/res/readme/dark/$f.png" "$OUT/img/dark/" done # Zyper's photo, in The name diff --git a/res/readme/README.md b/res/readme/README.md index 1c89291..895055c 100644 --- a/res/readme/README.md +++ b/res/readme/README.md @@ -2,8 +2,9 @@ The screenshots in this folder show the current launcher in Beard, the light theme: the README and the website show every screen in light. `dark/` holds -the screens their light-and-dark sections show in Salt & Pepper, the home, -the ZyperOS menu and a game's Cover and Video page, and the top bar's shot (`topbar`, the song's note and the +the screens their light-and-dark sections show in Salt & Pepper, the +Nintendo 64 shelf, the home, the ZyperOS menu and a game's Cover and Video +page, and the top bar's shot (`topbar`, the song's note and the headphone mark) in both themes. The 3D device on the website shows the light ones too. The logo/wordmark files remain the branding generator's output. @@ -12,41 +13,54 @@ mockups or photographs of a running device. The three `portal` shots are the File Transfer page (res/web) in headless Chrome, at a computer's 1280x800 and a phone's 390x844, served by the real routes over a scratch card of freeware names (build-native/portal-serve). The source commit, command flags -and output hashes are recorded in `captures.json`, which also names the frames -reused from the sessions that built a screen. - -The capture uses temporary demonstration cards: empty game and music files -with freeware or stand-in names, a card with no games for the empty home, and -curated Metal Gear Solid metadata. The launcher draws every game card itself: -no box art and no game screens are included. The Cover and Video shots -(`media`, `media-look`, `media-video`) show stand-in pictures readme-shots.py -draws in the shapes a source offers (a box, a 3D box, a title screen, a fan's -cover), blends of one color with a diamond, none of any game's art. The home screens show the -system art in res/system-icons. MP4 shows clearly named demonstration albums, -a film named after Blender's freely licensed Sintel and the public-domain -Treasure Island as a book, all of them empty files. The games page's heart -marks two demonstration favorites, set for that shot alone. -Achievement rows, playback timing, transfer progress, the hotspot, the -Scraper's counts, the Storage page and the System Update page use the launcher's built-in shot -fixtures and stand-in values. No ROMs, audio, account credentials or personal -save data are included or required. The clock and battery are fixed preview -values. +and output hashes are recorded in `captures.json`, with the art each one +shows. + +The capture uses temporary demonstration cards: empty game files named after +well-known games, empty music files with stand-in names, a card with no games +for the empty home, and curated Metal Gear Solid details (the synopsis is in +my words). The shots show the games' real box art, as the launcher's own +Scraper would put it on the card: covers, title screens and screenshots from +the [libretro-thumbnails](https://github.com/libretro-thumbnails) sets, +fetched once into a cache on the computer that takes the shots, outside this +repository. The Cover and Video shots (`media`, `media-look`, `media-video`) +show Metal Gear Solid's boxes, title screen and screenshot in the places the +page's fixture names; the in-game menu and Controls shots stand over a +Spyro the Dragon screenshot; the video snaps behind the play marks are two +seconds of each game's libretro screenshot. `captures.json` names every +picture used, its set and its checksum. The games and their art belong to +their owners: ZyperOS ships no games and no art, and only these composed +screenshots are in the repository. + +The home screens show the system art in res/system-icons. MP4 shows clearly +named demonstration albums, a film named after Blender's freely licensed +Sintel and the public-domain Treasure Island as a book, all of them empty +files. The ScummVM shelf shows the freeware games ScummVM's own site gives +away, with their libretro covers. The made-up port behind the Ports menu and +Controls shots (tools/fixtures/ports) has no art. The games pages' hearts +mark demonstration favorites, set for those shots alone. +Achievement rows and badges, playback timing, transfer progress, the hotspot, +the Scraper's counts, the Storage page and the System Update page use the +launcher's built-in shot fixtures and stand-in values. No ROMs, audio, +account credentials or personal save data are included or required. The +clock and battery are fixed preview values. ## Refresh ```sh make native -python3 tools/readme-shots.py +python3 tools/readme-shots.py --art ``` Review `build/readme-shots/light/` and `dark/`. Copy the approved PNGs to this folder and `dark/`, and copy `manifest.json` to `captures.json`. Names after the command take only those shots (`python3 tools/readme-shots.py menu`). -`--art-dir` copies local covers into the card; leave it out for these -pictures. The page shots need Google Chrome (`--chrome` names another +`--art` fetches what the cache lacks from raw.githubusercontent.com the first +time (ffmpeg makes the video snaps) and nothing after; `--art-cache` moves +the cache. Without `--art` every game wears the launcher's generated card. The page shots need Google Chrome (`--chrome` names another Chromium, `--no-page` leaves them out), and the page's server listens on port -8080 or 8081 while they are taken. The launcher and the page make no requests -outside this computer for the captures. A browser's frames are not quite +8080 or 8081 while they are taken. Apart from that first fetch of the art, the launcher and the page make no +requests outside this computer for the captures. A browser's frames are not quite repeatable: a shadow can differ by a few pixels from run to run, and the upload's percentage by its timing. diff --git a/res/readme/battery.png b/res/readme/battery.png index c3d0bd4..dae0a32 100644 Binary files a/res/readme/battery.png and b/res/readme/battery.png differ diff --git a/res/readme/captures.json b/res/readme/captures.json index 46b0381..cb730b3 100644 --- a/res/readme/captures.json +++ b/res/readme/captures.json @@ -1,8 +1,284 @@ { - "source_commit": "642bc2aae5042491c320976a7e317edcd3f3e65f", + "source_commit": "0a36a2dd8991e65dc1d5c2025a57e583539aaea7", "renderer": "current native launcher, SDL software, headless", - "data": "demonstration library (freeware or stand-in names, empty files); built-in shot fixtures", - "artwork": {}, + "data": "demonstration library (well-known games and freeware by name, empty files); built-in shot fixtures", + "artwork": { + "PlayStation/Castlevania - Symphony of the Night (USA).chd": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Castlevania - Symphony of the Night (USA)", + "sha256": "0a3064fc4eaa9c9fab4885dc5b65dac044b4079ab731a036f06411bb20048b57" + }, + "PlayStation/Crash Bandicoot (USA).chd": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Crash Bandicoot (USA)", + "sha256": "53da89f702471310d68dda076f42490e7f6bfcc22dd7402917c6a8077bf85a77" + }, + "PlayStation/Metal Gear Solid (USA).chd": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Metal Gear Solid (USA)", + "sha256": "213bc7c6de0ee52bb7981cd66f3294f51057b0d96ce2f6f3ad440aa452ec8120" + }, + "PlayStation/Spyro the Dragon (USA).chd": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Spyro the Dragon (USA)", + "sha256": "14d9a52c23559fbce8e337363c674d9a9abac3d81d2ba17fc9775d98728f303b" + }, + "PlayStation/Tekken 3 (USA).chd": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Tekken 3 (USA)", + "sha256": "080ad8cba468ea08f0d3c25b4396e586043852814a5e6d3690d86c41d8c6b1e1" + }, + "PlayStation/Tony Hawk's Pro Skater 2 (USA).chd": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Tony Hawk's Pro Skater 2 (USA)", + "sha256": "a901005870f4f3be71be4b95a2066b18e7d2b935b5d086bc308624eb2cab482a" + }, + "NES/Kirby's Adventure (USA).nes": { + "set": "libretro-thumbnails/Nintendo_-_Nintendo_Entertainment_System/Named_Boxarts", + "name": "Kirby's Adventure (USA)", + "sha256": "cd449ee183cfa05ec6d74381a11fa39aeea631bf9bb85d3e8078fbe75030546e" + }, + "NES/Mega Man 2 (USA).nes": { + "set": "libretro-thumbnails/Nintendo_-_Nintendo_Entertainment_System/Named_Boxarts", + "name": "Mega Man 2 (USA)", + "sha256": "507e52d5994f415e3df8f50735a7b845a055ecfffbd063b227ed80513fbeb8f7" + }, + "NES/Metroid (USA).nes": { + "set": "libretro-thumbnails/Nintendo_-_Nintendo_Entertainment_System/Named_Boxarts", + "name": "Metroid (USA)", + "sha256": "005aa3de5152eabcaf4038df012acb0810e2835ec1dadbe7d4123a37b1a29063" + }, + "NES/Super Mario Bros. 3 (USA).nes": { + "set": "libretro-thumbnails/Nintendo_-_Nintendo_Entertainment_System/Named_Boxarts", + "name": "Super Mario Bros. 3 (USA)", + "sha256": "3840a9a5992533c9d1c5f474cbe8bc6216dce441c9628564c62549f456d839b1" + }, + "NES/The Legend of Zelda (USA).nes": { + "set": "libretro-thumbnails/Nintendo_-_Nintendo_Entertainment_System/Named_Boxarts", + "name": "Legend of Zelda, The (USA)", + "sha256": "4ab0be443b2c3ed4a2d1520f85068877873c457c5c95809ba86c30f7ad7cf622" + }, + "SNES/Chrono Trigger (USA).sfc": { + "set": "libretro-thumbnails/Nintendo_-_Super_Nintendo_Entertainment_System/Named_Boxarts", + "name": "Chrono Trigger (USA)", + "sha256": "424f3104c6a62d7f50b01059cfa1d80cce594e5ce7b89b7040252d12deda160b" + }, + "SNES/Donkey Kong Country (USA).sfc": { + "set": "libretro-thumbnails/Nintendo_-_Super_Nintendo_Entertainment_System/Named_Boxarts", + "name": "Donkey Kong Country (USA)", + "sha256": "f2f8873d7433212f811aeff06f56681b7101eb532d1c2b21511cc7d7e5faa315" + }, + "SNES/Super Mario World (USA).sfc": { + "set": "libretro-thumbnails/Nintendo_-_Super_Nintendo_Entertainment_System/Named_Boxarts", + "name": "Super Mario World (USA)", + "sha256": "8c2bb5b52df24943e057efed073646fb9d3658348800602d23430dfd02812193" + }, + "SNES/Super Metroid (USA).sfc": { + "set": "libretro-thumbnails/Nintendo_-_Super_Nintendo_Entertainment_System/Named_Boxarts", + "name": "Super Metroid (Japan, USA) (En,Ja)", + "sha256": "5f2ee5da7bbc2fc4042ba77726e0fa19a9619bd427fa04d780d1ce1210de7d65" + }, + "SNES/The Legend of Zelda - A Link to the Past (USA).sfc": { + "set": "libretro-thumbnails/Nintendo_-_Super_Nintendo_Entertainment_System/Named_Boxarts", + "name": "Legend of Zelda, The - A Link to the Past (USA)", + "sha256": "ad56494a337cd5d2bf0477c7c8f2f344ef661ff43158f58cd2593b625ba2fddc" + }, + "Game Boy/Pokemon - Red Version (USA, Europe).gb": { + "set": "libretro-thumbnails/Nintendo_-_Game_Boy/Named_Boxarts", + "name": "Pokemon - Red Version (USA, Europe) (SGB Enhanced)", + "sha256": "48f38b05a808b61aa7578260b94dcbf9826597fb0b4cc1b0b274ff07eeb66c5c" + }, + "Game Boy/Super Mario Land (World).gb": { + "set": "libretro-thumbnails/Nintendo_-_Game_Boy/Named_Boxarts", + "name": "Super Mario Land (World)", + "sha256": "8376e6c016a5bd50a2a3600966fd88d5d67dc70e90d590f9a83d1e21f4153a78" + }, + "Game Boy/Tetris (World).gb": { + "set": "libretro-thumbnails/Nintendo_-_Game_Boy/Named_Boxarts", + "name": "Tetris (World) (Rev 1)", + "sha256": "73624db64263bca50e8a65678b6075fd35d0c364599e4ddd46f558fcf555641f" + }, + "Game Boy/The Legend of Zelda - Link's Awakening (USA, Europe).gb": { + "set": "libretro-thumbnails/Nintendo_-_Game_Boy/Named_Boxarts", + "name": "Legend of Zelda, The - Link's Awakening (USA, Europe)", + "sha256": "87bf63bb1ee64dc69b51448f84f9d9194cb88f626aabdc520e427de7b8b0056a" + }, + "Game Boy Advance/Advance Wars (USA).gba": { + "set": "libretro-thumbnails/Nintendo_-_Game_Boy_Advance/Named_Boxarts", + "name": "Advance Wars (USA)", + "sha256": "e8bc3d7e27d8902b639c2b4834e072a93cb81389b51bdf1278681738cad0a45c" + }, + "Game Boy Advance/Mario Kart - Super Circuit (USA).gba": { + "set": "libretro-thumbnails/Nintendo_-_Game_Boy_Advance/Named_Boxarts", + "name": "Mario Kart - Super Circuit (USA)", + "sha256": "21e774463bbb272670484d070b281f1a7c16c3a2bfe0a8ff1b6ffadabd8e01c9" + }, + "Game Boy Advance/Metroid Fusion (USA).gba": { + "set": "libretro-thumbnails/Nintendo_-_Game_Boy_Advance/Named_Boxarts", + "name": "Metroid Fusion (USA)", + "sha256": "bb0c041dda892fc734b42491e45a6ffa34d0d52577a176133988421095e7db67" + }, + "Game Boy Advance/Pokemon - Emerald Version (USA, Europe).gba": { + "set": "libretro-thumbnails/Nintendo_-_Game_Boy_Advance/Named_Boxarts", + "name": "Pokemon - Emerald Version (USA, Europe)", + "sha256": "f39a4d3d7044d2ad693a60f6af362619f7268cd9187c6576f3d4e3373dfa49b5" + }, + "Game Boy Advance/The Legend of Zelda - The Minish Cap (USA).gba": { + "set": "libretro-thumbnails/Nintendo_-_Game_Boy_Advance/Named_Boxarts", + "name": "Legend of Zelda, The - The Minish Cap (USA)", + "sha256": "299ec4f71780caf229616286d13e5a57e135427814be00000ede6589eb627642" + }, + "Genesis/Gunstar Heroes (USA).md": { + "set": "libretro-thumbnails/Sega_-_Mega_Drive_-_Genesis/Named_Boxarts", + "name": "Gunstar Heroes (USA)", + "sha256": "74ce0af2572dc5ab5c50ecf8330511575964861adfd0107137b7be31acca00c3" + }, + "Genesis/Sonic The Hedgehog (USA).md": { + "set": "libretro-thumbnails/Sega_-_Mega_Drive_-_Genesis/Named_Boxarts", + "name": "Sonic The Hedgehog (USA)", + "sha256": "6fb5e8f86532044471761ffebc9e557764ea7c2354b8c11d859d1665372ffd40" + }, + "Genesis/Sonic The Hedgehog 2 (USA).md": { + "set": "libretro-thumbnails/Sega_-_Mega_Drive_-_Genesis/Named_Boxarts", + "name": "Sonic The Hedgehog 2 (USA)", + "sha256": "00551991d5fbb78c824e4a3f97f11060a6d2123e89e78afe6a75bf954bd82649" + }, + "Genesis/Streets of Rage 2 (USA).md": { + "set": "libretro-thumbnails/Sega_-_Mega_Drive_-_Genesis/Named_Boxarts", + "name": "Streets of Rage 2 (USA)", + "sha256": "393034ba5b05e851decad54748b57997732097ea584c770a7f558b9fba2723ff" + }, + "Nintendo 64/GoldenEye 007 (USA).z64": { + "set": "libretro-thumbnails/Nintendo_-_Nintendo_64/Named_Boxarts", + "name": "GoldenEye 007 (USA)", + "sha256": "e3a2cfed3e140e14a738a8ecf5afb12740645ef4d793b2da0b99d3fa21434049" + }, + "Nintendo 64/Mario Kart 64 (USA).z64": { + "set": "libretro-thumbnails/Nintendo_-_Nintendo_64/Named_Boxarts", + "name": "Mario Kart 64 (USA)", + "sha256": "c6de99137340869eaa86200ab8209b161c2daad6a7bdd0241d585a3603df22ce" + }, + "Nintendo 64/Super Mario 64 (USA).z64": { + "set": "libretro-thumbnails/Nintendo_-_Nintendo_64/Named_Boxarts", + "name": "Super Mario 64 (USA)", + "sha256": "ec5ba1a5967bf2b5276ac164e5ce2970cee6c5bec4eb3013396faf2a6aa1ec2c" + }, + "Nintendo 64/The Legend of Zelda - Ocarina of Time (USA).z64": { + "set": "libretro-thumbnails/Nintendo_-_Nintendo_64/Named_Boxarts", + "name": "Legend of Zelda, The - Ocarina of Time (USA)", + "sha256": "1d71330d40934b4234901ea1df7d835eab74f7cc26fa2f45b2d4214ebb8f4890" + }, + "Dreamcast/Crazy Taxi (USA).chd": { + "set": "libretro-thumbnails/Sega_-_Dreamcast/Named_Boxarts", + "name": "Crazy Taxi (USA)", + "sha256": "8e07bc902b28445319bd5630e6df4aab0e8ca2c232dae559f6e1379b4c8f5eac" + }, + "Dreamcast/Jet Grind Radio (USA).chd": { + "set": "libretro-thumbnails/Sega_-_Dreamcast/Named_Boxarts", + "name": "Jet Grind Radio (USA)", + "sha256": "7f1166ce1dc61531749a190bcf34c4e26d8083f18a5ee05c3f062d828fa7d9e9" + }, + "Dreamcast/Sonic Adventure (USA).chd": { + "set": "libretro-thumbnails/Sega_-_Dreamcast/Named_Boxarts", + "name": "Sonic Adventure (USA)", + "sha256": "33fb22ec996af12b3a801aed06ff218b87b7c8b840219028657539146affa1cd" + }, + "Dreamcast/Soulcalibur (USA).chd": { + "set": "libretro-thumbnails/Sega_-_Dreamcast/Named_Boxarts", + "name": "Soulcalibur (USA)", + "sha256": "62d9c9f621c2784317eb2fbeb7bf64f62d728fbf13bf52b58a270589beebb0ad" + }, + "media-art/now.png": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Metal Gear Solid (USA)", + "sha256": "213bc7c6de0ee52bb7981cd66f3294f51057b0d96ce2f6f3ad440aa452ec8120" + }, + "media-art/c0.png": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Metal Gear Solid (USA) (Disc 1)", + "sha256": "213bc7c6de0ee52bb7981cd66f3294f51057b0d96ce2f6f3ad440aa452ec8120" + }, + "media-art/c1.png": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Metal Gear Solid (Japan)", + "sha256": "5fd5550f12b79cbde6603287a330ce78aa6c5b9e0f305f574dea8728781e627c" + }, + "media-art/c2.png": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Metal Gear Solid (Europe)", + "sha256": "ed2a57f3d24e97037d616d42279c19cc378a123b97aed482d8f85b64c773689f" + }, + "media-art/c3.png": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Metal Gear Solid (Spain) (Disc 1)", + "sha256": "6ca17212936cb4c53e2fa290835467bf806a2e7c75d560359f15c1d32d28ad58" + }, + "media-art/c4.png": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Metal Gear Solid (Japan, Asia) (Disc 1) (Ichi) (Premium Package)", + "sha256": "7af1d79ae8ac508da0a5a42251afb6fe56b93f5bd9a4e2e97be8c18dc7a62d6e" + }, + "media-art/c5.png": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Titles", + "name": "Metal Gear Solid (USA)", + "sha256": "a68a56d4d5245503eaafcc00bb70bd4f9a673ea7783d1da4c9b29e2cbb98cd20" + }, + "media-art/c6.png": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Snaps", + "name": "Metal Gear Solid (USA)", + "sha256": "6c9fec69ebcd71636dce05e16d538cc7f8a98158948fe5293df65dc05e21b978" + }, + "media-art/c7.png": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Metal Gear Solid (Japan) (Disc 1) (Ichi)", + "sha256": "5fd5550f12b79cbde6603287a330ce78aa6c5b9e0f305f574dea8728781e627c" + }, + "media-art/c8.png": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Titles", + "name": "Metal Gear Solid (Japan) (Disc 1) (Ichi)", + "sha256": "e9c6e54f51fe1414d964b45ab9e6bc4b24b466791e2cc537877251fe74b3f3dc" + }, + "media-art/c9.png": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Snaps", + "name": "Metal Gear Solid (USA) (Disc 1)", + "sha256": "be1dd3063532f910ade3533d5e66f6e0aa317d553580e574aec26ddcbed0c5c0" + }, + "media-art/c10.png": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Boxarts", + "name": "Metal Gear Solid (Germany) (Disc 1)", + "sha256": "ed2a57f3d24e97037d616d42279c19cc378a123b97aed482d8f85b64c773689f" + }, + "media-art/snap.png": { + "set": "libretro-thumbnails/Sony_-_PlayStation/Named_Snaps", + "name": "Metal Gear Solid (USA)", + "sha256": "6c9fec69ebcd71636dce05e16d538cc7f8a98158948fe5293df65dc05e21b978" + }, + "ScummVM/Beneath a Steel Sky": { + "set": "libretro-thumbnails/ScummVM/Named_Boxarts", + "name": "Beneath a Steel Sky", + "sha256": "2cdff43c9db0bc2176be1d7597f4c0f5a4052b7ea15b46f5fb65237a77194331" + }, + "ScummVM/Drascula - The Vampire Strikes Back": { + "set": "libretro-thumbnails/ScummVM/Named_Boxarts", + "name": "Drascula - The Vampire Strikes Back", + "sha256": "12c6ee5ee5a86aa88f4c22847a5fd2815709e80e5dc8a216db9d95a5204e19f8" + }, + "ScummVM/Flight of the Amazon Queen": { + "set": "libretro-thumbnails/ScummVM/Named_Boxarts", + "name": "Flight of the Amazon Queen (CD_DOS_English)", + "sha256": "b9a45a96b5e42c91c44415772ef2f3f942c95fbc46cc511cbf59a1b05dcb6e02" + }, + "ScummVM/Lure of the Temptress": { + "set": "libretro-thumbnails/ScummVM/Named_Boxarts", + "name": "Lure of the Temptress", + "sha256": "a3953fae6ee1c7a5ad61852011d5bd2fdd19b6bbd7507f7536b45e628bfdca8f" + }, + "ScummVM/Soltys": { + "set": "libretro-thumbnails/ScummVM/Named_Boxarts", + "name": "Soltys", + "sha256": "1b32cbfbe0f9da2748ff8b3615542861cf1a4675018c651e6d471cdaba6d5fd5" + } + }, "screenshots": [ { "file": "light/systems.png", @@ -20,8 +296,7 @@ "--home", "spotlight" ], - "sha256": "03f2f86944e4e2e3ed5dbe966dd6cedeb7e1a41ac3c61862a1b9f705caa35e44", - "source_commit": "23e4d4f5e09a27bcba5a747c60cbfc2c90c7ddda" + "sha256": "62aec47a8de381677bf931efcde6ccdcd826559c8a857bd575284a3690df90c5" }, { "file": "light/gallery.png", @@ -39,8 +314,7 @@ "--home", "gallery" ], - "sha256": "bb80a19fedd50dc4f2a1ee2ea780d9dbc6f2899b27e18699f5bad342c3ef419d", - "source_commit": "23e4d4f5e09a27bcba5a747c60cbfc2c90c7ddda" + "sha256": "fc9f7a224e3c8c6803cc212cf60bf4b609143b6466ba7ac5de47e0e8f61bfb8c" }, { "file": "light/shelf.png", @@ -58,8 +332,61 @@ "--screen", "games" ], - "sha256": "98d9d89f326be9cfd8eb940efafe9907b84369aa18e515d2fc3455bf795842df", - "source_commit": "76a5eea6f69f1b85e22490e15a8688b2ed913ac9" + "sha256": "1784c6f1b1d6b8463d507a4370d12121b21e3c3aa2326affc1ed3b93126451c0" + }, + { + "file": "light/shelf-n64.png", + "card": "demo", + "args": [ + "--theme", + "light", + "--screen", + "systems", + "--phase", + "3200", + "--status", + "10:08 24h wifi=off batt=82", + "--fn-volume", + "--screen", + "games" + ], + "sha256": "b2e88b660826271d998b3fd6f8423cf41e146b83734a3f1aeb63449e18d4e856" + }, + { + "file": "light/shelf-snes.png", + "card": "demo", + "args": [ + "--theme", + "light", + "--screen", + "systems", + "--phase", + "3200", + "--status", + "10:08 24h wifi=off batt=82", + "--fn-volume", + "--screen", + "games" + ], + "sha256": "d3c3a6d533b172ad300eea8f2a68ca0d879aed22574ef3fbfdde82b1bde323f2" + }, + { + "file": "light/favorites.png", + "card": "demo", + "args": [ + "--theme", + "light", + "--screen", + "systems", + "--phase", + "3200", + "--status", + "10:08 24h wifi=off batt=82", + "--fn-volume", + "--screen", + "games" + ], + "sha256": "9d6b49ccedbe6a970f194db0d0d321a5d4cb994f35887cca7e949b68e07728c7" }, { "file": "light/info.png", @@ -78,7 +405,7 @@ "games", "--info" ], - "sha256": "9e11df2762540b3f3ef675f8cc71be380c60cbc19000c917e4a37d72057ddf5a" + "sha256": "2ae6aeceb344638fc5813cc2fd39e31e312c02bb17a12e5da251f18e17442861" }, { "file": "light/info-delete.png", @@ -97,7 +424,7 @@ "games", "--info-delete" ], - "sha256": "3ae6828e1b16a0e6efce5bc79fb8ef31c22a108c96e98717cd4a43092b693e95" + "sha256": "136fdcf7b26b12081d885196bb8b83f3cb5e799a77af3ef7c116d0dac8082b19" }, { "file": "light/delete-ask.png", @@ -116,8 +443,7 @@ "games", "--delete-ask" ], - "sha256": "f68467086bde7322d8f8dff4e5a6aa6363eedacfadaba2126d0c66e97db45e4b", - "source_commit": "c04731c39dca9751387269001723c5831b3d4387" + "sha256": "02781fd3d26aa2a3bcf2f8051a6f4bfffdbc54f2020a2a0e6b073e3bf5dfe601" }, { "file": "light/cheevos.png", @@ -138,8 +464,7 @@ "{card}/Roms/PlayStation/.cheevos/Hagane.set", "b#" ], - "sha256": "e1d1b2292121197be1f105db0710487aba38fa3ec266c7b43841ca317003fe87", - "source_commit": "a7a594b3183cc59372b11be52b8da7883718e2cf" + "sha256": "7a3896f4168f272a718e270b49753a4ed59a0d58b7d96db7d0d0d64fd7753590" }, { "file": "light/cheevo-notice.png", @@ -162,8 +487,7 @@ "--notice-badge", "{card}/Roms/PlayStation/.cheevos/badges/b3.png" ], - "sha256": "d128b7bac15f3c55252988c9ca0a5920acff2a3e96de5165521fbb3bc0fa36a3", - "source_commit": "a7a594b3183cc59372b11be52b8da7883718e2cf" + "sha256": "da99740ac9abe477ff6cb2b7b99651884a4652d125fde31b16018eb4d9df858c" }, { "file": "light/menu.png", @@ -181,7 +505,7 @@ "--menu", "Search Games" ], - "sha256": "2dabb030569182ad068cbdcccfe1e9a3dd963d910b9a190f2d60ef688b5a3af0" + "sha256": "36333338b8b1971bab4d48b6f604748f7116197d1030bff7679f709878b55b06" }, { "file": "light/mp4.png", @@ -199,8 +523,7 @@ "--screen", "games" ], - "sha256": "f8609d18e498d050da1c8414ac27ff53cb4c796e355461b245e0e2c76dd56037", - "source_commit": "76a5eea6f69f1b85e22490e15a8688b2ed913ac9" + "sha256": "f8609d18e498d050da1c8414ac27ff53cb4c796e355461b245e0e2c76dd56037" }, { "file": "light/nowplaying.png", @@ -220,8 +543,7 @@ "0", "83" ], - "sha256": "7547dc533e6c4cf8c982acc9c46a606b6d7deded073cb98fbd66873f34fef680", - "source_commit": "4d46b1e95ec2100237a97a31e0b8491da25c5351" + "sha256": "7547dc533e6c4cf8c982acc9c46a606b6d7deded073cb98fbd66873f34fef680" }, { "file": "light/hare.png", @@ -241,8 +563,7 @@ "1 connected", "Tobu Tobu Girl.gb" ], - "sha256": "2923783813a9d1cf27c4852617d09c1e9c817a54ed7f6127fc3a22c6aa5f9b0d", - "source_commit": "70992931130292c04696593745b57a3f8c6fa726" + "sha256": "54fb23d626ba08f46755ec5f28e8afbcfb01a23733b96bc87ffce5f3d4021245" }, { "file": "light/hotspot.png", @@ -281,7 +602,7 @@ "--usb-ask", "0" ], - "sha256": "7253e2fe98cae3cd34c6fc52ba8929d30b376b90de6ee97a61a851f2aaaa7ce6" + "sha256": "6a30c8c452dbc8e4e1ab06d8c2c5e8464a4205f01f866defdef7fa41d7f42e4f" }, { "file": "light/scummvm.png", @@ -299,11 +620,11 @@ "--screen", "games" ], - "sha256": "c4ca9c17cbb8f7ff416e066f72a9531fb61e31efafa36605fa96ffc2eed8eb23" + "sha256": "12cc3bf9eed360e62ddffc94850a079867c8bbfcf60eee07f0c38e2138f15013" }, { - "file": "light/scraper.png", - "card": "demo", + "file": "light/openbor.png", + "card": "openbor", "args": [ "--theme", "light", @@ -314,14 +635,16 @@ "--status", "10:08 24h wifi=off batt=82", "--fn-volume", - "--scraper", - "2" + "--screen", + "games", + "--gamemenu", + "0", + "port" ], - "sha256": "ae34b32228001f66b0617e818a519aede54c2e19c2a55fe830542811d79ecbfd", - "source": "reused: the Scraper session's host frame of --scraper 2 (2026-11-06, the merged Scraper code), drawn over a card with one system; this tool draws the same panel over the demonstration card, sha256 ab8df6764c766976fb0c6b65c78aeda500d7a17337dbbe54540dd5d2662acc21" + "sha256": "e09493a701568a3b5798f89a239caa400d2554268e8fe74880ba0a79ee7e1b10" }, { - "file": "light/update.png", + "file": "light/scraper.png", "card": "demo", "args": [ "--theme", @@ -333,13 +656,14 @@ "--status", "10:08 24h wifi=off batt=82", "--fn-volume", - "--update" + "--scraper", + "2" ], - "sha256": "1eb4f64e067e7123aa2a357f212cfb8f5fb2797123e7c15e07a37a82afe0ea91" + "sha256": "bc90bfb99115b36797727b1250a8a911ea41e8b38f150cf8b4322d204d4c93b5" }, { - "file": "light/empty.png", - "card": "empty", + "file": "light/update.png", + "card": "demo", "args": [ "--theme", "light", @@ -350,14 +674,13 @@ "--status", "10:08 24h wifi=off batt=82", "--fn-volume", - "--ways", - "usb" + "--update" ], - "sha256": "09cfa5b1124c18422ce5e9dfec6424e85a60a826e49b20286522df48d3e95225" + "sha256": "1eb4f64e067e7123aa2a357f212cfb8f5fb2797123e7c15e07a37a82afe0ea91" }, { - "file": "light/tour-theme.png", - "card": "demo", + "file": "light/ports.png", + "card": "ports", "args": [ "--theme", "light", @@ -365,20 +688,17 @@ "systems", "--phase", "3200", + "--status", + "10:08 24h wifi=off batt=82", "--fn-volume", - "--tour", - "theme", - "2000", - "--tour-music", - "--tour-pick", - "0" + "--screen", + "games" ], - "sha256": "f9115a49d27f645f8f2561b5fed1cbdcd61d7d4215824ac91324e2cda3ce1a6c", - "source": "reused: the onboarding session's host frame, byte for byte what this tool draws" + "sha256": "4d013d60b8d8eb6cd84eb52a16c958927c54febc24cb162165956d192fd658ea" }, { - "file": "light/tour-home.png", - "card": "demo", + "file": "light/ports-menu.png", + "card": "portdocs", "args": [ "--theme", "light", @@ -386,19 +706,20 @@ "systems", "--phase", "3200", + "--status", + "10:08 24h wifi=off batt=82", "--fn-volume", - "--tour", - "home", - "2000", - "--tour-music", - "--tour-pick", - "1" + "--screen", + "games", + "--gamemenu", + "0", + "port" ], - "sha256": "4e640e4b04345526779d90720fefd8c1a87f06fa411334963c3eba9350a005ac" + "sha256": "e09493a701568a3b5798f89a239caa400d2554268e8fe74880ba0a79ee7e1b10" }, { - "file": "light/tour-sounds.png", - "card": "demo", + "file": "light/ports-controls.png", + "card": "portdocs", "args": [ "--theme", "light", @@ -406,19 +727,19 @@ "systems", "--phase", "3200", + "--status", + "10:08 24h wifi=off batt=82", "--fn-volume", - "--tour", - "sounds", - "2000", - "--tour-music", - "--tour-pick", - "1" + "--screen", + "games", + "--gamecontrols", + "0", + "PRT" ], - "sha256": "2363632f95805cd7b3ecfc0f82e402e48646f7df75b94ca148f05274b220e92d", - "source": "reused: the onboarding session's host frame, byte for byte what this tool draws" + "sha256": "b0fb310088e591d9c4bf4ca27c63841ef502f6870c7f5842d9e9894d095d1a85" }, { - "file": "light/tour-music.png", + "file": "light/portmaster.png", "card": "demo", "args": [ "--theme", @@ -427,22 +748,20 @@ "systems", "--phase", "3200", + "--status", + "10:08 24h wifi=off batt=82", "--fn-volume", - "--tour", - "music", - "2000", - "--tour-music", - "--tour-pick", - "0" + "--portmaster", + "running" ], - "sha256": "9c9bacda749075fb651c2ed0958fd9c42fb1beecd84e86c0abf419af425340f8" + "sha256": "b92537c0839e774d3c51c0339b08193651409776957088fd2d9732e88496200e" }, { - "file": "dark/systems.png", + "file": "light/storage.png", "card": "demo", "args": [ "--theme", - "dark", + "light", "--screen", "systems", "--phase", @@ -450,18 +769,17 @@ "--status", "10:08 24h wifi=off batt=82", "--fn-volume", - "--home", - "spotlight" + "--storage", + "main" ], - "sha256": "b1b9aeebf9a8db9d07a03bea1449bd943f2b022e0552c5e6950804af495712c3", - "source_commit": "23e4d4f5e09a27bcba5a747c60cbfc2c90c7ddda" + "sha256": "00f598069ccd7b17ebccf14beaadce97749356d5f401bc5fdb9269b48f7feab3" }, { - "file": "dark/menu.png", + "file": "light/storage-system.png", "card": "demo", "args": [ "--theme", - "dark", + "light", "--screen", "systems", "--phase", @@ -469,62 +787,13 @@ "--status", "10:08 24h wifi=off batt=82", "--fn-volume", - "--menu", - "Search Games" + "--storage", + "system" ], - "sha256": "716ae34e841de0be4a559567f67f887cb6da5914159109371d7665466b526b01" - }, - { - "file": "light/portal.png", - "card": "portal", - "args": { - "width": 1280, - "height": 800, - "scale": 1, - "phone": false, - "folder": "roms/NES", - "upload": [ - "Super Tilt Bro.nes", - 524304 - ], - "page": "res/web/index.html" - }, - "sha256": "2f1071c9dc19090f852ea46469016ac8f472adaa4830daa3523336d6a87dacf0", - "source_commit": "70992931130292c04696593745b57a3f8c6fa726", - "renderer": "headless Chrome over the DevTools protocol, the page served by build-native/portal-serve" - }, - { - "file": "light/portal-pin.png", - "card": "portal", - "args": { - "width": 390, - "height": 844, - "scale": 2, - "phone": true, - "gate": true, - "page": "res/web/index.html" - }, - "sha256": "e6132f1359a5e5e0070e768126381dcc1bbbfa3c1e422ae3a7d90bae0f7102ad", - "source_commit": "70992931130292c04696593745b57a3f8c6fa726", - "renderer": "headless Chrome over the DevTools protocol, the page served by build-native/portal-serve" - }, - { - "file": "light/portal-phone.png", - "card": "portal", - "args": { - "width": 390, - "height": 844, - "scale": 2, - "phone": true, - "folder": "roms", - "page": "res/web/index.html" - }, - "sha256": "7d2142cc09013df450509c33785435a5562c9bb5d9d148c75a089d8c0662ca5e", - "source_commit": "70992931130292c04696593745b57a3f8c6fa726", - "renderer": "headless Chrome over the DevTools protocol, the page served by build-native/portal-serve" + "sha256": "b07a60955cbdfc837b8cc81560e06b92c782f2d47c88e89742f8630583419530" }, { - "file": "light/charging.png", + "file": "light/storage-music.png", "card": "demo", "args": [ "--theme", @@ -536,14 +805,13 @@ "--status", "10:08 24h wifi=off batt=82", "--fn-volume", - "--charging", - "4000" + "--storage", + "music" ], - "sha256": "b1fba038aa10f4f934acfcba2acbf2f75ce5196e576f4f40e82dc2eb392dc65b", - "source_commit": "61913935902163db09b0db7a061ddfdbba7f2fec" + "sha256": "966ae702e9f276186cbebe21228c3451a5c1d459b1abcb926bac53f870245858" }, { - "file": "light/heat.png", + "file": "light/media.png", "card": "demo", "args": [ "--theme", @@ -555,12 +823,193 @@ "--status", "10:08 24h wifi=off batt=82", "--fn-volume", - "--heatpop", - "1600" - ], - "sha256": "4b833fada10ac6e55bc81472671086e254680b366c958589776a39f4ecd0192a", - "source_commit": "61913935902163db09b0db7a061ddfdbba7f2fec" - }, + "--screen", + "games", + "--media", + "page", + "--media-art", + "{card}/media-art" + ], + "sha256": "a4eb68d92a31c748b3de95c2a0c3cc3731782eb0dfe04484de1259a9f1ac1172" + }, + { + "file": "light/media-look.png", + "card": "demo", + "args": [ + "--theme", + "light", + "--screen", + "systems", + "--phase", + "3200", + "--status", + "10:08 24h wifi=off batt=82", + "--fn-volume", + "--screen", + "games", + "--media", + "look", + "--media-art", + "{card}/media-art" + ], + "sha256": "f0c5292eb5e257281ad42c9c231eb144b6e047e89119559a2399909eed5ade3a" + }, + { + "file": "light/media-video.png", + "card": "demo", + "args": [ + "--theme", + "light", + "--screen", + "systems", + "--phase", + "3200", + "--status", + "10:08 24h wifi=off batt=82", + "--fn-volume", + "--screen", + "games", + "--media", + "video", + "--media-art", + "{card}/media-art" + ], + "sha256": "02da9b86d186129b8a1d445cbb35db6b73d542ece17f6376d52ac9e4c6556b9c" + }, + { + "file": "light/empty.png", + "card": "empty", + "args": [ + "--theme", + "light", + "--screen", + "systems", + "--phase", + "3200", + "--status", + "10:08 24h wifi=off batt=82", + "--fn-volume", + "--ways", + "usb" + ], + "sha256": "09cfa5b1124c18422ce5e9dfec6424e85a60a826e49b20286522df48d3e95225" + }, + { + "file": "light/tour-theme.png", + "card": "demo", + "args": [ + "--theme", + "light", + "--screen", + "systems", + "--phase", + "3200", + "--fn-volume", + "--tour", + "theme", + "2000", + "--tour-music", + "--tour-pick", + "0" + ], + "sha256": "34ca861ce43af47b74dca1d667c414b8a25d80cf82fe3d58345e6ceac45601ba" + }, + { + "file": "light/tour-home.png", + "card": "demo", + "args": [ + "--theme", + "light", + "--screen", + "systems", + "--phase", + "3200", + "--fn-volume", + "--tour", + "home", + "2000", + "--tour-music", + "--tour-pick", + "1" + ], + "sha256": "bfd8ca62e7dd589ba8ff7c704f13855bba1ad2bc54b2fd4b30e42faf4423787f" + }, + { + "file": "light/tour-sounds.png", + "card": "demo", + "args": [ + "--theme", + "light", + "--screen", + "systems", + "--phase", + "3200", + "--fn-volume", + "--tour", + "sounds", + "2000", + "--tour-music", + "--tour-pick", + "1" + ], + "sha256": "2363632f95805cd7b3ecfc0f82e402e48646f7df75b94ca148f05274b220e92d" + }, + { + "file": "light/tour-music.png", + "card": "demo", + "args": [ + "--theme", + "light", + "--screen", + "systems", + "--phase", + "3200", + "--fn-volume", + "--tour", + "music", + "2000", + "--tour-music", + "--tour-pick", + "0" + ], + "sha256": "9c9bacda749075fb651c2ed0958fd9c42fb1beecd84e86c0abf419af425340f8" + }, + { + "file": "light/charging.png", + "card": "demo", + "args": [ + "--theme", + "light", + "--screen", + "systems", + "--phase", + "3200", + "--status", + "10:08 24h wifi=off batt=82", + "--fn-volume", + "--charging", + "4000" + ], + "sha256": "b1fba038aa10f4f934acfcba2acbf2f75ce5196e576f4f40e82dc2eb392dc65b" + }, + { + "file": "light/heat.png", + "card": "demo", + "args": [ + "--theme", + "light", + "--screen", + "systems", + "--phase", + "3200", + "--status", + "10:08 24h wifi=off batt=82", + "--fn-volume", + "--heatpop", + "1600" + ], + "sha256": "b8061dd03cd7d374b2f963ff687a81baf27e52774e6b982d639223945ff311f6" + }, { "file": "light/tour-language.png", "card": "demo", @@ -578,8 +1027,7 @@ "--tour-pick", "1" ], - "sha256": "00c929278b96a3e8ff719c4d879d805d5701ad8079ce19ff553994fe8c64caa5", - "source_commit": "087603e8f5d6f4be845b860c1d3239102d3e8922" + "sha256": "00c929278b96a3e8ff719c4d879d805d5701ad8079ce19ff553994fe8c64caa5" }, { "file": "light/menu-pt.png", @@ -599,8 +1047,7 @@ "--menu", "Language" ], - "sha256": "813cec980ed621a76002a021e604d62517b14efb2ca9d78f62bc90a284418c03", - "source_commit": "087603e8f5d6f4be845b860c1d3239102d3e8922" + "sha256": "cb225454f2556bb148d4ba8616e8fd7f102f9d8eb899b2b7e4ff19c293a095fb" }, { "file": "light/menu-ja.png", @@ -620,12 +1067,11 @@ "--menu", "Search Games" ], - "sha256": "b664fa29952faddc7301905bf60bffe3deeeaa3eb47e95e687db4caf16071086", - "source_commit": "087603e8f5d6f4be845b860c1d3239102d3e8922" + "sha256": "c4940f8d0b4fb58f21b142d29b9b78101fff8045a736861bc3255221c8e25c67" }, { - "file": "light/openbor.png", - "card": "openbor", + "file": "light/battery.png", + "card": "demo", "args": [ "--theme", "light", @@ -634,19 +1080,15 @@ "--phase", "3200", "--status", - "10:08 24h wifi=off batt=82", + "10:08 24h wifi=3 batt=62 eta=205 icon=time", "--fn-volume", - "--screen", - "games", - "--gamemenu", - "0", - "port" + "--menu", + "Battery Icon" ], - "sha256": "3b510aa57528e8fbc3f9a6a52daa5da7a16c8645d98b35f5a3cecab163777d5c", - "source_commit": "56cce5e91c5ad347ea83ac989fe41edea3ac4fe2" + "sha256": "360092be17cb4fef8f26947733e2471606af3798eb1767b8a4b632386c85b9e8" }, { - "file": "light/gamemenu.png", + "file": "light/topbar.png", "card": "demo", "args": [ "--theme", @@ -656,17 +1098,17 @@ "--phase", "3200", "--status", - "10:08 24h wifi=off batt=82", + "10:08 24h wifi=3 phones=on batt=62", "--fn-volume", - "--screen", - "games", - "--gamemenu", - "3" + "--home", + "spotlight", + "--song", + "Night Drive" ], - "sha256": "038d416a1408a7166372bd7a007dadd195b622134a3805369c76722b1794fb41" + "sha256": "1aa6a418a7a6225dbd56e56e82a0d3cd39bbec5f4a54f686e81fc53d10a3a6d1" }, { - "file": "light/controls-ps1.png", + "file": "light/topbar-paused.png", "card": "demo", "args": [ "--theme", @@ -676,18 +1118,18 @@ "--phase", "3200", "--status", - "10:08 24h wifi=off batt=82", + "10:08 24h wifi=3 phones=on batt=62", "--fn-volume", - "--screen", - "games", - "--gamecontrols", - "0", - "PS1" + "--home", + "spotlight", + "--song", + "Night Drive", + "--song-paused" ], - "sha256": "0a5f7e31e61e115b547dc911805ff95c938ed49deac880513436b02c285bb4a0" + "sha256": "4d1e5a4664b8b4023e83e758c515b47e64f3c343ae5345fd6429c7cfe5dbc1ee" }, { - "file": "light/controls-hotkeys.png", + "file": "light/topbar-long.png", "card": "demo", "args": [ "--theme", @@ -697,18 +1139,19 @@ "--phase", "3200", "--status", - "10:08 24h wifi=off batt=82", + "10:08 24h wifi=3 phones=on batt=62", "--fn-volume", - "--screen", - "games", - "--gamecontrols", - "2", - "PS1" + "--home", + "spotlight", + "--song", + "Night Drive (Extended Mix) - The Midnight Rider Collective", + "--phase", + "0" ], - "sha256": "87f433b86258cda9cebbd6e68cdd62fe1ba58d754fdfb628969f308c4f25bd26" + "sha256": "4aa5d0d6ef893b3c689c4c4f906f1062cef1f0d7350abc5b517c475674aa4ad6" }, { - "file": "light/storage.png", + "file": "light/topbar-long-moving.png", "card": "demo", "args": [ "--theme", @@ -718,15 +1161,19 @@ "--phase", "3200", "--status", - "10:08 24h wifi=off batt=82", + "10:08 24h wifi=3 phones=on batt=62", "--fn-volume", - "--storage", - "main" + "--home", + "spotlight", + "--song", + "Night Drive (Extended Mix) - The Midnight Rider Collective", + "--phase", + "2600" ], - "sha256": "de10a5af4434544d9d370e80823624fe29e145eb6139ebbdda5b2894bff93eb9" + "sha256": "837cb939249baabd5d2ccb04a41105a1e63cd1ce324cf60e93374b625275c30b" }, { - "file": "light/storage-system.png", + "file": "light/topbar-still.png", "card": "demo", "args": [ "--theme", @@ -736,16 +1183,20 @@ "--phase", "3200", "--status", - "10:08 24h wifi=off batt=82", + "10:08 24h wifi=3 phones=on batt=62", "--fn-volume", - "--storage", - "system" + "--home", + "spotlight", + "--song", + "Night Drive", + "--phase", + "0" ], - "sha256": "d419f0aedf488502a53e756f228cdfb4dc07120d05ab063c279d4c2cae58807b" + "sha256": "1aa6a418a7a6225dbd56e56e82a0d3cd39bbec5f4a54f686e81fc53d10a3a6d1" }, { - "file": "light/ports.png", - "card": "ports", + "file": "light/video-controls.png", + "card": "demo", "args": [ "--theme", "light", @@ -756,14 +1207,17 @@ "--status", "10:08 24h wifi=off batt=82", "--fn-volume", - "--screen", - "games" + "--nowplaying", + "0", + "0", + "83", + "--video", + "controls" ], - "sha256": "6ec8e8ddda137bafea694fd499fd085a7402129ecfe3ee1ca0fc03c4d1d34822", - "source_commit": "56cce5e91c5ad347ea83ac989fe41edea3ac4fe2" + "sha256": "00e3c013b193830a24457d5f52da4e96d629a8e8d46761d0e34f7d988cf0eedf" }, { - "file": "light/portmaster.png", + "file": "light/video-full.png", "card": "demo", "args": [ "--theme", @@ -775,15 +1229,18 @@ "--status", "10:08 24h wifi=off batt=82", "--fn-volume", - "--portmaster", - "running" + "--nowplaying", + "0", + "0", + "83", + "--video", + "full" ], - "sha256": "b92537c0839e774d3c51c0339b08193651409776957088fd2d9732e88496200e", - "source_commit": "56cce5e91c5ad347ea83ac989fe41edea3ac4fe2" + "sha256": "c2a4f340882560f3b1910c38df7a27a3c5cc6874c67315dcc1b378694e507263" }, { - "file": "light/ports-menu.png", - "card": "portdocs", + "file": "light/gamemenu.png", + "card": "demo", "args": [ "--theme", "light", @@ -797,15 +1254,15 @@ "--screen", "games", "--gamemenu", - "0", - "port" + "3", + "--backdrop", + "{art}/Sony_-_PlayStation/Named_Snaps/Spyro the Dragon (USA).png" ], - "sha256": "3b510aa57528e8fbc3f9a6a52daa5da7a16c8645d98b35f5a3cecab163777d5c", - "source_commit": "56cce5e91c5ad347ea83ac989fe41edea3ac4fe2" + "sha256": "92b20bcdb0d26e4bc3c4909274a3792b879478cfa789044ef0701e4f111895dd" }, { - "file": "light/ports-controls.png", - "card": "portdocs", + "file": "light/controls-ps1.png", + "card": "demo", "args": [ "--theme", "light", @@ -820,13 +1277,14 @@ "games", "--gamecontrols", "0", - "PRT" + "PS1", + "--backdrop", + "{art}/Sony_-_PlayStation/Named_Snaps/Spyro the Dragon (USA).png" ], - "sha256": "e4e47f6c76efeb7504d88418a216b842ec01bdfd2cde6b98b3638d083ad0ed1d", - "source_commit": "a5409f66d37aec320a9c1b3898e5df99f1f49d4b" + "sha256": "d0c691526d2a32c63c709ae54bd8192cd26f565eb88d510fd755de438d52ab7e" }, { - "file": "light/topbar.png", + "file": "light/controls-hotkeys.png", "card": "demo", "args": [ "--theme", @@ -836,40 +1294,38 @@ "--phase", "3200", "--status", - "10:08 24h wifi=3 phones=on batt=62", + "10:08 24h wifi=off batt=82", "--fn-volume", - "--home", - "spotlight", - "--song", - "Night Drive" + "--screen", + "games", + "--gamecontrols", + "2", + "PS1", + "--backdrop", + "{art}/Sony_-_PlayStation/Named_Snaps/Spyro the Dragon (USA).png" ], - "sha256": "c864d76acb8681d6e949e8e2eaf2b63d77c2af1d0c8e8429ba3fbd1b11c82960", - "source_commit": "0ffc223744e8203cceb858a8e8f6e62c97b4e554" + "sha256": "8cc8c20eeacec80fd5a78ae480830094eaec2638b4a44def2eaee8bd7e7d1f2f" }, { - "file": "light/topbar-paused.png", + "file": "dark/systems.png", "card": "demo", "args": [ "--theme", - "light", + "dark", "--screen", "systems", "--phase", "3200", "--status", - "10:08 24h wifi=3 phones=on batt=62", + "10:08 24h wifi=off batt=82", "--fn-volume", "--home", - "spotlight", - "--song", - "Night Drive", - "--song-paused" + "spotlight" ], - "sha256": "325533f6c5049bb237894693b90fcf3f18f3eb18fed07081afa763e8512a956f", - "source_commit": "0ffc223744e8203cceb858a8e8f6e62c97b4e554" + "sha256": "d6763415289bb523d44db78fa03ba4354397e66831605779b81c1db694cf505a" }, { - "file": "dark/topbar.png", + "file": "dark/shelf-n64.png", "card": "demo", "args": [ "--theme", @@ -879,22 +1335,19 @@ "--phase", "3200", "--status", - "10:08 24h wifi=3 phones=on batt=62", + "10:08 24h wifi=off batt=82", "--fn-volume", - "--home", - "spotlight", - "--song", - "Night Drive" + "--screen", + "games" ], - "sha256": "52d3818c5b892162375be40643e3150f1ac002c89a69df5bd950643c79108b37", - "source_commit": "0ffc223744e8203cceb858a8e8f6e62c97b4e554" + "sha256": "0df829b24406f86e5ea940d2b424c66b505bc8794aa9a2d4a5e3f6fbe5ddbdcc" }, { - "file": "light/media.png", + "file": "dark/menu.png", "card": "demo", "args": [ "--theme", - "light", + "dark", "--screen", "systems", "--phase", @@ -902,21 +1355,17 @@ "--status", "10:08 24h wifi=off batt=82", "--fn-volume", - "--screen", - "games", - "--media", - "page", - "--media-art", - "{card}/media-art" + "--menu", + "Search Games" ], - "sha256": "a22855dd60d9a5a270a2ccfafe76160874f67669ff08af4beadc541fbf78ab96" + "sha256": "9a0396364886b44ba80d70e1d797f90fbd6d8523ab74bcef4e343e73289d5ea8" }, { - "file": "light/media-look.png", + "file": "dark/nowplaying.png", "card": "demo", "args": [ "--theme", - "light", + "dark", "--screen", "systems", "--phase", @@ -924,21 +1373,19 @@ "--status", "10:08 24h wifi=off batt=82", "--fn-volume", - "--screen", - "games", - "--media", - "look", - "--media-art", - "{card}/media-art" + "--nowplaying", + "0", + "0", + "83" ], - "sha256": "64d1fca8a720e0c2225277dc901a05ae1bc54e3d230384513613f65fd8abe9a9" + "sha256": "d35bd12c4fb60e1ae5f27fb65b889d38ec49e649fa07459a25b82eedc9c1790b" }, { - "file": "light/media-video.png", + "file": "dark/media.png", "card": "demo", "args": [ "--theme", - "light", + "dark", "--screen", "systems", "--phase", @@ -949,14 +1396,34 @@ "--screen", "games", "--media", - "video", + "page", "--media-art", "{card}/media-art" ], - "sha256": "42736bc4b950db7794966292580133c92c19b121d85808e011596c2acb501989" + "sha256": "c836a4acf5c06acf41d95781337799beae80b70654cbde432d92a3646e916cce" }, { - "file": "dark/media.png", + "file": "dark/topbar.png", + "card": "demo", + "args": [ + "--theme", + "dark", + "--screen", + "systems", + "--phase", + "3200", + "--status", + "10:08 24h wifi=3 phones=on batt=62", + "--fn-volume", + "--home", + "spotlight", + "--song", + "Night Drive" + ], + "sha256": "d46565e076575649609611528a777939711f273b5c25eb432001d0f740084d1a" + }, + { + "file": "dark/video-controls.png", "card": "demo", "args": [ "--theme", @@ -968,15 +1435,79 @@ "--status", "10:08 24h wifi=off batt=82", "--fn-volume", + "--nowplaying", + "0", + "0", + "83", + "--video", + "controls" + ], + "sha256": "620c6a918dcdf159ea5ed63f80aad281e2e3280ef8a1737c8908d15b455f69cd" + }, + { + "file": "dark/video-full.png", + "card": "demo", + "args": [ + "--theme", + "dark", "--screen", - "games", - "--media", - "page", - "--media-art", - "{card}/media-art" + "systems", + "--phase", + "3200", + "--status", + "10:08 24h wifi=off batt=82", + "--fn-volume", + "--nowplaying", + "0", + "0", + "83", + "--video", + "full" ], - "sha256": "47e47a3a5cae39cad0363339168c83ae732bf53c28dfaaf0d6ed92d19f5f29cf" + "sha256": "eabd4e2253b7e1687d87af0aee2e4030f6ae65b5f09f2f61845fdb3593beb789" + }, + { + "file": "light/portal.png", + "card": "portal", + "args": { + "width": 1280, + "height": 800, + "scale": 1, + "phone": false, + "folder": "roms/NES", + "upload": [ + "Super Tilt Bro.nes", + 524304 + ], + "page": "res/web/index.html" + }, + "sha256": "eb375b07f588e5264afffd37b20e96b29e112c918c1e465911c0d223d59e85d1" + }, + { + "file": "light/portal-pin.png", + "card": "portal", + "args": { + "width": 390, + "height": 844, + "scale": 2, + "phone": true, + "gate": true, + "page": "res/web/index.html" + }, + "sha256": "73d45cbaf9345705b933f57c5123170eb38a014a48b0cec4b56a76582071aa6a" + }, + { + "file": "light/portal-phone.png", + "card": "portal", + "args": { + "width": 390, + "height": 844, + "scale": 2, + "phone": true, + "folder": "roms", + "page": "res/web/index.html" + }, + "sha256": "63fc70bd9264595d9b7d0f219358d2bdaaff11e8c41c294f633cd2fc61951649" } - ], - "layout": "light/NAME.png is res/readme/NAME.png; dark/NAME.png is res/readme/dark/NAME.png" + ] } diff --git a/res/readme/cheevo-notice.png b/res/readme/cheevo-notice.png index f4d17d8..3658401 100644 Binary files a/res/readme/cheevo-notice.png and b/res/readme/cheevo-notice.png differ diff --git a/res/readme/cheevos.png b/res/readme/cheevos.png index a70a7e6..0b5bb67 100644 Binary files a/res/readme/cheevos.png and b/res/readme/cheevos.png differ diff --git a/res/readme/controls-hotkeys.png b/res/readme/controls-hotkeys.png index d3b6335..ba5c0fe 100644 Binary files a/res/readme/controls-hotkeys.png and b/res/readme/controls-hotkeys.png differ diff --git a/res/readme/controls-ps1.png b/res/readme/controls-ps1.png index 75e551b..a0b0cd9 100644 Binary files a/res/readme/controls-ps1.png and b/res/readme/controls-ps1.png differ diff --git a/res/readme/dark/media.png b/res/readme/dark/media.png index 1bf41ec..af4203b 100644 Binary files a/res/readme/dark/media.png and b/res/readme/dark/media.png differ diff --git a/res/readme/dark/menu.png b/res/readme/dark/menu.png index 16c973c..dd7ec1c 100644 Binary files a/res/readme/dark/menu.png and b/res/readme/dark/menu.png differ diff --git a/res/readme/dark/shelf-n64.png b/res/readme/dark/shelf-n64.png new file mode 100644 index 0000000..bdcd5f9 Binary files /dev/null and b/res/readme/dark/shelf-n64.png differ diff --git a/res/readme/dark/systems.png b/res/readme/dark/systems.png index 164f5bd..8d8721c 100644 Binary files a/res/readme/dark/systems.png and b/res/readme/dark/systems.png differ diff --git a/res/readme/dark/topbar.png b/res/readme/dark/topbar.png index 64be97e..a1bd4b7 100644 Binary files a/res/readme/dark/topbar.png and b/res/readme/dark/topbar.png differ diff --git a/res/readme/delete-ask.png b/res/readme/delete-ask.png index d8a0e1b..5773e6d 100644 Binary files a/res/readme/delete-ask.png and b/res/readme/delete-ask.png differ diff --git a/res/readme/favorites.png b/res/readme/favorites.png new file mode 100644 index 0000000..4a269ea Binary files /dev/null and b/res/readme/favorites.png differ diff --git a/res/readme/gallery.png b/res/readme/gallery.png index 4416e1d..a7fa0bc 100644 Binary files a/res/readme/gallery.png and b/res/readme/gallery.png differ diff --git a/res/readme/gamemenu.png b/res/readme/gamemenu.png index 3db1af9..b0ee0be 100644 Binary files a/res/readme/gamemenu.png and b/res/readme/gamemenu.png differ diff --git a/res/readme/hare.png b/res/readme/hare.png index d966aba..259e98b 100644 Binary files a/res/readme/hare.png and b/res/readme/hare.png differ diff --git a/res/readme/heat.png b/res/readme/heat.png index 7112d07..e11623f 100644 Binary files a/res/readme/heat.png and b/res/readme/heat.png differ diff --git a/res/readme/info-delete.png b/res/readme/info-delete.png index 41ca617..29264cb 100644 Binary files a/res/readme/info-delete.png and b/res/readme/info-delete.png differ diff --git a/res/readme/info.png b/res/readme/info.png index 15c55f1..0239a5b 100644 Binary files a/res/readme/info.png and b/res/readme/info.png differ diff --git a/res/readme/media-look.png b/res/readme/media-look.png index 718f664..a973cf5 100644 Binary files a/res/readme/media-look.png and b/res/readme/media-look.png differ diff --git a/res/readme/media-video.png b/res/readme/media-video.png index f4ce9d8..a197547 100644 Binary files a/res/readme/media-video.png and b/res/readme/media-video.png differ diff --git a/res/readme/media.png b/res/readme/media.png index 5312068..0a89eec 100644 Binary files a/res/readme/media.png and b/res/readme/media.png differ diff --git a/res/readme/menu-ja.png b/res/readme/menu-ja.png index 84eb848..73aa225 100644 Binary files a/res/readme/menu-ja.png and b/res/readme/menu-ja.png differ diff --git a/res/readme/menu-pt.png b/res/readme/menu-pt.png index 4671365..785fbf9 100644 Binary files a/res/readme/menu-pt.png and b/res/readme/menu-pt.png differ diff --git a/res/readme/menu.png b/res/readme/menu.png index 64fafb1..c7c99f7 100644 Binary files a/res/readme/menu.png and b/res/readme/menu.png differ diff --git a/res/readme/openbor.png b/res/readme/openbor.png index f0c32c4..47f6652 100644 Binary files a/res/readme/openbor.png and b/res/readme/openbor.png differ diff --git a/res/readme/portal-phone.png b/res/readme/portal-phone.png index a8d6e14..b886478 100644 Binary files a/res/readme/portal-phone.png and b/res/readme/portal-phone.png differ diff --git a/res/readme/portal-pin.png b/res/readme/portal-pin.png index 855045c..4be3a62 100644 Binary files a/res/readme/portal-pin.png and b/res/readme/portal-pin.png differ diff --git a/res/readme/portal.png b/res/readme/portal.png index a8e2191..f743c77 100644 Binary files a/res/readme/portal.png and b/res/readme/portal.png differ diff --git a/res/readme/ports-controls.png b/res/readme/ports-controls.png index 547c83c..bb40ba9 100644 Binary files a/res/readme/ports-controls.png and b/res/readme/ports-controls.png differ diff --git a/res/readme/ports-menu.png b/res/readme/ports-menu.png index f0c32c4..47f6652 100644 Binary files a/res/readme/ports-menu.png and b/res/readme/ports-menu.png differ diff --git a/res/readme/ports.png b/res/readme/ports.png index 17a1c15..b646eac 100644 Binary files a/res/readme/ports.png and b/res/readme/ports.png differ diff --git a/res/readme/scraper.png b/res/readme/scraper.png index f4de53e..416d89f 100644 Binary files a/res/readme/scraper.png and b/res/readme/scraper.png differ diff --git a/res/readme/scummvm.png b/res/readme/scummvm.png index ce46d2b..f059d12 100644 Binary files a/res/readme/scummvm.png and b/res/readme/scummvm.png differ diff --git a/res/readme/shelf-n64.png b/res/readme/shelf-n64.png new file mode 100644 index 0000000..f0c464b Binary files /dev/null and b/res/readme/shelf-n64.png differ diff --git a/res/readme/shelf-snes.png b/res/readme/shelf-snes.png new file mode 100644 index 0000000..8ae7999 Binary files /dev/null and b/res/readme/shelf-snes.png differ diff --git a/res/readme/shelf.png b/res/readme/shelf.png index 1941bf7..7d07a92 100644 Binary files a/res/readme/shelf.png and b/res/readme/shelf.png differ diff --git a/res/readme/systems.png b/res/readme/systems.png index 707c18b..f8c2e2b 100644 Binary files a/res/readme/systems.png and b/res/readme/systems.png differ diff --git a/res/readme/topbar-paused.png b/res/readme/topbar-paused.png index 073c73a..c447110 100644 Binary files a/res/readme/topbar-paused.png and b/res/readme/topbar-paused.png differ diff --git a/res/readme/topbar.png b/res/readme/topbar.png index 3d5f641..cc612b9 100644 Binary files a/res/readme/topbar.png and b/res/readme/topbar.png differ diff --git a/res/readme/tour-home.png b/res/readme/tour-home.png index 54e973b..d5dca32 100644 Binary files a/res/readme/tour-home.png and b/res/readme/tour-home.png differ diff --git a/res/readme/tour-poster.jpg b/res/readme/tour-poster.jpg new file mode 100644 index 0000000..5eff154 Binary files /dev/null and b/res/readme/tour-poster.jpg differ diff --git a/res/readme/tour-theme.png b/res/readme/tour-theme.png index 4dd0c8c..81f9603 100644 Binary files a/res/readme/tour-theme.png and b/res/readme/tour-theme.png differ diff --git a/res/readme/usb-ask.png b/res/readme/usb-ask.png index 9f5ba88..33daeeb 100644 Binary files a/res/readme/usb-ask.png and b/res/readme/usb-ask.png differ diff --git a/site/index.html b/site/index.html index 0cfd2d6..522d987 100644 --- a/site/index.html +++ b/site/index.html @@ -24,7 +24,7 @@ Zyper's head. robots.txt, sitemap.xml (mk/site.sh), llms.txt and the IndexNow key file sit beside this page. --> Fast Custom Firmware for the GKD Pixel 2 | ZyperOS - + @@ -35,12 +35,12 @@ - + - + @@ -77,7 +77,7 @@ "description": "A fast, light firmware for the GKD Pixel 2 that plays your whole retro library: forty systems, PortMaster ports, box art, RetroAchievements, and your music and videos, from a card you can take out at any time.", "url": "https://zyperos.com/", "image": "https://zyperos.com/og.png", - "screenshot": "https://zyperos.com/img/systems.png", + "screenshot": ["https://zyperos.com/img/shelf-n64.png", "https://zyperos.com/img/systems.png"], "author": { "@id": "https://zyperos.com/#tarik" }, "license": "https://www.gnu.org/licenses/gpl-3.0.html", "softwareVersion": "1.0.2", @@ -153,6 +153,7 @@ .device.ready .device-poster { visibility: hidden; } .device canvas:focus-visible { outline: 2px solid var(--text); outline-offset: 4px; } .lockup { display: block; width: min(560px, 100%); } + .with-device .hero .lockup { width: min(440px, 100%); } .lockup img { display: block; width: 100%; height: auto; } .btn { display: inline-flex; align-items: center; gap: 12px; font: 600 17px/1 var(--font); padding: 15px 22px; border-radius: 14px; text-decoration: none; transition: transform 80ms var(--ease); } @@ -162,6 +163,20 @@ .glyph { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; box-shadow: inset 0 0 0 2.5px currentColor; font: 600 13px/1 var(--font); } .pill { display: inline-flex; align-items: center; gap: 9px; padding: 7px 13px; border-radius: 8px; background: var(--raised); color: var(--text-soft); font-size: 15px; line-height: 1.3; } .pill::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--accent-ink); flex: none; } + /* The hero's facts: measured or counted, never estimated (the README's + * "How it compares" has the boot time's note) */ + .proof { display: flex; flex-wrap: wrap; gap: 8px 18px; margin: 0; padding: 0; list-style: none; font-size: 15px; color: var(--text-soft); } + .proof b { color: var(--text); font-weight: 600; } + .proof sup { font-size: .7em; line-height: 0; } + .proof .note { flex-basis: 100%; color: var(--text-dim); font-size: 13px; } + .sub .lead { display: block; color: var(--text); font-weight: 600; margin-bottom: 4px; } + .stockgain { margin: 18px 0 0; padding: 0; list-style: none; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 40px; } + .stockgain li { color: var(--text-soft); font-size: 17px; padding-left: 22px; position: relative; } + .stockgain li::before { content: ""; position: absolute; left: 0; top: .62em; width: 8px; height: 8px; border-radius: 50%; background: var(--accent-ink); } + .stockgain b { color: var(--text); font-weight: 600; } + .why { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 28px 40px; } + .why > div { display: grid; gap: 6px; align-content: start; } + .why p { color: var(--text-soft); font-size: 17px; } .shelf { padding-block: 24px 72px; overflow: hidden; } .stage { position: relative; --sw: min(620px, 54vw); height: calc(var(--sw) * .75 + 36px); } @@ -177,7 +192,7 @@ .shelf-foot { display: grid; justify-items: center; gap: 8px; margin-top: 18px; text-align: center; padding-inline: 16px; } .shelf-foot b { font: 600 24px/1.2 var(--font); } .rail { width: min(520px, 70vw); height: 6px; border-radius: 3px; background: var(--track); position: relative; } - .rail i { position: absolute; top: 0; bottom: 0; left: 0; width: calc(100% / 12); border-radius: 3px; background: var(--accent-ink); transition: transform 160ms var(--ease); } + .rail i { position: absolute; top: 0; bottom: 0; left: 0; width: calc(100% / var(--n, 12)); border-radius: 3px; background: var(--accent-ink); transition: transform 160ms var(--ease); } .shelf-ctl { display: none; gap: 14px; justify-content: center; margin-top: 18px; } .js .shelf-ctl { display: flex; } .step { width: 52px; height: 44px; border-radius: 14px; border: 0; background: var(--surface); box-shadow: inset 0 0 0 3px var(--edge); color: var(--text); font: 600 18px/1 var(--font); cursor: pointer; transition: transform 80ms var(--ease); } @@ -241,7 +256,7 @@ footer { padding-block: 40px 56px; border-top: 2px solid var(--edge); } footer .in { display: flex; gap: 20px 32px; flex-wrap: wrap; align-items: center; } footer .lockup { width: 120px; } - footer .links { display: flex; gap: 20px; margin-left: auto; font-size: 16px; } + footer .links { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-left: auto; font-size: 16px; } @media (max-width: 760px) { .in { padding-left: max(18px, env(safe-area-inset-left, 0px)); padding-right: max(18px, env(safe-area-inset-right, 0px)); } @@ -254,7 +269,7 @@ .with-device .hero h1 { font-size: 34px; } .with-device .device { max-width: 340px; } .stage { --sw: 78vw; } - .feats, .thanks { grid-template-columns: minmax(0, 1fr); gap: 22px; } + .feats, .thanks, .why, .stockgain { grid-template-columns: minmax(0, 1fr); gap: 22px; } .pair { gap: 18px 12px; } .split { grid-template-columns: minmax(0, 1fr); gap: 24px; } .name { grid-template-columns: minmax(0, 1fr); gap: 20px; } @@ -283,11 +298,14 @@ @@ -298,12 +316,18 @@ ZyperOS: Zyper, a miniature schnauzer with his head cocked and his tongue out, beside the name

A fast, light firmware for the GKD Pixel 2 that plays your whole retro library.

-

Forty systems, PortMaster ports, and your music and videos, from a card you can take out at any time.

+

On in about 2 seconds. The stock firmware takes 30 to 40. Turn it off mid-game; turn it on, and 2 seconds later you're back where you were.

- See it on GitHub - Read the guide + Download ZyperOS 1.0.2 + Install in three steps
- ZyperOS 1.0.2 is out for the GKD Pixel 2. +
Green GKD Pixel 2 with a metal shell, standing upright.
@@ -312,43 +336,113 @@

A fast, light firmware for the GKD Pixel 2 that plays your whole retro libra page is right before, or without, the script below. -->
-
The Gallery home
-
The Spotlight home
-
A system's games as cards, a heart before a favorite's name
-
A game's details
-
An achievements list
-
The ZyperOS menu
-
MENU > Scraper and the covers each source found
-
MP4's shelf of an album, a film and a book, each marked with what it is
-
MENU > Battery Icon set to Time Left: the corner reads 3 h 25 min beside the battery
-
The in-game menu on its Quick Slot row, slot 1
-
Controls: Hotkeys. SELECT with R1 and L1 quick save and load, with R2 and L2 faster and slower
+
Favorites: Sonic The Hedgehog 2 between Metroid Fusion and Super Mario 64, Genesis under its name
+
The Nintendo 64 shelf: Super Mario 64's box between Mario Kart 64 and Ocarina of Time
+
The SNES shelf: Super Mario World's box between Donkey Kong Country and Super Metroid
+
The PlayStation shelf: Metal Gear Solid's box between Crash Bandicoot and Spyro the Dragon, a heart before its name
+
The Spotlight home
+
The Gallery home
+
Metal Gear Solid's Cover and Video page: its cover beside the boxes, title screen and screenshots libretro, ScreenScraper and SteamGridDB offer, and its video
+
Metal Gear Solid's details
+
An achievementalt=
+
An achievements list
+
Install PortMaster: its icon, its version and the download's bar
MENU > Storage: the card in one bar by part, two recommendations, then every system by size
-
A game's Cover and Video page: the cover it has beside what libretro, ScreenScraper and SteamGridDB offer, each named, and its video
+
The in-game menu over a paused Spyro the Dragon, on its Quick Slot row
+
Controls: Hotkeys. SELECT with R1 and L1 quick save and load, with R2 and L2 faster and slower
+
MP4's shelf of an album, a film and a book, each marked with what it is
+
MENU > Battery Icon set to Time Left: the corner reads 3 h 25 min beside the battery
+
MENU > Scraper and the covers each source found
+
The ZyperOS menu
- Every system, in pixel art - 2 / 13 -
+ Your games, cover by cover + 2 / 18 +
-

Current ZyperOS screens, shown with a sample library.

+

Current ZyperOS screens, with the real box art the Scraper finds. The games and their art belong to their owners: ZyperOS comes with no games and no art.

+
+ +
+
+

Why ZyperOS

+
+

Your library looks the part

Every game shows its real box art, found on the handheld from libretro, ScreenScraper and SteamGridDB, and a few seconds of video when you rest on it.

+

On in about 2 seconds

The stock firmware takes 30 to 40, in my test on my Pixel 2. Turn it off mid-game, and the next start puts you back where you were, 2 seconds later.

+

Plays more, smoothly

Forty systems and PortMaster's ports. Mario Kart DS keeps 60 frames a second and Super Mario 64 its 30, on the Pixel 2.

+

No more taking the card out

Add games, music and saves from any browser over Wi-Fi or the USB cable, and update ZyperOS the same way.

+

Made to be picked up

A first start that teaches every button, eight languages, light and dark, and a menu that never makes you wait.

+

Free and open source

GPL-3.0, with nothing locked behind a payment. Everyone gets the same ZyperOS.

+
+
+
+ +
+
+

Coming from the stock OS?

+

In my test on my Pixel 2, the stock firmware takes over 30 seconds to start, usually close to 40. ZyperOS is on in about 2, and POWER in a game saves it, so the next start puts you back in that game. Beyond that, ZyperOS has what the stock column of the comparison doesn't show:

+
    +
  • Box art on the handheld, from three sources, with no computer
  • +
  • RetroAchievements, signed in on the device
  • +
  • Games over the USB cable or from any browser, not only Samba
  • +
  • System updates over Wi-Fi or the cable, with roll back
  • +
  • Charge limit, battery time left and a charging screen
  • +
  • A first-start tutorial for every button
  • +
  • Eight languages and menu music
  • +
  • Storage by game, and side lights
  • +
+

Keep the stock card as your way back: ZyperOS goes on a second card, and swapping cards switches systems (keeping the stock card safe). Your games come over as files, each into its console's folder under Roms/.

+
+
+ +
+
+
+

Install in three steps

+
    +
  1. Download ZyperOS Installer for macOS, Windows or Linux from the latest release.
  2. +
  3. Write the card. Put the microSD card in the computer, pick the GKD Pixel 2, the latest release and the card, and choose Fresh install.
  4. +
  5. Play. Put the card in the Pixel 2 and turn it on. The first start sets the card up in about a minute, then shows you each button.
  6. +
+ +

ZyperOS 1.0.2, November 2026. Or write the card image, the .img.xz file, with any disk image writer, such as Raspberry Pi Imager. New versions come with System Update, over Wi-Fi or the USB cable, or with the installer's Update, which keeps a card's games, music and saves. For the GKD Pixel 2 only for now: the TrimUI Brick's image comes later. To build the card image yourself, see Building ZyperOS. Questions, ideas, or want to help build it? Come say hi on the ZyperOS Discord.

+
+
+
+ + +
+
+

The tour

+

ZyperOS in 105 seconds, every screen drawn by the launcher itself on a sample library.

+ +
+

What it does

-

Starts the moment you press A

The emulator stays running, so a game is up as soon as you choose it, and it picks up where you left it.

+

Starts the moment you press A

The emulator stays running, so a game is up as soon as you choose it. A picks up where you left it, and Y starts it from the start.

Every system in pixel art

Animated consoles on a Spotlight or Gallery home, A to Z or by release date, and your games as a row of covers, a heart before each favorite's name.

Eight languages

English, 简体中文, Español, Português (Brasil), Français, Deutsch, 日本語 and Italiano, from the menus and the in-game menu to the charging screen, the boot screen and the File Transfer page. Pick one on the first start or in MENU > Language.

A first start that asks

Zyper says hello in each language until you pick yours, teaches each button and shows where it is on the Pixel 2, then asks for the theme, the home screen, the button sounds and the menu music.

No games yet? It shows how

An empty card opens on the two ways to add games, USB or Wi-Fi, one button each. Zyper cheers when they arrive.

Box art it finds itself

MENU > Scraper asks libretro with no account, ScreenScraper with a free one in builds that carry its developer pair, and SteamGridDB with your own key. Renamed ROMs match by checksum.

-

Pick a game's cover

X on a game, then Cover and Video: every box, 3D box, title screen and screenshot the three sources offer, named with where it comes from. A shows it big first, and the cover it replaces is kept to put back.

+

Pick a game's cover

X on a game, then Cover and Video: every box, 3D box, title screen and screenshot the three sources offer, named with where it comes from. A shows it big first, and the cover it replaces is kept to put back. Its video snap is there too, to watch, download, replace or delete.

Video snaps

Rest on a game for a second and its cover turns into a few seconds of the game, from ScreenScraper. A play mark says which games have one.

Delete a game, and only that game

X on a game, then Delete Game: it asks first, then takes the game with its saves, states, cover and video, and the shelf moves on. A file another game still uses stays, and so does a BIOS set like neogeo.zip.

Storage, as a phone shows it

MENU > Storage shows what fills the card in one bar, then every system by size and each system's games largest first. Music, Videos and Audiobooks open their albums, films and books, and X sorts any list by size, A to Z or last played. It suggests what can go: art of games no longer there, and save states of games not played for a year.

@@ -363,12 +457,13 @@

What it does

Menu music

Ten free 8-bit tracks behind the menus, never over a game. Your own in Roms/BGM play instead.

Wi-Fi File Transfer

Move games, music and saves from any browser, behind a PIN. With no Wi-Fi around, the handheld makes its own network for your phone. It keeps going while you play, and shows the time left.

Battery care

A charge limit of 80, 85 or 90%, a charging screen while the handheld is off, and a warning before a low battery or heat gets in the way. The corner can show the time left at once, or the time to full on the cable, instead of the percentage, and says so when the charge is on hold or done.

-

USB that only charges

Plug in a computer and the Pixel 2 asks first: charge only, or open USB File Transfer.

+

USB that only charges

Plug in a computer and the Pixel 2 asks first: charge only, or open USB File Transfer. Developer Mode, hidden on About behind L2 + R2 held for five seconds, brings up SSH over the cable.

System Update

Over Wi-Fi, only what changed, signed, with Roll Back. Without Wi-Fi, the File Transfer page brings it over the USB cable.

Rumble and side lights

Dreamcast, PlayStation and Game Boy Color rumble games shake the Pixel 2. Its four side lights show the battery charging and running low, the volume and brightness as you change them, a climb when files or covers finish, and a sparkle for an achievement. MENU > Side Lights turns each on or off.

Clocks per system

8-bit and handheld systems run at lower clocks, measured to keep every frame, and the battery lasts longer.

Quick save, quick load

Hold SELECT and press R1 to save a state, L1 to load it, into the slot you pick for each system. SELECT on its own still belongs to the game.

Faster, slower, and turbo

SELECT + R2 runs a game at 1.5x, 2x or 3x, and SELECT + L2 steps back down. X and Y are turbo A and B.

+

A way out of anything

Hold MENU + START for a second and whatever runs ends, a stuck game or port included, and the home comes back. In the in-game menu, X quits from any row.

Every control, in the game

Controls in the in-game menu shows what each button does in that game, on its system: its own buttons, the PlayStation's as their shapes, turbo, the hotkeys, the N64's C buttons and the DS's stylus.

@@ -390,10 +485,10 @@

The File Transfer page

Light and dark

Beard in light, Salt & Pepper in dark. MENU > Appearance chooses Light, Dark or Auto, and the first start asks.

+
The Nintendo 64 shelf in the light theme, Super Mario 64's box in the middle
Light
+
The Nintendo 64 shelf in the dark theme, Super Mario 64's box in the middle
Dark
The Spotlight home in the light theme
Light
The Spotlight home in the dark theme
Dark
-
The ZyperOS menu in the light theme
Light
-
The ZyperOS menu in the dark theme
Dark
@@ -437,6 +532,8 @@

Forty systems

- The others: Knulli release 20260511, muOS's _data/devices/rk-pixel-2.yml, spruceUI/twigUI-next, NukeBomB3000/minui-gkd-pixel2 + - Boot time from power on, both columns (note 5): my own count on my + Pixel 2, stock card against ZyperOS, 2026-11-09 - ZyperOS: the README and release v1.0.2; the boot time from plastron#15 (1.58 s, kernel to the first frame, mean of 10 starts on the Pixel 2) and ZyperOS#83 -->
@@ -469,8 +566,10 @@

How it compares

Pick a game's cover on the device––––– Video previews on the shelf––––– Charge limit––––– + Battery time left––––– + A DS game's language––––– Charging screen when off––––– - Boot time1.6 s4––2.2 s4–– + Boot time1.6 s4, about 2 s from power on530 to 40 s from power on5–2.2 s4–– @@ -481,6 +580,7 @@

How it compares

  • No count is published, so this is the systems in its own list, counted without tools, media players and ports. Hacks and regions can be entries of their own.
  • In builds made with ScreenScraper's developer key.
  • Each project's own number, and not measured the same way: ZyperOS's from the kernel's start to the home screen, on the Pixel 2 (mean of 10 starts), and TortOS's from power on to its shelf.
  • +
  • My own count on my Pixel 2, from power on until it can be used, with the stock card it came with and with ZyperOS: the stock firmware takes over 30 seconds, usually close to 40; ZyperOS about 2.
  • Every source is linked in the README.

    @@ -498,26 +598,6 @@

    The name

    -
    -
    -
    -

    Install

    -

    ZyperOS 1.0.2 is out for the GKD Pixel 2, released in November 2026. Each release has the card image and ZyperOS Installer for macOS, Windows and Linux.

    -
      -
    1. Download ZyperOS Installer for your computer from the latest release.
    2. -
    3. Put the microSD card in the computer, then pick the GKD Pixel 2, the latest release and the card.
    4. -
    5. Choose Fresh install, which erases the card and puts ZyperOS on it.
    6. -
    7. Put the card in the Pixel 2 and turn it on. The first start sets the card up in about a minute.
    8. -
    -

    Or download the card image, the .img.xz file, and write it with any disk image writer, such as Raspberry Pi Imager. New versions come later with System Update, over Wi-Fi or the USB cable, or with the installer's Update, which keeps a card's games, music and saves.

    - -

    For the GKD Pixel 2 only for now: the TrimUI Brick's image comes later. To build the card image yourself, see Building ZyperOS.

    -
    -
    -
    @@ -566,6 +646,7 @@

    Support ZyperOS

    Blog GitHub + Discord Guide Building License @@ -617,7 +698,7 @@

    Support ZyperOS

    (() => { const shelf = document.querySelector(".shelf"), stage = shelf.querySelector(".stage"); const shots = [...stage.querySelectorAll(".shot")]; - const caps = ["Or the Gallery home", "Every system, in pixel art", "Your games, cover by cover", "A game's details, one button away", "RetroAchievements on the device", "The device's settings in one menu", "Box art from three sources", "Your albums, in MP4", "The battery, as time left", "Quick save and load, to the slot you pick", "SELECT with a shoulder: the hotkeys", "What fills the card, by part and by size", "Pick a game's cover, and see it first"]; + const caps = ["Favorites from every system", "Your games, cover by cover", "Box art the handheld finds itself", "A heart for each favorite", "Every system, in pixel art", "Or the Gallery home", "Pick a game's cover, and see it first", "A game's details, one button away", "RetroAchievements, and Zyper cheers", "Every achievement, with its badge", "PortMaster with one press", "What fills the card, by part and by size", "Quick save and load, to the slot you pick", "SELECT with a shoulder: the hotkeys", "Your albums, books and films, in MP4", "The battery, as time left", "Box art from three sources", "The device's settings in one menu"]; const STEP = [0, .86, 1.52, 2.06, 2.5], SCALE = [1, .78, .62, .5, .4], DIM = [1, .75, .55, .35, 0]; const cap = shelf.querySelector("[data-cap]"), count = shelf.querySelector("[data-count]"); const rail = shelf.querySelector(".rail i"), steps = [...shelf.querySelectorAll("[data-step]")]; diff --git a/site/models/pixel2-poster.png b/site/models/pixel2-poster.png index 92d26dc..f320e45 100644 Binary files a/site/models/pixel2-poster.png and b/site/models/pixel2-poster.png differ diff --git a/site/og.png b/site/og.png index 92e9c49..839d348 100644 Binary files a/site/og.png and b/site/og.png differ diff --git a/site/pixel2.js b/site/pixel2.js index 303e95c..90df44b 100644 --- a/site/pixel2.js +++ b/site/pixel2.js @@ -8,7 +8,7 @@ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js"; // The README's screenshots, all in the light theme, as on the page beside it -const SHOTS = ["systems", "gallery", "shelf", "info", "cheevos", "menu", "mp4", "nowplaying", "gamemenu"]; +const SHOTS = ["shelf-n64", "systems", "favorites", "shelf-snes", "gallery", "shelf", "info", "cheevos", "menu", "mp4", "nowplaying", "gamemenu"]; const REST = { pitch: 0, yaw: -0.28, roll: 0 }; export async function mount(canvas) { diff --git a/site/zyperos-tour.jpg b/site/zyperos-tour.jpg new file mode 100644 index 0000000..527e84f Binary files /dev/null and b/site/zyperos-tour.jpg differ diff --git a/site/zyperos-tour.mp4 b/site/zyperos-tour.mp4 new file mode 100644 index 0000000..ba9e808 Binary files /dev/null and b/site/zyperos-tour.mp4 differ diff --git a/src/main.c b/src/main.c index 46a64e3..3dc573a 100644 --- a/src/main.c +++ b/src/main.c @@ -12252,7 +12252,7 @@ static unsigned long long store_fixture_game(const char *title) { static const struct { const char *word; unsigned long long mb; } SZ[] = { { "Metal Gear", 1240 }, { "Castlevania", 690 }, { "Tekken", 560 }, - { "Crash", 470 }, { "Tony Hawk", 390 }, { "Peter Pan", 330 }, + { "Crash", 470 }, { "Tony Hawk", 390 }, { "Spyro", 330 }, }; size_t i; diff --git a/tools/build-pixel2-model.py b/tools/build-pixel2-model.py index ab6ccca..9f24bfe 100644 --- a/tools/build-pixel2-model.py +++ b/tools/build-pixel2-model.py @@ -490,7 +490,7 @@ def area(name,loc,power,size,scale=1): # Only the review/poster render uses a screenshot; the GLB is independent # of screenshot revisions and the website supplies its live texture. nodes=display.node_tree.nodes; nodes.clear() - image=nodes.new('ShaderNodeTexImage'); image.image=bpy.data.images.load(str(ROOT/'res/readme/systems.png')) + image=nodes.new('ShaderNodeTexImage'); image.image=bpy.data.images.load(str(ROOT/'res/readme/shelf-n64.png')) emission=nodes.new('ShaderNodeEmission'); output=nodes.new('ShaderNodeOutputMaterial') display.node_tree.links.new(image.outputs['Color'],emission.inputs['Color']) display.node_tree.links.new(emission.outputs[0],output.inputs['Surface']) diff --git a/tools/readme-shots.py b/tools/readme-shots.py index 8eda684..5622e57 100644 --- a/tools/readme-shots.py +++ b/tools/readme-shots.py @@ -6,11 +6,18 @@ Writes build/readme-shots/light (every screen, in Beard), build/readme-shots/dark (the few screens the README's light-and-dark section shows in Salt & Pepper) -and a manifest. --out changes that output directory. No device is accessed, -and nothing is fetched. Temporary demonstration cards use empty ROM and music -files with freeware or stand-in names; the launcher itself draws every pixel, -the game cards included. --art-dir copies local covers into the card; leave it -out for the repository's pictures, which show no commercial box art. +and a manifest. --out changes that output directory. No device is accessed. +Temporary demonstration cards use empty ROM and music files named after +well-known games and freeware; the launcher itself draws every pixel. + +--art puts the games' real box art on the demonstration card, as the +launcher's own Scraper would: the covers, title screens and screenshots of +the libretro-thumbnails sets (github.com/libretro-thumbnails), fetched once +into a cache outside the repository (~/Library/Caches/zyperos-readme-art, +--art-cache moves it) and reused after. Only the composed screenshots go in +the repository; the games and their art belong to their owners. Without +--art, nothing is fetched and every game wears the launcher's generated card, +as the checks that load this file use it. Achievements, playback timing, the Scraper's counts and the update page use the launcher's built-in shot fixtures and the status file written below. @@ -34,21 +41,85 @@ import subprocess import tempfile import time +import urllib.error import urllib.parse import urllib.request import zlib ROOT=Path(__file__).resolve().parents[1] -GAMES=[ - 'Castlevania - Symphony of the Night (USA)', 'Crash Bandicoot (USA)', - 'Metal Gear Solid (USA)', "Peter Pan in Disney's Return to Never Land (USA)", - 'Tekken 3 (USA)', "Tony Hawk's Pro Skater 2 (USA)", -] +# The demonstration library: well-known games on the systems the home shows, +# as empty files, each with the libretro-thumbnails name its art is filed +# under (--art). folder: [(the file on the card, libretro's name)] +LIBRARY={ + 'PlayStation':[ + ('Castlevania - Symphony of the Night (USA).chd','Castlevania - Symphony of the Night (USA)'), + ('Crash Bandicoot (USA).chd','Crash Bandicoot (USA)'), + ('Metal Gear Solid (USA).chd','Metal Gear Solid (USA)'), + ('Spyro the Dragon (USA).chd','Spyro the Dragon (USA)'), + ('Tekken 3 (USA).chd','Tekken 3 (USA)'), + ("Tony Hawk's Pro Skater 2 (USA).chd","Tony Hawk's Pro Skater 2 (USA)")], + 'NES':[ + ("Kirby's Adventure (USA).nes","Kirby's Adventure (USA)"), + ('Mega Man 2 (USA).nes','Mega Man 2 (USA)'), + ('Metroid (USA).nes','Metroid (USA)'), + ('Super Mario Bros. 3 (USA).nes','Super Mario Bros. 3 (USA)'), + ('The Legend of Zelda (USA).nes','Legend of Zelda, The (USA)')], + 'SNES':[ + ('Chrono Trigger (USA).sfc','Chrono Trigger (USA)'), + ('Donkey Kong Country (USA).sfc','Donkey Kong Country (USA)'), + ('Super Mario World (USA).sfc','Super Mario World (USA)'), + ('Super Metroid (USA).sfc','Super Metroid (Japan, USA) (En,Ja)'), + ('The Legend of Zelda - A Link to the Past (USA).sfc','Legend of Zelda, The - A Link to the Past (USA)')], + 'Game Boy':[ + ('Pokemon - Red Version (USA, Europe).gb','Pokemon - Red Version (USA, Europe) (SGB Enhanced)'), + ('Super Mario Land (World).gb','Super Mario Land (World)'), + ('Tetris (World).gb','Tetris (World) (Rev 1)'), + ("The Legend of Zelda - Link's Awakening (USA, Europe).gb","Legend of Zelda, The - Link's Awakening (USA, Europe)")], + 'Game Boy Advance':[ + ('Advance Wars (USA).gba','Advance Wars (USA)'), + ('Mario Kart - Super Circuit (USA).gba','Mario Kart - Super Circuit (USA)'), + ('Metroid Fusion (USA).gba','Metroid Fusion (USA)'), + ('Pokemon - Emerald Version (USA, Europe).gba','Pokemon - Emerald Version (USA, Europe)'), + ('The Legend of Zelda - The Minish Cap (USA).gba','Legend of Zelda, The - The Minish Cap (USA)')], + 'Genesis':[ + ('Gunstar Heroes (USA).md','Gunstar Heroes (USA)'), + ('Sonic The Hedgehog (USA).md','Sonic The Hedgehog (USA)'), + ('Sonic The Hedgehog 2 (USA).md','Sonic The Hedgehog 2 (USA)'), + ('Streets of Rage 2 (USA).md','Streets of Rage 2 (USA)')], + 'Nintendo 64':[ + ('GoldenEye 007 (USA).z64','GoldenEye 007 (USA)'), + ('Mario Kart 64 (USA).z64','Mario Kart 64 (USA)'), + ('Super Mario 64 (USA).z64','Super Mario 64 (USA)'), + ('The Legend of Zelda - Ocarina of Time (USA).z64','Legend of Zelda, The - Ocarina of Time (USA)')], + 'Dreamcast':[ + ('Crazy Taxi (USA).chd','Crazy Taxi (USA)'), + ('Jet Grind Radio (USA).chd','Jet Grind Radio (USA)'), + ('Sonic Adventure (USA).chd','Sonic Adventure (USA)'), + ('Soulcalibur (USA).chd','Soulcalibur (USA)')], +} +# Each folder's libretro-thumbnails repository +LR_REPO={'PlayStation':'Sony_-_PlayStation','NES':'Nintendo_-_Nintendo_Entertainment_System', + 'SNES':'Nintendo_-_Super_Nintendo_Entertainment_System','Game Boy':'Nintendo_-_Game_Boy', + 'Game Boy Advance':'Nintendo_-_Game_Boy_Advance','Genesis':'Sega_-_Mega_Drive_-_Genesis', + 'Nintendo 64':'Nintendo_-_Nintendo_64','Dreamcast':'Sega_-_Dreamcast','ScummVM':'ScummVM'} +# With --art, the in-game menu and its Controls pages stand over a screenshot +# of a PlayStation game (--backdrop) rather than a made-up frame +BACKDROP=['gamemenu','controls-ps1','controls-hotkeys'] +BACKDROP_GAME='Spyro the Dragon (USA)' # bright, so the panel reads against it +ART_CACHE=Path.home()/'Library/Caches/zyperos-readme-art' +GAMES=[Path(f).stem for f,_ in LIBRARY['PlayStation']] FOCUS=GAMES[2] # the games page and its details open on Metal Gear Solid +# Its details' Synopsis, in my words +SYNOPSIS=('Solid Snake slips into a nuclear weapons base on a snowbound Alaskan island, ' + 'alone, to stop a rogue unit that threatens to launch.') # ScummVM's freeware games, as ScummVM's own site gives them away: a folder each SCUMMVM=['Beneath a Steel Sky','Drascula - The Vampire Strikes Back', 'Flight of the Amazon Queen','Lure of the Temptress','Soltys'] SCUMMVM_FOCUS=SCUMMVM[2] +# ...and their art in libretro-thumbnails' ScummVM set (--art) +SCUMMVM_ART={'Beneath a Steel Sky':'Beneath a Steel Sky','Drascula - The Vampire Strikes Back':'Drascula - The Vampire Strikes Back', + 'Flight of the Amazon Queen':'Flight of the Amazon Queen (CD_DOS_English)', + 'Lure of the Temptress':'Lure of the Temptress','Soltys':'Soltys'} # OpenBOR's shelf: Senile Team's freeware game, as a stand-in module OPENBOR=['Beats of Rage.pak'] # name: (card, the shelf .last names, flags). 'demo' is the library above, @@ -59,6 +130,10 @@ 'systems':('demo','PS1',['--home','spotlight']), 'gallery':('demo','PS1',['--home','gallery']), 'shelf':('demo','PS1',['--screen','games']), + # Other shelves of the demonstration card, and Favorites across systems + 'shelf-n64':('demo','N64',['--screen','games']), + 'shelf-snes':('demo','SFC',['--screen','games']), + 'favorites':('demo','FAV',['--screen','games']), 'info':('demo','PS1',['--screen','games','--info']), 'info-delete':('demo','PS1',['--screen','games','--info-delete']), 'delete-ask':('demo','PS1',['--screen','games','--delete-ask']), @@ -138,17 +213,23 @@ # games page with the focused game and the one before it kept, so the heart # shows on a focused cover and on a side one (src/artbadge.h). Only there, so # the other shots' homes have no Favorites shelf. -SHOT_FAVS={'shelf':[('PS1',FOCUS+'.chd'),('PS1',GAMES[1]+'.chd')]} +SHOT_FAVS={'shelf':[('PS1',FOCUS+'.chd'),('PS1',GAMES[1]+'.chd')], + # Favorites: one game from each of seven systems + 'favorites':[('N64','Super Mario 64 (USA).z64'),('SFC','Super Metroid (USA).sfc'),('PS1',FOCUS+'.chd'), + ('GBA','Metroid Fusion (USA).gba'),('MD','Sonic The Hedgehog 2 (USA).md'), + ('NES','Super Mario Bros. 3 (USA).nes'),('DC','Jet Grind Radio (USA).chd')]} # The game a shot's shelf opens on, where it is not its system's own (below) # MP4's on the film, between an album and the book, so its three kinds show -SHOT_FOCUS={'ports-menu':'Star Farm.sh','ports-controls':'Star Farm.sh','mp4':'Videos'} +SHOT_FOCUS={'ports-menu':'Star Farm.sh','ports-controls':'Star Farm.sh','mp4':'Videos', + 'shelf-n64':'Super Mario 64 (USA).z64','shelf-snes':'Super Mario World (USA).sfc', + 'favorites':'Sonic The Hedgehog 2 (USA).md'} # A shot's own --status where the README's default (no Wi-Fi, 82%) would hide # what it shows: icon= is the setting for this shot, eta= the minutes left STATUS={'battery':'10:08 24h wifi=3 batt=62 eta=205 icon=time', 'topbar':'10:08 24h wifi=3 phones=on batt=62','topbar-paused':'10:08 24h wifi=3 phones=on batt=62', 'topbar-still':'10:08 24h wifi=3 phones=on batt=62','topbar-long':'10:08 24h wifi=3 phones=on batt=62','topbar-long-moving':'10:08 24h wifi=3 phones=on batt=62'} # The README's light-and-dark section: these, in both themes -DARK=['systems','menu','topbar','nowplaying','video-controls','video-full','media'] +DARK=['systems','menu','topbar','nowplaying','video-controls','video-full','media','shelf-n64'] # A shot whose screen draws its own clock, outside the status corner, so the # launcher's corner check (shot_corner_clear in src/main.c) reports this after # writing a right picture: the charging screen's clock is its own (DESIGN.md @@ -198,8 +279,32 @@ def stand_in_badge(path,color): ('c9.png',320,240,(80,150,110),'shot'),('c10.png',400,600,(60,70,140),'fan'), ('snap.png',320,240,(90,160,120),'shot'), ] -def media_art(dest): +# With --art, the page shows Metal Gear Solid's own pictures from the +# libretro-thumbnails sets in those places: its box from several regions, +# its title screen and screenshots. +MEDIA_REAL=[ # file, the set, libretro's name + ('now.png','Named_Boxarts','Metal Gear Solid (USA)'), + ('c0.png','Named_Boxarts','Metal Gear Solid (USA) (Disc 1)'), + ('c1.png','Named_Boxarts','Metal Gear Solid (Japan)'), + ('c2.png','Named_Boxarts','Metal Gear Solid (Europe)'), + ('c3.png','Named_Boxarts','Metal Gear Solid (Spain) (Disc 1)'), + ('c4.png','Named_Boxarts','Metal Gear Solid (Japan, Asia) (Disc 1) (Ichi) (Premium Package)'), + ('c5.png','Named_Titles','Metal Gear Solid (USA)'), + ('c6.png','Named_Snaps','Metal Gear Solid (USA)'), + ('c7.png','Named_Boxarts','Metal Gear Solid (Japan) (Disc 1) (Ichi)'), + ('c8.png','Named_Titles','Metal Gear Solid (Japan) (Disc 1) (Ichi)'), + ('c9.png','Named_Snaps','Metal Gear Solid (USA) (Disc 1)'), + ('c10.png','Named_Boxarts','Metal Gear Solid (Germany) (Disc 1)'), + ('snap.png','Named_Snaps','Metal Gear Solid (USA)'), +] +def media_art(dest,art=None,manifest=None): dest.mkdir(parents=True,exist_ok=True) + if art: + for name,kind,lr in MEDIA_REAL: + got=art_file(art,LR_REPO['PlayStation'],kind,lr) + shutil.copyfile(got,dest/name) + if manifest is not None: note_art(manifest,'media-art/'+name,'PlayStation',kind,lr,got) + return for name,w,h,color,kind in MEDIA_ART: rows=[] for y in range(h): @@ -215,28 +320,68 @@ def media_art(dest): rows.append(row) png_rgb(dest/name,w,h,rows) +def art_file(cache,repo,kind,name): + """One picture of a libretro-thumbnails set, from the cache, or fetched + into it from GitHub the first time. libretro files a name's & * / : ` < > + ? \\ | as _, and a set's link to another picture comes back as that + picture's name, which is followed.""" + path=cache/repo/kind/(name+'.png') + if path.is_file(): return path + safe=''.join('_' if c in '&*/:`<>?\\|' else c for c in name) + url=f'https://raw.githubusercontent.com/libretro-thumbnails/{repo}/master/{kind}/'+urllib.parse.quote(safe+'.png') + for _ in range(4): + with urllib.request.urlopen(url,timeout=60) as reply: data=reply.read() + if data.startswith(b'\x89PNG'): break + url=urllib.parse.urljoin(url,urllib.parse.quote(data.decode().strip())) + else: raise RuntimeError(f'{repo}/{kind}/{name}: no picture') + path.parent.mkdir(parents=True,exist_ok=True) + path.write_bytes(data) + return path + +def snap_video(cache,folder,name): + """A game's video snap for the shelf's play mark and its details: two + seconds of its libretro screenshot (Named_Snaps) as an MP4, made with + ffmpeg into the cache. None without ffmpeg or a screenshot.""" + out=cache/LR_REPO[folder]/'Videos'/(name+'.mp4') + if out.is_file(): return out + if not shutil.which('ffmpeg'): return None + try: still=art_file(cache,LR_REPO[folder],'Named_Snaps',name) + except urllib.error.HTTPError: return None + out.parent.mkdir(parents=True,exist_ok=True) + subprocess.run(['ffmpeg','-v','error','-y','-loop','1','-i',str(still),'-t','2','-r','30', + '-vf','scale=320:-2,format=yuv420p','-c:v','libx264','-movflags','+faststart',str(out)],check=True) + return out + +def note_art(manifest,where,folder,kind,name,path): + manifest['artwork'][where]={'set':f'libretro-thumbnails/{LR_REPO[folder]}/{kind}','name':name, + 'sha256':hashlib.sha256(path.read_bytes()).hexdigest()} + def card_dirs(card): for folder in ['ZyperOS','Roms','userdata','shared']: (card/folder).mkdir(parents=True) shutil.copyfile(ROOT/'config/systems.cfg',card/'ZyperOS/systems.cfg') shutil.copytree(ROOT/'res/system-icons',card/'ZyperOS/system-icons') (card/'ZyperOS/VERSION').write_text('0.1.0\n') -def demo_card(card,art_dir,manifest): +def demo_card(card,art,manifest): + """The demonstration card: LIBRARY's games as empty files, and with art + (the cache --art fills) each game's box art in its folder's .media, where + the Scraper puts it""" card_dirs(card) - for folder,game in [('NES','Super Mario Bros.nes'),('SNES','Super Metroid.sfc'),('Game Boy','Tetris.gb'),('Genesis','Sonic the Hedgehog.md'),('Dreamcast','Sonic Adventure.chd'),('Game Boy Advance','Metroid Fusion.gba')]: - d=card/'Roms'/folder;d.mkdir();(d/game).touch() + for folder,games in LIBRARY.items(): + d=card/'Roms'/folder;(d/'.media').mkdir(parents=True) + for file,lr in games: + (d/file).touch() + if art: + got=art_file(art,LR_REPO[folder],'Named_Boxarts',lr) + shutil.copyfile(got,d/'.media'/(Path(file).stem+'.png')) + note_art(manifest,f'{folder}/{file}',folder,'Named_Boxarts',lr,got) + video=snap_video(art,folder,lr) + if video: shutil.copyfile(video,d/'.media'/(Path(file).stem+'.mp4')) pm_art(card) # the Install PortMaster page's icon (--portmaster) - media=card/'Roms/PlayStation/.media';media.mkdir(parents=True) - for name in GAMES: - (media.parent/(name+'.chd')).touch() - art=art_dir/(name+'.png') if art_dir else None - if art and art.is_file(): - shutil.copyfile(art,media/(name+'.png')) - manifest['artwork'][name]=hashlib.sha256(art.read_bytes()).hexdigest() # Stand-in badges for the achievements list and notice shots, beside the # (absent) set of the PlayStation folder as src/rabadge.h keeps them - # The Cover and Video page's stand-in pictures, beside the card's files - media_art(card/'media-art') + # The Cover and Video page's pictures, beside the card's files + media_art(card/'media-art',art,manifest) badges=card/'Roms/PlayStation/.cheevos/badges';badges.mkdir(parents=True) for i,color in enumerate(BADGE_COLORS): stand_in_badge(badges/f'b{i+1}.png',color) # Curated metadata for the demonstration's focused game. This is @@ -244,7 +389,7 @@ def demo_card(card,art_dir,manifest): dbdir=card/'shared/.zyperos';dbdir.mkdir() with sqlite3.connect(dbdir/'library.db') as db: db.execute('CREATE TABLE games(folder TEXT NOT NULL,file TEXT NOT NULL,year TEXT,publisher TEXT,developer TEXT,players TEXT,genres TEXT,esrb TEXT,note TEXT,synopsis TEXT,scraped INTEGER,PRIMARY KEY(folder,file))') - db.execute('INSERT INTO games VALUES(?,?,?,?,?,?,?,?,?,?,?)',('PlayStation',FOCUS+'.chd','1998','Konami','KCEJ','1','Action','','','',0)) + db.execute('INSERT INTO games VALUES(?,?,?,?,?,?,?,?,?,?,?)',('PlayStation',FOCUS+'.chd','1998','Konami','KCEJ','1','Action','','',SYNOPSIS,0)) for album in ['First Light','Night Drive','Quiet Hours']: d=card/'Music/Demo Library'/album;d.mkdir(parents=True) for track in ['01 First Light.mp3','02 Open Road.mp3','03 Home Again.mp3']: (d/track).touch() @@ -257,18 +402,19 @@ def demo_card(card,art_dir,manifest): # (docs/updates.md), so the page offers its download as on Wi-Fi (up/'source').write_text('file:///nonexistent\n') -def media_card(card,art_dir=None,manifest=None): +def media_card(card,art=None,manifest=None): """The demonstration card, a film, Blender's freely licensed Sintel as a stand-in name, and a book, Treasure Island, which is in the public domain: MP4's shelf shows All, the albums, the book and the video mixed (src/muselib.h), each card with its kind's badge, and its menu has Show""" - demo_card(card,art_dir,manifest if manifest is not None else {'artwork':{}}) + demo_card(card,art,manifest if manifest is not None else {'artwork':{}}) (card/'Videos').mkdir();(card/'Videos/Sintel.mp4').touch() book=card/'Audiobooks/Treasure Island';book.mkdir(parents=True) (book/'01 The Old Sea-dog at the Admiral Benbow.mp3').touch() def set_favs(card,favs,on): """A shot's favorites (SHOT_FAVS) into the card's library, or out of it""" + if not favs: return # a card with no library yet (ScummVM's) keeps none with sqlite3.connect(card/'shared/.zyperos/library.db') as db: db.execute('CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY NOT NULL,value TEXT NOT NULL)') for tag,file in favs: @@ -304,10 +450,15 @@ def pm_env(env,name,folder): env['ZYPEROS_PM_STATUS']=str(status) return env -def scummvm_card(card): +def scummvm_card(card,art=None,manifest=None): card_dirs(card) for name in SCUMMVM: d=card/'Roms/ScummVM'/name;d.mkdir(parents=True);(d/'readme.txt').touch() + if art: + got=art_file(art,LR_REPO['ScummVM'],'Named_Boxarts',SCUMMVM_ART[name]) + (card/'Roms/ScummVM/.media').mkdir(exist_ok=True) + shutil.copyfile(got,card/'Roms/ScummVM/.media'/(name+'.png')) + if manifest is not None: note_art(manifest,'ScummVM/'+name,'ScummVM','Named_Boxarts',SCUMMVM_ART[name],got) def portdocs_card(card): """A card whose Ports folder has the made-up ports of tools/fixtures/ports @@ -492,7 +643,8 @@ def env_for(card): def main(): ap=argparse.ArgumentParser(description=__doc__) - ap.add_argument('--art-dir',type=Path) + ap.add_argument('--art',action='store_true',help="the games' real box art, from libretro-thumbnails (cached)") + ap.add_argument('--art-cache',type=Path,default=ART_CACHE,help='where --art keeps what it fetched') ap.add_argument('--out',type=Path,default=ROOT/'build/readme-shots') ap.add_argument('--chrome',default=CHROME,help='the browser for the File Transfer page') ap.add_argument('--no-page',action='store_true',help='leave out the File Transfer page') @@ -502,15 +654,17 @@ def main(): if not binary.is_file(): ap.error('run make native first') manifest={'source_commit':subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip(), 'renderer':'current native launcher, SDL software, headless', - 'data':'demonstration library (freeware or stand-in names, empty files); built-in shot fixtures', + 'data':'demonstration library (well-known games and freeware by name, empty files); built-in shot fixtures', 'artwork':{},'screenshots':[]} with tempfile.TemporaryDirectory(prefix='zyperos-readme-') as temporary: cards={'demo':Path(temporary)/'demo','empty':Path(temporary)/'empty','scummvm':Path(temporary)/'scummvm', 'openbor':Path(temporary)/'openbor','ports':Path(temporary)/'ports', 'portdocs':Path(temporary)/'portdocs','media':Path(temporary)/'media'} - demo_card(cards['demo'],args.art_dir,manifest) - media_card(cards['media'],args.art_dir) - scummvm_card(cards['scummvm']) + art=args.art_cache if args.art else None + demo_card(cards['demo'],art,manifest) + media_card(cards['media'],art) + scummvm_card(cards['scummvm'],art,manifest) + backdrop=art_file(art,LR_REPO['PlayStation'],'Named_Snaps',BACKDROP_GAME) if art else None openbor_card(cards['openbor']) ports_card(cards['ports']) portdocs_card(cards['portdocs']) @@ -529,6 +683,7 @@ def main(): # {card} is where this run's temporary card is; the manifest keeps the word command=[str(binary),'--shot',str(target),'--theme',theme, '--screen','systems','--phase','3200',*status,'--fn-volume',*[f.replace('{card}',str(card)) for f in flags]] + if backdrop and name in BACKDROP: command+=['--backdrop',str(backdrop)] set_favs(card,SHOT_FAVS.get(name,[]),True) result=subprocess.run(command,cwd=ROOT,env=pm_env(env_for(card),name,temporary),text=True, stdout=subprocess.PIPE,stderr=subprocess.STDOUT) @@ -537,7 +692,7 @@ def main(): if result.returncode and not (name in CORNER_OWN and CORNER_OWN[name] in result.stdout): raise RuntimeError(f'{theme}/{name}: {result.stdout}') if not target.is_file(): raise RuntimeError(f'missing {target}') - manifest['screenshots'].append({'file':f'{theme}/{name}.png','card':card_name,'args':[a.replace(str(card),'{card}') for a in command[3:]], + manifest['screenshots'].append({'file':f'{theme}/{name}.png','card':card_name,'args':[a.replace(str(card),'{card}').replace(str(args.art_cache),'{art}') for a in command[3:]], 'sha256':hashlib.sha256(target.read_bytes()).hexdigest()}) print(theme+'/'+name,flush=True) if not args.no_page and (not args.only or set(args.only)&set(PAGES)): diff --git a/tools/site-og.html b/tools/site-og.html index 05e11fb..c46f726 100644 --- a/tools/site-og.html +++ b/tools/site-og.html @@ -31,9 +31,9 @@

    Fast custom firmware for the GKD Pixel 2

    -

    Forty systems, PortMaster ports, box art and RetroAchievements, from a card you can take out.

    +

    Forty systems with their box art, PortMaster ports and RetroAchievements.

    zyperos.com
    -
    +
    diff --git a/video/.gitignore b/video/.gitignore new file mode 100644 index 0000000..ce7b257 --- /dev/null +++ b/video/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +out/ +# Made by scripts/prepare-assets.sh and footage/takes.py from the repository +# and the art cache: the launcher's frames, the device render, fonts, music +# and sounds. Their licenses are in LICENSES.md. +public/ +rec/rec.dylib +rec/snapsub.dylib diff --git a/video/LICENSES.md b/video/LICENSES.md new file mode 100644 index 0000000..4a8c1d0 --- /dev/null +++ b/video/LICENSES.md @@ -0,0 +1,56 @@ +# Licenses of what the tour is made of + +None of the tour's audio is new to the repository. `scripts/prepare-assets.sh` +copies it into `public/audio/` from `res/`, where ZyperOS already ships it, so the licenses are +the ones those files carry there. + +## Music + +The tour plays one of ZyperOS's own menu music tracks, `res/bgm/*.mp3`, all of +them CC0 1.0 (`res/bgm/LICENSE-CC0-1.0.txt`; each one's author and page are in +`res/bgm/tracks.json`). `scripts/prepare-assets.sh` copies them to +`public/music/`, and `src/music.ts` picks one: its file, title, artist, tempo +and the downbeat the tour starts on. The end card credits the track named +there: "Music: by <artist> (CC0), from ZyperOS's menu music." + +The tour plays **Leap by nene**, picked by Tarik. It is exactly 156 BPM, a +104-bar loop of 160.0 s whose downbeats start 0.015 s in. Its sections, in bars +of 1.54 s (a fill on every fourth bar): the full band 0 to 15, a break 16 to 19, +the band 20 to 23, a break 24 to 27, the band 28 to 31, a bridge 32 and 33, a +steadier part 34 to 47 and a build 48 to 51, the opening theme again 52 to 67, +and a break from 68. The tour runs from bar 0 to two beats past bar 68, fading +over those two beats; `src/timeline.ts` lays its scenes on these sections. + +The tracks, measured with librosa (the tempo from a line through every beat; +the full band's first downbeat is approximate until a track is picked and its +start is checked by ear and on the beat grid): + +| Track | Artist | BPM | Beat grid from | Full band from | Length | +|---|---|---|---|---|---| +| Apple Cider | Zane Little Music | 100.0 | 0.034 s | ~18.6 s | 197 s | +| Bossa Nova | Joth | 129.0 | 0.278 s | the start | 60 s | +| Hush Hamlet | Zane Little Music | 75.0 | 0.041 s | ~19.2 s | 134 s | +| Ice Cave | TAD | 120.0 | 0.045 s | ~1.0 s | 292 s | +| Leap | nene | 156.0 | 0.015 s | the start | 160 s, a 104-bar loop | +| Napping on a Cloud | congusbongus | 162.0 | 0.212 s | ~23.2 s | 248 s | +| Scheatt | Spring Spring | 170.0 | 0.035 s | ~11.3 s | 282 s | +| Starfall Island | John Oestmann | 99.4 | 0.084 s | ~12.8 s | 139 s | +| The Arplands | LordZintick | 120.0 | 0.273 s | 16.273 s (its drums) | 144 s | +| Truth | HydroGene | 156.0 | 0.031 s | the start | 111 s | + +## Sound effects + +`public/audio/ui/*.wav` are the launcher's own interface sounds, `res/sounds/wood/`, the +default UI Sounds. `tools/gensounds.py` synthesizes them from the tones in +`tools/designdef.py`; they are ZyperOS's, under its license (GPL-3.0). The tour +plays them where a press is seen on the screen (`src/sounds.ts`). + +## Pictures + +- The footage is the launcher's own frames (`footage/takes.py`), and the device + is `tools/build-pixel2-model.py`'s model (`device/render-device.py`); the fonts + are `res/fonts` (Lexend, Noto Sans JP and SC: OFL), the lockups `res/readme`. +- The games' box art and screenshots on the sample card come from the + [libretro-thumbnails](https://github.com/libretro-thumbnails) sets, cached + outside the repository and never committed. The games and their art belong to + their owners; only the composed video and its posters are in the repository. diff --git a/video/README.md b/video/README.md new file mode 100644 index 0000000..bcc9e2d --- /dev/null +++ b/video/README.md @@ -0,0 +1,99 @@ +# The ZyperOS tour video + +A 105-second tour of ZyperOS for the README, zyperos.com and anyone who wants to show +it. Every screen in it is the launcher's own: the host build of ZyperOS +(`make native`) draws each frame, as the Pixel 2 would, on a sample library +with the games' real box art. [Remotion](https://www.remotion.dev) puts those +frames in a drawn GKD Pixel 2 beside the site's own words, on the beat of one of +ZyperOS's menu music tracks (`src/music.ts`; LICENSES.md has every track's +tempo and sections). + +## Making it + +```sh +make native # the launcher, for the host +python3 video/footage/takes.py # the footage: video/public/footage/*.mp4 +sh video/scripts/prepare-assets.sh # fonts, music, sounds, the device render (Blender, once) +cd video && npm install +npm run stills # out/contact-sheet.jpg: three stills a scene, to look at first +npx remotion render ZyperOSTour out/zyperos-tour-raw.mp4 +sh scripts/finish.sh # the master, site/zyperos-tour.mp4 and both posters +``` + +`npm run preview` opens Remotion Studio to scrub the timeline. + +## The footage + +`footage/takes.py` makes two kinds of take, both from `build-native/zyperos` +with no window and no change to the launcher: + +- **Live** takes press buttons. `rec/rec.c` is a small library loaded into the + launcher with `DYLD_INSERT_LIBRARIES`. It answers SDL's clock with the frames + the launcher has drawn, one display refresh each, writes every frame to + ffmpeg at 60 a second, and delivers a script of presses at their times. So the + motion in the clips is the launcher's own, frame for frame, however long the + software renderer took. It also gives the take a fixed time of day (10:08), a + battery level for the corner, and the menu music set up (silent), so MENU + shows the card's track and Zyper names it on the home. +- **Shots** are the launcher's `--shot` pictures, for screens the host cannot + reach by pressing buttons: the in-game menu over a game, Now Playing, the + charging screen, the first start, Storage on a full card. Where a shot takes a + time (`--tour`, `--charging`, `--nowplaying`), there is a picture every + sixtieth of a second, so it moves as on the handheld; otherwise each picture + is held, as a cut from one row to the next. + +`footage/card.py` builds the card the takes run on: the README's demonstration +library (`tools/readme-shots.py`), eight systems of well-known games as empty +files, each with its cover from the +[libretro-thumbnails](https://github.com/libretro-thumbnails) sets, the same as +the Scraper would fetch. The covers are cached in +`~/Library/Caches/zyperos-readme-art` and never committed: the games and their +art belong to their owners. + +## A real video snap + +The video snap beat plays one input file. By default it is Spyro's libretro +snap clip from the art cache, which is a still picture. To use a real +ScreenScraper snap (`Roms/<system>/.media/<game>.mp4` on a card), name it and +its game on the demonstration card, then render again: + +```sh +SNAP_SOURCE=/path/to/snap.mp4 SNAP_GAME='PS1|Castlevania - Symphony of the Night (USA).chd' \ + SNAP_START=2 python3 video/footage/takes.py snap +cd video && npx remotion render ZyperOSTour out/zyperos-tour-raw.mp4 --crf=6 && sh scripts/finish.sh +``` + +`SNAP_GAME` is the system's tag and the file on the card (`video/footage/card.py` +lists them), `SNAP_START` where in the clip to start, in seconds. The snap plays +from the moment the cover turns, at its own rate. The tour as published uses +Symphony of the Night's ScreenScraper snap from Tarik's card, from 2 s in; the +clip itself stays outside the repository. + +## Layout + +- `src/music.ts`: the track, its tempo and the downbeat the tour starts on. +- `src/timeline.ts`: the one place for timing. A list of scenes, each whole + bars of the music, laid on its sections: which takes its screen + plays and how fast, the device's pose and any punch-in, how it arrives (a whip + or a spring), its label, and the browser windows of File Transfer. Change a + scene's beats or its takes there and render again. +- `src/Tour.tsx`, `src/parts.tsx`: the composition, the device and its camera, + the screen, the labels, the laptop and phone windows, the light and dark pair + and the language grid. `src/sounds.ts` plays the launcher's interface sounds + where a press is seen. +- `src/beats.tsx`: the boot race against stock firmware (a plain loading panel, + nothing of its interface) and the power off and on, with their timers. +- `rec/snapsub.c`: for the video snap beat, gives the host build's `--snap` turn a + frame of the game's snap in place of its test picture (no Muse on the host). +- `src/Poster.tsx`: the site's poster, and the README's with a play button. +- `device/render-device.py`: the Pixel 2 straight on, from + `tools/build-pixel2-model.py`'s scene, with its screen cut out. +- `public/`: made by the scripts above, and ignored. `LICENSES.md` says where + each part comes from and under which license. + +The labels are short, from the site's own words (`site/index.html`). Cuts, +whips and labels land on the beat; the device does not pulse to it. + +To check the pace before a full render: `npm run stills` for the contact sheet, +and `npx remotion render ZyperOSTour out/excerpt.mp4 --frames=300-599` for ten +seconds. diff --git a/video/device/render-device.py b/video/device/render-device.py new file mode 100644 index 0000000..b7e9845 --- /dev/null +++ b/video/device/render-device.py @@ -0,0 +1,74 @@ +"""The tour video's Pixel 2: tools/build-pixel2-model.py's model, straight on, +with its screen cut out so the launcher's frames show through it. + +blender --background BLEND --python video/device/render-device.py -- OUT_DIR [WIDTH] + +BLEND is the editable scene tools/build-pixel2-model.py saves +(build/pixel2-model/pixel2.blend). Writes OUT_DIR/pixel2-front.png, the +product on a transparent ground in the site poster's studio light, and +OUT_DIR/pixel2-front.json, where its screen is in that picture, in pixels. +video/scripts/prepare-assets.sh runs it. +""" +import json +import sys +from pathlib import Path + +import bpy +from bpy_extras.object_utils import world_to_camera_view + +argv = sys.argv[sys.argv.index('--') + 1:] +out = Path(argv[0]) +width = int(argv[1]) if len(argv) > 1 else 2000 +out.mkdir(parents=True, exist_ok=True) + +scene = bpy.context.scene +root = bpy.data.objects['GKD Pixel 2'] +root.rotation_euler = (0, 0, 0) # straight on, still +camera = scene.camera +camera.location = (0, -220, 0) +camera.rotation_euler = (1.5707963, 0, 0) +camera.data.type = 'ORTHO' +camera.data.ortho_scale = 109 +scene.render.resolution_x = width +scene.render.resolution_y = round(width * 1.2) +scene.render.resolution_percentage = 100 +scene.render.film_transparent = True +scene.cycles.samples = 96 +scene.cycles.use_denoising = True +# The GPU when Blender has one (Metal on a Mac) +try: + prefs = bpy.context.preferences.addons['cycles'].preferences + prefs.compute_device_type = 'METAL' + prefs.get_devices() + for d in prefs.devices: + d.use = True + scene.cycles.device = 'GPU' +except Exception as e: # the CPU, then + print('render-device: CPU,', e) + +# The screen: a hole, so the picture under the product shows +screen = bpy.data.objects['Screen'] +hole = bpy.data.materials.new('Screen hole') +hole.use_nodes = True +nodes = hole.node_tree.nodes +nodes.clear() +holdout = nodes.new('ShaderNodeHoldout') +output = nodes.new('ShaderNodeOutputMaterial') +hole.node_tree.links.new(holdout.outputs[0], output.inputs['Surface']) +screen.data.materials.clear() +screen.data.materials.append(hole) + +bpy.context.view_layer.update() +w, h = scene.render.resolution_x, scene.render.resolution_y +pts = [] +for v in screen.data.vertices: + p = world_to_camera_view(scene, camera, screen.matrix_world @ v.co) + pts.append((p.x * w, (1 - p.y) * h)) +xs = [p[0] for p in pts] +ys = [p[1] for p in pts] +rect = {'image': 'pixel2-front.png', 'width': w, 'height': h, + 'screen': {'x': min(xs), 'y': min(ys), 'w': max(xs) - min(xs), 'h': max(ys) - min(ys)}} +(out / 'pixel2-front.json').write_text(json.dumps(rect, indent=2) + '\n') +scene.render.filepath = str(out / 'pixel2-front.png') +bpy.ops.render.render(write_still=True) +print('render-device:', rect) diff --git a/video/footage/card.py b/video/footage/card.py new file mode 100644 index 0000000..2009a5b --- /dev/null +++ b/video/footage/card.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""The tour video's card: a demonstration library with the games' real box art. + +python3 video/footage/card.py CARD + +Builds a card at CARD the host launcher (build-native/zyperos) can run on: +the README's demonstration library (tools/readme-shots.py), eight systems of +well-known games as empty files, each with its cover from the +libretro-thumbnails sets (github.com/libretro-thumbnails), as the launcher's +own Scraper would put it in the folder's .media. The covers are fetched once +into ~/Library/Caches/zyperos-readme-art, the same cache tools/readme-shots.py +--art uses, and never go in the repository: the games and their art belong to +their owners. + +The details of a few games (year, publisher, players, genre) are their public +facts; each synopsis is in my words. +""" +import importlib.util +from pathlib import Path +import shutil +import sqlite3 +import subprocess +import sys +import urllib.parse +import urllib.request + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location('readme_shots', ROOT / 'tools/readme-shots.py') +rs = importlib.util.module_from_spec(spec) +spec.loader.exec_module(rs) + +ART_CACHE = Path.home() / 'Library/Caches/zyperos-readme-art' + +# folder: [(the file on the card, libretro's name)] +LIBRARY = { + 'PlayStation': [ + ('Castlevania - Symphony of the Night (USA).chd', 'Castlevania - Symphony of the Night (USA)'), + ('Crash Bandicoot (USA).chd', 'Crash Bandicoot (USA)'), + ('Metal Gear Solid (USA).chd', 'Metal Gear Solid (USA)'), + ('Spyro the Dragon (USA).chd', 'Spyro the Dragon (USA)'), + ('Tekken 3 (USA).chd', 'Tekken 3 (USA)'), + ("Tony Hawk's Pro Skater 2 (USA).chd", "Tony Hawk's Pro Skater 2 (USA)")], + 'NES': [ + ("Kirby's Adventure (USA).nes", "Kirby's Adventure (USA)"), + ('Mega Man 2 (USA).nes', 'Mega Man 2 (USA)'), + ('Metroid (USA).nes', 'Metroid (USA)'), + ('Super Mario Bros. 3 (USA).nes', 'Super Mario Bros. 3 (USA)'), + ('The Legend of Zelda (USA).nes', 'Legend of Zelda, The (USA)')], + 'SNES': [ + ('Chrono Trigger (USA).sfc', 'Chrono Trigger (USA)'), + ('Donkey Kong Country (USA).sfc', 'Donkey Kong Country (USA)'), + ('Super Mario World (USA).sfc', 'Super Mario World (USA)'), + ('Super Metroid (USA).sfc', 'Super Metroid (Japan, USA) (En,Ja)'), + ('The Legend of Zelda - A Link to the Past (USA).sfc', 'Legend of Zelda, The - A Link to the Past (USA)')], + 'Game Boy': [ + ('Pokemon - Red Version (USA, Europe).gb', 'Pokemon - Red Version (USA, Europe) (SGB Enhanced)'), + ('Super Mario Land (World).gb', 'Super Mario Land (World)'), + ('Tetris (World).gb', 'Tetris (World) (Rev 1)'), + ("The Legend of Zelda - Link's Awakening (USA, Europe).gb", "Legend of Zelda, The - Link's Awakening (USA, Europe)")], + 'Game Boy Advance': [ + ('Advance Wars (USA).gba', 'Advance Wars (USA)'), + ('Mario Kart - Super Circuit (USA).gba', 'Mario Kart - Super Circuit (USA)'), + ('Metroid Fusion (USA).gba', 'Metroid Fusion (USA)'), + ('Pokemon - Emerald Version (USA, Europe).gba', 'Pokemon - Emerald Version (USA, Europe)'), + ('The Legend of Zelda - The Minish Cap (USA).gba', 'Legend of Zelda, The - The Minish Cap (USA)')], + 'Genesis': [ + ('Gunstar Heroes (USA).md', 'Gunstar Heroes (USA)'), + ('Sonic The Hedgehog (USA).md', 'Sonic The Hedgehog (USA)'), + ('Sonic The Hedgehog 2 (USA).md', 'Sonic The Hedgehog 2 (USA)'), + ('Streets of Rage 2 (USA).md', 'Streets of Rage 2 (USA)')], + 'Nintendo 64': [ + ('GoldenEye 007 (USA).z64', 'GoldenEye 007 (USA)'), + ('Mario Kart 64 (USA).z64', 'Mario Kart 64 (USA)'), + ('Super Mario 64 (USA).z64', 'Super Mario 64 (USA)'), + ('The Legend of Zelda - Ocarina of Time (USA).z64', 'Legend of Zelda, The - Ocarina of Time (USA)')], + 'Nintendo DS': [ + ('Mario Kart DS (USA).nds', 'Mario Kart DS (USA) (En,Fr,De,Es,It)')], + 'Dreamcast': [ + ('Crazy Taxi (USA).chd', 'Crazy Taxi (USA)'), + ('Jet Grind Radio (USA).chd', 'Jet Grind Radio (USA)'), + ('Sonic Adventure (USA).chd', 'Sonic Adventure (USA)'), + ('Soulcalibur (USA).chd', 'Soulcalibur (USA)')], +} +LR_REPO = {'PlayStation': 'Sony_-_PlayStation', 'NES': 'Nintendo_-_Nintendo_Entertainment_System', + 'SNES': 'Nintendo_-_Super_Nintendo_Entertainment_System', 'Game Boy': 'Nintendo_-_Game_Boy', + 'Game Boy Advance': 'Nintendo_-_Game_Boy_Advance', 'Genesis': 'Sega_-_Mega_Drive_-_Genesis', + 'Nintendo 64': 'Nintendo_-_Nintendo_64', 'Dreamcast': 'Sega_-_Dreamcast', + 'Nintendo DS': 'Nintendo_-_Nintendo_DS'} + +# The Cover and Video page's pictures (src/main.c's media_shot names them): +# Metal Gear Solid's box from several regions, its title screen and screenshots +MEDIA_REAL = [ + ('now.png', 'Named_Boxarts', 'Metal Gear Solid (USA)'), + ('c0.png', 'Named_Boxarts', 'Metal Gear Solid (USA) (Disc 1)'), + ('c1.png', 'Named_Boxarts', 'Metal Gear Solid (Japan) (Disc 1) (Ichi)'), + ('c2.png', 'Named_Boxarts', 'Metal Gear Solid (Germany) (Disc 1)'), + ('c3.png', 'Named_Boxarts', 'Metal Gear Solid (Spain) (Disc 1)'), + ('c4.png', 'Named_Boxarts', 'Metal Gear Solid (Japan, Asia) (Disc 1) (Ichi) (Premium Package)'), + ('c5.png', 'Named_Titles', 'Metal Gear Solid (USA)'), + ('c6.png', 'Named_Snaps', 'Metal Gear Solid (USA)'), + ('c7.png', 'Named_Boxarts', 'Metal Gear Solid (USA)'), + ('c8.png', 'Named_Titles', 'Metal Gear Solid (Japan) (Disc 1) (Ichi)'), + # the plain (USA) snap: the Disc 1 and Disc 2 ones have Spanish subtitles + ('c9.png', 'Named_Titles', 'Metal Gear Solid (USA)'), + ('c10.png', 'Named_Boxarts', 'Metal Gear Solid (Spain) (Disc 1)'), + ('snap.png', 'Named_Snaps', 'Metal Gear Solid (USA)'), +] + +# Public facts, and a synopsis in my words: folder, file, year, publisher, +# developer, players, genres, synopsis +DETAILS = [ + ('PlayStation', 'Metal Gear Solid (USA).chd', '1998', 'Konami', 'KCEJ', '1', 'Action', + 'Solid Snake slips into a nuclear weapons base on a snowbound Alaskan island, ' + 'alone, to stop a rogue unit that threatens to launch.'), + ('PlayStation', 'Castlevania - Symphony of the Night (USA).chd', '1997', 'Konami', 'KCET', '1', 'Action', + "Alucard wakes to find Dracula's castle risen again, and explores it room by room, " + 'growing stronger with every weapon and spell he finds.'), + ('PlayStation', 'Crash Bandicoot (USA).chd', '1996', 'Sony Computer Entertainment', 'Naughty Dog', '1', 'Platform', + 'Crash runs, spins and jumps across three islands to rescue Tawna from Doctor Neo Cortex.'), + ('SNES', 'Super Metroid (USA).sfc', '1994', 'Nintendo', 'Nintendo R&D1', '1', 'Action', + 'Samus returns to planet Zebes to take back the last Metroid, ' + 'and every new power opens another part of the map.'), + ('Nintendo 64', 'Super Mario 64 (USA).z64', '1996', 'Nintendo', 'Nintendo EAD', '1', 'Platform', + "Mario jumps into the paintings of Peach's castle to find the Power Stars Bowser took."), +] + +# A favorite on a few shelves, so the heart shows before its name +FAVORITES = [('PS1', 'Metal Gear Solid (USA).chd'), ('PS1', 'Crash Bandicoot (USA).chd'), + ('SFC', 'Super Metroid (USA).sfc'), ('N64', 'Super Mario 64 (USA).z64')] + + +def art_file(cache, repo, kind, name): + """One picture of a libretro-thumbnails set, from the cache, or fetched + into it from GitHub the first time. libretro files a name's & * / : ` < > + ? \\ | as _, and a set's link to another picture comes back as that + picture's name, which is followed.""" + path = cache / repo / kind / (name + '.png') + if path.is_file(): + return path + safe = ''.join('_' if c in '&*/:`<>?\\|' else c for c in name) + url = f'https://raw.githubusercontent.com/libretro-thumbnails/{repo}/master/{kind}/' + urllib.parse.quote(safe + '.png') + for _ in range(4): + with urllib.request.urlopen(url, timeout=60) as reply: + data = reply.read() + if data.startswith(b'\x89PNG'): + break + url = urllib.parse.urljoin(url, urllib.parse.quote(data.decode().strip())) + else: + raise RuntimeError(f'{repo}/{kind}/{name}: no picture') + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + +def build(card, cache=ART_CACHE): + if card.exists(): + shutil.rmtree(card) + rs.card_dirs(card) + # The version System Update starts from (the one before this release) + (card / 'ZyperOS/VERSION').write_text('1.0.1\n') + for folder, games in LIBRARY.items(): + d = card / 'Roms' / folder + (d / '.media').mkdir(parents=True) + for file, lr in games: + (d / file).touch() + got = art_file(cache, LR_REPO[folder], 'Named_Boxarts', lr) + shutil.copyfile(got, d / '.media' / (Path(file).stem + '.png')) + # The DS game's screenshot, behind its in-game menu + art_file(cache, LR_REPO['Nintendo DS'], 'Named_Snaps', LIBRARY['Nintendo DS'][0][1]) + rs.pm_art(card) + media = card / 'media-art' + media.mkdir() + for name, kind, lr in MEDIA_REAL: + shutil.copyfile(art_file(cache, LR_REPO['PlayStation'], kind, lr), media / name) + badges = card / 'Roms/PlayStation/.cheevos/badges' + badges.mkdir(parents=True) + for i, color in enumerate(rs.BADGE_COLORS): + rs.stand_in_badge(badges / f'b{i + 1}.png', color) + dbdir = card / 'shared/.zyperos' + dbdir.mkdir(exist_ok=True) + with sqlite3.connect(dbdir / 'library.db') as db: + db.execute('CREATE TABLE games(folder TEXT NOT NULL,file TEXT NOT NULL,year TEXT,publisher TEXT,' + 'developer TEXT,players TEXT,genres TEXT,esrb TEXT,note TEXT,synopsis TEXT,scraped INTEGER,' + 'PRIMARY KEY(folder,file))') + for folder, file, year, pub, dev, players, genres, syn in DETAILS: + db.execute('INSERT INTO games VALUES(?,?,?,?,?,?,?,?,?,?,?)', + (folder, file, year, pub, dev, players, genres, '', '', syn, 0)) + db.execute('CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY NOT NULL,value TEXT NOT NULL)') + for tag, file in FAVORITES: + db.execute('INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)', (f'fav.{tag}\t{file}', '1')) + for album in ['First Light', 'Night Drive', 'Quiet Hours']: + d = card / 'Music/Demo Library' / album + d.mkdir(parents=True) + for track in ['01 First Light.mp3', '02 Open Road.mp3', '03 Home Again.mp3']: + (d / track).touch() + (card / 'Videos').mkdir() + (card / 'Videos/Sintel.mp4').touch() + book = card / 'Audiobooks/Treasure Island' + book.mkdir(parents=True) + (book / '01 The Old Sea-dog at the Admiral Benbow.mp3').touch() + # ZyperOS's own menu music, the one track the video plays too, so the + # song Zyper names on the home is the one heard + (card / 'ZyperOS/res/bgm').mkdir(parents=True) + shutil.copyfile(ROOT / 'res/bgm/The Arplands.mp3', card / 'ZyperOS/res/bgm/The Arplands.mp3') + shutil.copytree(ROOT / 'res/tour', card / 'ZyperOS/res/tour') + up = card / 'userdata/update' + up.mkdir(parents=True) + # 1.0.1 offered 1.0.2, as it was (83 MB, the two-slot download's size) + (up / 'status').write_text('state available\ninstalled 1.0.1\nversion 1.0.2\ndownload 83000000\nkind system\n') + (up / 'source').write_text('file:///nonexistent\n') + # Ports: PortMaster's installer, for the Ports shelf's offer + (card / 'Roms/Ports').mkdir(exist_ok=True) + shutil.copyfile(ROOT / 'res/portmaster/pm-install.py', card / 'ZyperOS/portmaster/pm-install.py') + return card + + +def env_for(card): + env = rs.env_for(card) + # No sound from the computer, and the launcher's clock zone for REC_CLOCK + env.update(SDL_AUDIODRIVER='dummy', SDL_AUDIO_DRIVER='dummy', TZ='America/New_York') + return env + + +def device_settings(card, binary, **kv): + """The device's settings (userdata/zyperos.db), after the launcher made it""" + subprocess.run([str(binary), '--dump'], cwd=ROOT, env=env_for(card), check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + with sqlite3.connect(card / 'userdata/zyperos.db') as db: + for k, v in kv.items(): + db.execute('INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)', (k, str(v))) + + +if __name__ == '__main__': + target = Path(sys.argv[1]).resolve() + build(target) + device_settings(target, ROOT / 'build-native/zyperos', **{'tour.done': 1, 'clock': '24h'}) + print(target) diff --git a/video/footage/takes.py b/video/footage/takes.py new file mode 100644 index 0000000..384c835 --- /dev/null +++ b/video/footage/takes.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""The tour video's footage: the host launcher's own frames, as video clips. + +make native +python3 video/footage/takes.py [TAKE ...] + +A "live" take runs build-native/zyperos on the card video/footage/card.py +builds, headless, with video/rec/rec.dylib loaded: a script of button presses +drives it, and every frame it draws goes into video/public/footage/TAKE.mp4 at +60 frames a second, on the launcher's own clock (video/rec/rec.c says how). + +A "shots" take is the launcher's own --shot pictures, for what the host cannot +reach by pressing buttons (a game's in-game menu, Now Playing, the charging +screen, the first start): a screen's motion where the shot takes a time (the +first start's hello, the charging screen, a song name sliding), a frame for +each sixtieth of a second, and otherwise one picture held, as a cut from one +row to the next. They go in the same kind of clip. + +TAKE.json beside each clip has its length and, for a live take, its presses. +Nothing here changes the launcher, and no window opens. +""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +sys.path.insert(0, str(HERE)) +import card as cardlib # noqa: E402 + +BINARY = ROOT / 'build-native/zyperos' +REC = ROOT / 'video/rec/rec.dylib' +OUT = ROOT / 'video/public/footage' +WORK = Path(os.environ.get('VIDEO_WORK', Path(tempfile.gettempdir()) / 'zyperos-video')) +FPS = 60 +# The corner on the host: no radio, so no Wi-Fi mark, in the shots too +STATUS = 'batt=82' +SHOT_STATUS = '10:08 24h batt=82' +CLOCK = '10:08' +MGS = 'Metal Gear Solid (USA).chd' + +TAKES = {} + + +def keys(steps, start=700, gap=420): + """Presses one after another: a step is a key name, (key, hold ms), or a + pause in ms (a number)""" + out, t = [], start + for s in steps: + if isinstance(s, (int, float)): + t += s + continue + k, hold = (s if isinstance(s, tuple) else (s, 90)) + out.append((t, k, hold)) + t += gap if hold <= 90 else hold + 200 + return out, t + + +# Zyper names the menu music's song on the home as a take starts; the presses +# wait until his pop-up has gone, but on the home take, which shows it +LEAD = 2600 + + +def live(name, last, steps, tail=900, settings=None, start=LEAD, gap=420): + script, t = keys(steps, start=start, gap=gap) + TAKES[name] = dict(kind='live', last=last, settings=settings or {}, script=script, end=t + tail) + + +def shots(name, segments, card='demo', env=None): + """segments: (last, args, hold ms) a still, or (last, args with {t}, [ms, ...]) a motion""" + TAKES[name] = dict(kind='shots', card=card, segments=segments, env=env or {}) + + +def motion(t0, t1): + """The times of a motion from t0 to t1 ms, a sixtieth of a second apart""" + return [round(t0 + i * 1000 / FPS) for i in range(int((t1 - t0) * FPS / 1000) + 1)] + + +PS1 = ('PS1', MGS) + +# ---- live --------------------------------------------------------------- +# The home moving between systems, with the consoles' own animation +live('home', PS1, start=700, steps=[1500, 'left', 800, 'left', 800, 'right', 700, 'right', 700, 'right', 800, 'right', 1400]) +# The same home as a Gallery +live('gallery', PS1, [1100, 'right', 700, 'right', 700, 'right', 1200], settings={'home': 'gallery'}) +# The PlayStation shelf: in, along the covers and back +live('shelf', ('PS1', 'Crash Bandicoot (USA).chd'), + [1000, 'a', 1400, 'right', 750, 'right', 750, 'right', 1100, 'left', 700, 'left', 1400]) +# MENU > Search Games: "son", three Sonics, and the second one started +live('search', PS1, + [800, 'menu', 700, 'a', 900, 'down', 'right', 'a', 'up'] + ['right'] * 7 + + ['a', 'down', 'down', 'left', 'left', 'left', 'a', 700, 'start', 1300, 'down', 450, 'down', 700, 'a', 1800], + gap=240) +# X on Metal Gear Solid: its details +live('details', PS1, [800, 'a', 1100, 'x', 2200, 'down', 600, 'down', 600, 'down', 600, 'down', 600, 'down', 1500, 'b', 1000]) +# MENU > Appearance to Dark, then the home and a shelf in the dark +live('dark', PS1, + [800, 'menu', 600] + ['down'] * 16 + [700, 'a', 1500, 'b', 1200, 'right', 900, 'left', 900, 'a', 1300, + 'right', 750, 'right', 1400], gap=150, settings={'appearance': 'light'}) +# MENU > Language: 日本語, and the menu in it +live('language', PS1, + [800, 'menu', 600] + ['down'] * 11 + [700, 'a', 1100] + ['down', 300] * 6 + [700, 'a', 2400, 'b', 1200], + gap=170, settings={'language': 'en'}) + +# ---- shots ---------------------------------------------------------------- +GEN = ('MD', 'Sonic The Hedgehog 2 (USA).md') +SONIC_SNAP = '{art}/Sega_-_Mega_Drive_-_Genesis/Named_Snaps/Sonic The Hedgehog 2 (World).png' +# The in-game menu over Sonic 2, a row at a time, then its Controls and Hotkeys +shots('ingame', [(GEN, ['--screen', 'games', '--gamemenu', str(r), '--backdrop', SONIC_SNAP], 330) for r in range(6)] + + [(GEN, ['--screen', 'games', '--gamecontrols', '0', 'MD', '--backdrop', SONIC_SNAP], 1700), + (GEN, ['--screen', 'games', '--gamecontrols', '2', 'MD', '--backdrop', SONIC_SNAP], 1700)]) +# PlayStation's buttons drawn as their shapes +shots('controls-ps1', [(PS1, ['--screen', 'games', '--gamecontrols', '0', 'PS1', + '--backdrop', '{card}/media-art/c6.png'], 2200)]) +# Cover and Video: what the sources offer, focus moving, one looked at closely +shots('media', [(PS1, ['--screen', 'games', '--media', 'page', str(k), '--media-art', '{card}/media-art'], 420) + for k in range(0, 5)] + + [(PS1, ['--screen', 'games', '--media', 'look', '4', '--media-art', '{card}/media-art'], 1500)]) +# MENU > Storage, its PlayStation page and its Music page +shots('storage', [(PS1, ['--storage', 'main'], 2200), (PS1, ['--storage', 'system'], 2000), + (PS1, ['--storage', 'music'], 1800)]) +# Achievements: the list, then an unlock with Zyper +shots('cheevos', [(PS1, ['--cheevos-screen', str(r), '--cheevos-badge', '{card}/Roms/PlayStation/.cheevos/Hagane.set', 'b#'], 330) + for r in range(4)] + + [(PS1, ['--notice-kind', 'unlock', '--notice', 'Violated Heavens', 'Unlocked · 10 points', + '--notice-badge', '{card}/Roms/PlayStation/.cheevos/badges/b3.png'], 4700)]) +# MP4: albums, the film and the book on one shelf; Now Playing; a video's controls +shots('mp4', [(('MUSE', 'Videos'), ['--screen', 'games'], 2500), + (PS1, ['--nowplaying', '0', '0', '{t}'], [f'{83 + t / 1000:.3f}' for t in motion(0, 3500)])]) +# Ports: PortMaster offered, installing, and a port paused under the in-game menu +shots('ports', [(('PRT', 'PortMaster.sh'), ['--screen', 'games'], 2000), + (PS1, ['--portmaster', 'running'], 2000)]) +shots('portmenu', [(('PRT', 'Star Farm.sh'), ['--screen', 'games', '--gamemenu', '0', 'port'], 2000), + (('PRT', 'Star Farm.sh'), ['--screen', 'games', '--gamecontrols', '0', 'PRT'], 2000)], + card='portdocs') +# The first start: Zyper says hello in each language +shots('firststart', [(PS1, ['--tour', 'lang', '{t}'], motion(400, 7000)), + (PS1, ['--tour', 'welcome', '{t}'], motion(0, 2200))]) +# The charging screen of a handheld that is off, and MENU > Battery Icon's Time Left +shots('battery', [(PS1, ['--charging', '{t}'], motion(0, 3000)), + (PS1, ['--menu', 'Battery Icon', '--status', '10:08 24h batt=62 eta=205 icon=time'], 3000)]) +# A song's name too long for the top bar, sliding +shots('topbar', [(PS1, ['--home', 'spotlight', '--song', 'Night Drive (Extended Mix) - The Midnight Rider Collective', + '--phase', '{t}'], motion(0, 4200))]) +# Zyper cheering the first games on an empty card +# Zyper's cheer for the first games: in, held at its height so it can be read, +# and out (the launcher's own frames, the middle one held) +shots('cheer', [(PS1, ['--cheer', '{t}'], motion(0, 700)), (PS1, ['--cheer', '700'], 1300), + (PS1, ['--cheer', '{t}'], motion(700, 1900)), (PS1, ['--cheer', '1900'], 400)]) +# The empty card: the two ways to add games +shots('empty', [(('', ''), ['--ways', 'both'], 3200)], card='empty') + +# ---- v2: quicker takes and the features v1 left out ------------------------ +# The PlayStation shelf flown along, then N64's and SNES's (the opening) +live('fast-shelf', ('PS1', 'Castlevania - Symphony of the Night (USA).chd'), + ['a', 700] + ['right'] * 5 + [500] + ['left'] * 5 + [400, 'b', 500, 'left', 600, 'a', 700] + ['right'] * 3 + + [400, 'b', 500, 'right', 300, 'right', 600, 'a', 700] + ['right'] * 3 + [600], gap=260) +# The Scraper fetching covers: its states (src/main.c, --scraper) +shots('scraper', [(PS1, ['--scraper', str(k)], 600) for k in range(6)] + + [(PS1, ['--art', 'Super Metroid (USA)'], 1400)]) +# SELECT+R1: the quick save's pop-up over the game, and the load +shots('quicksave', [(GEN, ['--screen', 'games', '--popup', 'saved', '--backdrop', SONIC_SNAP], 1400), + (GEN, ['--screen', 'games', '--popup', 'loaded', '--backdrop', SONIC_SNAP], 1400)]) +# File Transfer: over Wi-Fi, a computer on the cable, and over the cable +shots('wifi-xfer', [(PS1, ['--hare', 'http://192.168.1.42', '1 connected', 'Tobu Tobu Girl.gb'], 5200)]) +shots('cable', [(PS1, ['--usb-ask', '0'], 900), (PS1, ['--usb-ask', '1'], 900), + (PS1, ['--hare', 'http://10.42.0.1', '1 connected', 'Super Metroid (USA).sfc'], 2200)]) +shots('hotspot', [(PS1, ['--hotspot', 'ZyperOS-7A2B', 'q7mx-3kpa', '1 joined', 'waiting'], 2800)]) +# Files still moving while a game plays: the ring in the in-game menu's corner, over Sonic 2 +shots('xfer-ring', [(GEN, ['--screen', 'games', '--gamemenu', '0', '--backdrop', SONIC_SNAP, + '--status', f'10:08 24h batt=82 xfer={p}'], 80) for p in range(30, 92, 2)]) +# System Update +shots('update', [(PS1, ['--update'], 2400)]) +# Battery: held at the charge limit, charging toward it, MENU > Charge Limit +shots('charge', [(PS1, ['--charging', '{t}', 'kept'], motion(0, 1600)), + (PS1, ['--charging', '{t}', 'limit'], motion(0, 1200))]) +# MP4's lock screen +shots('lock', [(PS1, ['--nowplaying', '0', '0', '83', '--locked'], 1800)]) +# Nintendo DS: the in-game menu's screen rows, and N64's shelf +shots('ds', [(('NDS', 'Mario Kart DS (USA).nds'), ['--screen', 'games', '--gamemenu', str(r), 'dual', + '--backdrop', '{art}/Nintendo_-_Nintendo_DS/Named_Snaps/Mario Kart DS (USA) (En,Fr,De,Es,It).png'], 500) + for r in (8, 9, 10)] + + [(('NDS', 'Mario Kart DS (USA).nds'), ['--screen', 'games', '--popup', 'dslang', + '--backdrop', '{art}/Nintendo_-_Nintendo_DS/Named_Snaps/Mario Kart DS (USA) (En,Fr,De,Es,It).png'], 1400)]) +shots('n64', [(('N64', 'Super Mario 64 (USA).z64'), ['--screen', 'games'], 1500)]) +# Light and dark, the same home; MENU in each of the eight languages +shots('home-light', [(PS1, ['--home', 'spotlight', '--phase', '3200'], 100)]) +shots('home-dark', [(PS1, ['--home', 'spotlight', '--phase', '3200', '--theme', 'dark'], 100)]) +shots('shelf-dark', [(PS1, ['--screen', 'games', '--theme', 'dark'], 100)]) +# v3: the power-off animation, and a video snap: the bar under the resting +# cover fills (DESIGN.md 11.19), then the cover turns into the snap. The host +# has no Muse to decode it, so rec/snapsub.c gives --snap's turn a frame of +# Spyro's snap (its libretro screenshot) in place of the test picture. It +# rests 600 ms first, so the video can start the wait where its beat needs. +shots('poweroff', [(PS1, ['--screen', 'games', '--poweroff', '--shot-at', '{t}'], motion(0, 1600))]) +# The snap: SNAP_SOURCE, a video snap (.mp4, as ScreenScraper's on a card) or a +# picture, of SNAP_GAME ("TAG|file on the card"), Spyro's libretro snap clip +# by default. Its frames go to snapsub.dylib one a shot, at its own rate. +SNAP_GAME = tuple(os.environ.get('SNAP_GAME', 'PS1|Spyro the Dragon (USA).chd').split('|')) +SNAP_SOURCE = os.environ.get('SNAP_SOURCE', str(cardlib.ART_CACHE / 'Sony_-_PlayStation/Videos/Spyro the Dragon (USA).mp4')) +SNAP_FPS = 30 +# Where in the snap the beat starts, in seconds: a lively stretch of a long clip +SNAP_START = os.environ.get('SNAP_START', '0') +SNAP_ENV = {'SNAP_I420': '{work}/snap.yuv', 'DYLD_INSERT_LIBRARIES': str(ROOT / 'video/rec/snapsub.dylib')} +shots('snap', [(SNAP_GAME, ['--screen', 'games', '--snap-wait', '0'], 600), + (SNAP_GAME, ['--screen', 'games', '--snap-wait', '{t}'], [f'{t / 1000:.4f}' for t in motion(0, 1000)]), + (SNAP_GAME, ['--screen', 'games', '--snap', '{t}', '--snap-frame', '{i}'], [f'{t / 300:.4f}' for t in motion(0, 300)]), + (SNAP_GAME, ['--screen', 'games', '--snap', '1', '--snap-frame', '{i}'], motion(300, 3000))], + card='snaps', env=SNAP_ENV) +for code in ['en', 'zh-Hans', 'es', 'pt-BR', 'fr', 'de', 'ja', 'it']: + shots(f'lang-{code}', [(PS1, ['--lang', code, '--menu', 'Language'], 100)]) + + +# ---- running them -------------------------------------------------------------- +def write_last(card, tag, game): + (card / 'ZyperOS/.last').write_text(f'{tag}\n{game}\n') + + +def run_live(name, spec, card): + write_last(card, *spec['last']) + cardlib.device_settings(card, BINARY, **{'tour.done': 1, 'clock': '24h', 'home': 'spotlight', + 'appearance': 'light', 'language': 'en', **spec['settings']}) + script = WORK / f'{name}.keys' + lines = [f'{t} {k} {h}' for t, k, h in spec['script']] + [f"{spec['end']} end"] + script.write_text('# made by video/footage/takes.py\n' + '\n'.join(lines) + '\n') + (WORK / 'batt').write_text('82\n') + env = cardlib.env_for(card) + env.update(REC_OUT=str(OUT / f'{name}.mp4'), REC_SCRIPT=str(script), REC_CLOCK=CLOCK, + REC_BATT=str(WORK / 'batt'), REC_MENU_MUSIC='1', DYLD_INSERT_LIBRARIES=str(REC)) + log = WORK / f'{name}.log' + with open(log, 'w') as f: + r = subprocess.run([str(BINARY), '--status', STATUS], cwd=ROOT, env=env, + stdout=f, stderr=subprocess.STDOUT, timeout=600) + if r.returncode: + raise RuntimeError(f'{name}: exit {r.returncode}, see {log}') + (OUT / f'{name}.json').write_text(json.dumps({'fps': FPS, 'ms': spec['end'], + 'presses': spec['script']}) + '\n') + + +def run_shots(name, spec, cards): + card = cards[spec['card']] + # A live take before may have left another language or theme on the card + cardlib.device_settings(card, BINARY, **{'language': 'en', 'appearance': 'light', 'home': 'spotlight'}) + frames = WORK / name + if frames.exists(): + shutil.rmtree(frames) + frames.mkdir(parents=True) + listing, total, n = [], 0, 0 + # The first shot that shows the snap, for its frames' clock + first_turn = sum(len(w) if isinstance(w, list) else 1 for _, ar, w in spec['segments'] + if '--snap-frame' not in ar) if any('--snap-frame' in ar for _, ar, _ in spec['segments']) else 0 + env = cardlib.env_for(card) + env.update({k: v.replace('{work}', str(WORK)) for k, v in spec['env'].items()}) + for last, args, when in spec['segments']: + write_last(card, *last) + times = when if isinstance(when, list) else [None] + for t in times: + a = [x.replace('{t}', str(t)).replace('{card}', str(card)).replace('{art}', str(cardlib.ART_CACHE)) + for x in args] + # --snap-frame {i}: the snap's frame for this shot (snapsub.dylib), + # counted from the turn's start at the snap's own rate + if '--snap-frame' in a: + k = a.index('--snap-frame') + ms = (n - first_turn) * 1000 / FPS + env['SNAP_FRAME'] = str(int(ms * SNAP_FPS / 1000)) + del a[k:k + 2] + elif 'SNAP_FRAME' in env: + del env['SNAP_FRAME'] + status = [] if '--status' in a or '--tour' in a or '--charging' in a else ['--status', SHOT_STATUS] + png = frames / f'{n:05d}.png' + theme = [] if '--theme' in a else ['--theme', 'light'] + r = subprocess.run([str(BINARY), '--shot', str(png), *theme, '--fn-volume', *status, *a], + cwd=ROOT, env=env, capture_output=True, text=True) + if not png.is_file(): + raise RuntimeError(f'{name}: {a}: {r.stderr[-800:]}') + ms = 1000 / FPS if t is not None else when + listing.append((png, ms)) + total += ms + n += 1 + # The concat demuxer's durations, the last picture given twice so its own counts + lst = frames / 'list.txt' + lst.write_text(''.join(f"file '{p}'\nduration {ms / 1000:.6f}\n" for p, ms in listing) + + f"file '{listing[-1][0]}'\n") + subprocess.run(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', str(lst), + '-vf', f'fps={FPS},scale=out_color_matrix=bt709:out_range=tv', '-c:v', 'libx264', '-preset', 'fast', + '-crf', '10', '-pix_fmt', 'yuv420p', '-color_range', 'tv', '-colorspace', 'bt709', + '-color_primaries', 'bt709', '-color_trc', 'bt709', '-movflags', '+faststart', + str(OUT / f'{name}.mp4')], check=True) + (OUT / f'{name}.json').write_text(json.dumps({'fps': FPS, 'ms': round(total)}) + '\n') + # A take of one picture is used as a picture too + if n == 1: + shutil.copyfile(frames / '00000.png', OUT / f'{name}.png') + shutil.rmtree(frames) + + +def build_rec(): + flags = subprocess.check_output(['pkg-config', '--cflags', '--libs', 'sdl2'], text=True).split() + for name in ('rec', 'snapsub'): + src, lib = REC.parent / f'{name}.c', REC.parent / f'{name}.dylib' + if not lib.is_file() or lib.stat().st_mtime < src.stat().st_mtime: + subprocess.run(['cc', '-dynamiclib', '-O2', '-Wall', '-Wextra', '-o', str(lib), str(src), *flags], check=True) + + +def main(): + if not BINARY.is_file(): + sys.exit('run make native first') + build_rec() + OUT.mkdir(parents=True, exist_ok=True) + WORK.mkdir(parents=True, exist_ok=True) + only = sys.argv[1:] + unknown = [t for t in only if t not in TAKES] + if unknown: + sys.exit(f'no take called {", ".join(unknown)}; there are {", ".join(TAKES)}') + cards = {'demo': cardlib.build(WORK / 'card')} + cardlib.device_settings(cards['demo'], BINARY, **{'tour.done': 1, 'clock': '24h'}) + # The demonstration card with the snap's game holding it in its .media, + # for the play mark and the bar, and the snap's frames for snapsub.dylib + cards['snaps'] = cardlib.build(WORK / 'card-snaps') + cardlib.device_settings(cards['snaps'], BINARY, **{'tour.done': 1, 'clock': '24h'}) + folder = next(line.split('|')[2] for line in (ROOT / 'config/systems.cfg').read_text().splitlines() + if line.startswith('sys|') and line.split('|')[4] == SNAP_GAME[0]) + stem = Path(SNAP_GAME[1]).stem + media = cards['snaps'] / 'Roms' / folder / '.media' / (stem + '.mp4') + if SNAP_SOURCE.endswith('.mp4'): + shutil.copyfile(SNAP_SOURCE, media) + else: + media.touch() + subprocess.run(['ffmpeg', '-loglevel', 'error', '-y', '-ss', SNAP_START, '-i', SNAP_SOURCE, '-vf', f'fps={SNAP_FPS},scale=320:240', + '-pix_fmt', 'yuv420p', '-f', 'rawvideo', str(WORK / 'snap.yuv')], check=True) + cards['portdocs'] = WORK / 'card-portdocs' + cards['empty'] = WORK / 'card-empty' + for extra, make in (('portdocs', cardlib.rs.portdocs_card), ('empty', cardlib.rs.card_dirs)): + if cards[extra].exists(): + shutil.rmtree(cards[extra]) + make(cards[extra]) + cardlib.device_settings(cards[extra], BINARY, **{'tour.done': 1, 'clock': '24h'}) + for name, spec in TAKES.items(): + if only and name not in only: + continue + if spec['kind'] == 'live': + run_live(name, spec, cards['demo']) + else: + run_shots(name, spec, cards) + print(name, flush=True) + # Every take's length and presses in one file, for the video + index = {p.stem: json.loads(p.read_text()) for p in sorted(OUT.glob('*.json')) if p.stem != 'index'} + (OUT / 'index.json').write_text(json.dumps(index) + '\n') + + +if __name__ == '__main__': + main() diff --git a/video/package-lock.json b/video/package-lock.json new file mode 100644 index 0000000..0ed5a61 --- /dev/null +++ b/video/package-lock.json @@ -0,0 +1,3869 @@ +{ + "name": "zyperos-tour-video", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zyperos-tour-video", + "version": "0.1.0", + "dependencies": { + "@remotion/bundler": "4.0.529", + "@remotion/cli": "4.0.529", + "@remotion/renderer": "4.0.529", + "react": "18.3.1", + "react-dom": "18.3.1", + "remotion": "4.0.529" + }, + "devDependencies": { + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "typescript": "5.6.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.13.tgz", + "integrity": "sha512-j1nvOQCZYSgp7HLmisnCXFTpVvFODiijQ9QHkORfGKWH6YDtskTEeCJS7eV545GZ9UBzrq0e2xsOd5QQ5PjIqw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.13", + "@babel/helper-compilation-targets": "^7.29.13", + "@babel/helper-module-transforms": "^7.29.13", + "@babel/helpers": "^7.29.13", + "@babel/parser": "^7.29.13", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.13", + "@babel/types": "^7.29.11", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/@babel/parser": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz", + "integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.13.tgz", + "integrity": "sha512-T5FRo+RzVHvGjWOuF1Ljc44b+NXAOazodHkGUHp4I3zCkQ4/sNVJm56h1S278LMR0bY5xBhztGziSbli8zObIw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.13", + "@babel/types": "^7.29.11", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz", + "integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.13.tgz", + "integrity": "sha512-8WxJ4/Vmes3ghacj8VOJy3NW8eyHiFY/4thU/LSBuBE470+igjR/j1ZTNn8fV5VdNrLx6iXUwig526UdOvfP0w==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz", + "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.13.tgz", + "integrity": "sha512-/U96wOz+SCoZ7Da/8kjDGltcNY280JPQcRNPK0Hk3PTbdpaiBDQImtC6JsbVwb1HGMr3usZQU2WsWVjhYNmRzw==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.13" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.13.tgz", + "integrity": "sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.13.tgz", + "integrity": "sha512-laoRpD/kO/iA4CDlgJbixHekg7oPvWALK0PQyK3Ob2I2njZRvVAD77RfWCIImtqWCq6KijoBoZK7bp/HlTWASQ==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.11" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", + "integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==", + "license": "MIT", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/parser": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz", + "integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.13.tgz", + "integrity": "sha512-0ve0xMlFA73dgY1oyJRY6RoW+/bncdcOQqg6jjYcVfXBK90OdLXjm9R6lmRetdZVrPjXkKIoMVBqYFPjcAlZ4Q==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.13", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.13", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.11", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/parser": { + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz", + "integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediabunny/aac-encoder": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@mediabunny/aac-encoder/-/aac-encoder-1.56.1.tgz", + "integrity": "sha512-a+OY/BctP7zC4G/prHikooOVaoWXMD4+bUyEwHg2dtQiQdIwC6TDJdVYJu5dfrdSNOiAFkv1HKcYEeROdwMezg==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/flac-encoder": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@mediabunny/flac-encoder/-/flac-encoder-1.56.1.tgz", + "integrity": "sha512-zbmorMVlF1izwtlz5mWnu7A1rJLM+V26I0pEFr9Td1EDTwE5uAGRSW+/UZlnjwj1t00Woe3Zonfh9kb7lRRBqg==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/mp3-encoder": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@mediabunny/mp3-encoder/-/mp3-encoder-1.56.1.tgz", + "integrity": "sha512-p66ovEq6mZt3ejje45NOju84hIJI4v8rHfsmc1D2J34ziV0USE8AdJBzmiwqxl6nZkIAQLNB19HzbVRcQfc51g==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@module-federation/error-codes": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", + "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", + "license": "MIT" + }, + "node_modules/@module-federation/runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", + "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/runtime-core": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-core": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", + "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-tools": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", + "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/webpack-bundler-runtime": "0.22.0" + } + }, + "node_modules/@module-federation/sdk": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", + "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", + "license": "MIT" + }, + "node_modules/@module-federation/webpack-bundler-runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", + "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@remotion/bundler": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.529.tgz", + "integrity": "sha512-yrOoPWjwYz+E6B4uRub2L08VYZgo37T9fRaFqhtenrBFMVmR6JhftpXHyizOpQWRbeEuk1KLLo+M2duLhbi2qw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/media-parser": "4.0.529", + "@remotion/studio": "4.0.529", + "@remotion/studio-shared": "4.0.529", + "@remotion/timeline-utils": "4.0.529", + "@rspack/core": "1.7.11", + "@rspack/plugin-react-refresh": "1.6.1", + "css-loader": "7.1.4", + "esbuild": "0.28.1", + "react-refresh": "0.18.0", + "remotion": "4.0.529", + "style-loader": "4.0.0", + "webpack": "5.105.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/canvas": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/canvas/-/canvas-4.0.529.tgz", + "integrity": "sha512-zemhIIfjf+4jxwH20n+HDSEE/DxbO8HvMGdD+moozbk75E868uZWSSKNHY9EaFVi3i7hwkSv03XEktsRFLQl9A==", + "license": "Remotion License", + "dependencies": { + "@remotion/player": "4.0.529", + "remotion": "4.0.529" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/captions": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/captions/-/captions-4.0.529.tgz", + "integrity": "sha512-zXHY/FCqKqxOid60dDUVYWV/yRsIg+7F6/Qs4OMsouNqL6fC6jKwjA/+lsbimdxRUv/VUeaVVyNEufXfenQGNA==", + "license": "MIT" + }, + "node_modules/@remotion/cli": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/cli/-/cli-4.0.529.tgz", + "integrity": "sha512-1ONdF3q0adtw8FXW4CXeaSbzYV0vXIzJEm+b9mMrTDD9iyF+AKaLMzMUNespJaAvFxSxy/HV6hq17LS4UMfmiQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@babel/parser": "7.24.1", + "@remotion/bundler": "4.0.529", + "@remotion/media-utils": "4.0.529", + "@remotion/player": "4.0.529", + "@remotion/renderer": "4.0.529", + "@remotion/studio": "4.0.529", + "@remotion/studio-server": "4.0.529", + "@remotion/studio-shared": "4.0.529", + "dotenv": "17.3.1", + "minimist": "1.2.6", + "prompts": "2.4.2", + "remotion": "4.0.529", + "semver": "7.5.3" + }, + "bin": { + "remotion": "remotion-cli.js", + "remotionb": "remotionb-cli.js", + "remotiond": "remotiond-cli.js" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/codemods": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/codemods/-/codemods-4.0.529.tgz", + "integrity": "sha512-OfzvplLep4kpnxjZfnAPDTMWl+SDaudvrTpWizG7py58enZDpygoC3GD5TxqVcHi3KYja68k9vnJz/+DaAiJzw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "7.24.1", + "@babel/types": "7.24.0", + "@remotion/studio-protocol": "4.0.529", + "@remotion/studio-shared": "4.0.529", + "ast-types": "0.16.1", + "recast": "0.23.21", + "remotion": "4.0.529" + } + }, + "node_modules/@remotion/compositor-darwin-arm64": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.529.tgz", + "integrity": "sha512-uzCs1izkHlbQGvASFvnSSXodh0dfMIqaCwXX/4Zx6o+ML8MIiYfvoMXMH22bFAU6T5HYFKREhO+PKc/iDZKe4A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-darwin-x64": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.529.tgz", + "integrity": "sha512-5nPifOtkadthsrJTG4YGUR22HrAVML77zYoFKRWQGHboLcCik7UPZVy/X7yRu6HlGYuFULe0N7MKWude8jo2GA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-gnu": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.529.tgz", + "integrity": "sha512-hb1gOJPGiP588tTR2pfif6xkSvYOXh3qW2FHQOIRki97kj3EyANj/+0+naVroJhrpFpE6lDHz8BMDdd8Sc6MKg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-musl": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.529.tgz", + "integrity": "sha512-ZmGciox6hDUDIwiw2bq9KBtA6dK/y0ZNdsYpf/06rSZJMbWM3HhhLG0k+uBb/ELhQt8ZtuFBx8neN8qq5zi5/A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-gnu": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.529.tgz", + "integrity": "sha512-MSsX7ut2AOrTsgxCNPR9IulNkiMnZ4waQcUd43l8yePlsirTHVvOseB7fJtOFHDmjYJWYbbm3IcfaUWQJa68Zw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-musl": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.529.tgz", + "integrity": "sha512-Jada9IFuw4qY9YahXHVOgQy5tSRRLfTfaj3E5Xda7xJokju/hWUkBjUaGl6dtJmH+S+h19f5Tu2UH2J9M2v1gg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-win32-x64-msvc": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.529.tgz", + "integrity": "sha512-kyg7sG3bRdsKlcwDQ1yNMwDkAKpwlTtTrwbVWKcmii8LvHdMEDGPuteuQn6ULxwIAFqyzNkNAxhKrIjj9r8hZg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@remotion/licensing": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.529.tgz", + "integrity": "sha512-+hVV68ouCdjeNGKQOdDueH1OZej5biXGXLr9ADdiJr7MEl5l9mWPHa2Z9z45TwBHihMe5wKOQ5fmXHc+TBOjBg==", + "license": "MIT" + }, + "node_modules/@remotion/media": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/media/-/media-4.0.529.tgz", + "integrity": "sha512-ritB7TVRR3ZvYLNVCPwgZ33riUNn/B7IasroxLFXarMLZbz/9tHBbznO02Dth2ldkt643DTILUO/cBrnTtVWKg==", + "dependencies": { + "mediabunny": "1.56.1", + "remotion": "4.0.529", + "zod": "4.5.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/media-parser": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.529.tgz", + "integrity": "sha512-HGIm6dRtNRmGZbFZUy2/3EIWI0LW11l46qlAQs5fqQ6h24Cp+HqAfzNE05J75Hbv/2JYD+e1f5EPU/WKqykzzA==", + "license": "Remotion License https://remotion.dev/license" + }, + "node_modules/@remotion/media-utils": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.529.tgz", + "integrity": "sha512-0tYUgdRhKFSOsmGdBPFJc0TGDOz5ovyl0h984+azm9Y18w2OQwy/86Y/Vy+sB9nLRmoqn/wlaPuyH5do6iVCQw==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.56.1", + "remotion": "4.0.529" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/paths": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/paths/-/paths-4.0.529.tgz", + "integrity": "sha512-kTm3lehZkjpqLi+e1rdgsMoRulDVUTkZMOUFPu9tCZDDh+gVFqQcsuOnhYZlgxnSun8f8zvy9aP2khS3Oooq3Q==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.529" + } + }, + "node_modules/@remotion/player": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.529.tgz", + "integrity": "sha512-unUylvuwwByzIitVea5iQsBlFNqbZRElvoYVxXzeGa0jpiQRty9fYVxt3wQ97LkPEH1J1BmkSITLBWw+y/GF3w==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "remotion": "4.0.529" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/renderer": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.529.tgz", + "integrity": "sha512-2/jo+awVY0J6d0ZDanmIVa1ZvVurDitQwjq/iFNGHRTHNAvtu1jIuY1l6OXFW0FG49KU7/2/VdOCB6+wqbF9tQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/licensing": "4.0.529", + "@remotion/streaming": "4.0.529", + "execa": "5.1.1", + "remotion": "4.0.529", + "source-map": "0.8.0", + "ws": "8.21.0" + }, + "optionalDependencies": { + "@remotion/compositor-darwin-arm64": "4.0.529", + "@remotion/compositor-darwin-x64": "4.0.529", + "@remotion/compositor-linux-arm64-gnu": "4.0.529", + "@remotion/compositor-linux-arm64-musl": "4.0.529", + "@remotion/compositor-linux-x64-gnu": "4.0.529", + "@remotion/compositor-linux-x64-musl": "4.0.529", + "@remotion/compositor-win32-x64-msvc": "4.0.529" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/streaming": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.529.tgz", + "integrity": "sha512-jGY65mgj1o6giB3vBmcCkRE6xu1oxVks5ogpXje1ISj3bUaptT+81flCXS8K60bhCs7lwLk1jXETsmFZLT3tsA==", + "license": "MIT" + }, + "node_modules/@remotion/studio": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.529.tgz", + "integrity": "sha512-O+uL9nU8aM3xtPoTR7aFjuwipifwpAWi+RYKaWbL2/+G9bHUE+RHmQnp+3Y9az5VgXE5sR6Sh5tXSnGqPZhK2g==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@remotion/canvas": "4.0.529", + "@remotion/captions": "4.0.529", + "@remotion/codemods": "4.0.529", + "@remotion/media": "4.0.529", + "@remotion/media-utils": "4.0.529", + "@remotion/paths": "4.0.529", + "@remotion/player": "4.0.529", + "@remotion/renderer": "4.0.529", + "@remotion/studio-protocol": "4.0.529", + "@remotion/studio-shared": "4.0.529", + "@remotion/timeline-utils": "4.0.529", + "@remotion/web-renderer": "4.0.529", + "@remotion/zod-types": "4.0.529", + "@tanstack/react-virtual": "3.14.9", + "mediabunny": "1.56.1", + "memfs": "3.4.3", + "open": "8.4.2", + "prismjs": "1.30.0", + "remotion": "4.0.529", + "semver": "7.5.3", + "zod": "4.5.4" + }, + "peerDependencies": { + "@remotion/video-matting": "4.0.529", + "@remotion/whisper-webgpu": "4.0.529", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@remotion/video-matting": { + "optional": true + }, + "@remotion/whisper-webgpu": { + "optional": true + } + } + }, + "node_modules/@remotion/studio-protocol": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/studio-protocol/-/studio-protocol-4.0.529.tgz", + "integrity": "sha512-Sndjwx2xcPRD1d2YutpzALFP6sR0t1CR9VVQXopqmgjei7Ag6HlKKw8RKT8CXd63GdiNYm3mIBsw4MMWGyiWCQ==", + "license": "Remotion License", + "dependencies": { + "zod": "4.5.4" + } + }, + "node_modules/@remotion/studio-server": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/studio-server/-/studio-server-4.0.529.tgz", + "integrity": "sha512-7il0R2ynvtd2omdCXsn3ze4XSEplHp/g9l/1TvRaK8OTgeRwHE6Kf0ko0WqsE55nzEjsOxkOo4MALA7K2p/Q7g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "7.24.1", + "@babel/types": "7.24.0", + "@remotion/bundler": "4.0.529", + "@remotion/codemods": "4.0.529", + "@remotion/renderer": "4.0.529", + "@remotion/studio-protocol": "4.0.529", + "@remotion/studio-shared": "4.0.529", + "@svgr/core": "8.1.0", + "@svgr/plugin-jsx": "8.1.0", + "kiwi-schema": "0.5.0", + "memfs": "3.4.3", + "open": "8.4.2", + "recast": "0.23.21", + "remotion": "4.0.529", + "semver": "7.5.3", + "zod": "4.5.4" + } + }, + "node_modules/@remotion/studio-shared": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.529.tgz", + "integrity": "sha512-WQoNqqmTeho932NEIzhL5yH7UOeDjSpI3Ek38g9LaDx+wFZ6jVmBYaaYqjMlAHWkmom/mRl8lbMDOxyLz/7/Jg==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.529" + } + }, + "node_modules/@remotion/timeline-utils": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/timeline-utils/-/timeline-utils-4.0.529.tgz", + "integrity": "sha512-nw+H4/Q/gzxXbKU/XD8Sw9L5bpuccPS1c/CtpgZvyeEAGzlsgYPsbHInTAa++oIkasZgVK1X59qWjj5GXabiig==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.56.1", + "remotion": "4.0.529" + } + }, + "node_modules/@remotion/web-renderer": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.529.tgz", + "integrity": "sha512-+QP9k2C8PlvBfA+hmJ17BP/my92q6O/TYEz8Qiqc1cuE+1hnPF1+iNemjv5ETEWEZ2FUwbxVOYqa9+2khmdTYQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@mediabunny/aac-encoder": "1.56.1", + "@mediabunny/flac-encoder": "1.56.1", + "@mediabunny/mp3-encoder": "1.56.1", + "@remotion/licensing": "4.0.529", + "mediabunny": "1.56.1", + "remotion": "4.0.529" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@remotion/zod-types": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.529.tgz", + "integrity": "sha512-EInt82hUV5dH6QjI4hn4PhB9gFXiJH4QrJKJYxDI8Bog56nVGWkKLQE3vUQGhT5+3EUPdQnWEEm/OACutXvHFg==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.529" + } + }, + "node_modules/@rspack/binding": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.11.tgz", + "integrity": "sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q==", + "license": "MIT", + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "1.7.11", + "@rspack/binding-darwin-x64": "1.7.11", + "@rspack/binding-linux-arm64-gnu": "1.7.11", + "@rspack/binding-linux-arm64-musl": "1.7.11", + "@rspack/binding-linux-x64-gnu": "1.7.11", + "@rspack/binding-linux-x64-musl": "1.7.11", + "@rspack/binding-wasm32-wasi": "1.7.11", + "@rspack/binding-win32-arm64-msvc": "1.7.11", + "@rspack/binding-win32-ia32-msvc": "1.7.11", + "@rspack/binding-win32-x64-msvc": "1.7.11" + } + }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.11.tgz", + "integrity": "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.11.tgz", + "integrity": "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.11.tgz", + "integrity": "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.11.tgz", + "integrity": "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.11.tgz", + "integrity": "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.11.tgz", + "integrity": "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.11.tgz", + "integrity": "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "1.0.7" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.11.tgz", + "integrity": "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.11.tgz", + "integrity": "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.11.tgz", + "integrity": "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/core": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.11.tgz", + "integrity": "sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime-tools": "0.22.0", + "@rspack/binding": "1.7.11", + "@rspack/lite-tapable": "1.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.1" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@rspack/lite-tapable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", + "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", + "license": "MIT" + }, + "node_modules/@rspack/plugin-react-refresh": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.6.1.tgz", + "integrity": "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw==", + "license": "MIT", + "dependencies": { + "error-stack-parser": "^2.1.4", + "html-entities": "^2.6.0" + }, + "peerDependencies": { + "react-refresh": ">=0.10.0 <1.0.0", + "webpack-hot-middleware": "2.x" + }, + "peerDependenciesMeta": { + "webpack-hot-middleware": { + "optional": true + } + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz", + "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.35.tgz", + "integrity": "sha512-GNk8KX52CHqeNKGk8+7tZnQab/2k1t/BgrUHPKNdijzpEhF1saPQZHVKb6m5loSAxB39gyrTYw34Qf9CQ3u1qQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001823", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001823.tgz", + "integrity": "sha512-TQ5s/djDjNk1axreUW+4rULH+BmdCMLiJkhaXnO5wqivG7tITkSUj4ItIdzqdZHKaXcHAV8dhH3JxOM7/7bwqw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", + "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.478", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.478.tgz", + "integrity": "sha512-pftG+ma5Lm8O6/p45bhYT6StBN5ziQymaRzmCZwFl8DwWHKPPD93FdOmP1aqftcepolQYvrcyTLa17kE8Yf+jg==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.26.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.1.tgz", + "integrity": "sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "license": "Unlicense" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kiwi-schema": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/kiwi-schema/-/kiwi-schema-0.5.0.tgz", + "integrity": "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA==", + "license": "MIT", + "bin": { + "kiwic": "cli.js" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/mediabunny": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.56.1.tgz", + "integrity": "sha512-DNOm0bRBmeOxJgCK/g3LxNWbxY5morTauNdlychujGmJ9L37ezVezkNoI1WtO2Xdb4WlWWJlYfIKnOEzd5phRw==", + "license": "MPL-2.0", + "workspaces": [ + ".", + "packages/*" + ], + "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + } + }, + "node_modules/memfs": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.3.tgz", + "integrity": "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg==", + "license": "Unlicense", + "dependencies": { + "fs-monkey": "1.0.3" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-releases": { + "version": "2.0.63", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz", + "integrity": "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.2.0.tgz", + "integrity": "sha512-Khh/tERVBn0I1499dvBSHY0pWJloMJWUxDsOBM0CCDPAcVZ10Jl2L1qy1f19uEX22spa8Mv7DCdEQJc0cGwBhQ==", + "license": "MIT", + "dependencies": { + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/recast": { + "version": "0.23.21", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", + "integrity": "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remotion": { + "version": "4.0.529", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.529.tgz", + "integrity": "sha512-x0oWWwPPiC5cV6ms80USr69GmtbHc3MK5eFTwJpP0oN+emvgtvq+flovERaN29jy0wJW/ZGemSwo119iGCiH0g==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.8.3.tgz", + "integrity": "sha512-qIfo3f+lRNqnMPgpXSKvUhsT3pY5U+ppmgGr4j8LanFP3W/Ze+xj8DY/dPb5qVu3m6qFIMyMWKVRLNKxrNhxWg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "bin": { + "schema-utils": "bin/schema-utils.js" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/svg-parser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.1.0.tgz", + "integrity": "sha512-bwLf38YmY+TDYHJw1Ex0Co8c4yeXuJAo8YnXGZrscxrvYoVVIvLeniEkV1Ks/54VteMnL4FtyN1+P+TZJdUPmQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.6.4.tgz", + "integrity": "sha512-5H3Tce2GfzWbcr9VnSqyak2whFaONw4BdmI5BDnMXold4qSlCFd64XaMQESRh2vkDKmdUkv6rUo17UvZ9f9nXw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.7.0.tgz", + "integrity": "sha512-PdUhbCycwEgiS1hB/FBDksWKg/ashAIuJKqwdbPn8/dFVgU5LxQiBvG0js6VRRIzPujMrStYCczywp82P0KWng==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/video/package.json b/video/package.json new file mode 100644 index 0000000..06ed153 --- /dev/null +++ b/video/package.json @@ -0,0 +1,26 @@ +{ + "name": "zyperos-tour-video", + "version": "0.1.0", + "private": true, + "description": "The ZyperOS tour video: the launcher's own frames, composed with Remotion.", + "scripts": { + "preview": "remotion studio", + "render": "remotion render ZyperOSTour out/zyperos-tour-master.mp4", + "poster": "remotion still Poster out/poster.png", + "typecheck": "tsc --noEmit", + "stills": "node scripts/stills.mjs" + }, + "dependencies": { + "@remotion/bundler": "4.0.529", + "@remotion/cli": "4.0.529", + "@remotion/renderer": "4.0.529", + "react": "18.3.1", + "react-dom": "18.3.1", + "remotion": "4.0.529" + }, + "devDependencies": { + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "typescript": "5.6.3" + } +} diff --git a/video/rec/rec.c b/video/rec/rec.c new file mode 100644 index 0000000..0d2ff57 --- /dev/null +++ b/video/rec/rec.c @@ -0,0 +1,331 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +/* rec.dylib: records the host launcher (build-native/zyperos) as a video of + * its own frames, driven by a script of button presses, with no window on the + * desktop and no change to the launcher. + * + * Loaded with DYLD_INSERT_LIBRARIES into the headless host build (SDL's dummy + * video driver, the software renderer), it interposes five SDL calls: + * + * SDL_RenderPresent reads the frame that is about to be shown and hands it + * to ffmpeg, then moves the clock one display refresh on + * SDL_GetTicks(64) answer that clock rather than the wall's + * SDL_Delay moves it on by the delay (on the main thread) + * SDL_PollEvent delivers the script's key presses when their time comes + * + * So the clock the launcher animates by is the frames it drew: every frame of + * every motion is in the file, at 60 per second, however long the software + * renderer and the encoder took to make it. The clock is held back to the + * wall's pace when drawing is quicker than that, so art decoded on texload's + * workers arrives about when it would on a handheld. + * + * Environment: + * REC_OUT the video to write (an .mp4, H.264 at a high quality) + * REC_SCRIPT the presses: lines "MS KEY [HOLD]" (ms after the first frame, + * a key name below, how long it is held, 90 by default), and + * "MS end" to stop. # starts a comment. + * REC_FFMPEG the ffmpeg to run (ffmpeg on PATH by default) + * REC_FPS the frame rate of the file (60) + * REC_CLOCK "H:MM": the wall clock the launcher reads (time, and + * clock_gettime's CLOCK_REALTIME) starts today at that time and + * runs on the launcher's clock, so the top bar's time is the + * same in every take + * REC_MENU_MUSIC set: the menus' music and sounds are set up as on a + * handheld (silent, on SDL's dummy audio driver). The launcher + * leaves them out of a headless run, which reads + * ZYPEROS_HEADLESS three times (src/main.c): for the sounds and + * the music, which this answers as unset, so MENU > Menu Music + * shows the tracks a card has, then for the window, which keeps + * its value. Should that order change, the window is the one + * told it is not headless, and the take stops at video init + * REC_BATT a file with a battery level: the host build reads its stand-in + * gauge from /tmp/zyperos_fake_batt (src/device/brick.c), and + * this take reads REC_BATT there instead, so the corner shows a + * battery without a file in /tmp that other runs would see + * + * Key names: left right up down a b x y l1 r1 l2 r2 start select menu. + * They are the host build's keyboard keys (src/device/brick.c, map_key). + */ +#include <SDL.h> +#include <math.h> +#include <pthread.h> +#include <stdatomic.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +#define DYLD_INTERPOSE(rep, orig) \ + __attribute__((used)) static struct { \ + const void *replacement, *replacee; \ + } interpose_##orig __attribute__((section("__DATA,__interpose"))) = { \ + (const void *)(unsigned long)&rep, (const void *)(unsigned long)&orig }; + +#define MAX_EVENTS 4096 + +typedef struct { double at; SDL_Keycode key; int down; } rec_ev; + +static rec_ev g_ev[MAX_EVENTS]; +static int g_nev, g_next; +static double g_end = -1; + +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static double g_vt; /* the launcher's clock, ms */ +static double g_real0; /* the wall's clock at g_vt == 0 */ +static double g_first = -1; /* g_vt at the first frame: the script's 0 */ +static int g_started; + +static void start_wall(void); +static FILE *g_ff; +static unsigned char *g_last, *g_cur; +static int g_w, g_h; +static long g_ticks; /* frames written */ +static double g_fps = 60.0; +static double g_refresh = 1000.0 / 60.0; + +static double wall_ms(void) +{ + struct timespec ts; + + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec * 1000.0 + ts.tv_nsec / 1e6; +} + +static SDL_Keycode key_of(const char *name) +{ + static const struct { const char *n; SDL_Keycode k; } map[] = { + { "left", SDLK_LEFT }, { "right", SDLK_RIGHT }, { "up", SDLK_UP }, + { "down", SDLK_DOWN }, { "a", SDLK_RETURN }, { "b", SDLK_ESCAPE }, + { "x", SDLK_x }, { "y", SDLK_y }, { "l1", SDLK_COMMA }, + { "r1", SDLK_PERIOD }, { "l2", SDLK_k }, { "r2", SDLK_l }, + { "start", SDLK_s }, { "select", SDLK_LSHIFT }, { "menu", SDLK_m }, + }; + for (size_t i = 0; i < sizeof map / sizeof map[0]; i++) + if (!strcmp(map[i].n, name)) return map[i].k; + return SDLK_UNKNOWN; +} + +static int ev_cmp(const void *a, const void *b) +{ + const rec_ev *x = a, *y = b; + return x->at < y->at ? -1 : x->at > y->at ? 1 : 0; +} + +static void load_script(const char *path) +{ + FILE *f = fopen(path, "r"); + char line[256]; + + if (!f) { fprintf(stderr, "rec: cannot read %s\n", path); exit(4); } + while (fgets(line, sizeof line, f)) { + char name[32]; + double at, hold = 90; + char *hash = strchr(line, '#'); + int n; + + if (hash) *hash = 0; + n = sscanf(line, "%lf %31s %lf", &at, name, &hold); + if (n < 2) continue; + if (!strcmp(name, "end")) { g_end = at; continue; } + SDL_Keycode k = key_of(name); + if (k == SDLK_UNKNOWN) { fprintf(stderr, "rec: no key %s\n", name); exit(4); } + if (g_nev + 2 > MAX_EVENTS) break; + g_ev[g_nev++] = (rec_ev){ at, k, 1 }; + g_ev[g_nev++] = (rec_ev){ at + hold, k, 0 }; + } + fclose(f); + qsort(g_ev, (size_t)g_nev, sizeof g_ev[0], ev_cmp); + if (g_end < 0) { fprintf(stderr, "rec: the script has no end line\n"); exit(4); } +} + +static void start(void) +{ + const char *s; + + if (g_started) return; + g_started = 1; + if ((s = getenv("REC_FPS"))) g_fps = atof(s); + if ((s = getenv("REC_SCRIPT"))) load_script(s); + else { fprintf(stderr, "rec: REC_SCRIPT is not set\n"); exit(4); } + g_real0 = wall_ms(); + g_vt = 0; + start_wall(); +} + +static void open_ff(int w, int h) +{ + const char *out = getenv("REC_OUT"); + const char *ff = getenv("REC_FFMPEG"); + char cmd[2048]; + + if (!out) { fprintf(stderr, "rec: REC_OUT is not set\n"); exit(4); } + snprintf(cmd, sizeof cmd, + "'%s' -loglevel error -y -f rawvideo -pix_fmt rgba -s %dx%d -r %g -i - " + "-c:v libx264 -preset fast -crf 10 -pix_fmt yuv420p -color_range tv " + "-colorspace bt709 -color_primaries bt709 -color_trc bt709 " + "-vf scale=out_color_matrix=bt709:out_range=tv -movflags +faststart '%s'", + ff ? ff : "ffmpeg", w, h, g_fps, out); + g_ff = popen(cmd, "w"); + if (!g_ff) { fprintf(stderr, "rec: cannot start ffmpeg\n"); exit(4); } + g_w = w; g_h = h; + g_last = calloc((size_t)w * h, 4); + g_cur = malloc((size_t)w * h * 4); +} + +/* Every tick of the file up to the launcher's clock, from the frame on screen */ +static void emit_until(double vt) +{ + if (!g_ff || g_first < 0) return; + while ((double)g_ticks * (1000.0 / g_fps) <= vt - g_first) { + fwrite(g_last, 4, (size_t)g_w * g_h, g_ff); + g_ticks++; + } +} + +static void finish(void) +{ + if (g_ff) { + emit_until(g_first + g_end); + pclose(g_ff); + g_ff = NULL; + fprintf(stderr, "rec: %ld frames, %.2f s\n", g_ticks, g_ticks / g_fps); + } + _exit(0); +} + +/* REC_CLOCK's time today, in seconds since the epoch, or -1 */ +static double g_wall0 = -1; + +static void start_wall(void) +{ + const char *s = getenv("REC_CLOCK"); + int h, m; + + if (s && sscanf(s, "%d:%d", &h, &m) == 2) { + time_t now = time(NULL); + struct tm tm; + + localtime_r(&now, &tm); + tm.tm_hour = h; tm.tm_min = m; tm.tm_sec = 0; tm.tm_isdst = -1; + g_wall0 = (double)mktime(&tm); + } +} + +static time_t rec_time(time_t *t) +{ + time_t v; + + start(); + v = g_wall0 >= 0 ? (time_t)(g_wall0 + g_vt / 1000.0) : time(NULL); + if (t) *t = v; + return v; +} + +static int rec_clock_gettime(clockid_t id, struct timespec *ts) +{ + if (id == CLOCK_REALTIME && ts) { + start(); + if (g_wall0 >= 0) { + double s = g_wall0 + g_vt / 1000.0; + + ts->tv_sec = (time_t)s; + ts->tv_nsec = (long)((s - (double)ts->tv_sec) * 1e9); + return 0; + } + } + return clock_gettime(id, ts); +} + +static Uint32 rec_GetTicks(void) +{ + double v; + + start(); + pthread_mutex_lock(&g_lock); + /* A loop that waits on the clock alone, drawing nothing, still ends */ + if (pthread_main_np()) g_vt += 0.0005; + v = g_vt; + pthread_mutex_unlock(&g_lock); + return (Uint32)v; +} + +static Uint64 rec_GetTicks64(void) { return rec_GetTicks(); } + +static void rec_Delay(Uint32 ms) +{ + start(); + if (!pthread_main_np()) { SDL_Delay(ms); return; } + pthread_mutex_lock(&g_lock); + g_vt += ms; + pthread_mutex_unlock(&g_lock); + /* At the wall's pace, so a worker's art lands when it would */ + double ahead = g_vt - (wall_ms() - g_real0); + if (ahead > 0) usleep((useconds_t)(ahead * 1000)); + if (g_end >= 0 && g_first >= 0 && g_vt - g_first >= g_end) finish(); +} + +static void rec_RenderPresent(SDL_Renderer *r) +{ + int w, h; + + start(); + if (SDL_GetRendererOutputSize(r, &w, &h) == 0) { + if (!g_ff) open_ff(w, h); + if (w == g_w && h == g_h && + SDL_RenderReadPixels(r, NULL, SDL_PIXELFORMAT_RGBA32, g_cur, w * 4) == 0) { + if (g_first < 0) g_first = g_vt; + emit_until(g_vt); + unsigned char *t = g_last; g_last = g_cur; g_cur = t; + } + } + SDL_RenderPresent(r); + pthread_mutex_lock(&g_lock); + g_vt += g_refresh; + pthread_mutex_unlock(&g_lock); + double ahead = g_vt - (wall_ms() - g_real0); + if (ahead > 0) usleep((useconds_t)(ahead * 1000)); + if (g_end >= 0 && g_first >= 0 && g_vt - g_first >= g_end) finish(); +} + +static int rec_PollEvent(SDL_Event *e) +{ + start(); + if (g_first >= 0 && g_next < g_nev && g_vt - g_first >= g_ev[g_next].at) { + if (!e) return 1; + memset(e, 0, sizeof *e); + e->type = g_ev[g_next].down ? SDL_KEYDOWN : SDL_KEYUP; + e->key.state = g_ev[g_next].down ? SDL_PRESSED : SDL_RELEASED; + e->key.keysym.sym = g_ev[g_next].key; + e->key.timestamp = (Uint32)g_vt; + g_next++; + return 1; + } + return SDL_PollEvent(e); +} + +static FILE *rec_fopen(const char *path, const char *mode) +{ + const char *batt = getenv("REC_BATT"); + + if (batt && path && !strcmp(path, "/tmp/zyperos_fake_batt")) return fopen(batt, mode); + return fopen(path, mode); +} + +static char *rec_getenv(const char *name) +{ + static int asked; + + if (name && !strcmp(name, "ZYPEROS_HEADLESS") && getenv("REC_MENU_MUSIC") && asked++ < 2) + return NULL; + return getenv(name); +} + +DYLD_INTERPOSE(rec_fopen, fopen) +DYLD_INTERPOSE(rec_getenv, getenv) +DYLD_INTERPOSE(rec_GetTicks, SDL_GetTicks) +DYLD_INTERPOSE(rec_GetTicks64, SDL_GetTicks64) +DYLD_INTERPOSE(rec_Delay, SDL_Delay) +DYLD_INTERPOSE(rec_RenderPresent, SDL_RenderPresent) +DYLD_INTERPOSE(rec_PollEvent, SDL_PollEvent) +DYLD_INTERPOSE(rec_time, time) +DYLD_INTERPOSE(rec_clock_gettime, clock_gettime) diff --git a/video/rec/snapsub.c b/video/rec/snapsub.c new file mode 100644 index 0000000..06e5651 --- /dev/null +++ b/video/rec/snapsub.c @@ -0,0 +1,57 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +/* snapsub.dylib: a game's video snap picture in place of the host build's test + * picture, for a --snap shot of the launcher (build-native/zyperos). + * + * The host build has no Muse to decode a snap, so `--snap K` turns the cover + * into a test picture (src/mp4video.c, mp4v_test_picture), a 320x240 IYUV + * texture filled with SDL_UpdateYUVTexture. Loaded with DYLD_INSERT_LIBRARIES, + * this hands that call the planes of SNAP_I420 instead: raw 320x240 yuv420p + * frames of the game's snap (ffmpeg -pix_fmt yuv420p -f rawvideo), frame + * SNAP_FRAME of them (0 without it; past the last, it starts over), one shot a + * frame, so the snap plays as the shots step through it. + * Everything else the launcher draws, the turn from the cover to the snap + * included, is its own. Nothing else is changed. + */ +#include <SDL.h> +#include <stdio.h> +#include <stdlib.h> + +#define DYLD_INTERPOSE(rep, orig) \ + __attribute__((used)) static struct { \ + const void *replacement, *replacee; \ + } interpose_##orig __attribute__((section("__DATA,__interpose"))) = { \ + (const void *)(unsigned long)&rep, (const void *)(unsigned long)&orig }; + +enum { W = 320, H = 240 }; + +static int sub_UpdateYUVTexture(SDL_Texture *t, const SDL_Rect *rect, const Uint8 *y, int yp, + const Uint8 *u, int up, const Uint8 *v, int vp) +{ + static Uint8 *pic; + static int tried; + const char *path = getenv("SNAP_I420"); + int w = 0, h = 0; + + if (path && !rect && SDL_QueryTexture(t, NULL, NULL, &w, &h) == 0 && w == W && h == H && yp == W) { + if (!tried) { + FILE *f = fopen(path, "rb"); + const char *n = getenv("SNAP_FRAME"); + long frame = n ? atol(n) : 0, frames = 0, size = W * H * 3 / 2; + + tried = 1; + pic = malloc((size_t)size); + if (f && fseek(f, 0, SEEK_END) == 0) frames = ftell(f) / size; + if (!f || frames < 1 || fseek(f, (frame % frames) * size, SEEK_SET) != 0 || + fread(pic, 1, (size_t)size, f) != (size_t)size) { + fprintf(stderr, "snapsub: cannot read frame %ld of %s\n", frame, path); + free(pic); + pic = NULL; + } + if (f) fclose(f); + } + if (pic) return SDL_UpdateYUVTexture(t, rect, pic, W, pic + W * H, W / 2, pic + W * H * 5 / 4, W / 2); + } + return SDL_UpdateYUVTexture(t, rect, y, yp, u, up, v, vp); +} + +DYLD_INTERPOSE(sub_UpdateYUVTexture, SDL_UpdateYUVTexture) diff --git a/video/remotion.config.ts b/video/remotion.config.ts new file mode 100644 index 0000000..c74840e --- /dev/null +++ b/video/remotion.config.ts @@ -0,0 +1,8 @@ +import { Config } from "@remotion/cli/config"; + +Config.setVideoImageFormat("jpeg"); +Config.setJpegQuality(95); +Config.setOverwriteOutput(true); +Config.setCodec("h264"); +Config.setCrf(14); +Config.setConcurrency(3); diff --git a/video/scripts/finish.sh b/video/scripts/finish.sh new file mode 100644 index 0000000..4ac201d --- /dev/null +++ b/video/scripts/finish.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# From Remotion's render to the files people see: +# +# MASTER (outside the repository, for YouTube and creators): H.264 at a high +# quality, the audio at -16 LUFS integrated, 2.5 dB true peak headroom (AAC at web size overshoots) +# site/zyperos-tour.mp4: the same at a web size for zyperos.com (under +# 25 MB, Cloudflare Pages' file limit), with its poster site/zyperos-tour.jpg +# res/readme/tour-poster.jpg: the README's poster, with a play button, which +# links to the video on zyperos.com +# +# Both MP4s are limited range BT.709: Remotion writes full range (yuvj420p), +# which browsers and players show washed out or crushed when they assume the +# usual range. +# +# sh video/scripts/finish.sh [MASTER] +# MASTER defaults to ~/Documents/ZyperOS/video/zyperos-tour-master.mp4 +set -eu +VIDEO=$(cd "$(dirname "$0")/.." && pwd) +ROOT=$(cd "$VIDEO/.." && pwd) +RAW="$VIDEO/out/zyperos-tour-raw.mp4" +MASTER=${1:-$HOME/Documents/ZyperOS/video/zyperos-tour-master.mp4} +WEB="$ROOT/site/zyperos-tour.mp4" +[ -f "$RAW" ] || { echo "no $RAW: npx remotion render ZyperOSTour out/zyperos-tour-raw.mp4" >&2; exit 1; } +mkdir -p "$(dirname "$MASTER")" + +# The loudness, measured first (loudnorm's first pass), then applied exactly +stats=$(ffmpeg -hide_banner -nostats -i "$RAW" -af loudnorm=I=-16:TP=-2.5:LRA=11:print_format=json -f null - 2>&1 | + sed -n '/^{/,/^}/p') +val() { printf '%s' "$stats" | sed -n "s/.*\"$1\" : \"\([^\"]*\)\".*/\1/p"; } +norm="loudnorm=I=-16:TP=-2.5:LRA=11:measured_I=$(val input_i):measured_TP=$(val input_tp):measured_LRA=$(val input_lra):measured_thresh=$(val input_thresh):offset=$(val target_offset):linear=true" +range="scale=in_range=full:out_range=limited:out_color_matrix=bt709,format=yuv420p" +tags="-color_range tv -colorspace bt709 -color_primaries bt709 -color_trc bt709 -movflags +faststart" + +ffmpeg -loglevel error -y -i "$RAW" -vf "$range" -c:v libx264 -preset slow -crf "${MASTER_CRF:-6}" -profile:v high \ + -af "$norm" -ar 48000 -c:a aac -b:a 320k $tags "$MASTER" +ffmpeg -loglevel error -y -i "$MASTER" -c:v libx264 -preset veryslow -crf "${WEB_CRF:-21}" -tune animation \ + -profile:v high -pix_fmt yuv420p -x264-params keyint=60 -c:a aac -b:a 128k $tags "$WEB" + +(cd "$VIDEO" && npx remotion still Poster out/poster.png >/dev/null && npx remotion still PosterPlay out/poster-play.png >/dev/null) +ffmpeg -loglevel error -y -i "$VIDEO/out/poster.png" -vf scale=1600:-2 -q:v 3 "$ROOT/site/zyperos-tour.jpg" +ffmpeg -loglevel error -y -i "$VIDEO/out/poster-play.png" -vf scale=1280:-2 -q:v 3 "$ROOT/res/readme/tour-poster.jpg" + +for f in "$MASTER" "$WEB"; do + printf '%s %s MB %s s\n' "$f" "$(( $(stat -f %z "$f") / 1048576 ))" \ + "$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f")" +done +ffmpeg -hide_banner -nostats -i "$WEB" -af ebur128=peak=true -f null - 2>&1 | sed -n '/Summary/,$p' | grep -E "I:|Peak:" diff --git a/video/scripts/prepare-assets.sh b/video/scripts/prepare-assets.sh new file mode 100644 index 0000000..a7948fd --- /dev/null +++ b/video/scripts/prepare-assets.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Fills video/public with what the tour is made of, all of it from the +# repository or made from it, none of it committed (video/.gitignore): +# +# fonts/ Lexend and the CJK menu fonts (res/fonts) +# img/ the ZyperOS lockups and the File Transfer page's shots (res/readme), and Sonic 2's libretro screenshot +# from the art cache video/footage/card.py fills +# boot/ the boot animation (res/boot) +# music/ ZyperOS's menu music tracks (res/bgm): src/music.ts picks one +# audio/ the launcher's own interface sounds (res/sounds/wood) +# device/ the Pixel 2 straight on, its screen cut out, rendered in Blender +# from tools/build-pixel2-model.py's scene (video/device), once +# +# The footage is video/footage/takes.py's. Run from anywhere: +# sh video/scripts/prepare-assets.sh +set -eu +VIDEO=$(cd "$(dirname "$0")/.." && pwd) +ROOT=$(cd "$VIDEO/.." && pwd) +PUB="$VIDEO/public" +ART="${ZYPEROS_ART_CACHE:-$HOME/Library/Caches/zyperos-readme-art}" + +mkdir -p "$PUB/fonts" "$PUB/img" "$PUB/boot" "$PUB/audio/ui" "$PUB/device" +cp "$ROOT/res/fonts/menu.ttf" "$ROOT/res/fonts/title.ttf" "$ROOT/res/fonts/ja-menu.otf" \ + "$ROOT/res/fonts/zh-menu.otf" "$PUB/fonts/" +cp "$ROOT/res/readme/lockup.png" "$ROOT/res/readme/lockup-light.png" "$PUB/img/" +# The File Transfer page in a browser, as tools/readme-shots.py drew it +cp "$ROOT/res/readme/portal.png" "$ROOT/res/readme/portal-phone.png" "$ROOT/res/readme/portal-pin.png" "$PUB/img/" +cp "$ROOT/res/boot/zyperos-fly.mp4" "$PUB/boot/" +mkdir -p "$PUB/music" +cp "$ROOT"/res/bgm/*.mp3 "$PUB/music/" +cp "$ROOT"/res/sounds/wood/*.wav "$PUB/audio/ui/" +snap="$ART/Sega_-_Mega_Drive_-_Genesis/Named_Snaps/Sonic The Hedgehog 2 (World).png" +[ -f "$snap" ] || { echo "no $snap: run video/footage/takes.py first, which fills the art cache" >&2; exit 1; } +cp "$snap" "$PUB/img/sonic2.png" + +if [ ! -f "$PUB/device/pixel2-front.png" ]; then + command -v blender >/dev/null || { echo "Blender is needed once, for the device" >&2; exit 1; } + work=$(mktemp -d) + trap 'rm -rf "$work"' EXIT + # The model's script writes beside itself, so it runs from a copy + mkdir -p "$work/tools" "$work/res/readme" + cp "$ROOT/tools/build-pixel2-model.py" "$work/tools/" + cp "$ROOT/res/readme/systems.png" "$work/res/readme/" + (cd "$work" && blender --background --python tools/build-pixel2-model.py >/dev/null) + blender --background "$work/build/pixel2-model/pixel2.blend" \ + --python "$VIDEO/device/render-device.py" -- "$PUB/device" 2000 >/dev/null +fi +echo "video/public is ready" diff --git a/video/scripts/stills.mjs b/video/scripts/stills.mjs new file mode 100644 index 0000000..92cdb81 --- /dev/null +++ b/video/scripts/stills.mjs @@ -0,0 +1,51 @@ +// Stills of the tour at chosen frames, and a contact sheet of them, to look at +// before the full render: npm run stills [-- FRAME ...] +// Without frames: two of every scene (scenes last whole bars of src/music.ts's track) (just after it lands, and past its +// middle). Writes out/stills/<frame>.jpeg and out/contact-sheet.jpg, +// six to a row in the order given. +import { bundle } from "@remotion/bundler"; +import { renderStill, selectComposition } from "@remotion/renderer"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(here, ".."); +const out = path.join(root, "out/stills"); +mkdirSync(out, { recursive: true }); + +let frames = process.argv.slice(2).map(Number).filter((n) => !Number.isNaN(n)); +if (!frames.length) { + // The scene starts, from the beats of the timeline's SCENES, in order + const src = readFileSync(path.join(root, "src/timeline.ts"), "utf8"); + const table = src.slice(src.indexOf("export const SCENES"), src.indexOf("/** Scene starts")); + const bpm = Number(readFileSync(path.join(root, "src/music.ts"), "utf8").match(/bpm: ([\d.]+)/)[1]); + const beat = 1800 / bpm; + const beats = [...table.matchAll(/bars: ([\d.]+)/g)].map((m) => Math.round(Number(m[1]) * 4)); + let at = 0; + for (const b of beats) { + const len = Math.round(b * beat); + frames.push(at + Math.min(12, len - 1), at + Math.round(len * 0.6)); + at += len; + } +} +const serveUrl = await bundle({ entryPoint: path.join(root, "src/index.ts") }); +const composition = await selectComposition({ serveUrl, id: "ZyperOSTour" }); +for (const frame of frames) { + const file = path.join(out, `${String(frame).padStart(4, "0")}.jpeg`); + await renderStill({ serveUrl, composition, frame, output: file, imageFormat: "jpeg", jpegQuality: 85 }); + process.stdout.write(`${frame} `); +} +console.log(); +if (frames.length < 2) process.exit(0); +const cols = 6; +const list = frames.map((f) => path.join(out, `${String(f).padStart(4, "0")}.jpeg`)); +const inputs = list.flatMap((f) => ["-i", f]); +// In the order given, left to right: the file names say the frames +const labelled = list.map((_, i) => `[${i}]scale=480:-1[v${i}]`); +const layout = list.map((_, i) => `${(i % cols) * 480}_${Math.floor(i / cols) * 270}`).join("|"); +const filter = `${labelled.join(";")};${list.map((_, i) => `[v${i}]`).join("")}xstack=inputs=${list.length}:layout=${layout}:fill=black`; +execFileSync("ffmpeg", ["-loglevel", "error", "-y", ...inputs, "-filter_complex", filter, "-frames:v", "1", + path.join(root, "out/contact-sheet.jpg")]); +console.log("out/contact-sheet.jpg"); diff --git a/video/src/Poster.tsx b/video/src/Poster.tsx new file mode 100644 index 0000000..291a68b --- /dev/null +++ b/video/src/Poster.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { AbsoluteFill, Img, staticFile } from "remotion"; +import { Device, DeviceInfo, FONT, Footage, LIGHT, poseOf, Screen } from "./parts"; +import { Clip } from "./timeline"; + +export type PosterProps = { footage: Footage; device: DeviceInfo; play: boolean }; + +/** The PlayStation shelf with its covers, as the tour opens on it */ +const SHELF: Clip[] = [{ kind: "take", take: "shelf", at: 0, len: 1, fromMs: 5100, rate: 1, scene: "poster" }]; + +/** The device whole, the site's headline; the README's has a play button */ +export const Poster: React.FC<PosterProps> = ({ device, play }) => ( + <AbsoluteFill style={{ background: LIGHT.ground }}> + <Device d={device} cam={poseOf("hero", device)}> + <Screen clips={SHELF} d={device} /> + </Device> + <div style={{ position: "absolute", left: 1120, width: 700, top: 0, bottom: 0, display: "flex", flexDirection: "column", + justifyContent: "center", fontFamily: FONT }}> + <Img src={staticFile("img/lockup-light.png")} style={{ width: 420, marginLeft: -8, marginBottom: 36 }} /> + <div style={{ fontSize: 54, lineHeight: 1.08, fontWeight: 600, color: LIGHT.text, letterSpacing: -1 }}> + A fast, light firmware for the GKD Pixel 2 that plays your whole retro library. + </div> + {play && ( + <div style={{ display: "flex", alignItems: "center", gap: 22, marginTop: 48, fontSize: 32, fontWeight: 600, color: LIGHT.text }}> + <div style={{ width: 96, height: 96, borderRadius: 48, background: LIGHT.accent, display: "flex", + alignItems: "center", justifyContent: "center", boxShadow: "0 10px 30px rgba(0,0,0,0.18)" }}> + <div style={{ width: 0, height: 0, borderTop: "20px solid transparent", borderBottom: "20px solid transparent", + borderLeft: `32px solid ${LIGHT.text}`, marginLeft: 8 }} /> + </div> + Watch the tour + </div> + )} + </div> + </AbsoluteFill> +); diff --git a/video/src/Root.tsx b/video/src/Root.tsx new file mode 100644 index 0000000..644dd30 --- /dev/null +++ b/video/src/Root.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { CalculateMetadataFunction, Composition, staticFile, Still } from "remotion"; +import { Tour, TourProps } from "./Tour"; +import { Poster, PosterProps } from "./Poster"; +import { CLIPS, FPS, TOTAL } from "./timeline"; + +/** The footage index (video/footage/takes.py) and the device's screen rect (video/device) */ +const load = async () => { + const [footage, device] = await Promise.all([ + fetch(staticFile("footage/index.json")).then((r) => r.json()), + fetch(staticFile("device/pixel2-front.json")).then((r) => r.json()), + ]); + return { footage, device }; +}; + +/** Every clip inside its take: a scene longer than its footage would show black */ +const check = (footage: Record<string, { ms: number }>) => { + for (const c of CLIPS) { + if (c.kind !== "take") continue; + const need = c.fromMs + (c.len / FPS) * 1000 * c.rate; + const have = footage[c.take]?.ms ?? 0; + if (need > have + 20) throw new Error(`${c.scene}: ${c.take} needs ${Math.round(need)} ms, has ${have}`); + } +}; + +const tourMeta: CalculateMetadataFunction<TourProps> = async () => { + const props = await load(); + check(props.footage); + return { props }; +}; +const posterMeta: CalculateMetadataFunction<PosterProps> = async ({ props }) => ({ props: { ...props, ...(await load()) } }); + +const empty = { footage: {}, device: { image: "", width: 2000, height: 2400, screen: { x: 0, y: 0, w: 1, h: 1 } } }; + +export const Root: React.FC = () => ( + <> + <Composition id="ZyperOSTour" component={Tour} durationInFrames={TOTAL} fps={FPS} width={1920} height={1080} + defaultProps={empty as TourProps} calculateMetadata={tourMeta} /> + {/* The site's poster, and the README's, which carries a play button */} + <Still id="Poster" component={Poster} width={1920} height={1080} + defaultProps={{ ...empty, play: false } as PosterProps} calculateMetadata={posterMeta} /> + <Still id="PosterPlay" component={Poster} width={1920} height={1080} + defaultProps={{ ...empty, play: true } as PosterProps} calculateMetadata={posterMeta} /> + </> +); diff --git a/video/src/Tour.tsx b/video/src/Tour.tsx new file mode 100644 index 0000000..c6e462e --- /dev/null +++ b/video/src/Tour.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import { AbsoluteFill, Audio, Easing, Img, interpolate, Sequence, staticFile, useCurrentFrame } from "remotion"; +import { ease } from "./anim"; +import { BEAT, CLIPS, MUSIC_START_S, FPS, POWER_MS, sceneAt, sceneEnd, SCENES, STARTS, TOTAL } from "./timeline"; +import { MUSIC } from "./music"; +import { Cam, Device, DeviceInfo, FONT, Footage, Label, Laptop, LIGHT, DARK, mixCam, Phone, pop, poseOf, punchAt, Screen, Tile } from "./parts"; +import { SOUND_CUES } from "./sounds"; +import { Power, Race } from "./beats"; + +export type TourProps = { footage: Footage; device: DeviceInfo }; + +const LANGS = ["en", "zh-Hans", "es", "pt-BR", "fr", "de", "ja", "it"]; +const WHIP = 1700; + +/** Where the main device is at a frame: springs between poses, whips in and out */ +const camera = (frame: number, d: DeviceInfo): Cam => { + const i = sceneAt(frame); + const sc = SCENES[i]; + const start = STARTS[i]; + const end = sceneEnd(i); + const target = poseOf(sc.pose === "duo" ? "away" : sc.pose, d); + let cam: Cam; + if (sc.enter === "whip") { + const k = pop(frame, start, 150, 15); + cam = { ...target, x: target.x + (1 - k) * WHIP, blur: Math.min(18, Math.max(0, (1 - k) * 40 - 2)) }; + } else if (sc.enter === "cut" || i === 0) { + cam = target; + } else { + const prev = SCENES[i - 1].pose; + cam = mixCam(poseOf(prev === "duo" ? "away" : prev, d), target, pop(frame, start, 150, 17)); + } + // Out to the left, quick, when the next scene whips in + const next = SCENES[i + 1]; + if (next?.enter === "whip" && sc.pose !== "duo" && sc.pose !== "away") { + const k = ease(frame, end - 8, end, Easing.in(Easing.cubic)); + cam = { ...cam, x: cam.x - k * WHIP, blur: (cam.blur ?? 0) + k * 22 }; + } + return cam; +}; + +export const Tour: React.FC<TourProps> = ({ footage, device }) => { + const frame = useCurrentFrame(); + const i = sceneAt(frame); + const sc = SCENES[i]; + const start = STARTS[i]; + const end = sceneEnd(i); + const cam = camera(frame, device); + const zoom = punchAt(frame, start, end, sc.punch); + const labelAt = start + (sc.labelAt ?? 0) * BEAT; + const fadeOut = 1 - ease(frame, TOTAL - 12, TOTAL - 1); + + // The pair, light and dark: the ground wipes to dark on its right half + const duo = sc.id === "themes"; + const wipe = duo ? pop(frame, start + BEAT, 160, 20) : 0; + + return ( + <AbsoluteFill style={{ background: LIGHT.ground, overflow: "hidden" }}> + <AbsoluteFill style={{ opacity: Math.min(ease(frame, 0, 4), fadeOut) }}> + {duo && ( + <div style={{ position: "absolute", top: 0, bottom: 0, right: 0, width: 960 * wipe + 60 * wipe, background: DARK.ground, + clipPath: "polygon(60px 0, 100% 0, 100% 100%, 0 100%)" }} /> + )} + + {sc.pose !== "away" && !duo && ( + <Device d={device} cam={cam}> + <Screen clips={CLIPS} d={device} zoom={zoom} /> + </Device> + )} + + {duo && (["home-light", "home-dark"] as const).map((src, n) => { + const k = pop(frame, start + n * 4, 140, 14); + const kk = 0.4; + const c: Cam = { k: kk, x: 560 + n * 820 - 999.5 * kk, y: 600 - 1200 * kk + (1 - k) * 900 }; + const out = ease(frame, end - 6, end, Easing.in(Easing.cubic)); + return ( + <Device key={src} d={device} cam={{ ...c, y: c.y - out * 1200 }}> + <Img src={staticFile(`footage/${src}.png`)} style={{ position: "absolute", left: device.screen.x, top: device.screen.y, + width: device.screen.w, height: device.screen.h }} /> + </Device> + ); + })} + + {sc.id === "languages" && LANGS.map((code, n) => { + const k = pop(frame, start + BEAT / 2 + (n * BEAT) / 2, 190, 14); + const out = ease(frame, end - 6, end, Easing.in(Easing.cubic)); + return ( + <div key={code} style={{ position: "absolute", inset: 0, transform: `translateX(${-out * 2000}px)` }}> + <Tile src={`footage/lang-${code}.png`} x={115 + (n % 4) * 430} y={330 + Math.floor(n / 4) * 330} w={400} k={k} /> + </div> + ); + })} + + {sc.browser?.laptop && ( + <Laptop src={sc.browser.laptop} x={900} y={250} width={940} dark={0} + k={sc.browser.laptopAt === -1 ? 1 : pop(frame, start + (sc.browser.laptopAt ?? 0) * BEAT, 150, 16) * + (SCENES[i + 1]?.browser?.laptop ? 1 : 1 - ease(frame, end - 5, end))} /> + )} + {sc.browser?.phone && ( + <Phone src={sc.browser.phone} x={sc.browser.laptop ? 1560 : 1240} y={sc.browser.laptop ? 400 : 200} height={sc.browser.laptop ? 600 : 760} + k={pop(frame, start + (sc.browser.phoneAt ?? 0) * BEAT, 160, 14) * (1 - ease(frame, end - 5, end))} /> + )} + + {sc.label && sc.pose === "main" && ( + <Label frame={frame} at={labelAt} end={end} text={sc.label} sub={sc.sub} dark={0} x={1320} y={540} width={540} size={72} /> + )} + {sc.id === "boot" && <Race frame={frame} start={start} end={end} />} + {sc.id === "power" && <Power frame={frame} start={start} end={end} presses={[POWER_MS.off / 1000, POWER_MS.on / 1000]} backAt={POWER_MS.back / 1000} />} + {sc.label && sc.pose === "split" && sc.id === "boot" && ( + <Label frame={frame} at={labelAt} end={end} text={sc.label} sub={sc.sub} dark={0} x={960} y={60} width={900} size={70} anchor="top" /> + )} + {sc.label && sc.pose === "split" && sc.id !== "boot" && ( + <Label frame={frame} at={labelAt} end={end} text={sc.label} sub={sc.sub} dark={0} + x={sc.browser?.laptop ? 900 : 900} y={sc.browser?.laptop ? 40 : 300} width={sc.browser?.laptop ? 1000 : 320} size={62} anchor="top" /> + )} + {sc.label && duo && ( + <Label frame={frame} at={labelAt} end={end} text={sc.label} sub={sc.sub} dark={0} x={110} y={60} width={800} size={66} anchor="top" /> + )} + {sc.label && sc.id === "languages" && ( + <Label frame={frame} at={labelAt} end={end} text={sc.label} dark={0} x={0} y={90} width={1920} size={70} align="center" anchor="top" /> + )} + + {sc.id === "end" && <EndCard frame={frame} start={start} />} + </AbsoluteFill> + + <Audio src={staticFile(`music/${MUSIC.file}`)} startFrom={Math.round(MUSIC_START_S * FPS)} + volume={(f) => interpolate(f, [0, TOTAL - 2 * BEAT, TOTAL - 1], [0.9, 0.9, 0], { extrapolateRight: "clamp" })} /> + {SOUND_CUES(footage).map((c, n) => ( + <Sequence key={n} from={c.frame} durationInFrames={12} layout="none"> + <Audio src={staticFile(`audio/ui/${c.sound}.wav`)} volume={0.5 * (c.volume ?? 1)} /> + </Sequence> + ))} + </AbsoluteFill> + ); +}; + +/** The last three seconds: the name, where to get it, the music's credit */ +export const EndCard: React.FC<{ frame: number; start: number; still?: boolean }> = ({ frame, start, still }) => { + const k = (d: number) => (still ? 1 : pop(frame, start + d, 180, 16)); + const rise = (d: number) => ({ opacity: Math.min(1, k(d) * 1.4), transform: `translateY(${(1 - k(d)) * 40}px)` }); + return ( + <div style={{ position: "absolute", left: 1120, width: 740, top: 0, bottom: 0, display: "flex", flexDirection: "column", + justifyContent: "center", fontFamily: FONT }}> + <Img src={staticFile("img/lockup-light.png")} style={{ ...rise(0), width: 460, marginLeft: -10, marginBottom: 34 }} /> + <div style={{ ...rise(4), fontSize: 50, lineHeight: 1.12, fontWeight: 600, color: LIGHT.text }}> + Free, for the GKD Pixel 2. + </div> + <div style={{ ...rise(8), fontSize: 64, fontWeight: 600, color: LIGHT.ink, marginTop: 22, letterSpacing: -1 }}>zyperos.com</div> + <div style={{ ...rise(14), fontSize: 19, lineHeight: 1.5, color: LIGHT.dim, marginTop: 44 }}> + Music: {MUSIC.title} by {MUSIC.artist} ({MUSIC.license}), from ZyperOS's menu music.<br /> + Games and box art shown belong to their owners. + </div> + </div> + ); +}; diff --git a/video/src/anim.ts b/video/src/anim.ts new file mode 100644 index 0000000..dc334e9 --- /dev/null +++ b/video/src/anim.ts @@ -0,0 +1,20 @@ +import { Easing, interpolate } from "remotion"; + +/** 0 to 1 between two frames, eased, clamped */ +export const ease = (frame: number, from: number, to: number, easing = Easing.out(Easing.cubic)) => + interpolate(frame, [from, to], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing }); + +export const inOut = Easing.inOut(Easing.cubic); + +/** Fades in over `inDur` from `start`, out over `outDur` before `end` */ +export const windowed = (frame: number, start: number, end: number, inDur = 10, outDur = 8) => + Math.min(ease(frame, start, start + inDur), 1 - ease(frame, end - outDur, end, Easing.in(Easing.cubic))); + +export const mix = (a: number, b: number, k: number) => a + (b - a) * k; + +/** Two #rrggbb colours mixed */ +export const mixColor = (a: string, b: string, k: number) => { + const p = (c: string) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)); + const [x, y] = [p(a), p(b)]; + return `rgb(${x.map((v, i) => Math.round(mix(v, y[i], k))).join(",")})`; +}; diff --git a/video/src/beats.tsx b/video/src/beats.tsx new file mode 100644 index 0000000..a44fffc --- /dev/null +++ b/video/src/beats.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import { Easing, interpolate } from "remotion"; +import { ease } from "./anim"; +import { FPS } from "./timeline"; +import { FONT, LIGHT, pop } from "./parts"; + +const secs = (frame: number, start: number) => (frame - start) / FPS; +const fmt = (s: number) => (s < 10 ? `${s.toFixed(1)} s` : `${Math.floor(s)} s`); + +/** One racer: a name, a timer, and a bar out of 40 seconds */ +const Row: React.FC<{ y: number; name: string; t: number; done: boolean; color: string; k: number }> = ({ y, name, t, done, color, k }) => ( + <div style={{ position: "absolute", left: 960, top: y, width: 880, fontFamily: FONT, opacity: Math.min(1, k * 1.5), + transform: `translateX(${(1 - k) * 80}px)` }}> + <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}> + <div style={{ fontSize: 34, fontWeight: 600, color: LIGHT.text }}>{name}</div> + <div style={{ fontSize: 64, fontWeight: 600, color: done ? LIGHT.ink : LIGHT.text, fontVariantNumeric: "tabular-nums", letterSpacing: -1 }}> + {fmt(t)}{done ? " ✓" : ""} + </div> + </div> + <div style={{ marginTop: 10, height: 18, borderRadius: 9, background: "#cfcdc2", overflow: "hidden" }}> + <div style={{ height: "100%", width: `${Math.min(100, (t / 40) * 100)}%`, background: color, borderRadius: 9 }} /> + </div> + </div> +); + +/** + * ZyperOS's start against stock firmware's: Tarik's own timing, about 2 s + * against 30 to 40. ZyperOS's side is the device on the left, the real boot + * animation into the home; stock's is a plain loading panel, nothing of its + * own interface, its timer sped up once ZyperOS is done. + */ +export const Race: React.FC<{ frame: number; start: number; end: number }> = ({ frame, start, end }) => { + const t = secs(frame, start) - 0.3; + const zy = Math.max(0, Math.min(2, t)); + const lapse = 15; + const stock = t < 2 ? Math.max(0, t) : Math.min(35, 2 + (t - 2) * lapse); + const out = 1 - ease(frame, end - 6, end); + const k = (d: number) => pop(frame, start + d, 160, 16) * out; + const spin = (frame - start) * 14; + return ( + <> + <Row y={360} name="ZyperOS" t={zy} done={t >= 2} color={LIGHT.ink} k={k(4)} /> + <Row y={540} name="Stock firmware" t={stock} done={false} color="#8a8d89" k={k(7)} /> + <div style={{ position: "absolute", left: 960, top: 730, display: "flex", alignItems: "center", gap: 28, fontFamily: FONT, + opacity: Math.min(1, k(10) * 1.5) }}> + <div style={{ width: 280, height: 210, borderRadius: 14, background: "#2b2d2b", display: "flex", alignItems: "center", + justifyContent: "center", boxShadow: "0 16px 40px rgba(17,19,16,0.25)" }}> + <div style={{ width: 54, height: 54, borderRadius: 27, border: "6px solid #555955", borderTopColor: "#b9bcb7", + transform: `rotate(${spin}deg)` }} /> + </div> + <div style={{ fontSize: 24, lineHeight: 1.45, color: LIGHT.dim, maxWidth: 520 }}> + Stock firmware, still starting. + <br /> + {t >= 2 ? `Time-lapse ×${lapse}.` : " "} + </div> + </div> + </> + ); +}; + +/** POWER, pressed: a key cap that pops at each press */ +const Key: React.FC<{ frame: number; at: number }> = ({ frame, at }) => { + if (frame < at - 2 || frame > at + 24) return null; + const k = pop(frame, at, 260, 14); + const press = interpolate(frame, [at, at + 3, at + 8], [1, 0.9, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + const fade = 1 - ease(frame, at + 16, at + 24, Easing.in(Easing.cubic)); + return ( + <div style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", height: 64, padding: "0 28px", + borderRadius: 32, background: "#1d201e", color: "#e4e2d8", fontFamily: FONT, fontSize: 28, fontWeight: 600, letterSpacing: 2, + transform: `scale(${(0.6 + 0.4 * k) * press})`, opacity: Math.min(1, k * 2) * fade, boxShadow: "0 10px 24px rgba(0,0,0,0.25)" }}> + POWER + </div> + ); +}; + +/** Off, and back in the game: the two presses, and the timer from the second to the game */ +export const Power: React.FC<{ frame: number; start: number; end: number; presses: number[]; backAt: number }> = ({ + frame, start, end, presses, backAt, +}) => { + const on = start + presses[1] * FPS; + const t = Math.max(0, Math.min((backAt - presses[1]), (frame - on) / FPS)); + const shown = frame >= on; + const done = frame >= start + backAt * FPS; + const out = 1 - ease(frame, end - 6, end); + return ( + <div style={{ position: "absolute", left: 1320, top: 760, width: 540, fontFamily: FONT, opacity: out }}> + <div style={{ height: 70 }}>{presses.map((p) => <Key key={p} frame={frame} at={start + p * FPS} />)}</div> + {shown && ( + <div style={{ marginTop: 14, fontSize: 64, fontWeight: 600, color: done ? LIGHT.ink : LIGHT.text, fontVariantNumeric: "tabular-nums", + letterSpacing: -1, opacity: ease(frame, on, on + 6) }}> + {t.toFixed(1)} s{done ? " ✓" : ""} + </div> + )} + </div> + ); +}; diff --git a/video/src/index.ts b/video/src/index.ts new file mode 100644 index 0000000..e78af8b --- /dev/null +++ b/video/src/index.ts @@ -0,0 +1,4 @@ +import { registerRoot } from "remotion"; +import { Root } from "./Root"; + +registerRoot(Root); diff --git a/video/src/music.ts b/video/src/music.ts new file mode 100644 index 0000000..c40e67e --- /dev/null +++ b/video/src/music.ts @@ -0,0 +1,17 @@ +/** + * The tour's music: one of ZyperOS's own menu music tracks (res/bgm, all of + * them CC0; scripts/prepare-assets.sh copies them to public/music/). To change + * the track, change this object, lay src/timeline.ts's scenes on the new + * track's sections and render again; the end card credits whatever is here. LICENSES.md lists every track's tempo and the + * downbeat its full band starts on. + */ +export const MUSIC = { + file: "Leap.mp3", + title: "Leap", + artist: "nene", + license: "CC0", + /** beats a minute, measured: a 104-bar loop of exactly 160 s */ + bpm: 156, + /** the downbeat the tour starts on, in seconds into the file: bar 0, the full band */ + startS: 0.0154, +}; diff --git a/video/src/parts.tsx b/video/src/parts.tsx new file mode 100644 index 0000000..ab2e21e --- /dev/null +++ b/video/src/parts.tsx @@ -0,0 +1,180 @@ +import React from "react"; +import { continueRender, delayRender, Img, OffthreadVideo, Sequence, spring, staticFile } from "remotion"; +import { ease, mix, mixColor } from "./anim"; +import { BEAT, Clip, FPS, Pose, Punch } from "./timeline"; + +export type DeviceInfo = { image: string; width: number; height: number; screen: { x: number; y: number; w: number; h: number } }; +export type Take = { fps: number; ms: number; presses?: [number, string, number][] }; +export type Footage = Record<string, Take>; +export type Cam = { k: number; x: number; y: number; blur?: number; rot?: number }; + +/** The site's tokens (site/index.html) */ +export const LIGHT = { ground: "#e6e4da", text: "#1a1c1a", soft: "#3d403e", dim: "#5a5d5a", ink: "#476b00", accent: "#bef23c" }; +export const DARK = { ground: "#111310", text: "#e4e2d8", soft: "#c3c3ba", dim: "#969998", ink: "#bef23c", accent: "#bef23c" }; +export const theme = (k: number) => ({ + ground: mixColor(LIGHT.ground, DARK.ground, k), text: mixColor(LIGHT.text, DARK.text, k), + soft: mixColor(LIGHT.soft, DARK.soft, k), ink: mixColor(LIGHT.ink, DARK.ink, k), dim: mixColor(LIGHT.dim, DARK.dim, k), +}); +export const FONT = "Lexend, 'Noto Sans JP', 'Noto Sans SC', sans-serif"; + +/** Lexend (the launcher's and the site's) and the CJK menu fonts, before the first frame */ +const fonts = delayRender("fonts"); +Promise.all([ + new FontFace("Lexend", `url(${staticFile("fonts/menu.ttf")})`, { weight: "400" }), + new FontFace("Lexend", `url(${staticFile("fonts/title.ttf")})`, { weight: "600" }), + new FontFace("Noto Sans JP", `url(${staticFile("fonts/ja-menu.otf")})`), + new FontFace("Noto Sans SC", `url(${staticFile("fonts/zh-menu.otf")})`), +].map((f) => f.load())).then((loaded) => { + loaded.forEach((f) => (document.fonts as unknown as Set<FontFace>).add(f)); + continueRender(fonts); +}); + +/** A settled spring with a touch of overshoot: the motion of everything that arrives */ +export const pop = (frame: number, delay = 0, stiffness = 170, damping = 15) => + spring({ frame: frame - delay, fps: FPS, config: { damping, stiffness: stiffness * EASE_K, mass: 0.7 } }); + +/** How brisk every spring is: below 1 eases them, so nothing feels rushed */ +export const EASE_K = 0.72; + +/** Where the device is for a pose: scale k, then a shift */ +export const poseOf = (pose: Pose, d: DeviceInfo): Cam => { + if (pose === "hero") { + const k = 900 / 1796; + return { k, x: 640 - 999.5 * k, y: 540 - 1200 * k }; + } + if (pose === "split") { + const k = 700 / d.screen.w; + return { k, x: 70 - d.screen.x * k, y: 560 - (d.screen.y + d.screen.h / 2) * k }; + } + const k = 1000 / d.screen.w; + const main = { k, x: 110 - d.screen.x * k, y: 540 - (d.screen.y + d.screen.h / 2) * k }; + return pose === "away" ? { ...main, x: main.x - 2200 } : main; +}; + +export const mixCam = (a: Cam, b: Cam, t: number): Cam => ({ k: mix(a.k, b.k, t), x: mix(a.x, b.x, t), y: mix(a.y, b.y, t) }); + +/** The screen's digital zoom: the point brought to the middle, `zoom` times larger */ +export const punchAt = (frame: number, start: number, end: number, punches: Punch[] | undefined) => { + let z = 1, px = 0.5, py = 0.5; + for (const p of punches ?? []) { + const a = pop(frame, start + p.at * BEAT, 120, 18) * (1 - ease(frame, end - 10, end)); + z = mix(z, p.zoom, a); + px = mix(px, p.x, a); + py = mix(py, p.y, a); + } + return { z, px, py }; +}; + +/** What the screen shows: the clips, each in its own sequence, the later on top */ +export const Screen: React.FC<{ clips: Clip[]; d: DeviceInfo; zoom?: { z: number; px: number; py: number } }> = ({ clips, d, zoom }) => { + const z = zoom ?? { z: 1, px: 0.5, py: 0.5 }; + // The point (px, py) of the picture lands in the middle, as far as the edges allow + const ox = Math.min(0, Math.max(d.screen.w - d.screen.w * z.z, d.screen.w / 2 - z.px * d.screen.w * z.z)); + const oy = Math.min(0, Math.max(d.screen.h - d.screen.h * z.z, d.screen.h / 2 - z.py * d.screen.h * z.z)); + return ( + <div style={{ position: "absolute", left: d.screen.x, top: d.screen.y, width: d.screen.w, height: d.screen.h, + background: "#000", overflow: "hidden" }}> + <div style={{ position: "absolute", left: 0, top: 0, width: d.screen.w, height: d.screen.h, transformOrigin: "0 0", + transform: `translate(${ox}px, ${oy}px) scale(${z.z})` }}> + {clips.map((c, i) => ( + <Sequence key={i} from={c.at} durationInFrames={c.len} layout="none"> + {c.kind === "image" ? ( + <Img src={staticFile(c.src)} style={{ position: "absolute", inset: 0, width: "100%", height: "100%", imageRendering: "pixelated" }} /> + ) : ( + <OffthreadVideo muted src={staticFile(c.kind === "take" ? `footage/${c.take}.mp4` : c.src)} playbackRate={c.rate} + startFrom={Math.round((c.fromMs / 1000) * FPS)} + style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }} /> + )} + </Sequence> + ))} + </div> + <div style={{ position: "absolute", inset: 0, background: + "linear-gradient(135deg, rgba(255,255,255,0.07) 0%, rgba(255,255,255,0.0) 38%)" }} /> + </div> + ); +}; + +/** The Pixel 2 with a screen under its cut-out, at the camera's place */ +export const Device: React.FC<{ d: DeviceInfo; cam: Cam; children: React.ReactNode; shadow?: number }> = ({ d, cam, children, shadow = 0.22 }) => ( + <div style={{ position: "absolute", left: 0, top: 0, width: d.width, height: d.height, transformOrigin: "0 0", + transform: `translate(${cam.x}px, ${cam.y}px) scale(${cam.k}) rotate(${cam.rot ?? 0}deg)`, + filter: cam.blur ? `blur(${cam.blur}px)` : undefined }}> + {children} + <Img src={staticFile("device/pixel2-front.png")} style={{ position: "absolute", inset: 0, width: d.width, height: d.height, + filter: `drop-shadow(0 40px 60px rgba(0,0,0,${shadow}))` }} /> + </div> +); + +/** A label that lands: each word rises out of a mask, a lime bar wipes in, a line follows */ +export const Label: React.FC<{ + frame: number; at: number; end: number; text: string; sub?: string; dark: number; + x: number; y: number; width: number; size?: number; align?: "left" | "center"; anchor?: "middle" | "top"; +}> = ({ frame, at, end, text, sub, dark, x, y, width, size: wanted = 76, align = "left", anchor = "middle" }) => { + let size = wanted; + const t = theme(dark); + if (frame < at || frame >= end) return null; + const words = text.split(" "); + // A long word shrinks the label until it fits its column + const longest = Math.max(...words.map((w) => w.length)); + size = Math.min(size, Math.floor((width / (longest * 0.56)) * 1)); + const out = (i: number) => ease(frame, end - 9 + i * 0.5, end - 2 + i * 0.5); + return ( + <div style={{ position: "absolute", left: x, top: y, width, transform: anchor === "middle" ? "translateY(-50%)" : undefined, + fontFamily: FONT, textAlign: align }}> + <div style={{ height: 10, width: 84 * pop(frame, at, 200, 18), background: LIGHT.accent, borderRadius: 5, marginBottom: 26, + marginLeft: align === "center" ? "auto" : 0, marginRight: align === "center" ? "auto" : 0, opacity: 1 - out(0) }} /> + <div style={{ fontSize: size, lineHeight: 1.04, fontWeight: 600, color: t.text, letterSpacing: -1 }}> + {words.map((w, i) => { + const k = pop(frame, at + (i * BEAT) / 2, 210, 17); + return ( + <span key={i} style={{ display: "inline-block", overflow: "hidden", verticalAlign: "top", paddingBottom: 6, marginRight: "0.24em" }}> + <span style={{ display: "inline-block", transform: `translateY(${(1 - k) * 105 - out(i) * 110}%)` }}>{w}</span> + </span> + ); + })} + </div> + {sub && ( + <div style={{ fontSize: 30, lineHeight: 1.35, color: t.soft, marginTop: 18, + opacity: Math.min(ease(frame, at + 6, at + 14), 1 - out(0)), + transform: `translateY(${(1 - ease(frame, at + 6, at + 16)) * 16}px)` }}>{sub}</div> + )} + </div> + ); +}; + +/** File Transfer in a computer's browser: a window with the page */ +export const Laptop: React.FC<{ src: string; x: number; y: number; width: number; k: number; dark: number }> = ({ src, x, y, width, k }) => { + const h = (width * 800) / 1280; + return ( + <div style={{ position: "absolute", left: x, top: y + (1 - k) * 120, width, opacity: Math.min(1, k * 1.5), + transform: `scale(${0.9 + 0.1 * k})`, transformOrigin: "50% 100%", + borderRadius: 16, overflow: "hidden", background: "#f5f4ee", boxShadow: "0 30px 70px rgba(17,19,16,0.28), 0 0 0 1px rgba(0,0,0,0.08)" }}> + <div style={{ height: 40, display: "flex", alignItems: "center", gap: 8, padding: "0 16px", background: "#d9d7cc" }}> + {["#e0675a", "#e8b84a", "#7cbf5a"].map((c) => <div key={c} style={{ width: 12, height: 12, borderRadius: 6, background: c }} />)} + <div style={{ marginLeft: 18, flex: 1, height: 24, borderRadius: 12, background: "#f5f4ee", fontFamily: FONT, fontSize: 14, + color: "#5a5d5a", display: "flex", alignItems: "center", paddingLeft: 14 }}>192.168.1.42</div> + </div> + <Img src={staticFile(src)} style={{ display: "block", width, height: h }} /> + </div> + ); +}; + +/** ...and in a phone's */ +export const Phone: React.FC<{ src: string; x: number; y: number; height: number; k: number }> = ({ src, x, y, height, k }) => { + const w = (height * 780) / 1688; + return ( + <div style={{ position: "absolute", left: x, top: y + (1 - k) * 260, width: w + 24, height: height + 24, padding: 12, + borderRadius: 52, background: "#151715", opacity: Math.min(1, k * 2), + transform: `rotate(${(1 - k) * 8 - 3}deg)`, boxShadow: "0 30px 70px rgba(17,19,16,0.35)" }}> + <Img src={staticFile(src)} style={{ display: "block", width: w, height, borderRadius: 40 }} /> + </div> + ); +}; + +/** A screen on its own, a still of the launcher, for the grid and the pair */ +export const Tile: React.FC<{ src: string; x: number; y: number; w: number; k: number; label?: string; dark?: boolean }> = ({ src, x, y, w, k }) => ( + <div style={{ position: "absolute", left: x, top: y, width: w, height: (w * 3) / 4, borderRadius: w * 0.04, overflow: "hidden", + transform: `scale(${0.6 + 0.4 * k})`, opacity: Math.min(1, k * 1.6), boxShadow: "0 20px 50px rgba(17,19,16,0.25)" }}> + <Img src={staticFile(src)} style={{ display: "block", width: "100%", height: "100%" }} /> + </div> +); diff --git a/video/src/sounds.ts b/video/src/sounds.ts new file mode 100644 index 0000000..1c7ee88 --- /dev/null +++ b/video/src/sounds.ts @@ -0,0 +1,59 @@ +/** + * The launcher's own interface sounds (res/sounds/wood, the default UI Sounds) + * where a press is seen: a live take's presses from its index entry, and for a + * take of shots the moments one picture gives way to the next, both at the + * speed the clip plays its take. + */ +import { CLIPS, FPS, POWER_MS, SCENES, STARTS } from "./timeline"; +import type { Footage } from "./parts"; + +export type Cue = { frame: number; sound: string; volume?: number }; + +const SOUND: Record<string, string> = { + up: "move", down: "move", left: "move", right: "move", l1: "move", r1: "move", + a: "accept", x: "accept", y: "accept", start: "accept", menu: "accept", select: "accept", b: "back", +}; + +/** Shot takes: [ms into the take, sound], as video/footage/takes.py lays them out */ +const SHOT_PRESSES: Record<string, [number, string][]> = { + ingame: [[0, "accept"], [330, "move"], [660, "move"], [990, "move"], [1320, "move"], [1650, "move"]], + media: [[0, "accept"], [420, "move"], [840, "move"], [1260, "move"], [1680, "move"], [2100, "accept"]], + storage: [[2200, "accept"], [4200, "back"]], + cheevos: [[990, "move"], [1320, "reward"]], + mp4: [[2500, "accept"]], + lock: [[0, "accept"]], + ports: [[2000, "accept"]], + portmenu: [[0, "accept"]], + scraper: [[600, "accept"], [3600, "accept"]], + quicksave: [[0, "accept"], [1400, "accept"]], + cable: [[900, "move"], [1800, "accept"]], + ds: [[500, "move"], [1000, "move"], [1500, "accept"]], + cheer: [[300, "reward"]], + controls_ps1: [], +}; + +/** Scenes' own moments, in seconds into the scene: POWER pressed, and Zyper's + * reward sound when ZyperOS is on (src/beats.tsx) */ +const SCENE_PRESSES: Record<string, [number, string][]> = { + boot: [[2.3, "reward"]], + power: [[POWER_MS.off / 1000, "back"], [POWER_MS.on / 1000, "accept"], [POWER_MS.back / 1000, "reward"]], +}; + +export const SOUND_CUES = (footage: Footage): Cue[] => { + const out: Cue[] = []; + SCENES.forEach((sc, i) => { + for (const [s, sound] of SCENE_PRESSES[sc.id] ?? []) out.push({ frame: STARTS[i] + Math.round(s * FPS), sound, volume: sound === "reward" ? 0.7 : 1 }); + }); + for (const c of CLIPS) { + if (c.kind !== "take" || c.scene === "end") continue; + const presses: [number, string][] = + footage[c.take]?.presses?.map(([ms, key]) => [ms, SOUND[key] ?? "move"] as [number, string]) ?? SHOT_PRESSES[c.take] ?? []; + const spanMs = (c.len / FPS) * 1000 * c.rate; + for (const [ms, sound] of presses) { + // A press at the clip's very first frame belongs to the cut, not the screen + if (ms < c.fromMs || ms >= c.fromMs + spanMs || (ms === c.fromMs && ms !== 0)) continue; + out.push({ frame: c.at + Math.round(((ms - c.fromMs) / 1000 / c.rate) * FPS), sound, volume: sound === "reward" ? 0.7 : 1 }); + } + } + return out; +}; diff --git a/video/src/timeline.ts b/video/src/timeline.ts new file mode 100644 index 0000000..93dd689 --- /dev/null +++ b/video/src/timeline.ts @@ -0,0 +1,182 @@ +/** + * The tour's one source of timing, in frames at 30 fps. + * + * Music: src/music.ts, one of ZyperOS's own menu music tracks, started on a + * downbeat. A scene lasts whole bars of it, so every cut is on a downbeat, and + * its moments (a label, a punch-in, a phone arriving) are counted in its beats. + * Moving to another track means laying the scenes on its sections again. + * + * A scene is a short beat of one feature: what the screen plays (takes of + * video/footage/takes.py, cut and sped up), where the device is (a pose, and a + * punch-in on the part that matters), how it arrives (a whip or a spring), and a + * label of a few words, the site's own. + */ +import { MUSIC } from "./music"; + +export const FPS = 30; +export const BPM = MUSIC.bpm; +/** A beat in frames: 15 at 120 BPM, a fraction at most tempos */ +export const BEAT = (60 * FPS) / BPM; +export const MUSIC_START_S = MUSIC.startS; + +/** A piece of a take, from one ms to another, at `rate` times its speed (to: "fill" runs to the scene's end) */ +export type Step = + | { take: string; from: number; to?: number | "fill"; rate?: number } + | { image: string; beats?: number; ms?: number } + | { video: string; from: number; to: number } + | { gap: number }; + +/** Where the device is. "main": its screen large on the left; "split": smaller, + * room for a computer and a phone; "hero": whole, for the start and the end; + * "duo": two of them, light and dark; "away": off screen, for the language grid */ +export type Pose = "main" | "split" | "hero" | "duo" | "away"; + +/** A punch-in: the screen point (0 to 1) brought to the middle of the screen's place, `zoom` times larger */ +export type Punch = { at: number; x: number; y: number; zoom: number; until?: number }; + +export type Scene = { + id: string; + /** bars of the music, four beats each */ + bars: number; + pose: Pose; + enter?: "whip" | "spring" | "cut"; + screen: Step[]; + label?: string; + sub?: string; + /** beats into the scene the label waits */ + labelAt?: number; + punch?: Punch[]; + /** the computer and the phone of File Transfer */ + browser?: { laptop?: string; phone?: string; phoneAt?: number; laptopAt?: number }; + dark?: boolean; +}; + +/** A beat in ms, for the scenes whose moments are counted in beats */ +const B = 60000 / BPM; +/** The power scene's moments: POWER at beat 1, back in the game on the downbeat of + * its fourth bar, the second POWER 2 s before that (src/beats.tsx shows them) */ +export const POWER_MS = { off: B, on: 12 * B - 2000, back: 12 * B }; + +/* The order follows Leap's sections (LICENSES.md): the full band for bars 0 to + * 15, the break at 16 to 19 for the power off and on, back on the downbeat + * of bar 19 and the band at 20, the video snap turning on bar 21, the calm of + * 24 to 27 for the in-game menu, File Transfer from 28, light and dark whipping in + * on bar 52 where the theme returns, and the end on bar 68, the phrase end + * before the next break. */ +export const SCENES: Scene[] = [ + { id: "open", bars: 3, pose: "main", enter: "whip", screen: [{ take: "fast-shelf", from: 3400, to: "fill", rate: 1.2 }], + label: "Your whole retro library.", sub: "Fast, light firmware for the GKD Pixel 2", labelAt: 1 }, + // ZyperOS on in about 2 s against stock firmware's 30 to 40 (Tarik's own timing) + { id: "boot", bars: 4, pose: "split", enter: "whip", + screen: [{ gap: 300 }, { video: "boot/zyperos-fly.mp4", from: 0, to: 2000 }, { take: "home", from: 150, to: "fill" }], + label: "On in 2 seconds", sub: "Stock firmware: 30 to 40" }, + { id: "home", bars: 2, pose: "main", screen: [{ take: "home", from: 2100, to: "fill", rate: 1.28 }], + label: "Every system in pixel art" }, + { id: "art", bars: 3, pose: "main", screen: [{ take: "n64", from: 0, to: 1000 }, { take: "scraper", from: 0, to: "fill", rate: 1.35 }], + label: "Box art it finds itself", sub: "libretro, ScreenScraper, SteamGridDB" }, + { id: "search", bars: 3, pose: "main", screen: [{ take: "search", from: 3200, to: 6400, rate: 1.6 }, { take: "search", from: 9600, to: "fill", rate: 1.6 }], + label: "Search any game", sub: "A few letters, every system" }, + { id: "launch", bars: 1, pose: "main", enter: "cut", screen: [{ image: "img/sonic2.png" }], + label: "Starts the moment you press A" }, + // POWER in a game, the power-off, dark; POWER, and the same frame 2 s later + { id: "power", bars: 4, pose: "main", + screen: [{ image: "img/sonic2.png", ms: POWER_MS.off }, { take: "poweroff", from: 0, to: 1400 }, + { gap: POWER_MS.on - POWER_MS.off - 1400 + 300 }, + { video: "boot/zyperos-fly.mp4", from: 0, to: 1700 }, { image: "img/sonic2.png" }], + label: "Turn it off.", sub: "Back in the game in 2 seconds." }, + // Rest on a game: the bar fills for a second, and the cover turns into its + // video snap on the next downbeat (the take holds 600 ms before the bar) + { id: "snap", bars: 2, pose: "main", enter: "whip", screen: [{ take: "snap", from: Math.max(0, 600 - (4 * B - 1000)), to: "fill" }], + label: "Video snaps", sub: "Rest on a game, and its cover turns into the game", punch: [{ at: 0, x: 0.5, y: 0.42, zoom: 1.45 }] }, + { id: "quicksave", bars: 2, pose: "main", screen: [{ take: "quicksave", from: 0, to: "fill", rate: 0.9 }], + label: "Quick save, quick load", sub: "SELECT + R1, SELECT + L1", punch: [{ at: 0, x: 0.5, y: 0.86, zoom: 1.9 }] }, + { id: "controls", bars: 2, pose: "main", screen: [{ take: "ingame", from: 0, to: 2000, rate: 1.6 }, { take: "controls-ps1", from: 0, to: "fill", rate: 0.75 }], + label: "Every control, in the game" }, + { id: "cover", bars: 2, pose: "main", screen: [{ take: "details", from: 4700, to: 7100, rate: 1.6 }, { take: "media", from: 300, to: "fill", rate: 1.2 }], + label: "Pick a game's cover" }, + { id: "wifi", bars: 5, pose: "split", enter: "whip", screen: [{ take: "wifi-xfer", from: 0, to: "fill", rate: 0.66 }], + label: "Wi-Fi File Transfer", sub: "Any browser, behind a PIN", browser: { laptop: "img/portal.png", phone: "img/portal-phone.png", laptopAt: 2, phoneAt: 8 } }, + { id: "cable", bars: 3, pose: "split", enter: "cut", screen: [{ take: "cable", from: 0, to: "fill", rate: 0.6 }], + label: "Or over the USB cable", sub: "Charge only, or File Transfer", browser: { laptop: "img/portal.png", laptopAt: -1 } }, + { id: "hotspot", bars: 2, pose: "split", enter: "cut", screen: [{ take: "hotspot", from: 0, to: "fill", rate: 0.9 }], + label: "No Wi-Fi around?", sub: "It makes its own network for your phone", browser: { phone: "img/portal-pin.png", phoneAt: 1 } }, + // An empty card, the way in, the games arriving: a little story after File Transfer + { id: "empty", bars: 2, pose: "main", enter: "whip", screen: [{ take: "empty", from: 0, to: "fill" }], + label: "An empty card shows you how", punch: [{ at: 2, x: 0.5, y: 0.6, zoom: 1.08 }] }, + { id: "dropin", bars: 2, pose: "split", screen: [{ take: "wifi-xfer", from: 0, to: "fill", rate: 0.66 }], + label: "Drop your games in", browser: { laptop: "img/portal.png", laptopAt: 1 } }, + { id: "cheer", bars: 2, pose: "main", screen: [{ take: "cheer", from: 0, to: "fill" }], + label: "And they're on the shelf", punch: [{ at: 1, x: 0.48, y: 0.3, zoom: 1.22 }] }, + { id: "ring", bars: 2, pose: "main", enter: "whip", screen: [{ take: "xfer-ring", from: 0, to: "fill", rate: 0.8 }], + label: "Keeps going while you play", punch: [{ at: 1, x: 0.87, y: 0.083, zoom: 3.4 }] }, + { id: "update", bars: 2, pose: "main", screen: [{ take: "update", from: 0, to: "fill", rate: 0.75 }], + label: "System Update", sub: "Over Wi-Fi or the cable" }, + { id: "battery", bars: 2, pose: "main", screen: [{ take: "charge", from: 0, to: 1600 }, { take: "battery", from: 3200, to: "fill" }], + label: "Battery care", sub: "A charge limit, and the time left" }, + { id: "storage", bars: 2, pose: "main", screen: [{ take: "storage", from: 0, to: "fill", rate: 1.6 }], + label: "Storage, as a phone shows it" }, + { id: "themes", bars: 2, pose: "duo", enter: "whip", screen: [], label: "Light and dark", sub: "Beard and Salt & Pepper" }, + { id: "cheevos", bars: 2, pose: "main", screen: [{ take: "cheevos", from: 700, to: "fill", rate: 0.96 }], + label: "RetroAchievements", punch: [{ at: 2, x: 0.5, y: 0.86, zoom: 1.7 }] }, + { id: "mp4", bars: 2, pose: "main", screen: [{ take: "mp4", from: 1500, to: 3900, rate: 1.2 }, { take: "lock", from: 0, to: "fill", rate: 0.85 }], + label: "MP4", sub: "Music, books and videos. X locks the screen." }, + { id: "ports", bars: 2, pose: "main", enter: "whip", screen: [{ take: "ports", from: 0, to: 3200, rate: 1.6 }, { take: "portmenu", from: 0, to: "fill" }], + label: "PortMaster, one press" }, + { id: "ds", bars: 2, pose: "main", screen: [{ take: "ds", from: 0, to: "fill", rate: 0.72 }], + label: "Nintendo DS your way", sub: "Screen layouts, and a Game Language" }, + { id: "languages", bars: 2, pose: "away", screen: [], label: "Eight languages" }, + { id: "firststart", bars: 2, pose: "main", screen: [{ take: "firststart", from: 400, to: "fill", rate: 1.6 }], + label: "A first start that asks" }, + // Two bars and two beats: the downbeat of bar 68 rings while the music fades + { id: "end", bars: 2.5, pose: "hero", screen: [{ take: "fast-shelf", from: 3600, to: "fill", rate: 1 }] }, +]; + +/** A scene's length in beats of the music */ +export const beatsOf = (sc: Scene) => Math.round(sc.bars * 4); +const CUM = SCENES.reduce<number[]>((acc, sc) => [...acc, acc[acc.length - 1] + beatsOf(sc)], [0]); +/** Scene starts, in frames, each on a beat */ +export const STARTS: number[] = CUM.slice(0, -1).map((b) => Math.round(b * BEAT)); +export const TOTAL = Math.round(CUM[CUM.length - 1] * BEAT); +export const sceneEnd = (i: number) => (i + 1 < STARTS.length ? STARTS[i + 1] : TOTAL); + +export type Clip = + | { kind: "take"; take: string; at: number; len: number; fromMs: number; rate: number; scene: string } + | { kind: "video"; src: string; at: number; len: number; fromMs: number; rate: number; scene: string } + | { kind: "image"; src: string; at: number; len: number; scene: string }; + +/** The scenes' steps laid end to end: a clip each, the last one running to its scene's end */ +export const CLIPS: Clip[] = SCENES.flatMap((sc, i) => { + const start = STARTS[i]; + const end = sceneEnd(i); + let at = start; + return sc.screen.flatMap((st, k): Clip[] => { + const last = k === sc.screen.length - 1; + if ("gap" in st) { + at += Math.round((st.gap / 1000) * FPS); + return []; + } + if ("video" in st) { + const len = last ? end - at : Math.round(((st.to - st.from) / 1000) * FPS); + const c: Clip = { kind: "video", src: st.video, at, len, fromMs: st.from, rate: 1, scene: sc.id }; + at += len; + return [c]; + } + if ("image" in st) { + const len = last ? end - at : st.ms !== undefined ? Math.round((st.ms / 1000) * FPS) : Math.round((st.beats ?? 1) * BEAT); + const c: Clip = { kind: "image", src: st.image, at, len, scene: sc.id }; + at += len; + return [c]; + } + const rate = st.rate ?? 1; + const len = st.to === "fill" || st.to === undefined || last ? end - at : Math.round(((st.to - st.from) / 1000 / rate) * FPS); + const c: Clip = { kind: "take", take: st.take, at, len, fromMs: st.from, rate, scene: sc.id }; + at += len; + return [c]; + }); +}); + +export const sceneAt = (frame: number) => { + let i = 0; + while (i + 1 < STARTS.length && STARTS[i + 1] <= frame) i++; + return i; +}; diff --git a/video/tsconfig.json b/video/tsconfig.json new file mode 100644 index 0000000..a53d457 --- /dev/null +++ b/video/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true, + "lib": ["DOM", "ES2022"] + }, + "include": ["src", "remotion.config.ts"] +}