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": [