From 661faeb320c9bb8ba73a592e316ec268ed0535f0 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Thu, 15 Oct 2026 18:39:57 +0800 Subject: [PATCH] feat(live): assemble a live as a product and export it through the pipeline A live is the product `live`: one chart of a music, naming the products it plays with by their manifest paths. - `live` (subject `/[/]`, manifest `lives/_[_].json`) starts from the chart and the jacket. Its files are `live.json`, the chart as shipped (`chart.json`) and the converted notes with and without the mirror option. `live.json` holds the counts of the converted chart, the music's master rows, the band, the jacket's object id and `parts`: the manifests of the scene, the note highway with the default note skin and effect set, the default sounds and the music. - `export live` asks the depot's pipeline for the live and its parts and writes the product layout. `--live-option` adds the products of other note skins, note effect sets (with their Light variants at quality Low) and note sound sets. `--format` is the web format of the BGM, and the command takes the depot options of `export assets`. - `live.music` takes the BGM format as the parameter `audio`, so `--format` reaches only the stages that take an audio format. - The chart reader and the live share the music record, the band choice and the chart key. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 8 +- docs/cli.md | 12 +- docs/commands.md | 77 +++++------ docs/configuration.md | 10 +- docs/export.md | 10 +- docs/products.md | 51 +++++++- docs/stages.md | 3 +- src/nnnotes/cli/assets.py | 5 + src/nnnotes/cli/export.py | 94 ++++++++----- src/nnnotes/content/live.py | 204 ++++++++++++++++++++++++++--- src/nnnotes/content/livenotes.py | 14 +- src/nnnotes/content/score.py | 40 +----- src/nnnotes/pipeline/live.py | 111 ++++++++++++++-- tests/cli/test_cli.py | 75 +++++++++-- tests/content/test_live.py | 100 ++++++++++++++ tests/content/test_live_options.py | 13 +- tests/helpers/live.py | 66 ++++++++++ tests/pipeline/test_live.py | 87 +++++++++++- 18 files changed, 789 insertions(+), 191 deletions(-) create mode 100644 tests/content/test_live.py create mode 100644 tests/helpers/live.py diff --git a/docs/architecture.md b/docs/architecture.md index 42acf20..c6c9af6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,10 +36,10 @@ decrypted bundles in the depot's `downloads/` directory. ## Two ways to export -- **One subject.** `export episode`, `export story`, `export live`, `export shader`, `export audio`, `export ui`, - `site build` and `music-data` read what one subject needs through the Session and write their output directory - directly ([Export stories and lives](export.md)). `export live2d`, `export spot` and `export room` ask the pipeline - below for the products of one subject ([products](products.md)). +- **One subject.** `export episode`, `export story`, `export shader`, `export audio`, `export ui`, `site build` + and `music-data` read what one subject needs through the Session and write their output directory directly + ([Export stories and lives](export.md)). `export live`, `export live2d`, `export spot` and `export room` ask the + pipeline below for the products of one subject ([products](products.md)). - **Every asset.** `export assets` splits the work into stages ([stages](stages.md)). A task is one stage applied to one subject (a bundle, a content id, a view); its key covers exactly what its output depends on: the stage and converter versions, the input contents and the options ([contracts](contracts.md)). Results and their files are diff --git a/docs/cli.md b/docs/cli.md index 3baa75e..f5fb4b8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -210,19 +210,23 @@ an ADV episode -> story directory (episode, audio, scene, UI, media, videos) + i ## nnnotes export live -a live (music + difficulty) -> self-contained live directory +a live (music + difficulty) -> the live product and the products it plays with (scene, note highway, sounds, music) | Argument | Description | | --- | --- | | `music_id` | MasterLiveMusic id | | `--difficulty` `DIFFICULTY` | chart difficulty (default expert) (one of `easy`, `normal`, `hard`, `expert`) | -| `--format` `FORMAT` | audio file format (default flac) (one of `flac`, `ogg`, `wav`) | -| `--flac-level` `N` | FLAC compression level 0-12 of --format flac (default 8; every level decodes to the same samples) | -| `--fonts` `FONTS` | start canvas text: open (default): layout and style only, no font data (your fonts are not used); game: also the game's TMP fonts (needs the 'fonts' extra) (one of `open`, `game`) | +| `--format` `FORMAT` | web audio format of the BGM (default aac) (one of `aac`, `flac`, `mp3`, `opus`, `vorbis`) | +| `--flac-level` `N` | FLAC compression level 0-12 of the decoded sounds (default 8; every level decodes to the same samples) | | `--band` `BAND` | band of the LightWeight background and start timeline (default: band of the music's first vocal character) | | `--leader-card` `LEADER_CARD` | deck centre MasterMemberCard id (its character's band) | | `--live-option` `OPTION[=VALUES]` | also export the files of a Live option's variants (repeatable): MirrorChart, MeasureLineDisplay, NoteDesignId, NoteEffectId, LiveQuality, NoteSePatternId (every value of the master data, or =v,v...), defaults (the option defaults and ranges only) or all | | `-o`, `--out` `OUT` | output directory (required) | +| `--stored-catalog` `LABEL\|SHA` | a catalog version of the depot (`catalog versions`; default: the current catalog) | +| `--workers` `N` | worker processes (default: the usable CPUs) | +| `--link` `LINK` | as export assets --link (one of `auto`, `clone`, `hard`, `copy`) | +| `--explain` | print every setting of the run first | +| `--dry-run` | print the plan, run nothing | At most one of `--band`, `--leader-card`. diff --git a/docs/commands.md b/docs/commands.md index 87191f5..d4f3227 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -427,63 +427,64 @@ nnnotes apk settings -o OUT.json The game's render settings from the APK's boot data as JSON: `colorSpace`, `defaultPipeline`, `qualityLevels`, `qualityPerPlatform`, `pipelines`, `renderers`, `postProcessData`. Needs `[paths] apk`. The MonoBehaviours are read with the type trees that ship with nnnotes; an APK whose classes do not match them stops the command with exit -status 2 and a line naming the class, the game version and the Unity version (as `export story`, `export live` and `site build`). +status 2 and a line naming the class, the game version and the Unity version (as `export story` and `site build`). ## export live ``` -nnnotes export live MUSIC_ID -o OUT [--difficulty easy|normal|hard|expert] [--format flac|ogg|wav] - [--flac-level N] [--fonts open|game] [--band BAND | --leader-card CARD_ID] +nnnotes export live MUSIC_ID -o OUT [--difficulty easy|normal|hard|expert] [--format aac|flac|mp3|opus|vorbis] + [--flac-level N] [--band BAND | --leader-card CARD_ID] [--live-option OPTION[=VALUES] ...] ``` -One chart (music + difficulty, default `expert`) as a self-contained directory, the format ournotes-player reads: +One chart (music + difficulty, default `expert`) and the products it plays with, in the product layout +([products](products.md#lives)): ``` -OUT/score/ the chart as shipped, the converted runtime notes, master rows (title and band - names in every language), summary.json -OUT/audio// the BGM cue sheet decoded per cue + cues.json, streams.json -OUT/audio/live-audio.json the live's sounds (BGM, note SE, live SE: the start and finish cheers and the finish - sound of every result, all perfect, full combo, assist full combo and clear) with - their CRI routing; their cue sheets decoded next to the BGM -OUT/livescene/ scene graph, cameras, lane, background, start timeline, textures and shaders -OUT/livenotes/ note, line and effect prefabs, skins, clips, particle systems, textures and shaders -OUT/liveui/ the start canvas in [catalog] language: strings, text records or fonts, sprites -OUT/live.json index of the above +OUT/lives/_[_].json the live: the chart as shipped, its converted notes (also mirrored), + the music's master rows, the jacket, the manifest paths of the parts +OUT/scenes/default.json the live scene: scene graph, cameras, lane, UI assets, the start + timeline and LightWeight background of every band, the built-in + graphics settings +OUT/notes/default.json the note highway: note, line and lane effect prefabs, settings +OUT/noteskins/.json a note skin +OUT/noteeffects/.json a note effect set +OUT/sounds/.json the note and live sounds with their CRI routing and volumes +OUT/music/.json the BGM in a web format +OUT/assets/. every file the manifests name, once ``` -`--format` (default `flac`) is the format of every decoded cue sheet and `--flac-level` its FLAC compression level -(as for `export audio`). - -The start canvas follows the client language `[catalog] language` (`ja`, `en`, `zh-Hant`, `zh-Hans` or `ko`): its -text table column, fonts and line spacing. `--fonts open` (default) writes each text's layout and style (size, -alignment, wrapping, spacing, colours, outline / underlay from its material) and only the names of the game's fonts -and materials; `--fonts game` also writes the game's TextMesh Pro fonts reduced to the characters shown, with their -atlas textures (needs the optional `fonts` dependencies: `pip install 'nnnotes[fonts]'`). +`--format` (default `aac`) is the web format of the BGM: `aac` (AAC-LC in MP4), `opus`, `vorbis`, `mp3`, or `flac`, +the decoded FLAC itself. `--flac-level` is the FLAC compression level the cue sheets are decoded at (every level +decodes to the same samples). The game takes the band of the background and start timeline from the player's deck centre. Without a deck the band is `--band`, or the band of the character of `--leader-card` (a `MasterMemberCard` id), or by default the band -of the music's first vocal character; the choice is recorded in `livescene/scene.json`. Prints the index with a -summary. +of the music's first vocal character; `live.json` `band` records the choice, and the live's manifest path holds the +band only when one of the two flags is given. A band whose start timeline or background the catalog does not hold is +a usage error. -The directory holds what a live reads with the game's default options (option preset 1 of a fresh profile). -`--live-option` (repeatable) adds the files of other values of the options that select files, so that a player can -offer them; the options are named as in the game (the names ournotes-player's settings use): +The products hold what a live reads with the game's default options (option preset 1 of a fresh profile), and always +the mirrored notes, the bar line view and the option tables. `--live-option` (repeatable) adds the products of other +values of the options that select files, so that a player can offer them; the options are named as in the game (the +names ournotes-player's settings use): | `--live-option` | Adds | |---|---| -| `MirrorChart` | `score/.mirror.notes.json`, the chart converted with lanes and flick directions mirrored (the game mirrors while it converts the chart); `live.json` `notesMirror` | -| `NoteDesignId` | the other note skins (`MasterLiveNoteSkin`): `livenotes/notes.json` `noteSkins` (skin asset name -> a record like `noteSkin`) and `settings.skins` (`NoteDesignId` -> skin asset name) | -| `NoteEffectId` | the other note effect sets (`MasterLiveNoteEffectSkin`; the lane effects are the same for every set): their assets in `livenotes/notes.json` `assets`, and `settings.effects` (`NoteEffectId` -> effect set name) | -| `LiveQuality` | the qualities of `MasterLiveQualitySettings`; at the Low quality (2) the game loads the `Light` variant of a note effect set where the catalog has one (else the set itself), whose assets are added (and `settings.effects`) | -| `NoteSePatternId` | the other note sound sets (`MasterLiveNoteSe` groups): `audio/live-audio.json` `noteSe.groups` (set -> `LiveNoteSeType` -> sound id) and their sounds, decoded next to the others | -| `MeasureLineDisplay` | the bar lines: `livenotes/notes.json` `prefabs.bar_line_view`, the bar line view the live scene's bar line container instantiates (a node list like the note view prefabs), and its sprite's texture; the bar times and the BPM and time signature changes are in the score's notes (`barLineTimeMs`, `bpmChanges`, `barChanges`) with or without the option | -| `defaults` | only `settings.optionDefaults` / `optionRanges` of every option the player offers (note timing, the live and note sound volumes and mutes, the note sound set and per-type sounds, ...) | +| `NoteDesignId` | the other note skins (`MasterLiveNoteSkin`): a `live.noteskin` product each | +| `NoteEffectId` | the other note effect sets (`MasterLiveNoteEffectSkin`; the lane effects are the same for every set): a `live.noteeffect` product each | +| `LiveQuality` | the qualities of `MasterLiveQualitySettings`; at the Low quality (2) the game loads the `Light` variant of a note effect set where the catalog has one (else the set itself), whose `live.noteeffect` products are added | +| `NoteSePatternId` | the other note sound sets (`MasterLiveNoteSe` groups): the `live.sounds` product `NoteSePatternId=`, which holds them and the default set | +| `MirrorChart`, `MeasureLineDisplay`, `defaults` | nothing: the products always hold their files | | `all` | every option above with every value | An option without values offers every value the master data has; `NAME=v,v,...` offers those values (the default -value is always included; a value the master data does not have is a usage error). Any option also writes the -`defaults` tables. Without `--live-option` the directory holds the files of the default options only. +value is always included; a value the master data does not have is a usage error). + +The command runs the depot's pipeline for these products alone, as `export live2d` does, writes the same reports in +`OUT/_reports/` and takes `--stored-catalog`, `--workers`, `--link`, `--explain` and `--dry-run` as `export assets` +does. Needs `[paths] apk` (the built-in graphics and sound settings and the CRI ACF are in the APK), master data, +`vgmstream` and `ffmpeg`. ## site build @@ -580,8 +581,8 @@ SITE/assets/.[.gz|.br] content-addressed files shared by all and nnnotes' own code), so a later build with the same `--tmp` reuses what is unchanged and writes the same files faster. Deleting it is safe at any time outside a build; the next build then makes everything again. -- `--band` / `--leader-card`: as for `export live`, for every chart. `--fonts`: for charts as for `export live` (the site stores - no start canvas files); for stories where the glyphs of the story text come from (see Stories). +- `--band` / `--leader-card`: as for `export live`, for every chart. `--fonts`: for stories, where the glyphs of the + story text come from (see Stories); the site stores no start canvas of a chart. - `--live-option` (repeatable): as for `export live`, for the charts of this run (with `--pair` or `--all`). The live directories carry the variants' files, and a chart's read set is the union of its read set with the default options and its read sets with the settings of each offered variant (every combination of the offered mirror, diff --git a/docs/configuration.md b/docs/configuration.md index 9aedab4..615aff4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -86,7 +86,7 @@ exist when they are given. | `[master] key` | `NNNOTES_MASTER_KEY` | — | Rijndael-256 key of the master data files: 32 bytes as 64 hex digits | | `[master] iv` | `NNNOTES_MASTER_IV` | — | Rijndael-256 CBC initialization vector: 32 bytes as 64 hex digits | | `[catalog] region` | `NNNOTES_CATALOG_REGION` | `--region` | name of one `[servers.]` table | -| `[catalog] language` | `NNNOTES_CATALOG_LANGUAGE` | `--language` | catalog language: the `` of `catalog__.bin`: `ja`, `en`, `zh-Hant`, `zh-Hans` or `ko`; also the client language of `export live`, `export story` and `site build` (the text field, fonts and line spacing of their UI) and of the model labels of `site build --live2d` | +| `[catalog] language` | `NNNOTES_CATALOG_LANGUAGE` | `--language` | catalog language: the `` of `catalog__.bin`: `ja`, `en`, `zh-Hant`, `zh-Hans` or `ko`; also the client language of `export story` and `site build` (the text field, fonts and line spacing of their UI) and of the model labels of `site build --live2d` | | `[catalog] version` | `NNNOTES_CATALOG_VERSION` | `--catalog-version` | optional international resource version; unset: query the configured region API, or use legacy `main` when no API is configured; JP uses its own discovery | | `[servers.] name` | `NNNOTES_SERVERS__NAME` | — | label of the region in `catalog browse` (default: the region name) | | `[servers.] provider` | `NNNOTES_SERVERS__PROVIDER` | — | `international` or `jp`; empty selects `jp` for the region named jp, international otherwise | @@ -156,7 +156,7 @@ in, and bundles that ship inside the APK can only be read with it. "Master data" means the decoded master data directory of the region: the `--master` flag, else `[servers.] master` of `[catalog] region`, else `[paths] master`. -`--fonts game` (`export story`, `export live`, `site build`) also needs the optional `fonts` dependencies (`pip install 'nnnotes[fonts]'`), +`--fonts game` (`export story`, `site build`) also needs the optional `fonts` dependencies (`pip install 'nnnotes[fonts]'`), and so does `site build --story` / `--all-stories` with either font source (it generates the TextMesh Pro font assets of the story text); it is not a setting. @@ -178,8 +178,8 @@ story text); it is not a setting. | `export audio` | catalog, `[paths] apk`, `vgmstream`, `ffmpeg` (not for `--format wav`) | | `apk hca-key` | `[paths] apk` | | `apk settings` | `[paths] apk` | -| `export live` | catalog, `[catalog] language`, master data, `[paths] apk`, `vgmstream`, `ffmpeg` | -| `site build --pair` / `--all` | as `export live`, plus `[paths] player`, `node`; with `--site-region` / `--all-regions` each region's `[servers.]` table (its `cdn` for what must be downloaded) and master data (`[servers.] master`; `[paths] master` for at most one region) | +| `export live` | catalog, master data, `[paths] apk`, `vgmstream`, `ffmpeg` | +| `site build --pair` / `--all` | as `export live`, plus `[catalog] language`, `[paths] player`, `node`; with `--site-region` / `--all-regions` each region's `[servers.]` table (its `cdn` for what must be downloaded) and master data (`[servers.] master`; `[paths] master` for at most one region) | | `site build --live2d` / `--all-live2d` | catalog (bundles from the CDN of the site's first region), `[paths] apk`, `[paths] player`; not `node`; master data only for the model names (optional: without it `models.json` has no names) | | `site build --player-only` / `--reingest-json` | `[paths] player` | | `music-data` | catalog, `[master] key` + `iv` (not with `--decoded-master`); `--master-files` also `[catalog] region`; `--apk-master` also `[paths] apk`; `--decoded-master` also `[catalog] region` and master data with its `MasterManifest.json`; `[paths] apk` (optional otherwise) for the client version | @@ -235,7 +235,7 @@ nnnotes: game API call Version to [servers.tw] api failed: UNAVAILABLE (server u can hold several alternatives separated by `|`, and any one of them can be used as `cdn` / `api`. - **Client version**: the version of your game client (`versionName` of its `base.apk`). The game's API rejects a version older than the one it accepts (`game error code CLIENT_UPDATE_REQUIRED`). -- **`[paths] apk`**: the `base.apk` of your own installation of the game. `apk settings`, `export story`, `export live` and `site build` +- **`[paths] apk`**: the `base.apk` of your own installation of the game. `apk settings`, `export story` and `site build` read MonoBehaviours of its boot data with type trees that ship with nnnotes, one set per Unity version (currently game version 1.0.1, Unity 6000.3.12f1). With an APK whose classes do not match them, these commands stop with exit status 2 and a line naming the class, the game version and the Unity version. diff --git a/docs/export.md b/docs/export.md index 5ddeded..3a74d75 100644 --- a/docs/export.md +++ b/docs/export.md @@ -33,14 +33,14 @@ Details: [export episode](commands.md#export-episode), [export story](commands.m nnnotes export live 100001 --difficulty expert -o out/lives/100001_expert ``` -One chart as the self-contained directory ournotes-player reads: the chart as shipped and its converted runtime -notes, the 3D scene, the note and effect assets, the BGM and sound effects with their routing, and the start canvas -in the configured language. +One chart and the products it plays with, in the layout ournotes-player reads: the chart as shipped and its +converted runtime notes, the 3D scene, the note highway with its skins and effect sets, the sound effects with their +routing, and the BGM in a web format. - `--band BAND` or `--leader-card CARD_ID` picks the band of the background and start timeline (default: the band of the music's first vocal character). -- `--live-option NAME[=VALUES]` (repeatable) adds the files of other values of the live options, such as - `MirrorChart` or `NoteDesignId`; `--live-option all` adds every option with every value. +- `--live-option NAME[=VALUES]` (repeatable) adds the products of other values of the live options, such as + `NoteDesignId` or `NoteSePatternId`; `--live-option all` adds every option with every value. Details: [export live](commands.md#export-live). diff --git a/docs/products.md b/docs/products.md index cb8c8fc..d06daad 100644 --- a/docs/products.md +++ b/docs/products.md @@ -72,7 +72,8 @@ object), or the reason code of a generic, unsupported or failed object. ## Product layout -A product layout places products for a player (`export assets --layout product`, `export live2d --player`): +A product layout places products for a player (`export assets --layout product`, `export live`, +`export live2d --player`): - every file of a product and every file of the objects of its object table at `assets/.` (a file several products or objects share is written once); @@ -81,9 +82,9 @@ A product layout places products for a player (`export assets --layout product`, A command asks for products in one of two ways: `export assets --layout product` makes, with each product stage, the products of the catalog keys the selected bundles hold (the stage's `select`; a stage that reads master data has none -without it); `export live2d`, `export spot` and `export room` ask for the products of one subject. `export live2d` -without `--player`, `export spot` and `export room` place the files of their products at their own paths instead (the -`files` placement: the Cubism SDK files as a directory, a spot's files). +without it); `export live`, `export live2d`, `export spot` and `export room` ask for the products of one subject. +`export live2d` without `--player`, `export spot` and `export room` place the files of their products at their own +paths instead (the `files` placement: the Cubism SDK files as a directory, a spot's files). ## Product manifest: nnnotes.product/1 @@ -223,7 +224,7 @@ either, is `live.music`: its subject is a music id (`MasterLiveMusic._id`), its manifest `music/.json` and its input `master` the music's rows of `MasterLiveMusic`, `MasterSound` and `MasterSoundCueSheet`. Its files are `music.json` (`format` 1, `musicId`, `music`: the BGM's `soundId`, `volume` and `startTimeSec`, and `sounds`: the BGM's entry) -and each layer's file in the web format of the parameter `format`: `aac` (AAC-LC 160 kbit/s in MP4, the default), +and each layer's file in the web format of the parameter `audio`: `aac` (AAC-LC 160 kbit/s in MP4, the default), `opus`, `vorbis`, `mp3`, or `flac`, the decoded FLAC itself. An AAC layer records `encoderDelay`, the priming samples of the MP4 edit list, which a decoder that does not apply the edit list outputs first. The encode is bit-exact; the atom `audio.encode` names the ffmpeg build. @@ -306,3 +307,43 @@ lists that component as contained in the root GameObject holding it (`in`). A live plays the timeline and background of one band. The object table and the shader files are kept as in the note highway. The `product` layout makes `live.scene` when the selection holds the scene and the master data is there. + +## Lives + +A live is one chart of a music. It is a product of `nnnotes.pipeline.live` that holds what belongs to the chart and +names the products it plays with: + +| Product | Subject | Manifest | Roots | Document | +|---|---|---|---|---| +| `live` | `/[/]`: a `MasterLiveMusic` id, `easy`, `normal`, `hard` or `expert`, and optionally a band | `lives/_[_].json` | the chart `Live/MusicScore/<_musicScoreTextFileName>` and the jacket `Image/Jacket/<_jacketAssetName>` | `live.json` | + +The band is the one whose start timeline and LightWeight background the live plays. The game takes it from the +player's deck centre; a subject without a band takes the band of the music's first vocal character +(`MasterLiveMusic._vocalCharacterIDs[0]`, its `MasterCharacter._bandID`). The input `master` holds the music's rows +(`MasterLiveMusic`, its `MasterLiveMusicScore` row of each difficulty, the title and band names in every language from +`MasterText`, the BGM and jingle `MasterSound` rows with their cue sheets), the difficulty, the band and the manifest +paths of the parts. + +| File | | +|---|---| +| `live.json` | the document below | +| `chart.json` | the chart as shipped: the `TextAsset` of the chart key, un-gzipped | +| `notes.json` | the runtime notes the game converts the chart to, with `source`: the chart key, the music, the difficulty, the `MasterLiveMusicScore` id and the full combo count | +| `notes.mirror.json` | the same with the `MirrorChart` option on: lanes and flick directions mirrored | + +`live.json` has `format` 1 and + +| Field | | +|---|---| +| `musicId`, `difficulty` | the chart | +| `band` | `{band, source, note}`: the band, what it was taken from, and that the game takes it from the deck centre | +| `chart`, `notes`, `notesMirror` | the files above | +| `judgementNoteCount`, `noteCount`, `lastNoteTimeMs` | of the converted chart | +| `fullComboCount` | `MasterLiveMusicScore._fullComboCount` | +| `master` | the music's rows of the input `master` | +| `jacket` | the object id of the jacket Sprite | +| `parts` | `{part: manifest path}` of the products the live plays with: `scene` (`scenes/default.json`), `notes` (`notes/default.json`), `noteskin` and `noteeffect` (the note skin and effect set of the option defaults, `NoteDesignId` and `NoteEffectId` of preset 1), `sounds` (`sounds/default.json`) and `music` (`music/.json`). A path is relative to the layout's root, as a manifest path | + +The object table holds the jacket Sprite and its texture; the chart's `TextAsset` is in `chart.json`. The `product` +layout makes the live of each chart whose key and jacket the selection holds (with the master data), without a band. +`export live` asks for one live and its parts ([export live](commands.md#export-live)). diff --git a/docs/stages.md b/docs/stages.md index 1ed0a55..7145ce6 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -29,12 +29,13 @@ codec and channel of a USM) and `catalog.index` (version 2: gzip input and Japan | `live2d.model`, `live2d.cubism` | a Live2D model key (`Character/Live2D///model/`) a command asks for | `objects`: the object table of what the model reaches ([products](products.md#live2d-models)); depends on `link.addresses` and the `unity.export` tasks it reads | `model.json` (the player's model) and the object table; the Cubism SDK files | | `room.gltf` | a spot background key (`Spot//Background/`) | `objects`: the object table of what the background reaches ([products](products.md#spots)) | `room.glb` and `room.json` | | `spot.files` | a spot situation key (`Spot///`) | `objects`; `master`: the spot's master record | `spot.json` and the Spine files | -| `live.music` | a music id (`MasterLiveMusic`) | `objects`; `acb:` and `sheets`: the BGM's cue sheet and its `cri.audio` files ([products](products.md#live-sounds)); `master`: the music's master record; parameter `format` (`aac`) | `music.json` and the BGM in the web format | +| `live.music` | a music id (`MasterLiveMusic`) | `objects`; `acb:` and `sheets`: the BGM's cue sheet and its `cri.audio` files ([products](products.md#live-sounds)); `master`: the music's master record; parameter `audio` (`aac`) | `music.json` and the BGM in the web format | | `live.sounds` | the note sound sets offered: `default` or `NoteSePatternId=` | `objects` (the sound settings of the APK's built-in files); `acb:` and `sheets` of each cue sheet; `apk:assets/Cri/Sound/Sirius.acf`; `master` | `sounds.json` and the decoded sounds | | `live.notes` | `default` | `objects`: the object table of the note prefabs and the lane effect settings ([products](products.md#note-highway)); `master`: the settings' master record | `notes.json` and the object table | | `live.noteskin` | a note skin (`skin001`) | `objects`: the object table of the skin and the flick views | `noteskin.json` and the object table | | `live.noteeffect` | a note effect set (`effect001`, `effect001Light`) | `objects`: the object table of the effect settings | `noteeffect.json` and the object table | | `live.scene` | `default` | `objects`: the object table of the scene, the bands' start timelines and backgrounds, the lane skins, the UI assets and the built-in graphics settings ([products](products.md#live-scene)); `master`: the master record of its settings, lane skins and bands | `scene.json` and the object table | +| `live` | a live: `/[/]` | `objects`: the object table of the chart and the jacket ([products](products.md#lives)); `master`: the live's master record and the manifest paths of its parts | `live.json`, `chart.json`, `notes.json`, `notes.mirror.json` and the object table | | `link.addresses` | global | catalog index, censuses (the built-in files' too) | key -> objects (a scene key: the objects of the scene's file outside a hierarchy), Resources.Load path -> objects, built-in selector (class, MonoBehaviour script class) -> objects, serialized file -> bundle or built-in file, bundle -> keys | | `view.` | a catalog (`main`) | `master:` per master table it reads, `rules` (the view's rule and the resolvers it uses), `addresses` (the part of the address table it read: the keys looked up and the prefixes listed); no parameters, atoms or context | `view.:#view` (facts: rows, entries, gaps, unreferenced) | | `view.voices` | a catalog (`main`) | `master:
` per master table its rules read, `rules`, `addresses`, `sheets` (each cue sheet the `cri.audio` results are known by that a voice names or the rules' prefixes list: its task and ACB content id), `acb:` (the ACB of each decoded one), `episodes` (per story episode and part, the `unity.export` artifact holding the object its key names), `adv:` (those contents); context `audio` (the keys of those `cri.audio` results); runs after `unity.export` and `cri.audio` | `view.voices:#view` (facts: rows, entries, gaps, unreferenced streams) | diff --git a/src/nnnotes/cli/assets.py b/src/nnnotes/cli/assets.py index 876e529..91aae8e 100644 --- a/src/nnnotes/cli/assets.py +++ b/src/nnnotes/cli/assets.py @@ -126,6 +126,7 @@ def matches(self, stage: str) -> bool: StageSource("live.noteskin", "nnnotes.pipeline.live:NoteSkinStage", output=True, product=True), StageSource("live.noteeffect", "nnnotes.pipeline.live:NoteEffectStage", output=True, product=True), StageSource("live.scene", "nnnotes.pipeline.live:SceneStage", output=True, product=True), + StageSource("live", "nnnotes.pipeline.live:LiveStage", output=True, product=True), StageSource("view.", "nnnotes.pipeline.views:stages", output=True), StageSource("view.voices", "nnnotes.pipeline.voices:VoiceStage", output=True), ) @@ -675,6 +676,10 @@ def params(self, pipeline: list[str]) -> dict: for n in takers: base = stages[n].PARAMS["png"] out.setdefault(n, {})["png"] = {**(base if isinstance(base, dict) else {}), "level": level} + fmt = getattr(self.args, "audio_format", None) + if fmt is not None: + for n in [n for n in pipeline if "audio" in stages[n].PARAMS]: + out.setdefault(n, {})["audio"] = fmt level = getattr(self.args, "flac_level", None) if level is not None: if not 0 <= level <= 12: diff --git a/src/nnnotes/cli/export.py b/src/nnnotes/cli/export.py index d66b5e1..a72e9c2 100644 --- a/src/nnnotes/cli/export.py +++ b/src/nnnotes/cli/export.py @@ -9,6 +9,7 @@ from pathlib import Path from ..core.jsonio import write_json +from ..formats.encoders import DEFAULT_AUDIO_FORMAT, WEB_AUDIO from ..source.config import use from ..source.session import Session from . import assets @@ -17,6 +18,7 @@ audio_args, band_args, command, + flac_level_arg, flac_options, fonts_arg, fonts_extra, @@ -83,44 +85,54 @@ def cmd_story(args, cfg): def cmd_live(args, cfg): - from ..content import live, liveoptions - from ..site.web import all_pairs - from ..source import languages - from ..unity.player import PlayerData + from ..content import live, liveaudio, livenotes, liveoptions + from ..pipeline import live as lp + from ..pipeline.stages import Env + from ..source.master import table - fonts_extra(args.fonts) - request = live_options(args) - flac = flac_options(args) - language = languages.check(cfg.require("catalog", "language")) + cfg.require_path("paths", "apk") # the built-in graphics and sound settings and the CRI ACF are in the APK + use(cfg) # the tools the atoms name are part of the keys md = Session(cfg).master_dir() known_row(args, md, "MasterLiveMusic", args.music_id, "music") - if (args.music_id, args.difficulty) not in all_pairs(md): - usage(args, f"music {args.music_id}: no {args.difficulty} chart (no MasterLiveMusicScore row)") try: - options = liveoptions.resolve(request, md) + options = liveoptions.resolve(live_options(args), md) except liveoptions.OptionSpecError as e: usage(args, str(e)) - cat = Session(cfg).catalog() - try: # an unknown --leader-card, a --band without its scene keys - live.resolve_band(cat, md, args.music_id, band=args.band, leader_card=args.leader_card) + band = args.band + if args.leader_card is not None: + tables = {n: table(md, n) for n in ("MasterMemberCard", "MasterCharacter", "MasterLiveMusic")} + try: + band = live.band_choice(tables, args.music_id, leader_card=args.leader_card)["band"] + except KeyError as e: + usage(args, e.args[0]) + ws = assets.Workspace(args, cfg, layouts=["product"]) + env = Env(ws.depot, {"master": md}) + stage, subject = lp.LiveStage(), live.live_subject(args.music_id, args.difficulty, band) + try: # no chart of the difficulty, a music without a vocal character, a master table missing + b = stage.live_record(subject, env)["band"]["band"] + products = {lp.LIVE: [subject], **stage.parts(subject, env)} except (KeyError, ValueError) as e: usage(args, e.args[0] if e.args else type(e).__name__) - r = live.build( - cat, - md, - PlayerData(Session(cfg).apk()), - args.music_id, - args.difficulty, - Path(args.out), - audio_format=args.format, - band=args.band, - leader_card=args.leader_card, - language=language, - fonts=args.fonts, - options=options, - **flac, - ) - report(args, r) + ws.request(products, []) + ws.params(ws.pipeline()) # the flags the stages take, checked before the index + ws.run_index() + held = {loc["primaryKey"] for loc in ws.index()["locations"]} + missing = [k.format(band=b) for k in live.BAND_KEYS if k.format(band=b) not in held] + if missing: + usage(args, f"band {b}: {', '.join(missing)} not in the catalog") + skins = {r["_id"]: r["_assetName"] for r in table(md, "MasterLiveNoteSkin")} + effects = {r["_id"]: r["_assetName"] for r in table(md, "MasterLiveNoteEffectSkin")} + products[lp.NOTESKIN] += [skins[i] for i in options.designs if skins[i] not in products[lp.NOTESKIN]] + products[lp.NOTEEFFECT] += [ # a Light variant where the catalog has one, as the game + n for n, light in options.effect_sets(effects) if not light or livenotes.effect_settings_key(n) in held + ] + if options.se_patterns: + products[lp.SOUNDS].append(liveaudio.sounds_subject(options.se_patterns)) + classes = {lp.LIVE: lp.LiveStage, **lp.PART_STAGES} + stages = {n: classes[n]() for n in products} + keys = {k for n, subjects in products.items() for s in subjects for k in stages[n].keys(s, env)} + ws.request(products, sorted(f"key:{k}" for k in keys)) + assets.export(args, cfg, ws) def cmd_live2d(args, cfg): @@ -290,20 +302,30 @@ def register(sub) -> None: "(needs the 'fonts' extra)", ) - c = command(es, "live", cmd_live, "a live (music + difficulty) -> self-contained live directory") + c = command( + es, + "live", + cmd_live, + "a live (music + difficulty) -> the live product and the products it plays with (scene, note highway, " + "sounds, music)", + ) c.add_argument("music_id", type=int, help="MasterLiveMusic id") c.add_argument( "--difficulty", default="expert", choices=DIFFICULTY_CHOICES, help="chart difficulty (default expert)" ) - audio_args(c) - fonts_arg( - c, - "start canvas text: open (default): layout and style only, no font data (your fonts are not used); " - "game: also the game's TMP fonts (needs the 'fonts' extra)", + c.add_argument( + "--format", + dest="audio_format", + metavar="FORMAT", + default=DEFAULT_AUDIO_FORMAT, + choices=sorted(WEB_AUDIO), + help=f"web audio format of the BGM (default {DEFAULT_AUDIO_FORMAT})", ) + flac_level_arg(c, "of the decoded sounds") band_args(c) live_option_arg(c) out_arg(c, "output directory") + product_args(c) c = command(es, "live2d", cmd_live2d, "a Live2D model -> its Cubism SDK files, or the player's model") c.add_argument("key", help="model key Character/Live2D///model/, or its model id ") diff --git a/src/nnnotes/content/live.py b/src/nnnotes/content/live.py index 9058fab..470902c 100644 --- a/src/nnnotes/content/live.py +++ b/src/nnnotes/content/live.py @@ -1,4 +1,24 @@ -"""One live (music + difficulty) -> a self-contained live directory, the data format ournotes-player reads. +"""A live (a music's chart at a difficulty): the `live` product, and the self-contained live directory of `build`. + +The `live` product (live.json format LIVE_FORMAT), read from a product's object table (`Objects`, +pipeline.assemble), never from a bundle, holds what belongs to one chart and names the products it plays with by +their manifest paths: + + chart.json the chart as shipped (the TextAsset of chart_key, un-gzipped) + notes.json the runtime notes the game converts it to (score.convert), with `source` + notes.mirror.json the same with the MirrorChart option on + live.json musicId, difficulty, `band` (band_choice: the band whose start timeline and background the + live plays), the files above, the counts of the converted chart (judgementNoteCount, + fullComboCount, noteCount, lastNoteTimeMs), `master` (music_record: the music's rows, title + and band names), `jacket` (the Sprite of jacket_key) and `parts` {part: manifest path}: the + scene (live.scene), the note highway (live.notes) with the default note skin and effect set + (live.noteskin, live.noteeffect), the default sounds (live.sounds) and the music + (live.music) + +A subject names a chart and optionally the band: `/[/]` (live_subject). Without a band +the live takes the band of DEFAULT_BAND_RULE. + +`build` writes the live directory instead, the data format the player reads from a directory: /score/ chart as shipped, converted runtime notes, master rows (score.py) /audio// the live BGM cue sheet decoded per cue + cues.json + streams.json (score.py) @@ -20,21 +40,37 @@ The band of the LightWeight background and of the start timeline is the band of the player's deck centre (`SelfMemberList[2]`: LightWeightBackgroundLoadStep.d__3 reads its BandID, LiveResourceBandResolver.Resolve its MemberCard's Character.Band; MasterMemberCard.get_BandID = Character._bandID). The preview has no deck: -`resolve_band` takes it from `--leader-card` / `--band`, else from DEFAULT_BAND_RULE. +`band_choice` takes it from a leader card or a band given, else from DEFAULT_BAND_RULE. """ from __future__ import annotations +import gzip from pathlib import Path +from typing import TYPE_CHECKING from ..core.jsonio import write_json from ..formats.encoders import FLAC_LEVEL -from ..source.catalog import Catalog -from ..unity.player import PlayerData -from . import liveaudio, livenotes, livescene, liveui, score +from ..source import languages from .liveoptions import LiveOptions -from .score import master_table +from .prefabs import Objects, key_object + +if TYPE_CHECKING: + from ..source.catalog import Catalog + from ..unity.player import PlayerData +LIVE_FORMAT = 1 # live.json "format" +DIFFICULTIES = ("easy", "normal", "hard", "expert") # _easyID.._expertID -> file suffix _00.._03 +# the master tables of a music record (music_record) and of a live record (live_record) +MUSIC_RECORD_TABLES = ( + "MasterLiveMusic", + "MasterLiveMusicScore", + "MasterText", + "MasterBand", + "MasterSound", + "MasterSoundCueSheet", +) +LIVE_TABLES = (*MUSIC_RECORD_TABLES, "MasterCharacter") # Band of the preview when no deck centre is given: the band of the music's first vocal character # (MasterLiveMusic._vocalCharacterIDs[0] -> MasterCharacter._bandID). The music's own fields are not the game's input. DEFAULT_BAND_RULE = "firstVocalCharacter" @@ -46,15 +82,44 @@ BAND_KEYS = ("Band/{band}/live_stage/lightweight_background", "Band/{band}/timeline/live_start_playable_timeline") -def resolve_band( - cat: Catalog, master: Path, music_id: int, band: int | None = None, leader_card: int | None = None -) -> dict: - """The preview's band: {"band", "source", "note"} (recorded in livescene/scene.json slice.bandChoice).""" +def chart_key(file_name: str) -> str: + """LiveEntry loads `Live/MusicScore/` + MasterLiveMusicScore._musicScoreTextFileName.""" + return f"Live/MusicScore/{file_name}" + + +def jacket_key(name: str) -> str: + """The jacket sprite of MasterLiveMusic._jacketAssetName `name`.""" + return f"Image/Jacket/{name}" + + +def live_subject(music_id: int, difficulty: str, band: int | None = None) -> str: + """The subject of the live of a music's chart at `difficulty`, with the band `band` (else the default rule).""" + if difficulty not in DIFFICULTIES: + raise ValueError(f"{difficulty}: not a difficulty ({', '.join(DIFFICULTIES)})") + return f"{music_id}/{difficulty}" + (f"/{band}" if band is not None else "") + + +def parse_subject(subject: str) -> tuple[int, str, int | None]: + """(music id, difficulty, band or None) of a live subject (live_subject; ValueError for anything else).""" + parts = subject.split("/") + if len(parts) not in (2, 3) or not all(p.isdigit() for p in parts[::2]) or parts[1] not in DIFFICULTIES: + raise ValueError(f"{subject}: not a live subject, /[/]") + return int(parts[0]), parts[1], int(parts[2]) if len(parts) == 3 else None + + +def _rows(tables: dict, name: str) -> dict: + return {r["_id"]: r for r in tables[name]} + + +def band_choice(tables: dict, music_id: int, band: int | None = None, leader_card: int | None = None) -> dict: + """The preview's band {"band", "source", "note"} from master tables {name: rows}: the band of a leader card + (MasterMemberCard, MasterCharacter), the band given, else the band of DEFAULT_BAND_RULE (MasterLiveMusic, + MasterCharacter).""" if band is not None and leader_card is not None: raise ValueError("give either band or leader_card, not both") - chars = {r["_id"]: r for r in master_table(master, "MasterCharacter")} + chars = _rows(tables, "MasterCharacter") if leader_card is not None: - cards = [r for r in master_table(master, "MasterMemberCard") if r["_id"] == leader_card] + cards = [r for r in tables["MasterMemberCard"] if r["_id"] == leader_card] if len(cards) != 1: raise KeyError(f"MasterMemberCard {leader_card}: {len(cards)} rows") ch = chars.get(cards[0]["_characterID"]) @@ -70,12 +135,12 @@ def resolve_band( elif band is not None: b, source = band, "band given" else: - music = [r for r in master_table(master, "MasterLiveMusic") if r["_id"] == music_id] + music = [r for r in tables["MasterLiveMusic"] if r["_id"] == music_id] if len(music) != 1: raise KeyError(f"MasterLiveMusic {music_id}: {len(music)} rows") vocals = music[0]["_vocalCharacterIDs"] if not vocals: - raise ValueError(f"music {music_id}: no vocal character; give --band or --leader-card") + raise ValueError(f"music {music_id}: no vocal character; give a band or a leader card") if vocals[0] not in chars: raise KeyError(f"music {music_id}: vocal character {vocals[0]} not in MasterCharacter") b = chars[vocals[0]]["_bandID"] @@ -83,11 +148,116 @@ def resolve_band( f"default ({DEFAULT_BAND_RULE}): MasterLiveMusic._vocalCharacterIDs[0] = {vocals[0]} -> " f"MasterCharacter._bandID" ) - b = int(b) + return {"band": int(b), "source": source, "note": BAND_NOTE} + + +def music_record(tables: dict, music_id: int) -> dict: + """MasterLiveMusic row, its 4 MasterLiveMusicScore rows, title / band name texts in every language + (languages.LANGUAGES) and sound rows, from master tables {name: rows}.""" + music = [r for r in tables["MasterLiveMusic"] if r["_id"] == music_id] + if len(music) != 1: + raise KeyError(f"MasterLiveMusic {music_id}: {len(music)} rows") + music = music[0] + scores, text, bands = (_rows(tables, n) for n in ("MasterLiveMusicScore", "MasterText", "MasterBand")) + sounds, sheets = _rows(tables, "MasterSound"), _rows(tables, "MasterSoundCueSheet") + + def tx(i): + r = text.get(i) + return None if r is None else {"id": i, **languages.texts(r)} + + def snd(i): + r = sounds.get(i) + if r is None: + return None + return {"MasterSound": r, "MasterSoundCueSheet": sheets.get(r["_soundCueSheetID"])} + + return { + "MasterLiveMusic": music, + "MasterLiveMusicScore": {d: scores.get(music[f"_{d}ID"]) for d in DIFFICULTIES}, + "title": tx(music["_titleTextID"]), + "bands": [ + {"MasterBand": bands.get(b), "name": tx(bands[b]["_nameTextID"]) if b in bands else None} + for b in music["_bandIDs"] + ], + "music": snd(music["_musicSoundID"]), + "jingle": snd(music["_jingleSoundID"]), + } + + +def live_record(tables: dict, subject: str) -> dict: + """What `live` reads of the master tables `tables` (LIVE_TABLES) for a live subject: the music's rows + (music_record), the difficulty and the band (band_choice). A difficulty the music has no chart of raises.""" + music_id, difficulty, band = parse_subject(subject) + music = music_record(tables, music_id) + if music["MasterLiveMusicScore"][difficulty] is None: + raise KeyError(f"music {music_id}: no {difficulty} chart (no MasterLiveMusicScore row)") + return {"music": music, "difficulty": difficulty, "band": band_choice(tables, music_id, band)} + + +def live_keys(record: dict) -> list[str]: + """The catalog keys of a live: its chart and its jacket.""" + music = record["music"] + score = music["MasterLiveMusicScore"][record["difficulty"]] + return sorted( + [chart_key(score["_musicScoreTextFileName"]), jacket_key(music["MasterLiveMusic"]["_jacketAssetName"])] + ) + + +def live(objects: Objects, record: dict, parts: dict) -> tuple[dict, bytes, dict[str, dict], set[str]]: + """(live.json, chart.json, {file: JSON document} of the converted charts, the object ids the player reads) of a + live (module documentation; `record`: live_record, `parts`: {part: manifest path}).""" + from .score import convert, load_bytes + + music, difficulty = record["music"], record["difficulty"] + row = music["MasterLiveMusic"] + score = music["MasterLiveMusicScore"][difficulty] + key = chart_key(score["_musicScoreTextFileName"]) + text = key_object(objects, key, "TextAsset") + raw = objects.bytes(text, "data") + root = load_bytes(raw) + source = { + "key": key, + "musicId": row["_id"], + "difficulty": difficulty, + "masterLiveMusicScoreId": score["_id"], + "fullComboCount": score["_fullComboCount"], + } + notes, mirror = ({**convert(root, mirror=m), "source": source} for m in (False, True)) + doc = { + "format": LIVE_FORMAT, + "musicId": row["_id"], + "difficulty": difficulty, + "band": record["band"], + "chart": "chart.json", + "notes": "notes.json", + "notesMirror": "notes.mirror.json", + "judgementNoteCount": notes["judgementNoteCount"], + "fullComboCount": score["_fullComboCount"], + "noteCount": len(notes["notes"]), + "lastNoteTimeMs": notes["lastNoteTimeMs"], + "master": music, + "jacket": key_object(objects, jacket_key(row["_jacketAssetName"]), "Sprite"), + "parts": dict(sorted(parts.items())), + } + chart = gzip.decompress(raw) if raw[:2] == b"\x1f\x8b" else raw + keep = {o for o in objects.ids() if o != text and objects.info(o).get("class") != "MonoScript"} + return doc, chart, {"notes.json": notes, "notes.mirror.json": mirror}, keep + + +def resolve_band( + cat: Catalog, master: Path, music_id: int, band: int | None = None, leader_card: int | None = None +) -> dict: + """The preview's band (band_choice; recorded in livescene/scene.json slice.bandChoice) of a band whose start + timeline and background the catalog holds (BAND_KEYS).""" + from .score import master_table + + names = ("MasterCharacter", "MasterLiveMusic") + (("MasterMemberCard",) if leader_card is not None else ()) + choice = band_choice({n: master_table(master, n) for n in names}, music_id, band, leader_card) + b = choice["band"] missing = [k.format(band=b) for k in BAND_KEYS if not cat.has(k.format(band=b))] if missing: raise KeyError(f"band {b}: {', '.join(missing)} not in the catalog") - return {"band": b, "source": source, "note": BAND_NOTE} + return choice def index_doc(music_id: int, difficulty: str, score_summary: dict, audio: dict, live_audio: str) -> dict: @@ -128,6 +298,8 @@ def build( """One live directory. `language`: the client language of the start canvas (a languages.LANGUAGES code); `fonts`: "open" or "game" (liveui.extract); `options`: the option variants whose files it carries as well (liveoptions.resolve); `flac_level`: ffmpeg's compression level of the FLAC audio (cri.decode).""" + from . import liveaudio, livenotes, livescene, liveui, score + out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) choice = resolve_band(cat, Path(master), music_id, band=band, leader_card=leader_card) diff --git a/src/nnnotes/content/livenotes.py b/src/nnnotes/content/livenotes.py index bec0d2a..1a815ea 100644 --- a/src/nnnotes/content/livenotes.py +++ b/src/nnnotes/content/livenotes.py @@ -71,6 +71,7 @@ "SKIN_KEY", "animator_bindings", "asset_name", + "default_assets", "effect_settings_key", "extract", "notes", @@ -245,12 +246,21 @@ def notes_record(tables: dict) -> dict: } +def default_assets(record: dict) -> tuple[str, str]: + """(note skin, note effect set) of the option defaults (NoteDesignId, NoteEffectId) of a notes_record.""" + opts = {NOTE_ITEMS[r["_optionItemType"]]: r["_valueString"] for r in record["MasterOptionDefault"]} + skins = {r["_id"]: r["_assetName"] for r in record["MasterLiveNoteSkin"]} + effects = {r["_id"]: r["_assetName"] for r in record["MasterLiveNoteEffectSkin"]} + return skins[int(opts["NoteDesignId"])], effects[int(opts["NoteEffectId"])] + + def notes_settings(record: dict) -> dict: """notes.json `settings` (module documentation) from a notes_record.""" opts = {NOTE_ITEMS[r["_optionItemType"]]: r["_valueString"] for r in record["MasterOptionDefault"]} ls = {r["_key"]: r["_value"] for r in record["MasterLiveSettings"]} skins = {r["_id"]: r["_assetName"] for r in record["MasterLiveNoteSkin"]} effects = {r["_id"]: r["_assetName"] for r in record["MasterLiveNoteEffectSkin"]} + skin, effect = default_assets(record) return { **_lane_settings(), "optionDefaults": dict(sorted(opts.items())), @@ -266,8 +276,8 @@ def notes_settings(record: dict) -> dict: float(ls["note_speed_view_min"]), float(ls["note_speed_view_max"]), ), - "skin": skins[int(opts["NoteDesignId"])], - "effect": effects[int(opts["NoteEffectId"])], + "skin": skin, + "effect": effect, "laneEffect": LANE_EFFECT, "skins": {str(i): n for i, n in sorted(skins.items())}, "effects": {str(i): n for i, n in sorted(effects.items())}, diff --git a/src/nnnotes/content/score.py b/src/nnnotes/content/score.py index 8c4cdfe..7c1ef92 100644 --- a/src/nnnotes/content/score.py +++ b/src/nnnotes/content/score.py @@ -33,7 +33,7 @@ from ..core.jsonio import write_json from ..formats import cri from ..formats.encoders import FLAC_LEVEL -from ..source import languages +from .live import DIFFICULTIES, MUSIC_RECORD_TABLES, chart_key, music_record f32 = np.float32 @@ -41,7 +41,6 @@ TICKS_PER_BEAT = 480 # 60000 / (bpm * 480) in SsTickConverter.TickToTimeMs TICKS_PER_WHOLE = 1920 # ticks per measure = numerator * 1920 / denominator MAX_LINES = 20 # LineIndexAssigner(0x14) -DIFFICULTIES = ("easy", "normal", "hard", "expert") # _easyID.._expertID -> file suffix _00.._03 # SsNoteType / SsFlickDir / SsEase / SsFadeType (SsRootDeserializer.Parse*) NOTE_TYPES = {"tap": 0, "flick": 1, "trace": 2, "long": 3, "guide": 4, "node": 5} @@ -1209,11 +1208,6 @@ def master_table(master_dir: Path, name: str) -> list: return json.loads((Path(master_dir) / f"{name}.json").read_text(encoding="utf-8"))["_allData"] -def chart_key(file_name: str) -> str: - """LiveEntry loads `Live/MusicScore/` + MasterLiveMusicScore._musicScoreTextFileName.""" - return f"Live/MusicScore/{file_name}" - - def fetch_chart(cat, file_name: str) -> bytes: """Raw TextAsset bytes of a chart (still gzip, as shipped).""" from ..unity.reader import load_closure @@ -1235,36 +1229,8 @@ def fetch_chart(cat, file_name: str) -> bytes: def music_rows(master_dir: Path, music_id: int) -> dict: - """MasterLiveMusic row, its 4 MasterLiveMusicScore rows, title / band name texts in every language - (languages.LANGUAGES) and sound rows.""" - music = next(r for r in master_table(master_dir, "MasterLiveMusic") if r["_id"] == music_id) - scores = {r["_id"]: r for r in master_table(master_dir, "MasterLiveMusicScore")} - text = {r["_id"]: r for r in master_table(master_dir, "MasterText")} - bands = {r["_id"]: r for r in master_table(master_dir, "MasterBand")} - sounds = {r["_id"]: r for r in master_table(master_dir, "MasterSound")} - sheets = {r["_id"]: r for r in master_table(master_dir, "MasterSoundCueSheet")} - - def tx(i): - r = text.get(i) - return None if r is None else {"id": i, **languages.texts(r)} - - def snd(i): - r = sounds.get(i) - if r is None: - return None - return {"MasterSound": r, "MasterSoundCueSheet": sheets.get(r["_soundCueSheetID"])} - - return { - "MasterLiveMusic": music, - "MasterLiveMusicScore": {d: scores.get(music[f"_{d}ID"]) for d in DIFFICULTIES}, - "title": tx(music["_titleTextID"]), - "bands": [ - {"MasterBand": bands.get(b), "name": tx(bands[b]["_nameTextID"]) if b in bands else None} - for b in music["_bandIDs"] - ], - "music": snd(music["_musicSoundID"]), - "jingle": snd(music["_jingleSoundID"]), - } + """live.music_record of the master tables in `master_dir`.""" + return music_record({n: master_table(master_dir, n) for n in MUSIC_RECORD_TABLES}, music_id) def extract_audio( diff --git a/src/nnnotes/pipeline/live.py b/src/nnnotes/pipeline/live.py index a48c9a2..91ba179 100644 --- a/src/nnnotes/pipeline/live.py +++ b/src/nnnotes/pipeline/live.py @@ -1,12 +1,13 @@ """The live products: a music's BGM (live.music), the sounds a live plays besides it (live.sounds), the note highway -(live.notes, live.noteskin, live.noteeffect) and the scene (live.scene). +(live.notes, live.noteskin, live.noteeffect), the scene (live.scene), and a live (live), the chart of a music at a +difficulty that names the others it plays with. All are product stages (pipeline.assemble). The sound products read cue sheets: the ACBs their catalog keys load and the files cri.audio decoded from them. They keep no objects (their files are all they hold): live.music subject a music id (MasterLiveMusic._id): "#music.json" (kind live.music, liveaudio.music_doc) and "#audio//" for each layer of its BGM sound (kind audio.bgm) in the web - format of the parameter `format` (encoders.WEB_AUDIO; default aac: AAC-LC in MP4, each layer recording + format of the parameter `audio` (encoders.WEB_AUDIO; default aac: AAC-LC in MP4, each layer recording its encoder delay `encoderDelay`; flac: the decoded FLAC as it is). Its manifest is music/.json, its input "master" the music's master record (liveaudio.music_record). The atom `audio.encode` (ffmpeg) encodes the layers. @@ -32,10 +33,19 @@ its settings, lane skins and bands (livescene.scene_record), its roots also the built-in graphics settings (livescene.GRAPHICS) +live subject a live (content.live.live_subject: `/[/]`): "#live.json" + (kind live: content.live.live), "#chart.json" (kind live.chart: the chart as shipped), + "#notes.json" and "#notes.mirror.json" (kind live.notes.chart: the converted chart) and the + object table of its jacket; its manifest is lives/_[_].json, its input + "master" the live's master record (live.live_record) with the manifest paths of its parts. `parts` + gives the products a live plays with: the scene, the note highway with the default note skin and + effect set, the default sounds and the music. + `select` (the `product` layout) makes the live.music products of the musics whose BGM key the selection holds, the default live.sounds when it holds every cue sheet of the default sounds (both need the master data), live.notes when -it holds every key of it and live.scene when it holds the scene (and the master data is there), and the live.noteskin -and live.noteeffect products of the skin and effect set keys it holds. +it holds every key of it, live.scene when it holds the scene and the live of each chart whose key and jacket it holds +(and the master data is there), and the live.noteskin and live.noteeffect products of the skin and effect set keys it +holds. """ from __future__ import annotations @@ -43,7 +53,7 @@ import tempfile from pathlib import Path -from ..content import liveaudio, livenotes, livescene +from ..content import live, liveaudio, livenotes, livescene from ..formats.encoders import DEFAULT_AUDIO_FORMAT, WEB_AUDIO, mp4_priming, web_encode from . import contract from .assemble import Assembly, ProductStage @@ -51,12 +61,14 @@ from .views import master_rows __all__ = [ + "LIVE", "MUSIC", "NOTEEFFECT", "NOTES", "NOTESKIN", "SCENE", "SOUNDS", + "LiveStage", "MusicStage", "NoteEffectStage", "NoteSkinStage", @@ -69,6 +81,7 @@ MUSIC, SOUNDS = "live.music", "live.sounds" NOTES, NOTESKIN, NOTEEFFECT = "live.notes", "live.noteskin", "live.noteeffect" SCENE = "live.scene" +LIVE = "live" DEFAULT = "default" MASTER_INPUT = "master" @@ -106,16 +119,16 @@ class MusicStage(ProductStage): version = 1 KIND = "LiveMusic" MASTER = liveaudio.MUSIC_TABLES - PARAMS = {"format": DEFAULT_AUDIO_FORMAT} + PARAMS = {"audio": DEFAULT_AUDIO_FORMAT} def normalize(self, params: dict | None) -> dict: p = super().normalize(params) - if p["format"] not in WEB_AUDIO: - raise ValueError(f"stage {self.name}: format {p['format']!r}: one of {', '.join(WEB_AUDIO)}") + if p["audio"] not in WEB_AUDIO: + raise ValueError(f"stage {self.name}: audio {p['audio']!r}: one of {', '.join(WEB_AUDIO)}") return p def uses(self, subject: str, env) -> dict: - return encode_atoms(env.params["format"]) + return encode_atoms(env.params["audio"]) def select(self, keys: list[str], env) -> list[str]: if env.facts.get("master") is None: @@ -142,7 +155,7 @@ def manifest(self, subject: str) -> str: return f"music/{_music_id(subject)}.json" def assemble(self, task, objects) -> Assembly: - fmt = task.params["format"] + fmt = task.params["audio"] want = encode_atoms(fmt) if task.atoms != want: raise IncompatibleTask(f"task {task.id}: the audio encoder here is {want} (task: {task.atoms})") @@ -336,3 +349,81 @@ def assemble(self, task, objects) -> Assembly: record = contract.loads(objects.depot.input_bytes(task.input(MASTER_INPUT))) doc, keep, roles = livescene.scene(objects, record) return Assembly({"scene.json": _json(doc, SCENE)}, keep, roles) + + +# the parts of a live: {part: product stage} +PARTS = { + "scene": SCENE, + "notes": NOTES, + "noteskin": NOTESKIN, + "noteeffect": NOTEEFFECT, + "sounds": SOUNDS, + "music": MUSIC, +} +PART_STAGES = { + SCENE: SceneStage, + NOTES: NotesStage, + NOTESKIN: NoteSkinStage, + NOTEEFFECT: NoteEffectStage, + SOUNDS: SoundsStage, + MUSIC: MusicStage, +} + + +class LiveStage(ProductStage): + """live: a music's chart at a difficulty and the products it plays with (module documentation).""" + + name = LIVE + version = 1 + KIND = "Live" + MASTER = tuple(sorted({*live.LIVE_TABLES, *livenotes.NOTES_TABLES})) + + def select(self, keys: list[str], env) -> list[str]: + if env.facts.get("master") is None: + return [] + tables, held = master_rows(env, live.LIVE_TABLES), set(keys) + scores = {r["_id"]: r for r in tables["MasterLiveMusicScore"]} + out = [] + for m in tables["MasterLiveMusic"]: + jacket = live.jacket_key(m["_jacketAssetName"]) + for d in live.DIFFICULTIES: + s = scores.get(m[f"_{d}ID"]) + if s is not None and {live.chart_key(s["_musicScoreTextFileName"]), jacket} <= held: + out.append(live.live_subject(m["_id"], d)) + return sorted(out) + + def live_record(self, subject: str, env) -> dict: + """The live's master record (content.live.live_record).""" + return live.live_record(master_rows(env, live.LIVE_TABLES), subject) + + def keys(self, subject: str, env) -> list[str]: + return live.live_keys(self.live_record(subject, env)) + + def parts(self, subject: str, env) -> dict[str, list[str]]: + """{product stage: [subject]} of the products the live plays with (module documentation).""" + music_id, _, _ = live.parse_subject(subject) + skin, effect = livenotes.default_assets(livenotes.notes_record(master_rows(env, livenotes.NOTES_TABLES))) + return { + SCENE: [DEFAULT], + NOTES: [DEFAULT], + NOTESKIN: [skin], + NOTEEFFECT: [effect], + SOUNDS: [liveaudio.sounds_subject()], + MUSIC: [str(music_id)], + } + + def more_inputs(self, subject: str, env) -> list[Input]: + parts = self.parts(subject, env) + paths = {part: PART_STAGES[name]().manifest(parts[name][0]) for part, name in PARTS.items()} + return [_record(env, {"live": self.live_record(subject, env), "parts": paths})] + + def manifest(self, subject: str) -> str: + music_id, difficulty, band = live.parse_subject(subject) + return f"lives/{music_id}_{difficulty}{'' if band is None else f'_{band}'}.json" + + def assemble(self, task, objects) -> Assembly: + record = contract.loads(objects.depot.input_bytes(task.input(MASTER_INPUT))) + doc, chart, notes, keep = live.live(objects, record["live"], record["parts"]) + out = {"live.json": _json(doc, LIVE), "chart.json": (chart, "json", "live.chart")} + out.update({name: _json(body, "live.notes.chart") for name, body in notes.items()}) + return Assembly(out, keep) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 2fa65f5..2db7692 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -10,6 +10,7 @@ from nnnotes import cli from nnnotes.cli import common +from tests.helpers import livenotes as notes_helper from tests.helpers import synth KEY_HEX = synth.BUNDLE_KEY.hex() @@ -327,10 +328,24 @@ def test_crikey_write_creates_the_directory(tmp_path, capsys, monkeypatch): MASTER_TABLES = { "MasterAdv": [{"_id": 1}], "MasterHomeSpot": [{"_id": 1}], - "MasterLiveMusic": [{"_id": 7, "_expertID": 70, "_easyID": 0, "_vocalCharacterIDs": [1]}], - "MasterLiveMusicScore": [{"_id": 70}], + "MasterLiveMusic": [ + { + "_id": 7, + **{f"_{d}ID": 0 for d in ("easy", "normal", "hard")}, + "_expertID": 70, + "_vocalCharacterIDs": [1], + "_titleTextID": 1, + "_bandIDs": [2], + "_musicSoundID": 1, + "_jingleSoundID": 0, + "_jacketAssetName": "jacket7", + } + ], + "MasterLiveMusicScore": [{"_id": 70, "_musicScoreTextFileName": "m7_03", "_fullComboCount": 1}], "MasterMemberCard": [{"_id": 5, "_characterID": 1}], "MasterCharacter": [{"_id": 1, "_bandID": 2}], + **{t: [] for t in ("MasterText", "MasterBand", "MasterSound", "MasterSoundCueSheet")}, + **notes_helper.master(), } @@ -432,7 +447,6 @@ def test_the_command_line_starts_without_the_exporters(): (["export", "audio", "s", "--flac-level", "13"], "--flac-level 13: expected 0 to 12"), (["export", "live", "7", "--flac-level", "-1"], "--flac-level -1: expected 0 to 12"), (["export", "audio", "s", "--format", "ogg", "--flac-level", "5"], "--flac-level: --format ogg writes no FLAC"), - (["export", "live", "7", "--format", "wav", "--flac-level", "5"], "--flac-level: --format wav writes no FLAC"), (["export", "story", "1", "--no-audio", "--flac-level", "5"], "--flac-level: --no-audio decodes no audio"), ], ) @@ -445,7 +459,7 @@ def test_flac_level_is_checked(data, argv, message, capsys): def test_flac_level_reaches_the_decode(data, tmp_path, capsys, monkeypatch): from types import SimpleNamespace - from nnnotes.content import live, story + from nnnotes.content import story from nnnotes.formats import cri from nnnotes.source.session import Session from nnnotes.unity import player @@ -456,9 +470,7 @@ def test_flac_level_reaches_the_decode(data, tmp_path, capsys, monkeypatch): monkeypatch.setattr(player, "PlayerData", lambda apk: None) monkeypatch.setattr(cri, "decode", lambda *a, **kw: seen.append(kw.get("flac_level")) or []) monkeypatch.setattr(story, "build", lambda *a, **kw: seen.append(kw["audio_options"].get("flac_level")) or {}) - monkeypatch.setattr(live, "resolve_band", lambda *a, **kw: None) - monkeypatch.setattr(live, "build", lambda *a, **kw: seen.append(kw.get("flac_level")) or {}) - for argv in (["export", "audio", "s"], ["export", "story", "1"], ["export", "live", "7"]): + for argv in (["export", "audio", "s"], ["export", "story", "1"]): for flag, level in (([], None), (["--flac-level", "5"], 5)): seen.clear() code, _, err = run(data + argv + flag + ["-o", str(tmp_path / "o")], capsys) @@ -466,7 +478,7 @@ def test_flac_level_reaches_the_decode(data, tmp_path, capsys, monkeypatch): def test_live_passes_the_flac_level_to_its_decodes(tmp_path, monkeypatch): - from nnnotes.content import live, liveaudio + from nnnotes.content import live, liveaudio, score from nnnotes.formats import cri class Stop(Exception): @@ -474,13 +486,13 @@ class Stop(Exception): seen = {} monkeypatch.setattr(live, "resolve_band", lambda *a, **kw: {"band": 1}) - monkeypatch.setattr(live.score, "extract", lambda *a, **kw: seen.update(score=kw["flac_level"]) or {}) + monkeypatch.setattr(score, "extract", lambda *a, **kw: seen.update(score=kw["flac_level"]) or {}) def sounds(*a, **kw): seen["liveaudio"] = kw["flac_level"] raise Stop - monkeypatch.setattr(live.liveaudio, "extract", sounds) + monkeypatch.setattr(liveaudio, "extract", sounds) with pytest.raises(Stop): live.build(None, tmp_path, None, 7, "expert", tmp_path / "o", language="ja", flac_level=3) assert seen == {"score": 3, "liveaudio": 3} @@ -493,3 +505,46 @@ def decode(cat, sheet, d, **kw): monkeypatch.setattr(cri, "decode", decode) assert liveaudio._decode(None, "s", tmp_path, "flac", 3) == [] assert seen["decode"] == {"fmt": "flac", "flac_level": 3} + + +def test_export_live_asks_for_the_live_and_the_products_it_plays_with(data, tmp_path, capsys, monkeypatch): + from nnnotes.cli import assets + from nnnotes.content import livenotes + from tests.pipeline.test_live import live_master_dir + + md = live_master_dir(tmp_path / "live") + qualities = [{"_id": q, "_quality": q, "_effectRenderingScale": 0.5} for q in (1, 2)] # 2: Low + (md / "MasterLiveQualitySettings.json").write_text(json.dumps({"_allData": qualities}), encoding="utf-8") + argv = list(data) + argv[argv.index("--master") + 1] = str(md) + held = [ + "Band/1/live_stage/lightweight_background", + "Band/1/timeline/live_start_playable_timeline", + livenotes.effect_settings_key("fxALight"), + ] + seen = {} + monkeypatch.setattr(assets.Workspace, "run_index", lambda self: None) + monkeypatch.setattr(assets.Workspace, "index", lambda self: {"locations": [{"primaryKey": k} for k in held]}) + + def export(args, cfg, ws): + seen.update(products=ws.products, selection=ws.selection, params=ws.params(ws.pipeline())) + + monkeypatch.setattr(assets, "export", export) + opts = ["NoteDesignId=2", "NoteEffectId", "LiveQuality", "NoteSePatternId=1,3"] + flags = [*(x for o in opts for x in ("--live-option", o)), "--format", "flac", "--flac-level", "5"] + code, _, err = run(argv + ["export", "live", "7", "--leader-card", "5", *flags, "-o", str(tmp_path / "o")], capsys) + assert code == 0, err + assert seen["products"] == { + "live": ["7/expert/1"], # the band of the leader card + "live.scene": ["default"], + "live.notes": ["default"], + "live.noteskin": ["skin001", "skin002"], + "live.noteeffect": ["fxA", "fxALight", "fxASimple"], # no Light variant of fxASimple in the catalog + "live.sounds": ["NoteSePatternId=1,3", "default"], + "live.music": ["7"], + } + keys = {s[4:] for s in seen["selection"]} + assert {"EmbScene/Live", "Image/Jacket/jacket7", "Live/MusicScore/m7_03", "Cri/Sound/se3"} <= keys + assert livenotes.effect_settings_key("fxALight") in keys and livenotes.skin_key("skin002") in keys + assert seen["params"]["live.music"] == {"audio": "flac"} + assert seen["params"]["cri.audio"] == {"flac": {"level": 5}} diff --git a/tests/content/test_live.py b/tests/content/test_live.py new file mode 100644 index 0000000..6e81030 --- /dev/null +++ b/tests/content/test_live.py @@ -0,0 +1,100 @@ +"""The live product assembled from a synthetic object table, its subjects and its master record (content.live).""" + +import pytest + +from nnnotes.content import live, score +from nnnotes.pipeline import contract +from nnnotes.pipeline.assemble import Objects +from nnnotes.pipeline.depot import Depot +from tests.helpers import live as lh + +PARTS = {"scene": "scenes/default.json", "music": "music/7.json"} + + +def objects_of(tmp_path, **kw) -> Objects: + depot = Depot(tmp_path / "d") + return Objects(lh.table(depot, **kw), depot) + + +def test_a_subject_names_a_chart_and_optionally_a_band(): + assert live.live_subject(7, "expert") == "7/expert" and live.live_subject(7, "easy", 2) == "7/easy/2" + assert live.parse_subject("7/expert") == (7, "expert", None) + assert live.parse_subject("7/easy/2") == (7, "easy", 2) + for bad in ("7", "7/extreme", "x/expert", "7/expert/b", "7/expert/2/1", "7//2"): + with pytest.raises(ValueError, match="not a live subject"): + live.parse_subject(bad) + with pytest.raises(ValueError, match="not a difficulty"): + live.live_subject(7, "extreme") + + +def test_the_band_is_the_leader_cards_the_given_or_the_first_vocal_characters(): + t = lh.master() + assert live.band_choice(t, 7)["band"] == 2 # character 1, the first vocal character + assert live.band_choice(t, 7, band=4) == {"band": 4, "source": "band given", "note": live.BAND_NOTE} + assert live.band_choice(t, 7, leader_card=5)["band"] == 1 # card 5 -> character 3 + with pytest.raises(ValueError, match="either band or leader_card"): + live.band_choice(t, 7, band=1, leader_card=5) + with pytest.raises(KeyError, match="MasterMemberCard 9: 0 rows"): + live.band_choice(t, 7, leader_card=9) + with pytest.raises(KeyError, match="character 8 not in MasterCharacter"): + live.band_choice(t, 7, leader_card=6) + t["MasterLiveMusic"][0]["_vocalCharacterIDs"] = [] + with pytest.raises(ValueError, match="no vocal character; give a band or a leader card"): + live.band_choice(t, 7) + + +def test_the_record_holds_the_musics_rows_the_difficulty_and_the_band(): + record = live.live_record(lh.master(), "7/expert/1") + music = record["music"] + assert record["difficulty"] == "expert" and record["band"]["band"] == 1 + assert music["MasterLiveMusicScore"]["normal"]["_id"] == 71 and music["MasterLiveMusicScore"]["easy"] is None + assert music["title"]["ja"] == "曲" and music["bands"][0]["name"]["ja"] == "バンド" + assert music["music"]["MasterSoundCueSheet"]["_cueSheetName"] == "bgm_7" and music["jingle"] is None + assert live.live_keys(record) == ["Image/Jacket/jacket7", f"Live/MusicScore/{lh.EXPERT}"] + with pytest.raises(KeyError, match="music 7: no easy chart"): + live.live_record(lh.master(), "7/easy") + with pytest.raises(KeyError, match="MasterLiveMusic 8: 0 rows"): + live.live_record(lh.master(), "8/expert") + + +@pytest.mark.parametrize("gz", [True, False]) +def test_the_live_holds_its_chart_converted_and_names_its_parts(tmp_path, gz): + record = live.live_record(lh.master(), "7/expert") + doc, chart, converted, keep = live.live(objects_of(tmp_path, gz=gz), record, PARTS) + notes, mirror = score.convert(lh.CHART), score.convert(lh.CHART, mirror=True) + assert doc == { + "format": live.LIVE_FORMAT, + "musicId": 7, + "difficulty": "expert", + "band": record["band"], + "chart": "chart.json", + "notes": "notes.json", + "notesMirror": "notes.mirror.json", + "judgementNoteCount": notes["judgementNoteCount"], + "fullComboCount": 5, + "noteCount": len(notes["notes"]), + "lastNoteTimeMs": notes["lastNoteTimeMs"], + "master": record["music"], + "jacket": lh.JACKET, + "parts": {"music": "music/7.json", "scene": "scenes/default.json"}, + } + assert chart == contract.encode(lh.CHART) # as shipped, un-gzipped + source = { + "key": f"Live/MusicScore/{lh.EXPERT}", + "musicId": 7, + "difficulty": "expert", + "masterLiveMusicScoreId": 73, + "fullComboCount": 5, + } + assert converted == {"notes.json": {**notes, "source": source}, "notes.mirror.json": {**mirror, "source": source}} + assert mirror["mirror"] is True + assert keep == {lh.JACKET, lh.TEXTURE} # the chart is in the product's files + + +def test_a_chart_key_without_a_text_asset_is_an_error(tmp_path): + depot = Depot(tmp_path / "d") + doc = lh.table(depot) + doc["objects"][lh.TEXT]["class"] = "Texture2D" + record = live.live_record(lh.master(), "7/expert") + with pytest.raises(ValueError, match=f"Live/MusicScore/{lh.EXPERT}: 0 TextAsset objects"): + live.live(Objects(doc, depot), record, PARTS) diff --git a/tests/content/test_live_options.py b/tests/content/test_live_options.py index bd86eaf..cc25e06 100644 --- a/tests/content/test_live_options.py +++ b/tests/content/test_live_options.py @@ -6,6 +6,7 @@ from nnnotes.content import live, liveaudio, livenotes, liveoptions, livescene, score from nnnotes.content.liveoptions import LiveOptions, OptionSpecError, parse_specs, resolve +from tests.helpers.live import CHART SWITCHES = { 4, @@ -392,18 +393,6 @@ def test_note_se_individual_and_trace_mute(tmp_path): # ---------------------------------------------------------------- mirrored score -CHART = { - "score": { - "events": {"bpm": [{"t": 0, "bpm": 120}], "sig": [{"t": 0, "sig": [4, 4]}]}, - "notes": [ - {"type": "tap", "t": 480, "pos": 2, "size": 3}, - {"type": "flick", "t": 960, "pos": 10, "size": 4, "dir": "left"}, - {"type": "long", "node": [{"t": 1440, "pos": 0, "size": 2}, {"t": 1920, "pos": 5.5, "size": 2}]}, - ], - } -} - - def test_mirrored_conversion(): a, b = score.convert(CHART), score.convert(CHART, mirror=True) assert (a["mirror"], b["mirror"]) == (False, True) diff --git a/tests/helpers/live.py b/tests/helpers/live.py new file mode 100644 index 0000000..2478420 --- /dev/null +++ b/tests/helpers/live.py @@ -0,0 +1,66 @@ +"""A synthetic live: the master tables of music 7 and the object table of its chart and jacket (content.live).""" + +import gzip + +from nnnotes.content import live +from nnnotes.pipeline import contract +from tests.helpers import livenotes as ln + +CHART = { + "score": { + "events": {"bpm": [{"t": 0, "bpm": 120}], "sig": [{"t": 0, "sig": [4, 4]}]}, + "notes": [ + {"type": "tap", "t": 480, "pos": 2, "size": 3}, + {"type": "flick", "t": 960, "pos": 10, "size": 4, "dir": "left"}, + {"type": "long", "node": [{"t": 1440, "pos": 0, "size": 2}, {"t": 1920, "pos": 5.5, "size": 2}]}, + ], + } +} +TEXT, JACKET, TEXTURE, SCRIPT = "cab-chart:1", "cab-jacket:2", "cab-jacket:3", "cab-chart:9" +EXPERT, NORMAL = "m7_03", "m7_01" + + +def master() -> dict: + """Master tables {name: rows} of music 7: a normal and an expert chart, vocal characters 1 (band 2) and 3 (band + 1), member card 5 of character 3.""" + return { + "MasterLiveMusic": [ + { + "_id": 7, + "_easyID": 0, + "_normalID": 71, + "_hardID": 0, + "_expertID": 73, + "_vocalCharacterIDs": [1, 3], + "_titleTextID": 100, + "_bandIDs": [2], + "_musicSoundID": 900, + "_jingleSoundID": 0, + "_jacketAssetName": "jacket7", + } + ], + "MasterLiveMusicScore": [ + {"_id": 71, "_musicScoreTextFileName": NORMAL, "_fullComboCount": 9}, + {"_id": 73, "_musicScoreTextFileName": EXPERT, "_fullComboCount": 5}, + ], + "MasterText": [{"_id": 100, "_japanese": "曲", "_english": "Song"}, {"_id": 200, "_japanese": "バンド"}], + "MasterBand": [{"_id": 1, "_nameTextID": 0}, {"_id": 2, "_nameTextID": 200}], + "MasterSound": [{"_id": 900, "_soundCueSheetID": 10, "_cueName": "bgm_7"}], + "MasterSoundCueSheet": [{"_id": 10, "_cueSheetName": "bgm_7"}], + "MasterCharacter": [{"_id": 1, "_bandID": 2}, {"_id": 3, "_bandID": 1}], + "MasterMemberCard": [{"_id": 5, "_characterID": 3}, {"_id": 6, "_characterID": 8}], + } + + +def table(depot, chart: str = EXPERT, gz: bool = True) -> dict: + """The object table of the live of chart file `chart`: its TextAsset (the chart JSON, gzipped with `gz`) and the + jacket Sprite with its texture.""" + data = contract.encode(CHART) + objs = { + TEXT: ("TextAsset", chart, {"json": {"m_Name": chart}, "data": gzip.compress(data) if gz else data}), + JACKET: ("Sprite", "jacket7", {"json": {"m_RD": {"texture": {"$ref": {"file": "cab-jacket", "pathId": 3}}}}}), + TEXTURE: ("Texture2D", "jacket7", {"json": {"m_Name": "jacket7"}, "image": b"png"}), + SCRIPT: ("MonoScript", "TextAsset", {"json": {}}), + } + keys = {live.chart_key(chart): [TEXT], live.jacket_key("jacket7"): [JACKET]} + return ln.table(depot, keys, objs) diff --git a/tests/pipeline/test_live.py b/tests/pipeline/test_live.py index 2011e10..2e4c78b 100644 --- a/tests/pipeline/test_live.py +++ b/tests/pipeline/test_live.py @@ -14,10 +14,19 @@ from nnnotes.pipeline.contract import IncompatibleTask, Input, Task from nnnotes.pipeline.cristages import AudioStage from nnnotes.pipeline.depot import Depot -from nnnotes.pipeline.live import MusicStage, NoteEffectStage, NoteSkinStage, NotesStage, SceneStage, SoundsStage +from nnnotes.pipeline.live import ( + LiveStage, + MusicStage, + NoteEffectStage, + NoteSkinStage, + NotesStage, + SceneStage, + SoundsStage, +) from nnnotes.pipeline.stages import Env, describe, execute, registry from tests.content.test_live_options import write_master from tests.helpers import cri +from tests.helpers import live as lh from tests.helpers import livenotes as ln from tests.helpers import livescene as ls from tests.pipeline.test_assemble import addresses @@ -214,6 +223,72 @@ def test_the_scene_product_keeps_what_the_player_reads(tmp_path): assert ls.SCRIPT not in kept and ls.LIVE in kept and ls.GRAPHICS in kept +def live_master_dir(tmp_path): + """scene_master_dir with the tables of music 7's charts, title, bands and vocal characters.""" + md = scene_master_dir(tmp_path) + tables = lh.master() + tables["MasterLiveMusic"][0]["_musicSoundID"] = 900 # bgm_7 of master_dir + for name, rows in tables.items(): + if name not in ("MasterSound", "MasterSoundCueSheet"): # master_dir's sounds + (md / f"{name}.json").write_text(json.dumps({"_allData": rows}), encoding="utf-8") + return md + + +def test_a_live_names_its_subjects_keys_parts_and_manifest(tmp_path): + env = Env(Depot(tmp_path / "d"), {"master": live_master_dir(tmp_path)}) + stage = LiveStage() + expert, normal, jacket = f"Live/MusicScore/{lh.EXPERT}", f"Live/MusicScore/{lh.NORMAL}", "Image/Jacket/jacket7" + assert stage.keys("7/expert", env) == stage.keys("7/expert/1", env) == [jacket, expert] + assert stage.select([expert, normal, jacket], env) == ["7/expert", "7/normal"] + assert stage.select([expert, normal], env) == [] # without the jacket + assert stage.select([expert, jacket], Env(env.depot, {"master": None})) == [] + assert stage.live_record("7/expert", env)["band"]["band"] == 2 + assert ( + stage.manifest("7/expert") == "lives/7_expert.json" and stage.manifest("7/expert/1") == "lives/7_expert_1.json" + ) + assert stage.parts("7/expert", env) == { + live.SCENE: ["default"], + live.NOTES: ["default"], + live.NOTESKIN: ["skin001"], + live.NOTEEFFECT: ["fxA"], + live.SOUNDS: ["default"], + live.MUSIC: ["7"], + } + with pytest.raises(ValueError, match="not a live subject"): + stage.manifest("7") + with pytest.raises(KeyError, match="no easy chart"): + stage.keys("7/easy", env) + + +def test_the_live_product_holds_the_chart_and_the_manifests_of_its_parts(tmp_path): + depot = Depot(tmp_path / "d") + env = Env(depot, {"master": live_master_dir(tmp_path)}) + stage = LiveStage() + record = master_record(env, stage, "7/expert") + assert contract.loads(depot.input_bytes(record))["parts"] == { + "music": "music/7.json", + "noteeffect": "noteeffects/fxA.json", + "notes": "notes/default.json", + "noteskin": "noteskins/skin001.json", + "scene": "scenes/default.json", + "sounds": "sounds/default.json", + } + objects = stored(depot, contract.encode(lh.table(depot)), "objects") + _, _, arts = run(depot, stage, "7/expert", [objects, record]) + kinds = {n: a["semantics"]["kind"] for n, a in arts.items() if n != "objects"} + assert kinds == { + "live.json": "live", + "chart.json": "live.chart", + "notes.json": "live.notes.chart", + "notes.mirror.json": "live.notes.chart", + } + doc = contract.loads(depot.read(arts["live.json"]["content"]["sha256"])) + assert doc["jacket"] == lh.JACKET and doc["parts"]["scene"] == "scenes/default.json" + assert depot.read(arts["chart.json"]["content"]["sha256"]) == contract.encode(lh.CHART) + kept = contract.loads(depot.read(arts["objects"]["content"]["sha256"]))["objects"] + assert sorted(kept) == [lh.JACKET, lh.TEXTURE] + + def test_sounds_subjects_name_the_offered_sets(): assert liveaudio.sounds_subject() == "default" and liveaudio.sounds_groups("default") == () assert liveaudio.sounds_subject((3, 1)) == "NoteSePatternId=1,3" @@ -224,9 +299,9 @@ def test_sounds_subjects_name_the_offered_sets(): def test_the_music_parameter_is_a_web_format(tmp_path): - assert MusicStage().normalize(None) == {"format": "aac"} - with pytest.raises(ValueError, match="format 'wav'"): - MusicStage().normalize({"format": "wav"}) + assert MusicStage().normalize(None) == {"audio": "aac"} + with pytest.raises(ValueError, match="audio 'wav'"): + MusicStage().normalize({"audio": "wav"}) assert live.encode_atoms("flac") == {} @@ -236,7 +311,7 @@ def test_the_music_product_keeps_the_decoded_flac(tmp_path): env = Env(depot, {"master": master_dir(tmp_path)}) stage = MusicStage() sheets, doc = sheet_inputs(depot, ["bgm_7"]) - t, res, arts = run(depot, stage, "7", [table(depot), *sheets, master_record(env, stage, "7")], {"format": "flac"}) + t, res, arts = run(depot, stage, "7", [table(depot), *sheets, master_record(env, stage, "7")], {"audio": "flac"}) assert {p: a["semantics"]["kind"] for p, a in arts.items()} == { "audio/bgm_7/bgm_7.flac": "audio.bgm", "music.json": "live.music", @@ -364,7 +439,7 @@ def test_a_cue_sheet_that_was_not_decoded_makes_the_product_unsupported(tmp_path "files": {}, } sheets[-1] = stored(depot, contract.encode(doc), assemble.SHEETS_INPUT) - _, res, arts = run(depot, stage, "7", [table(depot), *sheets, master_record(env, stage, "7")], {"format": "flac"}) + _, res, arts = run(depot, stage, "7", [table(depot), *sheets, master_record(env, stage, "7")], {"audio": "flac"}) (item,) = res["items"] assert item["status"] == "unsupported" and item["reason"]["code"] == "unsupported.cri.awb_external"