From 8edc6eddc5ac2bcb177493429d3c56e89e2af214 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Thu, 15 Oct 2026 11:07:54 +0800 Subject: [PATCH] feat(live2d)!: assemble Live2D models from the exported objects Two product stages make a Live2D model from the unity.export results of the objects its prefab reaches, never from a bundle: - live2d.model (version 2) is the model the player reads: model.json (format 3) names the prefab, the moc, the canvas, the atlas pages, the shaders, the mask materials loaded by Resources.Load path, MotionSync and the bindings of every clip named in the model's hierarchy. Its object table drops the MonoScripts and keeps, of a shader, the parsed form, the variant index and the GLSL ES 3.00 programs the drawn materials use. - live2d.cubism is the model as Cubism SDK files: moc3, atlas pages, physics3, exp3, motion3 and model3. `export assets --layout product` places the products of the selection: each product stage makes the products of the keys the selected bundles hold, and without an object layout the run exports only what the products reach. `export live2d MODEL -o OUT` runs the same pipeline for one model and writes its Cubism SDK files, or with `--player` the player's model in the product layout. The Cubism format conversions live in nnnotes.formats.cubism, the choice of the player's shader program in formats.shader.player_program, and the model ids in content.live2d; the site's model export uses them from there. BREAKING CHANGE: `export live2d` writes the Cubism SDK files only (no prefab JSON, no motions/_fades.json) and runs through the depot; live2d.model tasks take the object table of the model instead of its bundles. Co-Authored-By: Claude Opus 5.5 --- docs/assets.md | 10 +- docs/cli.md | 12 +- docs/commands.md | 21 +- docs/export.md | 6 +- docs/products.md | 46 +++- docs/stages.md | 4 +- src/nnnotes/cli/assets.py | 108 +++++++- src/nnnotes/cli/export.py | 37 ++- src/nnnotes/content/live2d.py | 368 ++++++++++++++++++++++++++++ src/nnnotes/formats/cubism.py | 106 ++++++++ src/nnnotes/formats/motion.py | 37 --- src/nnnotes/formats/shader.py | 28 ++- src/nnnotes/pipeline/assemble.py | 4 + src/nnnotes/pipeline/layout.py | 23 +- src/nnnotes/pipeline/live2d.py | 87 +++++++ src/nnnotes/pipeline/modelstages.py | 241 +----------------- src/nnnotes/site/sitefiles.py | 4 +- src/nnnotes/site/storysite.py | 4 +- src/nnnotes/site/webmodel.py | 98 ++------ src/nnnotes/unity/bindings.py | 4 +- src/nnnotes/unity/live2d.py | 181 +------------- tests/cli/test_assets.py | 98 +++++++- tests/content/test_live2d.py | 161 ++++++++++++ tests/helpers/live2dmodel.py | 243 ++++++++++++++++++ tests/pipeline/test_live2d.py | 84 +++++++ tests/pipeline/test_modelstages.py | 245 +----------------- tests/site/test_webmodel.py | 28 --- 27 files changed, 1439 insertions(+), 849 deletions(-) create mode 100644 src/nnnotes/content/live2d.py create mode 100644 src/nnnotes/formats/cubism.py create mode 100644 src/nnnotes/pipeline/live2d.py create mode 100644 tests/content/test_live2d.py create mode 100644 tests/helpers/live2dmodel.py create mode 100644 tests/pipeline/test_live2d.py diff --git a/docs/assets.md b/docs/assets.md index 96e04b3..e3fb2ff 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -18,7 +18,7 @@ nnnotes depot plan --check && echo "up to date" ## Commands ``` -nnnotes export assets -o OUT [--layout original[,cas]] [--select group:G|key:PREFIX|bundle:GLOB ...] [--views all|none|a,b] +nnnotes export assets -o OUT [--layout original[,cas][,product]] [--select group:G|key:PREFIX|bundle:GLOB ...] [--views all|none|a,b] [--depot DIR] [--workers N] [--memory GiB] [--fetch-workers N] [--stored-catalog L|SHA] [--png-level N] [--flac-level N] [--only-class C,...] [--link auto|clone|hard|copy] [--strict] [--dry-run] [--explain] @@ -37,7 +37,7 @@ nnnotes depot verify [--quick] [--workers N] | `-o OUT` | output directory | | `--select SEL` | what to export (repeatable, the union; default everything): `group:G` the bundles of an Addressables group (the stable bundle name up to `_assets_`, else up to its first `_`), `key:PREFIX` the bundles and raw files the catalog keys under `PREFIX` load, `bundle:GLOB` the bundles and raw files whose stable name matches | | `--views` | the semantic views to build ([views.md](views.md)): `all`, `none` or names, comma-separated; default `all` when master data is set, else `none` | -| `--layout` | `original`, `cas` or both (below) | +| `--layout` | `original`, `cas`, `product`, comma-separated (below) | | `--depot DIR` | the depot (`[paths] depot`) | | `--workers N` | worker processes (default: the CPUs the process may run on, its affinity mask as `taskset` or a container's cpuset sets it; `0`: every task in this process) | | `--memory GiB` | memory budget (default: 80 % of the physical memory where it can be read, else none) | @@ -183,6 +183,12 @@ replaces or removes it where the system needs that (Windows), and the depot obje from the `original` manifest alone, `original` from `cas` and the results. The views are the exception: `original` has them with the paths of their objects, `cas` the view documents they are made from. +`product` places the products of the selection ([products](products.md)): every product stage makes its products +of the catalog keys the selected bundles hold (the Live2D stages: each model key), and the layout writes each +product's manifest (`models/.json`, `cubism/.json`) with its files and the files of its objects at +`assets/.`. The product stages run only with this layout. Without `original` or `cas`, the run exports +only the objects the products reach, not every object of the selection. + ## Reports `OUT/_reports/`: diff --git a/docs/cli.md b/docs/cli.md index ec38edb..158e10c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -178,7 +178,7 @@ every object of the selected bundles -> common formats (the depot and its layout | `--workers` `N` | worker processes (default: the CPUs this process may use; 0: this process) | | `--memory` `GiB` | memory budget of the running tasks and workers (default: 80 % of the physical memory) | | `--fetch-workers` `N` | parallel downloads (default 8) | -| `--layout` `LAYOUT` | original, cas or original,cas (default original) | +| `--layout` `LAYOUT` | original, cas, product, comma-separated (default original; product: the products of the selection) | | `--explain` | print every setting of the run first | | `--link` `LINK` | how the layout files are made from the depot: auto (default; [export] link): a clone where the file system makes them, else a hard link (read-only), else a copy; or that method only (one of `auto`, `clone`, `hard`, `copy`) | | `--strict` | exit 1 when a view has gaps | @@ -228,12 +228,18 @@ At most one of `--band`, `--leader-card`. ## nnnotes export live2d -a Live2D model -> runtime model directory +a Live2D model -> its Cubism SDK files, or the player's model | Argument | Description | | --- | --- | | `key` | model key Character/Live2D///model/, or its model id | | `-o`, `--out` `OUT` | output directory (required) | +| `--player` | write the player's model (models/.json and assets/) instead of the Cubism SDK files | +| `--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 spot @@ -321,7 +327,7 @@ what `export assets` would run and why, without running it (--json: nnnotes.plan | `--workers` `N` | worker processes (default: the CPUs this process may use; 0: this process) | | `--memory` `GiB` | memory budget of the running tasks and workers (default: 80 % of the physical memory) | | `--fetch-workers` `N` | parallel downloads (default 8) | -| `--layout` `LAYOUT` | original, cas or original,cas (default original) | +| `--layout` `LAYOUT` | original, cas, product, comma-separated (default original; product: the products of the selection) | | `--explain` | print every setting of the run first | | `--since` `RUN` | explain against this run (default: the latest of the selection) | | `--check` | exit 1 when anything would run or change | diff --git a/docs/commands.md b/docs/commands.md index 9670658..67c2697 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -287,24 +287,29 @@ way the TMP shader applies them. The document-level `textStyle.roles` gives, per ## export live2d ``` -nnnotes export live2d MODEL -o OUT +nnnotes export live2d MODEL -o OUT [--player] ``` -A Live2D (Cubism) model prefab as a Cubism runtime directory. `MODEL` is the model's key +A Live2D (Cubism) model as the Cubism SDK files other tools read. `MODEL` is the model's key `Character/Live2D///model/` or its id `` (`nnnotes catalog keys --prefix Character/Live2D/` lists the keys): ``` OUT/.moc3 the model's moc3 -OUT/textures/*.png atlas pages -OUT/.prefab.json the whole prefab: every GameObject and component, motions and expressions inlined -OUT/.exp3.json expressions +OUT/textures/.png the atlas pages the drawables draw with, by name OUT/.physics3.json physics (not written for a model without physics) -OUT/motions/.motion3.json motions (and motions/_fades.json) -OUT/.model3.json file references, EyeBlink / LipSync groups +OUT/.exp3.json expressions +OUT/motions/.motion3.json motions +OUT/.model3.json file references, the model's fade times per motion, EyeBlink / LipSync groups ``` -Needs `[paths] apk` (component classes are resolved through the APK). Prints a JSON summary. +With `--player`, the model the player reads instead: the product layout of `live2d.model`, `OUT/models/.json` +and the files it names under `OUT/assets/` ([products](products.md#live2d-models)). + +The command runs the depot's pipeline for the model alone (the census of the bundles of its key, the objects its +prefab reaches, the product) like `export assets`, and writes the same reports in `OUT/_reports/`; it takes +`--stored-catalog`, `--workers`, `--link`, `--explain` and `--dry-run` as `export assets` does. Needs `[paths] apk` +(the component classes and the mask materials are in the APK). ## export spot diff --git a/docs/export.md b/docs/export.md index 6cce4fc..dbf7d9c 100644 --- a/docs/export.md +++ b/docs/export.md @@ -51,9 +51,9 @@ nnnotes catalog keys --prefix Character/Live2D/ --limit 0 # every model key nnnotes export live2d adv_live2d_rana_003_casual_spring_01 -o out/live2d/rana ``` -`MODEL` is the model's key or its id, the last part of the key. The output is a Cubism runtime directory: moc3, atlas -pages, motions, expressions, physics, `model3.json` and the whole prefab as JSON. Details: -[export live2d](commands.md#export-live2d). +`MODEL` is the model's key or its id, the last part of the key. The output is the model's Cubism SDK files: moc3, +atlas pages, motions, expressions, physics and `model3.json`; `--player` writes the model the player reads instead. +Details: [export live2d](commands.md#export-live2d). ## A spot or a room diff --git a/docs/products.md b/docs/products.md index d9c5158..1aa989a 100644 --- a/docs/products.md +++ b/docs/products.md @@ -59,13 +59,18 @@ object), or the reason code of a generic, unsupported or failed object. ## Product layout -A product layout places products for a player: +A product layout places products for a player (`export assets --layout product`, `export live2d --player`): - every file of a product and every file of the objects of its object table at `assets/.` (a file several products or objects share is written once); - each product's manifest at `/.json` (the stage's `manifest(subject)`, by default `/.json` with the subject escaped as one path segment). +A command asks for products in one of two ways: `export assets --layout product` makes, with each product stage, the +products of the catalog keys the selected bundles hold (the stage's `select`); `export live2d` asks for one model. +`export live2d` without `--player` places the files of its one product at their own paths instead (the `files` +placement: the Cubism SDK files as a directory). + ## Product manifest: nnnotes.product/1 ([product.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/product.schema.json)) @@ -83,3 +88,42 @@ A product layout places products for a player: contained in an object that has one. An asset path is relative to the directory above the manifest's directory, as in the chart manifests of ournotes-player. A reference in an object's JSON (`{"$ref": {"file", "pathId"}}`) names the object `:` of `objects`. + +## Live2D models + +Two product stages (`nnnotes.pipeline.live2d`) assemble a Live2D model from the objects its prefab reaches. Their +subjects are model keys, `Character/Live2D///model/`; a model's id is its ``. + +`live2d.model` is the model the player reads. Its roots are the model's GameObject (the object of its key) and the +materials the Cubism mask pass draws with, which the APK's built-in files hold at the `Resources.Load` paths +`Live2D/Cubism/Materials/Mask` and `Live2D/Cubism/Materials/MaskCulling`. Its manifest is `models/.json`, and its +one file `model.json` (format 3) names objects of its object table: + +| Field | | +|---|---| +| `name`, `key` | the model's id and key | +| `prefab` | the object id of the model's GameObject; its `json` is the hierarchy (prefab.json) | +| `moc` | the object id of its CubismMoc (the root's `CubismModel._moc`); the moc3 is its file `blob:_bytes` | +| `canvas` | the moc3's canvas: `pixelsPerUnit`, `originX`, `originY`, `width`, `height`, `mocVersion` | +| `textures` | the object ids of the atlas pages the drawables draw with (`CubismRenderer._mainTexture`), by name | +| `shaders` | `{shader name: object id}` of the shaders of the object table | +| `resources` | `{"cubismMask", "cubismMaskCulling"}`: the object ids of the two mask materials | +| `motionSync` | the root has the MotionSync controller and its CRI audio input | +| `bindings` | `{clip object id: [{path, attribute}]}`: the bindings of every AnimationClip of the table named in the model's hierarchy, played from its root (`nnnotes.unity.bindings`; `null` where no name, or several, fit) | + +Its object table holds every object the model reaches but the MonoScripts. Of a shader it keeps the parsed form +(`json`), the variant index (`index`) and the GLSL ES 3.00 programs (subshader 0, pass 0) the player draws the +drawables' materials with: for each material's keywords, alone and with `_ADDITIONAL_LIGHTS_VERTEX` (the keyword the +story renderer adds to a character draw), and the mask materials' when a drawable is masked (`CUBISM_MASK_ON`). + +`live2d.cubism` is the model as the Cubism SDK files other tools read (`export live2d`); its manifest is +`cubism/.json` and it has no objects: + +| File | From | +|---|---| +| `.moc3` | the CubismMoc's bytes | +| `textures/.png` | the atlas pages, by name (a name two pages share is numbered: `-2.png`) | +| `.physics3.json` | the root's `CubismPhysicsController._rig` (when it has sub-rigs) | +| `.exp3.json` | each CubismExpressionData of the table | +| `motions/.motion3.json` | each CubismFadeMotionData, with the sample rate of the clip of its name | +| `.model3.json` | the files, the model's fade times of each motion, and the EyeBlink and LipSync groups (the GameObjects holding a CubismEyeBlinkParameter or a CubismMouthParameter) | diff --git a/docs/stages.md b/docs/stages.md index 9e8e106..4dc4fdc 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -14,7 +14,7 @@ The asset export is built from three layers: Every stage is at version 1, except `unity.census` (version 4: it lists what every object references, the hierarchy holding it and the Resources.Load paths of a ResourceManager), `link.addresses` (version 2: the -Resources.Load paths), `unity.export` (version 2: one task per unit), `cri.movie` (version 2: every stream kind, +Resources.Load paths), `live2d.model` (version 2: assembled from the exported objects), `unity.export` (version 2: one task per unit), `cri.movie` (version 2: every stream kind, codec and channel of a USM) and `catalog.index` (version 2: gzip input and Japanese remote asset placeholders). | Stage | Subject | Inputs | Outputs | @@ -26,7 +26,7 @@ codec and channel of a USM) and `catalog.index` (version 2: gzip input and Japan | `sprite.crop` | a SpriteAtlas | the atlas' JSON, the images of its textures, the metas of the sprites of other bundles it packs | the sprites' images | | `cri.audio` | ACB content | `acb` (a raw file of the catalog, or the `acb` artifact of a cue sheet held in a bundle), `awb` (an AWB the catalog pairs with it), `boot` (the game's boot data: the key's source) | FLAC per stream, `cues.json`, `streams.json` | | `cri.movie` | USM content | `usm` (a raw file of the catalog, or the `usm` artifact of a movie held in a bundle), `boot` | every stream as stored (video, alpha, audio, subtitles), `movie.mkv` (or `movie.webm`) | -| `live2d.model` | a Live2D model key (`Character/Live2D///model/`) whose bundle is selected | `bundle:`: the bundles of the key's dependency closure in the order the extractor loads them (the order is part of the key); context `internalId` (the key's location); atom `live2d.extract_model` (the extractor of `nnnotes export live2d`, run unchanged) | `live2d.model:#`: every file `nnnotes export live2d` writes for the model, `` relative to the model's directory; placed under `/` | +| `live2d.model`, `live2d.cubism` | a Live2D model key (`Character/Live2D///model/`) a command asks for | `objects`: the object table of what the model reaches ([products](products.md#live2d-models)); depends on `link.addresses` and the `unity.export` tasks it reads | `model.json` (the player's model) and the object table; the Cubism SDK files | | `spine.skeleton` | a SkeletonDataAsset of a selected bundle, by its stable address `:` (in a bundle of several serialized files the file's tag follows the bundle name, as in `link.addresses`) | `bundle:`: the bundles holding the serialized files the asset's file reaches through its externals; context `object` (the asset's object id); atom `spot.write_skeleton` (the Spine writer of `nnnotes export spot`) | `spine.skeleton:#`: the skeleton (`.json` or `.skel`), its atlases (`.atlas`) and their page images, the bytes `nnnotes export spot` writes into `spine/`; one item for the asset | | `link.addresses` | global | catalog index, censuses (the built-in files' too) | key -> objects, Resources.Load path -> 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) | diff --git a/src/nnnotes/cli/assets.py b/src/nnnotes/cli/assets.py index 81d2ad4..c570b66 100644 --- a/src/nnnotes/cli/assets.py +++ b/src/nnnotes/cli/assets.py @@ -1,7 +1,7 @@ """The asset commands: the default orchestration of the stages (`export assets`), its plan and single tasks (`depot plan`, `depot run`). - nnnotes export assets -o OUT [--layout original[,cas]] [--select group:G|key:PREFIX|bundle:GLOB ...] + nnnotes export assets -o OUT [--layout original[,cas][,product]] [--select group:G|key:PREFIX|bundle:GLOB ...] [--views all|none|a,b] [--workers N] [--memory GiB] [--fetch-workers N] [--stored-catalog L|SHA] [--png-level N] [--flac-level N] [--only-class C,...] [--link auto|clone|hard|copy] [--strict] [--dry-run] [--explain] @@ -21,12 +21,16 @@ bundles {stable bundle name: Input "bundle"} of the selected bundles and their dependencies (a bundle whose bytes are not known yet: a Pending "fetch:"), and of the APK's built-in files (`apk/data.unity3d`, `apk/Resources/unity default resources`: pipeline.builtins) - selected the selected bundles' stable names, sorted (the subjects of the export stages) + selected the selected bundles' stable names, sorted (the subjects of the export stages; none when only products + are asked for: the run exports what they reach) raw {stable raw file name: Input "raw"} of the selected raw files (only with a stage that reads them; export fetches them while the stages before run, and reading the fact waits for that) boot the Input of the game's boot data (with a CRI stage: cristages.boot_input; None without an APK) master the decoded master data directory, or None views the names of the selected views, sorted + products {product stage: subjects} (with a product stage in the pipeline: the `product` layout makes each + stage's products of the keys the selected bundles hold, held_keys; `export live2d` asks for one) + roots the root objects of those products (pipeline.assemble.request) Selection (--select, repeatable; the union; none: everything): `group:G` the bundles of an Addressables group (the stable name up to `_assets_`, else up to its first `_`), `key:PREFIX` the bundles and raw files the catalog keys @@ -69,7 +73,8 @@ from .common import nonnegative, print_json, usage GIB = 1 << 30 -LAYOUT_NAMES = ("original", "cas") +LAYOUT_NAMES = ("original", "cas", "product") +OBJECT_LAYOUTS = ("original", "cas") # the layouts of the objects: they ask for every object of the selection FETCH_WORKERS = 8 RECYCLE_TASKS = 500 RECYCLE_RSS = 3 * GIB # a worker whose resident memory after a task is above this is replaced ... @@ -88,12 +93,14 @@ class StageSource: """Where a stage (or, for a name ending with '.', a family of stages) comes from. `output`: its artifacts are placed in the layouts; `root`: the layout root of its artifacts without a container path (format fields - {stage} and {subject}; a stage's LAYOUT_ROOT attribute wins).""" + {stage} and {subject}; a stage's LAYOUT_ROOT attribute wins); `product`: a product stage (pipeline.assemble), + in the pipeline only when products are asked for, its results placed by the product layouts only.""" name: str target: str output: bool = False root: str | None = None + product: bool = False def matches(self, stage: str) -> bool: return stage.startswith(self.name) if self.name.endswith(".") else stage == self.name @@ -108,8 +115,9 @@ def matches(self, stage: str) -> bool: StageSource("sprite.crop", "nnnotes.pipeline.objexport:SpriteCropStage", output=True), StageSource("cri.audio", "nnnotes.pipeline.cristages:AudioStage", output=True), StageSource("cri.movie", "nnnotes.pipeline.cristages:MovieStage", output=True), - StageSource("live2d.model", "nnnotes.pipeline.modelstages:Live2DStage", output=True), StageSource("spine.skeleton", "nnnotes.pipeline.modelstages:SpineStage", output=True), + StageSource("live2d.model", "nnnotes.pipeline.live2d:ModelStage", output=True, product=True), + StageSource("live2d.cubism", "nnnotes.pipeline.live2d:CubismStage", output=True, product=True), StageSource("view.", "nnnotes.pipeline.views:stages", output=True), StageSource("view.voices", "nnnotes.pipeline.voices:VoiceStage", output=True), ) @@ -232,6 +240,23 @@ def select(index: dict, selection: list[str], builtin=()) -> Selection: return Selection(sorted(pick(chosen, "bundle") + own), sorted(pick(deps, "bundle") + builtin), pick(chosen, "raw")) +def held_keys(index: dict, bundles) -> list[str]: + """The catalog keys of the asset locations the bundles `bundles` hold (a location's first dependency is the + bundle holding it), sorted.""" + from ..pipeline import catalogdb + + files, want = catalogdb.file_refs(index), {("bundle", b) for b in bundles} + return sorted( + { + loc["primaryKey"] + for loc in index["locations"] + if loc["kind"] not in catalogdb.FILE_KINDS + and loc["dependencies"] + and files.get(loc["dependencies"][0]) in want + } + ) + + # ---------------------------------------------------------------- settings def default_memory() -> int | None: """80 % of the physical memory (Linux), else None (no budget).""" @@ -459,11 +484,13 @@ class Workspace: """What export and plan share: the depot, the catalog version, its index, the selection, the pipeline and its parameters, the facts.""" - def __init__(self, args, cfg, log=_log): + def __init__(self, args, cfg, log=_log, layouts=()): from ..pipeline.catalogdb import CatalogDB from ..pipeline.depot import open_depot self.args, self.cfg, self.log = args, cfg, log + self.layouts = list(layouts) + self.products: dict | None = None # {product stage: subjects} a command asks for (request) self.root = Session(cfg).depot_root() self.apk = cfg.path("paths", "apk") self.fetcher = CatalogFetcher(self.root, cfg) @@ -613,9 +640,19 @@ def views(self) -> list[str]: raise self.cfg.missing(*self.cfg.master(self.cfg.get("catalog", "region"))) return want + def request(self, products: dict, selection: list[str]) -> None: + """Ask for the products `products` {product stage: subjects} alone, with the selection `selection` (what + their census needs) instead of the command's: only what the products reach is exported.""" + self.products, self.selection = {k: sorted(v) for k, v in products.items()}, list(selection) + + def _wanted(self, name: str) -> bool: + if not self.installed.sources[name].product: + return True + return name in self.products if self.products is not None else "product" in self.layouts + def pipeline(self) -> list[str]: views = set(self.views()) - return [n for n in self.installed.stages if not n.startswith("view.") or n in views] + return [n for n in self.installed.stages if (not n.startswith("view.") or n in views) and self._wanted(n)] def params(self, pipeline: list[str]) -> dict: """{stage: parameters} from the flags; a flag no stage of the pipeline takes is a usage error.""" @@ -695,6 +732,19 @@ def facts(self, pipeline: list[str], fetch: bool, background: bool = False) -> d "classes": self.classes(), "raw": Inputs({}), } + products = [n for n in pipeline if self.installed.sources[n].product] + if products: + from ..pipeline import assemble + + if self.products is not None: + chosen = {n: self.products.get(n, []) for n in products} + else: + held = held_keys(index, sel.selected) + chosen = {n: self.installed.stages[n].select(held) for n in products} + facts["products"] = chosen + facts["roots"] = assemble.request(self.installed.stages, chosen, Env(self.depot, facts)) + if self.products is not None or not set(self.layouts) & set(OBJECT_LAYOUTS): + facts["selected"] = [] # products alone: what they reach if any(n in pipeline for n in RAW_STAGES): from ..pipeline.cristages import boot_input @@ -906,9 +956,13 @@ def layout_entries(self, name: str, placed: LayoutInput, *, store_derived: bool from ..pipeline import layout stages = self.installed.stages + if name not in OBJECT_LAYOUTS: + return self._product_entries(name, placed, store_derived) sources, named = [], [] for tid, doc in placed.results: stage_name, subject = contract.parse_task_id(tid) + if self.installed.sources[stage_name].product: + continue stage = stages[stage_name] names = self.names(stage_name).get(subject) if name == "original" else None if names: @@ -937,6 +991,25 @@ def layout_entries(self, name: str, placed: LayoutInput, *, store_derived: bool taken.add(e["path"].casefold()) return sorted(entries + extra, key=lambda e: (e["path"], e["id"])), report + def _product_entries(self, name: str, placed: LayoutInput, store: bool) -> tuple[list[dict], dict]: + """The entries of a product layout (`product`: manifests and files; `files`: the products' own files at + their paths) for the product results given to `placed`, and its report.""" + from ..pipeline import layout + + results = [(t, d) for t, d in placed.results if self.installed.sources[contract.parse_task_id(t)[0]].product] + if name == "files": + return layout.product_files(d for _, d in results), {"collisions": [], "products": len(results)} + products = [] + for tid, doc in results: + stage_name, subject = contract.parse_task_id(tid) + rec = next(a for a in doc["artifacts"] if a["id"] == contract.artifact_id(tid, layout.PRODUCT_TABLE)) + table = contract.loads(self.depot.read(rec["content"]["sha256"])) + body = contract.encode(layout.product_manifest(tid, doc, table)) + sha = self.depot.put(body) if store else contract.sha256(body) + manifest = {"id": contract.artifact_id(tid, "manifest"), "sha256": sha, "size": len(body)} + products.append((self.installed.stages[stage_name].manifest(subject), manifest, doc, table)) + return layout.product_entries(products), {"collisions": [], "products": len(products)} + def done(self, tasks: dict) -> None: """The tasks whose results the layouts place, {task id: key} (after a run: every task with a result).""" self._done, self._names = dict(tasks), {} @@ -1161,15 +1234,20 @@ def _write_report(d: Path, name: str, doc) -> Path: def cmd_export(args, cfg): - from ..pipeline import builtins, layout - from ..pipeline.orchestrate import Orchestrator, failure - try: names = parse_layouts(args.layout) except ValueError as e: usage(args, str(e)) use(cfg) # the tools the atoms name are part of the keys - ws = Workspace(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).""" + from ..pipeline import builtins, layout + from ..pipeline.orchestrate import Orchestrator, failure + + names = ws.layouts pipeline = ws.pipeline() params = ws.params(pipeline) orch = Orchestrator( @@ -1334,7 +1412,7 @@ def cmd_plan(args, cfg): except ValueError as e: usage(args, str(e)) use(cfg) # the tools the atoms name are part of the keys - ws = Workspace(args, cfg) + ws = Workspace(args, cfg, layouts=layouts) pipeline = ws.pipeline() params = ws.params(pipeline) if args.explain: @@ -1552,5 +1630,9 @@ def run_args(c) -> None: help="memory budget of the running tasks and workers (default: 80 %% of the physical memory)", ) c.add_argument("--fetch-workers", type=int, metavar="N", help=f"parallel downloads (default {FETCH_WORKERS})") - c.add_argument("--layout", default="original", help="original, cas or original,cas (default original)") + c.add_argument( + "--layout", + default="original", + help="original, cas, product, comma-separated (default original; product: the products of the selection)", + ) c.add_argument("--explain", action="store_true", help="print every setting of the run first") diff --git a/src/nnnotes/cli/export.py b/src/nnnotes/cli/export.py index a59b440..bc35a77 100644 --- a/src/nnnotes/cli/export.py +++ b/src/nnnotes/cli/export.py @@ -9,6 +9,7 @@ from pathlib import Path from ..core.jsonio import write_json +from ..source.config import use from ..source.session import Session from . import assets from .common import ( @@ -24,6 +25,7 @@ known_row, live_option_arg, live_options, + nonnegative, out_arg, report, usage, @@ -122,16 +124,20 @@ def cmd_live(args, cfg): def cmd_live2d(args, cfg): - from ..site import webmodel - from ..unity import live2d - - cfg.require_path("paths", "apk") # the Cubism component classes are read from the APK - cat = Session(cfg).catalog() + from ..content import live2d + from ..pipeline.live2d import CUBISM, MODEL + + cfg.require_path("paths", "apk") # the Cubism component classes and the mask materials are in the APK + use(cfg) # the tools the atoms name are part of the keys + ws = assets.Workspace(args, cfg, layouts=["product" if args.player else "files"]) + ws.run_index() + keys = (loc["primaryKey"] for loc in ws.index()["locations"]) try: # a model id or a model key - (key,) = webmodel.catalog_models(cat, [args.key]).values() + (key,) = live2d.select_models([args.key], live2d.model_keys(keys)).values() except ValueError as e: usage(args, str(e)) - report(args, live2d.extract_model(cat, key, Path(args.out))) + ws.request({MODEL if args.player else CUBISM: [key]}, [f"key:{key}"]) + assets.export(args, cfg, ws) def cmd_spot(args, cfg): @@ -293,9 +299,24 @@ def register(sub) -> None: live_option_arg(c) out_arg(c, "output directory") - c = command(es, "live2d", cmd_live2d, "a Live2D model -> runtime model directory") + c = command(es, "live2d", cmd_live2d, "a Live2D model -> its Cubism SDK files, or the player's model") c.add_argument("key", help="model key Character/Live2D///model/, or its model id ") out_arg(c, "output directory") + c.add_argument( + "--player", + action="store_true", + help="write the player's model (models/.json and assets/) instead of the Cubism SDK files", + ) + c.add_argument( + "--stored-catalog", + metavar="LABEL|SHA", + help="a catalog version of the depot (`catalog versions`; default: the current catalog)", + ) + c.add_argument("--workers", type=nonnegative, metavar="N", help="worker processes (default: the usable CPUs)") + c.add_argument("--link", choices=("auto", "clone", "hard", "copy"), help="as export assets --link") + c.add_argument("--explain", action="store_true", help="print every setting of the run first") + c.add_argument("--dry-run", action="store_true", help="print the plan, run nothing") + c.set_defaults(views="none", strict=False) c = command(es, "spot", cmd_spot, "a spot -> spot.json + Spine + room.glb + shaders") c.add_argument("spot_id", type=int, help="MasterHomeSpot id") diff --git a/src/nnnotes/content/live2d.py b/src/nnnotes/content/live2d.py new file mode 100644 index 0000000..5897b3e --- /dev/null +++ b/src/nnnotes/content/live2d.py @@ -0,0 +1,368 @@ +"""Live2D models assembled from the objects their prefab reaches: the player's model document and the Cubism SDK +interchange files. + +Both read a product's object table (`Objects`, pipeline.assemble), never a bundle. The model is the GameObject among +the table's roots (the object its catalog key names, Character/Live2D///model/); its components +are read from its prefab document (prefab.json), a MonoBehaviour's class being the last part of its script entry. + +`model` -> model.json (MODEL_FORMAT): + + name, key the model's id (model_id: the key's last part) and its key + prefab the object id of the model's GameObject + moc the object id of its CubismMoc (the root's CubismModel._moc); the moc3 is its file `blob:_bytes` + canvas the moc3's canvas (formats.cubism.moc3_canvas) + textures the object ids of the atlas pages the drawables draw with (CubismRenderer._mainTexture), by name + shaders {shader name: object id} of the shaders of the table (the drawables' materials', the mask + materials', ...) + resources {name: object id} of the materials the Cubism mask pass draws with (RESOURCES: Resources.Load + paths, each the Material among the roots named as the path's last part) + motionSync the root has the MotionSync controller and its CRI audio input (MOTION_SYNC_COMPONENTS); a model + without them takes its mouth in a story from the voice's lip sync analysis + bindings {clip object id: [{path, attribute}]}: the bindings of every AnimationClip of the table named in the + model's hierarchy, played from its root (unity.bindings.resolve) + +Of the objects, the player reads all but the MonoScripts; of a shader, its parsed form (`json`), its variant index +(`index`) and the programs it draws the materials with (formats.shader.player_program): those of each drawable +material's keywords, alone and with STORY_KEYWORDS, and, when a drawable is masked (MASK_KEYWORD), the mask +materials'. A shader no drawn material uses keeps its parsed form and index. + +`cubism` -> the Cubism SDK files other tools read: .moc3, textures/.png (the atlas pages, by name), +.physics3.json (CubismPhysicsController._rig; when the rig has sub-rigs), .exp3.json (each +CubismExpressionData), motions/.motion3.json (each CubismFadeMotionData, with the sample rate of the clip of +its name) and .model3.json (the files, the model's fade times of each motion, and the EyeBlink and LipSync +groups: the names of the GameObjects holding a CubismEyeBlinkParameter or a CubismMouthParameter). +""" + +from __future__ import annotations + +import re +from collections.abc import Iterator +from typing import Any, Protocol + +from ..core.jsonio import dumps +from ..formats import motion +from ..formats.cubism import expression_json, moc3_canvas, physics_json +from ..formats.shader import player_program +from ..unity import bindings +from ..unity.ids import object_id + +__all__ = [ + "MASK_KEYWORD", + "LIVE2D_PREFIX", + "MODEL_FORMAT", + "MODEL_ID", + "MODEL_KEY", + "MOTION_SYNC_COMPONENTS", + "RESOURCES", + "STORY_KEYWORDS", + "Objects", + "cubism", + "model", + "model_id", + "model_keys", + "model_match", + "select_models", +] + +MODEL_FORMAT = 3 # model.json "format" +LIVE2D_PREFIX = "Character/Live2D/" +MODEL_KEY = re.compile(r"Character/Live2D/(?P[^/]+)/(?P[^/]+)/model/(?P=name)") +MODEL_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*") +# Resources.Load paths of the materials the Cubism mask pass draws with +RESOURCES = { + "cubismMask": "Live2D/Cubism/Materials/Mask", + "cubismMaskCulling": "Live2D/Cubism/Materials/MaskCulling", +} +MASK_KEYWORD = "CUBISM_MASK_ON" # a drawable material's keyword: the drawable is masked +# keywords the story renderer adds to a character draw (_ADDITIONAL_LIGHTS_VERTEX: at quality 4); the mask pass draws +# with none +STORY_KEYWORDS = ("_ADDITIONAL_LIGHTS_VERTEX",) +# a model's MotionSync: both components on the prefab's root +MOTION_SYNC_COMPONENTS = ("CubismMotionSyncController", "Live2DMotionSyncCriAudioInput") +SHADER_ROLES = ("json", "index") # a shader's files the player reads besides its programs +_UNSAFE = re.compile(r"[^A-Za-z0-9_.-]+") + + +class Objects(Protocol): + """A product's object table and its objects' files (pipeline.assemble.Objects).""" + + roots: list[str] + + def __contains__(self, oid: str) -> bool: ... + + def ids(self, cls: str | None = None) -> list[str]: ... + + def info(self, oid: str) -> dict: ... + + def bytes(self, oid: str, role: str) -> bytes: ... + + def json(self, oid: str, role: str = "json") -> Any: ... + + +def model_match(key: str) -> re.Match[str]: + """The parts of a model key: group, name (ValueError for a key that is not a model's).""" + m = MODEL_KEY.fullmatch(key) + if m is None: + raise ValueError(f"{key}: not a Live2D model key (Character/Live2D///model/)") + return m + + +def model_id(key: str) -> str: + """The id of a model key: its (ValueError for a key that is not a model's, or a name that is not + URL-safe).""" + m = model_match(key) + if not MODEL_ID.fullmatch(m["name"]): + raise ValueError(f"{key}: model name {m['name']!r} is not URL-safe") + return m["name"] + + +def model_keys(keys) -> dict[str, str]: + """{id: key} of the model keys among `keys`, in id order; two keys with the same id raise.""" + out: dict[str, str] = {} + for k in keys: + if MODEL_KEY.fullmatch(k): + i = model_id(k) + if out.get(i, k) != k: + raise ValueError(f"model id {i}: keys {out[i]} and {k}") + out[i] = k + return dict(sorted(out.items())) + + +def select_models(specs, models: dict[str, str]) -> dict[str, str]: + """The models `specs` names (ids or keys; None: all) as {id: key} in id order, from `models` ({id: key}).""" + if specs is None: + return dict(models) + by_key = {k: i for i, k in models.items()} + out, unknown = {}, [] + for s in specs: + i = s if s in models else by_key.get(s) + if i is None: + unknown.append(s) + else: + out[i] = models[i] + if unknown: + raise ValueError( + f"not a Live2D model of the catalog: {', '.join(unknown)} " + f"(give a model id or a key Character/Live2D///model/)" + ) + return dict(sorted(out.items())) + + +def ref_id(v) -> str | None: + """The object id of a reference value ({"$ref": {file, pathId}}); None for anything else.""" + r = v.get("$ref") if isinstance(v, dict) else None + return object_id(r["file"], r["pathId"]) if isinstance(r, dict) and "file" in r else None + + +def _class(c: dict) -> str: + """A component's class: a MonoBehaviour's is its script's.""" + if c["type"] == "MonoBehaviour" and isinstance(c.get("script"), str): + return c["script"].rsplit("|", 1)[-1] + return c["type"] + + +class _Model: + """A model's GameObject, prefab document and components.""" + + def __init__(self, objects: Objects, key: str): + self.objects, self.key, self.name = objects, key, model_id(key) + gos = [o for o in objects.roots if o in objects and objects.info(o).get("class") == "GameObject"] + if len(gos) != 1: + raise ValueError(f"{key}: {len(gos)} GameObjects among the roots, expected the model's") + self.id = gos[0] + self.prefab = objects.json(self.id) + self.root = self.prefab["nodes"][0] + + def components(self, cls: str, root_only: bool = False) -> Iterator[tuple[dict, dict]]: + """(node, component) of every component of class `cls` (of the root node only).""" + for n in [self.root] if root_only else self.prefab["nodes"]: + for c in n["components"]: + if _class(c) == cls: + yield n, c + + def one(self, cls: str) -> dict: + found = [c for _, c in self.components(cls, root_only=True)] + if len(found) != 1: + raise ValueError(f"{self.key}: {len(found)} {cls} on the model's root") + return found[0] + + def moc(self) -> str: + oid = ref_id(self.one("CubismModel")["fields"].get("_moc")) + if oid is None or oid not in self.objects: + raise ValueError(f"{self.key}: the CubismModel's moc is not in the object table") + return oid + + def moc_bytes(self) -> bytes: + return self.objects.bytes(self.moc(), "blob:_bytes") + + def textures(self) -> list[str]: + """The atlas pages the drawables draw with, by name and object id.""" + pages = {ref_id(c["fields"].get("_mainTexture")) for _, c in self.components("CubismRenderer")} + pages.discard(None) + return sorted((o for o in pages if o is not None), key=lambda o: (self.objects.info(o).get("name") or "", o)) + + def drawable_materials(self) -> list[str]: + """The first material of every drawable's MeshRenderer.""" + out = [] + for _, c in self.components("MeshRenderer"): + mats = c["fields"].get("m_Materials") or [] + oid = ref_id(mats[0]) if mats else None + if oid is not None: + out.append(oid) + return out + + def scripted(self, cls: str) -> list[str]: + """The MonoBehaviours of the table whose script class is `cls` (outside the prefab: data assets).""" + return [o for o in self.objects.ids("MonoBehaviour") if _script_class(self.objects, o) == cls] + + +def _script_class(objects: Objects, oid: str) -> str | None: + if "json" not in (objects.info(oid).get("files") or {}): + return None + entry = objects.json(oid).get("$script") + return entry.rsplit("|", 1)[-1] if isinstance(entry, str) else None + + +def _resources(m: _Model) -> dict[str, str]: + out = {} + for name, path in RESOURCES.items(): + leaf = path.rsplit("/", 1)[-1] + hits = [ + o + for o in m.objects.roots + if o in m.objects and m.objects.info(o).get("class") == "Material" and m.objects.info(o).get("name") == leaf + ] + if len(hits) != 1: + raise ValueError(f"{m.key}: {len(hits)} Materials {leaf!r} among the roots (Resources.Load {path})") + out[name] = hits[0] + return out + + +def model(objects: Objects, key: str) -> tuple[dict, set[str], dict[str, set[str]]]: + """(model.json, the object ids the player reads, {shader id: the roles of its files it reads}) of the model + `key` (module documentation).""" + m = _Model(objects, key) + moc = m.moc() + resources = _resources(m) + sets: dict[str, set[tuple]] = {} # shader id -> keyword sets + drawn = [objects.json(o) for o in m.drawable_materials()] + for mat in drawn: + sid = ref_id(mat["shader"]) + if sid is None: + raise ValueError(f"{key}: material {mat['material']!r} has no shader") + kws = tuple(mat["keywords"]) + sets.setdefault(sid, set()).update({kws, (*kws, *STORY_KEYWORDS)}) + masked = any(MASK_KEYWORD in mat["keywords"] for mat in drawn) + for oid in resources.values(): + mat = objects.json(oid) + sid = ref_id(mat["shader"]) + if sid is None: + raise ValueError(f"{key}: material {mat['material']!r} has no shader") + sets.setdefault(sid, set()) + if masked: + sets[sid].add(tuple(mat["keywords"])) + for sid in objects.ids("Shader"): + sets.setdefault(sid, set()) # a shader no drawn material uses: its parsed form and index only + shaders, roles = {}, {} + for sid, kw_sets in sorted(sets.items()): + index = objects.json(sid, "index") + shaders[index["name"]] = sid + keep = set(SHADER_ROLES) + for kws in sorted(kw_sets): + try: + keep.add(player_program(index["variants"], kws)["role"]) + except ValueError as e: + raise ValueError(f"{key}: shader {index['name']}: {e}") from None + roles[sid] = keep + classes = {_class(c) for c in m.root["components"]} + + def material(ref) -> dict | None: + oid = ref_id(ref) + return objects.json(oid) if oid is not None and oid in objects else None + + clips = {} + for cid in objects.ids("AnimationClip"): + if "json" in (objects.info(cid).get("files") or {}): + clips[cid] = bindings.resolve(objects.json(cid)["bindings"], m.prefab, m.root["path"], material) + doc = { + "format": MODEL_FORMAT, + "name": m.name, + "key": key, + "prefab": m.id, + "moc": moc, + "canvas": moc3_canvas(m.moc_bytes()), + "textures": m.textures(), + "shaders": dict(sorted(shaders.items())), + "resources": resources, + "motionSync": all(k in classes for k in MOTION_SYNC_COMPONENTS), + "bindings": clips, + } + keep = {o for o in objects.ids() if objects.info(o).get("class") != "MonoScript"} + return doc, keep, roles + + +def _json(doc, indent: int | None = None) -> bytes: + return dumps(doc, indent=indent, ensure_ascii=False).encode("utf-8") + + +def _page_files(m: _Model) -> dict[str, str]: + """{object id: path} of the atlas pages: textures/., a name two pages share numbered after it.""" + out, used = {}, set() + for oid in m.textures(): + info = m.objects.info(oid) + stem = _UNSAFE.sub("_", info.get("name") or "texture") + ext = (info.get("files") or {}).get("image", {}).get("ext", "png") + path, n = f"textures/{stem}.{ext}", 1 + while path.casefold() in used: + n += 1 + path = f"textures/{stem}-{n}.{ext}" + used.add(path.casefold()) + out[oid] = path + return out + + +def cubism(objects: Objects, key: str) -> dict[str, bytes]: + """{path: bytes} of the Cubism SDK files of the model `key` (module documentation).""" + m = _Model(objects, key) + name = m.name + files = {f"{name}.moc3": m.moc_bytes()} + pages = _page_files(m) + for oid, path in pages.items(): + files[path] = objects.bytes(oid, "image") + refs: dict = {"Moc": f"{name}.moc3", "Textures": list(pages.values())} + controllers = [c for _, c in m.components("CubismPhysicsController", root_only=True)] + rig = controllers[0]["fields"].get("_rig") if controllers else None + if rig and rig.get("SubRigs"): + refs["Physics"] = f"{name}.physics3.json" + files[refs["Physics"]] = _json(physics_json(rig)) + expressions = [] + for oid in m.scripted("CubismExpressionData"): + tt = objects.json(oid) + ename = tt["m_Name"].removesuffix(".exp3") + files[f"{ename}.exp3.json"] = _json(expression_json(tt)) + expressions.append({"Name": ename, "File": f"{ename}.exp3.json"}) + if expressions: + refs["Expressions"] = sorted(expressions, key=lambda e: e["Name"]) + rates = {} + for cid in objects.ids("AnimationClip"): + if "json" in (objects.info(cid).get("files") or {}): + clip = objects.json(cid) + rates[clip["name"]] = float(clip["sampleRate"]) + motions = {} + for oid in m.scripted("CubismFadeMotionData"): + tt = objects.json(oid) + mname = motion.motion_name(tt) + files[f"motions/{mname}.motion3.json"] = _json(motion.convert_fade(tt, rates.get(mname))) + entry = {"File": f"motions/{mname}.motion3.json"} + for k, field in (("FadeInTime", "ModelFadeInTime"), ("FadeOutTime", "ModelFadeOutTime")): + if tt[field] >= 0: + entry[k] = tt[field] + motions[mname] = [entry] + refs["Motions"] = dict(sorted(motions.items())) + groups = [] + for group, cls in (("EyeBlink", "CubismEyeBlinkParameter"), ("LipSync", "CubismMouthParameter")): + ids = sorted(n["name"] for n, _ in m.components(cls)) + if ids: + groups.append({"Target": "Parameter", "Name": group, "Ids": ids}) + files[f"{name}.model3.json"] = _json({"Version": 3, "FileReferences": refs, "Groups": groups}, indent=1) + return files diff --git a/src/nnnotes/formats/cubism.py b/src/nnnotes/formats/cubism.py new file mode 100644 index 0000000..effbcd7 --- /dev/null +++ b/src/nnnotes/formats/cubism.py @@ -0,0 +1,106 @@ +"""Cubism-for-Unity data -> the Cubism SDK interchange files: the canvas of a moc3, physics3.json, exp3.json. + +Cubism-for-Unity keeps a model's physics rig (CubismPhysicsController._rig) and its expressions +(CubismExpressionData) in its own serialized layout; these functions write them in the layout of the Cubism SDK's +JSON files (motion3.json: formats.motion). +""" + +from __future__ import annotations + +import struct + +__all__ = ["EXP_BLEND", "PHYS_TYPE", "expression_json", "moc3_canvas", "physics_json"] + +# Live2D.Cubism.Core.CubismParameterBlendMode -> exp3.json "Blend" +EXP_BLEND = {0: "Overwrite", 1: "Add", 2: "Multiply"} +# Live2D.Cubism.Framework.Physics.CubismPhysicsSourceComponent -> physics3 "Type" +PHYS_TYPE = {0: "X", 1: "Y", 2: "Angle"} + + +def moc3_canvas(moc: bytes) -> dict: + """CanvasInfo from a moc3 (section table at 0x40; entry 1 = canvas info).""" + if moc[:4] != b"MOC3": + raise ValueError("not a moc3") + canvas_off = struct.unpack_from(" dict: + """physics3.json of a CubismPhysicsController's rig (the inverse of Cubism-for-Unity's import, ToRig).""" + settings, dictionary = [], [] + n_in = n_out = n_vtx = 0 + for i, sr in enumerate(rig["SubRigs"]): + sid = f"PhysicsSetting{i + 1}" + dictionary.append({"Id": sid, "Name": sr["Name"]}) + inputs = [ + { + "Source": {"Target": "Parameter", "Id": x["SourceId"]}, + "Weight": x["Weight"], + "Type": PHYS_TYPE[x["SourceComponent"]], + "Reflect": bool(x["IsInverted"]), + } + for x in sr["Input"] + ] + outputs = [ + { + "Destination": {"Target": "Parameter", "Id": x["DestinationId"]}, + "VertexIndex": x["ParticleIndex"], + "Scale": x["AngleScale"], + "Weight": x["Weight"], + "Type": PHYS_TYPE[x["SourceComponent"]], + "Reflect": bool(x["IsInverted"]), + } + for x in sr["Output"] + ] + verts = [ + { + "Position": _vec2(p["InitialPosition"]), + "Mobility": p["Mobility"], + "Delay": p["Delay"], + "Acceleration": p["Acceleration"], + "Radius": p["Radius"], + } + for p in sr["Particles"] + ] + nz = sr["Normalization"] + settings.append( + { + "Id": sid, + "Input": inputs, + "Output": outputs, + "Vertices": verts, + "Normalization": { + "Position": {k: nz["Position"][k] for k in ("Minimum", "Default", "Maximum")}, + "Angle": {k: nz["Angle"][k] for k in ("Minimum", "Default", "Maximum")}, + }, + } + ) + n_in += len(inputs) + n_out += len(outputs) + n_vtx += len(verts) + meta = { + "PhysicsSettingCount": len(settings), + "TotalInputCount": n_in, + "TotalOutputCount": n_out, + "VertexCount": n_vtx, + "EffectiveForces": {"Gravity": _vec2(rig["Gravity"]), "Wind": _vec2(rig["Wind"])}, + "PhysicsDictionary": dictionary, + } + if rig["Fps"] > 0: # 0 = unset in physics3 terms + meta["Fps"] = rig["Fps"] + return {"Version": 3, "Meta": meta, "PhysicsSettings": settings} + + +def expression_json(tt: dict) -> dict: + """exp3.json of a CubismExpressionData's fields.""" + return { + "Type": tt["Type"], + "FadeInTime": tt["FadeInTime"], + "FadeOutTime": tt["FadeOutTime"], + "Parameters": [{"Id": p["Id"], "Value": p["Value"], "Blend": EXP_BLEND[p["Blend"]]} for p in tt["Parameters"]], + } diff --git a/src/nnnotes/formats/motion.py b/src/nnnotes/formats/motion.py index 82f140e..b33888e 100644 --- a/src/nnnotes/formats/motion.py +++ b/src/nnnotes/formats/motion.py @@ -17,8 +17,6 @@ import math from pathlib import Path -from ..core.jsonio import write_json - SEG_LINEAR, SEG_BEZIER, SEG_STEPPED = 0, 1, 2 WEIGHT_IN, WEIGHT_OUT = 1, 2 # UnityEngine.WeightedMode flags @@ -56,14 +54,6 @@ def _segments(kf: list[dict]): return segs, nseg, npts, restricted -def _clip_rates(env) -> dict[str, float]: - return { - o.read_typetree()["m_Name"]: float(o.read_typetree()["m_SampleRate"]) - for o in env.objects - if o.type.name == "AnimationClip" - } - - def convert_fade(fade: dict, fps: float | None) -> dict: curves = [] total_seg = total_pts = 0 @@ -110,30 +100,3 @@ def convert_fade(fade: dict, fps: float | None) -> dict: def motion_name(fade: dict) -> str: return Path(str(fade["MotionName"])).name.removesuffix(".motion3.json") - - -def extract_motions(env, out_dir: Path) -> dict[str, str]: - """Every CubismFadeMotionData in env -> /.motion3.json. - - Returns {motionName: fileName}. Also writes `_fades.json` with the - model-level fade overrides (ModelFadeIn/OutTime, -1 = unset) the model3 - motion entries need. - """ - out_dir = Path(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - rates = _clip_rates(env) - manifest: dict[str, str] = {} - model_fades: dict[str, dict] = {} - for o in env.objects: - if o.type.name != "MonoBehaviour": - continue - if o.read().m_Script.read().m_ClassName != "CubismFadeMotionData": - continue - tt = o.read_typetree() - name = motion_name(tt) - fn = f"{name}.motion3.json" - write_json(out_dir / fn, convert_fade(tt, rates.get(name)), indent=None) - manifest[name] = fn - model_fades[name] = {"FadeInTime": tt["ModelFadeInTime"], "FadeOutTime": tt["ModelFadeOutTime"]} - write_json(out_dir / "_fades.json", model_fades, indent=None, ensure_ascii=True) - return manifest diff --git a/src/nnnotes/formats/shader.py b/src/nnnotes/formats/shader.py index aac32f8..a957473 100644 --- a/src/nnnotes/formats/shader.py +++ b/src/nnnotes/formats/shader.py @@ -29,10 +29,6 @@ import struct from pathlib import Path -import UnityPy -from UnityPy.enums import ShaderGpuProgramType -from UnityPy.export.ShaderConverter import CompressionHelper - from ..core import cache from ..core.jsonio import dumps, write_json @@ -59,6 +55,8 @@ } TEXT_TYPES = {"GLES", "GLES3", "GLES31", "GLES31AEP", "GLCore32", "GLCore41", "GLCore43", "MetalVS", "MetalFS"} STAGES = ("progVertex", "progFragment", "progGeometry", "progHull", "progDomain") +# the programs the player draws with: GLSL ES 3.00 (WebGL2) +PLAYER_PLATFORM, PLAYER_TYPE = "gles3", "GLES3" def _safe(name: str) -> str: @@ -71,6 +69,8 @@ def _entry(arr, i): def _type_name(v: int) -> str: + from UnityPy.enums import ShaderGpuProgramType + return ShaderGpuProgramType(v).name.replace("kShaderGpuProgram", "") @@ -97,6 +97,8 @@ def _code_of(entry: bytes) -> bytes: def platform_blobs(shader) -> dict[int, list[bytes]]: + from UnityPy.export.ShaderConverter import CompressionHelper + blob = bytes(shader.compressedBlob or b"") out = {} n = min(len(shader.platforms), len(shader.compressedLengths), len(shader.decompressedLengths)) @@ -181,6 +183,22 @@ def _render(o, tt: dict, name: str) -> tuple[str, list[dict], list[bytes]]: return text, recs, codes +def player_program(variants: list[dict], keywords) -> dict: + """The variant record of a shader (platform, subShader, pass, type, keywords, ...) the player draws a material + with `keywords` by: the PLAYER_TYPE program of subshader 0, pass 0 whose keywords are exactly the material's + among those the programs there use. ValueError when not exactly one fits.""" + vs = [ + v + for v in variants + if v["platform"] == PLAYER_PLATFORM and v["type"] == PLAYER_TYPE and v["subShader"] == 0 and v["pass"] == 0 + ] + want = set(keywords) & {k for v in vs for k in v["keywords"]} + hits = [v for v in vs if set(v["keywords"]) == want] + if len(hits) != 1: + raise ValueError(f"{len(hits)} {PLAYER_TYPE} programs with the keywords {sorted(want)}") + return hits[0] + + def _dump_key(o) -> str: st = o.serialized_type type_hash = bytes(st.old_type_hash) if st is not None and st.old_type_hash else b"" @@ -239,6 +257,8 @@ def write_index(index: list, out_dir: Path) -> dict: def dump(bundles: list, out_dir: Path) -> dict: + import UnityPy + index: list = [] for bpath in bundles: env = UnityPy.load(str(bpath)) diff --git a/src/nnnotes/pipeline/assemble.py b/src/nnnotes/pipeline/assemble.py index a1ab4e7..f2fa27e 100644 --- a/src/nnnotes/pipeline/assemble.py +++ b/src/nnnotes/pipeline/assemble.py @@ -258,6 +258,10 @@ def __setstate__(self, state): def subjects(self, env) -> list[str]: return sorted((env.facts.get("products") or {}).get(self.name, ())) + def select(self, keys: list[str]) -> list[str]: + """The subjects among the catalog keys `keys` (those a selection's bundles hold), sorted.""" + return [] + def keys(self, subject: str, env) -> list[str]: """The catalog keys whose objects are roots of `subject` (from the facts alone).""" return [] diff --git a/src/nnnotes/pipeline/layout.py b/src/nnnotes/pipeline/layout.py index b20eb4d..705df28 100644 --- a/src/nnnotes/pipeline/layout.py +++ b/src/nnnotes/pipeline/layout.py @@ -30,6 +30,8 @@ `product` (product_entries): the products of pipeline.assemble. Every file of a product and of the objects of its object table is at `assets/.`; each product has a manifest (nnnotes.product/1, product_manifest) at `/.json` (ProductStage.manifest), whose asset paths are relative to the directory above its own. +`files` (product_files): the files of one product at their own paths (a model's Cubism SDK files as a directory); a +path two products give is an error. So every path ends with its artifact's extension, and `cas` follows from the `original` manifest alone; `original` follows from `cas` plus the results (the records). Documents a stage derives for `original` from its records and the paths (the views with the files of their objects) are the exception: `original` has the derived documents, @@ -65,7 +67,7 @@ MANIFEST = "manifest.json" JOURNAL = ".nnnotes-layout-journal.json" -LAYOUTS = ("original", "cas") +LAYOUTS = ("original", "cas", "product", "files") PRODUCT_TABLE = "objects" # the role of a product's object table (nnnotes.objects/1) among its artifacts PLACEMENTS = ("clone", "hard", "copy") # how a file is made from its byte source LINKS = ("auto", *PLACEMENTS) # what a run may ask for: auto probes @@ -410,13 +412,28 @@ def product_entries(products) -> list[dict]: return [out[k] for k in sorted(out)] +def product_files(results) -> list[dict]: + """The entries of the `files` layout: the files of the product results `results` at their own paths.""" + out: dict[str, dict] = {} + for result in results: + for a in result["artifacts"]: + path = contract.parse_artifact_id(a["id"])[1] + if path == PRODUCT_TABLE: + continue + e = _entry(escape_path(path.split("/")), a) + seen = out.setdefault(e["path"].casefold(), e) + if seen["id"] != e["id"]: + raise LayoutError(f"{e['path']}: a file of {seen['id']} and of {e['id']}") + return sorted(out.values(), key=lambda e: (e["path"], e["id"])) + + def entries(name: str, sources, **kw) -> tuple[list[dict], dict]: - """The entries and report of layout `name`.""" + """The entries and report of layout `original` or `cas`.""" if name == "cas": return cas_entries(sources, kw.get("extra", ())), {"collisions": [], "shared": []} if name == "original": return original_entries(sources, **kw) - raise ValueError(f"unknown layout {name!r} (one of {', '.join(LAYOUTS)})") + raise ValueError(f"layout {name!r}: entries() places original or cas") def paths(entries_: list[dict]) -> dict[str, str]: diff --git a/src/nnnotes/pipeline/live2d.py b/src/nnnotes/pipeline/live2d.py new file mode 100644 index 0000000..fc4c198 --- /dev/null +++ b/src/nnnotes/pipeline/live2d.py @@ -0,0 +1,87 @@ +"""The Live2D products: a model for the player (live2d.model) and its Cubism SDK files (live2d.cubism). + +Both are product stages (pipeline.assemble) whose subjects are model keys (Character/Live2D///model/ +, the fact "products") and whose roots are the objects of the key; the player's model also starts from the +materials the Cubism mask pass loads by Resources.Load path (content.live2d.RESOURCES), which the APK's built-in +files hold. They assemble with content.live2d: + +live2d.model "#model.json" (kind live2d.model: content.live2d.model) and the object table of what the + player reads; its manifest is models/.json +live2d.cubism "#" for every Cubism SDK file (content.live2d.cubism; kinds by file: CUBISM_KINDS) and + no objects; its manifest is cubism/.json, and its files are the model's directory +""" + +from __future__ import annotations + +from ..content import live2d +from . import contract +from .assemble import Assembly, ProductStage + +__all__ = ["CUBISM", "CUBISM_KINDS", "MODEL", "CubismStage", "ModelStage", "cubism_kind"] + +MODEL, CUBISM = "live2d.model", "live2d.cubism" +# Cubism SDK files -> artifact kinds (by suffix, the first that matches) +CUBISM_KINDS = ( + (".model3.json", "live2d.model3"), + (".physics3.json", "live2d.physics3"), + (".exp3.json", "live2d.expression"), + (".motion3.json", "live2d.motion"), + (".moc3", "live2d.moc3"), + ("textures/", "live2d.texture"), +) + + +def cubism_kind(path: str) -> str: + for suffix, kind in CUBISM_KINDS: + if path.endswith(suffix) or path.startswith(suffix): + return kind + raise ValueError(f"{path}: not a Cubism SDK file") + + +class ModelStage(ProductStage): + """live2d.model: a model key -> model.json and the object table the player reads (module documentation).""" + + name = MODEL + version = 2 + KIND = "Live2DModel" + + def select(self, keys: list[str]) -> list[str]: + return sorted(live2d.model_keys(keys).values()) + + def keys(self, subject: str, env) -> list[str]: + live2d.model_id(subject) + return [subject] + + def resources(self, subject: str, env) -> list[str]: + return sorted(live2d.RESOURCES.values()) + + def manifest(self, subject: str) -> str: + return f"models/{live2d.model_id(subject)}.json" + + def assemble(self, task, objects) -> Assembly: + doc, keep, roles = live2d.model(objects, task.subject) + return Assembly({"model.json": (contract.encode(doc), "json", "live2d.model")}, keep, roles) + + +class CubismStage(ProductStage): + """live2d.cubism: a model key -> its Cubism SDK files (module documentation).""" + + name = CUBISM + version = 1 + KIND = "CubismModel" + + def select(self, keys: list[str]) -> list[str]: + return sorted(live2d.model_keys(keys).values()) + + def keys(self, subject: str, env) -> list[str]: + live2d.model_id(subject) + return [subject] + + def manifest(self, subject: str) -> str: + return f"cubism/{live2d.model_id(subject)}.json" + + def assemble(self, task, objects) -> Assembly: + files = {} + for path, data in sorted(live2d.cubism(objects, task.subject).items()): + files[path] = (data, path.rsplit(".", 1)[-1], cubism_kind(path)) + return Assembly(files, set()) diff --git a/src/nnnotes/pipeline/modelstages.py b/src/nnnotes/pipeline/modelstages.py index 290feda..ca4c19a 100644 --- a/src/nnnotes/pipeline/modelstages.py +++ b/src/nnnotes/pipeline/modelstages.py @@ -1,17 +1,5 @@ -"""The model stages: Live2D models and Spine skeletons, written by the extractors of `nnnotes export live2d` and -`nnnotes export spot` (unchanged) from the bundles of their tasks. - -live2d.model, one task per Live2D model key (Character/Live2D///model/, the keys `nnnotes export -live2d` takes) whose own bundle (the first dependency of the key's location) is selected: - inputs "bundle:": the bundles of the key's dependency closure in the order the catalog gives them - (Catalog.resolve; n is the position: the extractor loads them in this order, so the order is part of - the key) - context {"internalId": the internal id of the key's location} (the container path the model is read from) - artifacts "live2d.model:#": every file live2d.extract_model writes, relative to the model's - directory (.moc3, .prefab.json, textures/*.png, .model3.json, *.exp3.json, - .physics3.json, motions/*.motion3.json, motions/_fades.json), with its bytes -The extractor runs through TaskCatalog, which answers what it asks of a catalog from the task. It reads nothing of -the APK but the APK's bundles of the closure, which are inputs like the others. +"""The Spine skeleton stage, written by the extractor of `nnnotes export spot` (unchanged) from the bundles of its +tasks. spine.skeleton, one task per SkeletonDataAsset (a MonoBehaviour whose script the script table names SkeletonDataAsset) of a selected bundle; its subject is the asset's stable address by path id, @@ -24,46 +12,28 @@ the atlas pages (PNG), as spot.write_skeleton writes them for `nnnotes export spot` item the asset: exported (its artifacts), or failed with the writer's error -Both tasks name their extractor as their one atom ("live2d.extract_model", "spot.write_skeleton"), whose id carries -the versions of the libraries the files depend on; this module imports UnityPy only when a task runs. +The task names its extractor as its one atom ("spot.write_skeleton"), whose id carries the versions of the libraries +the files depend on; this module imports UnityPy only when a task runs. """ from __future__ import annotations import re -import tempfile -from pathlib import Path -from ..source.catalog import Bundle from ..unity.atoms import impl_id -from . import catalogdb, contract, link +from . import contract, link from .contract import Cost, IncompatibleTask, Input from .stages import Output, Pending, Stage -LIVE2D = "live2d.model" SPINE = "spine.skeleton" CENSUS = "unity.census" SCRIPTS = "link.scripts" SCRIPTS_TASK = contract.task_id(SCRIPTS, "all") -# the model keys of `nnnotes export live2d` (webmodel.MODEL_KEY; webmodel imports UnityPy) -MODEL_KEY = re.compile(r"Character/Live2D/(?P[^/]+)/(?P[^/]+)/model/(?P=name)") SKELETON_CLASS = "SkeletonDataAsset" -# the libraries the extractors' output depends on (export._LIBS, the salt of export's PNG cache) +# the libraries the extractor's output depends on (export._LIBS, the salt of export's PNG cache) LIBRARIES = ("UnityPy", "Pillow", "texture2ddecoder", "astc-encoder-py", "etcpak", "numpy") -_ASSET_PREFIXES = ("Assets/", "Packages/") _ADDRESS = re.compile(r"0x[0-9a-fA-F]{6,}") _EXT = re.compile(r"[0-9a-z]+") -# live2d.extract_model files -> artifact kinds (by suffix, the first that matches) -LIVE2D_KINDS = ( - (".model3.json", "live2d.model3"), - (".prefab.json", "live2d.prefab"), - (".physics3.json", "live2d.physics3"), - (".exp3.json", "live2d.expression"), - (".motion3.json", "live2d.motion"), - ("motions/_fades.json", "live2d.fades"), - (".moc3", "live2d.moc3"), - ("textures/", "live2d.texture"), -) def _message(e: BaseException) -> str: @@ -94,203 +64,6 @@ def _fact_input(bundles, stable: str, what: str) -> Input: return inp -def _ordered(task, prefix: str = "bundle:") -> list[Input]: - """The inputs "bundle:" of a task by n.""" - ins = [i for i in task.inputs if i.role.startswith(prefix)] - return sorted(ins, key=lambda i: int(i.role[len(prefix) :])) - - -# ---------------------------------------------------------------- the catalog of a task -class Closures: - """Key lookup and dependency closures of a catalog index, as Catalog.entry and Catalog.resolve give them on the - catalogs the index was made of: a key's location is its first one inside a bundle (internal id under Assets/ or - Packages/), else its first one (remote catalog before APK catalog, each by offset); its closure is every bundle - location its dependencies reach, depth first, in the order Catalog.resolve lists them.""" - - def __init__(self, index: dict): - self.index = index - self.locations = catalogdb.by_id(index) - self.files = catalogdb.file_refs(index) - self.by_key: dict[str, list[dict]] = {} - for loc in index["locations"]: - self.by_key.setdefault(loc["primaryKey"], []).append(loc) - - def keys(self) -> list[str]: - return sorted(self.by_key) - - def entry(self, key: str) -> dict: - locs = self.by_key.get(key) - if not locs: - raise KeyError(key) - return next((loc for loc in locs if loc["internalId"].startswith(_ASSET_PREFIXES)), locs[0]) - - def own_bundle(self, key: str) -> str | None: - """The stable name of the bundle a key's location names first (the bundle holding it), else None.""" - deps = self.entry(key)["dependencies"] - ref = self.files.get(deps[0]) if deps else None - return ref[1] if ref is not None and ref[0] == "bundle" else None - - def closure(self, key: str) -> list[str]: - """The location ids of the bundles of a key's closure, in Catalog.resolve order.""" - seen: set[str] = set() - out: dict[str, None] = {} - stack = [self.entry(key)["id"]] - while stack: - lid = stack.pop() - if lid in seen: - continue - seen.add(lid) - loc = self.locations.get(lid) - if loc is None: - continue - if loc["kind"] == "bundle": - out.setdefault(lid, None) - for d in loc["dependencies"]: - if d not in seen: - stack.append(d) - return list(out) - - def closure_bundles(self, key: str) -> list[str]: - """The stable names of the bundles of a key's closure, in Catalog.resolve order (a file two locations name - is listed twice, as Catalog.fetch_key gives it twice).""" - return [self.files[lid][1] for lid in self.closure(key)] - - -# the value of TaskCatalog.apk: not None, the APK's bundles of the closure are inputs of the task -APK_FROM_INPUTS = "" - - -class TaskCatalog: - """What the model extractors ask of a Catalog (live2d.extract_model through export.Exporter: fetch_key, entry, - apk), answered from a task: its key, the internal id of the key's location and the bundles of the key's closure - in load order, each bundle read through the depot (Depot.open_input: its locators, checked against its content - id). Another key is not in it (KeyError). `apk` is not None: the extractors need the APK's bundles, and those of - the closure are inputs of the task like the others.""" - - def __init__(self, key: str, internal_id: str, bundles: list[Input], depot): - self.key, self.internal_id, self.bundles, self.depot = key, internal_id, list(bundles), depot - self.apk = APK_FROM_INPUTS - - @classmethod - def from_task(cls, task, depot) -> TaskCatalog: - """The catalog of a live2d.model task: its subject, context internalId and inputs "bundle:".""" - return cls(task.subject, task.context["internalId"], _ordered(task), depot) - - def _check(self, key: str) -> None: - if key != self.key: - raise KeyError(key) - - def has(self, key: str) -> bool: - return key == self.key - - def keys(self, prefix: str = "") -> list[str]: - return [self.key] if self.key.startswith(prefix) else [] - - def entry(self, key: str) -> dict: - self._check(key) - return {"primary_key": key, "internal_id": self.internal_id} - - def entries_for(self, key: str) -> list[dict]: - return [self.entry(key)] if key == self.key else [] - - def resolve(self, key: str) -> list[Bundle]: - """The closure as Bundles in load order: offset = the position, name = the input's file name (its content - id when it has none); internal_id is empty and remote True (a task has a file's content, not its place in - a catalog; every bundle is read from the inputs).""" - self._check(key) - return [Bundle(i, "", inp.name or inp.sha256, True) for i, inp in enumerate(self.bundles)] - - def fetch_key(self, key: str) -> list[Path]: - """The files of the closure's bundles, in load order.""" - self._check(key) - return [self.depot.open_input(inp) for inp in self.bundles] - - -# ---------------------------------------------------------------- live2d.model -def live2d_kind(path: str) -> str: - """The artifact kind of a file live2d.extract_model writes (LIVE2D_KINDS; else "live2d.file").""" - for suffix, kind in LIVE2D_KINDS: - if path.endswith(suffix) or (suffix.endswith("/") and path.startswith(suffix)): - return kind - return "live2d.file" - - -def _tree(root: Path) -> list[tuple[str, Path]]: - """(relative POSIX path, file) of every file under `root`, sorted by path.""" - return sorted((p.relative_to(root).as_posix(), p) for p in root.rglob("*") if p.is_file()) - - -class Live2DStage(Stage): - """live2d.model: a Live2D model key -> the files of `nnnotes export live2d` (module documentation). Subjects and - inputs come from the facts "index" (the catalog index), "selected" and "bundles" ({stable name: Input "bundle"}).""" - - name = LIVE2D - version = 1 - ATOMS = {"live2d.extract_model": impl_id(LIBRARIES, 1)} - LAYOUT_ROOT = "{subject}" - - def __init__(self): - self._closures: Closures | None = None - - def __getstate__(self): - return {} - - def __setstate__(self, state): - self.__init__() - - def closures(self, env) -> Closures: - index = env.fact("index") - if self._closures is None or self._closures.index is not index: - self._closures = Closures(index) - return self._closures - - def subjects(self, env) -> list[str]: - c, selected = self.closures(env), set(env.fact("selected")) - return [k for k in c.keys() if MODEL_KEY.fullmatch(k) and c.own_bundle(k) in selected] - - def inputs(self, subject: str, env) -> list[Input]: - bundles, what = env.fact("bundles"), f"{self.name} {subject}" - names = self.closures(env).closure_bundles(subject) - width = max(3, len(str(len(names) - 1))) - out = [] - for i, stable in enumerate(names): - inp = _fact_input(bundles, stable, what) - out.append(Input(f"bundle:{i:0{width}d}", inp.sha256, inp.size, inp.name, inp.locators)) - return out - - def context(self, subject: str, env) -> dict: - return {"internalId": self.closures(env).entry(subject)["internalId"]} - - def estimate(self, subject: str, env, inputs: list[Input]) -> Cost: - """From the closure's bytes (least squares over the models of a catalog: CPU seconds; peak resident bytes - of a worker process).""" - size = sum(i.size for i in inputs) - return Cost(0.9 + size * 2.2e-7, (420 << 20) + 10 * size) - - def run(self, task, depot) -> Output: - _check_atoms(task, self.ATOMS) - from ..unity import live2d - - cat = TaskCatalog.from_task(task, depot) - arts = [] - with tempfile.TemporaryDirectory(prefix="nnnotes-live2d-") as d: - # TaskCatalog answers the catalog calls of the model extractor - summary = live2d.extract_model(cat, task.subject, Path(d)) # pyright: ignore[reportArgumentType] - for rel, p in _tree(Path(d)): - kind = live2d_kind(rel) - facts = {k: summary[k] for k in sorted(summary)} if kind == "live2d.model3" else {} - ext = _ext(rel) - arts.append( - contract.artifact( - contract.artifact_id(task.id, rel), - depot.add(p.read_bytes(), ext), - contract.provenance(task), - {"kind": kind, "format": ext, "facts": facts}, - ) - ) - return Output(arts, []) - - # ---------------------------------------------------------------- spine.skeleton def skeleton_index(env, selected) -> dict: """What spine.skeleton plans from, read from the census results and the script table: {"subjects": {subject: @@ -407,7 +180,7 @@ def depends(self, subject: str, env) -> list[str]: return sorted({*(contract.task_id(CENSUS, s) for s in self.closure(subject, env)), SCRIPTS_TASK}) def estimate(self, subject: str, env, inputs: list[Input]) -> Cost: - """From the closure's bytes (least squares over the skeletons of a catalog, as Live2DStage.estimate).""" + """From the closure's bytes (least squares over the skeletons of a catalog).""" size = sum(i.size for i in inputs) return Cost(0.27 + size * 1.9e-7, (440 << 20) + 20 * size) diff --git a/src/nnnotes/site/sitefiles.py b/src/nnnotes/site/sitefiles.py index aded184..d8398ed 100644 --- a/src/nnnotes/site/sitefiles.py +++ b/src/nnnotes/site/sitefiles.py @@ -26,6 +26,8 @@ from pathlib import Path from ..core import cache, jsonio +from ..formats.shader import PLAYER_PLATFORM as SHADER_PLATFORM +from ..formats.shader import PLAYER_TYPE as SHADER_TYPE from ..source.config import Config, ConfigError from . import compress from .compress import DEFAULT_ENCODING @@ -73,8 +75,6 @@ ".skel", ".bin", } # .glb / .atlas / .skel: an Overlay story's home spot; .bin: a story's CRI Lips data (crilips.py) -SHADER_PLATFORM = "gles3" -SHADER_TYPE = "GLES3" CHARTS_INDEX = "charts.json" # documentation fields of the extractors the player does not read (live-audio.json `spec`, the voice rule notes, # scene.json `slice` / `derived` notes): left out of the site's copies diff --git a/src/nnnotes/site/storysite.py b/src/nnnotes/site/storysite.py index e166319..a3fb2f2 100644 --- a/src/nnnotes/site/storysite.py +++ b/src/nnnotes/site/storysite.py @@ -49,7 +49,7 @@ import uuid from pathlib import Path -from ..content import adv, advui, story, storyfonts, storyhost +from ..content import adv, advui, live2d, story, storyfonts, storyhost from ..core import jsonio from ..source import languages, master from ..source.config import Config, ConfigError, usable_cpus, use @@ -802,7 +802,7 @@ def build( if keys: model_build = webmodel.build( site, - webmodel.model_keys(keys), + live2d.model_keys(keys), cfg, player_dir, force, diff --git a/src/nnnotes/site/webmodel.py b/src/nnnotes/site/webmodel.py index ee2a6f8..8cb37a8 100644 --- a/src/nnnotes/site/webmodel.py +++ b/src/nnnotes/site/webmodel.py @@ -47,13 +47,23 @@ import json import multiprocessing as mp import os -import re import shutil import tempfile import time import traceback from pathlib import Path +from ..content.live2d import ( + LIVE2D_PREFIX, + MASK_KEYWORD, + MOTION_SYNC_COMPONENTS, + RESOURCES, + STORY_KEYWORDS, + model_id, + model_keys, + model_match, + select_models, +) from ..core import jsonio from ..formats import shader as shader_mod from ..source import languages, master @@ -63,8 +73,6 @@ from .sitefiles import ( MANIFEST_FORMAT, MODELS_DIR, - SHADER_PLATFORM, - SHADER_TYPE, Store, _dump, _lock_fetches, @@ -78,80 +86,19 @@ ) from .siteindex import write_index -__all__ = ["catalog_models", "model_id", "model_keys"] +__all__ = ["catalog_models"] -LIVE2D_PREFIX = "Character/Live2D/" -MODEL_KEY = re.compile(r"Character/Live2D/(?P[^/]+)/(?P[^/]+)/model/(?P=name)") -MODEL_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*") MODEL_INDEX = "model.json" MODEL_FORMAT = 2 # model.json "format" SHADER_DIR = "shaders" -MASK_KEYWORD = "CUBISM_MASK_ON" # a drawable material's keyword: the drawable is masked -# keywords the story renderer adds to a character draw (_ADDITIONAL_LIGHTS_VERTEX: at quality 4); the mask pass draws -# with none -STORY_KEYWORDS = ("_ADDITIONAL_LIGHTS_VERTEX",) -# Resources.Load paths of the materials the Cubism mask pass draws with -RESOURCES = { - "cubismMask": "Live2D/Cubism/Materials/Mask", - "cubismMaskCulling": "Live2D/Cubism/Materials/MaskCulling", -} -# a model's MotionSync path (the player's CubismMotionSyncController.fromPrefab): both components on the prefab's root -MOTION_SYNC_COMPONENTS = ("CubismMotionSyncController", "Live2DMotionSyncCriAudioInput") COSTUME_TABLE = "MasterCharacterCostume" # _live2dPath (the key after LIVE2D_PREFIX) -> _characterID NAME_FIELDS = ("character", "names", "label") # listing fields from the master data (model_names) # ---------------------------------------------------------------- models and ids -def _model_key(key: str) -> re.Match[str]: - m = MODEL_KEY.fullmatch(key) - if not m: - raise ValueError(f"{key}: not a Live2D model key (Character/Live2D///model/)") - return m - - -def model_id(key: str) -> str: - """The id of a model key: its .""" - m = _model_key(key) - if not MODEL_ID.fullmatch(m["name"]): - raise ValueError(f"{key}: model name {m['name']!r} is not URL-safe") - return m["name"] - - -def model_keys(keys) -> dict[str, str]: - """{id: key} of the model keys among `keys`, in id order; two keys with the same id raise.""" - out: dict[str, str] = {} - for k in keys: - if MODEL_KEY.fullmatch(k): - i = model_id(k) - if out.get(i, k) != k: - raise ValueError(f"model id {i}: keys {out[i]} and {k}") - out[i] = k - return dict(sorted(out.items())) - - -def select(specs, models: dict[str, str]) -> dict[str, str]: - """The models `specs` names (ids or keys; None: all) as {id: key} in id order, from `models` ({id: key}).""" - if specs is None: - return dict(models) - by_key = {k: i for i, k in models.items()} - out, unknown = {}, [] - for s in specs: - i = s if s in models else by_key.get(s) - if i is None: - unknown.append(s) - else: - out[i] = models[i] - if unknown: - raise ValueError( - f"not a Live2D model of the catalog: {', '.join(unknown)} " - f"(give a model id or a key Character/Live2D///model/)" - ) - return dict(sorted(out.items())) - - def catalog_models(cat, specs=None) -> dict[str, str]: - """`select` over the model keys of the catalog `cat`.""" - return select(specs, model_keys(cat.keys(LIVE2D_PREFIX))) + """content.live2d.select_models over the model keys of the catalog `cat`.""" + return select_models(specs, model_keys(cat.keys(LIVE2D_PREFIX))) # ---------------------------------------------------------------- names from the master data @@ -275,16 +222,11 @@ def shader_files(index: list, materials: list[dict], extra: tuple[str, ...] = () for name, kws in sorted(sets): if name not in recs: raise RuntimeError(f"shader {name}: not in the shader index") - variants = [ - v - for v in recs[name]["variants"] - if v["platform"] == SHADER_PLATFORM and v["type"] == SHADER_TYPE and v["subShader"] == 0 and v["pass"] == 0 - ] - want = set(kws) & {k for v in variants for k in v["keywords"]} - hits = [v for v in variants if set(v["keywords"]) == want] - if len(hits) != 1: - raise RuntimeError(f"shader {name}: {len(hits)} GLES3 programs with the keywords {sorted(want)}") - files |= {recs[name]["parsed"], hits[0]["file"]} + try: + program = shader_mod.player_program(recs[name]["variants"], kws) + except ValueError as e: + raise RuntimeError(f"shader {name}: {e}") from None + files |= {recs[name]["parsed"], program["file"]} return sorted(files) @@ -354,7 +296,7 @@ def model_facts(key: str, summary: dict, names: dict | None = None) -> dict: """What a model listing needs, from the key and the export summary, plus the NAME_FIELDS of `names` (an entry of model_names).""" facts = { - "group": _model_key(key)["group"], + "group": model_match(key)["group"], "canvas": summary["canvas"], "textures": len(summary["textures"]), "nodes": summary["nodes"], diff --git a/src/nnnotes/unity/bindings.py b/src/nnnotes/unity/bindings.py index 7c8cc5d..b4ddb51 100644 --- a/src/nnnotes/unity/bindings.py +++ b/src/nnnotes/unity/bindings.py @@ -24,8 +24,6 @@ import zlib from collections.abc import Callable, Iterable -from UnityPy.enums import ClassIDType - __all__ = [ "ENGINE_PROPS", "MATERIAL_PROPS", @@ -150,6 +148,8 @@ def material_attribute(attribute: int, kinds: dict[str, str]) -> str | None: def _class_name(type_id: int) -> str | None: + from UnityPy.enums import ClassIDType + try: return ClassIDType(type_id).name except ValueError: diff --git a/src/nnnotes/unity/live2d.py b/src/nnnotes/unity/live2d.py index 1cecd2d..e426c65 100644 --- a/src/nnnotes/unity/live2d.py +++ b/src/nnnotes/unity/live2d.py @@ -16,130 +16,26 @@ expression list and every AnimationClip (Mecanim clip data + resolved bindings) inlined; plus `canvas` (moc3) -`extract_model` adds the Cubism SDK interchange files for other tools: - - .exp3.json CubismExpressionData - .physics3.json CubismPhysicsController._rig (inverse of ToRig), when the - model has physics - motions/.motion3.json CubismFadeMotionData (see motion.py) - .model3.json file refs + EyeBlink/LipSync groups from the - model's own CubismEyeBlinkParameter/CubismMouthParameter - -model3.json lists the atlas pages sorted by name (the Cubism Editor's -texture_NN naming). The texture each drawable is actually drawn with is its -CubismRenderer._mainTexture in prefab.json, keyed by CubismDrawable._unmanagedIndex -(the Cubism Core drawable index). +The texture each drawable is drawn with is its CubismRenderer._mainTexture in prefab.json, keyed by +CubismDrawable._unmanagedIndex (the Cubism Core drawable index). """ from __future__ import annotations -import json -import struct from dataclasses import dataclass from pathlib import Path from ..core.jsonio import write_json -from ..formats import motion as motion_mod +from ..formats.cubism import moc3_canvas from ..source.catalog import Catalog from ..source.config import apk_missing from .export import Exporter from .reader import SceneGraph, script_class -# Live2D.Cubism.Core.CubismParameterBlendMode -> exp3.json "Blend" -EXP_BLEND = {0: "Overwrite", 1: "Add", 2: "Multiply"} -# Live2D.Cubism.Framework.Physics.CubismPhysicsSourceComponent -> physics3 "Type" -PHYS_TYPE = {0: "X", 1: "Y", 2: "Angle"} # the moc is written as .moc3; prefab.json names it only STUB_ASSETS = ("CubismMoc",) -def moc3_canvas(moc: bytes) -> dict: - """CanvasInfo from a moc3 (section table at 0x40; entry 1 = canvas info).""" - if moc[:4] != b"MOC3": - raise ValueError("not a moc3") - canvas_off = struct.unpack_from(" dict: - settings, dictionary = [], [] - n_in = n_out = n_vtx = 0 - for i, sr in enumerate(rig["SubRigs"]): - sid = f"PhysicsSetting{i + 1}" - dictionary.append({"Id": sid, "Name": sr["Name"]}) - inputs = [ - { - "Source": {"Target": "Parameter", "Id": x["SourceId"]}, - "Weight": x["Weight"], - "Type": PHYS_TYPE[x["SourceComponent"]], - "Reflect": bool(x["IsInverted"]), - } - for x in sr["Input"] - ] - outputs = [ - { - "Destination": {"Target": "Parameter", "Id": x["DestinationId"]}, - "VertexIndex": x["ParticleIndex"], - "Scale": x["AngleScale"], - "Weight": x["Weight"], - "Type": PHYS_TYPE[x["SourceComponent"]], - "Reflect": bool(x["IsInverted"]), - } - for x in sr["Output"] - ] - verts = [ - { - "Position": _vec2(p["InitialPosition"]), - "Mobility": p["Mobility"], - "Delay": p["Delay"], - "Acceleration": p["Acceleration"], - "Radius": p["Radius"], - } - for p in sr["Particles"] - ] - nz = sr["Normalization"] - settings.append( - { - "Id": sid, - "Input": inputs, - "Output": outputs, - "Vertices": verts, - "Normalization": { - "Position": {k: nz["Position"][k] for k in ("Minimum", "Default", "Maximum")}, - "Angle": {k: nz["Angle"][k] for k in ("Minimum", "Default", "Maximum")}, - }, - } - ) - n_in += len(inputs) - n_out += len(outputs) - n_vtx += len(verts) - meta = { - "PhysicsSettingCount": len(settings), - "TotalInputCount": n_in, - "TotalOutputCount": n_out, - "VertexCount": n_vtx, - "EffectiveForces": {"Gravity": _vec2(rig["Gravity"]), "Wind": _vec2(rig["Wind"])}, - "PhysicsDictionary": dictionary, - } - if rig["Fps"] > 0: # 0 = unset in physics3 terms - meta["Fps"] = rig["Fps"] - return {"Version": 3, "Meta": meta, "PhysicsSettings": settings} - - -def expression_json(tt: dict) -> dict: - return { - "Type": tt["Type"], - "FadeInTime": tt["FadeInTime"], - "FadeOutTime": tt["FadeOutTime"], - "Parameters": [{"Id": p["Id"], "Value": p["Value"], "Blend": EXP_BLEND[p["Blend"]]} for p in tt["Parameters"]], - } - - def _classes(env) -> dict[str | None, list]: by_cls: dict[str | None, list] = {} for o in env.objects: @@ -201,74 +97,3 @@ def extract_runtime(cat: Catalog, key: str, out_dir: Path) -> dict: """Write the runtime files of the model `key` into `out_dir`; returns the summary: name, moc3, prefab, moc3Bytes, textures, nodes.""" return export_runtime(cat, key, out_dir).summary - - -def extract_model(cat: Catalog, key: str, out_dir: Path) -> dict: - out_dir = Path(out_dir) - rt = export_runtime(cat, key, out_dir) - res, env, graph, by_cls = rt.summary, rt.env, rt.graph, rt.classes - name = res["name"] - (out_dir / "motions").mkdir(parents=True, exist_ok=True) - - def owner_names(cls): - return sorted(graph.go[o.read_typetree()["m_GameObject"]["m_PathID"]]["m_Name"] for o in by_cls.get(cls, [])) - - # expressions - exp_refs = [] - for o in by_cls.get("CubismExpressionData", []): - tt = o.read_typetree() - ename = tt["m_Name"].removesuffix(".exp3") - fn = f"{ename}.exp3.json" - write_json(out_dir / fn, expression_json(tt), indent=None) - exp_refs.append({"Name": ename, "File": fn}) - exp_refs.sort(key=lambda e: e["Name"]) - - # physics (none without a CubismPhysicsController) - rig = ( - _only(by_cls, "CubismPhysicsController", name)["_rig"] - if by_cls.get("CubismPhysicsController") - else {"SubRigs": []} - ) - phys_file = None - if rig["SubRigs"]: - phys_file = f"{name}.physics3.json" - write_json(out_dir / phys_file, physics_json(rig, []), indent=None) - - # motions - motions = motion_mod.extract_motions(env, out_dir / "motions") - model_fades = json.loads((out_dir / "motions" / "_fades.json").read_text(encoding="utf-8")) - - # model3.json - fr = {"Moc": res["moc3"], "Textures": res["textures"]} - if phys_file: - fr["Physics"] = phys_file - if exp_refs: - fr["Expressions"] = exp_refs - - def motion_entry(m, f): - e = {"File": f"motions/{f}"} - for k in ("FadeInTime", "FadeOutTime"): - if model_fades[m][k] >= 0: - e[k] = model_fades[m][k] - return [e] - - fr["Motions"] = {m: motion_entry(m, f) for m, f in sorted(motions.items())} - groups = [] - eye_ids = owner_names("CubismEyeBlinkParameter") - mouth_ids = owner_names("CubismMouthParameter") - if eye_ids: - groups.append({"Target": "Parameter", "Name": "EyeBlink", "Ids": eye_ids}) - if mouth_ids: - groups.append({"Target": "Parameter", "Name": "LipSync", "Ids": mouth_ids}) - model3 = {"Version": 3, "FileReferences": fr, "Groups": groups} - write_json(out_dir / f"{name}.model3.json", model3) - - return { - **res, - "model3": f"{name}.model3.json", - "expressions": len(exp_refs), - "motions": len(motions), - "physicsSettings": len(rig["SubRigs"]), - "eyeBlink": eye_ids, - "lipSync": mouth_ids, - } diff --git a/tests/cli/test_assets.py b/tests/cli/test_assets.py index e52104d..1b4f66b 100644 --- a/tests/cli/test_assets.py +++ b/tests/cli/test_assets.py @@ -16,7 +16,7 @@ from nnnotes import cli from nnnotes.cli import assets, common from nnnotes.cli import catalog as catalog_cli -from nnnotes.pipeline import contract, link, units +from nnnotes.pipeline import assemble, catalogdb, contract, link, units from nnnotes.pipeline.census import CensusStage from nnnotes.pipeline.contract import Cost from nnnotes.pipeline.depot import Depot @@ -1428,3 +1428,99 @@ def test_worker_counts_follow_the_cpus_this_process_may_use(monkeypatch): assert ws.workers() == 12 and cri.default_workers() == 3 ws.args.workers = 0 assert ws.workers() == 0 + + +# ---------------------------------------------------------------- products +class FakeProduct(assemble.ProductStage): + """A product per Bg/ key of the selection: index.json naming its roots and objects.""" + + name = "fake.product" + version = 1 + KIND = "FakeProduct" + + def select(self, keys): + return [k for k in keys if k.startswith("Bg/")] + + def keys(self, subject, env): + return [subject] + + def assemble(self, task, objects): + doc = {"roots": objects.roots, "objects": objects.ids()} + return assemble.Assembly({"index.json": (contract.encode(doc), "json", "fake.index")}) + + +class FakeCubism(assemble.ProductStage): + """live2d.cubism standing in for the real stage: a model3.json and a texture.""" + + name = "live2d.cubism" + version = 1 + KIND = "CubismModel" + + def keys(self, subject, env): + return [subject] + + def assemble(self, task, objects): + files = {"m.model3.json": (b'{"Version": 3}', "json", "live2d.model3"), "textures/t.png": (b"png", "png", "x")} + return assemble.Assembly(files, set()) + + +PRODUCT_SOURCES = FAKE_SOURCES + ( + assets.StageSource("fake.product", "tests.cli.test_assets:FakeProduct", output=True, product=True), + assets.StageSource("live2d.cubism", "tests.cli.test_assets:FakeCubism", output=True, product=True), +) +MODEL_KEY = "Character/Live2D/g/m/model/m" + + +def test_held_keys_are_those_of_the_bundles_holding_them(): + idx = catalogdb.index(catalog_bytes([(MODEL_KEY, "Assets/Game/Bg/x.prefab", [3])])) + assert assets.held_keys(idx, ["bg_x"]) == ["Bg/x", MODEL_KEY] + assert assets.held_keys(idx, [contract.stable_bundle_name(NAMES["card_1"])]) == ["Card/1/full"] + assert assets.held_keys(idx, []) == [] + + +def test_the_product_layout_places_the_products_of_the_selection(tmp_path, capsys, monkeypatch): + monkeypatch.setattr(assets, "STAGE_SOURCES", PRODUCT_SOURCES) + d = setup_data(tmp_path, censuses(broken=False)) + code, out, err = export(capsys, tmp_path, d, "s", "out", "--layout", "product") + assert code == 0, err + run = contract.loads((tmp_path / "out" / assets.REPORTS / "run.json").read_bytes()) + ids = sorted(t["id"] for t in run["tasks"]) + bg = f"{contract.stable_bundle_name(NAMES['bg_x'])}:3" + assert [i for i in ids if i.startswith(("unity.export:", "fake.product:", "live2d."))] == [ + "fake.product:Bg/x", + f"unity.export:{bg}", # what the product reaches, not every unit of the selection + ] + manifest = contract.loads((tmp_path / "out" / "fake.product" / "Bg%2Fx.json").read_bytes()) + assert manifest["product"] == "fake.product" and manifest["subject"] == "Bg/x" + index = (tmp_path / "out" / manifest["files"]["index.json"]["asset"]).read_bytes() + assert contract.loads(index)["roots"] == [f"{CABS['bg_x']}:3"] + go = manifest["objects"][f"{CABS['bg_x']}:3"] + assert go["class"] == "GameObject" and (tmp_path / "out" / go["files"]["json"]["asset"]).is_file() + assert manifest["objects"][f"{CABS['bg_x']}:2"] == {"class": "Transform", "in": f"{CABS['bg_x']}:3"} + assert sorted(p.name for p in (tmp_path / "out").iterdir()) == [ + assets.REPORTS, + "assets", + "fake.product", + "manifest.json", + ] # no object layout + code, out, err = export(capsys, tmp_path, d, "s", "both", "--layout", "original,product") + assert code == 0, err + assert (tmp_path / "both" / "original" / "Assets" / "Game" / "Card" / "1" / "full.png.json").is_file() + assert (tmp_path / "both" / "product" / "fake.product" / "Bg%2Fx.json").is_file() + + +def test_export_live2d_writes_the_cubism_files_of_one_model(tmp_path, capsys, monkeypatch): + 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([])}) + argv = [*flags(d), "--apk", apk, "export", "live2d", "m", "-o", tmp_path / "m", "--depot", tmp_path / "s"] + code, out, err = nn(capsys, *argv, "--workers", "0") + assert code == 0, err + assert tree(tmp_path / "m", skip=(assets.REPORTS, "manifest.json")) == { + "m.model3.json": hashlib.sha256(b'{"Version": 3}').hexdigest(), + "textures/t.png": hashlib.sha256(b"png").hexdigest(), + } + 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 diff --git a/tests/content/test_live2d.py b/tests/content/test_live2d.py new file mode 100644 index 0000000..0a82828 --- /dev/null +++ b/tests/content/test_live2d.py @@ -0,0 +1,161 @@ +"""Live2D models assembled from a synthetic object table (content.live2d).""" + +import json + +import pytest + +from nnnotes.content import live2d +from nnnotes.pipeline.assemble import Objects +from nnnotes.pipeline.depot import Depot +from tests.helpers import live2dmodel as lm + +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" + + +def objects_of(tmp_path, objs=None, roots=(lm.ROOT, lm.MASK_MAT, lm.CULL_MAT)) -> Objects: + depot = Depot(tmp_path / "d") + return Objects(lm.table(depot, objs, roots), depot) + + +def test_model_document(tmp_path): + doc, keep, roles = live2d.model(objects_of(tmp_path), lm.KEY) + assert doc == { + "format": live2d.MODEL_FORMAT, + "name": "m001", + "key": lm.KEY, + "prefab": lm.ROOT, + "moc": lm.MOC, + "canvas": { + "pixelsPerUnit": 100.0, + "originX": 0.5, + "originY": 0.5, + "width": 20.0, + "height": 30.0, + "mocVersion": 5, + }, + "textures": [lm.TEX0, lm.TEX1], # by name + "shaders": {"Live2D Cubism/Mask": lm.MASK_SHADER, "Live2D Cubism/Unlit": lm.SHADER}, + "resources": {"cubismMask": lm.MASK_MAT, "cubismMaskCulling": lm.CULL_MAT}, + "motionSync": True, + "bindings": {lm.CLIP: [{"path": "Parameters/ParamEyeLOpen", "attribute": "Value"}]}, + } + assert lm.SCRIPT not in keep and keep == set(lm.objects()) - {lm.SCRIPT} + + +def test_the_player_reads_the_programs_of_the_drawn_keywords(tmp_path): + _, _, roles = live2d.model(objects_of(tmp_path), lm.KEY) + programs = {v["role"] for v in lm.SHADER_VARIANTS if v["platform"] == "gles3"} # each set alone and with ALV + assert roles[lm.SHADER] == {"json", "index"} | programs # no typetree, no other platform + assert roles[lm.MASK_SHADER] == {"json", "index", "program:gles3/s0p0_progVertex_0"} # a drawable is masked + + +def test_unmasked_models_read_no_mask_program(tmp_path): + objs = lm.objects() + cls, name, files = objs[lm.MAT1] + objs[lm.MAT1] = (cls, name, {"json": {**files["json"], "keywords": []}}) + _, _, roles = live2d.model(objects_of(tmp_path, objs), lm.KEY) + assert roles[lm.MASK_SHADER] == {"json", "index"} + assert roles[lm.SHADER] == {"json", "index", "program:gles3/s0p0_progVertex_0", "program:gles3/s0p0_progVertex_2"} + + +def test_a_model_without_motion_sync(tmp_path): + objs = lm.objects() + prefab = objs[lm.ROOT][2]["json"] + prefab["nodes"][0]["components"] = [ + c for c in prefab["nodes"][0]["components"] if c.get("script") is None or "MotionSync" not in c["script"] + ] + doc, _, _ = live2d.model(objects_of(tmp_path, objs), lm.KEY) + assert doc["motionSync"] is False + + +def test_a_keyword_set_without_a_program_is_an_error(tmp_path): + objs = lm.objects() + shader = objs[lm.SHADER][2] + shader["index"] = {**shader["index"], "variants": [v for v in lm.SHADER_VARIANTS if v["keywords"] != [lm.ALV]]} + with pytest.raises(ValueError, match="Live2D Cubism/Unlit: 0 GLES3 programs"): + live2d.model(objects_of(tmp_path, objs), lm.KEY) + + +def test_the_roots_name_the_model_and_the_mask_materials(tmp_path): + with pytest.raises(ValueError, match="0 GameObjects among the roots"): + live2d.model(objects_of(tmp_path, roots=(lm.MASK_MAT, lm.CULL_MAT)), lm.KEY) + with pytest.raises(ValueError, match="'MaskCulling' among the roots"): + live2d.model(objects_of(tmp_path, roots=(lm.ROOT, lm.MASK_MAT)), lm.KEY) + with pytest.raises(ValueError, match="not a Live2D model key"): + live2d.model(objects_of(tmp_path), "Character/Live2D/c001/m001/model/other") + + +def test_cubism_files(tmp_path): + files = live2d.cubism(objects_of(tmp_path), lm.KEY) + assert sorted(files) == [ + "m001.moc3", + "m001.model3.json", + "m001.physics3.json", + "motions/idle.motion3.json", + "smile.exp3.json", + "textures/texture_00.png", + "textures/texture_01.png", + ] + assert files["m001.moc3"] == lm.moc3() and files["textures/texture_00.png"] == b"png0" + model3 = json.loads(files["m001.model3.json"]) + assert model3 == { + "Version": 3, + "FileReferences": { + "Moc": "m001.moc3", + "Textures": ["textures/texture_00.png", "textures/texture_01.png"], + "Physics": "m001.physics3.json", + "Expressions": [{"Name": "smile", "File": "smile.exp3.json"}], + "Motions": {"idle": [{"File": "motions/idle.motion3.json", "FadeInTime": 0.5}]}, + }, + "Groups": [ + {"Target": "Parameter", "Name": "EyeBlink", "Ids": ["ParamEyeLOpen"]}, + {"Target": "Parameter", "Name": "LipSync", "Ids": ["ParamMouthOpenY"]}, + ], + } + motion = json.loads(files["motions/idle.motion3.json"]) + assert motion["Meta"]["Fps"] == 30.0 and motion["Curves"][0]["Id"] == "ParamEyeLOpen" + assert json.loads(files["smile.exp3.json"])["Parameters"] == [ + {"Id": "ParamEyeLOpen", "Value": 0.0, "Blend": "Multiply"} + ] + physics = json.loads(files["m001.physics3.json"]) + assert physics["Meta"]["PhysicsSettingCount"] == 1 and physics["PhysicsSettings"][0]["Output"][0]["Type"] == "Angle" + + +def test_pages_that_share_a_name_are_numbered(tmp_path): + objs = lm.objects() + cls, _, files = objs[lm.TEX1] + objs[lm.TEX1] = (cls, "texture_00", files) + files_ = live2d.cubism(objects_of(tmp_path, objs), lm.KEY) + assert [p for p in sorted(files_) if p.startswith("textures/")] == [ + "textures/texture_00-2.png", + "textures/texture_00.png", + ] + + +def test_model_id_is_the_model_name(): + assert live2d.model_id(KEY_A) == "adv_model_a" + for bad in ( + "Character/Live2D/001_adv/a/model/b", + "Character/Live2D/a/model/a", + "Other/001/a/model/a", + "Character/Live2D/001_adv/a/model/a/extra", + ): + with pytest.raises(ValueError, match="not a Live2D model key"): + live2d.model_id(bad) + with pytest.raises(ValueError, match="not URL-safe"): + live2d.model_id("Character/Live2D/g/a b/model/a b") + + +def test_model_keys_and_select(): + keys = [KEY_B, "Character/Live2D/001_adv/adv_model_a/motion/x", KEY_A, "Live/Score/1"] + models = live2d.model_keys(keys) + assert models == {"adv_model_a": KEY_A, "adv_model_b": KEY_B} + assert list(models) == ["adv_model_a", "adv_model_b"] + assert live2d.select_models(None, models) == models + assert live2d.select_models([KEY_B, "adv_model_a"], models) == models + assert live2d.select_models(["adv_model_b"], models) == {"adv_model_b": KEY_B} + with pytest.raises(ValueError, match="nope"): + live2d.select_models(["adv_model_a", "nope"], models) + with pytest.raises(ValueError, match="model id adv_model_a"): + live2d.model_keys([KEY_A, "Character/Live2D/002_adv/adv_model_a/model/adv_model_a"]) diff --git a/tests/helpers/live2dmodel.py b/tests/helpers/live2dmodel.py new file mode 100644 index 0000000..ee2a305 --- /dev/null +++ b/tests/helpers/live2dmodel.py @@ -0,0 +1,243 @@ +"""A synthetic Live2D model as a product's object table (nnnotes.objects/1) whose files are in a depot: a prefab with +a moc, two drawables (one masked), a physics rig, MotionSync, eye blink and lip sync parameters, a clip, its fade +motion data, an expression, the mask materials of the built-in files and a MonoScript.""" + +from __future__ import annotations + +import struct + +from nnnotes.pipeline import contract + +KEY = "Character/Live2D/c001/m001/model/m001" +CAB, RES = "CAB-m", "resources.assets" +CUBISM = "Live2D.Cubism|Live2D.Cubism.Core|" +FRAMEWORK = "Live2D.Cubism|Live2D.Cubism.Framework|" +ALV, MASK = "_ADDITIONAL_LIGHTS_VERTEX", "CUBISM_MASK_ON" +ROOT, MOC, TEX0, TEX1, MAT0, MAT1, SHADER = (f"{CAB}:{i}" for i in (1, 10, 20, 21, 30, 31, 40)) +CLIP, FADE, EXPR, SCRIPT = f"{CAB}:50", f"{CAB}:60", f"{CAB}:70", "CAB-s:1" +MASK_MAT, CULL_MAT, MASK_SHADER = f"{RES}:4", f"{RES}:5", f"{RES}:6" +CANVAS = (100.0, 0.5, 0.5, 20.0, 30.0) + + +def ref(oid: str, cls: str | None = None) -> dict: + file, _, pid = oid.rpartition(":") + r = {"file": file, "pathId": int(pid)} + if cls: + r["class"] = cls + return {"$ref": r} + + +def moc3() -> bytes: + """A moc3 header whose section table's entry 1 points at the canvas info.""" + data = bytearray(0x80) + data[:4] = b"MOC3" + data[4] = 5 + struct.pack_into(" dict: + return {"type": "MonoBehaviour", "pathId": 0, "script": script, "fields": fields} + + +def node(path: str, *components) -> dict: + return {"path": path, "name": path.rsplit("/", 1)[-1], "components": list(components)} + + +def prefab() -> dict: + rig = { + "Gravity": {"x": 0.0, "y": -1.0}, + "Wind": {"x": 0.0, "y": 0.0}, + "Fps": 0.0, + "SubRigs": [ + { + "Name": "Hair", + "Input": [{"SourceId": "ParamAngleX", "Weight": 60.0, "SourceComponent": 0, "IsInverted": 0}], + "Output": [ + { + "DestinationId": "ParamHair", + "ParticleIndex": 1, + "AngleScale": 1.5, + "Weight": 100.0, + "SourceComponent": 2, + "IsInverted": 1, + } + ], + "Particles": [ + { + "InitialPosition": {"x": 0.0, "y": 0.0}, + "Mobility": 1.0, + "Delay": 1.0, + "Acceleration": 1.0, + "Radius": 0.0, + } + ], + "Normalization": { + "Position": {"Minimum": -10.0, "Default": 0.0, "Maximum": 10.0}, + "Angle": {"Minimum": -10.0, "Default": 0.0, "Maximum": 10.0}, + }, + } + ], + } + return { + "name": "m001", + "nodes": [ + node( + "m001", + mono(CUBISM + "CubismModel", _moc=ref(MOC, "MonoBehaviour")), + mono(FRAMEWORK + "CubismPhysicsController", _rig=rig), + mono("Live2D.Cubism|Live2D.Cubism.Framework.MotionSync|CubismMotionSyncController"), + mono("Assembly-CSharp||Live2DMotionSyncCriAudioInput"), + {"type": "Animator", "pathId": 2, "fields": {}}, + ), + node("m001/Parameters"), + node( + "m001/Parameters/ParamEyeLOpen", + mono(CUBISM + "CubismParameter", Value=1.0), + mono(FRAMEWORK + "CubismEyeBlinkParameter"), + ), + node( + "m001/Parameters/ParamMouthOpenY", + mono(CUBISM + "CubismParameter", Value=0.0), + mono(FRAMEWORK + "CubismMouthParameter"), + ), + node("m001/Drawables"), + node( + "m001/Drawables/ArtMesh0", + mono("Live2D.Cubism|Live2D.Cubism.Rendering|CubismRenderer", _mainTexture=ref(TEX1, "Texture2D")), + {"type": "MeshRenderer", "pathId": 3, "fields": {"m_Materials": [ref(MAT0, "Material")]}}, + ), + node( + "m001/Drawables/ArtMesh1", + mono("Live2D.Cubism|Live2D.Cubism.Rendering|CubismRenderer", _mainTexture=ref(TEX0, "Texture2D")), + {"type": "MeshRenderer", "pathId": 4, "fields": {"m_Materials": [ref(MAT1, "Material")]}}, + ), + ], + } + + +def variant(n: int, keywords, platform="gles3", type_="GLES3") -> dict: + return { + "file": f"s/{platform}/s0p0_progVertex_{n}.glsl", + "platform": platform, + "subShader": 0, + "pass": 0, + "stage": "progVertex", + "type": type_, + "keywords": list(keywords), + "role": f"program:{platform}/s0p0_progVertex_{n}", + } + + +SHADER_VARIANTS = [ + variant(0, []), + variant(1, [MASK]), + variant(2, [ALV]), + variant(3, [MASK, ALV]), + variant(4, [], "vulkan", "SPIRV"), +] +MASK_VARIANTS = [variant(0, []), variant(1, ["CUBISM_INVERT"])] + + +def fade() -> dict: + keys = [ + {"time": 0.0, "value": 1.0, "inSlope": 0.0, "outSlope": 0.0, "weightedMode": 0, "inWeight": 0, "outWeight": 0}, + {"time": 1.0, "value": 0.0, "inSlope": 0.0, "outSlope": 0.0, "weightedMode": 0, "inWeight": 0, "outWeight": 0}, + ] + return { + "$script": FRAMEWORK + "CubismFadeMotionData", + "m_Name": "idle", + "MotionName": "motions/idle.motion3.json", + "ModelFadeInTime": 0.5, + "ModelFadeOutTime": -1.0, + "FadeInTime": 1.0, + "FadeOutTime": 1.0, + "ParameterIds": ["ParamEyeLOpen"], + "ParameterCurves": [{"m_Curve": keys}], + "ParameterFadeInTimes": [-1.0], + "ParameterFadeOutTimes": [-1.0], + "MotionLength": 1.0, + } + + +def objects() -> dict: + """{object id: (class, name, {role: bytes | JSON document})} of the model.""" + clip_binding = { + "pathCrc": 0, + "attributeCrc": 0, + "typeID": 114, + "customType": 0, + "pptr": False, + "int": False, + "serializeReference": False, + "script": ref("CAB-s:2"), + "scriptClass": CUBISM + "CubismParameter", + } + from nnnotes.unity.bindings import crc + + clip_binding.update(pathCrc=crc("Parameters/ParamEyeLOpen"), attributeCrc=crc("Value")) + material = {"material": "Unlit", "shader": ref(SHADER, "Shader"), "keywords": [], "floats": {}, "colors": {}} + return { + ROOT: ("GameObject", "m001", {"json": prefab()}), + MOC: ("MonoBehaviour", "m001", {"json": {"$script": CUBISM + "CubismMoc"}, "blob:_bytes": moc3()}), + TEX0: ("Texture2D", "texture_00", {"json": {"m_Name": "texture_00"}, "image": b"png0"}), + TEX1: ("Texture2D", "texture_01", {"json": {"m_Name": "texture_01"}, "image": b"png1"}), + MAT0: ("Material", "Unlit", {"json": material}), + MAT1: ("Material", "UnlitMasked", {"json": {**material, "material": "UnlitMasked", "keywords": [MASK]}}), + SHADER: ( + "Shader", + "Live2D Cubism/Unlit", + { + "json": {"name": "Live2D Cubism/Unlit"}, + "index": {"name": "Live2D Cubism/Unlit", "variants": SHADER_VARIANTS}, + "typetree": {"m_Name": ""}, + **{v["role"]: f"program {v['role']}".encode() for v in SHADER_VARIANTS}, + }, + ), + CLIP: ("AnimationClip", "idle", {"json": {"name": "idle", "sampleRate": 30.0, "bindings": [clip_binding]}}), + FADE: ("MonoBehaviour", "idle", {"json": fade()}), + EXPR: ( + "MonoBehaviour", + "smile.exp3", + { + "json": { + "$script": FRAMEWORK + "CubismExpressionData", + "m_Name": "smile.exp3", + "Type": "Live2D Expression", + "FadeInTime": 0.5, + "FadeOutTime": 0.5, + "Parameters": [{"Id": "ParamEyeLOpen", "Value": 0.0, "Blend": 2}], + } + }, + ), + SCRIPT: ("MonoScript", "CubismModel", {"json": {"m_ClassName": "CubismModel"}}), + MASK_MAT: ("Material", "Mask", {"json": {**material, "material": "Mask", "shader": ref(MASK_SHADER)}}), + CULL_MAT: ( + "Material", + "MaskCulling", + {"json": {**material, "material": "MaskCulling", "shader": ref(MASK_SHADER)}}, + ), + MASK_SHADER: ( + "Shader", + "Live2D Cubism/Mask", + { + "json": {"name": "Live2D Cubism/Mask"}, + "index": {"name": "Live2D Cubism/Mask", "variants": MASK_VARIANTS}, + **{v["role"]: b"mask program" for v in MASK_VARIANTS}, + }, + ), + } + + +def table(depot, objs: dict | None = None, roots=(ROOT, MASK_MAT, CULL_MAT)) -> dict: + """The object table of `objs` (objects()), every file put in `depot`.""" + out = {} + for oid, (cls, name, files) in sorted((objs if objs is not None else objects()).items()): + recs = {} + for role, body in sorted(files.items()): + data = body if isinstance(body, bytes) else contract.encode(body) + ext = ("png" if role == "image" else "bin") if isinstance(body, bytes) else "json" + recs[role] = {"sha256": depot.put(data), "size": len(data), "ext": ext} + out[oid] = {"status": "exported", "class": cls, "name": name, "files": recs} + return {"schema": contract.OBJECTS, "roots": sorted(roots), "objects": out, "missing": []} diff --git a/tests/pipeline/test_live2d.py b/tests/pipeline/test_live2d.py new file mode 100644 index 0000000..d67f7ee --- /dev/null +++ b/tests/pipeline/test_live2d.py @@ -0,0 +1,84 @@ +"""The Live2D product stages (pipeline.live2d) run on a synthetic object table.""" + +import json +import subprocess +import sys + +import pytest + +from nnnotes.pipeline import contract, layout +from nnnotes.pipeline.contract import Input, Task +from nnnotes.pipeline.depot import Depot +from nnnotes.pipeline.live2d import CubismStage, ModelStage, cubism_kind +from nnnotes.pipeline.stages import execute, registry +from tests.helpers import live2dmodel as lm + + +def run(tmp_path, stage): + depot = Depot(tmp_path / "d") + data = contract.encode(lm.table(depot)) + inp = Input("objects", depot.put(data), len(data), None, ({"kind": "depot"},)) + t = Task(stage.name, stage.version, lm.KEY, {}, {}, (inp,)) + execute(t, depot, registry(stage), force=True) + doc = depot.result(t.key) + arts = {contract.parse_artifact_id(a["id"])[1]: a for a in doc["artifacts"]} + return depot, t, doc, arts + + +def test_the_stages_start_from_the_key_and_the_mask_materials(): + model, cubism = ModelStage(), CubismStage() + assert model.keys(lm.KEY, None) == [lm.KEY] and cubism.keys(lm.KEY, None) == [lm.KEY] + assert model.resources(lm.KEY, None) == ["Live2D/Cubism/Materials/Mask", "Live2D/Cubism/Materials/MaskCulling"] + assert cubism.resources(lm.KEY, None) == [] + assert model.manifest(lm.KEY) == "models/m001.json" and cubism.manifest(lm.KEY) == "cubism/m001.json" + with pytest.raises(ValueError, match="not a Live2D model key"): + model.keys("Character/Live2D/c001/m001/other", None) + + +def test_the_model_product(tmp_path): + depot, t, doc, arts = run(tmp_path, ModelStage()) + assert set(arts) == {"model.json", "objects"} and arts["model.json"]["semantics"]["kind"] == "live2d.model" + model = contract.loads(depot.read(arts["model.json"]["content"]["sha256"])) + assert model["prefab"] == lm.ROOT and model["format"] == 3 + table = contract.loads(depot.read(arts["objects"]["content"]["sha256"])) + assert lm.SCRIPT not in table["objects"] + shader = table["objects"][lm.SHADER]["files"] + assert "typetree" not in shader and "program:vulkan/s0p0_progVertex_4" not in shader + assert doc["items"][0]["class"] == "Live2DModel" and doc["items"][0]["status"] == "exported" + manifest = layout.product_manifest(t.id, doc, table) + assert manifest["product"] == "live2d.model" and set(manifest["files"]) == {"model.json"} + assert lm.TEX0 in manifest["objects"] and "image" in manifest["objects"][lm.TEX0]["files"] + + +def test_the_cubism_product(tmp_path): + depot, t, doc, arts = run(tmp_path, CubismStage()) + assert {p: a["semantics"]["kind"] for p, a in arts.items()} == { + "m001.moc3": "live2d.moc3", + "m001.model3.json": "live2d.model3", + "m001.physics3.json": "live2d.physics3", + "motions/idle.motion3.json": "live2d.motion", + "smile.exp3.json": "live2d.expression", + "textures/texture_00.png": "live2d.texture", + "textures/texture_01.png": "live2d.texture", + "objects": "product.objects", + } + table = contract.loads(depot.read(arts["objects"]["content"]["sha256"])) + assert table["objects"] == {} + model3 = json.loads(depot.read(arts["m001.model3.json"]["content"]["sha256"])) + assert model3["FileReferences"]["Moc"] == "m001.moc3" + assert doc["items"][0]["class"] == "CubismModel" + + +def test_cubism_kinds(): + assert cubism_kind("x.model3.json") == "live2d.model3" and cubism_kind("textures/a.png") == "live2d.texture" + with pytest.raises(ValueError, match="not a Cubism SDK file"): + cubism_kind("other.txt") + + +def test_planning_the_stages_does_not_import_unitypy(): + code = ( + "import sys, nnnotes.pipeline.live2d as m; m.ModelStage(); m.CubismStage(); " + "print(sorted(x for x in ('UnityPy', 'numpy', 'PIL') if x in sys.modules))" + ) + r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert r.returncode == 0 and r.stdout.strip() == "[]", r.stderr[-2000:] diff --git a/tests/pipeline/test_modelstages.py b/tests/pipeline/test_modelstages.py index bb59984..5edc140 100644 --- a/tests/pipeline/test_modelstages.py +++ b/tests/pipeline/test_modelstages.py @@ -1,263 +1,28 @@ -"""The model stages: the closures of Live2D model keys (as Catalog.resolve gives them), the task catalog the -Live2D extractor runs on, the keys of both stages, and their runs with synthetic extractors, censuses and objects.""" +"""The Spine skeleton stage: its subjects, closures, keys and runs with synthetic censuses and objects.""" import subprocess import sys -import zipfile from pathlib import Path import pytest from nnnotes.pipeline import census, contract, link, modelstages -from nnnotes.pipeline.catalogdb import index from nnnotes.pipeline.census import CensusStage -from nnnotes.pipeline.contract import IncompatibleTask, Input, Task +from nnnotes.pipeline.contract import Input, Task from nnnotes.pipeline.depot import Depot -from nnnotes.pipeline.modelstages import Closures, Live2DStage, SpineStage, TaskCatalog +from nnnotes.pipeline.modelstages import SpineStage from nnnotes.pipeline.stages import Env, Pending, describe, execute, registry -from nnnotes.source.catalog import Catalog -from tests.helpers import synth - -H = [f"{i:x}" * 32 for i in range(1, 10)] -M1 = "Character/Live2D/g1/m1/model/m1" -M2 = "Character/Live2D/g2/m2/model/m2" -PREFAB = "Assets/AddressableResources/Character/Live2D/g1/m1/model/m1.prefab" - - -def remote_entries(order=(2, 3, 5), m1_prefab=PREFAB): - return [ - (M1, "Character/Live2D/g1/m1/model/m1", [3]), # an alias location first - (M1, m1_prefab, list(order), {"type": "UnityEngine.GameObject"}), - (f"model_m1_{H[0]}.bundle", synth.remote(f"model_m1_{H[0]}.bundle"), [4]), - (f"shared_{H[1]}.bundle", synth.remote(f"shared_{H[1]}.bundle"), []), - (f"tex_{H[2]}.bundle", synth.remote(f"tex_{H[2]}.bundle"), [2]), # a cycle back to the model bundle - ("monoscripts.bundle", synth.local("monoscripts.bundle"), []), - (M2, "Assets/AddressableResources/Character/Live2D/g2/m2/model/m2.prefab", [7, 5, 3]), - (f"model_m2_{H[3]}.bundle", synth.remote(f"model_m2_{H[3]}.bundle"), []), - ("Character/Live2D/g2/m2/motion/idle", "Assets/Game/idle.anim", [7]), - ("Character/Live2D/g3/m3/model/m3", "Assets/Game/m3.prefab", []), # no bundle - ] - - -APK = [("Shared/S", "Assets/Game/Shared/S.prefab", [1]), ("monoscripts.bundle", synth.local("monoscripts.bundle"), [])] - - -def build(entries): - return synth.CatalogWriter().build(entries) - - -def make_apk(path, entries=APK): - with zipfile.ZipFile(path, "w") as z: - z.writestr("assets/aa/catalog.bin", build(entries)) - return path - - -def test_closures_are_those_of_catalog_resolve(tmp_path): - remote = build(remote_entries()) - cat = Catalog(remote, tmp_path / "downloads", apk=make_apk(tmp_path / "base.apk")) - doc = index(remote, build(APK)) - c = Closures(doc) - names = {b["stable"]: b["name"] for b in doc["bundles"]} - assert c.keys() == cat.keys() - for key in cat.keys(): - assert c.entry(key)["internalId"] == cat.entry(key)["internal_id"] - assert [names[s] for s in c.closure_bundles(key)] == [b.name for b in cat.resolve(key)], key - assert c.entry(M1)["internalId"] == PREFAB # the location inside a bundle - assert c.closure_bundles(M1) == ["monoscripts", "shared", "model_m1", "tex"] - assert c.own_bundle(M1) == "model_m1" and c.own_bundle("Character/Live2D/g3/m3/model/m3") is None - with pytest.raises(KeyError): - c.entry("Character/Live2D/none") - - -# ---------------------------------------------------------------- live2d.model -def contents(tag=b""): - return { - "model_m1": b"UnityFS m1" + tag, - "shared": b"UnityFS shared", - "tex": b"UnityFS tex", - "monoscripts": b"UnityFS scripts", - "model_m2": b"UnityFS m2", - } - - -def bundles_fact(doc, data, locators=True): - out = {} - for b in doc["bundles"]: - body = data[b["stable"]] - locs = ({"kind": "download", "path": f"bundles/{b['name']}"},) if locators else () - out[b["stable"]] = Input("bundle", contract.sha256(body), len(body), b["name"], locs) - return out - - -def live2d_env(depot, entries=None, data=None, selected=None, locators=True): - doc = index(build(entries or remote_entries()), build(APK)) - data = data or contents() - bundles = bundles_fact(doc, data, locators) - return Env(depot, {"index": doc, "bundles": bundles, "selected": sorted(selected or bundles)}) - - -def test_live2d_subjects_inputs_and_context(tmp_path): - stage = Live2DStage() - env = live2d_env(Depot(tmp_path / "s")) - assert stage.subjects(env) == [M1, M2] # model keys with their bundle - assert stage.subjects(live2d_env(Depot(tmp_path / "s"), selected=["model_m2", "shared"])) == [M2] - t = describe(stage, M1, None, env) - assert t.id == "live2d.model:" + M1 and t.params == {} and t.atoms == Live2DStage.ATOMS - assert [(i.role, i.name) for i in t.inputs] == [ - ("bundle:000", "monoscripts.bundle"), - ("bundle:001", f"shared_{H[1]}.bundle"), - ("bundle:002", f"model_m1_{H[0]}.bundle"), - ("bundle:003", f"tex_{H[2]}.bundle"), - ] - assert t.inputs[2].sha256 == contract.sha256(contents()["model_m1"]) - assert t.context == {"internalId": PREFAB} - assert t.inputs[0].locators == ({"kind": "download", "path": "bundles/monoscripts.bundle"},) - assert set(Live2DStage.ATOMS) == {"live2d.extract_model"} - assert all(lib.lower() in Live2DStage.ATOMS["live2d.extract_model"] for lib in modelstages.LIBRARIES) - - -def test_live2d_keys_follow_the_closure_contents_and_order_only(tmp_path): - stage, depot = Live2DStage(), Depot(tmp_path / "s") - key = describe(stage, M1, None, live2d_env(depot)).key - # other file names, no locators, another catalog with more keys: the same task - renamed = [(e[0].replace(H[1], H[8]), e[1].replace(H[1], H[8]), *e[2:]) for e in remote_entries()] - renamed.append(("Other/key", "Assets/Game/other.asset", [3])) - assert describe(stage, M1, None, live2d_env(depot, renamed, locators=False)).key == key - # a bundle of the closure changed, the closure's order changed, the key's location changed: another task - assert describe(stage, M1, None, live2d_env(depot, data=contents(b"!"))).key != key - assert describe(stage, M1, None, live2d_env(depot, remote_entries(order=(3, 2, 5)))).key != key - assert describe(stage, M1, None, live2d_env(depot, remote_entries(m1_prefab=PREFAB + "x"))).key != key - # a bundle outside the closure changed: the same task - other = dict(contents(), model_m2=b"UnityFS m2 changed") - assert describe(stage, M1, None, live2d_env(depot, data=other)).key == key - bumped = type("Bumped", (Live2DStage,), {"version": 2})() - assert describe(bumped, M1, None, live2d_env(depot)).key != key - - -def test_live2d_waits_for_unfetched_bundles_and_names_unreadable_ones(tmp_path): - stage, depot = Live2DStage(), Depot(tmp_path / "s") - env = live2d_env(depot) - env.facts["bundles"] = dict(env.facts["bundles"], tex=Pending("fetch:tex")) - with pytest.raises(Pending, match="fetch:tex"): - describe(stage, M1, None, env) - env.facts["bundles"]["tex"] = None - with pytest.raises(Pending, match="fetch:tex"): - describe(stage, M1, None, env) - del env.facts["bundles"]["tex"] - with pytest.raises(ValueError, match="bundle tex is not readable"): - describe(stage, M1, None, env) - - -def stored_task(depot, stage=None): - """The live2d.model task of M1 with every bundle in the depot (depot locators only).""" - for body in contents().values(): - depot.put(body) - return describe(stage or Live2DStage(), M1, None, live2d_env(depot, locators=False)) - - -def test_task_catalog_answers_from_the_task(tmp_path): - depot = Depot(tmp_path / "s") - t = stored_task(depot) - cat = TaskCatalog.from_task(t, depot) - assert cat.apk is not None and cat.has(M1) and not cat.has(M2) - assert cat.keys("Character/") == [M1] and cat.keys("Other/") == [] - assert cat.entry(M1)["internal_id"] == PREFAB and cat.entries_for(M2) == [] - order = ["monoscripts", "shared", "model_m1", "tex"] - assert [p.read_bytes() for p in cat.fetch_key(M1)] == [contents()[s] for s in order] - assert [b.name for b in cat.resolve(M1)] == [i.name for i in t.inputs] - assert [b.offset for b in cat.resolve(M1)] == [0, 1, 2, 3] - for f in (cat.entry, cat.fetch_key, cat.resolve): - with pytest.raises(KeyError): - f(M2) - # inputs are ordered by position, not by role text - many = [Input(f"bundle:{i}", contract.sha256(b"x%d" % i), 2) for i in (10, 2, 1)] - assert [ - i.role - for i in TaskCatalog( - "k", "Assets/k", modelstages._ordered(Task(Live2DStage.name, 1, "k", inputs=tuple(many))), depot - ).bundles - ] == ["bundle:1", "bundle:2", "bundle:10"] - - -def fake_extract(calls): - def extract_model(cat, key, out_dir): - assert cat.apk is not None - data = b"".join(p.read_bytes() for p in cat.fetch_key(key)) - name = key.rsplit("/", 1)[-1] - calls.append((key, cat.entry(key)["internal_id"])) - (out_dir / "textures").mkdir() - (out_dir / "motions").mkdir() - (out_dir / f"{name}.moc3").write_bytes(b"MOC3" + data) - (out_dir / "textures" / "texture_00-1a2b3c4d.png").write_bytes(b"\x89PNG page") - (out_dir / f"{name}.prefab.json").write_text('{"nodes": []}', encoding="utf-8") - (out_dir / "motions" / "idle.motion3.json").write_text("{}", encoding="utf-8") - (out_dir / "motions" / "_fades.json").write_text("{}", encoding="utf-8") - (out_dir / "smile.exp3.json").write_text("{}", encoding="utf-8") - (out_dir / f"{name}.model3.json").write_text('{"Version": 3}\n', encoding="utf-8") - return {"name": name, "textures": ["textures/texture_00-1a2b3c4d.png"], "motions": 1, "expressions": 1} - - return extract_model - - -def test_live2d_run_stores_every_file_of_the_extractor(tmp_path, monkeypatch): - from nnnotes.unity import live2d - - calls = [] - monkeypatch.setattr(live2d, "extract_model", fake_extract(calls)) - depot = Depot(tmp_path / "s") - stage = Live2DStage() - t = stored_task(depot, stage) - ex = execute(t, depot, registry(stage)) - assert ex.status == "ran" and calls == [(M1, PREFAB)] - doc = depot.result(t.key) - tid = "live2d.model:" + M1 - got = {contract.parse_artifact_id(a["id"])[1]: a for a in doc["artifacts"]} - assert sorted(got) == [ - "m1.moc3", - "m1.model3.json", - "m1.prefab.json", - "motions/_fades.json", - "motions/idle.motion3.json", - "smile.exp3.json", - "textures/texture_00-1a2b3c4d.png", - ] - assert all(a["id"].startswith(tid + "#") for a in doc["artifacts"]) and doc["items"] == [] - kinds = {r: (a["semantics"]["kind"], a["content"]["ext"]) for r, a in got.items()} - assert kinds == { - "m1.model3.json": ("live2d.model3", "json"), - "m1.moc3": ("live2d.moc3", "moc3"), - "m1.prefab.json": ("live2d.prefab", "json"), - "motions/_fades.json": ("live2d.fades", "json"), - "motions/idle.motion3.json": ("live2d.motion", "json"), - "smile.exp3.json": ("live2d.expression", "json"), - "textures/texture_00-1a2b3c4d.png": ("live2d.texture", "png"), - } - order = ["monoscripts", "shared", "model_m1", "tex"] - assert depot.read(got["m1.moc3"]["content"]["sha256"]) == b"MOC3" + b"".join(contents()[s] for s in order) - assert got["m1.model3.json"]["semantics"]["facts"] == { - "expressions": 1, - "motions": 1, - "name": "m1", - "textures": ["textures/texture_00-1a2b3c4d.png"], - } - assert execute(t, depot, registry(stage)).status == "hit" - assert execute(t, depot, registry(stage), force=True).status == "ran" # the same result again - other = Task(t.stage, t.version, t.subject, t.params, {"live2d.extract_model": "unitypy-0/1"}, t.inputs, t.context) - with pytest.raises(IncompatibleTask): - execute(other, depot, registry(stage)) -def test_the_model_key_and_libraries_are_those_of_the_extractors(): - from nnnotes.site import webmodel +def test_the_libraries_are_those_of_the_extractor(): from nnnotes.unity import export - assert modelstages.MODEL_KEY.pattern == webmodel.MODEL_KEY.pattern assert modelstages.LIBRARIES == export._LIBS def test_importing_the_stages_does_not_import_unitypy(): code = ( - "import sys, nnnotes.pipeline.modelstages as m; m.Live2DStage(); m.SpineStage(); " + "import sys, nnnotes.pipeline.modelstages as m; m.SpineStage(); " "assert 'UnityPy' not in sys.modules, 'UnityPy imported'" ) r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) diff --git a/tests/site/test_webmodel.py b/tests/site/test_webmodel.py index 814231f..93c3ff3 100644 --- a/tests/site/test_webmodel.py +++ b/tests/site/test_webmodel.py @@ -18,34 +18,6 @@ KEY_B = "Character/Live2D/sub_x/adv_model_b/model/adv_model_b" -def test_model_id_is_the_model_name(): - assert webmodel.model_id(KEY_A) == "adv_model_a" - for bad in ( - "Character/Live2D/001_adv/a/model/b", - "Character/Live2D/a/model/a", - "Other/001/a/model/a", - "Character/Live2D/001_adv/a/model/a/extra", - ): - with pytest.raises(ValueError, match="not a Live2D model key"): - webmodel.model_id(bad) - with pytest.raises(ValueError, match="not URL-safe"): - webmodel.model_id("Character/Live2D/g/a b/model/a b") - - -def test_model_keys_and_select(): - keys = [KEY_B, "Character/Live2D/001_adv/adv_model_a/motion/x", KEY_A, "Live/Score/1"] - models = webmodel.model_keys(keys) - assert models == {"adv_model_a": KEY_A, "adv_model_b": KEY_B} - assert list(models) == ["adv_model_a", "adv_model_b"] - assert webmodel.select(None, models) == models - assert webmodel.select([KEY_B, "adv_model_a"], models) == models - assert webmodel.select(["adv_model_b"], models) == {"adv_model_b": KEY_B} - with pytest.raises(ValueError, match="nope"): - webmodel.select(["adv_model_a", "nope"], models) - with pytest.raises(ValueError, match="model id adv_model_a"): - webmodel.model_keys([KEY_A, "Character/Live2D/002_adv/adv_model_a/model/adv_model_a"]) - - def test_shader_names(): doc = { "nodes": [