From 617aed9da25b63bf5b0d918bc0863b7ce5bbfd5d Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sun, 18 Oct 2026 13:12:47 +0800 Subject: [PATCH 1/2] feat(ui): assemble libraries from shared object products --- docs/cli.md | 7 +- docs/contracts.md | 7 + docs/products.md | 6 + docs/schemas.md | 2 + docs/stages.md | 4 + docs/ui.md | 83 ++-- src/nnnotes/cli/assets.py | 13 +- src/nnnotes/cli/export.py | 42 +- src/nnnotes/content/ui.py | 479 +++------------------ src/nnnotes/pipeline/assemble.py | 42 +- src/nnnotes/pipeline/ui.py | 36 ++ src/nnnotes/pipeline/units.py | 22 +- src/nnnotes/schemas/ui-library.schema.json | 148 +++++++ src/nnnotes/schemas/ui-pack.schema.json | 129 ++++++ tests/content/test_ui.py | 315 ++++---------- tests/pipeline/test_assemble.py | 18 + 16 files changed, 621 insertions(+), 732 deletions(-) create mode 100644 src/nnnotes/pipeline/ui.py create mode 100644 src/nnnotes/schemas/ui-library.schema.json create mode 100644 src/nnnotes/schemas/ui-pack.schema.json diff --git a/docs/cli.md b/docs/cli.md index db06a5f..07d2755 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -313,8 +313,11 @@ the APK's UI prefabs -> an offline UI prefab library for ournotes-player/ui | `--key` `KEY` | exact APK catalog key (repeatable) | | `--prefix` `PREFIX` | APK UI key prefix (default EmbUI/) | | `--limit` `LIMIT` | maximum number of keys | -| `--no-dependencies` | omit additional prefab roots and controllers | -| `--force` | re-export the selected keys | +| `--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 apk settings diff --git a/docs/contracts.md b/docs/contracts.md index d3073d1..69ea77a 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -306,3 +306,10 @@ of them. Downloads, file identities by path (`inputs/paths/`), catalog versions, stay in the depot directory. Every request names one object; only `depot gc --remote` lists the remote. + +## UI contracts + +`nnnotes.ui-pack/1` describes a UI product's root objects and animation bindings. `nnnotes.ui-library/1` indexes its selected keys, referenced prefabs and controllers. Both use the shared `nnnotes.product/1` object storage contract; see [UI libraries](ui.md). + +- [ui-pack.schema.json](../src/nnnotes/schemas/ui-pack.schema.json) +- [ui-library.schema.json](../src/nnnotes/schemas/ui-library.schema.json) diff --git a/docs/products.md b/docs/products.md index 7953e01..4583062 100644 --- a/docs/products.md +++ b/docs/products.md @@ -536,3 +536,9 @@ asks for the `story` product first, reads its `story.json`, then asks for the st `parts.ui` and `parts.fonts` map every client language to its product manifest. The command exports those entries for the requested languages. The `product` layout makes the story of every episode whose key the selection holds. + +## UI library products + +`ui.pack` assembles one catalog key and its reachable objects. Its `ui.json` (`nnnotes.ui-pack/1`) names root prefabs, controllers, animation bindings and shaders. The product layout writes `index.json` (`nnnotes.ui-library/1`) over the selected UI products; all referenced data lives in their object tables. See [UI libraries](ui.md). + +A stage with `INCLUDE_FONTS = True` requests traversal through its referenced font objects. The request records these roots in `font_roots`; unit selection and assembly follow the same rule. Other products keep their own font selection behavior. diff --git a/docs/schemas.md b/docs/schemas.md index b3861fe..f1537ab 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -37,6 +37,8 @@ schemas.load("plan") # the schema as a dict | `story-host` | `nnnotes.story-host/1` (`format`) | an Overlay story's host screen, layout and initial state | | `story-simple-ui` | `nnnotes.story-simple-ui/1` (`format`) | localized Overlay talk-window and system-message prefabs | | `task` | `nnnotes.task/1` | one stage applied to one subject, with everything needed to run it (`depot run`) | +| `ui-library` | `nnnotes.ui-library/1` | selected UI keys and their product manifests | +| `ui-pack` | `nnnotes.ui-pack/1` | native UI roots, prefab and controller identities, animation bindings | | `view` | `nnnotes.view/1` | a semantic view of a master table, each row with its names and the objects it names | The documents of the asset export are described in [contracts](contracts.md), the views in [views](views.md). The diff --git a/docs/stages.md b/docs/stages.md index 53db9e1..c88b33b 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -322,3 +322,7 @@ The codes of items that are `generic`, `unsupported` or `failed`, and of limits | `failed.export` | the object's typetree JSON could not be made | | `source.absent` | a catalog entry whose file is not there (an APK entry without a file) | | `source.error` | an input that could not be fetched | + +## ui.pack + +`ui.pack` assembles the selected APK UI key from its object table, including referenced font objects. It writes `ui.json` with native roots, prefab/controller lists and named animation bindings. The product layout groups its manifests into the UI library index. See [UI libraries](ui.md). diff --git a/docs/ui.md b/docs/ui.md index 5251ce8..8c29bb3 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -1,66 +1,45 @@ -# Serialized UI libraries (experimental) +# UI libraries -`nnnotes ui` exports an offline prefab library for the optional `ournotes-player/ui` preview module. It reads the -embedded Addressables catalog and bundle closure of the APK set you supply; it does not contact a game API. Keep -outputs outside the nnnotes and ournotes-player source repositories. The export contains game data that these -repositories and their packages do not redistribute. +`nnnotes export ui` assembles an offline UI library for `ournotes-player/ui`. The supplied APK set provides the +catalog keys, native prefab fields, animations, sprites and fonts. The depot stores their converted objects and +product results; the output directory is a product layout. ```sh -nnnotes --apk /path/to/base.apk ui --prefix EmbUI/Prefab/ --limit 10 -o /path/to/ui-data -nnnotes --apk /path/to/base.apk ui --key YOUR_EXACT_APK_KEY -o /path/to/ui-data +nnnotes --apk /path/to/base.apk export ui --prefix EmbUI/Prefab/ --limit 10 -o /path/to/ui-data +nnnotes --apk /path/to/base.apk export ui --key YOUR_EXACT_APK_KEY -o /path/to/ui-data ``` -`--key` is repeatable. Without it, `--prefix` defaults to `EmbUI/`. `--no-dependencies` omits additional root -prefabs and AnimatorControllers; `--force` re-exports the selected keys. A failed key is retained as an explicit -index entry and makes the command exit with status 1. Existing unrelated entries of the same input stay present. +`--key` is repeatable. Otherwise `--prefix` selects APK asset keys and defaults to `EmbUI/`; `--limit` takes the +first keys in sorted order. `--stored-catalog` selects a saved catalog version. The common `--workers`, `--link`, +`--dry-run` and `--explain` options apply. The output describes the complete selection of that invocation; +repeated invocations reuse unchanged depot tasks. Reports under `_reports/` describe coverage and failures. -## Inputs and regions +## Input configuration -The command uses the same `ApkSet` reader as the existing extractors: one APK, base.apk with adjacent splits, a -directory of splits, or an APKS/XAPK archive. Explicit `--apk` wins over the selected region's APK configuration. -The configured depot and bundle decryption settings are used when needed. Readable unencrypted local bundles do -not require a key. Default Unity resources are read from the selected APK set through the existing player reader. +Configure the APK set and depot as described in [configuration](configuration.md). APK input may be a single APK, +base.apk with adjacent splits, a split directory, or an APKS/XAPK archive. Region selection chooses its configured +APK. Keep each library's output directory associated with its intended input configuration. -```sh -nnnotes --region jp ui --prefix EmbUI/Prefab/ --limit 10 -o /path/to/jp-ui-data -``` - -For JP, configure the actual JP APK set as described in [jp.md](jp.md). Do not point a JP region at an international -APK: this command exports the APK selected by the configuration, not a replacement fetched from that region. -Keep international and JP libraries in separate output directories. Their keys, classes and fonts can differ. - -Current real UI smoke checks used international client 1.0.1 (versionCode 25). A synthetic split-APK test verifies -the JP command/configuration path; it is not a full JP UI export or pixel-fidelity verification. Existing JP -catalog, chart, story and model validation is described separately in [jp.md](jp.md). - -## Files and resume - -`index.json` has `schema: 1`, `format: "ournotes-ui-library"`, and three lists: +## Product layout -- `assets`: selected catalog keys, IDs, kind, export status, pack file and SHA-256; failures contain an error type - and message rather than a pack file. -- `embedded`: additional root GameObjects found in a selected key's dependency closure. -- `controllers`: AnimatorControllers found in that closure, keyed by actual serialized object identity. +`index.json` has `format: "nnnotes.ui-library/1"`: -Pack files under `packs/`, `embedded/` and `controllers/` have `format: "ournotes-ui-pack"`, a `document`, -`resources` and `resourceBase: "../"`. Hierarchy nodes retain serialized rectangles, transforms, active flags, -component fields and preorder paths. `nodeId` and reference `nodeId` values distinguish same-named instances; -controller references carry a matching ID. Negative intermediate rectangle sizes are preserved. +- `assets`: selected catalog keys, display names, stable key IDs, root object IDs and product manifest paths. +- `embedded`: referenced prefab roots, identified by serialized object ID. +- `controllers`: referenced AnimatorController objects, with their object IDs and containing product manifests. -Resources contain Sprite metadata and texture/font paths. PNGs and embedded TTFs are written under `textures/` -and `fonts/`. Each used VibeMO numeric font face has separate ASCII glyph metrics in `fontMetricsByAsset`; the -legacy `fontMetrics` field names the first one. A metrics file's texture is relative to that file when its -`textureBase` is `"metrics"`. Rotated packed sprites are decoded to standalone RGBA images. +Each `uipacks/.json` is a `nnnotes.product/1` manifest for `ui.pack`. Its `files` map contains `ui.json`, and its +`objects` map lists each retained object's files as content-addressed assets. Texture images, embedded Font data, +TMP font records, clips, controllers and prefabs use the shared object conversion layer. -The index records hashes of every pack and its texture/font/metrics files, as well as dependency IDs. A resumed -key is skipped only when these files and its dependency records still validate. Missing or corrupt files are -re-exported. Provenance records hashes of the APK catalog and effective APK-set member metadata, the manifest -version and configured region; it contains no credentials or server addresses. A different input is rejected -before updating an existing library. The index is replaced atomically after each key. +`ui.json` has `format: "nnnotes.ui-pack/1"`, the catalog `key`, `roots`, `prefabs`, `controllers`, display `entries`, +`shaders`, and per-prefab animation `bindings`. Nodes preserve their serialized transforms, rectangles, active +flags, component type/script/fields and path IDs. References use object identity or a local hierarchy target. +Clips retain their raw curves; bindings name each curve's path and property in the Animator's hierarchy. -## Scope +UI products include the fonts referenced by their object graph. Product stages opt into this traversal with +`INCLUDE_FONTS`; language-specific story products select fonts through their own products. -This is inspectable serialized data, not an implementation of every game Presenter. The preview implements a -subset of layout, graphics, component binding and animation. Exported particle, shader/material, localization, -video, camera and custom component fields do not imply that the preview executes them. `runtime_verified` stays -false. The companion player documentation lists rendering limits and the optional viewer. +See [ui-pack schema](schemas.md) and [products](products.md) for the format contracts. The preview module provides +Canvas 2D inspection, animation sampling and editable copies of prefab state. It supports a defined subset of +runtime behavior; its API documents the rendering and animation limits. diff --git a/src/nnnotes/cli/assets.py b/src/nnnotes/cli/assets.py index 01c07c6..a6c639a 100644 --- a/src/nnnotes/cli/assets.py +++ b/src/nnnotes/cli/assets.py @@ -139,6 +139,7 @@ def matches(self, stage: str) -> bool: StageSource("story.simpleui", "nnnotes.pipeline.story:SimpleUiStage", output=True, product=True), StageSource("story.host", "nnnotes.pipeline.storyhost:HostStage", output=True, product=True), StageSource("story", "nnnotes.pipeline.story:StoryStage", output=True, product=True), + StageSource("ui.pack", "nnnotes.pipeline.ui:UiStage", output=True, product=True), StageSource("view.", "nnnotes.pipeline.views:stages", output=True), StageSource("view.voices", "nnnotes.pipeline.voices:VoiceStage", output=True), ) @@ -1064,7 +1065,17 @@ def _product_entries(self, name: str, placed: LayoutInput, store: bool) -> tuple 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)} + entries = layout.product_entries(products) + indexed = {} + for tid, doc in results: + stage_name, subject = contract.parse_task_id(tid) + if hasattr(self.installed.stages[stage_name], "layout_index"): + indexed.setdefault(stage_name, []).append((subject, doc)) + for name, records in sorted(indexed.items()): + path, body = self.installed.stages[name].layout_index(records, self.depot) + sha = self.depot.put(body) if store else contract.sha256(body) + entries.append({"path": path, "sha256": sha, "size": len(body), "id": f"{name}:library#index"}) + return entries, {"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).""" diff --git a/src/nnnotes/cli/export.py b/src/nnnotes/cli/export.py index 7dbaeda..6c2233c 100644 --- a/src/nnnotes/cli/export.py +++ b/src/nnnotes/cli/export.py @@ -4,7 +4,6 @@ from __future__ import annotations -import sys from pathlib import Path from ..core.jsonio import write_json @@ -261,30 +260,26 @@ def cmd_audio(args, cfg): def cmd_ui(args, cfg): - from ..content import ui + from ..pipeline.ui import UI if args.limit is not None and args.limit < 1: usage(args, "--limit must be positive") - index = ui.export_apk_library( - cfg, - args.out, - keys=args.key, - prefix=args.prefix, - limit=args.limit, - dependencies=not args.no_dependencies, - force=args.force, - ) - report( - args, - { - "index": str(Path(args.out).resolve() / "index.json"), - "counts": index["counts"], - "embedded": len(index["embedded"]), - "controllers": len(index["controllers"]), - }, - ) - if index["counts"].get("failed"): - sys.exit(1) + cfg.require_path("paths", "apk") + use(cfg) + ws = assets.Workspace(args, cfg, layouts=["product"]) + ws.run_index() + known = { + loc["primaryKey"] for loc in ws.index()["locations"] if loc["id"].startswith("apk:") and loc["kind"] == "asset" + } + keys = sorted(set(args.key) if args.key else {key for key in known if key.startswith(args.prefix)}) + if missing := sorted(set(keys) - known): + usage(args, "UI key not in the APK catalog: " + ", ".join(missing)) + if args.limit is not None: + keys = keys[: args.limit] + if not keys: + usage(args, "no UI keys selected") + ws.request({UI: keys}, [f"key:{key}" for key in keys]) + assets.export(args, cfg, ws) def product_args(c) -> None: @@ -445,5 +440,4 @@ def register(sub) -> None: c.add_argument("--key", action="append", help="exact APK catalog key (repeatable)") c.add_argument("--prefix", default="EmbUI/", help="APK UI key prefix (default EmbUI/)") c.add_argument("--limit", type=int, help="maximum number of keys") - c.add_argument("--no-dependencies", action="store_true", help="omit additional prefab roots and controllers") - c.add_argument("--force", action="store_true", help="re-export the selected keys") + product_args(c) diff --git a/src/nnnotes/content/ui.py b/src/nnnotes/content/ui.py index 559348e..2566ed1 100644 --- a/src/nnnotes/content/ui.py +++ b/src/nnnotes/content/ui.py @@ -1,418 +1,81 @@ -"""Serialized UI library for ournotes-player/ui, from user-supplied game files. - -Outputs contain game data and belong outside either source repository. This is -an inspectable prefab preview format, not a claim of Unity runtime parity. -""" +"""UI prefab libraries assembled from serialized object products.""" from __future__ import annotations import hashlib -import json from collections import Counter -from pathlib import Path - -from ..core.cache import write_atomic -from ..core.jsonio import dumps, write_json -from ..unity.export import Exporter - - -def identity(value: str) -> str: - return hashlib.sha256(value.encode("utf-8")).hexdigest()[:24] - - -def object_id(obj) -> str: - return identity(f"{obj.assets_file.name}:{obj.path_id}") - - -class UIExporter(Exporter): - """The generic Exporter plus portable images and stable PPtr identities.""" - - def ref(self, owner, pointer): - out = super().ref(owner, pointer) - if not isinstance(out, dict): - return out - if "gameObject" not in out and "transform" not in out and out.get("object") != "AnimatorController": - return out - obj = self.deref(owner, pointer) - if obj is None: - return out - if obj.type.name == "AnimatorController": - return {**out, "id": object_id(obj)} - go = obj if obj.type.name == "GameObject" else self.deref(obj, obj.read_typetree()["m_GameObject"]) - return {**out, "nodeId": object_id(go)} if go is not None else out - - def hierarchy(self, env, graph, root_tf): - nodes = super().hierarchy(env, graph, root_tf) - transforms = [] - - def walk(pid): - transforms.append(pid) - for child in graph.tf[pid]["m_Children"]: - walk(child["m_PathID"]) - walk(root_tf) - for node, pid in zip(nodes, transforms, strict=True): - go_pid = graph.tf[pid]["m_GameObject"]["m_PathID"] - node["nodeId"] = identity(f"{self._go_file[go_pid]}:{go_pid}") - return nodes - - def sprite(self, obj): - try: - return super().sprite(obj) - except NotImplementedError as exc: - if "packing rotation" not in str(exc): - raise - tt = obj.read_typetree() - image = obj.read().image.convert("RGBA") - key = f"{obj.assets_file.name}:{obj.path_id}" - texture_key = "decoded-sprite:" + key - if not hasattr(self, "decoded_sprites"): - self.decoded_sprites = {} - self.decoded_sprites[texture_key] = image - self.sprite_recs[key] = { - "name": tt["m_Name"], - "textureRef": texture_key, - "rect": {"x": 0, "y": 0, "width": image.width, "height": image.height}, - "textureRect": {"x": 0, "y": 0, "width": image.width, "height": image.height}, - "textureRectOffset": {"x": 0, "y": 0}, - "border": tt["m_Border"], - "pivot": tt["m_Pivot"], - "pixelsPerUnit": tt["m_PixelsToUnits"], - "settingsRaw": 0, - } - return {"spriteRef": key, "name": tt["m_Name"]} - - def atlas(self, obj): - tt = obj.read_typetree() - return { - "atlas": tt["m_Name"], - "sprites": [ - self.sprite(sprite) - for ptr in tt.get("m_PackedSprites", []) - if (sprite := self.deref(obj, ptr)) is not None - ], - } - - -def _font_metrics(ex, document, out): - used = { - c["m_fontAsset"]["name"] - for n in document.get("nodes", []) - for c in n["components"] - if isinstance(c.get("m_fontAsset"), dict) and "VibeMO" in c["m_fontAsset"].get("name", "") - } - if not used: - return {} - (out / "fonts").mkdir(parents=True, exist_ok=True) - result = {} - for obj in ex._objects.values(): - if obj.type.name != "MonoBehaviour" or ex.script_class(obj) != "TMP_FontAsset": - continue - tt = obj.read_typetree() - if tt.get("m_Name") not in used: - continue - atlas = ex.deref(obj, tt["m_AtlasTextures"][0]) - if atlas is None: - raise ValueError(f"UI font {tt['m_Name']}: no atlas") - key = f"{atlas.assets_file.name}:{atlas.path_id}" - ex.tex_objs[key] = atlas - chars = {str(c["m_Unicode"]): c["m_GlyphIndex"] for c in tt["m_CharacterTable"] if 32 <= c["m_Unicode"] < 127} - glyphs = {str(g["m_Index"]): g for g in tt["m_GlyphTable"] if g["m_Index"] in chars.values()} - rel = f"fonts/{object_id(obj)}.json" - write_json( - out / rel, +from .prefabs import clip_bindings, component_class, controller_clips, has_json, prefab_ids, shader_table + +FORMAT = "nnnotes.ui-pack/1" +LIBRARY = "nnnotes.ui-library/1" + + +def identity(key: str) -> str: + """A stable file name for a catalog key.""" + return hashlib.sha256(key.encode("utf-8")).hexdigest()[:24] + + +def pack(objects, key: str) -> tuple[dict, dict]: + """The UI roots, reachable prefabs and controllers, with per-prefab animation bindings.""" + roots = sorted(objects.key(key)) + if not roots: + raise ValueError(f"{key}: no UI objects") + prefabs = prefab_ids(objects) + controllers = [oid for oid in objects.ids("AnimatorController") if has_json(objects, oid)] + bindings = {} + for oid in prefabs: + document = objects.json(oid) + bindings[oid] = clip_bindings(objects, document, controller_clips(objects, document)) + shaders, roles = shader_table(objects) + entries = [] + for oid in sorted(set(roots) | set(prefabs) | set(controllers)): + info = objects.info(oid) + entry = {"id": oid, "name": info.get("name", oid), "class": info["class"]} + if oid in prefabs: + nodes = objects.json(oid)["nodes"] + entry.update( + nodeCount=len(nodes), + classes=dict(sorted(Counter(component_class(c) for n in nodes for c in n["components"]).items())), + ) + entries.append(entry) + return { + "format": FORMAT, + "key": key, + "roots": roots, + "prefabs": prefabs, + "controllers": controllers, + "entries": entries, + "bindings": bindings, + "shaders": shaders, + }, roles + + +def library(packs) -> dict: + """A library index of (manifest path, UI document) pairs.""" + assets, embedded, controllers = [], {}, {} + for manifest, doc in sorted(packs): + rows = {e["id"]: e for e in doc["entries"]} + primary = rows[doc["roots"][0]] + assets.append( { - "name": tt["m_Name"], - "face": tt["m_FaceInfo"], - "characters": chars, - "glyphs": glyphs, - "texture": f"../textures/{identity(key)}.png", - "textureBase": "metrics", - }, + "id": identity(doc["key"]), + "key": doc["key"], + "name": doc["key"].rsplit("/", 1)[-1], + "kind": "prefab" if primary["class"] == "GameObject" else primary["class"], + "manifest": manifest, + "roots": doc["roots"], + "nodeCount": sum(rows[oid].get("nodeCount", 0) for oid in doc["roots"]), + } ) - result[tt["m_Name"]] = rel - if used - result.keys(): - raise ValueError("UI font metrics not present in the loaded dependency closure") - return result - - -def resources(ex, document, out: Path, written=None) -> dict: - written = written if written is not None else set() - result = {"textures": {}, "sprites": {}, "fonts": {}} - metrics = _font_metrics(ex, document, out) - if metrics: - result["fontMetrics"] = next(iter(metrics.values())) - result["fontMetricsByAsset"] = metrics - (out / "textures").mkdir(parents=True, exist_ok=True) - for key, obj in ex.tex_objs.items(): - rel = f"textures/{identity(key)}.png" - if rel not in written or not (out / rel).is_file(): - obj.read().image.save(out / rel, format="PNG") - written.add(rel) - result["textures"][key] = rel - for key, sprite in ex.sprite_recs.items(): - texture_key = sprite.get("textureRef") - if texture_key is None: - texture_key = f"{sprite['tex'].assets_file.name}:{sprite['tex'].path_id}" - result["sprites"][key] = {**{k: v for k, v in sprite.items() if k != "tex"}, "textureRef": texture_key} - for key, image in getattr(ex, "decoded_sprites", {}).items(): - rel = f"textures/{identity(key)}.png" - if rel not in written or not (out / rel).is_file(): - image.save(out / rel, format="PNG") - written.add(rel) - result["textures"][key] = rel - for obj in ex._objects.values(): - if obj.type.name != "Font": - continue - font = obj.read() - if font.m_FontData: - rel = f"fonts/{object_id(obj)}.ttf" - (out / "fonts").mkdir(exist_ok=True) - if rel not in written or not (out / rel).is_file(): - (out / rel).write_bytes(bytes(font.m_FontData)) - written.add(rel) - result["fonts"][font.m_Name] = rel - return result - - -def _record(out, ident, name, kind, document, resource_table, directory, **extra): - rel = f"{directory}/{ident}.json" - write_json( - out / rel, - { - "schema": 1, - "format": "ournotes-ui-pack", - "resourceBase": "../", - "document": document, - "resources": resource_table, - }, - ) - nodes = document.get("nodes", []) - files = { - rel, - *resource_table.get("textures", {}).values(), - *resource_table.get("fonts", {}).values(), - *resource_table.get("fontMetricsByAsset", {}).values(), - } - if resource_table.get("fontMetrics"): - files.add(resource_table["fontMetrics"]) + for oid in doc["prefabs"]: + if oid not in doc["roots"]: + embedded.setdefault(oid, {**rows[oid], "manifest": manifest, "roots": [oid], "kind": "prefab"}) + for oid in doc["controllers"]: + controllers.setdefault(oid, {**rows[oid], "manifest": manifest}) return { - "id": ident, - "name": name, - "kind": kind, - "file": rel, - "status": "exported", - "sha256": hashlib.sha256((out / rel).read_bytes()).hexdigest(), - "node_count": len(nodes), - "classes": dict(Counter(c.get("class", c["type"]) for n in nodes for c in n["components"])), - "files": {name: hashlib.sha256(_output_file(out, name).read_bytes()).hexdigest() for name in sorted(files)}, - "runtime_verified": False, - **extra, - } - - -def _output_file(out, name): - file = (out / name).resolve() - if not file.is_relative_to(out): - raise ValueError("UI index contains a file outside its output directory") - return file - - -def _valid_record(out, record): - files = record.get("files") if record else None - if not files or record.get("status") != "exported": - return False - if record.get("file") not in files: - return False - _output_file(out, record["file"]) - for name, expected in files.items(): - file = _output_file(out, name) - if not file.is_file() or hashlib.sha256(file.read_bytes()).hexdigest() != expected: - return False - return True - - -def export_library( - cat, out, *, player=None, keys=None, prefix="EmbUI/", dependencies=True, force=False, provenance=None -): - """Incrementally export keys, their unregistered prefab roots and controllers. - - Resume verifies packs, dependency packs and every texture/font hash; failures stay explicit in index.json. - Existing unrelated entries remain present when exporting an additional key. - """ - out = Path(out).resolve() - out.mkdir(parents=True, exist_ok=True) - for directory in ("packs", "embedded", "controllers", "fonts", "textures"): - (out / directory).mkdir(exist_ok=True) - keys = sorted(set(keys if keys is not None else cat.keys(prefix))) - old = json.loads((out / "index.json").read_text(encoding="utf-8")) if (out / "index.json").is_file() else {} - provenance = provenance or { - "apk_catalog_sha256": hashlib.sha256(cat.sources().get("apk", cat.sources()["remote"])).hexdigest() + "format": LIBRARY, + "assets": assets, + "embedded": [embedded[k] for k in sorted(embedded)], + "controllers": [controllers[k] for k in sorted(controllers)], } - if old and old.get("provenance") != provenance: - raise ValueError("UI output belongs to different input data; use a separate output directory") - assets = {e["key"]: e for e in old.get("assets", [])} - embedded = {e["id"]: e for e in old.get("embedded", [])} - controllers = {e["id"]: e for e in old.get("controllers", [])} - written, processed_roots, processed_controllers = set(), set(), set() - - def exporter(): - return UIExporter( - cat, - out, - player=player, - textures="deferred", - inline_meshes=False, - stub_assets=("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet"), - ) - - def save_index(): - rows = sorted(assets.values(), key=lambda e: e["key"]) - index = { - "schema": 1, - "format": "ournotes-ui-library", - "scope": "serialized UI assets and dependency roots", - "counts": dict(Counter(e["status"] for e in rows)), - "assets": rows, - "embedded": sorted(embedded.values(), key=lambda e: e["id"]), - "controllers": sorted(controllers.values(), key=lambda e: e["id"]), - "runtime_verified": False, - "provenance": provenance, - } - write_atomic(out / "index.json", dumps(index, indent=1, ensure_ascii=False).encode("utf-8")) - return index - - for key in keys: - previous = assets.get(key) - if not force and previous is not None and _valid_record(out, previous): - refs = previous.get("dependencies", {}) - valid_deps = ( - not dependencies - or previous.get("dependency_export") - and all(_valid_record(out, embedded.get(i)) for i in refs.get("embedded", [])) - and all(_valid_record(out, controllers.get(i)) for i in refs.get("controllers", [])) - ) - if valid_deps: - continue - ident, name = identity(key), key.rsplit("/", 1)[-1] - try: - ex = exporter() - refs = {"embedded": [], "controllers": []} - document = ex.export_key(key) - env, graph = ex.load(key) - main_roots = {n["nodeId"] for n in document.get("nodes", []) if n["path"].count("/") == 0} - if dependencies and document.get("nodes"): - for pid, transform in graph.tf.items(): - if transform["m_Father"]["m_PathID"]: - continue - go_pid = transform["m_GameObject"]["m_PathID"] - root_id = identity(f"{ex._go_file[go_pid]}:{go_pid}") - if root_id in main_roots: - continue - refs["embedded"].append(root_id) - go_name = graph.go[go_pid]["m_Name"] - if root_id not in processed_roots and (force or not _valid_record(out, embedded.get(root_id))): - doc = {"key": go_name, "nodes": ex.hierarchy(env, graph, pid)} - embedded[root_id] = _record( - out, - root_id, - go_name, - "prefab", - doc, - resources(ex, doc, out, written), - "embedded", - source_key=key, - ) - processed_roots.add(root_id) - if dependencies: - for obj in env.objects: - if obj.type.name != "AnimatorController": - continue - cid = object_id(obj) - refs["controllers"].append(cid) - if cid in processed_controllers or not force and _valid_record(out, controllers.get(cid)): - continue - fresh = exporter() - fresh.load(key) - owned = fresh._objects[(obj.assets_file.name, obj.path_id)] - doc = fresh.controller(owned) - controllers[cid] = _record( - out, - cid, - doc["name"], - "controller", - doc, - resources(fresh, doc, out, written), - "controllers", - source_key=key, - ) - processed_controllers.add(cid) - kind = cat.entry(key)["internal_id"].rsplit(".", 1)[-1] - assets[key] = _record( - out, - ident, - name, - kind, - document, - resources(ex, document, out, written), - "packs", - key=key, - dependencies=refs, - dependency_export=dependencies, - ) - except Exception as exc: - assets[key] = { - "id": ident, - "name": name, - "key": key, - "status": "failed", - "error_type": type(exc).__name__, - "error": str(exc), - "runtime_verified": False, - } - save_index() - return save_index() - - -def export_apk_library( - cfg, out, *, keys=None, prefix: str = "EmbUI/", limit: int | None = None, dependencies=True, force=False -) -> dict: - """The library of the APK catalog's keys `keys` (else the keys under `prefix`, the first `limit`), with the - provenance of the APK it was read from -> its index (export_library). A key the APK catalog does not have is a - ConfigError.""" - from ..source.apkset import MANIFEST_IN_APK, ApkSet, manifest_version_code, manifest_version_name - from ..source.catalog import APK_CATALOG, Catalog - from ..source.config import ConfigError - from ..source.session import Session - from ..unity.player import PlayerData - - apk = cfg.require_path("paths", "apk") - with ApkSet(apk) as source: - catalog_data = source.read(APK_CATALOG) - members = sorted((i.filename, i.CRC, i.file_size) for i in source.infolist()) - manifest = source.read(MANIFEST_IN_APK) if MANIFEST_IN_APK in source.namelist() else b"" - provenance = { - "apk_catalog_sha256": hashlib.sha256(catalog_data).hexdigest(), - "apk_members_sha256": hashlib.sha256(dumps(members).encode("utf-8")).hexdigest(), - "region": cfg.get("catalog", "region"), - "client": {"versionName": manifest_version_name(manifest), "versionCode": manifest_version_code(manifest)}, - } - cat = Catalog(catalog_data, Session(cfg).downloads(), bundle_key=lambda: Session(cfg).bundle_key(), apk=apk) - keys = keys or cat.keys(prefix) - if limit is not None: - keys = keys[:limit] - unknown = sorted(set(keys) - set(cat.keys(""))) - if unknown: - raise ConfigError("UI key not in the APK catalog: " + ", ".join(unknown)) - try: - return export_library( - cat, - out, - player=PlayerData(Session(cfg).apk()), - keys=keys, - dependencies=dependencies, - force=force, - provenance=provenance, - ) - except ValueError as exc: - raise ConfigError(str(exc)) from None diff --git a/src/nnnotes/pipeline/assemble.py b/src/nnnotes/pipeline/assemble.py index 951013e..f48ca4d 100644 --- a/src/nnnotes/pipeline/assemble.py +++ b/src/nnnotes/pipeline/assemble.py @@ -75,20 +75,25 @@ def master_input(env, record: dict) -> Input: def request(stages: dict, products: dict, env) -> dict: """The fact "roots" (pipeline.units) of a request for `products` {stage name: subjects}: the catalog keys, the resource paths, the built-in selectors and the root objects of every subject, sorted.""" - keys, resources, builtins, objects = set(), set(), set(), set() + groups = {name: set() for name in ("keys", "resources", "builtins", "objects")} + font_roots = {name: set() for name in groups} for name, subjects in sorted(products.items()): stage = stages[name] for s in subjects: - keys.update(stage.keys(s, env)) - resources.update(stage.resources(s, env)) - builtins.update(stage.builtins(s, env)) - objects.update(stage.root_objects(s, env)) - return { - "keys": sorted(keys), - "resources": sorted(resources), - "builtins": sorted(builtins), - "objects": sorted(objects), - } + for kind, method in ( + ("keys", "keys"), + ("resources", "resources"), + ("builtins", "builtins"), + ("objects", "root_objects"), + ): + values = getattr(stage, method)(s, env) + groups[kind].update(values) + if stage.INCLUDE_FONTS: + font_roots[kind].update(values) + result: dict = {kind: sorted(values) for kind, values in groups.items()} + if any(font_roots.values()): + result["font_roots"] = {kind: sorted(values) for kind, values in font_roots.items()} + return result def _entry(item: dict, arts: dict) -> dict: @@ -152,7 +157,7 @@ def get(self, env, tid: str) -> tuple[list, dict]: return doc -def reach(env, roots, results: _Results | None = None, keys: dict | None = None) -> Reach: +def reach(env, roots, results: _Results | None = None, keys: dict | None = None, *, fonts=False) -> Reach: """The objects reachable from the object ids `roots` (module documentation); `keys` {catalog key: its object ids, all among the roots} goes into the table as it is.""" results = results or _Results() @@ -189,9 +194,9 @@ def reach(env, roots, results: _Results | None = None, keys: dict | None = None) t = table.subject_of(r) if t is None: missing.add(r) - elif table.fences(s, t): + elif not fonts and table.fences(s, t): blocked[r] = table.info[r] - elif t not in seen and table.reaches(s, t, s in start): + elif t not in seen and table.reaches(s, t, s in start, fonts=fonts): todo.append(t) for atlas, sprites in sorted(crops.items()): s = table.subject_of(atlas) @@ -199,7 +204,11 @@ def reach(env, roots, results: _Results | None = None, keys: dict | None = None) cut = { o for o in sprites - if s is not None and s not in seen and (src := table.subject_of(o)) is not None and table.fences(src, s) + if not fonts + and s is not None + and s not in seen + and (src := table.subject_of(o)) is not None + and table.fences(src, s) } if cut: blocked[atlas] = table.info[atlas] @@ -409,6 +418,7 @@ class ProductStage(Stage): after = (ADDRESSES, EXPORT, CROP, AUDIO, MOVIE) KIND = "product" + INCLUDE_FONTS = False MASTER: tuple[str, ...] = () APK: tuple[str, ...] = () @@ -485,7 +495,7 @@ def reach(self, subject: str, env) -> Reach: memo = self._memo.get(subject) if memo is not None and memo[0] == sig and all(env.key(t) == k for t, k in memo[1]): return memo[2] - r = reach(env, roots, self._results, by_key) + r = reach(env, roots, self._results, by_key, fonts=self.INCLUDE_FONTS) self._memo[subject] = (sig, [(t, env.key(t)) for t in r.read], r) return r diff --git a/src/nnnotes/pipeline/ui.py b/src/nnnotes/pipeline/ui.py new file mode 100644 index 0000000..1ef1ee1 --- /dev/null +++ b/src/nnnotes/pipeline/ui.py @@ -0,0 +1,36 @@ +"""UI products and their library index.""" + +from ..content import ui +from . import contract +from .assemble import Assembly, ProductStage + +UI = "ui.pack" + + +class UiStage(ProductStage): + """A catalog key with its prefab, animation, image and font object dependencies.""" + + name = UI + version = 1 + KIND = "UI" + INCLUDE_FONTS = True + + def select(self, keys: list[str], env) -> list[str]: + return sorted(k for k in keys if k.startswith("EmbUI/")) + + def keys(self, subject: str, env) -> list[str]: + return [subject] + + def manifest(self, subject: str) -> str: + return f"uipacks/{ui.identity(subject)}.json" + + def assemble(self, task, objects) -> Assembly: + doc, roles = ui.pack(objects, task.subject) + return Assembly({"ui.json": (contract.encode(doc), "json", UI)}, roles=roles) + + def layout_index(self, results, depot) -> tuple[str, bytes]: + packs = [] + for subject, result in results: + artifact = next(a for a in result["artifacts"] if a["id"].endswith("#ui.json")) + packs.append((self.manifest(subject), contract.loads(depot.read(artifact["content"]["sha256"])))) + return "index.json", contract.encode(ui.library(packs)) diff --git a/src/nnnotes/pipeline/units.py b/src/nnnotes/pipeline/units.py index e359965..e0cf159 100644 --- a/src/nnnotes/pipeline/units.py +++ b/src/nnnotes/pipeline/units.py @@ -105,9 +105,9 @@ def subject_of(self, oid: str) -> str | None: """The subject of the unit holding object `oid` (None when no census read holds it).""" return self.of.get(oid) - def reaches(self, source: str, target: str, start: bool) -> bool: + def reaches(self, source: str, target: str, start: bool, *, fonts=False) -> bool: """A reference of the unit `source` to the unit `target` makes the target reachable (_reaches).""" - return _reaches(self.kinds, self.fenced, source, target, start) + return _reaches(self.kinds, () if fonts else self.fenced, source, target, start) def fences(self, source: str, target: str) -> bool: """The font fence cuts a reference of the unit `source` to the unit `target` (_fences).""" @@ -195,7 +195,7 @@ def _census_info(doc: dict) -> dict: } -def build(censuses, bundles, selected, classes=None, roots=(), fenced=()) -> Units: +def build(censuses, bundles, selected, classes=None, roots=(), fenced=(), font_roots=()) -> Units: """The Units of a request over `censuses` [(stable bundle name, census document)] of the bundles `bundles`, from the units of the bundles `selected` and of the objects `roots`; `fenced` the stable names of the fenced bundles (module documentation).""" @@ -232,6 +232,17 @@ def build(censuses, bundles, selected, classes=None, roots=(), fenced=()) -> Uni if s is not None and s not in want and _reaches(kinds, fenced_units, source, s, source in start): want.add(s) todo.append(s) + font_start = {of[o] for o in font_roots if o in of} + font_seen = set(font_start) + todo = list(font_start) + while todo: + source = todo.pop() + for oid in refs.get(source, ()): + s = of.get(oid) + if s is not None and s not in font_seen and _reaches(kinds, (), source, s, source in font_start): + font_seen.add(s) + todo.append(s) + want.update(font_seen) if classes is not None: keep = set(classes) want = {s for s in want if units[s].cls in keep} @@ -326,12 +337,15 @@ def table(env) -> Units: request.get("builtins", ()), ) fenced = font_bundles(env) + font_request = request.get("font_roots") or {} + font_roots = root_objects(env, **font_request) sig = ( id(env.depot), tuple(keys), tuple(env.fact("selected")), None if classes is None else tuple(classes), tuple(roots), + tuple(font_roots), tuple(sorted(fenced)), ) if _MEMO and _MEMO[0][0] == sig: @@ -344,6 +358,6 @@ def censuses(): rec = next(a for a in env.depot.committed(key, tid)["artifacts"] if a["id"] == aid) yield b, contract.loads(env.depot.read(rec["content"]["sha256"])) - t = build(censuses(), bundles, env.fact("selected"), classes, roots, fenced) + t = build(censuses(), bundles, env.fact("selected"), classes, roots, fenced, font_roots) _MEMO[:] = [(sig, t)] return t diff --git a/src/nnnotes/schemas/ui-library.schema.json b/src/nnnotes/schemas/ui-library.schema.json new file mode 100644 index 0000000..e130916 --- /dev/null +++ b/src/nnnotes/schemas/ui-library.schema.json @@ -0,0 +1,148 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:nnnotes:schema:ui-library", + "type": "object", + "required": [ + "format", + "assets", + "embedded", + "controllers" + ], + "properties": { + "format": { + "const": "nnnotes.ui-library/1" + }, + "assets": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "manifest", + "key", + "kind", + "roots" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "manifest": { + "type": "string", + "minLength": 1 + }, + "roots": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "key": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string" + }, + "nodeCount": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "embedded": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "manifest", + "roots", + "kind" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "manifest": { + "type": "string", + "minLength": 1 + }, + "roots": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "key": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string" + }, + "nodeCount": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "controllers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "manifest" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "manifest": { + "type": "string", + "minLength": 1 + }, + "roots": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "key": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string" + }, + "nodeCount": { + "type": "integer", + "minimum": 0 + } + } + } + } + }, + "title": "UI product library" +} diff --git a/src/nnnotes/schemas/ui-pack.schema.json b/src/nnnotes/schemas/ui-pack.schema.json new file mode 100644 index 0000000..ed6b3cc --- /dev/null +++ b/src/nnnotes/schemas/ui-pack.schema.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:nnnotes:schema:ui-pack", + "type": "object", + "required": [ + "format", + "key", + "roots", + "prefabs", + "controllers", + "entries", + "bindings", + "shaders" + ], + "properties": { + "format": { + "const": "nnnotes.ui-pack/1" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "roots": { + "type": "array", + "items": { + "type": "string", + "pattern": "^.+:-?[0-9]+$" + }, + "minItems": 1 + }, + "prefabs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^.+:-?[0-9]+$" + } + }, + "controllers": { + "type": "array", + "items": { + "type": "string", + "pattern": "^.+:-?[0-9]+$" + } + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "class" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "class": { + "type": "string" + }, + "nodeCount": { + "type": "integer", + "minimum": 0 + }, + "classes": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + } + } + } + }, + "bindings": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "required": [ + "path", + "attribute" + ], + "properties": { + "path": { + "type": [ + "string", + "null" + ] + }, + "attribute": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "propertyNames": { + "type": "string", + "pattern": "^.+:-?[0-9]+$" + } + } + }, + "propertyNames": { + "type": "string", + "pattern": "^.+:-?[0-9]+$" + } + }, + "shaders": { + "type": "object", + "additionalProperties": { + "type": "string", + "pattern": "^.+:-?[0-9]+$" + } + } + }, + "title": "UI object product" +} diff --git a/tests/content/test_ui.py b/tests/content/test_ui.py index f17ebab..224f6b5 100644 --- a/tests/content/test_ui.py +++ b/tests/content/test_ui.py @@ -1,241 +1,106 @@ -"""UI export/resume contracts with synthetic objects, images and split packages only.""" +"""Synthetic UI product graphs, library indexes and CLI selection.""" -import json -import zipfile from types import SimpleNamespace as NS import pytest -from PIL import Image -from nnnotes import cli +from nnnotes.cli import export from nnnotes.content import ui -from nnnotes.source.config import Config -from nnnotes.source.session import Session -from nnnotes.unity import player +from nnnotes.pipeline.assemble import Objects +from nnnotes.pipeline.contract import Task, encode +from nnnotes.pipeline.depot import Depot +from nnnotes.pipeline.stages import execute, registry +from nnnotes.pipeline.ui import UI, UiStage +from tests.helpers.live2dmodel import ref +from tests.helpers.livenotes import clip, comp, node, table KEY = "EmbUI/Example/Box" +ROOT, CHILD, CTL, CLIP = (f"example:{i}" for i in range(1, 5)) -class Catalog: - def __init__(self, data=b"synthetic catalog", *args, **kwargs): - self._sources = {"remote": data, "apk": data} - - def sources(self): - return dict(self._sources) - - def keys(self, prefix): - return [k for k in [KEY, KEY + "Alias"] if k.startswith(prefix)] - - def entry(self, key): - return {"internal_id": "Assets/Example/Box.prefab"} - - -class Exporter: - exports = 0 - controller_exports = 0 - fail = False - - def __init__(self, *args, **kwargs): - self.controller_obj = NS(type=NS(name="AnimatorController"), assets_file=NS(name="synthetic"), path_id=3) - texture = NS(type=NS(name="Texture2D"), read=lambda: NS(image=Image.new("RGBA", (2, 2), (8, 16, 24, 255)))) - self.tex_objs = {"synthetic:7": texture} - self.sprite_recs = {} - self._objects = {("synthetic", 3): self.controller_obj} - self._go_file = {1: "synthetic", 2: "synthetic"} - - def export_key(self, key): - type(self).exports += 1 - if self.fail: - raise RuntimeError("synthetic unsupported component") - return { - "key": key, - "nodes": [ - { - "nodeId": ui.identity("synthetic:1"), - "path": "Box", - "rect": {"m_SizeDelta": {"x": -30, "y": 90}}, - "components": [], - } - ], - } - - def load(self, key): - transforms = { - 10: {"m_Father": {"m_PathID": 0}, "m_GameObject": {"m_PathID": 1}}, - 20: {"m_Father": {"m_PathID": 0}, "m_GameObject": {"m_PathID": 2}}, - } - return NS(objects=[self.controller_obj]), NS(tf=transforms, go={1: {"m_Name": "Box"}, 2: {"m_Name": "Extra"}}) - - def hierarchy(self, env, graph, pid): - return [{"nodeId": ui.identity("synthetic:2"), "path": "Extra", "components": []}] - - def controller(self, obj): - assert obj is self.controller_obj, "the new exporter must use its own loaded object" - type(self).controller_exports += 1 - return {"name": "Example", "clips": []} - - -@pytest.fixture -def exporter(monkeypatch): - Exporter.exports = Exporter.controller_exports = 0 - Exporter.fail = False - monkeypatch.setattr(ui, "UIExporter", Exporter) - return Exporter - - -def read(path): - return json.loads(path.read_text(encoding="utf-8")) - - -def test_export_preserves_signed_rects_and_stable_dependency_identity(tmp_path, exporter): - index = ui.export_library(Catalog(), tmp_path, keys=[KEY]) - entry = index["assets"][0] - assert entry["kind"] == "prefab" - pack = read(tmp_path / entry["file"]) - assert pack["document"]["nodes"][0]["rect"]["m_SizeDelta"]["x"] == -30 +def graph(tmp_path): + depot = Depot(tmp_path / "depot") + doc = { + "name": "Box", + "nodes": [ + node("Box", comp("Animator", 10, m_Controller=ref(CTL))), + node("Box/Image", comp("SpriteRenderer", 11, m_Color={"r": 1, "g": 1, "b": 1, "a": 1})), + ], + } + records = { + ROOT: ("GameObject", "Box", {"json": doc}), + CHILD: ("GameObject", "Box", {"json": {"name": "Box", "nodes": [node("Box")]}}), + CTL: ("AnimatorController", "Show", {"json": {"name": "Show", "clips": [ref(CLIP)]}}), + CLIP: ("AnimationClip", "Show", {"json": clip("Show", "Image")}), + } + return depot, Objects(table(depot, {KEY: [ROOT]}, records), depot) + + +def test_ui_objects_keep_prefab_and_animation_identity(tmp_path): + _, objects = graph(tmp_path) + before = encode(objects.json(ROOT)) + doc, roles = ui.pack(objects, KEY) + assert doc["roots"] == [ROOT] + assert doc["prefabs"] == [ROOT, CHILD] + assert doc["controllers"] == [CTL] + assert doc["bindings"][ROOT]["Box"][CLIP][0]["path"] == "Image" + assert doc["bindings"][ROOT]["Box"][CLIP][0]["attribute"] == "m_Color.a" + assert doc["entries"][0]["nodeCount"] == 2 + assert doc["entries"][0]["classes"] == {"Animator": 1, "SpriteRenderer": 1} + assert encode(objects.json(ROOT)) == before + assert roles == {} + with pytest.raises(KeyError): + ui.pack(objects, "unknown") + + +def test_library_indexes_share_dependency_objects_and_distinguish_keys(tmp_path): + _, objects = graph(tmp_path) + doc, _ = ui.pack(objects, KEY) + other = {**doc, "key": KEY + "Alias"} + index = ui.library([("uipacks/b.json", other), ("uipacks/a.json", doc)]) + assert index["format"] == ui.LIBRARY + assert len({e["id"] for e in index["assets"]}) == 2 assert len(index["embedded"]) == len(index["controllers"]) == 1 - assert entry["dependencies"]["controllers"] == [index["controllers"][0]["id"]] - assert all((tmp_path / name).is_file() for name in entry["files"]) - assert index["runtime_verified"] is False + assert index["embedded"][0]["id"] == CHILD + assert index["controllers"][0]["id"] == CTL + assert index["assets"][0]["roots"] == [ROOT] + assert ui.library(reversed([("uipacks/b.json", other), ("uipacks/a.json", doc)])) == index -@pytest.mark.parametrize("damage", ["pack", "texture", "controller", "embedded"]) -def test_resume_repairs_corrupt_or_missing_dependencies(tmp_path, exporter, damage): - first = ui.export_library(Catalog(), tmp_path, keys=[KEY]) - before = exporter.exports - ui.export_library(Catalog(), tmp_path, keys=[KEY]) - assert exporter.exports == before - entry = first["assets"][0] - target = ( - entry["file"] - if damage == "pack" - else ( - "textures/" + ui.identity("synthetic:7") + ".png" - if damage == "texture" - else first["controllers" if damage == "controller" else "embedded"][0]["file"] - ) - ) - (tmp_path / target).write_bytes(b"corrupt") - result = ui.export_library(Catalog(), tmp_path, keys=[KEY]) - assert exporter.exports == before + 1 - assert (tmp_path / target).read_bytes() != b"corrupt" - assert ui._valid_record(tmp_path, result["assets"][0]) - +def test_ui_stage_retains_the_graph_in_a_product(tmp_path): + depot, objects = graph(tmp_path) + stage = UiStage() + source = objects.doc + from nnnotes.pipeline.contract import Input -def test_enabling_dependency_export_after_a_minimal_export(tmp_path, exporter): - first = ui.export_library(Catalog(), tmp_path, keys=[KEY], dependencies=False) - assert not first["controllers"] and not first["embedded"] - complete = ui.export_library(Catalog(), tmp_path, keys=[KEY]) - assert len(complete["controllers"]) == len(complete["embedded"]) == 1 - - -def test_force_exports_a_shared_controller_once_and_keeps_unrelated_entries(tmp_path, exporter): - ui.export_library(Catalog(), tmp_path, keys=[KEY]) - exporter.controller_exports = 0 - result = ui.export_library(Catalog(), tmp_path, force=True) - assert len(result["assets"]) == 2 - assert exporter.controller_exports == 1 - - -def test_a_different_input_cannot_mix_with_an_existing_library(tmp_path, exporter): - ui.export_library(Catalog(), tmp_path, keys=[KEY]) - original = (tmp_path / "index.json").read_bytes() - with pytest.raises(ValueError, match="separate output directory"): - ui.export_library(Catalog(b"another release"), tmp_path, keys=[KEY]) - assert (tmp_path / "index.json").read_bytes() == original - - -def test_resume_rejects_paths_outside_the_output(tmp_path, exporter): - result = ui.export_library(Catalog(), tmp_path, keys=[KEY]) - result["assets"][0]["files"]["../outside.png"] = "invalid" - (tmp_path / "index.json").write_text(json.dumps(result), encoding="utf-8") - with pytest.raises(ValueError, match="outside"): - ui.export_library(Catalog(), tmp_path, keys=[KEY]) - - -def test_failure_is_an_explicit_index_entry(tmp_path, exporter): - exporter.fail = True - result = ui.export_library(Catalog(), tmp_path, keys=[KEY]) - assert result["counts"] == {"failed": 1} - assert result["assets"][0]["error_type"] == "RuntimeError" - - -def test_cli_selects_the_jp_split_apk_and_keeps_public_provenance(tmp_path, exporter, monkeypatch, capsys): - from nnnotes.source import catalog - - apk = tmp_path / "jp" - apk.mkdir() - with zipfile.ZipFile(apk / "base.apk", "w") as z: - z.writestr("assets/boot-data", b"synthetic boot") - with zipfile.ZipFile(apk / "split_Unity.apk", "w") as z: - z.writestr(catalog.APK_CATALOG, b"synthetic catalog") - monkeypatch.setattr(catalog, "Catalog", Catalog) - monkeypatch.setattr(Session, "apk", lambda self, region=None: None) - monkeypatch.setattr(player, "PlayerData", lambda apk: None) - cfg = Config( - { - "catalog": {"region": "jp"}, - "servers": {"jp": {"apk": str(apk)}}, - "paths": {"apk": "wrong-global.apk", "depot": str(tmp_path / "depot")}, - }, - environ={}, - ) - args = cli.parse_args(["export", "ui", "--key", KEY, "-o", str(tmp_path / "out")]) - args.func(args, cfg) - index = read(tmp_path / "out/index.json") - assert index["provenance"]["region"] == "jp" - assert len(index["provenance"]["apk_members_sha256"]) == 64 - assert "exported" in capsys.readouterr().out - - -def test_limit_is_validated_before_any_apk_read(tmp_path, capsys): - args = cli.parse_args(["export", "ui", "--limit", "0", "-o", str(tmp_path)]) - with pytest.raises(SystemExit) as exc: - args.func(args, Config(environ={})) - assert exc.value.code == 2 - assert "--limit must be positive" in capsys.readouterr().err - - -def test_all_used_numeric_font_faces_keep_independent_metrics_and_resource_hashes(tmp_path): - atlas = NS( - type=NS(name="Texture2D"), - assets_file=NS(name="synthetic-font"), - path_id=9, - read=lambda: NS(image=Image.new("RGBA", (2, 2), (128, 128, 128, 128))), + body = encode(source) + task = Task( + UI, stage.version, KEY, {}, {}, (Input("objects", depot.put(body), len(body), None, ({"kind": "depot"},)),) ) - objects = {} - names = ["VibeMO Synthetic Regular", "VibeMO Synthetic Medium"] - for i, name in enumerate(names): - tree = { - "m_Name": name, - "m_AtlasTextures": [{}], - "m_FaceInfo": {"m_PointSize": 32}, - "m_CharacterTable": [{"m_Unicode": 65, "m_GlyphIndex": 1}], - "m_GlyphTable": [{"m_Index": 1}], - } - objects[name] = NS( - type=NS(name="MonoBehaviour"), - path_id=i, - assets_file=NS(name="synthetic-font"), - read_typetree=lambda tree=tree: tree, - ) - ex = NS( - _objects=objects, - tex_objs={}, - sprite_recs={}, - script_class=lambda o: "TMP_FontAsset", - deref=lambda o, ptr: atlas, + execute(task, depot, registry(stage)) + result = depot.result(task.key) + path, body = stage.layout_index([(KEY, result)], depot) + assert path == "index.json" + assert ui.identity(KEY).encode() in body + assert stage.INCLUDE_FONTS + + +def test_ui_command_requests_only_selected_apk_keys(monkeypatch, tmp_path): + requests = [] + ws = NS( + run_index=lambda: None, + index=lambda: { + "locations": [ + {"id": "apk:1", "primaryKey": KEY, "kind": "asset"}, + {"id": "apk:2", "primaryKey": KEY + "Alias", "kind": "asset"}, + {"id": "remote:1", "primaryKey": "EmbUI/Remote", "kind": "asset"}, + ] + }, + request=lambda products, selection: requests.append((products, selection)), ) - doc = { - "nodes": [ - {"components": [{"type": "MonoBehaviour", "class": "TextMeshProUGUI", "m_fontAsset": {"name": name}}]} - for name in names - ] - } - table = ui.resources(ex, doc, tmp_path) - assert set(table["fontMetricsByAsset"]) == set(names) - assert len(set(table["fontMetricsByAsset"].values())) == 2 - for file in table["fontMetricsByAsset"].values(): - data = read(tmp_path / file) - assert data["textureBase"] == "metrics" - assert (tmp_path / file).parent.joinpath(data["texture"]).is_file() + monkeypatch.setattr(export.assets, "Workspace", lambda *a, **kw: ws) + monkeypatch.setattr(export.assets, "export", lambda *a: None) + monkeypatch.setattr(export, "use", lambda cfg: None) + args = NS(limit=1, key=None, prefix="EmbUI/", out=tmp_path) + export.cmd_ui(args, NS(require_path=lambda *a: tmp_path / "game.apk")) + assert requests == [({UI: [KEY]}, [f"key:{KEY}"])] diff --git a/tests/pipeline/test_assemble.py b/tests/pipeline/test_assemble.py index 549e541..73e103c 100644 --- a/tests/pipeline/test_assemble.py +++ b/tests/pipeline/test_assemble.py @@ -297,6 +297,24 @@ def test_a_fenced_root_reaches_out_of_the_fence_and_takes_the_image_of_its_atlas assert f"{ATLAS_CAB}:99" in table["objects"] and set(table["objects"][oid(13)]["files"]) == {"json", "image"} +def test_a_product_can_include_referenced_font_objects_without_affecting_other_products(tmp_path): + class WithFonts(Toy): + INCLUDE_FONTS = True + + depot = Depot(tmp_path / "s") + env = env_for(depot, {"key/a": [oid(13)]}, selected=[], fonts=["atlas"]) + stage = WithFonts({"m/a": "key/a"}) + env.facts["roots"] = assemble.request({stage.name: stage}, {stage.name: ["m/a"]}, env) + assert "atlas:99" in ExportStage(FAKE_READER).subjects(env) + exported(depot, env) + _, doc = product(depot, env, "m/a", stage=stage) + table = table_of(depot, doc) + assert table["fenced"] == {} + assert set(table["objects"][oid(13)]["files"]) == {"json", "image"} + _, ordinary = product(depot, env, "m/a", {"m/a": "key/a"}) + assert f"{ATLAS_CAB}:99" in table_of(depot, ordinary)["fenced"] + + def test_depends_names_the_units_it_reads(tmp_path): depot = Depot(tmp_path / "s") env = env_for(depot, {"key/a": [oid(60)]}) From b1cb682790c5cb8dbc8a6ab112181b04b007d53d Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sun, 18 Oct 2026 13:14:39 +0800 Subject: [PATCH 2/2] docs(ui): link schema files from the documentation site --- docs/contracts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contracts.md b/docs/contracts.md index 69ea77a..85e2aa3 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -311,5 +311,5 @@ Every request names one object; only `depot gc --remote` lists the remote. `nnnotes.ui-pack/1` describes a UI product's root objects and animation bindings. `nnnotes.ui-library/1` indexes its selected keys, referenced prefabs and controllers. Both use the shared `nnnotes.product/1` object storage contract; see [UI libraries](ui.md). -- [ui-pack.schema.json](../src/nnnotes/schemas/ui-pack.schema.json) -- [ui-library.schema.json](../src/nnnotes/schemas/ui-library.schema.json) +- [ui-pack.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/ui-pack.schema.json) +- [ui-library.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/ui-library.schema.json)