Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,7 @@ SITE/assets/<sha256>.<ext>[.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
Expand Down
4 changes: 3 additions & 1 deletion src/nnnotes/content/story.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions src/nnnotes/formats/acb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]

Expand Down
12 changes: 12 additions & 0 deletions src/nnnotes/formats/cri.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}.

Expand All @@ -223,6 +225,8 @@ def decode(
`also` = (extension, ffmpeg codec options): every stream is encoded a second time from the same decoded samples
into `<stem><extension>` 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
Expand All @@ -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:
Expand All @@ -259,6 +264,7 @@ def decode(
flac_level=flac_level,
workers=workers,
also=also,
categories=categories,
vgmstream=vgm,
ffmpeg=ffmpeg,
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions tests/formats/test_acb_categories.py
Original file line number Diff line number Diff line change
@@ -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
}
Loading