diff --git a/docs/commands.md b/docs/commands.md index 7dc367c..a5188e5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -597,7 +597,7 @@ SITE/assets/.[.gz|.br] content-addressed files shared by all A story's manifest lists what the player's story page reads, in the layout of `export story` (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`, +`cues.json` as `encoderDelay`; the CRI category names a cue plays in as `categories`, e.g. `["Voice", "VoiceConfig"]`), the media files the episode uses and its videos; per language (`--story-languages`, default `ja,en,zh-Hant,zh-Hans,ko`) the story UI in that language (`ui/ui.json`), its font data (`ui/fonts.json` with glyph pages under `ui/fonts/`) and `ui/languages.json`. UI files that are equal in every language are common files. `scene.json` and `ui/ui.json` are stored split per top-level key, so the parts stories and languages share are stored diff --git a/src/nnnotes/content/story.py b/src/nnnotes/content/story.py index 6404df9..83274ba 100644 --- a/src/nnnotes/content/story.py +++ b/src/nnnotes/content/story.py @@ -118,7 +118,9 @@ def build( sheets = {} if audio: for sheet in sorted({c["_cueSheetName"] for c in episode["cuesheets"].values()}): - cri.decode(cat, sheet, out_dir / "audio" / sheet, fmt=audio_format, **(audio_options or {})) + cri.decode( + cat, sheet, out_dir / "audio" / sheet, fmt=audio_format, categories=True, **(audio_options or {}) + ) sheets[sheet] = f"audio/{sheet}" media = advmedia.extract(cat, player, master, episode, out_dir, fonts=fonts) diff --git a/src/nnnotes/formats/acb.py b/src/nnnotes/formats/acb.py index c6d661a..21c337d 100644 --- a/src/nnnotes/formats/acb.py +++ b/src/nnnotes/formats/acb.py @@ -97,6 +97,28 @@ def commands(b: bytes) -> list[tuple[int, bytes]]: return out +def cue_categories(acb: bytes) -> dict[str, list[str]]: + """{cue name: the CRI category names its sequence puts it in} (command 65 of the cue's sequence, the names of the + ACF category references the ACB lists). A cue that is not a sequence, or whose sequence has no category command, + has an empty list.""" + _, T = tables(acb) + names = {r["Id"]: r["Name"] for r in T.get("AcfReferenceTable", []) if r["Type"] == 3} + cue_names = {r["CueIndex"]: r["CueName"] for r in T.get("CueNameTable", [])} + seqs = T.get("SequenceTable", []) + cmds = T.get("SeqCommandTable", []) + out: dict[str, list[str]] = {} + for i, cue in enumerate(T.get("CueTable", [])): + found: list[str] = [] + if cue["ReferenceType"] == REF_SEQUENCE and cue["ReferenceIndex"] < len(seqs): + ci = seqs[cue["ReferenceIndex"]]["CommandIndex"] + if ci != 65535 and ci < len(cmds): + for code, v in commands(cmds[ci]["Command"]): + if code == 65: + found += [names[struct.unpack_from(">I", v, j)[0]] for j in range(0, len(v) - 3, 4)] + out[cue_names[i]] = found + return out + + def u16s(b: bytes) -> list[int]: return [struct.unpack_from(">H", b, i)[0] for i in range(0, len(b) - 1, 2)] diff --git a/src/nnnotes/formats/cri.py b/src/nnnotes/formats/cri.py index c617df5..ccce5da 100644 --- a/src/nnnotes/formats/cri.py +++ b/src/nnnotes/formats/cri.py @@ -51,6 +51,7 @@ from ..source.addressables import remote_path from ..source.catalog import Catalog from ..source.config import tool, usable_cpus +from . import acb as acbtables from . import crikey from .encoders import FLAC_LEVEL, encode_args @@ -215,6 +216,7 @@ def decode( flac_level: int = FLAC_LEVEL, workers: int | None = None, also: tuple[str, list[str]] | None = None, + categories: bool = False, ) -> dict[str, Path]: """Decode every stream of a cue sheet. Returns {streamName: file of its first stream}. @@ -223,6 +225,8 @@ def decode( `also` = (extension, ffmpeg codec options): every stream is encoded a second time from the same decoded samples into `` next to its file (encode_args: no metadata, bit-exact flags), e.g. the web format of a BGM, so that it need not be transcoded from the first file later; the manifests name the first file only. + `categories`: each cue of cues.json also names the CRI categories its sequence puts it in (`categories`, as + acb.cue_categories reads them; a cue without any has no such key). """ vgm = tool("vgmstream", "vgmstream-cli") ffmpeg = tool("ffmpeg", "ffmpeg") if fmt != "wav" or also else None @@ -245,6 +249,7 @@ def decode( cache.file_id(vgm), cache.file_id(ffmpeg) if ffmpeg else None, None if also is None else [also[0], list(also[1])], + bool(categories), ) hit = DECODED.get(ck) if hit is not None: @@ -259,6 +264,7 @@ def decode( flac_level=flac_level, workers=workers, also=also, + categories=categories, vgmstream=vgm, ffmpeg=ffmpeg, ) @@ -296,6 +302,7 @@ def decode_acb_bytes( flac_level: int = FLAC_LEVEL, workers: int | None = None, also: tuple[str, list[str]] | None = None, + categories: bool = False, vgmstream: str | None = None, ffmpeg: str | None = None, ) -> Decoded: @@ -389,6 +396,11 @@ def one(item) -> Path: if cue not in manifest: manifest[cue] = entry result[cue] = dst + if categories: + named = acbtables.cue_categories(acb) + for cue, entry in manifest.items(): + if named.get(cue): + entry["categories"] = named[cue] write_json(out_dir / "cues.json", manifest, ensure_ascii=True) write_json(out_dir / "streams.json", streams, ensure_ascii=True) shutil.rmtree(work) diff --git a/tests/formats/test_acb_categories.py b/tests/formats/test_acb_categories.py new file mode 100644 index 0000000..ac75775 --- /dev/null +++ b/tests/formats/test_acb_categories.py @@ -0,0 +1,52 @@ +"""The CRI categories of the cues of a synthetic ACB (no game data).""" + +import struct + +from nnnotes.formats import acb +from tests.pipeline.test_voices import tlv, utf + + +def acb_with_categories() -> bytes: + ref = lambda i, t, n: {"Id": i, "Type": t, "Name": n} # noqa: E731 + tables = { + "CueNameTable": utf( + "CueName", + [("CueName", 0xA), ("CueIndex", 2)], + [{"CueName": n, "CueIndex": i} for i, n in enumerate(["voice", "plain", "wave"])], + ), + "CueTable": utf( + "Cue", + [("CueId", 4), ("ReferenceType", 0), ("ReferenceIndex", 2), ("Length", 4)], + [ + {"CueId": 0, "ReferenceType": 3, "ReferenceIndex": 0, "Length": 10}, + {"CueId": 1, "ReferenceType": 3, "ReferenceIndex": 1, "Length": 10}, + {"CueId": 2, "ReferenceType": 1, "ReferenceIndex": 0, "Length": 10}, + ], + ), + "SequenceTable": utf( + "Sequence", + [("Type", 0), ("TrackIndex", 0xB), ("CommandIndex", 2)], + [ + {"Type": 0, "TrackIndex": b"", "CommandIndex": 0}, + {"Type": 0, "TrackIndex": b"", "CommandIndex": 65535}, + ], + ), + "SeqCommandTable": utf( + "SeqCommand", [("Command", 0xB)], [{"Command": tlv(65, struct.pack(">II", 7, 9)) + tlv(146, bytes(4))}] + ), + "AcfReferenceTable": utf( + "AcfReference", + [("Id", 2), ("Type", 0), ("Name", 0xA)], + [ref(7, 3, "Voice"), ref(9, 3, "VoiceConfig"), ref(1, 2, "Bus")], + ), + } + cols = [(k, 0xB) for k in tables] + [("Name", 0xA)] + return utf("Header", cols, [{**tables, "Name": "sheet"}]) + + +def test_a_cue_names_the_categories_its_sequence_puts_it_in(): + assert acb.cue_categories(acb_with_categories()) == { + "voice": ["Voice", "VoiceConfig"], # command 65 of its sequence + "plain": [], # a sequence without commands + "wave": [], # not a sequence + }