diff --git a/docs/cli.md b/docs/cli.md index e6788a2..9cd8867 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -195,11 +195,14 @@ an ADV episode -> JSON ## nnnotes export story -an ADV episode -> the story product and the products it plays with (scene, assets, Live2D models, cue sheets, videos, chat, CRI Lips) +an ADV episode -> the story product and the products it plays with (scene, assets, Live2D models, cue sheets, videos, chat, UI, fonts, CRI Lips) | Argument | Description | | --- | --- | | `adv_id` | MasterAdv id of the episode | +| `--story-languages` `LANG,...` | text languages, comma-separated (default: [catalog] language) | +| `--fonts` `FONTS` | glyph source: open font files (default) or game TMP assets (one of `open`, `game`) | +| `--font` `LANG=PATH` | font file for a language, or emoji=PATH (repeatable; [paths] fonts.) | | `--format` `FORMAT` | web audio format of the cue sheets (default aac) (one of `aac`, `flac`, `mp3`, `opus`, `vorbis`) | | `--flac-level` `N` | FLAC compression level 0-12 of the decoded sounds (default 8; every level decodes to the same samples) | | `-o`, `--out` `OUT` | output directory (required) | diff --git a/docs/commands.md b/docs/commands.md index 1caac51..e6f9e27 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -176,6 +176,7 @@ row of a Movie / Clip row's video id. ``` nnnotes export story ADV_ID -o OUT [--format FORMAT] [--flac-level N] [--stored-catalog LABEL|SHA] [--workers N] + [--story-languages LANG,...] [--fonts open|game] [--font LANG=PATH] [--link auto|clone|hard|copy] [--explain] [--dry-run] ``` @@ -190,6 +191,8 @@ OUT/models/.json each Live2D model of the episode (`live2d.model`) OUT/cuesheets/.json each cue sheet of the episode, decoded (`story.sound`) OUT/videos/.json each video of the episode, as WebM (`story.video`; : its asset, "/" as "~") OUT/storychat/default.json the shared chat rows, when the episode shows chat (`story.chat`) +OUT/storyui/.json the story UI in each requested language (`story.ui`) +OUT/storyfonts/_.json the episode's font records and glyph pages (`story.fonts`) OUT/crilips/default.json the CRI Lips analysis data, when a voice can reach it (`story.crilips`) OUT/assets/. the files the manifests name ``` @@ -205,6 +208,14 @@ has an alpha stream. A product several stories play with (the scene, a stage, a model, a cue sheet) is one product: stories exported into one directory share its manifest and files, and the depot keeps every result for the next export. +`--story-languages` selects text languages, separated by commas; the default is `[catalog] language`. +`--fonts open` (the default) generates glyph pages from the font files in `[paths.fonts]` or the repeatable +`--font LANG=PATH` flags. Supply each requested language's font and any fonts needed by its fallback chain. +`--font emoji=PATH` supplies a bitmap emoji font. Without it, open emoji sprites retain their layout with empty +images. `--fonts game` uses the client's TMP glyphs and generates the runtime glyphs its font assets require. +Both sources use font settings from the client's assets and require the `fonts` extra. Font files are explicit +content-addressed inputs, so changing their bytes rebuilds the corresponding font product. + `--format` (default `aac`) is the web audio format of the cue sheets: `aac` (AAC-LC in MP4, `cues.json` records each file's encoder delay), `opus`, `vorbis`, `mp3`, or `flac` (the decoded FLAC as it is); `--flac-level` is the FLAC compression level of the decoded sounds (as for `export audio`). `crilips/` holds the weights of the game's CRI Lips diff --git a/docs/contracts.md b/docs/contracts.md index d23c9e9..f133367 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -11,6 +11,10 @@ Every document names its format in a `schema` field (`nnnotes.result/1`). Within added, never renamed, removed or given another meaning; readers ignore fields they do not know. A breaking change is a new major version (`nnnotes.result/2`). +Episode font data uses the `format` field `nnnotes.story-fonts/1`, described in +[story-fonts.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/story-fonts.schema.json) +and [story products](products.md#stories). + Stages carry their own version. A change of what a stage writes for the same inputs is a new stage version, which changes the key of every task of that stage (golden tests fail when a stage's output changes while its version stays). diff --git a/docs/export.md b/docs/export.md index 3dddf4d..15efdbb 100644 --- a/docs/export.md +++ b/docs/export.md @@ -12,7 +12,7 @@ Every command below prints a short summary; add `--json` for its report as JSON. ```sh nnnotes export episode 10462 -o out/episode_10462.json # the episode's commands, lines and resources -nnnotes export story 10462 -o out/stories/10462 # everything the episode plays +nnnotes export story 10462 --fonts game -o out/stories/10462 # the episode with the client's TMP fonts ``` `export story` writes the story product of an episode and the products it plays with, as a product layout below `-o`: @@ -21,6 +21,11 @@ other asset in `storyassets/`, each Live2D model in `models/`, each cue sheet in `videos/` and, when the episode uses them, the shared chat rows in `storychat/default.json` and the CRI Lips data in `crilips/default.json`. The files themselves are under `assets/`. +`storyui/.json` and `storyfonts/_.json` provide the UI, text bindings and glyph pages for +`[catalog] language`. `--story-languages ja,en` selects several languages. `--fonts open` (the default) uses font +files from `[paths.fonts]` or repeatable `--font LANG=PATH` flags; supply any languages needed by the fallback chain. +Both font sources require the `fonts` extra. + Products that several stories share (the scene, assets, models and cue sheets) are placed once, so exporting several stories into one directory keeps one copy of each. Every product goes through the depot, so a second export reuses its results. diff --git a/docs/products.md b/docs/products.md index ca9eaae..a308aa6 100644 --- a/docs/products.md +++ b/docs/products.md @@ -372,6 +372,7 @@ A story episode plays with products that several episodes share, made by `nnnote | `story.video` | a video key `Cri/Video/` (the video of a Movie / Clip command) | `videos/.json`, `` the asset with `/` as `~` | the key | `video.json` and `movie.webm` | | `story.chat` | `default` | `storychat/default.json` | the shared chat shards `Adv/Chat/AdvChat-Text`, `Adv/Chat/AdvChat-Sound` and `Adv/Chat/AdvChat-SoundCueSheet` | `chat.json` | | `story.ui` | a client language (`ja`, `en`, `zh-Hant`, `zh-Hans`, `ko`) | `storyui/.json` | the story UI's catalog keys (the widget, talk windows, chat widget, dialogs, UIManager, letterbox, animations and settings), the built-in `GraphicsSettings` and `LocalizeManager`, and the resources `DOTweenSettings` and `TMP Settings` | `ui.json` and the object table | +| `story.fonts` | `/` | `storyfonts/_.json` | the UI and font keys, the episode's chat windows and frames, and the UI settings | `fonts.json`, glyph pages and text shaders | | `story` | a `MasterAdv` id | `stories/.json` | the episode key `Adv/Episode//` (``: the row's `_advEpisodeAsset`), its shard keys (the key with `-Text`, `-Sound`, `-SoundCueSheet`, `-Video`) and `EmbCommon/Adv/Settings/AdvPlayerSettings` | `episode.json` and `story.json` | `story.sound`, `story.crilips`, `story.video`, `story.chat` and `story` keep no objects: their files are all they @@ -440,6 +441,24 @@ The retained object table is the UI closure, so the font assets' files remain in `font-bundles` names the bundles holding the font assets, and `apk:AndroidManifest.xml` supplies the client version. A client requests the product for each language it uses. +`story.fonts` contains the font records for one episode and language. Its `episode` input is the first run's +`episode.json`, supplied in the planning fact `product_inputs` under `episode:`. This snapshot determines the +chat-window and frame roots before object export. Its other inputs are localized `MasterText` rows, the font +bundle record, APK version and object table. With parameter `fonts: open`, configured font files enter as +`font:` and optionally `font:emoji`; `fonts: game` reads the client's TMP assets. + +`fonts.json` has format `nnnotes.story-fonts/1`, `language`, `source`, `fonts`, `textures`, `materials`, +`materialKeywords`, `coverage`, `lineBreaking` and `shaders` (shader names to retained object ids). +`texts` maps TMP component ids to `style`, `localized` (the LocalizeText result, or null), and `font` (the effective +font asset, material and line spacing to render, including generated open font names). Ruby and Korean-adjustment +settings accompany the relevant text records. `sprites` maps driven TMP component ids to their sprite asset and +tint flag; `spriteAssets` and `emojiSpriteAsset` are present when text can draw emoji sprites. + +Glyph selection includes episode lines and names, the title, localized UI labels, status text, chat runtime +strings and frame text. It follows each primary font's fallback chain and models synthesized control characters, +underline and missing glyphs. The product retains glyph pages and the required shader programs; font files and +source atlas objects remain in the depot. Page file names in `textures` resolve through the product's `files`. + `story` is one episode. Its input `master` is the episode's master record: its `MasterAdv` row, the `MasterText` row of its title (none when `_titleTextId` is empty) and every `MasterAdvChat` row. `episode.json` is the episode as `export episode` writes it, each resource given by its kind and address: the command list with the lines of every @@ -459,5 +478,7 @@ A model's MotionSync (model.json `motionSync`) decides whether a lip-synced voic `crilips` names the data whenever a voice can reach it. The episode's talk windows are listed in `episode.json` `resources`. `parts` depend on the episode's commands, which only the story's result holds: a command asks for the `story` product first, reads its `story.json`, then asks for the story with every product `parts` names -(`nnnotes.pipeline.story.part_products`); `export story` works this way. The `product` layout makes the story of +(`nnnotes.pipeline.story.part_products`, with the requested languages); `export story` works this way. +`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. diff --git a/docs/quickstart.md b/docs/quickstart.md index 2da1d10..9be670c 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -62,14 +62,17 @@ the decoded directory the default of every command that reads master data. ```sh nnnotes export episode 10462 -o out/episode_10462.json -nnnotes export story 10462 -o out/story_10462 +nnnotes export story 10462 --fonts game -o out/story_10462 ``` `export episode` writes the episode's command list with its lines in five languages and the index of its voices, sounds and videos. `export story` writes the story product of the episode and the products it plays with below `out/story_10462`: the story index, the stage scene, the assets, the Live2D models, the cue sheets decoded to audio (AAC by default, `--format` chooses another web format), the videos as WebM, and the shared chat rows and CRI Lips -data when the episode uses them. The files are under `assets/`. Add `--json` to any command to get its report as JSON. +data when the episode uses them. It also exports the UI and glyph pages in `[catalog] language`; select several +with `--story-languages ja,en`. The example uses the client's TMP fonts. For open fonts, use +`--fonts open --font ja=/path/to/font.otf` (or configure `[paths.fonts]`). Both modes require the `fonts` extra. +The files are under `assets/`. Add `--json` to any command to get its report as JSON. ## Next diff --git a/docs/schemas.md b/docs/schemas.md index fa3c1f9..4d2922a 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -2,7 +2,7 @@ The documents nnnotes writes in a fixed format have JSON Schemas (draft 2020-12), shipped in the package `nnnotes.schemas`. Each schema has the `$id` `urn:nnnotes:schema:`, and the shared definitions are in -`common`. Every document names its format and version in a `schema` field, such as `nnnotes.plan/1`; within a major +`common`. Documents name their format and version in `schema` (such as `nnnotes.plan/1`) or `format`; within a major version fields are only added ([versioning](contracts.md#versioning)). ```python @@ -32,6 +32,7 @@ schemas.load("plan") # the schema as a dict | `result` | `nnnotes.result/1` | the result of a task, a function of its key alone | | `run` | `nnnotes.run/1` | a run manifest: context, selection, tasks with keys and statuses, layouts | | `scripts` | `nnnotes.scripts/1` | every MonoScript of the censuses, by object id | +| `story-fonts` | `nnnotes.story-fonts/1` (`format`) | an episode's text bindings, font records, glyph pages and text shaders | | `task` | `nnnotes.task/1` | one stage applied to one subject, with everything needed to run it (`depot run`) | | `view` | `nnnotes.view/1` | a semantic view of a master table, each row with its names and the objects it names | diff --git a/docs/stages.md b/docs/stages.md index 3c6fda4..ee10230 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -43,6 +43,7 @@ codec and channel of a USM) and `catalog.index` (version 2: gzip input and Japan | `story.video` | a video key `Cri/Video/` | `objects`: the object table of the key; `usm:` and `movies`: the key's USM and its `cri.movie` files with their facts ([products](products.md#stories)); the `cri.movie` task takes `format` `webm` | `video.json` and `movie.webm` | | `story.chat` | `default` | `objects`: the object table of the shared chat shards | `chat.json` | | `story.ui` | a client language (`ja`, `en`, `zh-Hant`, `zh-Hans`, `ko`) | `objects`: UI, font and chat-window objects; `ui.objects`: the retained UI closure; `master`: localized `MasterText` rows; `font-bundles`; `apk:AndroidManifest.xml` ([products](products.md#stories)) | `ui.json` (text styles and chat status texts) and the UI object table | +| `story.fonts` | `/` | `objects`; `episode`: the parsed episode snapshot; `master`: localized texts; `font-bundles`; `apk:AndroidManifest.xml`; `font:` and `font:emoji` for parameter `fonts: open` ([products](products.md#stories)) | `fonts.json`, glyph pages and the text shader objects; `fonts: game` uses TMP assets | | `story` | a `MasterAdv` id | `objects`: the object table of the episode's command list, its shards and the ADV player settings ([products](products.md#stories)); `master`: the episode's master record | `episode.json` and `story.json` | | `link.addresses` | global | catalog index, censuses (the built-in files' too) | key -> objects (a scene key: the objects of the scene's file outside a hierarchy), Resources.Load path -> objects, built-in selector (class, MonoBehaviour script class) -> objects, serialized file -> bundle or built-in file, bundle -> keys | | `view.` | a catalog (`main`) | `master:` per master table it reads, `rules` (the view's rule and the resolvers it uses), `addresses` (the part of the address table it read: the keys looked up and the prefixes listed); no parameters, atoms or context | `view.:#view` (facts: rows, entries, gaps, unreferenced) | diff --git a/src/nnnotes/cli/assets.py b/src/nnnotes/cli/assets.py index a95798f..ef07298 100644 --- a/src/nnnotes/cli/assets.py +++ b/src/nnnotes/cli/assets.py @@ -134,6 +134,7 @@ def matches(self, stage: str) -> bool: StageSource("story.video", "nnnotes.pipeline.story:VideoStage", output=True, product=True), StageSource("story.chat", "nnnotes.pipeline.story:ChatStage", output=True, product=True), StageSource("story.ui", "nnnotes.pipeline.story:UiStage", output=True, product=True), + StageSource("story.fonts", "nnnotes.pipeline.story:FontsStage", output=True, product=True), StageSource("story", "nnnotes.pipeline.story:StoryStage", output=True, product=True), StageSource("view.", "nnnotes.pipeline.views:stages", output=True), StageSource("view.voices", "nnnotes.pipeline.voices:VoiceStage", output=True), @@ -508,6 +509,7 @@ def __init__(self, args, cfg, log=_log, layouts=()): 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.product_inputs: dict[str, Input] = {} self.root = Session(cfg).depot_root() self.apk = cfg.path("paths", "apk") self.fetcher = CatalogFetcher(self.root, cfg) @@ -657,13 +659,15 @@ 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: + def request(self, products: dict, selection: list[str], inputs: dict[str, Input] | None = None) -> 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.""" + their census needs) instead of the command's: only what the products reach is exported. `inputs` supplies + immutable content inputs from a preceding run as the planning fact `product_inputs`.""" unknown = sorted(n for n in products if n not in self.installed.stages) if unknown: raise ValueError(f"no product stage {', '.join(unknown)} is installed") self.products, self.selection = {k: sorted(v) for k, v in products.items()}, list(selection) + self.product_inputs = dict(inputs or {}) def _wanted(self, name: str) -> bool: if not self.installed.sources[name].product: @@ -695,6 +699,10 @@ def params(self, pipeline: list[str]) -> dict: if fmt is not None: for n in [n for n in pipeline if "format" in stages[n].PARAMS]: out.setdefault(n, {})["format"] = fmt + source = getattr(self.args, "fonts", None) + if source is not None: + for n in [n for n in pipeline if "fonts" in stages[n].PARAMS]: + out.setdefault(n, {})["fonts"] = source level = getattr(self.args, "flac_level", None) if level is not None: if not 0 <= level <= 12: @@ -761,7 +769,14 @@ def facts(self, pipeline: list[str], fetch: bool, background: bool = False) -> d "bundles": inputs, "classes": self.classes(), "raw": Inputs({}), + "product_inputs": self.product_inputs, } + if "story.fonts" in products: + from ..source.languages import LANGUAGES + + font_paths = {code: self.cfg.path("paths.fonts", code) for code in (*LANGUAGES, "emoji")} + font_paths.update({code: Path(path) for code, path in (getattr(self.args, "font", None) or [])}) + facts["font_files"] = {code: path for code, path in font_paths.items() if path is not None} if products: from ..pipeline import assemble diff --git a/src/nnnotes/cli/export.py b/src/nnnotes/cli/export.py index 3bd64ab..f9583ab 100644 --- a/src/nnnotes/cli/export.py +++ b/src/nnnotes/cli/export.py @@ -9,7 +9,7 @@ from ..core.jsonio import write_json from ..formats.encoders import DEFAULT_AUDIO_FORMAT, WEB_AUDIO -from ..source.config import use +from ..source.config import ConfigError, use from ..source.session import Session from . import assets from .common import ( @@ -26,6 +26,8 @@ live_options, nonnegative, out_arg, + parse_font, + parse_languages, report, usage, ) @@ -60,6 +62,18 @@ def cmd_story(args, cfg): classes = {sp.STORY: sp.StoryStage, **sp.PART_STAGES} ws.run_index() held = {loc["primaryKey"] for loc in ws.index()["locations"]} + env.facts["index"] = ws.index() + from ..source.languages import check + + wanted_languages = getattr(args, "story_languages", None) or [check(cfg.get("catalog", "language"))] + if getattr(args, "fonts", "open") == "open": + supplied = dict(getattr(args, "font", None) or []) + for code in wanted_languages: + path = Path(supplied[code]) if code in supplied else cfg.path("paths.fonts", code) + if path is None or not path.is_file(): + raise ConfigError( + f"the story text of {code} needs a font file: --font {code}=PATH or [paths.fonts] {code}" + ) def request(products: dict) -> tuple[dict, list[str]]: keys = {k for n, subjects in products.items() for s in subjects for k in classes[n]().keys(s, env)} @@ -74,12 +88,29 @@ def parts(run): # the products story.json names, with the story art = next((a for a in doc["artifacts"] if a["id"] == contract.artifact_id(tid, "story.json")), None) if art is None: # the story is unsupported: the run reports it return None - products = {sp.STORY: [subject], **sp.part_products(contract.loads(ws.depot.read(art["content"]["sha256"])))} + products = { + sp.STORY: [subject], + **sp.part_products( + contract.loads(ws.depot.read(art["content"]["sha256"])), + wanted_languages, + ), + } + episode = next(a for a in doc["artifacts"] if a["id"] == contract.artifact_id(tid, "episode.json"))["content"] + inputs = { + f"episode:{subject}": contract.Input( + f"episode:{subject}", + episode["sha256"], + episode["size"], + None, + ({"kind": "depot"},), + ) + } + env.facts["product_inputs"] = inputs missing = sorted(k for n, subjects in products.items() if n != sp.STORY for s in subjects for k in classes[n]().keys(s, env) if k not in held) # fmt: skip if missing: usage(args, f"episode {args.adv_id}: {', '.join(missing)} not in the catalog") - return request(products) + return (*request(products), inputs) ws.request(*request({sp.STORY: [subject]})) assets.export(args, cfg, ws, expand=parts) @@ -288,9 +319,28 @@ def register(sub) -> None: "story", cmd_story, "an ADV episode -> the story product and the products it plays with (scene, assets, Live2D models, " - "cue sheets, videos, chat, CRI Lips)", + "cue sheets, videos, chat, UI, fonts, CRI Lips)", ) c.add_argument("adv_id", type=int, help="MasterAdv id of the episode") + c.add_argument( + "--story-languages", + type=parse_languages, + metavar="LANG,...", + help="text languages, comma-separated (default: [catalog] language)", + ) + c.add_argument( + "--fonts", + choices=("open", "game"), + default="open", + help="glyph source: open font files (default) or game TMP assets", + ) + c.add_argument( + "--font", + type=parse_font, + action="append", + metavar="LANG=PATH", + help="font file for a language, or emoji=PATH (repeatable; [paths] fonts.)", + ) c.add_argument( "--format", dest="audio_format", diff --git a/src/nnnotes/content/advui.py b/src/nnnotes/content/advui.py index 7bbde5f..1a2f503 100644 --- a/src/nnnotes/content/advui.py +++ b/src/nnnotes/content/advui.py @@ -1117,9 +1117,14 @@ def sprite_asset_record(ex: Exporter, key: str = EMOJI_SPRITE_ASSET_KEY) -> dict implemented.""" o = ex.key_object(key) tt = o.read_typetree() - if tt.get("fallbackSpriteAssets"): - raise NotImplementedError(f"{key}: fallback sprite assets") sheet = ex.referent(o, tt["spriteSheet"]).read_typetree() + return sprite_record(tt, sheet, ex.material(ex.deref(o, tt["m_Material"]))) + + +def sprite_record(tt: dict, sheet: dict, material: dict) -> dict: + """The text layout's sprite asset record from its fields, sheet metadata and material.""" + if tt.get("fallbackSpriteAssets"): + raise NotImplementedError(f"{tt['m_Name']}: fallback sprite assets") chars = [ {"index": i, "unicode": c["m_Unicode"], "name": c["m_Name"], "glyph": c["m_GlyphIndex"], "scale": c["m_Scale"]} for i, c in enumerate(tt["m_SpriteCharacterTable"]) @@ -1153,7 +1158,7 @@ def sprite_asset_record(ex: Exporter, key: str = EMOJI_SPRITE_ASSET_KEY) -> dict "mipCount": sheet.get("m_MipCount", 1), "settings": sheet.get("m_TextureSettings"), }, - "material": ex.material(ex.deref(o, tt["m_Material"])), + "material": material, } diff --git a/src/nnnotes/content/storyfontproduct.py b/src/nnnotes/content/storyfontproduct.py new file mode 100644 index 0000000..1fcc573 --- /dev/null +++ b/src/nnnotes/content/storyfontproduct.py @@ -0,0 +1,322 @@ +"""An episode's text and glyph pages assembled from object tables and explicit font bytes.""" + +from __future__ import annotations + +import copy + +from ..source import languages +from ..unity import fontsettings, textstyle, tmpfont +from ..unity.export import MemoryPacker, _safe, packed_png +from ..unity.ids import object_id, parse_object_id +from . import advui, storyfonts, storyui, tmpassets +from .prefabs import component_class, key_object, mono_json, ref_id, shader_table + +FORMAT = "nnnotes.story-fonts/1" + + +def asset_keys(episode: dict) -> list[str]: + """The episode's chat windows and frames whose texts contribute glyphs.""" + return sorted({r["address"] for r in episode["resources"] if r["kind"] in ("chatwindow", "frame")}) + + +def text_records(objects, keys: list[str], language: str, lang: dict, settings) -> tuple[dict, dict, dict]: + """(component-id text records, sprite bindings, frame nodes) for the named prefabs.""" + mode = languages.mode(language) + out, sprites, frames = {}, {}, {} + for key in sorted(set(keys)): + oid = key_object(objects, key, "GameObject") + file, _ = parse_object_id(oid) + nodes = objects.json(oid)["nodes"] + extra = key.startswith((storyfonts.FRAME_PREFIX, "Adv/Chat/Prefabs/")) + local_lang = advui._lookup_lang(lang) if extra else lang + styles = storyui._Styles(local_lang, mode, settings) + by_path = { + n["path"]: object_id(file, int(c["pathId"])) + for n in nodes + for c in n["components"] + if component_class(c) in storyui.TEXT_CLASSES + } + driven = set() + for n in nodes: + for c in n["components"]: + if component_class(c) in storyfonts.UI_TEXT_CLASSES: + target = c["fields"].get("_targetText") or {} + driven.add(ref_id(target) or by_path.get(target.get("gameObject"))) + frame_nodes = [] + for node in nodes: + comps = [{"class": component_class(c), **c["fields"]} for c in node["components"]] + frame_nodes.append({"path": node["path"], "components": comps}) + texts = [c for c in node["components"] if component_class(c) in storyui.TEXT_CLASSES] + locs = [c for c in comps if c["class"] == "LocalizeText"] + if not texts: + continue + if len(texts) != 1 or len(locs) > 1: + raise ValueError(f"{node['path']}: ambiguous text or localization components") + comp = {"class": component_class(texts[0]), **texts[0]["fields"]} + tid = object_id(file, int(texts[0]["pathId"])) + comp["m_fontAsset"] = {"name": storyui._name(objects, comp["m_fontAsset"])} + comp["m_sharedMaterial"] = {"material": storyui._name(objects, comp["m_sharedMaterial"])} + record = styles.record(node["path"], comp, locs[0] if locs else None) + record.update(storyfonts.text_extras(node["path"], comps)) + # Glyph selection always has an effective font, including texts without LocalizeText. + effective = record["localized"] or { + "fontAsset": comp["m_fontAsset"]["name"], + "material": comp["m_sharedMaterial"]["material"], + "lineSpacing": comp["m_lineSpacing"], + } + record["font"] = dict(effective) + if tid in driven: + sprites[tid] = {"m_tintAllSprites": comp["m_tintAllSprites"]} + elif ref_id(comp.get("m_spriteAsset")) is not None: + raise NotImplementedError(f"{node['path']}: a text with its own sprite asset") + out[tid] = record + if key.startswith(storyfonts.FRAME_PREFIX): + frames[key[len(storyfonts.FRAME_PREFIX) :]] = frame_nodes + return out, sprites, frames + + +def shown_texts( + episode: dict, records: dict, master: dict, settings: dict, status: dict, frames: dict, field: str +) -> list[str]: + """The strings an episode can display, including localized labels and dynamic chat/frame text.""" + texts = [row.get(field) for row in episode["text"].values()] + texts.append((episode.get("title") or {}).get(field)) + texts += [r["text"] for r in storyui.master_id_texts(settings, master).values()] + texts += list(status.values()) + advui.chat_runtime_texts(episode) + for r in records.values(): + style = r["style"] + if style.get("textKey"): + texts.append(master[str(style["textKey"])]) + else: + texts.append(style.get("text")) + texts += storyfonts.frame_texts(frames, episode, field) + return [s for s in texts if isinstance(s, str)] + + +def line_breaking(objects) -> dict: + tt = mono_json(objects, storyui.script_root(objects, "TMP_Settings")) + + def text(field): + oid = ref_id(tt[field]) + return objects.bytes(oid, "data").decode("utf-8") if oid else "" + + return { + "leading": text("m_leadingCharacters"), + "following": text("m_followingCharacters"), + "useModernHangulLineBreakingRules": bool(tt["m_UseModernHangulLineBreakingRules"]), + } + + +def sprite_record(objects, fs): + oid = key_object(objects, advui.EMOJI_SPRITE_ASSET_KEY, "TMP_EmojiSpriteAsset") + tt = objects.json(oid) + sheet = ref_id(tt["spriteSheet"]) + material = ref_id(tt["m_Material"]) + if sheet is None or material is None: + raise ValueError("emoji sprite asset needs a sheet and material") + return advui.sprite_record(tt, objects.json(sheet), fs.material(material)), sheet + + +def game_font_set(fs, bindings, shown, missing_char, sprite_drawn) -> dict: + """Reduced game font assets, their materials, fallback chains and PNG page bytes.""" + primaries = sorted({b["localized"]["fontAsset"] for b in bindings}) + needed, runtime, reports, missing_glyphs = {}, {}, {}, {} + for name in primaries: + primary = fs.loaded(name) + chars = sorted({ord(c) for s in shown for c in s} - sprite_drawn.get(name, set())) + tmpfont.check_glyph_variants(fs, primary, shown) + coverage, baked, dynamic = tmpfont.glyph_coverage(fs, primary, chars) + mg = tmpfont.missing_glyph(fs, primary, chars, missing_char) + for u in [storyfonts.UNDERLINE_CHARACTER, *([mg["unicode"]] if mg else [])]: + hit = fs.lookup(primary, u) + if hit is not None: + kind, font = hit + if u == storyfonts.UNDERLINE_CHARACTER and font is not primary: + continue + if kind != "synthesized": + (baked if kind == "baked" else dynamic).setdefault(font.name, set()).add(u) + if mg: + missing_glyphs[name] = mg + reports[name] = coverage + for target, source in ((needed, baked), (runtime, dynamic)): + for fname, chars in source.items(): + target.setdefault(fname, set()).update(chars) + materials, text_materials = tmpfont.text_material_records( + fs, + [b["localized"] for b in bindings], + set(primaries), + needed, + runtime, + fs.named_material, + ) + packer = MemoryPacker() + fonts = tmpfont.export_fonts( + fs, + set(primaries), + needed, + runtime, + text_materials, + packer, + subset="episode text, synthesized control characters, underline and missing glyph", + ) + textures, pages = packer.build() + tmpfont.resolve_font_blocks(fonts) + for name, font in fonts.items(): + font["fallbacks"] = storyfonts.search_order( + lambda n: [f.name for f in fs.fallbacks(fs.loaded(n))], + name, + set(fonts), + ) + if name in missing_glyphs: + font["missingGlyph"] = missing_glyphs[name] + for b in bindings: + loc = b["localized"] + for fb in fonts[loc["fontAsset"]]["fallbacks"]: + name = f"{loc['material']} + {fb}" + materials[name] = storyfonts.fallback_material( + materials[loc["material"]], + materials[fonts[fb]["material"]], + name, + ) + return { + "fonts": fonts, + "textures": textures, + "materials": materials, + "pages": pages, + "missing": {ord(c) for r in reports.values() for c in r["missing"]}, + "substituted": {u for r in missing_glyphs.values() for u in r["characters"]}, + } + + +def build( + objects, episode: dict, language: str, master: dict, settings, source: str, font_files: dict +) -> tuple[dict, dict, set, dict]: + """(fonts.json, PNG files, shader ids, shader roles) from object data and font inputs {code: bytes}.""" + lang = textstyle.language_fonts_json(mono_json(objects, storyui.script_root(objects, "LocalizeManager"))) + ui_keys = [ + storyui.WIDGET_KEY, + storyui.CHAT_WIDGET_KEY, + storyui.UI_MANAGER_KEY, + *storyui.window_keys(objects.keys()).values(), + *storyui.DIALOG_KEYS.values(), + ] + records, sprite_bindings, frames = text_records(objects, ui_keys + asset_keys(episode), language, lang, settings) + status = storyui.chat_status_texts(objects, master) + ids = objects.json(key_object(objects, storyui.MASTER_ID_SETTINGS_KEY, "AdvMasterIdSettings")) + shown = shown_texts(episode, records, master, ids, status, frames, languages.column(language)[1:]) + chars = sorted({ord(c) for s in shown for c in s}) + bindings = {tid: {"localized": dict(r["font"])} for tid, r in records.items()} + fs = tmpassets.FontSet(objects) + for name in sorted({r["font"]["fontAsset"] for r in records.values()}): + fs.by_key(fontsettings.font_key(tmpassets._Keys(objects), name, name)) + material_ids = {fs.loaded(name).material_oid for name in fs.fonts} + for r in records.values(): + font = r["font"] + key = fontsettings.font_key(tmpassets._Keys(objects), font["fontAsset"], font["material"]) + material_ids.add(key_object(objects, key, "Material")) + rec, sheet, sprite_drawn = None, None, {} + if sprite_bindings: + rec, sheet = sprite_record(objects, fs) + sprite_id = key_object(objects, advui.EMOJI_SPRITE_ASSET_KEY, "TMP_EmojiSpriteAsset") + sprite_material = ref_id(objects.json(sprite_id)["m_Material"]) + if sprite_material is None: + raise ValueError("emoji sprite asset needs a material") + material_ids.add(sprite_material) + sprite_drawn = storyfonts.drawn_by_sprites( + rec, + [bindings[t] for t in sprite_bindings], + chars, + lambda name, u: fs.lookup(fs.loaded(name), u), + ) + missing_char = tmpassets.missing_glyph_character(objects) + if source == "open": + loaded = {} + + def font_of(code): + if code not in font_files: + return storyfonts.no_font_file(code) + if code not in loaded: + loaded[code] = storyfonts.FontFile(f"{code}.font", data=font_files[code]) + return loaded[code] + + result = storyfonts.build_open_font_set( + language, + font_of(language), + list(bindings.values()), + shown, + lang, + missing_char, + font_of=font_of, + sprite_drawn=sprite_drawn, + settings=settings, + ) + elif source == "game": + result = game_font_set(fs, list(bindings.values()), shown, missing_char, sprite_drawn) + else: + raise ValueError(f"font source {source!r}: expected open or game") + for tid, record in records.items(): + record["font"] = bindings[tid]["localized"] + coverage = {"characters": len(chars), "missing": [chr(u) for u in sorted(result["missing"])]} + if result["substituted"]: + coverage["missingGlyph"] = [chr(u) for u in sorted(result["substituted"])] + doc = { + "format": FORMAT, + "language": language, + "source": source, + **{k: result[k] for k in ("fonts", "textures", "materials")}, + "texts": records, + "sprites": {}, + "coverage": coverage, + "lineBreaking": line_breaking(objects), + } + pages = result["pages"] + indices = storyfonts.sprite_needs(rec, shown, set().union(*sprite_drawn.values())) if rec else [] + if rec is not None and indices: + if source == "open": + emoji = storyfonts.EmojiFont("emoji.font", data=font_files["emoji"]) if "emoji" in font_files else None + name = emoji.asset_name if emoji else storyfonts.NO_EMOJI_FONT + sprites = storyfonts.open_sprite_asset(emoji, rec, indices, name) + for pname, px in sprites["pages"]: + pages[sprites["textures"][pname]["texture"]] = packed_png(px) + asset, textures, material = sprites["asset"], sprites["textures"], sprites["material"] + coverage["sprites"] = {"characters": len(asset["characters"]), "missing": sprites["missing"]} + else: + name = rec["name"] + sh = rec["sheet"] + if sh["mipCount"] != 1: + raise NotImplementedError(f"{name}: sprite sheet with mip levels") + page, path = f"sprite_{name}", f"fonts/{_safe(name)}.png" + pages[path] = objects.bytes(sheet, "image") + material = storyfonts._sheet_material( + rec["material"], rec["material"]["material"], page, sh["width"], sh["height"] + ) + asset = {k: copy.deepcopy(rec[k]) for k in ("faceInfo", "characters", "glyphs", "sequences")} + asset["material"] = material["material"] + for g in asset["glyphs"].values(): + if g["rect"]["m_Width"] and g["rect"]["m_Height"]: + g["packed"] = {"texture": page, "dx": 0, "dy": 0} + textures = { + page: {"texture": path, **{k: sh[k] for k in ("name", "width", "height", "mipCount", "settings")}} + } + coverage["sprites"] = {"characters": len(asset["characters"]), "missing": []} + doc["textures"].update(textures) + doc["materials"][material["material"]] = material + doc["spriteAssets"], doc["emojiSpriteAsset"] = {name: asset}, name + doc["sprites"] = {t: {"spriteAsset": name, **v} for t, v in sprite_bindings.items()} + found = {ref_id(objects.json(oid)["shader"]) for oid in material_ids if oid is not None} + shaders, roles = shader_table(objects, found=[o for o in found if o is not None]) + keep, selected, keywords = set(), {}, {} + for name, material in sorted(doc["materials"].items()): + shader = material["shader"]["shader"] + sid = shaders[shader] + variants = objects.json(sid, "index")["variants"] + gles = [v for v in variants if v["platform"] == "gles3" and v["type"] == "GLES3"] + known = {k for v in gles for k in v["keywords"]} + want = sorted(k for k in material["keywords"] if k in known) + if not any(sorted(v["keywords"]) == want and v["stage"] == "vertex" for v in gles): + raise ValueError(f"{shader}: no GLES3 variant for material {name} keywords {want}") + keywords[name], selected[shader] = want, sid + keep.add(sid) + storyfonts._material_textures(material) + doc["materialKeywords"], doc["shaders"] = keywords, selected + return doc, pages, keep, {o: roles[o] for o in keep} diff --git a/src/nnnotes/content/storyfonts.py b/src/nnnotes/content/storyfonts.py index afcd601..213af91 100644 --- a/src/nnnotes/content/storyfonts.py +++ b/src/nnnotes/content/storyfonts.py @@ -127,11 +127,11 @@ def _salt() -> str: class FontFile: """A font file of one's own (OpenType / TrueType; the first face of a collection): its bytes, identity and names.""" - def __init__(self, path, face_index: int = 0): + def __init__(self, path, face_index: int = 0, *, data: bytes | None = None): from fontTools.ttLib import TTFont self.path = Path(path) - self.data = self.path.read_bytes() + self.data = self.path.read_bytes() if data is None else data self.face_index = face_index self.sha256 = hashlib.sha256(self.data).hexdigest() import io @@ -526,7 +526,7 @@ def frame_texts(frames: dict[str, list[dict]], episode: dict, field: str) -> lis if (c.get("TargetAssetName") or "").strip() not in receivers: continue for i in c["TargetTextIDs"]: - row = episode["text"].get(i) + row = episode["text"].get(i, episode["text"].get(str(i))) if row is not None and isinstance(row.get(field), str): out.append(unicodedata.normalize("NFC", row[field])) return serialized + (out + ["@\n"] if out else out) @@ -948,8 +948,43 @@ def open_font_set( the glyph margin covers (so the pages equal another document's with the same characters). The pages go to `ui_dir`/fonts/. -> {fonts, textures, materials, missing (code points no asset maps), substituted (code points drawn as a missing glyph)}.""" + result = build_open_font_set( + language, + font, + bindings, + shown, + textstyle.language_fonts(player), + tmpfont.missing_glyph_character(player), + font_of, + sprite_drawn, + margin_materials, + settings, + ) + for path, body in result.pop("pages").items(): + dest = ui_dir / path + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(body) + return result + + +def build_open_font_set( + language: str, + font: FontFile, + bindings: list[dict], + shown: list[str], + lang: dict, + missing_char: int, + font_of=no_font_file, + sprite_drawn: dict | None = None, + margin_materials: dict | None = None, + settings=None, +) -> dict: + """Generate open font records and PNG page bytes from explicit language and font settings. + + The localized font and material of each binding is updated to the generated asset. `pages` maps relative + file names to bytes; the other result fields are those of open_font_set. + """ settings = settings if settings is not None else fontsettings.builtin() - lang = textstyle.language_fonts(player) sprite_drawn = sprite_drawn or {} margin_materials = margin_materials or {} chars = sorted({ord(ch) for s in shown for ch in s}) @@ -957,7 +992,6 @@ def open_font_set( for t in bindings: by_game.setdefault(t["localized"]["fontAsset"], []).append(t) primaries = sorted(by_game) - missing_char = tmpfont.missing_glyph_character(player) files = {language: font} def file_of(code: str) -> FontFile: @@ -1024,7 +1058,7 @@ def name_of(key: tuple[str, str]) -> str: if len(set(names.values())) != len(names): raise RuntimeError(f"open font asset names collide: {sorted(names.values())}") users = {k: [n for n in primaries if k in chain(n, language)] for k in keys} - fonts, textures, materials, renames = {}, {}, {}, {} + fonts, textures, materials, renames, pages = {}, {}, {}, {}, {} for key in keys: gname, code = key primary = gname in by_game and code == language @@ -1048,9 +1082,8 @@ def name_of(key: tuple[str, str]) -> str: missing |= a["missing"] if primary: renames[gname] = a["renames"] - (ui_dir / PAGES_DIR).mkdir(parents=True, exist_ok=True) for pname, px in a["pages"]: - (ui_dir / a["textures"][pname]["texture"]).write_bytes(packed_png(px)) + pages[a["textures"][pname]["texture"]] = packed_png(px) # the fallback materials of the primaries' text materials, then the bindings for gname in primaries: prim = fonts[names[(gname, language)]] @@ -1068,6 +1101,7 @@ def name_of(key: tuple[str, str]) -> str: "materials": materials, "missing": missing, "substituted": substituted, + "pages": pages, } @@ -1201,12 +1235,12 @@ class EmojiFont: Font License 1.1): the image of an emoji, a code point or a sequence resolved through the font's ligature substitutions (with U+FE0F dropped when the full sequence has no ligature), from the largest strike.""" - def __init__(self, path, face_index: int = 0): + def __init__(self, path, face_index: int = 0, *, data: bytes | None = None): import io from fontTools.ttLib import TTFont - self.file = FontFile(path, face_index) + self.file = FontFile(path, face_index, data=data) self.tt = TTFont(io.BytesIO(self.file.data), fontNumber=face_index) self.cmap = self.tt.getBestCmap() or {} self.ligatures = _ligatures(self.tt) diff --git a/src/nnnotes/content/tmpassets.py b/src/nnnotes/content/tmpassets.py index aff833e..8f11b4d 100644 --- a/src/nnnotes/content/tmpassets.py +++ b/src/nnnotes/content/tmpassets.py @@ -27,7 +27,7 @@ from PIL import Image from ..unity.export import texel_rows -from ..unity.fontsettings import FONT_CLASS, font_dir, font_key +from ..unity.fontsettings import FONT_CLASS, font_key from ..unity.ids import parse_object_id from ..unity.tmpfont import ( MISSING_GLYPH_CHARACTER, @@ -226,7 +226,7 @@ def _texture_entry(self, v: dict) -> dict: def named_material(self, font: FontAsset, name: str) -> dict: """The material `name` of the font asset `font`, at its key in the font's folder (tmpfont.material_by_name).""" - key = font_dir(font.name) + name + key = font_key(_Keys(self.objects), font.name, name) if not _Keys(self.objects).has(key): raise KeyError(f"material key {key}") return self.material(key_object(self.objects, key, "Material")) diff --git a/src/nnnotes/pipeline/story.py b/src/nnnotes/pipeline/story.py index a9503b9..e6c4093 100644 --- a/src/nnnotes/pipeline/story.py +++ b/src/nnnotes/pipeline/story.py @@ -99,6 +99,8 @@ "SoundStage", "StoryStage", "UI", + "FONTS", + "FontsStage", "UiStage", "VideoStage", "cues", @@ -107,6 +109,7 @@ SOUND, CRILIPS, SCENE, ASSET = "story.sound", "story.crilips", "story.scene", "story.asset" CHAT, STORY, UI, VIDEO = "story.chat", "story", "story.ui", "story.video" +FONTS = "story.fonts" DEFAULT = "default" SHEET_PREFIX = "Cri/Sound/" LIBRARY = crilips.LIB_ENTRY_SUFFIX @@ -378,6 +381,14 @@ def more_inputs(self, subject: str, env) -> list: ) by_key = {k: units.root_objects(env, [k]) for k in keys} ui_table = reach(env, roots, self._results, by_key).table + return [ + master_input(env, self.record(subject, env)), + _record_input(env, "ui.objects", ui_table), + self.font_bundles(subject, env), + ] + + def font_bundles(self, subject: str, env) -> Input: + """The bundle names of the font assets read by this product.""" objects = Objects(self.reach(subject, env).table, env.depot) font_files = set(_font_files(objects).values()) bundles = {} @@ -386,36 +397,122 @@ def more_inputs(self, subject: str, env) -> list: bundles.update( {f: addresses["bundles"][b]["name"] for f, b in addresses["files"].items() if f in font_files} ) - return [ - master_input(env, self.record(subject, env)), - _record_input(env, "ui.objects", ui_table), - _record_input(env, "font-bundles", bundles), - ] + return _record_input(env, "font-bundles", bundles) def manifest(self, subject: str) -> str: return f"storyui/{_language(subject)}.json" def assemble(self, task, objects) -> Assembly: - from ..content import tmpassets - record = contract.loads(objects.depot.input_bytes(task.input(MASTER_INPUT))) ui_table = contract.loads(objects.depot.input_bytes(task.input("ui.objects"))) ui_objects = Objects(ui_table, objects.depot) localize = storyui.script_root(ui_objects, "LocalizeManager") lang = textstyle.language_fonts_json(mono_json(ui_objects, localize)) - bundles = contract.loads(objects.depot.input_bytes(task.input("font-bundles"))) - font_files = _font_files(objects) - game_version = manifest_version_name(objects.depot.input_bytes(task.input(f"apk:{MANIFEST_IN_APK}"))) - if game_version is None: - raise ValueError("story.ui: the APK manifest has no version name") - settings = tmpassets.build_font_settings(objects, lang, game_version, lambda name: bundles[font_files[name]]) + settings = _font_settings(task, objects, lang) status = storyui.chat_status_texts(objects, record["texts"]) - doc, keep, roles = storyui.ui( - ui_objects, task.subject, record, fontsettings.FontSettings(settings), status_texts=status - ) + doc, keep, roles = storyui.ui(ui_objects, task.subject, record, settings, status_texts=status) return Assembly({"ui.json": (contract.encode(doc), "json", UI)}, keep, roles, ui_table) +def _font_settings(task, objects, lang): + from ..content import tmpassets + + bundles = contract.loads(objects.depot.input_bytes(task.input("font-bundles"))) + files = _font_files(objects) + version = manifest_version_name(objects.depot.input_bytes(task.input(f"apk:{MANIFEST_IN_APK}"))) + if version is None: + raise ValueError(f"{task.stage}: the APK manifest has no version name") + doc = tmpassets.build_font_settings(objects, lang, version, lambda name: bundles[files[name]]) + return fontsettings.FontSettings(doc) + + +def font_subject(subject: str) -> tuple[str, str]: + episode, sep, language = subject.partition("/") + if not sep or str(_adv_id(episode)) != episode: + raise ValueError(f"{subject!r}: expected /") + return episode, _language(language) + + +def font_atoms() -> dict: + from ..unity.atoms import impl_id + + return {"text.glyphs": impl_id(("fonttools", "freetype-py", "scipy", "numpy", "Pillow"), 1)} + + +class FontsStage(UiStage): + """An episode and language's font records and glyph pages from its object and font inputs.""" + + name = FONTS + version = 1 + KIND = "StoryFonts" + PARAMS = {"fonts": "open"} + + def normalize(self, params): + p = super().normalize(params) + if p["fonts"] not in ("open", "game"): + raise ValueError(f"stage {self.name}: fonts must be open or game") + return p + + def uses(self, subject, env): + return font_atoms() + + def episode_input(self, subject, env): + episode, _ = font_subject(subject) + return env.fact("product_inputs")[f"episode:{episode}"] + + def keys(self, subject, env): + _, language = font_subject(subject) + episode = contract.loads(env.depot.input_bytes(self.episode_input(subject, env))) + assets = {r["address"] for r in episode["resources"] if r["kind"] in ("chatwindow", "frame")} + return sorted({*UiStage().keys(language, env), *assets}) + + def resources(self, subject, env): + font_subject(subject) + return list(storyui.RESOURCES) + + def builtins(self, subject, env): + font_subject(subject) + return list(storyui.BUILTINS) + + def record(self, subject, env): + return UiStage().record(font_subject(subject)[1], env) + + def more_inputs(self, subject, env): + src = self.episode_input(subject, env) + out = [ + master_input(env, self.record(subject, env)), + self.font_bundles(subject, env), + Input("episode", src.sha256, src.size, None, src.locators), + ] + if env.params["fonts"] == "open": + for code, path in sorted((env.facts.get("font_files") or {}).items()): + data = Path(path).read_bytes() + out.append(Input(f"font:{code}", env.depot.put(data), len(data), None, ({"kind": "depot"},))) + return out + + def manifest(self, subject): + episode, language = font_subject(subject) + return f"storyfonts/{episode}_{language}.json" + + def assemble(self, task, objects): + from ..content import storyfontproduct + + if task.atoms != font_atoms(): + raise IncompatibleTask(f"task {task.id}: the font libraries differ from the task") + _, language = font_subject(task.subject) + lang = textstyle.language_fonts_json(mono_json(objects, storyui.script_root(objects, "LocalizeManager"))) + settings = _font_settings(task, objects, lang) + episode = contract.loads(objects.depot.input_bytes(task.input("episode"))) + master = contract.loads(objects.depot.input_bytes(task.input(MASTER_INPUT)))["texts"] + fonts = {i.role[5:]: objects.depot.input_bytes(i) for i in task.inputs if i.role.startswith("font:")} + doc, pages, keep, roles = storyfontproduct.build( + objects, episode, language, master, settings, task.params["fonts"], fonts + ) + files = {p: (body, "png", "story.glyphs") for p, body in pages.items()} + files["fonts.json"] = (contract.encode(doc), "json", FONTS) + return Assembly(files, keep, roles) + + def _record_input(env, role: str, record: dict) -> Input: """A JSON record read by a product, stored as a content-addressed input.""" body = contract.encode(record) @@ -437,7 +534,7 @@ def _adv_id(subject: str) -> int: return int(subject) -def part_paths(parts: dict) -> dict: +def part_paths(parts: dict, adv_id: int) -> dict: """story.json `parts`: the manifest paths of the products of storyepisode.parts `parts`.""" return { "scene": SceneStage().manifest(DEFAULT), @@ -447,10 +544,12 @@ def part_paths(parts: dict) -> dict: "videos": {v: VideoStage().manifest(v) for v in parts["videos"]}, "chat": ChatStage().manifest(DEFAULT) if parts["chat"] else None, "crilips": CriLipsStage().manifest(DEFAULT) if parts["crilips"] else None, + "ui": {lang: UiStage().manifest(lang) for lang in languages.LANGUAGES}, + "fonts": {lang: FontsStage().manifest(f"{adv_id}/{lang}") for lang in languages.LANGUAGES}, } -def part_products(story: dict) -> dict[str, list[str]]: +def part_products(story: dict, requested_languages=()) -> dict[str, list[str]]: """{product stage: [subject]} of the products story.json `story` names in `parts`.""" parts = story["parts"] out = { @@ -461,6 +560,8 @@ def part_products(story: dict) -> dict[str, list[str]]: VIDEO: sorted(parts["videos"]), CHAT: [DEFAULT] if parts["chat"] else [], CRILIPS: [DEFAULT] if parts["crilips"] else [], + UI: sorted({_language(lang) for lang in requested_languages}), + FONTS: sorted({f"{story['advId']}/{_language(lang)}" for lang in requested_languages}), } return {name: subjects for name, subjects in out.items() if subjects} @@ -474,6 +575,8 @@ def part_products(story: dict) -> dict[str, list[str]]: VIDEO: VideoStage, CHAT: ChatStage, CRILIPS: CriLipsStage, + UI: UiStage, + FONTS: FontsStage, } @@ -481,7 +584,7 @@ class StoryStage(ProductStage): """story: an ADV episode -> episode.json and story.json (module documentation).""" name = STORY - version = 1 + version = 2 KIND = "Story" MASTER = storyepisode.TABLES @@ -508,7 +611,7 @@ def manifest(self, subject: str) -> str: def assemble(self, task, objects) -> Assembly: record = contract.loads(objects.depot.input_bytes(task.input(MASTER_INPUT))) episode = storyepisode.episode_json(storyepisode.episode(objects, record)) - doc = storyepisode.story(episode, record, part_paths(storyepisode.parts(episode))) + doc = storyepisode.story(episode, record, part_paths(storyepisode.parts(episode), _adv_id(task.subject))) files = { "episode.json": (contract.encode(episode), "json", "story.episode"), "story.json": (contract.encode(doc), "json", STORY), diff --git a/src/nnnotes/schemas/story-fonts.schema.json b/src/nnnotes/schemas/story-fonts.schema.json new file mode 100644 index 0000000..6deecc2 --- /dev/null +++ b/src/nnnotes/schemas/story-fonts.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:nnnotes:schema:story-fonts", + "title": "Episode font records and glyph pages", + "type": "object", + "required": ["format", "language", "source", "fonts", "textures", "materials", "materialKeywords", "texts", "sprites", "coverage", "lineBreaking", "shaders"], + "properties": { + "format": {"const": "nnnotes.story-fonts/1"}, + "language": {"enum": ["ja", "en", "zh-Hant", "zh-Hans", "ko"]}, + "source": {"enum": ["open", "game"]}, + "fonts": {"type": "object", "additionalProperties": {"type": "object", "required": ["characters", "glyphs", "material", "fallbacks"]}}, + "textures": {"type": "object", "additionalProperties": {"type": "object", "required": ["texture", "width", "height"], "properties": {"texture": {"type": "string"}, "width": {"type": "integer", "minimum": 1}, "height": {"type": "integer", "minimum": 1}}}}, + "materials": {"type": "object", "additionalProperties": {"type": "object"}}, + "materialKeywords": {"type": "object", "additionalProperties": {"type": "array", "items": {"type": "string"}}}, + "texts": {"type": "object", "additionalProperties": {"type": "object", "required": ["style", "localized", "font"], "properties": {"style": {"type": "object"}, "localized": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/font"}]}, "font": {"$ref": "#/$defs/font"}}}}, + "sprites": {"type": "object", "additionalProperties": {"type": "object", "required": ["spriteAsset", "m_tintAllSprites"]}}, + "coverage": {"type": "object", "required": ["characters", "missing"], "properties": {"characters": {"type": "integer", "minimum": 0}, "missing": {"type": "array", "items": {"type": "string"}}}}, + "lineBreaking": {"type": "object", "required": ["leading", "following", "useModernHangulLineBreakingRules"], "properties": {"leading": {"type": "string"}, "following": {"type": "string"}, "useModernHangulLineBreakingRules": {"type": "boolean"}}}, + "shaders": {"type": "object", "additionalProperties": {"type": "string"}}, + "spriteAssets": {"type": "object", "additionalProperties": {"type": "object"}}, + "emojiSpriteAsset": {"type": "string"} + }, + "$defs": { + "font": {"type": "object", "required": ["fontAsset", "material", "lineSpacing"], "properties": {"fontAsset": {"type": "string"}, "material": {"type": "string"}, "lineSpacing": {"type": "number"}}} + } +} diff --git a/tests/content/test_storyfontproduct.py b/tests/content/test_storyfontproduct.py new file mode 100644 index 0000000..cceb857 --- /dev/null +++ b/tests/content/test_storyfontproduct.py @@ -0,0 +1,266 @@ +"""Episode font products with synthetic font files, prefabs, glyphs and atlas pages.""" + +import copy +import io + +import numpy as np +import pytest +from jsonschema import Draft202012Validator +from PIL import Image + +from nnnotes import schemas +from nnnotes.content import storyfontproduct as fp +from nnnotes.content import storyui +from nnnotes.pipeline import contract, story +from nnnotes.pipeline.assemble import Objects +from nnnotes.pipeline.depot import Depot +from nnnotes.pipeline.stages import Env +from tests.content.test_tmpassets import texture_doc +from tests.helpers import storyui as sui +from tests.helpers import synth +from tests.helpers.fonts import make_font +from tests.helpers.live2dmodel import ref +from tests.pipeline.test_live import run, stored + + +def fixture(depot): + fonts, keys = sui.font_objects() + objs = {**sui.objects(), **fonts} + for cls, _, files in objs.values(): + if cls == "GameObject": + for node in files["json"]["nodes"]: + for comp in node["components"]: + if "m_text" in comp["fields"]: + comp["fields"]["m_text"] = "AB" + elif cls == "Material": + mat = files["json"] + mat["shader"] = ref(sui.SHADER) + mat["floats"].update({"_TextureWidth": 32, "_TextureHeight": 32}) + elif cls == "MonoBehaviour" and files["json"].get("$script", "").endswith("|TMP_FontAsset"): + doc = files["json"] + doc.update( + { + "m_AtlasTextures": [ref("CAB-atlas:1")], + "m_AtlasWidth": 32, + "m_AtlasHeight": 32, + "m_FontFeatureTable": {"m_GlyphPairAdjustmentRecords": []}, + } + ) + doc["m_CharacterTable"] = [ + {"m_Unicode": u, "m_GlyphIndex": u, "m_Scale": 1.0, "m_ElementType": 1} for u in (32, 65, 66, 95, 9633) + ] + doc["m_GlyphTable"] = [ + { + "m_Index": u, + "m_Metrics": { + "m_Width": 4, + "m_Height": 4, + "m_HorizontalBearingX": 0, + "m_HorizontalBearingY": 4, + "m_HorizontalAdvance": 5, + }, + "m_GlyphRect": {"m_X": 16, "m_Y": 16, "m_Width": 4, "m_Height": 4}, + "m_Scale": 1.0, + "m_AtlasIndex": 0, + } + for u in (32, 65, 66, 95, 9633) + ] + png = io.BytesIO() + Image.fromarray(np.full((32, 32, 4), 100, dtype=np.uint8)).save(png, format="PNG") + objs["CAB-atlas:1"] = ("Texture2D", "Atlas", {"json": texture_doc("Atlas", 32, 32, 4), "image": png.getvalue()}) + objs[sui.TMP_SETTINGS][2]["json"].update( + { + "m_missingGlyphCharacter": 9633, + "m_fallbackFontAssets": [], + "m_leadingCharacters": None, + "m_followingCharacters": None, + "m_UseModernHangulLineBreakingRules": True, + } + ) + variants = objs[sui.SHADER][2]["index"]["variants"] + for v in variants: + v["keywords"] = [] + if v["platform"] == "gles3": + v["stage"] = "vertex" + doc = sui.table_doc(depot, objs=objs, keys={**sui.table_keys(), **keys}) + doc["objects"]["CAB-atlas:1"]["files"]["image"]["ext"] = "png" + episode = { + "text": {"1": {"japanese": "AB", "traditionalChinese": "BA"}}, + "title": None, + "commands": [], + "resources": [], + } + inputs = [ + stored(depot, contract.encode(doc), "objects"), + stored(depot, contract.encode({"texts": {str(i): "AB" for i in sui.MASTER_IDS.values()}}), "master"), + stored(depot, contract.encode({"CAB-font": "font.bundle"}), "font-bundles"), + stored(depot, synth.axml(["versionName", "manifest", "1.2.3"], 2), "apk:AndroidManifest.xml"), + stored(depot, contract.encode(episode), "episode"), + ] + return inputs, doc, episode + + +@pytest.mark.parametrize("source", ["open", "game"]) +def test_font_product_pages_texts_and_retained_shader_objects(tmp_path, source): + depot = Depot(tmp_path / "d") + inputs, _, _ = fixture(depot) + if source == "open": + for code in ("ja", "zh-Hant"): + data = make_font(tmp_path / f"{code}.ttf", underscore=True).read_bytes() + inputs.append(stored(depot, data, f"font:{code}")) + _, _, arts = run(depot, story.FontsStage(), "1/zh-Hant", inputs, {"fonts": source}, story.font_atoms()) + doc = contract.loads(depot.read(arts["fonts.json"]["content"]["sha256"])) + Draft202012Validator(schemas.load("story-fonts")).validate(doc) + assert doc["format"] == fp.FORMAT and doc["source"] == source + assert set(doc["texts"]) == {"CAB-ui:101", "CAB-ui:103", "CAB-ui:111"} + assert doc["texts"]["CAB-ui:103"]["localized"] is None + assert doc["texts"]["CAB-ui:101"]["style"]["textKey"] == "101" + assert doc["coverage"]["missing"] == [] + assert doc["lineBreaking"]["useModernHangulLineBreakingRules"] + assert set(contract.loads(depot.read(arts["objects"]["content"]["sha256"]))["objects"]) == {sui.SHADER} + for texture in doc["textures"].values(): + data = depot.read(arts[texture["texture"]]["content"]["sha256"]) + image = Image.open(io.BytesIO(data)) + assert image.size == (texture["width"], texture["height"]) + for record in doc["texts"].values(): + assert record["font"]["fontAsset"] in doc["fonts"] + assert record["font"]["material"] in doc["materials"] + + +def test_text_targets_resolve_internal_component_paths(tmp_path): + depot = Depot(tmp_path / "d") + _, table, _ = fixture(depot) + objects = Objects(table, depot) + widget = copy.deepcopy(objects.json(sui.WIDGET)) + widget["nodes"][0]["components"].append( + { + "type": "MonoBehaviour", + "pathId": 500, + "script": "Fwk|Fwk.UI|UIText", + "fields": {"_targetText": {"component": "TextMeshProUGUI", "gameObject": "UIAdvWidget/Talk"}}, + } + ) + widget["nodes"][1]["components"][0]["fields"]["m_tintAllSprites"] = True + body = contract.encode(widget) + table["objects"][sui.WIDGET]["files"]["json"].update(sha256=depot.put(body), size=len(body)) + from nnnotes.unity import textstyle + + records, sprites, _ = fp.text_records( + Objects(table, depot), + [storyui.WIDGET_KEY], + "zh-Hant", + textstyle.language_fonts_json(sui.LOCALIZE_JSON), + sui.font_settings(), + ) + assert set(sprites) == {"CAB-ui:101"} + assert sprites["CAB-ui:101"] == {"m_tintAllSprites": True} + assert set(records) == {"CAB-ui:101", "CAB-ui:103"} + + +def test_episode_snapshot_roots_and_font_content_are_inputs(tmp_path): + depot = Depot(tmp_path / "d") + episode = {"resources": [{"kind": "frame", "address": "Adv/Frame/a"}, {"kind": "stage", "address": "Adv/Stage/b"}]} + env = Env( + depot, + {"index": sui.index(), "product_inputs": {"episode:1": stored(depot, contract.encode(episode), "episode:1")}}, + ) + stage = story.FontsStage() + assert stage.keys("1/ja", env) == sorted({*story.UiStage().keys("ja", env), "Adv/Frame/a"}) + assert stage.manifest("1/ja") == "storyfonts/1_ja.json" + assert stage.normalize(None) == {"fonts": "open"} + with pytest.raises(ValueError): + stage.manifest("1/fr") + with pytest.raises(ValueError): + stage.normalize({"fonts": "unknown"}) + + +def test_requested_languages_select_ui_and_fonts_only_for_those_languages(): + parts = {"assets": [], "models": [], "sounds": [], "videos": [], "chat": False, "crilips": False} + doc = {"advId": 7, "parts": story.part_paths(parts, 7)} + assert story.part_products(doc, ["ja", "ja", "en"]) == { + story.SCENE: ["default"], + story.UI: ["en", "ja"], + story.FONTS: ["7/en", "7/ja"], + } + + +def test_font_input_keys_follow_file_bytes_and_source_mode(tmp_path, monkeypatch): + depot = Depot(tmp_path / "d") + path = tmp_path / "font.ttf" + path.write_bytes(b"first font bytes") + episode = stored(depot, contract.encode({"resources": []}), "episode:1") + env = Env(depot, {"product_inputs": {"episode:1": episode}, "font_files": {"ja": path}}) + env.params = {"fonts": "open"} + stage = story.FontsStage() + monkeypatch.setattr(stage, "font_bundles", lambda subject, env: stored(depot, b"{}", "font-bundles")) + monkeypatch.setattr(stage, "record", lambda subject, env: {"texts": {}}) + first = stage.more_inputs("1/ja", env) + a = contract.Task(stage.name, stage.version, "1/ja", env.params, {}, tuple(first)) + path.write_bytes(b"second font bytes") + second = stage.more_inputs("1/ja", env) + b = contract.Task(stage.name, stage.version, "1/ja", env.params, {}, tuple(second)) + assert a.key != b.key + assert depot.input_bytes(a.input("font:ja")) == b"first font bytes" + assert depot.input_bytes(b.input("font:ja")) == b"second font bytes" + assert a.input("episode").sha256 == b.input("episode").sha256 == episode.sha256 + env.params = {"fonts": "game"} + assert all(not i.role.startswith("font:") for i in stage.more_inputs("1/ja", env)) + + +def test_emoji_subclass_and_sprite_page_are_exported_for_driven_text(tmp_path): + from nnnotes.content import advui, tmpassets + from nnnotes.unity import fontsettings, textstyle + + depot = Depot(tmp_path / "d") + _, table, episode = fixture(depot) + emoji_id = "CAB-emoji:1" + body = contract.encode( + { + "$script": "Kyub.EmojiSearch|Kyub.EmojiSearch|TMP_EmojiSpriteAsset", + "m_Name": "Emoji", + "m_FaceInfo": {}, + "m_Material": ref("CAB-font:20"), + "spriteSheet": ref("CAB-atlas:1"), + "m_SpriteCharacterTable": [{"m_Unicode": 0x1F600, "m_Name": "1F600", "m_GlyphIndex": 1, "m_Scale": 1}], + "m_GlyphTable": [ + { + "m_Index": 1, + "m_Metrics": {}, + "m_GlyphRect": {"m_X": 0, "m_Y": 0, "m_Width": 4, "m_Height": 4}, + "m_Scale": 1, + "m_AtlasIndex": 0, + } + ], + } + ) + table["objects"][emoji_id] = { + "class": "MonoBehaviour", + "name": "Emoji", + "files": {"json": {"sha256": depot.put(body), "size": len(body), "ext": "json"}}, + } + table["keys"][advui.EMOJI_SPRITE_ASSET_KEY] = [emoji_id] + objects = Objects(table, depot) + widget = objects.json(sui.WIDGET) + widget["nodes"][0]["components"].append( + { + "type": "MonoBehaviour", + "pathId": 500, + "script": "Fwk|Fwk.UI|UIText", + "fields": {"_targetText": {"component": "TextMeshProUGUI", "gameObject": "UIAdvWidget/Talk"}}, + } + ) + widget["nodes"][1]["components"][0]["fields"]["m_tintAllSprites"] = True + episode["text"]["1"]["traditionalChinese"] += "\U0001f600" + lang = textstyle.language_fonts_json(sui.LOCALIZE_JSON) + settings = fontsettings.FontSettings( + tmpassets.build_font_settings(objects, lang, "1.2.3", lambda name: "font.bundle") + ) + doc, pages, keep, _ = fp.build( + objects, episode, "zh-Hant", {str(i): "AB" for i in sui.MASTER_IDS.values()}, settings, "game", {} + ) + assert doc["emojiSpriteAsset"] == "Emoji" + assert doc["sprites"] == {"CAB-ui:101": {"spriteAsset": "Emoji", "m_tintAllSprites": True}} + glyph = doc["spriteAssets"]["Emoji"]["glyphs"]["1"] + texture = doc["textures"][glyph["packed"]["texture"]] + assert pages[texture["texture"]] == objects.bytes("CAB-atlas:1", "image") + assert keep == {sui.SHADER} diff --git a/tests/content/test_tmpassets.py b/tests/content/test_tmpassets.py index 52227f9..4886590 100644 --- a/tests/content/test_tmpassets.py +++ b/tests/content/test_tmpassets.py @@ -153,6 +153,18 @@ def atlas_entries(pid: int, name: str, w: int = 4, h: int = 3) -> dict: } +@pytest.mark.parametrize("prefix", ["EmbFont/", "Font/"]) +def test_named_font_material_uses_its_available_catalog_folder(prefix): + entries = { + oid(10): ("MonoBehaviour", "X SDF", tmp_font("X SDF", material=oid(12)), {}), + oid(12): ("Material", "X - Default", material_doc("X - Default"), {}), + } + keys = {f"{prefix}X/X SDF": [oid(10)], f"{prefix}X/X - Default": [oid(12)]} + fs = tmpassets.FontSet(Table(entries, keys=keys)) + f = fs.by_key(f"{prefix}X/X SDF") + assert fs.named_material(f, "X - Default") == f.default_material() + + def test_texture_pixels_are_the_decoded_image_bottom_row_first(): rng = np.random.default_rng(7) rgba = rng.integers(0, 256, (3, 4, 4), dtype=np.uint8) diff --git a/tests/pipeline/test_story.py b/tests/pipeline/test_story.py index 8b97523..ec7a830 100644 --- a/tests/pipeline/test_story.py +++ b/tests/pipeline/test_story.py @@ -325,9 +325,11 @@ def test_the_story_product_holds_the_episode_and_the_story_document(tmp_path): "videos": {"Cri/Video/adv/movie_a/movie_a": "videos/adv~movie_a~movie_a.json"}, "chat": "storychat/default.json", "crilips": None, + "ui": {lang: f"storyui/{lang}.json" for lang in story.languages.LANGUAGES}, + "fonts": {lang: f"storyfonts/1_{lang}.json" for lang in story.languages.LANGUAGES}, }, } - assert story_doc["parts"] == story.part_paths(storyepisode.parts(episode)) + assert story_doc["parts"] == story.part_paths(storyepisode.parts(episode), 1) # ---------------------------------------------------------------- the chat product @@ -380,7 +382,7 @@ def test_the_story_ui_product_has_one_subject_per_client_language(tmp_path): assert stage.resources("ja", None) == ["DOTweenSettings", "TMP Settings"] assert stage.builtins("en", None) == ["GraphicsSettings", "MonoBehaviour:LocalizeManager"] assert stage.manifest("zh-Hant") == "storyui/zh-Hant.json" - assert UI not in PART_STAGES + assert PART_STAGES[UI] is UiStage with pytest.raises(ValueError, match="not a client language"): stage.manifest("fr") with pytest.raises(ValueError, match="not a client language"): @@ -440,7 +442,7 @@ def test_part_paths_give_the_manifest_of_each_part(): "chat": True, "crilips": False, } - assert story.part_paths(parts) == { + assert story.part_paths(parts, 1) == { "scene": "storyscenes/default.json", "assets": { "Adv/Stage/st_a/st_a": "storyassets/Adv~Stage~st_a~st_a.json", @@ -451,9 +453,11 @@ def test_part_paths_give_the_manifest_of_each_part(): "videos": {"Cri/Video/adv/m/m": "videos/adv~m~m.json"}, "chat": "storychat/default.json", "crilips": None, + "ui": {lang: f"storyui/{lang}.json" for lang in story.languages.LANGUAGES}, + "fonts": {lang: f"storyfonts/1_{lang}.json" for lang in story.languages.LANGUAGES}, } quiet = {**parts, "assets": [], "models": [], "sounds": [], "videos": [], "chat": False, "crilips": True} - assert story.part_paths(quiet) == { + assert story.part_paths(quiet, 1) == { "scene": "storyscenes/default.json", "assets": {}, "models": {}, @@ -461,6 +465,8 @@ def test_part_paths_give_the_manifest_of_each_part(): "videos": {}, "chat": None, "crilips": "crilips/default.json", + "ui": {lang: f"storyui/{lang}.json" for lang in story.languages.LANGUAGES}, + "fonts": {lang: f"storyfonts/1_{lang}.json" for lang in story.languages.LANGUAGES}, } @@ -473,7 +479,7 @@ def test_part_products_list_the_subjects_of_each_product_the_story_needs(): "chat": True, "crilips": True, } - doc = {"parts": story.part_paths(parts)} # story.json names each part by its manifest + doc = {"parts": story.part_paths(parts, 1)} # story.json names each part by its manifest assert story.part_products(doc) == { story.SCENE: ["default"], story.ASSET: ["Adv/Stage/a", "Adv/Stage/z"], @@ -487,9 +493,9 @@ def test_part_products_list_the_subjects_of_each_product_the_story_needs(): def test_part_products_omit_the_products_a_story_does_not_need(): parts = {"assets": [], "models": [], "sounds": [], "videos": [], "chat": False, "crilips": False} - assert story.part_products({"parts": story.part_paths(parts)}) == {story.SCENE: ["default"]} + assert story.part_products({"parts": story.part_paths(parts, 1)}) == {story.SCENE: ["default"]} parts["sounds"] = ["sheet_a"] - doc = {"parts": story.part_paths(parts)} + doc = {"parts": story.part_paths(parts, 1)} assert story.part_products(doc) == {story.SCENE: ["default"], story.SOUND: ["sheet_a"]}