diff --git a/docs/cli.md b/docs/cli.md index f5fb4b8..b6fc062 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -195,18 +195,19 @@ an ADV episode -> JSON ## nnnotes export story -an ADV episode -> story directory (episode, audio, scene, UI, media, videos) + its Live2D models +an ADV episode -> the story product and the products it plays with (scene, assets, Live2D models, cue sheets, chat, CRI Lips) | Argument | Description | | --- | --- | | `adv_id` | MasterAdv id of the episode | +| `--format` `FORMAT` | web audio format of the cue sheets (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) | | `-o`, `--out` `OUT` | output directory (required) | -| `--models` `DIR` | directory of the Live2D models, one / each (default: OUT/../live2d) | -| `--force` | export the models again whose directory exists | -| `--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) | -| `--no-audio` | do not decode the cue sheets | -| `--fonts` `FONTS` | open (default): text layout and style only, no font data; game: also the game's TMP fonts (needs the 'fonts' extra) (one of `open`, `game`) | +| `--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 | ## nnnotes export live diff --git a/docs/commands.md b/docs/commands.md index d4f3227..85560d9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -175,114 +175,45 @@ row of a Movie / Clip row's video id. ## export story ``` -nnnotes export story ADV_ID -o OUT [--models MODELS] [--force] [--format flac|ogg|wav] [--flac-level N] [--no-audio] - [--fonts open|game] -``` - -One ADV episode as a directory, and its Live2D models in a models directory: - -``` -MODELS// every Live2D model of the episode (MODELS: `--models`, default OUT/../live2d), the files - of a site's model manifest (see `site build`): model.json, moc3, prefab, the atlas pages and the - shader programs its drawables use -OUT/episode.json as `export episode` -OUT/audio// every cue sheet of the episode, one file per cue + cues.json, streams.json -OUT/scene.json player graphics, cameras, ADV fields, volumes, settings and stages -OUT/frames.json Frame prefabs by asset name: uGUI, Animator controllers and clips, UI particle systems -OUT/effects.json particle effect prefabs by asset name, `instances` (effect name -> asset name) -OUT/posteffects.json PostEffect volume profiles by asset name -OUT/stills.json Still prefabs by asset name -OUT/talkwindows.json talk window prefabs of the TalkWindow rows -OUT/chat.json chat window prefabs, icons and stamps (sprites), the MasterAdvChat rows, shared chat texts, - sounds and cue sheet rows -OUT/textures/, shaders/ textures and shaders of the scene and of the files above -OUT/videos/ Movie / Clip videos as WebM + videos.json (video id -> file, video row, size, frame rate) -OUT/ui/ ADV UI: ui.json (front canvas, still / frame / video canvases and their camera, letterbox), - packed textures, UI shaders, rule transitions -OUT/crilips/ crilips.json + crilips.bin: the CRI Lips analysis data, when a voice reaches the analysis -OUT/story.json index of the above (a file the episode does not need is null and not written) -``` - -story.json `models` maps the key of each model to its id (`` of `Character/Live2D///model/`) -and `modelsDir` is the path from OUT to MODELS. A model directory that exists is used as it is, so the stories of one -models directory export each model once; `--force` exports the episode's models again. The printed summary lists -the models exported (`modelsBuilt`) and those used as they were (`modelsSkipped`). Several `export story` processes may -share a models directory: a model appears in it in one rename, whole, and a model two processes export at once is -kept from the first to finish (the other's export, the same bytes, is dropped and counted in `modelsSkipped`). - -`--format` (default `flac`) is the audio format and `--flac-level` its FLAC compression level (as for `export audio`); -`--no-audio` leaves the cue sheets undecoded (no `audio/`, `audio` -in story.json is empty). With audio, `crilips/` holds the weights of the game's CRI Lips mouth analysis, read from the -CRI Lips library in `[paths] apk` (or in the arm64-v8a split next to it, `split_config.arm64_v8a.apk`), when a voice -reaches that analysis: a lip-synced voice while a model has no MotionSync controller (model.json `motionSync`), or a -voice that other speakers follow (story.json `crilips`; ournotes-player docs/crilips.md). Videos keep the VP9 stream -of the game's USM file and carry its ADX audio as Opus (FFmpeg, -`[paths] ffmpeg`); the USM streams are unmasked with the CRI key read from `[paths] apk`. TextMesh Pro text in -frames, talk windows and chat keeps its layout and style; with `--fonts open` its font and sprite assets and text -materials are only named (no atlas is written), with `--fonts game` they are exported. The command stops before -writing anything when the episode uses resources that are not in the catalog or resource kinds that are not -supported (`timeline`). Prints the index with a summary. - -The shared chat sound rows (`chat.json` `sounds`, `cueSheets`) name the cue sheet `AdvSe_Common`. In the version -1.0.1 data nothing holds it: no catalog language (`ja`, `en`, `zh-Hant`, `zh-Hans`, `ko`) has a key -`Cri/Sound/AdvSe_Common` or `EmbCri/Sound/AdvSe_Common` (remote or APK catalog), the APK has no ACB file of that -name, and it is not a Resources asset. The story therefore has no audio for these rows; the rows are kept as they -are. Every cue sheet of the episodes' own `-SoundCueSheet` shards has its `Cri/Sound/` key. +nnnotes export story ADV_ID -o OUT [--format FORMAT] [--flac-level N] [--stored-catalog LABEL|SHA] [--workers N] + [--link auto|clone|hard|copy] [--explain] [--dry-run] +``` -`ui/ui.json` holds the parts of the game's ADV widget the story draws: the front canvas (talk window, speaker plate, -location caption, title, rule transition, curtains, flash, subtitles caption, next indicator, menu entry button, menu -panel and video buttons, backlog, choices, tap areas), the canvases of stills, frames and videos with the screen -image and the camera they render with (`videoAndStillCamera`), the UI camera that renders the frame canvas -(`uiCamera`), and the letterbox bands; per node its transform, rect -and uGUI components, the views' serialized references as node paths (the menu view with its fast-forward sprites), -the other game components' serialized fields (`behaviours`), the widget's canvas sort orders (`widget`), the ADV -screen's dialogs (`dialogs`: the skip confirm and common dialog prefabs) and their texts of the language -(`masterIdTexts`), the chat phone (`chatWidget`), the text -records of the episode's chat windows (`chatTexts`) with their status texts (`chatStatusTexts`). The talk windows -sit under `TalkView`: the default window, -then each other window the episode's TalkWindow rows attach; when one of them dims and blurs the screen behind its -talk (`UICenterTalkWindow`), the front canvas' `CenterTalkBackdrop` node, the renderer's blur settings (`blur`) and -the blur shader come with it. +One ADV episode and the products it plays with, in the `product` layout ([products](products.md#stories)): -`ui/` follows the client language `[catalog] language` (`ja`, `en`, `zh-Hant`, `zh-Hans` or `ko`): the localized -fonts and materials, the line spacing LocalizeText applies, and, with `--fonts game`, the characters of the lines -(Talk, Location and Subtitles rows, ruby readings included), speaker names, title and UI texts in that language -(`ui/ui.json` `language`: `mode`, `field`, `lineSpacing`). +``` +OUT/stories/.json the story (`story`): episode.json and story.json +OUT/storyscenes/default.json the ADV scene (`story.scene`) +OUT/storyassets/.json each stage, frame, effect, post effect, still, timeline, transition, chat window, + chat icon and stamp the episode loads (`story.asset`; : its key, "/" as "~") +OUT/models/.json each Live2D model of the episode (`live2d.model`) +OUT/cuesheets/.json each cue sheet of the episode, decoded (`story.sound`) +OUT/storychat/default.json the shared chat rows, when the episode shows chat (`story.chat`) +OUT/crilips/default.json the CRI Lips analysis data, when a voice can reach it (`story.crilips`) +OUT/assets/. the files the manifests name +``` -`--fonts` (default `open`) chooses how `ui/` handles text. Both modes write a text record `textStyle` for each text -node of `ui/ui.json`, and a document-level `textStyle` that holds the units and the line metrics of each font role. -With `open`, `ui/` contains no font data: no font atlases, glyph tables, font materials or text shader. `game` also -exports the game's TextMesh Pro fonts: per node the serialized text settings with the localized font and material -(`text`), and in the document the font assets reduced to the characters the episode shows (`fonts`), runtime glyphs, -text materials, `glyphCoverage`, `tmpSettings` and the text shader. `game` needs the optional `fonts` dependencies -(`pip install -e ".[fonts]"`). +`episode.json` is the episode as `export episode` writes it, each resource given by its kind and address. +`story.json` names the episode's chat rows, its effect instances and, in `parts`, the manifest of each product above +it plays with. The command runs twice: it asks for the story, reads its `story.json`, then asks for the story and +every product `parts` names and places them. It stops with a usage error before the second run when a key of such a +product is not in the catalog. The episode's talk windows and videos are listed in `episode.json` `resources`. -The `ui/ui.json` text record (`textStyle` of a node; the node's `rect` and transform give its layout box): +A product several stories play with (the scene, a stage, a model, a cue sheet) is one product: stories exported into +one directory share its manifest and files, and the depot keeps every result for the next export. -| Field | Value | -|---|---| -| `class`, `enabled` | TMP component class, enabled flag | -| `fontRole` | font slot of the game's localized fonts: `primary` (main text face), `number` (Latin / numeral face) | -| `materialType` | material style name of the text (`Default`, `OutlineAdvCommon`, ...) | -| `localized`, `textKey` | LocalizeText enabled; its master text id, or null when the runtime sets the text (talk, speaker, location and title texts come from `episode.json`, one field per language) | -| `text`, `richText`, `parseControlCharacters` | serialized text, rich text tags on, `\n`-style escapes parsed | -| `fontSize`, `autoSize` | size in canvas units; auto-size `enabled`, `min`, `max`, `maxCharWidthAdjust`, `maxLineSpacingAdjust` | -| `fontStyle`, `fontWeight` | style flags (`bold`, `italic`, `underline`, `strikethrough`, `lowerCase`, `upperCase`, `smallCaps`, `superscript`, `subscript`, `highlight`), weight 100-900 | -| `alignment` | `horizontal` (`left`, `center`, `right`, `justified`, `flush`, `geometry`), `vertical` (`top`, `middle`, `bottom`, `baseline`, `geometry`, `capline`) | -| `wrapping`, `overflow` | `noWrap` / `normal` / `preserveWhitespace` / `preserveWhitespaceNoWrap`; `overflow` / `ellipsis` / `masking` / `truncate` / `scrollRect` / `page` / `linked` | -| `margin` | `left`, `top`, `right`, `bottom` insets of the rect | -| `lineSpacing` | `serialized`, `applied` (the value for the export language), `byLanguage` (the per-language line spacing LocalizeText applies; null when not localized) | -| `paragraphSpacing`, `characterSpacing`, `wordSpacing` | spacing in 1/100 em | -| `characterHorizontalScale`, `kerning`, `rightToLeft`, `orthographic` | as serialized | -| `color`, `colorMode`, `colorGradient`, `overrideHtmlColors` | vertex colour, gradient mode, four-corner gradient (null when off) | -| `face` | `color` (fill = `color` x `face.color`), `dilateEm`, `boldDilateEm` (glyph edge moved outwards), `softnessEm` (edge ramp width) | -| `outline` | null, or `color`, `widthEm` (band on each side of the glyph edge: stroke width 2 x `widthEm`, drawn over the fill), `softnessEm` | -| `underlay` | null, or `color`, `offsetEm` [x, y] (+x right, +y down), `dilateEm`, `softnessEm`, `inner` (shadow inside the glyph) | +`--format` (default `aac`) is the web audio format of the cue sheets: `aac` (AAC-LC in MP4, `cues.json` records each +file's encoder delay), `opus`, `vorbis`, `mp3`, or `flac` (the decoded FLAC as it is); `--flac-level` is the FLAC +compression level of the decoded sounds (as for `export audio`). `crilips/` holds the weights of the game's CRI Lips +mouth analysis, read from the CRI Lips library in `[paths] apk` (or in the arm64-v8a split next to it, +`split_config.arm64_v8a.apk`); `story.json` names it when a voice of the episode is lip-synced, and the models' +MotionSync (model.json `motionSync`) decides whether a player runs the analysis. -`*Em` values are fractions of the drawn font size, converted from the text material's distance-field properties the -way the TMP shader applies them. The document-level `textStyle.roles` gives, per role, `lineHeightEm`, `ascentEm`, -`descentEm` (line pitch = `lineHeightEm` + `lineSpacing` / 100 em) and the font's `spacingOffset` / `boldSpacing` -(1/100 em) for the export language. +The shared chat sound rows (`chat.json` `sounds` and `cueSheets` of `storychat/default.json`) name the cue sheet +`AdvSe_Common`. In the version 1.0.1 data nothing holds it: no catalog language (`ja`, `en`, `zh-Hant`, `zh-Hans`, +`ko`) has a key `Cri/Sound/AdvSe_Common` or `EmbCri/Sound/AdvSe_Common` (remote or APK catalog), the APK has no ACB +file of that name, and it is not a Resources asset. The story therefore has no audio for these rows; the rows are +kept as they are. Every cue sheet of the episodes' own `-SoundCueSheet` shards has its `Cri/Sound/` key. ## export live2d @@ -603,7 +534,7 @@ SITE/assets/.[.gz|.br] content-addressed files shared by all ### Stories -A story's manifest lists what the player's story page reads, in the layout of `export story` (below the site's `assets/`): +A story's manifest lists what the player's story page reads (below the site's `assets/`): `story.json`, `episode.json`, `scene.json`, the scene shaders (GLSL ES 3.00 programs only), textures, per cue sheet `cues.json` and the file of each cue in the `--format` (an AAC file's encoder delay in `cues.json` as `encoderDelay`), the media files the episode uses and its videos; per language (`--story-languages`, @@ -660,6 +591,60 @@ root), and `stories.json` counts their files in `size.models`. The format is des that receives texts is given at run time (the texts of its Frame rows' `TargetTextIDs`, as the frame formats them). - Stories that fail are listed in the printed summary and in `SITE.story-failures.json`; the exit status is then 1. +`ui/ui.json` holds the parts of the game's ADV widget the story draws: the front canvas (talk window, speaker plate, +location caption, title, rule transition, curtains, flash, subtitles caption, next indicator, menu entry button, menu +panel and video buttons, backlog, choices, tap areas), the canvases of stills, frames and videos with the screen +image and the camera they render with (`videoAndStillCamera`), the UI camera that renders the frame canvas +(`uiCamera`), and the letterbox bands; per node its transform, rect +and uGUI components, the views' serialized references as node paths (the menu view with its fast-forward sprites), +the other game components' serialized fields (`behaviours`), the widget's canvas sort orders (`widget`), the ADV +screen's dialogs (`dialogs`: the skip confirm and common dialog prefabs) and their texts of the language +(`masterIdTexts`), the chat phone (`chatWidget`), the text +records of the episode's chat windows (`chatTexts`) with their status texts (`chatStatusTexts`). The talk windows +sit under `TalkView`: the default window, +then each other window the episode's TalkWindow rows attach; when one of them dims and blurs the screen behind its +talk (`UICenterTalkWindow`), the front canvas' `CenterTalkBackdrop` node, the renderer's blur settings (`blur`) and +the blur shader come with it. + +Each language's `ui/` holds the localized fonts and materials, the line spacing LocalizeText applies, and, with +`--fonts game`, the characters of the lines (Talk, Location and Subtitles rows, ruby readings included), speaker +names, title and UI texts in that language (`ui/ui.json` `language`: `mode`, `field`, `lineSpacing`). + +`--fonts` (default `open`) chooses how `ui/` handles text. Both modes write a text record `textStyle` for each text +node of `ui/ui.json`, and a document-level `textStyle` that holds the units and the line metrics of each font role. +With `open`, `ui/` contains no font data: no font atlases, glyph tables, font materials or text shader. `game` also +exports the game's TextMesh Pro fonts: per node the serialized text settings with the localized font and material +(`text`), and in the document the font assets reduced to the characters the episode shows (`fonts`), runtime glyphs, +text materials, `glyphCoverage`, `tmpSettings` and the text shader. `game` needs the optional `fonts` dependencies +(`pip install -e ".[fonts]"`). + +The `ui/ui.json` text record (`textStyle` of a node; the node's `rect` and transform give its layout box): + +| Field | Value | +|---|---| +| `class`, `enabled` | TMP component class, enabled flag | +| `fontRole` | font slot of the game's localized fonts: `primary` (main text face), `number` (Latin / numeral face) | +| `materialType` | material style name of the text (`Default`, `OutlineAdvCommon`, ...) | +| `localized`, `textKey` | LocalizeText enabled; its master text id, or null when the runtime sets the text (talk, speaker, location and title texts come from `episode.json`, one field per language) | +| `text`, `richText`, `parseControlCharacters` | serialized text, rich text tags on, `\n`-style escapes parsed | +| `fontSize`, `autoSize` | size in canvas units; auto-size `enabled`, `min`, `max`, `maxCharWidthAdjust`, `maxLineSpacingAdjust` | +| `fontStyle`, `fontWeight` | style flags (`bold`, `italic`, `underline`, `strikethrough`, `lowerCase`, `upperCase`, `smallCaps`, `superscript`, `subscript`, `highlight`), weight 100-900 | +| `alignment` | `horizontal` (`left`, `center`, `right`, `justified`, `flush`, `geometry`), `vertical` (`top`, `middle`, `bottom`, `baseline`, `geometry`, `capline`) | +| `wrapping`, `overflow` | `noWrap` / `normal` / `preserveWhitespace` / `preserveWhitespaceNoWrap`; `overflow` / `ellipsis` / `masking` / `truncate` / `scrollRect` / `page` / `linked` | +| `margin` | `left`, `top`, `right`, `bottom` insets of the rect | +| `lineSpacing` | `serialized`, `applied` (the value for the export language), `byLanguage` (the per-language line spacing LocalizeText applies; null when not localized) | +| `paragraphSpacing`, `characterSpacing`, `wordSpacing` | spacing in 1/100 em | +| `characterHorizontalScale`, `kerning`, `rightToLeft`, `orthographic` | as serialized | +| `color`, `colorMode`, `colorGradient`, `overrideHtmlColors` | vertex colour, gradient mode, four-corner gradient (null when off) | +| `face` | `color` (fill = `color` x `face.color`), `dilateEm`, `boldDilateEm` (glyph edge moved outwards), `softnessEm` (edge ramp width) | +| `outline` | null, or `color`, `widthEm` (band on each side of the glyph edge: stroke width 2 x `widthEm`, drawn over the fill), `softnessEm` | +| `underlay` | null, or `color`, `offsetEm` [x, y] (+x right, +y down), `dilateEm`, `softnessEm`, `inner` (shadow inside the glyph) | + +`*Em` values are fractions of the drawn font size, converted from the text material's distance-field properties the +way the TMP shader applies them. The document-level `textStyle.roles` gives, per role, `lineHeightEm`, `ascentEm`, +`descentEm` (line pitch = `lineHeightEm` + `lineSpacing` / 100 em) and the font's `spacingOffset` / `boldSpacing` +(1/100 em) for the export language. + ### Regions and languages One site serves several regions and every language: diff --git a/docs/configuration.md b/docs/configuration.md index 615aff4..f36ad31 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 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 `site build` (the text field, fonts and line spacing of its story 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`, `site build`) also needs the optional `fonts` dependencies (`pip install 'nnnotes[fonts]'`), +`--fonts game` (`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. @@ -170,7 +170,7 @@ story text); it is not a setting. | `master version` | `[catalog] region` and that region's `api`; the client version: `[client] version` or `[paths] apk` | | `servers` | `[bootstrap] api`; the client version: `[client] version` or `[paths] apk` | | `export episode` | catalog, master data | -| `export story` | catalog, `[catalog] language`, master data, `[paths] apk`, `ffmpeg` (audio and videos), `vgmstream` (not with `--no-audio`) | +| `export story` | catalog, master data, `[paths] apk`, `vgmstream`, `ffmpeg` | | `export live2d` | catalog, `[paths] apk` | | `export spot` | catalog, master data, `[paths] apk` | | `export room` | catalog | diff --git a/docs/export.md b/docs/export.md index 3a74d75..5a1aefa 100644 --- a/docs/export.md +++ b/docs/export.md @@ -15,14 +15,19 @@ nnnotes export episode 10462 -o out/episode_10462.json # the episode's comma nnnotes export story 10462 -o out/stories/10462 # everything the episode plays ``` -`export story` writes the episode, its decoded cue sheets, the stage scene, the story UI, frames, effects, stills, -chats and videos below `-o`, and its Live2D models below the models directory (`--models`, default `live2d/` next to -the story directory). A model directory that exists is used as it is, so exporting the stories of a season into -sibling directories exports each model once. - -- `--format flac|ogg|wav` and `--flac-level N` choose the audio format; `--no-audio` leaves the cue sheets undecoded. -- `--fonts game` also writes the game's fonts reduced to the characters shown (needs `pip install 'nnnotes[fonts]'`). -- `--force` exports the episode's models again. +`export story` writes the story product of an episode and the products it plays with, as a product layout below `-o`: +the story index in `stories/.json`, the stage scene in `storyscenes/default.json`, each stage, frame, effect and +other asset in `storyassets/`, each Live2D model in `models/`, each cue sheet in `cuesheets/`, and, when the episode +uses them, the shared chat rows in `storychat/default.json` and the CRI Lips data in `crilips/default.json`. The files +themselves are under `assets/`. + +Products that several stories share (the scene, assets, models and cue sheets) are placed once, so exporting several +stories into one directory keeps one copy of each. Every product goes through the depot, so a second export reuses +its results. + +- `--format NAME` chooses the web audio format of the cue sheets: `aac` (default), `opus`, `vorbis`, `mp3` or `flac`. +- `--flac-level N` sets the FLAC compression level (0 to 12, default 8) of the decoded sounds. +- `--dry-run` prints the plan and runs nothing. `MasterAdv` lists the episode ids; `nnnotes voices list --source story --episode ID` lists the voiced lines of one. Details: [export episode](commands.md#export-episode), [export story](commands.md#export-story). diff --git a/docs/products.md b/docs/products.md index 5ceef0b..6b12f90 100644 --- a/docs/products.md +++ b/docs/products.md @@ -359,9 +359,12 @@ A story episode plays with products that several episodes share, made by `nnnote | `story.sound` | a cue sheet name (`_cueSheetName` of an episode's cue sheet table) | `cuesheets/.json` | the cue sheet's key `Cri/Sound/` | `cues.json` and the cues' sounds | | `story.crilips` | `default` | `crilips/default.json` | none (it reads the APK) | `crilips.json` and `crilips.bin` | | `story.scene` | `default` | `storyscenes/default.json` | the ADV scene `EmbScene/Adv`, the prefabs `EmbCommon/Prefab/CameraManager`, `EmbCommon/Adv/Prefab/AdvCharacterField`, `AdvBackgroundField` and `AdvGlobalVolume`, the settings `EmbCommon/Adv/Settings/AdvPlayerSettings` and `AdvMasterIdSettings`, and the built-in `GraphicsSettings`, `QualitySettings` and `PlayerSettings` | `scene.json` and the object table | -| `story.asset` | a catalog key a story loads: a stage (`Adv/Stage/`), frame, effect, post effect, still, chat window, chat icon or stamp, transition | `storyassets/.json`, `` the key with `/` as `~` | the key | `asset.json` and the object table | +| `story.asset` | a catalog key a story loads: a stage (`Adv/Stage/`), frame, effect, post effect, still, timeline, chat window, chat icon or stamp, transition | `storyassets/.json`, `` the key with `/` as `~` | the key | `asset.json` and the object table | +| `story.chat` | `default` | `storychat/default.json` | the shared chat shards `Adv/Chat/AdvChat-Text`, `Adv/Chat/AdvChat-Sound` and `Adv/Chat/AdvChat-SoundCueSheet` | `chat.json` | +| `story` | a `MasterAdv` id | `stories/.json` | the episode key `Adv/Episode//` (``: the row's `_advEpisodeAsset`), its shard keys (the key with `-Text`, `-Sound`, `-SoundCueSheet`, `-Video`) and `EmbCommon/Adv/Settings/AdvPlayerSettings` | `episode.json` and `story.json` | -Neither keeps objects. `story.sound` reads its cue sheet's `cri.audio` files; `cues.json` maps each cue name to the +`story.sound`, `story.crilips`, `story.chat` and `story` keep no objects: their files are all they hold. +`story.sound` reads its cue sheet's `cri.audio` files; `cues.json` maps each cue name to the first stream of that name (in stream order): `file` (the sound, next to `cues.json`), `sampleRate`, `channels`, `samples` (frames at `sampleRate`, without encoder padding) and, for a looping stream, `loopStart` and `loopEnd` (sample frames). The sounds are in the web format of the parameter `audio`, as for `live.music`: `aac` (the default) @@ -390,3 +393,30 @@ render pipeline asset, its renderer data and their features). `story.asset` hold and what they reach; an asset several stories load is one product. `asset.json` has `format` 1, `key`, `objects` (the key's object ids), `bindings` and `shaders` as above. The CRI Lips data and the story assets are made only when a request names them; the `product` layout makes `story.scene` when the selection holds every key of it. + +`story.chat` holds the rows of the chat shards every chat of a story reads. `chat.json` has `format` 1, `text` (the +chat message texts), `sounds` (their sound rows) and `cueSheets` (the cue sheet rows those name), each the `_allData` +rows of its shard as stored; a shard key without a TextAsset gives `[]`. The `product` layout makes it when the +selection holds the three shard keys. + +`story` is one episode. Its input `master` is the episode's master record: its `MasterAdv` row, the `MasterText` row +of its title (none when `_titleTextId` is empty) and every `MasterAdvChat` row. `episode.json` is the episode as +`export episode` writes it, each resource given by its kind and address: the command list with the lines of every +language, the voices, sounds, cue sheets and videos the commands name, and the resources the episode loads. +`story.json` has + +| Field | | +|---|---| +| `format` | 1 | +| `advId` | the `MasterAdv` id | +| `episode` | `"episode.json"`, the file next to it | +| `chats` | `{chat id: MasterAdvChat row}`: each chat id the commands name (`TargetChatID`) | +| `effects` | `{TargetName: "Adv/Effect/"}`: the effect instances, the first asset an Effect command loads under a name | +| `parts` | the manifests of the products the story plays with: `scene` (`storyscenes/default.json`), `assets` (`{address: manifest}` of each `story.asset`), `models` (`{address: manifest}` of each `live2d.model`), `sounds` (`{cue sheet: manifest}` of each `story.sound`), `chat` (`storychat/default.json` when a command names a chat, else null) and `crilips` (`crilips/default.json` when a voice command has a target and voice cues and sets neither `IgnoreData` nor `IgnoreLipSync`, else null) | + +A model's MotionSync (model.json `motionSync`) decides whether a lip-synced voice runs the CRI Lips analysis, so +`crilips` names the data whenever a voice can reach it. The episode's talk windows and videos are listed in +`episode.json` `resources`. `parts` depend on the episode's commands, which only the story's result holds: a command +asks for the `story` product first, reads its `story.json`, then asks for the story with every product `parts` names +(`nnnotes.pipeline.story.part_products`); `export story` works this way. The `product` layout makes the story of +every episode whose key the selection holds. diff --git a/docs/quickstart.md b/docs/quickstart.md index 8c8cc44..7658031 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,6 +1,6 @@ # Quick start -This walk-through takes you from an empty configuration to an exported ADV episode and a story directory. It needs +This walk-through takes you from an empty configuration to an exported ADV episode and a story export. It needs your own installation of the game: its `base.apk`, and the keys and server addresses of your client ([where the values come from](configuration.md#where-the-values-come-from)). @@ -66,9 +66,10 @@ nnnotes export story 10462 -o out/story_10462 ``` `export episode` writes the episode's command list with its lines in five languages and the index of its voices, -sounds and videos. `export story` writes the whole story directory: the episode, its audio, the stage scene and -shaders, the story UI, frames, effects, stills, chats and videos; the Live2D models it uses go to `out/live2d/`, -where the next story reuses them. Add `--json` to any command to get its report as JSON. +sounds and videos. `export story` writes the story product of the episode and the products it plays with below +`out/story_10462`: the story index, the stage scene, the assets, the Live2D models, the cue sheets decoded to audio +(AAC by default, `--format` chooses another web format), and the shared chat rows and CRI Lips data when the episode +uses them. The files are under `assets/`. Add `--json` to any command to get its report as JSON. ## Next diff --git a/docs/stages.md b/docs/stages.md index dbc2933..b129b3d 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -40,6 +40,8 @@ codec and channel of a USM) and `catalog.index` (version 2: gzip input and Japan | `story.crilips` | `default` | `objects` (empty); `apk:lib/arm64-v8a/libcri_lips_unity.so` | `crilips.json` and `crilips.bin` | | `story.scene` | `default` | `objects`: the object table of the ADV scene, its prefabs and settings and the built-in graphics settings ([products](products.md#stories)) | `scene.json` and the object table | | `story.asset` | a catalog key a story loads | `objects`: the object table of what the key reaches | `asset.json` and the object table | +| `story.chat` | `default` | `objects`: the object table of the shared chat shards | `chat.json` | +| `story` | a `MasterAdv` id | `objects`: the object table of the episode's command list, its shards and the ADV player settings ([products](products.md#stories)); `master`: the episode's master record | `episode.json` and `story.json` | | `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 8700119..22f8b4a 100644 --- a/src/nnnotes/cli/assets.py +++ b/src/nnnotes/cli/assets.py @@ -131,6 +131,8 @@ def matches(self, stage: str) -> bool: StageSource("story.crilips", "nnnotes.pipeline.story:CriLipsStage", output=True, product=True), StageSource("story.scene", "nnnotes.pipeline.story:SceneStage", output=True, product=True), StageSource("story.asset", "nnnotes.pipeline.story:AssetStage", output=True, product=True), + StageSource("story.chat", "nnnotes.pipeline.story:ChatStage", output=True, product=True), + StageSource("story", "nnnotes.pipeline.story:StoryStage", output=True, product=True), StageSource("view.", "nnnotes.pipeline.views:stages", output=True), StageSource("view.voices", "nnnotes.pipeline.voices:VoiceStage", output=True), ) @@ -1269,8 +1271,10 @@ def cmd_export(args, cfg): export(args, cfg, Workspace(args, cfg, layouts=names)) -def export(args, cfg, ws: Workspace) -> None: - """Run the request of `ws` and place its layouts (ws.layouts) in args.out (cmd_export; export live2d).""" +def export(args, cfg, ws: Workspace, expand=None) -> None: + """Run the request of `ws` and place its layouts (ws.layouts) in args.out (cmd_export; export live2d). `expand`: + called with the run when it was not aborted, gives the request of a further run ((products, selection) for + Workspace.request) or None; the last run places the layouts (export story: the products a story names).""" from ..pipeline import builtins, layout from ..pipeline.orchestrate import Orchestrator, failure @@ -1332,6 +1336,11 @@ def export(args, cfg, ws: Workspace) -> None: aborted = aborted or str(e) if isinstance(facts["raw"], Later) and aborted is None: ws.log(f"{sum(p['kind'] == 'raw' for p in ws.problems())} raw files not readable") + if expand is not None and aborted is None: + more = expand(run) + if more is not None: + ws.request(*more) + return export(args, cfg, ws) stage_of = {"bundle": "unity.census", "raw": next((n for n in RAW_STAGES if n in pipeline), "cri.audio")} for p in ws.problems(): if p["code"] == "source.error": diff --git a/src/nnnotes/cli/export.py b/src/nnnotes/cli/export.py index a72e9c2..bd0a140 100644 --- a/src/nnnotes/cli/export.py +++ b/src/nnnotes/cli/export.py @@ -4,7 +4,6 @@ from __future__ import annotations -import os import sys from pathlib import Path @@ -20,8 +19,6 @@ command, flac_level_arg, flac_options, - fonts_arg, - fonts_extra, group, known_keys, known_row, @@ -49,39 +46,43 @@ def cmd_episode(args, cfg): def cmd_story(args, cfg): - from ..content import story - from ..site import webmodel - from ..source import languages - from ..unity.player import PlayerData + from ..pipeline import contract + from ..pipeline import story as sp + from ..pipeline.stages import Env - fonts_extra(args.fonts) - flac = flac_options(args) - out = Path(os.path.abspath(args.out)) - models = Path(os.path.abspath(args.models or Path(args.out) / os.pardir / "live2d")) - try: - os.path.relpath(models, out) - except ValueError: - usage(args, f"--models {models}: not on the drive of {out} (story.json names it relative to the story)") - language = languages.check(cfg.require("catalog", "language")) # the story UI's language + cfg.require_path("paths", "apk") # the ADV settings, the built-in graphics settings and CRI Lips 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, "MasterAdv", args.adv_id, "episode") - cat = Session(cfg).catalog() - player = PlayerData(Session(cfg).apk()) - source = webmodel.ModelDir(cat, player, models, force=args.force) - r = story.build( - cat, - md, - player, - args.adv_id, - out, - audio_format=args.format, - audio=not args.no_audio, - fonts=args.fonts, - models=source, - language=language, - audio_options=flac, - ) - report(args, {**r, "modelsBuilt": source.built, "modelsSkipped": source.skipped}) + ws = assets.Workspace(args, cfg, layouts=["product"]) + env = Env(ws.depot, {"master": md}) + subject = str(args.adv_id) + classes = {sp.STORY: sp.StoryStage, **sp.PART_STAGES} + ws.run_index() + held = {loc["primaryKey"] for loc in ws.index()["locations"]} + + def request(products: dict) -> tuple[dict, list[str]]: + keys = {k for n, subjects in products.items() for s in subjects for k in classes[n]().keys(s, env)} + return products, sorted(f"key:{k}" for k in keys if k in held) + + def parts(run): # the products story.json names, with the story + tid = contract.task_id(sp.STORY, subject) + task = run.tasks.get(tid) + if task is None or task["status"] == "failed": + return None + doc = ws.depot.committed(task["key"], tid) + art = next((a for a in doc["artifacts"] if a["id"] == contract.artifact_id(tid, "story.json")), None) + if art is None: # the story is unsupported: the run reports it + return None + products = {sp.STORY: [subject], **sp.part_products(contract.loads(ws.depot.read(art["content"]["sha256"])))} + missing = sorted(k for n, subjects in products.items() if n != sp.STORY for s in subjects + for k in classes[n]().keys(s, env) if k not in held) # fmt: skip + if missing: + usage(args, f"episode {args.adv_id}: {', '.join(missing)} not in the catalog") + return request(products) + + ws.request(*request({sp.STORY: [subject]})) + assets.export(args, cfg, ws, expand=parts) def cmd_live(args, cfg): @@ -286,21 +287,21 @@ def register(sub) -> None: es, "story", cmd_story, - "an ADV episode -> story directory (episode, audio, scene, UI, media, videos) + its Live2D models", + "an ADV episode -> the story product and the products it plays with (scene, assets, Live2D models, " + "cue sheets, chat, CRI Lips)", ) c.add_argument("adv_id", type=int, help="MasterAdv id of the episode") - out_arg(c, "output directory") c.add_argument( - "--models", metavar="DIR", help="directory of the Live2D models, one / each (default: OUT/../live2d)" - ) - c.add_argument("--force", action="store_true", help="export the models again whose directory exists") - audio_args(c) - c.add_argument("--no-audio", action="store_true", help="do not decode the cue sheets") - fonts_arg( - c, - "open (default): text layout and style only, no font data; game: also the game's TMP fonts " - "(needs the 'fonts' extra)", + "--format", + dest="audio_format", + metavar="FORMAT", + default=DEFAULT_AUDIO_FORMAT, + choices=sorted(WEB_AUDIO), + help=f"web audio format of the cue sheets (default {DEFAULT_AUDIO_FORMAT})", ) + flac_level_arg(c, "of the decoded sounds") + out_arg(c, "output directory") + product_args(c) c = command( es, diff --git a/src/nnnotes/content/adv.py b/src/nnnotes/content/adv.py index 482fab9..fdfa074 100644 --- a/src/nnnotes/content/adv.py +++ b/src/nnnotes/content/adv.py @@ -11,13 +11,13 @@ from __future__ import annotations +import json from dataclasses import dataclass from pathlib import Path from ..source import master from ..source.catalog import Catalog from ..source.config import apk_missing -from ..unity import reader as unity from .advcommand import COMMAND # AdvEpisodeResourceLoader.AddEpisodeLoadingTask, per command: (resource kind, catalog-key prefix) of the asset named @@ -78,7 +78,23 @@ def _master_text(text_id: str, master_dir: Path) -> dict: raise KeyError(f"MasterText has no row {text_id}") +def parse_shard(text: str) -> dict: + """A `-Text/-Sound/...` shard: {_header:[{_name,_type}], _allData:[rows]}. + + Returns {"header": [...], "rows": [...]}. Row keys keep their leading + underscore + lowercased-first-letter form as serialized (e.g. `_cueName`). + """ + d = json.loads(text) + return {"header": d.get("_header", []), "rows": d.get("_allData", [])} + + +def shard_index(rows: list[dict], id_key: str = "_id") -> dict: + return {r[id_key]: r for r in rows if id_key in r} + + def _text_of(cat: Catalog, key: str) -> str | None: + from ..unity import reader as unity + if not cat.has(key): return None for p in cat.fetch_key(key): @@ -89,6 +105,8 @@ def _text_of(cat: Catalog, key: str) -> str | None: def extract(cat: Catalog, master_dir: Path, adv_id: int) -> AdvExtract: + from ..unity import reader as unity + row = _master_row(adv_id, master_dir) a = row["_advEpisodeAsset"] base = f"Adv/Episode/{a}/{a}" @@ -127,14 +145,14 @@ def build(row: dict, coll: list[dict], shards: dict, has, chat, transition, titl def shard(suf: str) -> list[dict]: t = shards.get(suf) - return unity.parse_shard(t)["rows"] if t else [] + return parse_shard(t)["rows"] if t else [] text_rows = shard("-Text") sound_rows = shard("-Sound") cue_rows = shard("-SoundCueSheet") video_rows = shard("-Video") - text_idx = unity.shard_index(text_rows) - sound_idx = unity.shard_index(sound_rows) + text_idx = shard_index(text_rows) + sound_idx = shard_index(sound_rows) # enrich commands out_cmds = [] @@ -165,7 +183,7 @@ def default_transition() -> str: default.append(transition()) return default[0] - resources = closure(coll, has, unity.shard_index(video_rows), chat, default_transition) + resources = closure(coll, has, shard_index(video_rows), chat, default_transition) return AdvExtract( adv_id=row["_id"], @@ -186,6 +204,21 @@ def _name(v) -> str | None: return v if isinstance(v, str) and v.strip() else None +def effect_instances(commands: list[dict]) -> dict[str, str]: + """TargetName -> TargetAssetName of the effect instances (LoadParticleEffect: the first asset loaded under a + TargetName is the instance every later Effect row with that TargetName drives).""" + out: dict[str, str] = {} + for c in commands: + if ( + c["cmd"] == "Effect" + and not c.get("IgnoreData") + and _name(c.get("TargetName")) + and _name(c.get("TargetAssetName")) + ): + out.setdefault(c["TargetName"], c["TargetAssetName"]) + return out + + def closure(rows: list[dict], has, videos: dict, chat, default_transition) -> list[dict]: """The assets AddEpisodeLoadingTask loads for the episode's rows (raw `Collection` rows, `Command` numbers), as [{kind, address, present}] sorted by (kind, address). @@ -238,6 +271,8 @@ def add(kind: str, address: str) -> None: def _default_transition(cat: Catalog) -> str: """AdvPlayerSettings._defaultTransitionAssetAddress (embedded content: the catalog needs the APK's).""" + from ..unity import reader as unity + if not cat.has(PLAYER_SETTINGS_KEY): raise apk_missing("the default ADV transition (AdvPlayerSettings)") for p in cat.fetch_key(PLAYER_SETTINGS_KEY): diff --git a/src/nnnotes/content/advmedia.py b/src/nnnotes/content/advmedia.py index c5b9608..07e0e2a 100644 --- a/src/nnnotes/content/advmedia.py +++ b/src/nnnotes/content/advmedia.py @@ -40,20 +40,15 @@ from ..source import master as master_mod from ..source.catalog import Catalog from ..unity import fontsettings -from ..unity import reader as unity from ..unity.export import HEADER, Exporter from ..unity.player import PlayerData from . import adv +from .storychat import SHARDS as CHAT_SHARDS TMP_SHADER_PREFIX = "TextMeshPro/" TMP_ASSETS = ("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet") FONT_CHOICES = ("open", "game") FONT_NOTE = "font data not exported (fonts: open): draw the text with a font of your own" -CHAT_SHARDS = { - "text": "Adv/Chat/AdvChat-Text", - "sounds": "Adv/Chat/AdvChat-Sound", - "cueSheets": "Adv/Chat/AdvChat-SoundCueSheet", -} # resource kind -> (story.json key = file key, file, address prefix removed from the entry keys; None: last segment) FILES = { "frame": ("frames", "frames.json", adv.RESOURCE_PREFIX["Frame"][1]), @@ -132,21 +127,6 @@ def _suffix(address: str, prefix: str | None) -> str: return address[len(prefix) :] -def effect_instances(commands: list[dict]) -> dict[str, str]: - """TargetName -> TargetAssetName of the effect instances (LoadParticleEffect: the first asset loaded under a - TargetName is the instance every later Effect row with that TargetName drives).""" - out: dict[str, str] = {} - for c in commands: - if ( - c["cmd"] == "Effect" - and not c.get("IgnoreData") - and adv._name(c.get("TargetName")) - and adv._name(c.get("TargetAssetName")) - ): - out.setdefault(c["TargetName"], c["TargetAssetName"]) - return out - - def _chat(cat: Catalog, master: Path, ex: Exporter, episode: dict, resources: list[dict]) -> dict: rows = {r["_id"]: r for r in master_mod.table(master, "MasterAdvChat")} ids = sorted({c["TargetChatID"] for c in episode["commands"] if c.get("TargetChatID")}) @@ -160,7 +140,7 @@ def _chat(cat: Catalog, master: Path, ex: Exporter, episode: dict, resources: li doc["stamps"][_suffix(r["address"], adv.RESOURCE_PREFIX["ChatStamp"][1])] = ex.key_sprite(r["address"]) for name, key in CHAT_SHARDS.items(): t = adv._text_of(cat, key) - doc[name] = unity.parse_shard(t)["rows"] if t else [] + doc[name] = adv.parse_shard(t)["rows"] if t else [] return doc @@ -193,7 +173,7 @@ def done(ex: Exporter, name: str, doc: dict) -> None: entries = {_suffix(r["address"], prefix): ex.export_key(r["address"]) for r in by_kind[kind]} doc: dict = {key: entries} if kind == "effect": - doc["instances"] = effect_instances(episode["commands"]) + doc["instances"] = adv.effect_instances(episode["commands"]) done(ex, name, doc) files[key] = name counts[key] = len(entries) diff --git a/src/nnnotes/content/story.py b/src/nnnotes/content/story.py index 6404df9..850be88 100644 --- a/src/nnnotes/content/story.py +++ b/src/nnnotes/content/story.py @@ -18,11 +18,10 @@ APK (crilips.py), when a voice reaches the CRI Lips path (needs_cri_lips) /story.json index of the above (a kind file the episode does not use is null) -The Live2D models the episode uses come from a model source (webmodel: ModelDir, SiteModels), `ensure(address) -> -{"id", "motionSync"}`: story.json `models` maps each model's address to its id, plus the source's `story_fields` -(ModelDir: `modelsDir`, the directory that holds /). Every resource kind of the closure has an exporter -(KIND_OUTPUT); the build stops before writing anything when a resource is missing from the catalog or of a kind -without one. +The Live2D models the episode uses come from a model source (webmodel: SiteModels), `ensure(address) -> +{"id", "motionSync"}`: story.json `models` maps each model's address to its id, plus the fields of the source's +`story_fields` (SiteModels adds none). Every resource kind of the closure has an exporter (KIND_OUTPUT); the build +stops before writing anything when a resource is missing from the catalog or of a kind without one. """ from __future__ import annotations diff --git a/src/nnnotes/content/storychat.py b/src/nnnotes/content/storychat.py new file mode 100644 index 0000000..205e70d --- /dev/null +++ b/src/nnnotes/content/storychat.py @@ -0,0 +1,34 @@ +"""The chat product of a story: the rows of the shared chat shards every chat of a story reads (text, sounds and cue +sheets of chat messages). chat.json carries the rows, so the product keeps no objects. +""" + +from __future__ import annotations + +from .adv import parse_shard +from .prefabs import Objects +from .storyepisode import shard_text + +__all__ = ["CHAT_FORMAT", "SHARDS", "chat", "keys"] + +CHAT_FORMAT = 1 # chat.json "format" +SHARDS = { + "text": "Adv/Chat/AdvChat-Text", + "sounds": "Adv/Chat/AdvChat-Sound", + "cueSheets": "Adv/Chat/AdvChat-SoundCueSheet", +} + + +def keys() -> list[str]: + """The catalog keys of the chat shards, sorted.""" + return sorted(SHARDS.values()) + + +def chat(objects: Objects) -> tuple[dict, set[str], dict[str, set[str]]]: + """(chat.json, the object ids the player reads, {object id: the roles of its files it reads}) of the chat product. + Each shard's rows are [] when its key has no TextAsset; the product keeps no objects, so the set and the roles + are empty.""" + doc: dict = {"format": CHAT_FORMAT} + for name, key in SHARDS.items(): + text = shard_text(objects, key) + doc[name] = parse_shard(text)["rows"] if text else [] + return doc, set(), {} diff --git a/src/nnnotes/content/storyepisode.py b/src/nnnotes/content/storyepisode.py index 88f312c..efdf28a 100644 --- a/src/nnnotes/content/storyepisode.py +++ b/src/nnnotes/content/storyepisode.py @@ -3,23 +3,41 @@ The episode logic is `adv.build`. This module reads its inputs from a product: the episode's command list and shard TextAssets from the object table (`Objects`), and the MasterAdv, MasterText and MasterAdvChat rows from master tables. Every address is taken as present, since the product names the addresses it is asked for. + +`episode_json` gives episode.json; `story` gives story.json, which names the chats, the effect instances and the +products the story plays with (`parts`). """ from __future__ import annotations +from . import adv from .adv import LANGS, PLAYER_SETTINGS_KEY, SHARD_SUFFIXES, AdvExtract, build from .prefabs import Objects, has_json TABLES = ("MasterAdv", "MasterText", "MasterAdvChat") - - -def _base(asset: str) -> str: +STORY_FORMAT = 1 # story.json "format" +ASSET_KINDS = ( + "chaticon", + "chatstamp", + "chatwindow", + "effect", + "frame", + "posteffect", + "stage", + "still", + "timeline", + "transition", +) + + +def episode_key(asset: str) -> str: + """The catalog key of the episode whose `_advEpisodeAsset` is `asset`.""" return f"Adv/Episode/{asset}/{asset}" def keys(asset: str) -> list[str]: """The catalog keys of an episode: its key, the four shard keys and the AdvPlayerSettings key, sorted.""" - base = _base(asset) + base = episode_key(asset) return sorted([base, *(f"{base}{suf}" for suf in SHARD_SUFFIXES), PLAYER_SETTINGS_KEY]) @@ -57,7 +75,7 @@ def _collection(objects: Objects, key: str) -> list[dict]: return objects.json(_single(key, "MonoBehaviours with a Collection", hits))["Collection"] -def _shard_text(objects: Objects, key: str) -> str | None: +def shard_text(objects: Objects, key: str) -> str | None: """The text of the TextAsset of `key` (its `data` file, UTF-8); None when the key has no TextAsset.""" hits = [o for o in objects.key(key) if o in objects and objects.info(o).get("class") == "TextAsset"] if not hits: @@ -93,9 +111,9 @@ def _always(address: str) -> bool: def episode(objects: Objects, record: dict) -> AdvExtract: """The episode of `record` (episode_record) from the object table: pass the result to `adv.to_json`.""" row = record["MasterAdv"][0] - base = _base(row["_advEpisodeAsset"]) + base = episode_key(row["_advEpisodeAsset"]) coll = _collection(objects, base) - shards = {suf: _shard_text(objects, f"{base}{suf}") for suf in SHARD_SUFFIXES} + shards = {suf: shard_text(objects, f"{base}{suf}") for suf in SHARD_SUFFIXES} chats = {r["_id"]: r for r in record["MasterAdvChat"]} def chat(chat_id: int) -> dict: @@ -112,3 +130,65 @@ def chat(chat_id: int) -> dict: lambda: _default_transition(objects), _title(row, record["MasterText"]), ) + + +def episode_json(x: AdvExtract) -> dict: + """`adv.to_json(x)` with each resource reduced to its kind and address. The product names the addresses it is + asked for, so a resource carries no presence flag.""" + doc = adv.to_json(x) + doc["resources"] = [{"kind": r["kind"], "address": r["address"]} for r in doc["resources"]] + return doc + + +def lip_synced(episode: dict) -> bool: + """A command of the episode is a voice with a target and voice cues, and neither IgnoreData nor IgnoreLipSync is + set. Such a voice can reach the CRI Lips analysis; whether it does depends on the MotionSync of its models.""" + return any( + c["cmd"] in ("Talk", "Voice") + and c.get("TargetName") + and c.get("VoiceIDs") + and not c.get("IgnoreData") + and not c.get("IgnoreLipSync") + for c in episode["commands"] + ) + + +def chat_ids(episode: dict) -> list[int]: + """The distinct non-zero TargetChatID values of the episode's commands, sorted.""" + return sorted({c["TargetChatID"] for c in episode["commands"] if c.get("TargetChatID")}) + + +def parts(episode: dict) -> dict: + """What the story plays with, from episode.json: the addresses of the ASSET_KINDS resources, the addresses of the + live2d models, the cue sheets of the episode, whether it shows chat and whether a voice reaches the CRI Lips + analysis. Talk windows and videos are products of their own and are not listed.""" + resources = episode["resources"] + return { + "assets": sorted(r["address"] for r in resources if r["kind"] in ASSET_KINDS), + "models": sorted(r["address"] for r in resources if r["kind"] == "live2d"), + "sounds": sorted({cs["_cueSheetName"] for cs in episode["cuesheets"].values() if cs.get("_cueSheetName")}), + "chat": bool(chat_ids(episode)), + "crilips": lip_synced(episode), + } + + +def story(episode: dict, record: dict, paths: dict) -> dict: + """The story.json document: the MasterAdvChat row of each chat id of the episode (KeyError when there is none), the + effect instances (TargetName -> "Adv/Effect/" + TargetAssetName) and `parts`. `paths` are the manifest paths of the + products the story plays with, given by the caller: {"scene", "assets": {address: path}, "models": {address: path}, + "sounds": {cue sheet: path}, "chat", "crilips"}.""" + chats = {} + for chat_id in chat_ids(episode): + hits = [r for r in record["MasterAdvChat"] if r["_id"] == chat_id] + if not hits: + raise KeyError(f"MasterAdvChat has no row {chat_id}") + chats[str(chat_id)] = hits[0] + prefix = adv.RESOURCE_PREFIX["Effect"][1] + return { + "format": STORY_FORMAT, + "advId": episode["advId"], + "episode": "episode.json", + "chats": chats, + "effects": {name: prefix + asset for name, asset in adv.effect_instances(episode["commands"]).items()}, + "parts": paths, + } diff --git a/src/nnnotes/pipeline/assemble.py b/src/nnnotes/pipeline/assemble.py index ba8dd74..89f3fac 100644 --- a/src/nnnotes/pipeline/assemble.py +++ b/src/nnnotes/pipeline/assemble.py @@ -11,14 +11,13 @@ referenced object that no census holds is listed as missing. A task's inputs are the object table of what it reaches ("objects", nnnotes.objects/1: every object with its class, -name, item status and files, the contained ones with the object holding them, and the root objects of each of its -keys), the APK files the stage names in `APK` -("apk:", from the fact "apk"), the cue sheets of the catalog keys `cue_sheets(subject)` gives and -what `more_inputs` adds (master tables, parameters' data); it depends on the unity.export and sprite.crop tasks it -read and the cri.audio tasks of its cue sheets. A cue sheet is the ACB the key loads: held in a bundle (the `acb` -file of an object of the key) or a raw file of the catalog (a dependency of the key that cri.audio decoded); its -inputs are the ACB ("acb:") and, in "sheets", the files cri.audio made of it ({key: {acb, status, reason?, -files}}). +name, item status and files, the contained ones with the object holding them, and the root objects of each of its keys), +the APK files the stage names in `APK` ("apk:", from the fact "apk"), the cue sheets of the catalog +keys `cue_sheets(subject)` gives and what `more_inputs` adds (a master record as "master": master_input; parameters' +data); it depends on the unity.export and sprite.crop tasks it read and the cri.audio tasks of its cue sheets. A cue +sheet is the ACB the key loads: held in a bundle (the `acb` file of an object of the key) or a raw file of the catalog +(a dependency of the key that cri.audio decoded); its inputs are the ACB ("acb:") and, in "sheets", the files +cri.audio made of it ({key: {acb, status, reason?, files}}). `assemble` makes the product's own files from the table, the bytes of its objects and its cue sheets (Sheet); a file may be content the depot already holds (Stored: a decoded waveform), recorded without reading it again. The result @@ -43,6 +42,13 @@ ADDRESSES = units.ADDRESSES TABLE_KIND = "product.objects" SHEETS_INPUT = "sheets" +MASTER_INPUT = "master" + + +def master_input(env, record: dict) -> Input: + """The input "master" of a product task: the master record `record` (the rows it reads), stored in the depot.""" + body = contract.encode(record) + return Input(MASTER_INPUT, env.depot.put(body), len(body), None, ({"kind": "depot"},)) def request(stages: dict, products: dict, env) -> dict: diff --git a/src/nnnotes/pipeline/live.py b/src/nnnotes/pipeline/live.py index 91ba179..b5c329d 100644 --- a/src/nnnotes/pipeline/live.py +++ b/src/nnnotes/pipeline/live.py @@ -56,7 +56,7 @@ 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 +from .assemble import MASTER_INPUT, Assembly, ProductStage, master_input from .contract import IncompatibleTask, Input from .views import master_rows @@ -83,7 +83,6 @@ SCENE = "live.scene" LIVE = "live" DEFAULT = "default" -MASTER_INPUT = "master" def _ffmpeg() -> str: @@ -107,11 +106,6 @@ def _music_id(subject: str) -> int: return int(subject) -def _record(env, data: dict) -> Input: - body = contract.encode(data) - return Input(MASTER_INPUT, env.depot.put(body), len(body), None, ({"kind": "depot"},)) - - class MusicStage(ProductStage): """live.music: a music id -> music.json and its BGM layers (module documentation).""" @@ -149,7 +143,7 @@ def cue_sheets(self, subject: str, env) -> list[str]: return self.keys(subject, env) def more_inputs(self, subject: str, env) -> list[Input]: - return [_record(env, liveaudio.music_record(master_rows(env, self.MASTER), _music_id(subject)))] + return [master_input(env, liveaudio.music_record(master_rows(env, self.MASTER), _music_id(subject)))] def manifest(self, subject: str) -> str: return f"music/{_music_id(subject)}.json" @@ -209,7 +203,7 @@ def builtins(self, subject: str, env) -> list[str]: return [f"MonoBehaviour:{c}" for c in liveaudio.SETTINGS_SCRIPTS] def more_inputs(self, subject: str, env) -> list[Input]: - return [_record(env, liveaudio.sounds_record(master_rows(env, self.MASTER), subject))] + return [master_input(env, liveaudio.sounds_record(master_rows(env, self.MASTER), subject))] def manifest(self, subject: str) -> str: liveaudio.sounds_groups(subject) @@ -263,7 +257,7 @@ def keys(self, subject: str, env) -> list[str]: return livenotes.notes_keys() def more_inputs(self, subject: str, env) -> list[Input]: - return [_record(env, livenotes.notes_record(master_rows(env, self.MASTER)))] + return [master_input(env, livenotes.notes_record(master_rows(env, self.MASTER)))] def manifest(self, subject: str) -> str: return f"notes/{_default(subject)}.json" @@ -340,7 +334,7 @@ def builtins(self, subject: str, env) -> list[str]: return list(livescene.GRAPHICS.values()) def more_inputs(self, subject: str, env) -> list[Input]: - return [_record(env, livescene.scene_record(master_rows(env, self.MASTER)))] + return [master_input(env, livescene.scene_record(master_rows(env, self.MASTER)))] def manifest(self, subject: str) -> str: return f"scenes/{_default(subject)}.json" @@ -415,7 +409,7 @@ def parts(self, subject: str, env) -> dict[str, list[str]]: 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})] + return [master_input(env, {"live": self.live_record(subject, env), "parts": paths})] def manifest(self, subject: str) -> str: music_id, difficulty, band = live.parse_subject(subject) diff --git a/src/nnnotes/pipeline/spot.py b/src/nnnotes/pipeline/spot.py index 205c7d9..38832c1 100644 --- a/src/nnnotes/pipeline/spot.py +++ b/src/nnnotes/pipeline/spot.py @@ -18,14 +18,13 @@ from ..content import room, spot from . import contract -from .assemble import Assembly, ProductStage +from .assemble import MASTER_INPUT, Assembly, ProductStage, master_input from .contract import Input from .views import master_rows __all__ = ["ROOM", "SPOT", "RoomStage", "SpotStage", "spine_files"] ROOM, SPOT = "room.gltf", "spot.files" -MASTER_INPUT = "master" def spine_files(doc: dict, spine: dict[str, bytes]) -> dict: @@ -93,8 +92,7 @@ def keys(self, subject: str, env) -> list[str]: return [subject] def more_inputs(self, subject: str, env) -> list[Input]: - data = contract.encode(spot.master_record(master_rows(env, self.MASTER), subject)) - return [Input(MASTER_INPUT, env.depot.put(data), len(data), None, ({"kind": "depot"},))] + return [master_input(env, spot.master_record(master_rows(env, self.MASTER), subject))] def manifest(self, subject: str) -> str: return f"spots/{spot.situation_name(subject)}.json" diff --git a/src/nnnotes/pipeline/story.py b/src/nnnotes/pipeline/story.py index 3c19810..fda88a6 100644 --- a/src/nnnotes/pipeline/story.py +++ b/src/nnnotes/pipeline/story.py @@ -1,6 +1,7 @@ -"""The story products: the cue sheets a story plays (story.sound) and the CRI Lips analysis data (story.crilips). +"""The story products: an ADV episode (story) and the products it plays with. -Both are product stages (pipeline.assemble) that keep no objects (their files are all they hold): +All are product stages (pipeline.assemble); story.sound, story.crilips, story.chat and story keep no objects (their +files are all they hold): story.sound subject a cue sheet name (the `_cueSheetName` of a story's cue sheet table): "#cues.json" (kind story.sound: cue name -> the cue's first stream: `file`, `sampleRate`, `channels`, `samples`, @@ -21,14 +22,26 @@ global volume, the ADV player and master id settings, and as roots also the built-in graphics settings (livescene.GRAPHICS). Its manifest is storyscenes/default.json. -story.asset subject a catalog key a story loads (a stage, frame, effect, post effect, still, chat window, chat icon - or stamp, transition): "#asset.json" (kind story.asset: content.storyasset.asset) and the - object table of what the key reaches. Its manifest is storyassets/.json, the key with +story.asset subject a catalog key a story loads (a stage, frame, effect, post effect, still, timeline, chat window, + chat icon or stamp, transition): "#asset.json" (kind story.asset: content.storyasset.asset) and + the object table of what the key reaches. Its manifest is storyassets/.json, the key with "/" as "~" (storyasset.manifest_name). An asset several stories load is one product. -`select` (the `product` layout) makes the story.sound product of every cue sheet key the selection holds and -story.scene when it holds every key of the scene; the CRI Lips data and the story assets are made only when a -request names them. +story.chat subject `default`: "#chat.json" (kind story.chat: content.storychat.chat, the rows of the + shared chat shards). Its manifest is storychat/default.json. + +story subject a MasterAdv id: "#episode.json" (kind story.episode: content.storyepisode.episode_json, + the command list with its text, sounds, videos and resource closure) and "#story.json" (kind + story: content.storyepisode.story, the episode's chat rows, its effect instances and `parts`, the + manifest paths of the products above it plays with: part_paths). Its roots are the episode key, its + shard keys and the ADV player settings; its input "master" the episode's master record + (storyepisode.episode_record). Its manifest is stories/.json. The parts depend on the episode's + content, so a command asks for the story first and then for what its story.json names + (part_products). + +`select` (the `product` layout) makes the story.sound product of every cue sheet key the selection holds, story.scene +and story.chat when it holds every key of theirs, and the story of every episode whose key it holds; the CRI Lips data +and the story assets are made only when a request names them. """ from __future__ import annotations @@ -36,20 +49,39 @@ import tempfile from pathlib import Path -from ..content import liveaudio, storyasset, storyscene +from ..content import liveaudio, storyasset, storychat, storyepisode, storyscene from ..content.livescene import GRAPHICS from ..formats import crilips from ..formats.encoders import DEFAULT_AUDIO_FORMAT, WEB_AUDIO, mp4_priming, web_encode from ..unity.atoms import Unsupported from . import contract -from .assemble import Assembly, ProductStage +from .assemble import MASTER_INPUT, Assembly, ProductStage, master_input from .contract import IncompatibleTask from .layout import escape_segment from .live import encode_atoms - -__all__ = ["ASSET", "CRILIPS", "SCENE", "SOUND", "AssetStage", "CriLipsStage", "SceneStage", "SoundStage", "cues"] +from .live2d import MODEL, ModelStage +from .views import master_rows + +__all__ = [ + "ASSET", + "CHAT", + "CRILIPS", + "PART_STAGES", + "SCENE", + "SOUND", + "STORY", + "AssetStage", + "ChatStage", + "CriLipsStage", + "SceneStage", + "SoundStage", + "StoryStage", + "cues", + "part_products", +] SOUND, CRILIPS, SCENE, ASSET = "story.sound", "story.crilips", "story.scene", "story.asset" +CHAT, STORY = "story.chat", "story" DEFAULT = "default" SHEET_PREFIX = "Cri/Sound/" LIBRARY = crilips.LIB_ENTRY_SUFFIX @@ -211,3 +243,107 @@ def manifest(self, subject: str) -> str: def assemble(self, task, objects) -> Assembly: doc, keep, roles = storyasset.asset(objects, task.subject) return Assembly({"asset.json": (contract.encode(doc), "json", ASSET)}, keep, roles) + + +class ChatStage(ProductStage): + """story.chat: the rows of the shared chat shards (module documentation).""" + + name = CHAT + version = 1 + KIND = "StoryChat" + + def select(self, keys: list[str], env) -> list[str]: + return [DEFAULT] if set(storychat.keys()) <= set(keys) else [] + + def keys(self, subject: str, env) -> list[str]: + _default(subject) + return storychat.keys() + + def manifest(self, subject: str) -> str: + return f"storychat/{_default(subject)}.json" + + def assemble(self, task, objects) -> Assembly: + doc, keep, roles = storychat.chat(objects) + return Assembly({"chat.json": (contract.encode(doc), "json", CHAT)}, keep, roles) + + +def _adv_id(subject: str) -> int: + if not subject.isdigit(): + raise ValueError(f"{subject}: not an episode id") + return int(subject) + + +def part_paths(parts: dict) -> dict: + """story.json `parts`: the manifest paths of the products of storyepisode.parts `parts`.""" + return { + "scene": SceneStage().manifest(DEFAULT), + "assets": {a: AssetStage().manifest(a) for a in parts["assets"]}, + "models": {a: ModelStage().manifest(a) for a in parts["models"]}, + "sounds": {s: SoundStage().manifest(s) for s in parts["sounds"]}, + "chat": ChatStage().manifest(DEFAULT) if parts["chat"] else None, + "crilips": CriLipsStage().manifest(DEFAULT) if parts["crilips"] else None, + } + + +def part_products(story: dict) -> dict[str, list[str]]: + """{product stage: [subject]} of the products story.json `story` names in `parts`.""" + parts = story["parts"] + out = { + SCENE: [DEFAULT], + ASSET: sorted(parts["assets"]), + MODEL: sorted(parts["models"]), + SOUND: sorted(parts["sounds"]), + CHAT: [DEFAULT] if parts["chat"] else [], + CRILIPS: [DEFAULT] if parts["crilips"] else [], + } + return {name: subjects for name, subjects in out.items() if subjects} + + +# the products a story plays with: {product stage: its class} +PART_STAGES = { + SCENE: SceneStage, + ASSET: AssetStage, + MODEL: ModelStage, + SOUND: SoundStage, + CHAT: ChatStage, + CRILIPS: CriLipsStage, +} + + +class StoryStage(ProductStage): + """story: an ADV episode -> episode.json and story.json (module documentation).""" + + name = STORY + version = 1 + KIND = "Story" + MASTER = storyepisode.TABLES + + def select(self, keys: list[str], env) -> list[str]: + if env.facts.get("master") is None: + return [] + held = set(keys) + rows = master_rows(env, ("MasterAdv",))["MasterAdv"] + return sorted(str(r["_id"]) for r in rows if storyepisode.episode_key(r["_advEpisodeAsset"]) in held) + + def record(self, subject: str, env) -> dict: + """The episode's master record (storyepisode.episode_record).""" + return storyepisode.episode_record(master_rows(env, self.MASTER), _adv_id(subject)) + + def keys(self, subject: str, env) -> list[str]: + return storyepisode.keys(self.record(subject, env)["MasterAdv"][0]["_advEpisodeAsset"]) + + def more_inputs(self, subject: str, env) -> list: + return [master_input(env, self.record(subject, env))] + + def manifest(self, subject: str) -> str: + return f"stories/{_adv_id(subject)}.json" + + def assemble(self, task, objects) -> Assembly: + record = contract.loads(objects.depot.input_bytes(task.input(MASTER_INPUT))) + episode = storyepisode.episode_json(storyepisode.episode(objects, record)) + doc = storyepisode.story(episode, record, part_paths(storyepisode.parts(episode))) + files = { + "episode.json": (contract.encode(episode), "json", "story.episode"), + "story.json": (contract.encode(doc), "json", STORY), + } + return Assembly(files, set()) diff --git a/src/nnnotes/site/webmodel.py b/src/nnnotes/site/webmodel.py index 8cb37a8..92222d9 100644 --- a/src/nnnotes/site/webmodel.py +++ b/src/nnnotes/site/webmodel.py @@ -36,17 +36,15 @@ `label`; the manifests of skipped models get the same fields of this build. Without master data the fields are not written (and skipped manifests keep theirs). -Model sources: story.build takes the models of an episode from a source, `ensure(address) -> {"id", "motionSync"}` -plus `story_fields(story_dir)` (what story.json gets besides `models`). ModelDir exports each model into // -(the files above; `nnnotes export story`), SiteModels reads model.json from the model manifests of a site (the site's -stories). +Model source: story.build takes the models of an episode from a source, `ensure(address) -> {"id", "motionSync"}` +plus `story_fields(story_dir)` (what story.json gets besides `models`). SiteModels reads model.json from the model +manifests of a site (the site's stories). """ from __future__ import annotations import json import multiprocessing as mp -import os import shutil import tempfile import time @@ -335,84 +333,6 @@ def source_entry(address: str, doc: dict, where: str) -> dict: return {"id": model_id(address), "motionSync": doc["motionSync"]} -class ModelDir: - """The models of `root`: each model in //, the files of a model manifest (read_files; the shader index - reduced to the listed GLES3 programs as sitefiles.collect reduces it) in the layout export_model writes. ensure - exports a model whose directory does not exist, or every model once when `force`; `built` / `skipped`: the ids (a - model another process installed meanwhile is skipped). - - Processes may share `root` (several `nnnotes export story` at once): a model directory appears in one rename, - whole, and the first process to install a model wins; the others drop their export (the same bytes: exports are - deterministic) and use it. `force` moves a previous directory aside before installing; a process that finds the - directory gone in between exports the model itself.""" - - def __init__(self, cat, player, root: Path, force: bool = False): - self.cat, self.player, self.root, self.force = cat, player, Path(root), force - self.built: list[str] = [] - self.skipped: list[str] = [] - self._done: dict[str, dict] = {} - - def ensure(self, address: str) -> dict: - mid = model_id(address) - if mid not in self._done: - d = self.root / mid - index = d / MODEL_INDEX - built = (self.force or not d.exists()) and self._export(address, d) - if not index.is_file() and not d.exists(): # moved aside by a forced export of another process - built = self._export(address, d) - (self.built if built else self.skipped).append(mid) - try: - doc = json.loads(index.read_text(encoding="utf-8")) - except FileNotFoundError: - raise RuntimeError( - f"{d}: no {MODEL_INDEX} (not a model directory); remove it or export again (--force)" - ) from None - self._done[mid] = source_entry(address, doc, str(index)) - return self._done[mid] - - def _export(self, address: str, d: Path) -> bool: - """The model into a temporary directory in `root`, then renamed to `d`; False when another process's `d` is - there instead (kept: without `force` any `d`, with `force` one installed after the previous `d` was moved - aside).""" - self.root.mkdir(parents=True, exist_ok=True) - work = Path(tempfile.mkdtemp(prefix=f".{d.name}-", dir=self.root)) - try: - summary = export_model(self.cat, self.player, address, work / "export") - text, binary = collect(work / "export", summary["files"]) - files = {**{p: t.encode("utf-8") for p, t in text.items()}, **binary} - model = work / "model" - for rel, data in sorted(files.items()): - (model / rel).parent.mkdir(parents=True, exist_ok=True) - (model / rel).write_bytes(data) - if _install(model, d): - return True - if not self.force: - return False - try: - d.rename(work / "previous") - except FileNotFoundError: # moved aside by another process - pass - return _install(model, d) - finally: - shutil.rmtree(work, ignore_errors=True) - - def story_fields(self, story_dir: Path) -> dict: - """`modelsDir`: the relative path from the story directory to `root` (posix).""" - return {"modelsDir": Path(os.path.relpath(os.path.abspath(self.root), os.path.abspath(story_dir))).as_posix()} - - -def _install(src: Path, d: Path) -> bool: - """Rename the directory `src` to `d`; False when `d` exists (a non-empty directory: POSIX refuses to replace it, - Windows any directory).""" - try: - src.rename(d) - return True - except OSError: - if not d.exists(): - raise - return False - - class SiteModels: """The models of the site at `site`: ensure reads model.json from the model manifest models/.json (the models are built before the stories: storysite.build).""" diff --git a/src/nnnotes/unity/reader.py b/src/nnnotes/unity/reader.py index eef4285..962e10c 100644 --- a/src/nnnotes/unity/reader.py +++ b/src/nnnotes/unity/reader.py @@ -5,7 +5,6 @@ import hashlib import io -import json import threading from collections import OrderedDict from pathlib import Path @@ -176,20 +175,6 @@ def textassets(env) -> dict[str, str]: return out -def parse_shard(text: str) -> dict: - """A `-Text/-Sound/...` shard: {_header:[{_name,_type}], _allData:[rows]}. - - Returns {"header": [...], "rows": [...]}. Row keys keep their leading - underscore + lowercased-first-letter form as serialized (e.g. `_cueName`). - """ - d = json.loads(text) - return {"header": d.get("_header", []), "rows": d.get("_allData", [])} - - -def shard_index(rows: list[dict], id_key: str = "_id") -> dict: - return {r[id_key]: r for r in rows if id_key in r} - - # ---- meshes ------------------------------------------------------------- def _channel(values, count: int, keep: int, what: str) -> np.ndarray: """A vertex channel as a (count, keep) float64 array: each vertex's first `keep` components, whatever the diff --git a/tests/cli/test_assets.py b/tests/cli/test_assets.py index 23e64c3..757f2eb 100644 --- a/tests/cli/test_assets.py +++ b/tests/cli/test_assets.py @@ -1569,3 +1569,51 @@ def test_export_live2d_writes_the_cubism_files_of_one_model(tmp_path, capsys, mo run = contract.loads((tmp_path / "m" / assets.REPORTS / "run.json").read_bytes()) ids = {t["id"] for t in run["tasks"]} assert f"live2d.cubism:{MODEL_KEY}" in ids and "fake.product:Bg/x" not in ids + + +def test_export_runs_the_request_expand_gives_and_places_the_last_run(tmp_path, capsys, monkeypatch): + from urllib.parse import quote + + monkeypatch.setattr(assets, "STAGE_SOURCES", PRODUCT_SOURCES) + monkeypatch.setattr("nnnotes.pipeline.catalogdb.apk_version", lambda apk: None) + d = setup_data(tmp_path, censuses(broken=False), extra=[(MODEL_KEY, "Assets/Game/Bg/x.prefab", [3])]) + apk = builtin_apk(tmp_path / "base.apk", {"assets/aa/catalog.bin": synth.CatalogWriter().build([])}) + run_export = assets.export + calls = [] + + def hook(run): # called once, with the first run (the command's own request: the product layout) + calls.append(set(run.tasks)) + if len(calls) > 1: + pytest.fail("expand was called again after its second answer") + assert "fake.product:Bg/x" in calls[0] + return {"live2d.cubism": [MODEL_KEY]}, [f"key:{MODEL_KEY}"] + + def hooked(args, cfg, ws, expand=None): # the command's export takes the hook; its own re-run takes none + monkeypatch.setattr(assets, "export", run_export) + return run_export(args, cfg, ws, expand=hook) + + monkeypatch.setattr(assets, "export", hooked) + argv = [ + *flags(d), + "--apk", + apk, + "export", + "assets", + "-o", + tmp_path / "out", + "--depot", + tmp_path / "s", + "--layout", + "product", + "--workers", + "0", + ] + code, out, err = nn(capsys, *argv) + assert code == 0, err + assert len(calls) == 1 + run = contract.loads((tmp_path / "out" / assets.REPORTS / "run.json").read_bytes()) + ids = {t["id"] for t in run["tasks"]} + assert f"live2d.cubism:{MODEL_KEY}" in ids and "fake.product:Bg/x" not in ids + manifest = contract.loads((tmp_path / "out" / "live2d.cubism" / f"{quote(MODEL_KEY, safe='')}.json").read_bytes()) + assert manifest["product"] == "live2d.cubism" and manifest["subject"] == MODEL_KEY + assert not (tmp_path / "out" / "fake.product").exists() # the first run's product is not placed diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 2db7692..cce082e 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -426,12 +426,6 @@ def test_missing_master_table_names_it(data, tmp_path, capsys): assert code == 2 and "no MasterHomeSpot.json" in err and len(err.strip().splitlines()) == 1 -def test_story_needs_the_language(data, capsys): - i = data.index("--language") - code, _, err = run(data[:i] + data[i + 2 :] + ["export", "story", "1", "-o", "s"], capsys) - assert code == 2 and "catalog.language" in err and "NNNOTES_CATALOG_LANGUAGE" in err - - def test_the_command_line_starts_without_the_exporters(): probe = ( "import sys, nnnotes.cli as c; c.build_parser(); " @@ -447,7 +441,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", "story", "1", "--no-audio", "--flac-level", "5"], "--flac-level: --no-audio decodes no audio"), ], ) def test_flac_level_is_checked(data, argv, message, capsys): @@ -459,18 +452,13 @@ 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 story from nnnotes.formats import cri from nnnotes.source.session import Session - from nnnotes.unity import player seen = [] monkeypatch.setattr(Session, "catalog", lambda self: SimpleNamespace(has=lambda key: True)) - monkeypatch.setattr(Session, "apk", lambda self, region=None: None) - 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 {}) - for argv in (["export", "audio", "s"], ["export", "story", "1"]): + for argv in (["export", "audio", "s"],): for flag, level in (([], None), (["--flac-level", "5"], 5)): seen.clear() code, _, err = run(data + argv + flag + ["-o", str(tmp_path / "o")], capsys) diff --git a/tests/content/test_story_kinds.py b/tests/content/test_story_kinds.py index ff0a568..660e1cd 100644 --- a/tests/content/test_story_kinds.py +++ b/tests/content/test_story_kinds.py @@ -55,7 +55,7 @@ def test_effect_instances_first_asset_per_target_name(): {"cmd": "Effect", "TargetAssetName": "fx_d/fx_d"}, {"cmd": "Still", "TargetName": "x", "TargetAssetName": "s"}, ] - assert advmedia.effect_instances(cmds) == {"spot": "fx_a/fx_a"} + assert adv.effect_instances(cmds) == {"spot": "fx_a/fx_a"} class _Type: @@ -87,10 +87,11 @@ def test_missing_script_component_is_recorded(tmp_path): } -def test_story_no_audio_flag(): - args = cli.parse_args(["export", "story", "10001", "-o", "out", "--no-audio"]) - assert args.no_audio is True and args.format == "flac" - assert cli.parse_args(["export", "story", "10001", "-o", "out"]).no_audio is False +def test_story_takes_the_web_audio_format_of_its_cue_sheets(): + from nnnotes.formats.encoders import DEFAULT_AUDIO_FORMAT + + assert cli.parse_args(["export", "story", "10001", "-o", "out"]).audio_format == DEFAULT_AUDIO_FORMAT + assert cli.parse_args(["export", "story", "10001", "-o", "out", "--format", "flac"]).audio_format == "flac" class _Parsed: diff --git a/tests/content/test_storychat.py b/tests/content/test_storychat.py new file mode 100644 index 0000000..c2836ea --- /dev/null +++ b/tests/content/test_storychat.py @@ -0,0 +1,63 @@ +"""storychat: the chat document from synthetic shard TextAssets.""" + +import json + +from nnnotes.content import storychat + +ROWS = { + "text": [{"_id": 1, "_japanese": "こんにちは", "_english": "Hello"}], + "sounds": [{"_id": 2, "_cueName": "v_001"}], + "cueSheets": [{"_id": 3, "_name": "AdvSe_Common"}], +} + + +class FakeObjects: + """An object table: {oid: (info, {role: value})} with catalog keys {key: [oid]}.""" + + def __init__(self, objs: dict, keys: dict): + self._objs = objs + self._keys = keys + + def __contains__(self, oid): + return oid in self._objs + + def key(self, key): + return list(self._keys.get(key, [])) + + def info(self, oid): + return self._objs[oid][0] + + def bytes(self, oid, role): + return self._objs[oid][1][role] + + +def shard(rows: list[dict]) -> bytes: + return json.dumps({"_header": [], "_allData": rows}).encode("utf-8") + + +def table(shards: tuple[str, ...]) -> FakeObjects: + """A table holding one TextAsset per named shard of ROWS, keyed by its catalog key.""" + objs: dict = {} + keys: dict = {} + for name in shards: + oid = f"shard_{name}" + objs[oid] = ({"class": "TextAsset", "files": {"data": {}}}, {"data": shard(ROWS[name])}) + keys[storychat.SHARDS[name]] = [oid] + return FakeObjects(objs, keys) + + +def test_keys_are_the_sorted_shard_keys(): + assert storychat.keys() == sorted(storychat.SHARDS.values()) + assert len(storychat.keys()) == 3 + + +def test_chat_document_holds_the_rows_of_all_three_shards(): + doc, keep, roles = storychat.chat(table(("text", "sounds", "cueSheets"))) + assert doc == {"format": storychat.CHAT_FORMAT, **ROWS} + assert keep == set() + assert roles == {} + + +def test_a_missing_shard_gives_no_rows(): + doc, _, _ = storychat.chat(table(("text", "cueSheets"))) + assert doc == {"format": storychat.CHAT_FORMAT, "text": ROWS["text"], "sounds": [], "cueSheets": ROWS["cueSheets"]} diff --git a/tests/content/test_storyepisode.py b/tests/content/test_storyepisode.py index 94b10cc..8d967c2 100644 --- a/tests/content/test_storyepisode.py +++ b/tests/content/test_storyepisode.py @@ -1,14 +1,9 @@ """storyepisode: episode documents from a synthetic object table and master rows.""" -import json - import pytest from nnnotes.content import adv, storyepisode - -ASSET = "ep_test" -BASE = f"Adv/Episode/{ASSET}/{ASSET}" -ALL_LANGS = ("_japanese", "_english", "_traditionalChinese", "_simplifiedChinese", "_korean") +from tests.helpers.story import ALL_LANGS, ASSET, BASE, COLLECTION, master_tables, shard class FakeObjects: @@ -42,20 +37,9 @@ def put(objs: dict, oid: str, cls: str, **files) -> None: objs[oid] = ({"class": cls, "files": {role: {} for role in files}}, files) -def shard(rows: list[dict]) -> bytes: - return json.dumps({"_header": [], "_allData": rows}).encode("utf-8") - - def table() -> FakeObjects: objs: dict = {} - collection = [ - {"Index": 0, "Command": 2, "AdvTextID": 101, "VoiceIDs": [201, 202], "Speaker": 3, "Volume": 0.0, "Note": ""}, - {"Index": 1, "Command": 25, "TargetAssetName": "st_a/st_a"}, - {"Index": 2, "Command": 26, "VideoID": 41}, - {"Index": 3, "Command": 36, "TargetChatID": 7}, - {"Index": 4, "Command": 6, "Duration": 1.0}, - ] - put(objs, "episode", "MonoBehaviour", json={"$script": "App|AdvEpisodeCollection", "Collection": collection}) + put(objs, "episode", "MonoBehaviour", json={"$script": "App|AdvEpisodeCollection", "Collection": list(COLLECTION)}) put( objs, "text", @@ -85,21 +69,6 @@ def table() -> FakeObjects: return FakeObjects(objs, keys) -def master_tables() -> dict: - title = {"_id": "9001", **{lang: f"title{lang}" for lang in ALL_LANGS}} - return { - "MasterAdv": [ - {"_id": 1, "_advEpisodeAsset": ASSET, "_titleTextId": "9001"}, - {"_id": 2, "_advEpisodeAsset": "other", "_titleTextId": ""}, - ], - "MasterText": [title, {"_id": "9002", **{lang: "x" for lang in ALL_LANGS}}], - "MasterAdvChat": [ - {"_id": 9, "_chatWindowAssetName": "win_9", "_chatIconAssetName": ""}, - {"_id": 7, "_chatWindowAssetName": "win_7", "_chatIconAssetName": "icon_7"}, - ], - } - - def test_keys_are_sorted_and_cover_the_shards_and_player_settings(): assert storyepisode.keys(ASSET) == sorted( [ @@ -201,3 +170,111 @@ def test_episode_record_selects_rows_and_reports_missing_ones(): broken["MasterText"] = [] with pytest.raises(KeyError, match="MasterText"): storyepisode.episode_record(broken, 1) + + +def _episode_doc() -> dict: + record = storyepisode.episode_record(master_tables(), 1) + return storyepisode.episode_json(storyepisode.episode(table(), record)) + + +def test_episode_json_resources_have_no_presence_flag(): + doc = _episode_doc() + assert doc["resources"] == [ + {"kind": "chatwindow", "address": "Adv/Chat/Prefabs/win_7"}, + {"kind": "stage", "address": "Adv/Stage/st_a/st_a"}, + {"kind": "transition", "address": "Adv/Transition/adv_transition_default/x"}, + {"kind": "video", "address": "Cri/Video/adv/movie_a/movie_a"}, + ] + assert all("present" not in r for r in doc["resources"]) + + +def test_lip_synced_voice_rows(): + def episode(*commands) -> dict: + return {"commands": list(commands)} + + voice = {"cmd": "Talk", "TargetName": "a", "VoiceIDs": [1]} + assert storyepisode.lip_synced(episode(voice)) + assert storyepisode.lip_synced(episode({"cmd": "Voice", "TargetName": "a", "VoiceIDs": [1]})) + assert storyepisode.lip_synced(episode({**voice, "Parameter1": "EveryoneLipSync", "Speaker": 3})) + assert not storyepisode.lip_synced(episode({**voice, "IgnoreLipSync": True})) + assert not storyepisode.lip_synced(episode({**voice, "IgnoreData": True})) + assert not storyepisode.lip_synced(episode({"cmd": "Talk", "TargetName": "a"})) + assert not storyepisode.lip_synced(episode({"cmd": "Talk", "VoiceIDs": [1]})) + assert not storyepisode.lip_synced(episode({"cmd": "Stage", "TargetName": "a", "VoiceIDs": [1]})) + assert not storyepisode.lip_synced(episode()) + + +def test_chat_ids_are_sorted_distinct_and_non_zero(): + commands = [{"TargetChatID": 9}, {"TargetChatID": 0}, {}, {"TargetChatID": 7}, {"TargetChatID": 9}] + assert storyepisode.chat_ids({"commands": commands}) == [7, 9] + assert storyepisode.chat_ids({"commands": []}) == [] + + +def test_parts_lists_the_products_the_story_plays_with(): + episode = { + "advId": 1, + "commands": [ + {"cmd": "Talk", "TargetName": "a", "VoiceIDs": [1], "TargetChatID": 7}, + {"cmd": "Stage", "TargetAssetName": "s"}, + ], + "resources": [ + {"kind": "live2d", "address": "Character/Live2D/b"}, + {"kind": "live2d", "address": "Character/Live2D/a"}, + {"kind": "stage", "address": "Adv/Stage/s"}, + {"kind": "transition", "address": "Adv/Transition/t"}, + {"kind": "talkwindow", "address": "UI/Prefab/Parts/Adv/Talk/UIDefaultTalkWindow"}, + {"kind": "video", "address": "Cri/Video/v"}, + ], + "cuesheets": { + "1": {"_cueSheetName": "sheet_b"}, + "2": {"_cueSheetName": "sheet_a"}, + "3": {"_cueSheetName": ""}, + "4": {"_id": 4}, + "5": {"_cueSheetName": "sheet_a"}, + }, + } + assert storyepisode.parts(episode) == { + "assets": ["Adv/Stage/s", "Adv/Transition/t"], + "models": ["Character/Live2D/a", "Character/Live2D/b"], + "sounds": ["sheet_a", "sheet_b"], + "chat": True, + "crilips": True, + } + quiet = {**episode, "commands": [{"cmd": "Stage", "TargetAssetName": "s"}], "cuesheets": {}} + assert storyepisode.parts(quiet) == { + "assets": ["Adv/Stage/s", "Adv/Transition/t"], + "models": ["Character/Live2D/a", "Character/Live2D/b"], + "sounds": [], + "chat": False, + "crilips": False, + } + + +def test_story_document_names_chats_effects_and_parts(): + doc = _episode_doc() + doc["commands"].append({"i": 5, "cmd": "Effect", "TargetName": "spot", "TargetAssetName": "fx_a/fx_a"}) + paths = { + "scene": "scene.json", + "assets": {}, + "models": {}, + "sounds": {}, + "chat": "chat.json", + "crilips": "crilips.json", + } + record = storyepisode.episode_record(master_tables(), 1) + assert storyepisode.story(doc, record, paths) == { + "format": 1, + "advId": 1, + "episode": "episode.json", + "chats": {"7": {"_id": 7, "_chatWindowAssetName": "win_7", "_chatIconAssetName": "icon_7"}}, + "effects": {"spot": "Adv/Effect/fx_a/fx_a"}, + "parts": paths, + } + + +def test_story_document_needs_a_row_for_each_chat_id(): + doc = _episode_doc() + doc["commands"].append({"i": 5, "cmd": "ChatWindow", "TargetChatID": 99}) + record = storyepisode.episode_record(master_tables(), 1) + with pytest.raises(KeyError, match="MasterAdvChat has no row 99"): + storyepisode.story(doc, record, {}) diff --git a/tests/helpers/story.py b/tests/helpers/story.py new file mode 100644 index 0000000..f5f62de --- /dev/null +++ b/tests/helpers/story.py @@ -0,0 +1,104 @@ +"""Synthetic story data: the master tables and the object table of a small ADV episode, and the chat shards.""" + +import json + +from nnnotes.content import storychat, storyepisode +from nnnotes.content.adv import PLAYER_SETTINGS_KEY +from tests.helpers import livenotes as ln + +ASSET = "ep_test" +BASE = storyepisode.episode_key(ASSET) +ALL_LANGS = ("_japanese", "_english", "_traditionalChinese", "_simplifiedChinese", "_korean") +CHAT_KEYS = storychat.SHARDS + +COLLECTION = [ + {"Index": 0, "Command": 2, "AdvTextID": 101, "VoiceIDs": [201, 202], "Speaker": 3, "Volume": 0.0, "Note": ""}, + {"Index": 1, "Command": 25, "TargetAssetName": "st_a/st_a"}, + {"Index": 2, "Command": 26, "VideoID": 41}, + {"Index": 3, "Command": 36, "TargetChatID": 7}, + {"Index": 4, "Command": 6, "Duration": 1.0}, +] + + +def shard(rows: list[dict]) -> bytes: + """The bytes of a shard TextAsset holding `rows`.""" + return json.dumps({"_header": [], "_allData": rows}).encode("utf-8") + + +def master_tables() -> dict: + """Master tables {name: rows}: episode 1 (title text 9001, chats 7 and 9), episode 2 (another asset) and episode + 10 (the same asset as episode 1, no title).""" + title = {"_id": "9001", **{lang: f"title{lang}" for lang in ALL_LANGS}} + return { + "MasterAdv": [ + {"_id": 1, "_advEpisodeAsset": ASSET, "_titleTextId": "9001"}, + {"_id": 2, "_advEpisodeAsset": "other", "_titleTextId": ""}, + {"_id": 10, "_advEpisodeAsset": ASSET, "_titleTextId": ""}, + ], + "MasterText": [title, {"_id": "9002", **{lang: "x" for lang in ALL_LANGS}}], + "MasterAdvChat": [ + {"_id": 9, "_chatWindowAssetName": "win_9", "_chatIconAssetName": ""}, + {"_id": 7, "_chatWindowAssetName": "win_7", "_chatIconAssetName": "icon_7"}, + ], + } + + +def master_dir(tmp_path, tables: dict | None = None): + """A master directory holding each table as
.json ({"_allData": rows}).""" + md = tmp_path / "master" + md.mkdir(parents=True, exist_ok=True) + for name, rows in (tables if tables is not None else master_tables()).items(): + (md / f"{name}.json").write_text(json.dumps({"_allData": rows}), encoding="utf-8") + return md + + +def episode_table(depot) -> dict: + """The object table of episode ASSET: its command list, its four shards (the cue sheet shard is absent) and the + AdvPlayerSettings, with the catalog keys the episode reads.""" + objs = { + "episode": ("MonoBehaviour", "ep", {"json": {"$script": "App|AdvEpisodeCollection", "Collection": COLLECTION}}), + "text": ("TextAsset", "text", {"data": shard([{"_id": 101, "_japanese": "こんにちは", "_english": "Hello"}])}), + "sound": ( + "TextAsset", + "sound", + {"data": shard([{"_id": 201, "_cueName": "v_001"}, {"_id": 202, "_cueName": "v_002"}])}, + ), + "video": ("TextAsset", "video", {"data": shard([{"_id": 41, "_assetName": "adv/movie_a/movie_a"}])}), + "settings": ( + "MonoBehaviour", + "settings", + { + "json": { + "$script": "App|AdvPlayerSettings", + "_defaultTransitionAssetAddress": "adv_transition_default/x", + } + }, + ), + } + keys = { + BASE: ["episode"], + f"{BASE}-Text": ["text"], + f"{BASE}-Sound": ["sound"], + f"{BASE}-SoundCueSheet": [], + f"{BASE}-Video": ["video"], + PLAYER_SETTINGS_KEY: ["settings"], + } + return ln.table(depot, keys, objs) + + +def chat_table(depot, present: tuple[str, ...] = tuple(CHAT_KEYS)) -> dict: + """The object table of the chat shards: each shard named in `present` (text, sounds, cueSheets) has a TextAsset + with its rows; the other shard keys are catalog keys without an object.""" + rows = { + "text": [{"_id": 1, "_text": "hello"}], + "sounds": [{"_id": 2, "_cueName": "chat_se"}], + "cueSheets": [{"_id": 3, "_cueSheetName": "chat_sheet"}], + } + objs, keys = {}, {} + for name, key in CHAT_KEYS.items(): + keys[key] = [] + if name in present: + oid = f"{name}-shard" + objs[oid] = ("TextAsset", name, {"data": shard(rows[name])}) + keys[key] = [oid] + return ln.table(depot, keys, objs) diff --git a/tests/pipeline/test_story.py b/tests/pipeline/test_story.py index c8b3bf3..b845b06 100644 --- a/tests/pipeline/test_story.py +++ b/tests/pipeline/test_story.py @@ -2,15 +2,18 @@ import pytest -from nnnotes.content import storyscene +from nnnotes.content import storychat, storyepisode, storyscene from nnnotes.formats import crilips from nnnotes.pipeline import assemble, contract, cristages, live, story +from nnnotes.pipeline.assemble import Objects from nnnotes.pipeline.contract import IncompatibleTask from nnnotes.pipeline.depot import Depot +from nnnotes.pipeline.live2d import MODEL from nnnotes.pipeline.stages import Env -from nnnotes.pipeline.story import AssetStage, CriLipsStage, SceneStage, SoundStage +from nnnotes.pipeline.story import AssetStage, ChatStage, CriLipsStage, SceneStage, SoundStage, StoryStage from tests.formats.test_crilips import _elf_with_rodata, _synthetic_version -from tests.pipeline.test_live import run, stored, table +from tests.helpers import story as sh +from tests.pipeline.test_live import master_record, run, stored, table from tests.site.test_story_site import mp4 # the streams of a voice sheet as cri.audio lists them: cue v2 has two layers, cue loop loops @@ -194,3 +197,176 @@ def test_the_crilips_product_needs_the_library_in_the_apk(tmp_path): env = Env(depot, {"apk": {}, "products": {story.CRILIPS: ["default"]}}) with pytest.raises(ValueError, match="libcri_lips_unity.so of the APK"): CriLipsStage().inputs("default", env) + + +# ---------------------------------------------------------------- the story product: subjects and inputs +def test_the_story_product_selects_the_episodes_whose_keys_it_holds(tmp_path): + depot = Depot(tmp_path / "d") + env = Env(depot, {"master": sh.master_dir(tmp_path)}) + stage = StoryStage() + assert stage.select(storyepisode.keys(sh.ASSET), env) == ["1", "10"] + assert stage.select(["Adv/Episode/other/other", "Cri/Sound/x"], env) == ["2"] + assert stage.select(["Cri/Sound/x"], env) == [] + assert stage.select(storyepisode.keys(sh.ASSET), Env(depot, {"master": None})) == [] + + +def test_the_story_product_names_its_keys_and_manifest(tmp_path): + env = Env(Depot(tmp_path / "d"), {"master": sh.master_dir(tmp_path)}) + stage = StoryStage() + assert stage.keys("1", env) == storyepisode.keys(sh.ASSET) + assert stage.manifest("1") == "stories/1.json" + for bad in ("", "1a", "-1"): + with pytest.raises(ValueError, match="not an episode id"): + stage.manifest(bad) + + +def test_the_story_master_input_is_the_episode_record(tmp_path): + depot = Depot(tmp_path / "d") + env = Env(depot, {"master": sh.master_dir(tmp_path)}) + inp = master_record(env, StoryStage(), "1") + assert inp.role == assemble.MASTER_INPUT + record = contract.loads(depot.input_bytes(inp)) + assert record == storyepisode.episode_record(sh.master_tables(), 1) + assert [r["_id"] for r in record["MasterAdvChat"]] == [7, 9] + + +# ---------------------------------------------------------------- the story product: assembly +def test_the_story_product_holds_the_episode_and_the_story_document(tmp_path): + depot = Depot(tmp_path / "d") + env = Env(depot, {"master": sh.master_dir(tmp_path)}) + stage = StoryStage() + doc = sh.episode_table(depot) + objects = stored(depot, contract.encode(doc), "objects") + _, _, arts = run(depot, stage, "1", [objects, master_record(env, stage, "1")]) + assert {p: a["semantics"]["kind"] for p, a in arts.items()} == { + "episode.json": "story.episode", + "objects": "product.objects", + "story.json": "story", + } + record = storyepisode.episode_record(sh.master_tables(), 1) + # the stored document is the JSON of the episode: integer keys read back as strings + episode = contract.loads( + contract.encode(storyepisode.episode_json(storyepisode.episode(Objects(doc, depot), record))) + ) + assert read_json(depot, arts["episode.json"]) == episode + assert all(set(r) == {"kind", "address"} for r in episode["resources"]) + assert read_json(depot, arts["objects"])["objects"] == {} + + story_doc = read_json(depot, arts["story.json"]) + assert story_doc == { + "format": 1, + "advId": 1, + "episode": "episode.json", + "chats": {"7": {"_id": 7, "_chatWindowAssetName": "win_7", "_chatIconAssetName": "icon_7"}}, + "effects": {}, + "parts": { + "scene": "storyscenes/default.json", + "assets": { + "Adv/Chat/Prefabs/win_7": "storyassets/Adv~Chat~Prefabs~win_7.json", + "Adv/Stage/st_a/st_a": "storyassets/Adv~Stage~st_a~st_a.json", + "Adv/Transition/adv_transition_default/x": "storyassets/Adv~Transition~adv_transition_default~x.json", + }, + "models": {}, + "sounds": {}, + "chat": "storychat/default.json", + "crilips": None, + }, + } + assert story_doc["parts"] == story.part_paths(storyepisode.parts(episode)) + + +# ---------------------------------------------------------------- the chat product +def test_the_chat_product_has_one_default_subject_and_needs_every_shard(tmp_path): + stage = ChatStage() + assert stage.keys("default", None) == storychat.keys() == sorted(storychat.SHARDS.values()) + assert stage.manifest("default") == "storychat/default.json" + assert stage.select(storychat.keys(), None) == ["default"] + assert stage.select(storychat.keys()[1:], None) == [] + with pytest.raises(ValueError, match="the only subject is default"): + stage.keys("other", None) + with pytest.raises(ValueError, match="the only subject is default"): + stage.manifest("other") + + +def test_the_chat_product_holds_the_rows_of_the_shards(tmp_path): + depot = Depot(tmp_path / "d") + objects = stored(depot, contract.encode(sh.chat_table(depot)), "objects") + _, _, arts = run(depot, ChatStage(), "default", [objects]) + assert {p: a["semantics"]["kind"] for p, a in arts.items()} == { + "chat.json": "story.chat", + "objects": "product.objects", + } + assert read_json(depot, arts["chat.json"]) == { + "format": 1, + "text": [{"_id": 1, "_text": "hello"}], + "sounds": [{"_id": 2, "_cueName": "chat_se"}], + "cueSheets": [{"_id": 3, "_cueSheetName": "chat_sheet"}], + } + assert read_json(depot, arts["objects"])["objects"] == {} + + +def test_a_chat_shard_without_a_text_asset_has_no_rows(tmp_path): + depot = Depot(tmp_path / "d") + objects = stored(depot, contract.encode(sh.chat_table(depot, present=("text",))), "objects") + _, _, arts = run(depot, ChatStage(), "default", [objects]) + chat = read_json(depot, arts["chat.json"]) + assert chat["text"] == [{"_id": 1, "_text": "hello"}] + assert chat["sounds"] == [] and chat["cueSheets"] == [] + + +# ---------------------------------------------------------------- the products a story plays with +def test_part_paths_give_the_manifest_of_each_part(): + parts = { + "assets": ["Adv/Stage/st_a/st_a", "Adv/Still/st"], + "models": ["Character/Live2D/g/m/model/m"], + "sounds": ["sheet_a"], + "chat": True, + "crilips": False, + } + assert story.part_paths(parts) == { + "scene": "storyscenes/default.json", + "assets": { + "Adv/Stage/st_a/st_a": "storyassets/Adv~Stage~st_a~st_a.json", + "Adv/Still/st": "storyassets/Adv~Still~st.json", + }, + "models": {"Character/Live2D/g/m/model/m": "models/m.json"}, + "sounds": {"sheet_a": "cuesheets/sheet_a.json"}, + "chat": "storychat/default.json", + "crilips": None, + } + quiet = {**parts, "assets": [], "models": [], "sounds": [], "chat": False, "crilips": True} + assert story.part_paths(quiet) == { + "scene": "storyscenes/default.json", + "assets": {}, + "models": {}, + "sounds": {}, + "chat": None, + "crilips": "crilips/default.json", + } + + +def test_part_products_list_the_subjects_of_each_product_the_story_needs(): + parts = { + "assets": ["Adv/Stage/z", "Adv/Stage/a"], + "models": ["Character/Live2D/g/m/model/m"], + "sounds": ["sheet_a"], + "chat": True, + "crilips": True, + } + doc = {"parts": story.part_paths(parts)} # story.json names each part by its manifest + assert story.part_products(doc) == { + story.SCENE: ["default"], + story.ASSET: ["Adv/Stage/a", "Adv/Stage/z"], + MODEL: ["Character/Live2D/g/m/model/m"], + story.SOUND: ["sheet_a"], + story.CHAT: ["default"], + story.CRILIPS: ["default"], + } + + +def test_part_products_omit_the_products_a_story_does_not_need(): + parts = {"assets": [], "models": [], "sounds": [], "chat": False, "crilips": False} + assert story.part_products({"parts": story.part_paths(parts)}) == {story.SCENE: ["default"]} + parts["sounds"] = ["sheet_a"] + doc = {"parts": story.part_paths(parts)} + assert story.part_products(doc) == {story.SCENE: ["default"], story.SOUND: ["sheet_a"]} diff --git a/tests/site/test_webmodel.py b/tests/site/test_webmodel.py index 93c3ff3..679dc02 100644 --- a/tests/site/test_webmodel.py +++ b/tests/site/test_webmodel.py @@ -12,7 +12,6 @@ from nnnotes.source.config import Config, ConfigError from nnnotes.source.session import Session from nnnotes.unity import live2d -from nnnotes.unity import player as nnnotes_player KEY_A = "Character/Live2D/001_adv/adv_model_a/model/adv_model_a" KEY_B = "Character/Live2D/sub_x/adv_model_b/model/adv_model_b" @@ -775,138 +774,3 @@ def test_site_models_read_model_json(tmp_path): # the manifest of another key with the same id with pytest.raises(RuntimeError, match="not of Character/Live2D/other/adv_model_a"): src.ensure("Character/Live2D/other/adv_model_a/model/adv_model_a") - - -def dir_files(d): - return sorted(p.relative_to(d).as_posix() for p in d.rglob("*") if p.is_file()) - - -def test_model_dir_exports_each_model_once(tmp_path, monkeypatch): - player = fake_export(monkeypatch, motion_sync=True) - calls, real = [], webmodel.export_model - - def counted(*a): - calls.append(a[2]) - return real(*a) - - monkeypatch.setattr(webmodel, "export_model", counted) - root = tmp_path / "out" / "live2d" - a = webmodel.ModelDir(None, player, root) - assert a.ensure(KEY_A) == a.ensure(KEY_A) == {"id": "adv_model_a", "motionSync": True} - assert calls == [KEY_A] and (a.built, a.skipped) == (["adv_model_a"], []) - d = root / "adv_model_a" - # the files of the model's manifest: no unused atlas page, no other platform or GLES 3.1 program - assert dir_files(root) == [f"adv_model_a/{f}" for f in model_files("adv_model_a")] - index = json.loads((d / "shaders/shaders.json").read_text(encoding="utf-8")) - assert [v["file"] for r in index for v in r["variants"]] == [ - "S_Lit/gles3/s0p0_vertex_0.glsl", - "S_Lit/gles3/s0p0_vertex_1.glsl", - "S_Lit/gles3/s0p0_vertex_5.glsl", - "S_Lit/gles3/s0p0_vertex_6.glsl", - "S_Mask/gles3/s0p0_vertex_0.glsl", - ] - assert (d / "adv_model_a.moc3").read_bytes() == b"MOC3 bytes" - assert a.story_fields(tmp_path / "out" / "s1") == {"modelsDir": "../live2d"} - # another story's source: the directory is used as it is - before = {f: (d / f).read_bytes() for f in dir_files(d)} - b = webmodel.ModelDir(None, player, root) - assert b.ensure(KEY_A) == {"id": "adv_model_a", "motionSync": True} - assert calls == [KEY_A] and (b.built, b.skipped) == ([], ["adv_model_a"]) - assert b.story_fields(tmp_path / "out" / "s2") == {"modelsDir": "../live2d"} - # force: exported again, once per source; the same files - c = webmodel.ModelDir(None, player, root, force=True) - c.ensure(KEY_A) - c.ensure(KEY_A) - assert calls == [KEY_A, KEY_A] and c.built == ["adv_model_a"] - assert {f: (d / f).read_bytes() for f in dir_files(d)} == before - - def failing(*a): - raise RuntimeError("export failed") - - # a failed export leaves no directory; a directory that is not a model export is refused - monkeypatch.setattr(webmodel, "export_model", failing) - with pytest.raises(RuntimeError, match="export failed"): - webmodel.ModelDir(None, player, root).ensure(KEY_B) - assert sorted(p.name for p in root.iterdir()) == ["adv_model_a"] - (root / "adv_model_b").mkdir() - with pytest.raises(RuntimeError, match="not a model directory"): - webmodel.ModelDir(None, player, root).ensure(KEY_B) - - -@pytest.mark.parametrize("force", [False, True]) -def test_model_dir_shared_by_processes_keeps_one_whole_model(tmp_path, monkeypatch, force): - """Another process (another ModelDir on the same root) installs the model while this one exports it: without - force the first installed copy is kept and this export dropped, with force this export replaces it; either way - one whole model directory and no temporary directory is left.""" - player = fake_export(monkeypatch, motion_sync=True) - root, real, other = tmp_path / "live2d", webmodel.export_model, [] - - def racing(*a): - if not other: - other.append(webmodel.ModelDir(None, player, root, force=force)) - other[0].ensure(KEY_A) - return real(*a) - - monkeypatch.setattr(webmodel, "export_model", racing) - a = webmodel.ModelDir(None, player, root, force=force) - assert a.ensure(KEY_A) == {"id": "adv_model_a", "motionSync": True} - assert (a.built, a.skipped) == ((["adv_model_a"], []) if force else ([], ["adv_model_a"])) - assert other[0].built == ["adv_model_a"] - assert [p.name for p in root.iterdir()] == ["adv_model_a"] - assert dir_files(root) == [f"adv_model_a/{f}" for f in model_files("adv_model_a")] - - -def test_model_dir_exports_a_model_moved_aside_after_the_check(tmp_path, monkeypatch): - """The model directory existed when checked but is gone when read (a forced export of another process moved it - aside): exported here.""" - player = fake_export(monkeypatch, motion_sync=True) - root = tmp_path / "live2d" - d, real, seen = root / "adv_model_a", webmodel.Path.exists, [] - - def exists(p): - if p == d and not seen: - seen.append(p) - return True - return real(p) - - monkeypatch.setattr(webmodel.Path, "exists", exists) - a = webmodel.ModelDir(None, player, root) - assert a.ensure(KEY_A) == {"id": "adv_model_a", "motionSync": True} - assert seen and (a.built, a.skipped) == (["adv_model_a"], []) - assert dir_files(root) == [f"adv_model_a/{f}" for f in model_files("adv_model_a")] - - -def test_story_command_exports_its_models_next_to_the_story(tmp_path, capsys, monkeypatch): - from nnnotes.content import story - - player = fake_export(monkeypatch) - monkeypatch.setattr(Session, "catalog", lambda self, bundles=True, region=None: None) - monkeypatch.setattr(Session, "apk", lambda self, region=None: None) - monkeypatch.setattr(nnnotes_player, "PlayerData", lambda apk: player) - - def fake_build(cat, md, pl, adv_id, out_dir, *, models, **kw): - index = {"models": {KEY_A: models.ensure(KEY_A)["id"]}, **models.story_fields(out_dir)} - out_dir.mkdir(parents=True) - jsonio.write_json(out_dir / "story.json", index) - return index - - monkeypatch.setattr(story, "build", fake_build) - md = tmp_path / "master" - md.mkdir() - (md / "MasterAdv.json").write_text(json.dumps({"_allData": [{"_id": 1}]}), encoding="utf-8") - base = ["--json", "--master", str(md), "--language", "ja", "export", "story", "1"] - out = tmp_path / "out" - code, o, err = run([*base, "-o", str(out / "s1")], capsys) - assert code == 0, err - r = json.loads(o) - assert r["models"] == {KEY_A: "adv_model_a"} and r["modelsDir"] == "../live2d" - assert (r["modelsBuilt"], r["modelsSkipped"]) == (["adv_model_a"], []) - assert json.loads((out / "s1/story.json").read_text(encoding="utf-8"))["modelsDir"] == "../live2d" - assert (out / "live2d/adv_model_a" / webmodel.MODEL_INDEX).is_file() - code, o, _ = run([*base, "-o", str(out / "s2")], capsys) # a second story: the model is reused - r = json.loads(o) - assert code == 0 and (r["modelsBuilt"], r["modelsSkipped"]) == ([], ["adv_model_a"]) - code, o, _ = run([*base, "-o", str(out / "s3"), "--models", str(tmp_path / "m"), "--force"], capsys) - r = json.loads(o) - assert code == 0 and r["modelsDir"] == "../../m" and r["modelsBuilt"] == ["adv_model_a"] - assert (tmp_path / "m/adv_model_a" / webmodel.MODEL_INDEX).is_file()