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