nnnotes 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nnnotes/__init__.py +7 -0
- nnnotes/__main__.py +4 -0
- nnnotes/acb.py +190 -0
- nnnotes/addressables.py +413 -0
- nnnotes/adv.py +243 -0
- nnnotes/advcommand.py +29 -0
- nnnotes/advmedia.py +178 -0
- nnnotes/advscene.py +113 -0
- nnnotes/advui.py +1154 -0
- nnnotes/advvideo.py +684 -0
- nnnotes/atoms/__init__.py +151 -0
- nnnotes/atoms/astc.py +52 -0
- nnnotes/atoms/gltf.py +290 -0
- nnnotes/atoms/mesh.py +289 -0
- nnnotes/atoms/png.py +28 -0
- nnnotes/atoms/reader.py +269 -0
- nnnotes/atoms/sniff.py +198 -0
- nnnotes/atoms/sprite.py +236 -0
- nnnotes/atoms/texture.py +80 -0
- nnnotes/cache.py +289 -0
- nnnotes/catalog.py +373 -0
- nnnotes/catalogdb.py +457 -0
- nnnotes/census.py +276 -0
- nnnotes/cli.py +744 -0
- nnnotes/cli_assets.py +1539 -0
- nnnotes/config.py +238 -0
- nnnotes/contract.py +497 -0
- nnnotes/cri.py +371 -0
- nnnotes/crikey.py +71 -0
- nnnotes/crilips.py +265 -0
- nnnotes/cristages.py +558 -0
- nnnotes/deckdata.py +462 -0
- nnnotes/export.py +1462 -0
- nnnotes/gameapi.py +318 -0
- nnnotes/jsonio.py +98 -0
- nnnotes/languages.py +44 -0
- nnnotes/layout.py +596 -0
- nnnotes/link.py +418 -0
- nnnotes/live.py +115 -0
- nnnotes/live2d.py +235 -0
- nnnotes/liveaudio.py +331 -0
- nnnotes/livenotes.py +291 -0
- nnnotes/liveoptions.py +250 -0
- nnnotes/livescene.py +450 -0
- nnnotes/liveui.py +328 -0
- nnnotes/master.py +251 -0
- nnnotes/modelstages.py +424 -0
- nnnotes/motion.py +131 -0
- nnnotes/objexport.py +1564 -0
- nnnotes/orchestrate.py +630 -0
- nnnotes/plan.py +218 -0
- nnnotes/player.py +392 -0
- nnnotes/room.py +391 -0
- nnnotes/score.py +1139 -0
- nnnotes/shader.py +217 -0
- nnnotes/spot.py +201 -0
- nnnotes/stages.py +285 -0
- nnnotes/store.py +518 -0
- nnnotes/story.py +142 -0
- nnnotes/storyfonts.py +1258 -0
- nnnotes/storyhost.py +1190 -0
- nnnotes/storysite.py +662 -0
- nnnotes/storyvoices.py +102 -0
- nnnotes/textstyle.py +355 -0
- nnnotes/tmpfont.py +636 -0
- nnnotes/typetrees/6000.3.12f1.json +19 -0
- nnnotes/unity.py +368 -0
- nnnotes/viewrules.json +441 -0
- nnnotes/views.py +966 -0
- nnnotes/voices.py +1363 -0
- nnnotes/web.py +1624 -0
- nnnotes/webaudio.py +14 -0
- nnnotes/webmodel.py +397 -0
- nnnotes-0.1.0.dist-info/METADATA +200 -0
- nnnotes-0.1.0.dist-info/RECORD +79 -0
- nnnotes-0.1.0.dist-info/WHEEL +5 -0
- nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
- nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
- nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/voices.py
ADDED
|
@@ -0,0 +1,1363 @@
|
|
|
1
|
+
"""Voices: every character voice the master data names, joined to its cue in the cue sheet and to the decoded
|
|
2
|
+
streams of the export.
|
|
3
|
+
|
|
4
|
+
Sources (the rules: `voices` in viewrules.json, docs/views.md):
|
|
5
|
+
|
|
6
|
+
rows a master table whose rows name a MasterSound id (the voice collection's CharacterVoiceMasterType
|
|
7
|
+
tables, and MasterHomeSpot's intro voice): per row the character(s), the sound, the text, the row's
|
|
8
|
+
own category enum and the listed conditions (dates, unlock ranks)
|
|
9
|
+
code MasterTitle: the characters of `_voiceCharacterIds`, each with the cue names the client builds for
|
|
10
|
+
them (InitialVoicePlayer: title call and splash company voices)
|
|
11
|
+
Sound the MasterSound rows of the voice category that no other source names: their sheet and cue, and
|
|
12
|
+
what the sheet and cue names suggest (`inferred`: a situation, characters by name, the rows of other
|
|
13
|
+
sources in the same sheet), kept apart from the master facts
|
|
14
|
+
Story the ADV episodes of MasterAdv (`story` rules; storyvoices): per voice id of an episode command, the
|
|
15
|
+
command, its speaker and line texts (the episode's -Text shard), its sound (-Sound) and cue sheet
|
|
16
|
+
(-SoundCueSheet); the characters the speaker names suggest are `inferred`. The episodes, with their
|
|
17
|
+
kind (the table that plays them), title and status, are the document's `episodes`
|
|
18
|
+
|
|
19
|
+
A row's sound gives its cue sheet (MasterSoundCueSheet) and cue name. The cue is looked up in the sheet's ACB
|
|
20
|
+
(acb.cue_streams: cue -> waveforms -> the decoder's stream numbers, from the ACB's tables, no audio decoded) and
|
|
21
|
+
each stream in the result of the sheet's cri.audio task (its file, sha256, rate, channels, samples).
|
|
22
|
+
|
|
23
|
+
Content (source, characters, category, sound, text, conditions) and availability are kept apart: `availability`
|
|
24
|
+
says whether the master data resolves the sound to a sheet and cue (`master`), whether the catalog has the sheet's
|
|
25
|
+
key (`catalog`) and whether the export decoded the cue's streams (`exported`); `status` is the first that fails:
|
|
26
|
+
|
|
27
|
+
no-value the master data does not resolve the sound (no MasterSound row, no sheet row)
|
|
28
|
+
missing-key the catalog has no key Cri/Sound/<sheet>
|
|
29
|
+
not-exported the key exists, the export has no cri.audio result for it
|
|
30
|
+
unsupported the sheet's cri.audio task could not decode it (its reason in `detail`)
|
|
31
|
+
missing-cue the sheet is decoded, its ACB has no such cue, or the cue plays none of its streams
|
|
32
|
+
ok the cue's streams, in `audio`
|
|
33
|
+
|
|
34
|
+
Gaps are the rows that are missing-key or missing-cue. Reverse coverage: the catalog keys under the rules'
|
|
35
|
+
prefixes whose sheet no row names, and per decoded sheet the streams no row reaches. Whether a site shows a voice
|
|
36
|
+
is not recorded here: sites are built separately.
|
|
37
|
+
|
|
38
|
+
The stage `view.voices` (one task per catalog subject, when master data is set and the view selected) reads the
|
|
39
|
+
master tables of its rules, the rules, the part of the address table it looks up ("addresses", views.Recorder), the
|
|
40
|
+
sheets it resolves ("sheets": sheet -> cri.audio task and ACB content), each decoded sheet's ACB ("acb:<sha256>"),
|
|
41
|
+
the episode objects the unity.export results hold ("episodes": asset -> part -> object, artifact and content id;
|
|
42
|
+
"adv:<sha256>" each content) and, in its context, the keys of the cri.audio results. Its document
|
|
43
|
+
(nnnotes.voices/1) is the artifact "view.voices:<subject>#view"; the `original` layout gets views/voices.json with
|
|
44
|
+
the file of every stream.
|
|
45
|
+
|
|
46
|
+
The command `nnnotes voices` lists, searches and fetches voices from that document or, without an export, from an
|
|
47
|
+
index built from the configured master data and catalog (content only; the episodes named by --episode or a row id
|
|
48
|
+
are read from the catalog). Nothing here imports UnityPy or numpy before a sheet is decoded or an episode loaded.
|
|
49
|
+
"""
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import copy
|
|
53
|
+
import functools
|
|
54
|
+
import json
|
|
55
|
+
import re
|
|
56
|
+
import shutil
|
|
57
|
+
import sys
|
|
58
|
+
import tempfile
|
|
59
|
+
import unicodedata
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
|
|
62
|
+
from . import acb, advcommand, contract, languages, storyvoices, views
|
|
63
|
+
from .contract import Cost, Input
|
|
64
|
+
from .stages import Output, Pending, Stage
|
|
65
|
+
|
|
66
|
+
SCHEMA = "nnnotes.voices/1"
|
|
67
|
+
VIEW = "voices"
|
|
68
|
+
STAGE = views.STAGE_PREFIX + VIEW
|
|
69
|
+
AUDIO_STAGE = "cri.audio"
|
|
70
|
+
EXPORT_STAGE = "unity.export"
|
|
71
|
+
SHEET_PREFIX = "Cri/Sound/"
|
|
72
|
+
STATUSES = ("ok", "missing-cue", "missing-key", "not-exported", "unsupported", "no-value")
|
|
73
|
+
GAP_STATUSES = ("missing-key", "missing-cue")
|
|
74
|
+
SOUND_SOURCE = "Sound"
|
|
75
|
+
STORY_SOURCE = "Story"
|
|
76
|
+
SPEAKER_RULE = "speaker-name"
|
|
77
|
+
LANGUAGES = tuple(languages.LANGUAGES)
|
|
78
|
+
_SOURCE_KEYS = {"name", "type", "table", "category", "voices", "names", "conditions", "characters", "sheet", "cues"}
|
|
79
|
+
_VOICE_KEYS = {"slot", "character", "characters", "sound", "text"}
|
|
80
|
+
_RULE_KEYS = {"version", "key", "prefixes", "sound", "characters", "enums", "sources", "situations", "near", "story"}
|
|
81
|
+
_STORY_KEYS = {"table", "asset", "title", "key", "shards", "split", "kinds"}
|
|
82
|
+
_KIND_KEYS = {"kind", "table", "adv", "fields", "names"}
|
|
83
|
+
# story part -> (the class of its object, the unity.export artifact role that holds its content)
|
|
84
|
+
STORY_OBJECTS = {"script": ("MonoBehaviour", "json"), "text": ("TextAsset", "data"), "sound": ("TextAsset", "data"),
|
|
85
|
+
"sheets": ("TextAsset", "data")}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------- rules
|
|
89
|
+
def check_rules(rules: dict) -> None:
|
|
90
|
+
"""Raise views.ViewError naming the first problem of the `voices` rules."""
|
|
91
|
+
v = rules.get(VIEW)
|
|
92
|
+
if not isinstance(v, dict):
|
|
93
|
+
raise views.ViewError("view rules: no voices rules")
|
|
94
|
+
if set(v) - _RULE_KEYS:
|
|
95
|
+
raise views.ViewError(f"voices: unknown keys {sorted(set(v) - _RULE_KEYS)}")
|
|
96
|
+
if not isinstance(v.get("version"), int) or v["version"] < 1:
|
|
97
|
+
raise views.ViewError("voices: version is a positive integer")
|
|
98
|
+
if views.fields(v.get("key", "")) != ["sheet"]:
|
|
99
|
+
raise views.ViewError("voices: key is a template of {sheet}")
|
|
100
|
+
for k in ("table", "sheets", "sheet", "sheetName", "cue", "category", "voice"):
|
|
101
|
+
if k not in v.get("sound", {}):
|
|
102
|
+
raise views.ViewError(f"voices.sound: no {k}")
|
|
103
|
+
if "table" not in v.get("characters", {}) or "token" not in v["characters"]:
|
|
104
|
+
raise views.ViewError("voices.characters: a table and a token column")
|
|
105
|
+
enums = v.get("enums", {})
|
|
106
|
+
names = set()
|
|
107
|
+
for s in v.get("sources", []):
|
|
108
|
+
where = f"voices.sources.{s.get('name')}"
|
|
109
|
+
if set(s) - _SOURCE_KEYS or not s.get("name") or not s.get("table"):
|
|
110
|
+
raise views.ViewError(f"{where}: keys are {', '.join(sorted(_SOURCE_KEYS))} (name and table needed)")
|
|
111
|
+
if s["name"] in names or s["name"] in (SOUND_SOURCE, STORY_SOURCE):
|
|
112
|
+
raise views.ViewError(f"{where}: repeated or reserved name")
|
|
113
|
+
names.add(s["name"])
|
|
114
|
+
cat = s.get("category")
|
|
115
|
+
if cat is not None and (set(cat) != {"column", "enum"} or cat["enum"] not in enums):
|
|
116
|
+
raise views.ViewError(f"{where}.category: {{column, enum}} of a known enum")
|
|
117
|
+
if ("voices" in s) == ("cues" in s):
|
|
118
|
+
raise views.ViewError(f"{where}: either voices or cues")
|
|
119
|
+
for x in s.get("voices", []):
|
|
120
|
+
if set(x) - _VOICE_KEYS or "sound" not in x or ("character" in x) == ("characters" in x):
|
|
121
|
+
raise views.ViewError(f"{where}.voices: a sound and a character or characters column")
|
|
122
|
+
if len(s.get("voices", [])) > 1 and len({x.get("slot") for x in s["voices"]}) != len(s["voices"]):
|
|
123
|
+
raise views.ViewError(f"{where}.voices: several voices of a row need distinct slots")
|
|
124
|
+
if "cues" in s:
|
|
125
|
+
if not s.get("characters") or not s.get("sheet"):
|
|
126
|
+
raise views.ViewError(f"{where}: cues need a characters column and a sheet")
|
|
127
|
+
for c in s["cues"]:
|
|
128
|
+
if set(c) != {"category", "cue"} or views.fields(c["cue"]) != ["character"]:
|
|
129
|
+
raise views.ViewError(f"{where}.cues: {{category, cue}}, the cue a template of {{character}}")
|
|
130
|
+
for n in v.get("near", []):
|
|
131
|
+
if n not in names:
|
|
132
|
+
raise views.ViewError(f"voices.near: {n!r} is not a source")
|
|
133
|
+
for s in v.get("situations", []):
|
|
134
|
+
if set(s) != {"situation", "cue"}:
|
|
135
|
+
raise views.ViewError("voices.situations: {situation, cue}")
|
|
136
|
+
re.compile(s["cue"])
|
|
137
|
+
for p in v.get("prefixes", []):
|
|
138
|
+
if not isinstance(p, str) or not p:
|
|
139
|
+
raise views.ViewError("voices.prefixes: non-empty strings")
|
|
140
|
+
if "story" in v:
|
|
141
|
+
_check_story(v["story"])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_story(st) -> None:
|
|
145
|
+
if not isinstance(st, dict) or set(st) - _STORY_KEYS or not all(st.get(k) for k in ("table", "asset", "key")):
|
|
146
|
+
raise views.ViewError(f"voices.story: keys are {', '.join(sorted(_STORY_KEYS))} (table, asset and key "
|
|
147
|
+
f"needed)")
|
|
148
|
+
if set(views.fields(st["key"])) != {"asset"}:
|
|
149
|
+
raise views.ViewError("voices.story: key is a template of {asset}")
|
|
150
|
+
shards = st.get("shards")
|
|
151
|
+
if not isinstance(shards, dict) or set(shards) != {"text", "sound", "sheets"} or not all(
|
|
152
|
+
isinstance(x, str) and x for x in shards.values()):
|
|
153
|
+
raise views.ViewError("voices.story.shards: the key suffixes of text, sound and sheets")
|
|
154
|
+
if not isinstance(st.get("split", ""), str):
|
|
155
|
+
raise views.ViewError("voices.story.split: a string")
|
|
156
|
+
kinds = set()
|
|
157
|
+
for k in st.get("kinds", []):
|
|
158
|
+
where = f"voices.story.kinds.{k.get('kind') if isinstance(k, dict) else k}"
|
|
159
|
+
if not isinstance(k, dict) or set(k) - _KIND_KEYS or not all(k.get(x) for x in ("kind", "table", "adv")):
|
|
160
|
+
raise views.ViewError(f"{where}: keys are {', '.join(sorted(_KIND_KEYS))} (kind, table and adv needed)")
|
|
161
|
+
if k["kind"] in kinds:
|
|
162
|
+
raise views.ViewError(f"{where}: repeated kind")
|
|
163
|
+
kinds.add(k["kind"])
|
|
164
|
+
for part in ("fields", "names"):
|
|
165
|
+
for label, spec in (k.get(part) or {}).items():
|
|
166
|
+
views._check_var(f"{where}.{part}.{label}", spec)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def load_rules(path=None) -> dict:
|
|
170
|
+
"""{"format", "voices"} of the view rules (the package's viewrules.json by default), checked."""
|
|
171
|
+
rules = views.load_rules(path)
|
|
172
|
+
out = {"format": rules["format"], VIEW: rules.get(VIEW)}
|
|
173
|
+
check_rules(out)
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def rules_digest(rules: dict) -> str:
|
|
178
|
+
return contract.digest({"format": rules["format"], VIEW: rules[VIEW]})
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def tables_of(rules: dict) -> list[str]:
|
|
182
|
+
"""Every master table the voices rules read, sorted."""
|
|
183
|
+
v = rules[VIEW]
|
|
184
|
+
out = {v["sound"]["table"], v["sound"]["sheets"], v["characters"]["table"], views.TEXT_TABLE}
|
|
185
|
+
out.update(s["table"] for s in v["sources"])
|
|
186
|
+
st = v.get("story")
|
|
187
|
+
if st:
|
|
188
|
+
out.add(st["table"])
|
|
189
|
+
for k in st.get("kinds", []):
|
|
190
|
+
out.add(k["table"])
|
|
191
|
+
for spec in list((k.get("fields") or {}).values()) + list((k.get("names") or {}).values()):
|
|
192
|
+
out.update(step.partition(".")[0] for step in views._chain(spec)[1:])
|
|
193
|
+
return sorted(out)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def story_keys(rules: dict, asset: str) -> dict:
|
|
197
|
+
"""{part: catalog key} of an episode asset (storyvoices.PARTS)."""
|
|
198
|
+
return _story_keys(rules[VIEW]["story"], asset)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _story_keys(st: dict, asset: str) -> dict:
|
|
202
|
+
key = st["key"].format(asset=asset)
|
|
203
|
+
return {"script": key, **{p: key + st["shards"][p] for p in ("text", "sound", "sheets")}}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def story_assets(rules: dict, tables) -> list[str]:
|
|
207
|
+
"""The episode assets of the story table's rows (sorted by row id; an empty asset names none)."""
|
|
208
|
+
st = rules[VIEW].get("story")
|
|
209
|
+
if not st:
|
|
210
|
+
return []
|
|
211
|
+
rows = views._Lookup(tables).rows(st["table"])
|
|
212
|
+
out = []
|
|
213
|
+
for r in sorted(rows, key=lambda r: views._sort_key(r.get("_id"))):
|
|
214
|
+
a = r.get(st["asset"])
|
|
215
|
+
if isinstance(a, str) and a and a not in out:
|
|
216
|
+
out.append(a)
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------- audio of the decoded sheets
|
|
221
|
+
def sheet_audio(result: dict | None, cues: dict | None) -> dict:
|
|
222
|
+
"""What the index reads of one decoded sheet: its cri.audio result (streams by number: artifact id, file, sha256,
|
|
223
|
+
size and the stream facts; status and reason) and its ACB's cue table (acb.cue_streams)."""
|
|
224
|
+
if result is None:
|
|
225
|
+
return {"status": "not-exported", "streams": {}, "cues": {}}
|
|
226
|
+
streams = {}
|
|
227
|
+
for a in result["artifacts"]:
|
|
228
|
+
if a["semantics"].get("kind") != "audio.stream":
|
|
229
|
+
continue
|
|
230
|
+
f = dict(a["semantics"].get("facts") or {})
|
|
231
|
+
n = f.pop("stream", None)
|
|
232
|
+
if n is None:
|
|
233
|
+
continue
|
|
234
|
+
streams[int(n)] = {"artifact": a["id"], "file": contract.parse_artifact_id(a["id"])[1],
|
|
235
|
+
"sha256": a["content"]["sha256"], "size": a["content"]["size"], **f}
|
|
236
|
+
item = next(iter(result["items"]), {})
|
|
237
|
+
out = {"status": "unsupported" if item.get("status") == "unsupported" else "exported",
|
|
238
|
+
"streams": streams, "cues": cues or {}}
|
|
239
|
+
if out["status"] == "unsupported":
|
|
240
|
+
out["why"] = (item.get("reason") or {}).get("code")
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _stream_entry(s: dict) -> dict:
|
|
245
|
+
e = {k: s[k] for k in ("artifact", "file", "sha256", "size", "sampleRate", "channels", "samples", "loopStart",
|
|
246
|
+
"loopEnd") if k in s}
|
|
247
|
+
if s.get("samples") and s.get("sampleRate"):
|
|
248
|
+
e["seconds"] = round(s["samples"] / s["sampleRate"], 6)
|
|
249
|
+
return e
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------- the index
|
|
253
|
+
class _Master:
|
|
254
|
+
def __init__(self, rules: dict, tables):
|
|
255
|
+
self.v = rules[VIEW]
|
|
256
|
+
self.look = views._Lookup(tables)
|
|
257
|
+
sd = self.v["sound"]
|
|
258
|
+
self.sound = {r.get("_id"): r for r in self.look.rows(sd["table"])}
|
|
259
|
+
self.sheets = {r.get("_id"): r.get(sd["sheetName"]) for r in self.look.rows(sd["sheets"])}
|
|
260
|
+
cd = self.v["characters"]
|
|
261
|
+
self.characters = {}
|
|
262
|
+
ids = {}
|
|
263
|
+
for r in sorted(self.look.rows(cd["table"]), key=lambda r: views._sort_key(r.get("_id"))):
|
|
264
|
+
names = {k: self.look.text(r.get(col)) for k, col in cd.get("names", {}).items()
|
|
265
|
+
if not views._empty(r.get(col))}
|
|
266
|
+
ids[r.get("_id")] = str(r.get(cd["token"]) or "")
|
|
267
|
+
token = ids[r.get("_id")].rsplit("_", 1)[-1]
|
|
268
|
+
self.characters[r.get("_id")] = {"id": r.get("_id"), "names": names, "token": token}
|
|
269
|
+
self.name_parts = _name_parts(ids)
|
|
270
|
+
|
|
271
|
+
def enum(self, name: str, value):
|
|
272
|
+
return self.v["enums"].get(name, {}).get(str(value))
|
|
273
|
+
|
|
274
|
+
def text(self, tid):
|
|
275
|
+
if views._empty(tid):
|
|
276
|
+
return None
|
|
277
|
+
return {"id": tid, "texts": self.look.text(tid)}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _name_parts(ids: dict) -> dict:
|
|
281
|
+
"""{name part, case folded: character}: the `_` parts of each character's token column (`_nameTextID`) after
|
|
282
|
+
the leading parts every character's value shares (its last part always kept); a part of several characters is
|
|
283
|
+
left out."""
|
|
284
|
+
split = {c: [p for p in v.split("_") if p] for c, v in ids.items()}
|
|
285
|
+
lists = [v for v in split.values() if v]
|
|
286
|
+
head = 0
|
|
287
|
+
while lists and head < min(len(v) for v in lists) - 1 and len({v[head] for v in lists}) == 1:
|
|
288
|
+
head += 1
|
|
289
|
+
owners: dict = {}
|
|
290
|
+
for c, parts in split.items():
|
|
291
|
+
for p in parts[head:]:
|
|
292
|
+
owners.setdefault(p.casefold(), set()).add(c)
|
|
293
|
+
return {p: next(iter(cs)) for p, cs in owners.items() if len(cs) == 1}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class _Resolver:
|
|
297
|
+
"""The status, availability and audio of a (sheet, cue); records the catalog keys it looks up."""
|
|
298
|
+
|
|
299
|
+
def __init__(self, rules: dict, index, audio: dict):
|
|
300
|
+
self.key = rules[VIEW]["key"]
|
|
301
|
+
self.index, self.audio = index, audio
|
|
302
|
+
self.reached: dict[str, set] = {}
|
|
303
|
+
|
|
304
|
+
def __call__(self, sheet, cue) -> dict:
|
|
305
|
+
out = {"availability": {"master": sheet is not None and cue is not None, "catalog": None,
|
|
306
|
+
"exported": False}}
|
|
307
|
+
if sheet is None or cue is None:
|
|
308
|
+
return out
|
|
309
|
+
present = self.index.objects(self.key.format(sheet=sheet)) is not None
|
|
310
|
+
out["availability"]["catalog"] = present
|
|
311
|
+
if not present:
|
|
312
|
+
out.update(status="missing-key", detail=f"no catalog key {self.key.format(sheet=sheet)}")
|
|
313
|
+
return out
|
|
314
|
+
a = self.audio.get(sheet)
|
|
315
|
+
if a is None or a["status"] == "not-exported":
|
|
316
|
+
out["status"] = "not-exported"
|
|
317
|
+
return out
|
|
318
|
+
if a["status"] == "unsupported":
|
|
319
|
+
out.update(status="unsupported", detail=a.get("why"))
|
|
320
|
+
return out
|
|
321
|
+
c = a["cues"].get(cue)
|
|
322
|
+
if c is None:
|
|
323
|
+
out.update(status="missing-cue", detail=f"sheet {sheet} has no cue {cue}")
|
|
324
|
+
return out
|
|
325
|
+
streams, problems = [], []
|
|
326
|
+
for w in c["streams"]:
|
|
327
|
+
s = a["streams"].get(w.get("stream"))
|
|
328
|
+
if s is None:
|
|
329
|
+
problems.append(f"waveform {w.get('awbId')} is not a decoded stream")
|
|
330
|
+
continue
|
|
331
|
+
if abs((s.get("samples") or 0) - (w.get("samples") or 0)) > 8 or s.get("sampleRate") != w.get(
|
|
332
|
+
"sampleRate") or s.get("channels") != w.get("channels"):
|
|
333
|
+
problems.append(f"stream {w['stream']}: the ACB's waveform differs from the decoded stream")
|
|
334
|
+
streams.append({"stream": w["stream"], **_stream_entry(s)})
|
|
335
|
+
self.reached.setdefault(sheet, set()).add(w["stream"])
|
|
336
|
+
if not streams:
|
|
337
|
+
out.update(status="missing-cue", detail=f"cue {cue} plays no stream of sheet {sheet}")
|
|
338
|
+
return out
|
|
339
|
+
out["availability"]["exported"] = True
|
|
340
|
+
out.update(status="ok", audio={"cueId": c.get("cueId"), "lengthMs": c.get("lengthMs"), "streams": streams})
|
|
341
|
+
if problems:
|
|
342
|
+
out["detail"] = "; ".join(problems)
|
|
343
|
+
return out
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _sound_fields(m: _Master, sid) -> tuple[dict, str | None, str | None, str | None]:
|
|
347
|
+
"""(sound entry, sheet, cue, why the master data does not resolve it)."""
|
|
348
|
+
sd = m.v["sound"]
|
|
349
|
+
snd = m.sound.get(sid)
|
|
350
|
+
if snd is None:
|
|
351
|
+
return {"id": sid}, None, None, f"{sd['table']} has no row {sid}"
|
|
352
|
+
sheet = m.sheets.get(snd.get(sd["sheet"]))
|
|
353
|
+
entry = {"id": sid, "category": snd.get(sd["category"]), "sheet": sheet, "cue": snd.get(sd["cue"])}
|
|
354
|
+
if sheet is None:
|
|
355
|
+
return entry, None, None, f"{sd['sheets']} has no row {snd.get(sd['sheet'])}"
|
|
356
|
+
return entry, sheet, snd.get(sd["cue"]), None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _finish(row: dict, res: dict, why: str | None) -> dict:
|
|
360
|
+
row["availability"] = res["availability"]
|
|
361
|
+
if why is not None:
|
|
362
|
+
row.update(status="no-value", detail=why)
|
|
363
|
+
return row
|
|
364
|
+
row["status"] = res["status"]
|
|
365
|
+
for k in ("detail", "audio"):
|
|
366
|
+
if k in res:
|
|
367
|
+
row[k] = res[k]
|
|
368
|
+
return row
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _category(m: _Master, s: dict, raw: dict) -> dict:
|
|
372
|
+
cat = s.get("category")
|
|
373
|
+
if cat is None:
|
|
374
|
+
return {"name": s["name"]}
|
|
375
|
+
value = raw.get(cat["column"])
|
|
376
|
+
return {"name": m.enum(cat["enum"], value), "enum": cat["enum"], "value": value}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _characters(value) -> list:
|
|
380
|
+
vals = value if isinstance(value, list) else ([] if views._empty(value) else [value])
|
|
381
|
+
return [v for v in vals if not views._empty(v) and v != 0]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _row_sources(m: _Master, resolve: _Resolver, named: set) -> list[dict]:
|
|
385
|
+
out = []
|
|
386
|
+
for s in m.v["sources"]:
|
|
387
|
+
if "voices" not in s:
|
|
388
|
+
continue
|
|
389
|
+
stype = s.get("type")
|
|
390
|
+
for raw in sorted(m.look.rows(s["table"]), key=lambda r: views._sort_key(r.get("_id"))):
|
|
391
|
+
for x in s["voices"]:
|
|
392
|
+
sid = raw.get(x["sound"])
|
|
393
|
+
if views._empty(sid) or sid == 0:
|
|
394
|
+
continue
|
|
395
|
+
named.add(sid)
|
|
396
|
+
rid = f"{s['table']}:{raw.get('_id')}" + (f":{x['slot']}" if len(s["voices"]) > 1 else "")
|
|
397
|
+
source = {"name": s["name"], "table": s["table"], "row": raw.get("_id"), "column": x["sound"]}
|
|
398
|
+
if stype is not None:
|
|
399
|
+
source["type"] = {"enum": "CharacterVoiceMasterType", "value": stype,
|
|
400
|
+
"name": m.enum("CharacterVoiceMasterType", stype)}
|
|
401
|
+
if len(s["voices"]) > 1:
|
|
402
|
+
source["slot"] = x["slot"]
|
|
403
|
+
chars = _characters(raw.get(x["character"] if "character" in x else x["characters"]))
|
|
404
|
+
sound, sheet, cue, why = _sound_fields(m, sid)
|
|
405
|
+
row = {"id": rid, "source": source, "characters": chars, "category": _category(m, s, raw),
|
|
406
|
+
"sound": sound}
|
|
407
|
+
text = m.text(raw.get(x["text"])) if "text" in x else None
|
|
408
|
+
if text is not None:
|
|
409
|
+
row["text"] = text
|
|
410
|
+
names = {k: m.look.text(raw.get(col)) for k, col in s.get("names", {}).items()
|
|
411
|
+
if not views._empty(raw.get(col))}
|
|
412
|
+
if names:
|
|
413
|
+
row["names"] = names
|
|
414
|
+
cond = {c: raw[c] for c in s.get("conditions", []) if c in raw and not views._empty(raw[c])}
|
|
415
|
+
if cond:
|
|
416
|
+
row["conditions"] = cond
|
|
417
|
+
out.append(_finish(row, resolve(sheet, cue), why))
|
|
418
|
+
return out
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _code_sources(m: _Master, resolve: _Resolver) -> list[dict]:
|
|
422
|
+
out = []
|
|
423
|
+
for s in m.v["sources"]:
|
|
424
|
+
if "cues" not in s:
|
|
425
|
+
continue
|
|
426
|
+
by_char: dict = {}
|
|
427
|
+
for raw in sorted(m.look.rows(s["table"]), key=lambda r: views._sort_key(r.get("_id"))):
|
|
428
|
+
for c in _characters(raw.get(s["characters"])):
|
|
429
|
+
by_char.setdefault(c, []).append(raw.get("_id"))
|
|
430
|
+
for c in sorted(by_char, key=views._sort_key):
|
|
431
|
+
for pat in s["cues"]:
|
|
432
|
+
cue = pat["cue"].format(character=c)
|
|
433
|
+
row = {"id": f"{s['name']}:{c}:{pat['category']}",
|
|
434
|
+
"source": {"name": s["name"], "table": s["table"], "rows": by_char[c], "column": s["characters"],
|
|
435
|
+
"cue": pat["cue"]},
|
|
436
|
+
"characters": [c], "category": {"name": pat["category"]},
|
|
437
|
+
"sound": {"id": None, "sheet": s["sheet"], "cue": cue}}
|
|
438
|
+
out.append(_finish(row, resolve(s["sheet"], cue), None))
|
|
439
|
+
return out
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _inferred(m: _Master, sheet: str | None, cue: str, near: dict) -> dict:
|
|
443
|
+
out = {}
|
|
444
|
+
for s in m.v.get("situations", []):
|
|
445
|
+
if re.fullmatch(s["cue"], cue or ""):
|
|
446
|
+
out["situation"] = s["situation"]
|
|
447
|
+
break
|
|
448
|
+
tokens = [t.casefold() for t in re.split(r"[_\W]+", cue or "") if t]
|
|
449
|
+
chars = []
|
|
450
|
+
for t in tokens:
|
|
451
|
+
for c in m.characters.values():
|
|
452
|
+
if c["token"] and c["token"].casefold() == t and c["id"] not in chars:
|
|
453
|
+
chars.append(c["id"])
|
|
454
|
+
if chars:
|
|
455
|
+
out["characters"] = chars
|
|
456
|
+
rows = near.get(sheet)
|
|
457
|
+
if rows:
|
|
458
|
+
out["rows"] = sorted(rows)
|
|
459
|
+
return out
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _sound_rows(m: _Master, resolve: _Resolver, named: set, rows: list[dict]) -> list[dict]:
|
|
463
|
+
sd = m.v["sound"]
|
|
464
|
+
near: dict[str, set] = {}
|
|
465
|
+
wanted = set(m.v.get("near", []))
|
|
466
|
+
for r in rows:
|
|
467
|
+
if r["source"]["name"] in wanted and r["sound"].get("sheet"):
|
|
468
|
+
near.setdefault(r["sound"]["sheet"], set()).add(r["id"])
|
|
469
|
+
out = []
|
|
470
|
+
for sid in sorted((k for k, r in m.sound.items() if r.get(sd["category"]) == sd["voice"] and k not in named),
|
|
471
|
+
key=views._sort_key):
|
|
472
|
+
sound, sheet, cue, why = _sound_fields(m, sid)
|
|
473
|
+
row = {"id": f"{sd['table']}:{sid}", "source": {"name": SOUND_SOURCE, "table": sd["table"], "row": sid},
|
|
474
|
+
"characters": [], "category": {"name": None}, "sound": sound}
|
|
475
|
+
inf = _inferred(m, sheet, sound.get("cue"), near)
|
|
476
|
+
if inf:
|
|
477
|
+
row["inferred"] = inf
|
|
478
|
+
out.append(_finish(row, resolve(sheet, cue), why))
|
|
479
|
+
return out
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _kinds(m: _Master) -> dict:
|
|
483
|
+
"""{adv id: (kind rule, row)}: the first row (kinds in rules order, rows by id) that plays the episode."""
|
|
484
|
+
out: dict = {}
|
|
485
|
+
for k in m.v["story"].get("kinds", []):
|
|
486
|
+
for raw in sorted(m.look.rows(k["table"]), key=lambda r: views._sort_key(r.get("_id"))):
|
|
487
|
+
aid = raw.get(k["adv"])
|
|
488
|
+
if not views._empty(aid) and aid != 0:
|
|
489
|
+
out.setdefault(aid, (k, raw))
|
|
490
|
+
return out
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _episode(m: _Master, raw: dict, kinds: dict) -> dict:
|
|
494
|
+
st = m.v["story"]
|
|
495
|
+
ep = {"id": raw.get("_id"), "asset": raw.get(st["asset"])}
|
|
496
|
+
title = m.text(raw.get(st["title"])) if st.get("title") else None
|
|
497
|
+
if title is not None:
|
|
498
|
+
ep["title"] = title
|
|
499
|
+
rule, row = kinds.get(ep["id"], (None, None))
|
|
500
|
+
ep["kind"] = rule["kind"] if rule else None
|
|
501
|
+
if rule:
|
|
502
|
+
ep["table"], ep["row"] = rule["table"], row.get("_id")
|
|
503
|
+
r = views._Row(row, m.look)
|
|
504
|
+
fields = {}
|
|
505
|
+
for label, spec in (rule.get("fields") or {}).items():
|
|
506
|
+
value, _ = r.value(spec)
|
|
507
|
+
if not views._empty(value):
|
|
508
|
+
fields[label] = value
|
|
509
|
+
if fields:
|
|
510
|
+
ep["fields"] = fields
|
|
511
|
+
names = views._names(r, rule.get("names") or {}, m.look)
|
|
512
|
+
if names:
|
|
513
|
+
ep["names"] = names
|
|
514
|
+
return ep
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _speaker_matches(m: _Master, target: str) -> list[dict]:
|
|
518
|
+
"""The segments of a command's TargetName (split at the rules' `split`) that are, ignoring case, a name part of
|
|
519
|
+
one character (_name_parts: its token or another part of its name id), with that character (rule SPEAKER_RULE),
|
|
520
|
+
in order, each character once."""
|
|
521
|
+
split = m.v["story"].get("split") or ""
|
|
522
|
+
out = []
|
|
523
|
+
for part in (target.split(split) if split else [target]):
|
|
524
|
+
seg = part.strip()
|
|
525
|
+
c = m.name_parts.get(seg.casefold()) if seg else None
|
|
526
|
+
if c is not None and all(x["character"] != c for x in out):
|
|
527
|
+
out.append({"segment": seg, "character": c})
|
|
528
|
+
return out
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _unresolved(status: str, detail: str) -> dict:
|
|
532
|
+
return {"availability": {"master": False, "catalog": None, "exported": False}, "status": status,
|
|
533
|
+
"detail": detail}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _story_rows(m: _Master, resolve: _Resolver, index, story: dict) -> tuple[list[dict], list[dict]]:
|
|
537
|
+
"""(episodes, rows) of the story source: every row of the story table, and a row per voice id of each command
|
|
538
|
+
of the exported episodes (`story`: {asset: storyvoices.episode(...)})."""
|
|
539
|
+
st, sd = m.v["story"], m.v["sound"]
|
|
540
|
+
kinds = _kinds(m)
|
|
541
|
+
episodes, rows = [], []
|
|
542
|
+
for raw in sorted(m.look.rows(st["table"]), key=lambda r: views._sort_key(r.get("_id"))):
|
|
543
|
+
ep = _episode(m, raw, kinds)
|
|
544
|
+
episodes.append(ep)
|
|
545
|
+
aid, asset = ep["id"], ep["asset"]
|
|
546
|
+
ep["voices"] = 0
|
|
547
|
+
if not isinstance(asset, str) or not asset:
|
|
548
|
+
ep.update(status="no-value", detail=f"{st['table']} row {aid} has no {st['asset']}")
|
|
549
|
+
continue
|
|
550
|
+
keys = _story_keys(st, asset)
|
|
551
|
+
present = {p: index.objects(k) is not None for p, k in keys.items()}
|
|
552
|
+
data = story.get(asset) or {}
|
|
553
|
+
if not present["script"]:
|
|
554
|
+
ep.update(status="missing-key", detail=f"no catalog key {keys['script']}")
|
|
555
|
+
continue
|
|
556
|
+
if "why" in data:
|
|
557
|
+
ep.update(status="unsupported", detail=data["why"])
|
|
558
|
+
continue
|
|
559
|
+
if data.get("commands") is None:
|
|
560
|
+
ep["status"] = "not-exported"
|
|
561
|
+
continue
|
|
562
|
+
ep["status"] = "ok"
|
|
563
|
+
|
|
564
|
+
def shard(part):
|
|
565
|
+
if data.get(part) is not None:
|
|
566
|
+
return data[part], None
|
|
567
|
+
if not present[part]:
|
|
568
|
+
return None, _unresolved("missing-key", f"no catalog key {keys[part]}")
|
|
569
|
+
return None, _unresolved("not-exported", f"{keys[part]} is not exported")
|
|
570
|
+
|
|
571
|
+
texts, _ = shard("text")
|
|
572
|
+
sounds, sound_gap = shard("sound")
|
|
573
|
+
sheets, sheet_gap = shard("sheets")
|
|
574
|
+
|
|
575
|
+
def text_of(tid):
|
|
576
|
+
return {"id": tid, "texts": languages.texts(texts[tid]) if texts is not None and tid in texts else None}
|
|
577
|
+
|
|
578
|
+
for c in data["commands"]:
|
|
579
|
+
vids = [v for v in c.get("VoiceIDs") or () if not views._empty(v)]
|
|
580
|
+
for n, vid in enumerate(vids, 1):
|
|
581
|
+
source = {"name": STORY_SOURCE, "table": st["table"], "row": aid, "command": c.get("Index"),
|
|
582
|
+
"type": {"enum": "AdvCommand", "value": c.get("Command"),
|
|
583
|
+
"name": advcommand.name(c.get("Command"))}}
|
|
584
|
+
if len(vids) > 1:
|
|
585
|
+
source["slot"] = str(n)
|
|
586
|
+
if c.get("IgnoreData"):
|
|
587
|
+
source["ignoreData"] = True
|
|
588
|
+
row = {"id": f"{st['table']}:{aid}:{c.get('Index')}" + (f":{n}" if len(vids) > 1 else ""),
|
|
589
|
+
"source": source, "characters": [], "category": {"name": ep["kind"]}, "sound": {"id": vid}}
|
|
590
|
+
if not views._empty(c.get("AdvTextID")):
|
|
591
|
+
row["text"] = text_of(c["AdvTextID"])
|
|
592
|
+
target, tids = c.get("TargetName") or "", list(c.get("TargetTextIDs") or ())
|
|
593
|
+
if target or tids:
|
|
594
|
+
row["speaker"] = {"name": target, "texts": [text_of(t) for t in tids]}
|
|
595
|
+
matches = _speaker_matches(m, target)
|
|
596
|
+
if matches:
|
|
597
|
+
row["inferred"] = {"characters": [x["character"] for x in matches], "rule": SPEAKER_RULE,
|
|
598
|
+
"matches": matches}
|
|
599
|
+
res, why = sound_gap, None
|
|
600
|
+
if res is None:
|
|
601
|
+
s = sounds.get(vid)
|
|
602
|
+
if s is None:
|
|
603
|
+
why = f"{keys['sound']} has no row {vid}"
|
|
604
|
+
else:
|
|
605
|
+
cue = s.get(sd["cue"]) or None
|
|
606
|
+
row["sound"].update(category=s.get(sd["category"]), sheet=None, cue=cue)
|
|
607
|
+
res = sheet_gap
|
|
608
|
+
if res is None:
|
|
609
|
+
sh = sheets.get(s.get(sd["sheet"]))
|
|
610
|
+
if sh is None:
|
|
611
|
+
why = f"{keys['sheets']} has no row {s.get(sd['sheet'])}"
|
|
612
|
+
elif not sh.get(sd["sheetName"]) or cue is None:
|
|
613
|
+
why = f"{keys['sound']} row {vid}: no cue sheet or cue name"
|
|
614
|
+
else:
|
|
615
|
+
row["sound"]["sheet"] = sh[sd["sheetName"]]
|
|
616
|
+
res = resolve(row["sound"]["sheet"], cue)
|
|
617
|
+
rows.append(_finish(row, res if res is not None else resolve(None, None), why))
|
|
618
|
+
ep["voices"] += 1
|
|
619
|
+
return episodes, rows
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _coverage(v: dict, rows: list[dict], index, audio: dict, reached: dict, empty: dict,
|
|
623
|
+
episodes: list[dict] | None = None) -> dict:
|
|
624
|
+
sources = {}
|
|
625
|
+
for s in [x["name"] for x in v["sources"]] + [SOUND_SOURCE] + ([STORY_SOURCE] if "story" in v else []):
|
|
626
|
+
sources[s] = {"rows": 0, "counts": {}, "gaps": []}
|
|
627
|
+
if s in empty:
|
|
628
|
+
sources[s]["empty"] = empty[s]
|
|
629
|
+
if "story" in v:
|
|
630
|
+
eps = episodes or []
|
|
631
|
+
counts: dict = {}
|
|
632
|
+
for e in eps:
|
|
633
|
+
counts[e["status"]] = counts.get(e["status"], 0) + 1
|
|
634
|
+
sources[STORY_SOURCE]["empty"] = sum(1 for e in eps if e["status"] == "ok" and not e["voices"])
|
|
635
|
+
sources[STORY_SOURCE]["episodes"] = {
|
|
636
|
+
"count": len(eps), "counts": dict(sorted(counts.items())),
|
|
637
|
+
"gaps": [f"{v['story']['table']}:{e['id']}" for e in eps if e["status"] in GAP_STATUSES]}
|
|
638
|
+
for r in rows:
|
|
639
|
+
c = sources[r["source"]["name"]]
|
|
640
|
+
c["rows"] += 1
|
|
641
|
+
c["counts"][r["status"]] = c["counts"].get(r["status"], 0) + 1
|
|
642
|
+
if r["status"] in GAP_STATUSES:
|
|
643
|
+
c["gaps"].append(r["id"])
|
|
644
|
+
for c in sources.values():
|
|
645
|
+
c["counts"] = dict(sorted(c["counts"].items()))
|
|
646
|
+
key = v["key"]
|
|
647
|
+
head = key.split("{", 1)[0]
|
|
648
|
+
keys = sorted({k for p in v.get("prefixes", []) for k in index.keys(p)})
|
|
649
|
+
named = {r["sound"]["sheet"] for r in rows if r["sound"].get("sheet")}
|
|
650
|
+
loose = [k for k in keys if k.startswith(head) and k[len(head):] not in named]
|
|
651
|
+
sheets = sorted({k[len(head):] for k in keys if k.startswith(head)} | named)
|
|
652
|
+
streams, total = {}, 0
|
|
653
|
+
for sh in sheets:
|
|
654
|
+
a = audio.get(sh)
|
|
655
|
+
if a is None or a["status"] != "exported":
|
|
656
|
+
continue
|
|
657
|
+
total += len(a["streams"])
|
|
658
|
+
left = [[n, a["streams"][n].get("name")] for n in sorted(a["streams"]) if n not in reached.get(sh, ())]
|
|
659
|
+
if left:
|
|
660
|
+
streams[sh] = left
|
|
661
|
+
return {"rows": len(rows), "sources": sources,
|
|
662
|
+
"reverse": {"prefixes": list(v.get("prefixes", [])), "keys": len(keys), "unreferencedKeys": loose,
|
|
663
|
+
"streams": total, "unreferencedStreams": streams}}
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def build(rules: dict, tables, index, audio: dict | None = None, snapshot: dict | None = None,
|
|
667
|
+
story: dict | None = None) -> dict:
|
|
668
|
+
"""The voices document (nnnotes.voices/1) of master `tables` (views.Tables), the address `index` (catalog
|
|
669
|
+
keys; wrap it in a views.Recorder to learn what it read), `audio` ({sheet: sheet_audio(...)} of the decoded
|
|
670
|
+
sheets; none: nothing exported) and `story` ({episode asset: storyvoices.episode(...)} of the exported
|
|
671
|
+
episodes). `snapshot`: {"tables": {table: sha256}, "sheets": {...}, ...} to record."""
|
|
672
|
+
check_rules(rules)
|
|
673
|
+
audio = audio or {}
|
|
674
|
+
m = _Master(rules, tables)
|
|
675
|
+
resolve = _Resolver(rules, index, audio)
|
|
676
|
+
named: set = set()
|
|
677
|
+
rows = _row_sources(m, resolve, named)
|
|
678
|
+
rows += _code_sources(m, resolve)
|
|
679
|
+
empty = {}
|
|
680
|
+
for s in m.v["sources"]:
|
|
681
|
+
if "voices" in s:
|
|
682
|
+
empty[s["name"]] = sum(1 for raw in m.look.rows(s["table"]) for x in s["voices"]
|
|
683
|
+
if views._empty(raw.get(x["sound"])) or raw.get(x["sound"]) == 0)
|
|
684
|
+
rows += _sound_rows(m, resolve, named, rows)
|
|
685
|
+
episodes = None
|
|
686
|
+
if "story" in m.v:
|
|
687
|
+
episodes, story_rows = _story_rows(m, resolve, index, story or {})
|
|
688
|
+
rows += story_rows
|
|
689
|
+
used = sorted({c for r in rows for c in r["characters"] + r.get("inferred", {}).get("characters", [])},
|
|
690
|
+
key=views._sort_key)
|
|
691
|
+
doc = {"schema": SCHEMA, "view": VIEW, "version": rules[VIEW]["version"], "rules": rules_digest(rules),
|
|
692
|
+
"tables": tables_of(rules),
|
|
693
|
+
"characters": [m.characters[c] for c in used if c in m.characters],
|
|
694
|
+
"rows": rows}
|
|
695
|
+
if episodes is not None:
|
|
696
|
+
doc["episodes"] = episodes
|
|
697
|
+
if snapshot:
|
|
698
|
+
doc["snapshot"] = snapshot
|
|
699
|
+
doc["coverage"] = _coverage(m.v, rows, index, audio, resolve.reached, empty, episodes)
|
|
700
|
+
return doc
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def gaps(doc: dict) -> int:
|
|
704
|
+
"""The gap rows of every source and the story episodes whose key is missing."""
|
|
705
|
+
return sum(len(c["gaps"]) + len(c.get("episodes", {}).get("gaps", ())) for c in doc["coverage"]["sources"].values())
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def counts(doc: dict) -> dict:
|
|
709
|
+
out: dict = {}
|
|
710
|
+
for r in doc["rows"]:
|
|
711
|
+
out[r["status"]] = out.get(r["status"], 0) + 1
|
|
712
|
+
return dict(sorted(out.items()))
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def unreferenced_streams(doc: dict) -> int:
|
|
716
|
+
return sum(len(v) for v in doc["coverage"]["reverse"]["unreferencedStreams"].values())
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def with_paths(doc: dict, paths) -> dict:
|
|
720
|
+
"""A copy of a voices document with the layout path of every stream (`paths.artifact(artifact id)`; null when
|
|
721
|
+
the layout places none)."""
|
|
722
|
+
out = copy.deepcopy(doc)
|
|
723
|
+
for r in out["rows"]:
|
|
724
|
+
for s in (r.get("audio") or {}).get("streams", []):
|
|
725
|
+
s["path"] = paths.artifact(s["artifact"])
|
|
726
|
+
out["layout"] = "original"
|
|
727
|
+
return out
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
# ---------------------------------------------------------------- the stage
|
|
731
|
+
def _sheet_name(name: str) -> str | None:
|
|
732
|
+
if name.startswith(SHEET_PREFIX):
|
|
733
|
+
return name[len(SHEET_PREFIX):] or None
|
|
734
|
+
return name if "/" not in name else None
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
_address_cache: dict = {} # content id of an address table -> (AddressIndex, files), the latest one
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _address_parts(env, tid: str) -> tuple:
|
|
741
|
+
"""(AddressIndex, {serialized file: stable bundle name}) of the result of the link.addresses task `tid`."""
|
|
742
|
+
sha = env.artifact(tid, "addresses")["content"]["sha256"]
|
|
743
|
+
hit = _address_cache.get(sha)
|
|
744
|
+
if hit is None:
|
|
745
|
+
doc = contract.loads(env.store.read(sha))
|
|
746
|
+
hit = (views.AddressIndex.from_addresses(doc), dict(doc.get("files") or {}))
|
|
747
|
+
_address_cache.clear()
|
|
748
|
+
_address_cache[sha] = hit
|
|
749
|
+
return hit
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
class VoiceStage(Stage):
|
|
753
|
+
"""view.voices (module documentation). Artifact "<task id>#view" (nnnotes.voices/1); facts rows, entries (rows
|
|
754
|
+
per status), gaps, unreferenced (streams of the decoded sheets no row reaches). No items."""
|
|
755
|
+
name = STAGE
|
|
756
|
+
after = (views.ADDRESSES_STAGE, EXPORT_STAGE, AUDIO_STAGE)
|
|
757
|
+
|
|
758
|
+
def __init__(self, rules: dict | None = None):
|
|
759
|
+
self.rules = load_rules() if rules is None else {"format": rules["format"], VIEW: rules[VIEW]}
|
|
760
|
+
check_rules(self.rules)
|
|
761
|
+
self.version = self.rules[VIEW]["version"]
|
|
762
|
+
self.rules_bytes = contract.encode(self.rules)
|
|
763
|
+
self.tables = tables_of(self.rules)
|
|
764
|
+
self._memo = None
|
|
765
|
+
|
|
766
|
+
def __getstate__(self):
|
|
767
|
+
return {"rules": self.rules}
|
|
768
|
+
|
|
769
|
+
def __setstate__(self, state):
|
|
770
|
+
self.__init__(state["rules"])
|
|
771
|
+
|
|
772
|
+
def subjects(self, env) -> list[str]:
|
|
773
|
+
if env.facts.get("master") is None:
|
|
774
|
+
return []
|
|
775
|
+
selected = env.facts.get("views")
|
|
776
|
+
if selected is not None and VIEW not in selected:
|
|
777
|
+
return []
|
|
778
|
+
return sorted(env.fact("catalogs"))
|
|
779
|
+
|
|
780
|
+
def _audio_tasks(self, env) -> list[str]:
|
|
781
|
+
pending = env.pending(AUDIO_STAGE)
|
|
782
|
+
if pending:
|
|
783
|
+
raise Pending(pending[0])
|
|
784
|
+
return env.tasks(AUDIO_STAGE)
|
|
785
|
+
|
|
786
|
+
def _export_tasks(self, env) -> list[str]:
|
|
787
|
+
pending = env.pending(EXPORT_STAGE)
|
|
788
|
+
if pending:
|
|
789
|
+
raise Pending(pending[0])
|
|
790
|
+
return env.tasks(EXPORT_STAGE)
|
|
791
|
+
|
|
792
|
+
def depends(self, subject: str, env) -> list[str]:
|
|
793
|
+
return ([contract.task_id(views.ADDRESSES_STAGE, subject)] + self._audio_tasks(env)
|
|
794
|
+
+ self._plan(subject, env)[6])
|
|
795
|
+
|
|
796
|
+
def context(self, subject: str, env) -> dict:
|
|
797
|
+
sheets = self._plan(subject, env)[2]
|
|
798
|
+
return {"audio": {t: env.key(t) for t in sorted({s["task"] for s in sheets.values()})}}
|
|
799
|
+
|
|
800
|
+
def _sheets(self, env) -> tuple[dict, dict]:
|
|
801
|
+
"""({sheet: {"task", "acb"}}, {acb sha256: Input}) of the cri.audio results: the sheets their contents are
|
|
802
|
+
known by (cristages.AudioStage.index: the catalog keys and the cue sheet names of the ACB artifacts), and
|
|
803
|
+
the ACBs of the decoded ones (an unsupported sheet's ACB is not read)."""
|
|
804
|
+
tids = self._audio_tasks(env)
|
|
805
|
+
if not tids:
|
|
806
|
+
return {}, {}
|
|
807
|
+
from .cristages import AudioStage
|
|
808
|
+
index = AudioStage().index(env)
|
|
809
|
+
sheets, acbs = {}, {}
|
|
810
|
+
for tid in tids:
|
|
811
|
+
sha = contract.parse_task_id(tid, AUDIO_STAGE)[1]
|
|
812
|
+
e = index.get(sha)
|
|
813
|
+
if e is None:
|
|
814
|
+
continue
|
|
815
|
+
decoded = any(i.get("status") == "exported" for i in env.result(tid)["items"])
|
|
816
|
+
for n in sorted(e["names"]):
|
|
817
|
+
sheet = _sheet_name(n)
|
|
818
|
+
if sheet is not None and sheet not in sheets:
|
|
819
|
+
sheets[sheet] = {"task": tid, "acb": sha}
|
|
820
|
+
if decoded:
|
|
821
|
+
a = e["acb"]
|
|
822
|
+
acbs[sha] = Input(f"acb:{sha}", a.sha256, a.size, a.name, a.locators)
|
|
823
|
+
return dict(sorted(sheets.items())), acbs
|
|
824
|
+
|
|
825
|
+
def _plan(self, subject: str, env) -> tuple:
|
|
826
|
+
"""(master inputs, the recorded part of the address table, {sheet: {task, acb}}, ACB inputs, {episode asset:
|
|
827
|
+
{part: {object, artifact, sha256}}}, episode content inputs, the unity.export tasks read) for a task: of the
|
|
828
|
+
decoded sheets, those a row names or the prefixes list (a music or effect sheet is not read); of the story
|
|
829
|
+
episodes, the parts the unity.export results hold. Kept for the planning state it was made in (context,
|
|
830
|
+
inputs and depends ask for it in turn)."""
|
|
831
|
+
link = contract.task_id(views.ADDRESSES_STAGE, subject)
|
|
832
|
+
tids = self._audio_tasks(env)
|
|
833
|
+
exports = self._export_tasks(env)
|
|
834
|
+
masters = self._masters(env)
|
|
835
|
+
sig = (subject, env.key(link), tuple((t, env.key(t)) for t in tids), tuple((t, env.key(t)) for t in exports),
|
|
836
|
+
tuple(i.sha256 for i in masters), id(env.store), id(env.facts.get("index")))
|
|
837
|
+
if self._memo is not None and self._memo[0] == sig:
|
|
838
|
+
return self._memo[1]
|
|
839
|
+
index, files = _address_parts(env, link)
|
|
840
|
+
tables = {i.role[len(views.MASTER_ROLE):]: views._cached_rows(i.sha256, Path(i.locators[0]["path"]).read_bytes)
|
|
841
|
+
for i in masters}
|
|
842
|
+
found, acbs = self._sheets(env)
|
|
843
|
+
rec = views.Recorder(index)
|
|
844
|
+
stub = build(self.rules, tables, rec, {s: {"status": "not-exported", "streams": {}, "cues": {}}
|
|
845
|
+
for s in found})
|
|
846
|
+
head = self.rules[VIEW]["key"].split("{", 1)[0]
|
|
847
|
+
wanted = {r["sound"]["sheet"] for r in stub["rows"] if r["sound"].get("sheet")}
|
|
848
|
+
wanted |= {k[len(head):] for p in self.rules[VIEW].get("prefixes", []) for k in rec.keys(p)
|
|
849
|
+
if k.startswith(head)}
|
|
850
|
+
sheets = {s: v for s, v in found.items() if s in wanted}
|
|
851
|
+
used = {v["acb"] for v in sheets.values()}
|
|
852
|
+
episodes, contents, read = self._episodes(env, rec, tables, files, set(exports))
|
|
853
|
+
plan = (masters, rec.subset(), sheets, [acbs[a] for a in sorted(acbs) if a in used], episodes, contents,
|
|
854
|
+
read)
|
|
855
|
+
self._memo = (sig, plan)
|
|
856
|
+
return plan
|
|
857
|
+
|
|
858
|
+
def _episodes(self, env, index, tables, files: dict, exports: set) -> tuple[dict, list[Input], list[str]]:
|
|
859
|
+
"""The story parts the unity.export results hold: the object a part's key names (the first of its class,
|
|
860
|
+
STORY_OBJECTS) in the bundle of its serialized file (the address table's `files`), and the artifact with
|
|
861
|
+
its content. ({asset: {part: {object, artifact, sha256}}}, the content inputs, the tasks read.)"""
|
|
862
|
+
arts: dict[str, dict] = {}
|
|
863
|
+
out: dict = {}
|
|
864
|
+
contents: dict[str, Input] = {}
|
|
865
|
+
read = set()
|
|
866
|
+
for asset in story_assets(self.rules, tables):
|
|
867
|
+
for part, key in story_keys(self.rules, asset).items():
|
|
868
|
+
cls, role = STORY_OBJECTS[part]
|
|
869
|
+
pick = views.choose(index.objects(key) or [], None, cls)
|
|
870
|
+
if not pick:
|
|
871
|
+
continue
|
|
872
|
+
oid = pick[0].id
|
|
873
|
+
stable = files.get(contract.parse_object_id(oid)[0])
|
|
874
|
+
tid = contract.task_id(EXPORT_STAGE, stable) if stable is not None else None
|
|
875
|
+
if tid not in exports:
|
|
876
|
+
continue
|
|
877
|
+
if tid not in arts:
|
|
878
|
+
arts[tid] = {a["id"]: a for a in env.result(tid)["artifacts"]}
|
|
879
|
+
aid = contract.artifact_id(oid, role)
|
|
880
|
+
a = arts[tid].get(aid)
|
|
881
|
+
if a is None:
|
|
882
|
+
continue
|
|
883
|
+
c = a["content"]
|
|
884
|
+
out.setdefault(asset, {})[part] = {"object": oid, "artifact": aid, "sha256": c["sha256"]}
|
|
885
|
+
contents.setdefault(c["sha256"], Input(f"adv:{c['sha256']}", c["sha256"], c["size"], None,
|
|
886
|
+
({"kind": "store"},)))
|
|
887
|
+
read.add(tid)
|
|
888
|
+
return dict(sorted(out.items())), [contents[k] for k in sorted(contents)], sorted(read)
|
|
889
|
+
|
|
890
|
+
def inputs(self, subject: str, env) -> list[Input]:
|
|
891
|
+
masters, subset, sheets, acbs, episodes, contents, _ = self._plan(subject, env)
|
|
892
|
+
store = env.store
|
|
893
|
+
return (masters + [views._stored(store, "addresses", contract.encode(subset)),
|
|
894
|
+
views._stored(store, "rules", self.rules_bytes),
|
|
895
|
+
views._stored(store, "sheets", contract.encode(sheets)),
|
|
896
|
+
views._stored(store, "episodes", contract.encode(episodes))] + acbs + contents)
|
|
897
|
+
|
|
898
|
+
def _masters(self, env) -> list[Input]:
|
|
899
|
+
mdir = Path(env.fact("master"))
|
|
900
|
+
out = []
|
|
901
|
+
for t in self.tables:
|
|
902
|
+
p = mdir / f"{t}.json"
|
|
903
|
+
if not p.is_file():
|
|
904
|
+
raise views.ViewError(f"view {VIEW}: master table {t} is not in the master data")
|
|
905
|
+
sha, size = env.store.identify(p)
|
|
906
|
+
out.append(Input(views.MASTER_ROLE + t, sha, size, p.name, ({"kind": "file", "path": str(p)},)))
|
|
907
|
+
return out
|
|
908
|
+
|
|
909
|
+
def estimate(self, subject: str, env, inputs: list[Input]) -> Cost:
|
|
910
|
+
size = sum(i.size for i in inputs)
|
|
911
|
+
big = max((i.size for i in inputs if i.role.startswith("acb:")), default=0)
|
|
912
|
+
story = [i.size for i in inputs if i.role.startswith("adv:")]
|
|
913
|
+
return Cost(0.2 + size / 300e6 + sum(story) / 80e6,
|
|
914
|
+
(96 << 20) + 4 * big + 16 * sum(i.size for i in inputs if i.role.startswith(views.MASTER_ROLE))
|
|
915
|
+
+ 2 * sum(story) + 12 * max(story, default=0))
|
|
916
|
+
|
|
917
|
+
def run(self, task, store) -> Output:
|
|
918
|
+
rules = contract.loads(store.input_bytes(task.input("rules")))
|
|
919
|
+
check_rules(rules)
|
|
920
|
+
if rules[VIEW]["version"] != task.version:
|
|
921
|
+
raise views.ViewError(f"task {task.id}: its rules are not those of view {VIEW} version {task.version}")
|
|
922
|
+
masters = [i for i in task.inputs if i.role.startswith(views.MASTER_ROLE)]
|
|
923
|
+
tables = {i.role[len(views.MASTER_ROLE):]: views._cached_rows(i.sha256, functools.partial(store.input_bytes, i))
|
|
924
|
+
for i in masters}
|
|
925
|
+
index = views.AddressIndex.from_subset(contract.loads(store.input_bytes(task.input("addresses"))))
|
|
926
|
+
sheets = contract.loads(store.input_bytes(task.input("sheets")))
|
|
927
|
+
results = task.context.get("audio", {})
|
|
928
|
+
roles = {i.role for i in task.inputs}
|
|
929
|
+
cues: dict[str, dict] = {}
|
|
930
|
+
audio = {}
|
|
931
|
+
for sheet, s in sorted(sheets.items()):
|
|
932
|
+
key = results.get(s["task"])
|
|
933
|
+
res = store.result(key) if key is not None else None
|
|
934
|
+
if res is None:
|
|
935
|
+
raise views.ViewError(f"task {task.id}: no result of {s['task']}")
|
|
936
|
+
role = f"acb:{s['acb']}"
|
|
937
|
+
if role in roles and s["acb"] not in cues:
|
|
938
|
+
cues[s["acb"]] = acb.cue_streams(store.input_bytes(task.input(role)))
|
|
939
|
+
audio[sheet] = sheet_audio(res, cues.get(s["acb"]))
|
|
940
|
+
snapshot = {"tables": {i.role[len(views.MASTER_ROLE):]: i.sha256 for i in masters},
|
|
941
|
+
"sheets": {k: {"task": v["task"], "acb": v["acb"], "status": audio[k]["status"]}
|
|
942
|
+
for k, v in sorted(sheets.items())}}
|
|
943
|
+
story = {}
|
|
944
|
+
if "episodes" in roles:
|
|
945
|
+
snapshot["episodes"] = task.input("episodes").sha256
|
|
946
|
+
column = rules[VIEW]["sound"]["sheet"]
|
|
947
|
+
for asset, parts in sorted(contract.loads(store.input_bytes(task.input("episodes"))).items()):
|
|
948
|
+
got = {p: store.input_bytes(task.input(f"adv:{x['sha256']}")) for p, x in parts.items()}
|
|
949
|
+
story[asset] = storyvoices.episode(got.get("script"), got.get("text"), got.get("sound"),
|
|
950
|
+
got.get("sheets"), sheet_column=column)
|
|
951
|
+
doc = build(rules, tables, index, audio, snapshot, story)
|
|
952
|
+
content = store.add(contract.encode(doc), "json")
|
|
953
|
+
facts = {"rows": len(doc["rows"]), "entries": counts(doc), "gaps": gaps(doc),
|
|
954
|
+
"unreferenced": unreferenced_streams(doc)}
|
|
955
|
+
rec = contract.artifact(contract.artifact_id(task.id, views.VIEW_ROLE), content, contract.provenance(task),
|
|
956
|
+
{"kind": "view", "format": "json", "facts": facts})
|
|
957
|
+
return Output([rec], [])
|
|
958
|
+
|
|
959
|
+
def derived(self, task_id: str, result: dict, store, paths) -> list[tuple[str, bytes]]:
|
|
960
|
+
"""The voices document with the layout path of every stream, at views/voices.json."""
|
|
961
|
+
aid = contract.artifact_id(task_id, views.VIEW_ROLE)
|
|
962
|
+
rec = next((a for a in result["artifacts"] if a["id"] == aid), None)
|
|
963
|
+
if rec is None:
|
|
964
|
+
raise ValueError(f"result of {task_id} has no artifact {aid}")
|
|
965
|
+
doc = contract.loads(store.read(rec["content"]["sha256"]))
|
|
966
|
+
subject = contract.parse_task_id(task_id, self.name)[1]
|
|
967
|
+
return [(views.view_path(VIEW, subject), contract.encode(with_paths(doc, paths)))]
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
# ---------------------------------------------------------------- queries
|
|
971
|
+
def _norm(s) -> str:
|
|
972
|
+
return unicodedata.normalize("NFKC", str(s or "")).casefold()
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _flat(s) -> str:
|
|
976
|
+
return re.sub(r"[\s_\-.]+", "", _norm(s))
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def character_ids(doc: dict, spec: str) -> set:
|
|
980
|
+
"""The ids of the document's characters that `spec` names: an id, or a part of a name in any language."""
|
|
981
|
+
if spec.strip().isdigit():
|
|
982
|
+
return {int(spec)}
|
|
983
|
+
want = _norm(spec).strip()
|
|
984
|
+
out = set()
|
|
985
|
+
for c in doc["characters"]:
|
|
986
|
+
texts = [c.get("token")] + [t for n in c["names"].values() for t in (n or {}).values()]
|
|
987
|
+
if any(want and want in _norm(t) for t in texts if t):
|
|
988
|
+
out.add(c["id"])
|
|
989
|
+
return out
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _category_match(row: dict, spec: str) -> bool:
|
|
993
|
+
want = _flat(spec)
|
|
994
|
+
src, cat = row["source"]["name"], row["category"].get("name")
|
|
995
|
+
return want in {_flat(src), _flat(cat), _flat(f"{src}.{cat}")}
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def episode_ids(doc: dict, spec: str) -> set:
|
|
999
|
+
"""The ids of the document's episodes that `spec` names: an id, or an episode asset."""
|
|
1000
|
+
spec = spec.strip()
|
|
1001
|
+
return {e["id"] for e in doc.get("episodes", []) if str(e["id"]) == spec or e.get("asset") == spec}
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def select(doc: dict, *, characters=None, category=None, source=None, status=None, text=None,
|
|
1005
|
+
language=None, episodes=None) -> list[dict]:
|
|
1006
|
+
"""The rows of a document matching every given filter (characters: a set of ids, a row matches by its
|
|
1007
|
+
characters or, for Sound and Story rows, the inferred ones; text: a part of the row's text in `language`, every
|
|
1008
|
+
language when None, or of its cue name; episodes: a set of episode ids, the Story rows of those)."""
|
|
1009
|
+
out = []
|
|
1010
|
+
want = _norm(text) if text else None
|
|
1011
|
+
for r in doc["rows"]:
|
|
1012
|
+
if episodes is not None and (r["source"]["name"] != STORY_SOURCE or r["source"].get("row") not in episodes):
|
|
1013
|
+
continue
|
|
1014
|
+
if source and _flat(r["source"]["name"]) != _flat(source):
|
|
1015
|
+
continue
|
|
1016
|
+
if status and r["status"] != status:
|
|
1017
|
+
continue
|
|
1018
|
+
if category and not _category_match(r, category):
|
|
1019
|
+
continue
|
|
1020
|
+
if characters is not None:
|
|
1021
|
+
chars = set(r["characters"]) | set(r.get("inferred", {}).get("characters", []))
|
|
1022
|
+
if not chars & characters:
|
|
1023
|
+
continue
|
|
1024
|
+
if want is not None:
|
|
1025
|
+
texts = (r.get("text") or {}).get("texts") or {}
|
|
1026
|
+
pool = [texts.get(language)] if language else list(texts.values())
|
|
1027
|
+
pool += [r["sound"].get("cue")]
|
|
1028
|
+
if not any(t and want in _norm(t) for t in pool):
|
|
1029
|
+
continue
|
|
1030
|
+
out.append(r)
|
|
1031
|
+
return out
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def rows_for(doc: dict, ident: str) -> list[dict]:
|
|
1035
|
+
"""The rows of a row id, or of a MasterSound id (every row naming the sound)."""
|
|
1036
|
+
by_id = [r for r in doc["rows"] if r["id"] == ident]
|
|
1037
|
+
if by_id:
|
|
1038
|
+
return by_id
|
|
1039
|
+
if ident.strip().isdigit():
|
|
1040
|
+
n = int(ident)
|
|
1041
|
+
return [r for r in doc["rows"] if r["sound"].get("id") == n]
|
|
1042
|
+
return []
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def summary(doc: dict) -> dict:
|
|
1046
|
+
"""Rows per status, per source and category, per character; gaps both ways."""
|
|
1047
|
+
by_cat: dict = {}
|
|
1048
|
+
by_char: dict = {}
|
|
1049
|
+
for r in doc["rows"]:
|
|
1050
|
+
k = f"{r['source']['name']}.{r['category'].get('name')}"
|
|
1051
|
+
c = by_cat.setdefault(k, {"rows": 0, "ok": 0})
|
|
1052
|
+
c["rows"] += 1
|
|
1053
|
+
c["ok"] += r["status"] == "ok"
|
|
1054
|
+
for ch in r["characters"] or r.get("inferred", {}).get("characters", []) or [None]:
|
|
1055
|
+
x = by_char.setdefault(str(ch), {"rows": 0, "ok": 0})
|
|
1056
|
+
x["rows"] += 1
|
|
1057
|
+
x["ok"] += r["status"] == "ok"
|
|
1058
|
+
rev = doc["coverage"]["reverse"]
|
|
1059
|
+
fam: dict = {}
|
|
1060
|
+
for sheet, left in rev["unreferencedStreams"].items():
|
|
1061
|
+
f = re.sub(r"\d+", "N", sheet)
|
|
1062
|
+
fam[f] = fam.get(f, 0) + len(left)
|
|
1063
|
+
keys: dict = {}
|
|
1064
|
+
for k in rev["unreferencedKeys"]:
|
|
1065
|
+
f = re.sub(r"\d+", "N", k)
|
|
1066
|
+
keys[f] = keys.get(f, 0) + 1
|
|
1067
|
+
out = {"rows": len(doc["rows"]), "statuses": counts(doc), "gaps": gaps(doc),
|
|
1068
|
+
"categories": dict(sorted(by_cat.items())), "characters": dict(sorted(by_char.items())),
|
|
1069
|
+
"forward": {s: [r["id"] for r in doc["rows"] if r["status"] == s] for s in GAP_STATUSES},
|
|
1070
|
+
"reverse": {"keys": rev["keys"], "streams": rev["streams"],
|
|
1071
|
+
"unreferencedKeys": dict(sorted(keys.items())),
|
|
1072
|
+
"unreferencedStreams": dict(sorted(fam.items()))}}
|
|
1073
|
+
if "episodes" in doc:
|
|
1074
|
+
out["story"] = _story_summary(doc)
|
|
1075
|
+
return out
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _story_summary(doc: dict) -> dict:
|
|
1079
|
+
"""Episodes per status and kind; per speaker name its rows and inferred characters; the story voices whose cue
|
|
1080
|
+
(sheet and cue name) another source's row also names, per source."""
|
|
1081
|
+
eps = doc["episodes"]
|
|
1082
|
+
statuses: dict = {}
|
|
1083
|
+
kinds: dict = {}
|
|
1084
|
+
for e in eps:
|
|
1085
|
+
statuses[e["status"]] = statuses.get(e["status"], 0) + 1
|
|
1086
|
+
k = kinds.setdefault(str(e.get("kind")), {"episodes": 0, "voices": 0})
|
|
1087
|
+
k["episodes"] += 1
|
|
1088
|
+
k["voices"] += e.get("voices", 0)
|
|
1089
|
+
others: dict = {}
|
|
1090
|
+
for r in doc["rows"]:
|
|
1091
|
+
if r["source"]["name"] != STORY_SOURCE and r["sound"].get("sheet") and r["sound"].get("cue"):
|
|
1092
|
+
others.setdefault((r["sound"]["sheet"], r["sound"]["cue"]), set()).add(r["source"]["name"])
|
|
1093
|
+
speakers: dict = {}
|
|
1094
|
+
shared = {"rows": 0, "cues": set(), "sources": {}}
|
|
1095
|
+
for r in doc["rows"]:
|
|
1096
|
+
if r["source"]["name"] != STORY_SOURCE:
|
|
1097
|
+
continue
|
|
1098
|
+
name = (r.get("speaker") or {}).get("name") or ""
|
|
1099
|
+
s = speakers.setdefault(name, {"rows": 0, "characters": []})
|
|
1100
|
+
s["rows"] += 1
|
|
1101
|
+
for c in r.get("inferred", {}).get("characters", []):
|
|
1102
|
+
if c not in s["characters"]:
|
|
1103
|
+
s["characters"].append(c)
|
|
1104
|
+
cue = (r["sound"].get("sheet"), r["sound"].get("cue"))
|
|
1105
|
+
if cue in others:
|
|
1106
|
+
shared["rows"] += 1
|
|
1107
|
+
shared["cues"].add(cue)
|
|
1108
|
+
for src in others[cue]:
|
|
1109
|
+
shared["sources"][src] = shared["sources"].get(src, 0) + 1
|
|
1110
|
+
shared["cues"] = len(shared["cues"])
|
|
1111
|
+
shared["sources"] = dict(sorted(shared["sources"].items()))
|
|
1112
|
+
return {"episodes": len(eps), "statuses": dict(sorted(statuses.items())), "kinds": dict(sorted(kinds.items())),
|
|
1113
|
+
"gaps": doc["coverage"]["sources"].get(STORY_SOURCE, {}).get("episodes", {}).get("gaps", []),
|
|
1114
|
+
"speakers": dict(sorted(speakers.items())), "sharedCues": shared}
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
# ---------------------------------------------------------------- command line
|
|
1118
|
+
def _out(text: str) -> None:
|
|
1119
|
+
sys.stdout.buffer.write(text.encode("utf-8"))
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _print_json(obj) -> None:
|
|
1123
|
+
from .jsonio import dumps
|
|
1124
|
+
_out(dumps(obj, ensure_ascii=False, indent=1) + "\n")
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _label(doc: dict, ids, language: str) -> str:
|
|
1128
|
+
names = {c["id"]: c for c in doc["characters"]}
|
|
1129
|
+
out = []
|
|
1130
|
+
for i in ids:
|
|
1131
|
+
c = names.get(i)
|
|
1132
|
+
n = ((c or {}).get("names", {}).get("name") or {}).get(language) if c else None
|
|
1133
|
+
out.append(n or (c or {}).get("token") or str(i))
|
|
1134
|
+
return ",".join(out) or "-"
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _line(doc: dict, r: dict, language: str) -> str:
|
|
1138
|
+
chars = r["characters"] or r.get("inferred", {}).get("characters", [])
|
|
1139
|
+
text = ((r.get("text") or {}).get("texts") or {}).get(language) or ""
|
|
1140
|
+
cat = r["category"].get("name") or r.get("inferred", {}).get("situation") or "-"
|
|
1141
|
+
where = f"{r['sound'].get('sheet')}/{r['sound'].get('cue')}"
|
|
1142
|
+
who = _label(doc, chars, language)
|
|
1143
|
+
if "speaker" in r:
|
|
1144
|
+
names = [(t.get("texts") or {}).get(language) for t in r["speaker"]["texts"]]
|
|
1145
|
+
who = ",".join(n for n in names if n) or r["speaker"]["name"] or who
|
|
1146
|
+
return "\t".join([r["id"], who, f"{r['source']['name']}.{cat}", where, r["status"],
|
|
1147
|
+
text.replace("\n", " ")]) + "\n"
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def _story_ids(tables, rules: dict, specs) -> dict:
|
|
1151
|
+
"""{episode asset: adv id} of the story table's rows that `specs` name (an id or an asset each)."""
|
|
1152
|
+
st = rules[VIEW]["story"]
|
|
1153
|
+
want = {str(s).strip() for s in specs}
|
|
1154
|
+
out = {}
|
|
1155
|
+
for r in views._Lookup(tables).rows(st["table"]):
|
|
1156
|
+
a = r.get(st["asset"])
|
|
1157
|
+
if isinstance(a, str) and a and (str(r.get("_id")) in want or a in want):
|
|
1158
|
+
out[a] = r.get("_id")
|
|
1159
|
+
return out
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _document(args, cfg, common, episodes=()) -> tuple[dict, Path | None]:
|
|
1163
|
+
"""The document of --from, else the index of the configured master data and catalog, with the story episodes
|
|
1164
|
+
`episodes` (ids or assets) read from the catalog."""
|
|
1165
|
+
if args.source_dir:
|
|
1166
|
+
p = Path(args.source_dir)
|
|
1167
|
+
f = p if p.is_file() else p / views.view_path(VIEW, views.MAIN_SUBJECT)
|
|
1168
|
+
if not f.is_file():
|
|
1169
|
+
args.usage(f"--from {p}: no {views.view_path(VIEW, views.MAIN_SUBJECT)} (export --views voices "
|
|
1170
|
+
f"writes it)")
|
|
1171
|
+
doc = contract.loads(f.read_bytes())
|
|
1172
|
+
if doc.get("schema") != SCHEMA:
|
|
1173
|
+
args.usage(f"{f}: not a voices document")
|
|
1174
|
+
return doc, (f.parent.parent if not p.is_file() else None)
|
|
1175
|
+
rules = load_rules()
|
|
1176
|
+
tables = views.MasterTables(common.master_dir(cfg))
|
|
1177
|
+
cat = common.open_catalog(cfg, bundles=False)
|
|
1178
|
+
prefixes = [SHEET_PREFIX] + ([rules[VIEW]["story"]["key"].split("{", 1)[0]] if "story" in rules[VIEW] else [])
|
|
1179
|
+
index = views.AddressIndex({k: [] for p in prefixes for k in cat.keys(p)})
|
|
1180
|
+
story = {}
|
|
1181
|
+
assets = _story_ids(tables, rules, episodes) if episodes and "story" in rules[VIEW] else {}
|
|
1182
|
+
if assets:
|
|
1183
|
+
full = common.open_catalog(cfg)
|
|
1184
|
+
column = rules[VIEW]["sound"]["sheet"]
|
|
1185
|
+
for asset in sorted(assets):
|
|
1186
|
+
got = storyvoices.load_episode(full, story_keys(rules, asset))
|
|
1187
|
+
story[asset] = storyvoices.episode(got.get("script"), got.get("text"), got.get("sound"),
|
|
1188
|
+
got.get("sheets"), sheet_column=column)
|
|
1189
|
+
return build(rules, tables, index, story=story), None
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _language(args, cfg) -> str:
|
|
1193
|
+
lang = getattr(args, "language", None) or cfg.get("catalog", "language") or "ja"
|
|
1194
|
+
if lang not in LANGUAGES:
|
|
1195
|
+
args.usage(f"--language {lang}: one of {', '.join(LANGUAGES)}")
|
|
1196
|
+
return lang
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _filters(args, doc: dict) -> dict:
|
|
1200
|
+
chars = None
|
|
1201
|
+
if getattr(args, "character", None):
|
|
1202
|
+
chars = character_ids(doc, args.character)
|
|
1203
|
+
if not chars:
|
|
1204
|
+
args.usage(f"--character {args.character}: no character of the master data has this id or name")
|
|
1205
|
+
eps = None
|
|
1206
|
+
if getattr(args, "episode", None):
|
|
1207
|
+
eps = episode_ids(doc, args.episode)
|
|
1208
|
+
if not eps:
|
|
1209
|
+
args.usage(f"--episode {args.episode}: no episode of the master data has this id or asset")
|
|
1210
|
+
return {"characters": chars, "category": getattr(args, "category", None), "source": getattr(args, "source", None),
|
|
1211
|
+
"status": getattr(args, "status", None), "episodes": eps}
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _emit(args, doc: dict, rows: list[dict], language: str) -> None:
|
|
1215
|
+
rows = rows[: args.limit] if args.limit else rows
|
|
1216
|
+
if args.json:
|
|
1217
|
+
_print_json(rows)
|
|
1218
|
+
return
|
|
1219
|
+
for r in rows:
|
|
1220
|
+
_out(_line(doc, r, language))
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def _episode_args(args) -> tuple:
|
|
1224
|
+
return (args.episode,) if getattr(args, "episode", None) else ()
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def cmd_list(args, cfg, common) -> None:
|
|
1228
|
+
doc, _ = _document(args, cfg, common, _episode_args(args))
|
|
1229
|
+
lang = _language(args, cfg)
|
|
1230
|
+
_emit(args, doc, select(doc, **_filters(args, doc)), lang)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def cmd_search(args, cfg, common) -> None:
|
|
1234
|
+
doc, _ = _document(args, cfg, common, _episode_args(args))
|
|
1235
|
+
lang = _language(args, cfg)
|
|
1236
|
+
only = args.language if args.language else None
|
|
1237
|
+
_emit(args, doc, select(doc, **_filters(args, doc), text=args.text, language=only), lang)
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def cmd_summary(args, cfg, common) -> None:
|
|
1241
|
+
doc, _ = _document(args, cfg, common)
|
|
1242
|
+
s = summary(doc)
|
|
1243
|
+
if args.json:
|
|
1244
|
+
_print_json(s)
|
|
1245
|
+
return
|
|
1246
|
+
_out(f"rows {s['rows']} gaps {s['gaps']} " + " ".join(f"{k} {v}" for k, v in s["statuses"].items()) + "\n")
|
|
1247
|
+
for k, v in s["categories"].items():
|
|
1248
|
+
_out(f" {k}\t{v['rows']}\t{v['ok']} ok\n")
|
|
1249
|
+
rev = s["reverse"]
|
|
1250
|
+
_out(f"reverse: {rev['keys']} keys, {sum(rev['unreferencedKeys'].values())} named by no row; {rev['streams']} "
|
|
1251
|
+
f"decoded streams, {sum(rev['unreferencedStreams'].values())} reached by no row\n")
|
|
1252
|
+
if "story" in s:
|
|
1253
|
+
st = s["story"]
|
|
1254
|
+
_out(f"story: {st['episodes']} episodes (" + ", ".join(f"{k} {v}" for k, v in st["statuses"].items())
|
|
1255
|
+
+ f"); {st['sharedCues']['rows']} voices share a cue with another source\n")
|
|
1256
|
+
for k, v in st["kinds"].items():
|
|
1257
|
+
_out(f" {k}\t{v['episodes']} episodes\t{v['voices']} voices\n")
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _decode_one(cfg, common, sheet: str, cue: str, fmt: str, level: int, dst: Path) -> list[Path]:
|
|
1261
|
+
"""The files of a cue's streams, decoded from its sheet alone (cri.decode, as the export decodes a sheet)."""
|
|
1262
|
+
from . import cri
|
|
1263
|
+
cfg.require_path("paths", "apk")
|
|
1264
|
+
cat = common.open_catalog(cfg)
|
|
1265
|
+
files, _ = cri.acb_data(cat, sheet)
|
|
1266
|
+
c = acb.cue_streams(files["acb"]).get(cue)
|
|
1267
|
+
if c is None:
|
|
1268
|
+
raise LookupError(f"sheet {sheet} has no cue {cue}")
|
|
1269
|
+
with tempfile.TemporaryDirectory(prefix="nnnotes-voice-") as tmp:
|
|
1270
|
+
cri.decode(cat, sheet, Path(tmp), fmt=fmt, flac_level=level)
|
|
1271
|
+
streams = {s["stream"]: s for s in json.loads((Path(tmp) / "streams.json").read_text(encoding="utf-8"))}
|
|
1272
|
+
out = []
|
|
1273
|
+
for w in c["streams"]:
|
|
1274
|
+
s = streams.get(w.get("stream"))
|
|
1275
|
+
if s is None:
|
|
1276
|
+
continue
|
|
1277
|
+
out.append(dst / s["file"])
|
|
1278
|
+
shutil.copyfile(Path(tmp) / s["file"], out[-1])
|
|
1279
|
+
return out
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def cmd_get(args, cfg, common) -> None:
|
|
1283
|
+
head = f"{load_rules()[VIEW].get('story', {}).get('table')}:"
|
|
1284
|
+
story = (args.id[len(head):].split(":", 1)[0],) if args.id.startswith(head) else ()
|
|
1285
|
+
doc, base = _document(args, cfg, common, story)
|
|
1286
|
+
rows = rows_for(doc, args.id)
|
|
1287
|
+
if not rows:
|
|
1288
|
+
args.usage(f"{args.id}: no voice row or sound id of that number")
|
|
1289
|
+
r = next((x for x in rows if x["status"] == "ok"), rows[0])
|
|
1290
|
+
sheet, cue = r["sound"].get("sheet"), r["sound"].get("cue")
|
|
1291
|
+
dst = Path(args.out)
|
|
1292
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
1293
|
+
written = []
|
|
1294
|
+
streams = (r.get("audio") or {}).get("streams", [])
|
|
1295
|
+
if base is not None and args.format == "flac" and streams and all(s.get("path") for s in streams):
|
|
1296
|
+
for s in streams:
|
|
1297
|
+
written.append(dst / s["file"])
|
|
1298
|
+
shutil.copyfile(base / s["path"], written[-1])
|
|
1299
|
+
elif sheet is None or cue is None or r["status"] in ("no-value", "missing-key", "missing-cue", "unsupported"):
|
|
1300
|
+
print(f"nnnotes: {r['id']}: {r['status']}" + (f" ({r['detail']})" if r.get("detail") else ""),
|
|
1301
|
+
file=sys.stderr)
|
|
1302
|
+
sys.exit(1)
|
|
1303
|
+
else:
|
|
1304
|
+
from .cristages import FLAC_LEVEL
|
|
1305
|
+
try:
|
|
1306
|
+
written = _decode_one(cfg, common, sheet, cue, args.format, FLAC_LEVEL, dst)
|
|
1307
|
+
except LookupError as e:
|
|
1308
|
+
print(f"nnnotes: {r['id']}: {e}", file=sys.stderr)
|
|
1309
|
+
sys.exit(1)
|
|
1310
|
+
from .jsonio import dumps
|
|
1311
|
+
(dst / "voice.json").write_text(dumps({"rows": rows, "files": [p.name for p in written]}, ensure_ascii=False,
|
|
1312
|
+
indent=1) + "\n", encoding="utf-8", newline="\n")
|
|
1313
|
+
for p in written:
|
|
1314
|
+
_out(f"{p}\n")
|
|
1315
|
+
if not written:
|
|
1316
|
+
sys.exit(1)
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def register(sub, common) -> None:
|
|
1320
|
+
"""The `voices` command (common: open_catalog(cfg, bundles=...), master_dir(cfg))."""
|
|
1321
|
+
c = sub.add_parser("voices", help="character voices of the master data and the story episodes: list, search, "
|
|
1322
|
+
"summary, get one")
|
|
1323
|
+
vs = c.add_subparsers(dest="voices_cmd", required=True, metavar="<voices command>")
|
|
1324
|
+
|
|
1325
|
+
def base(p, text=True):
|
|
1326
|
+
p.add_argument("--from", dest="source_dir", metavar="OUT",
|
|
1327
|
+
help="an export directory with views/voices.json (export --views voices); else the index is "
|
|
1328
|
+
"built from the configured master data and catalog (no files)")
|
|
1329
|
+
if text:
|
|
1330
|
+
p.add_argument("--character", help="a MasterCharacter id or a part of a name in any language")
|
|
1331
|
+
p.add_argument("--category", help="a category (CharacterRankUp or character-rank-up), Source.Category "
|
|
1332
|
+
"or a source")
|
|
1333
|
+
p.add_argument("--source", help="a source (Talk, CharacterVoice, ..., HomeSpot, Title, Sound, Story)")
|
|
1334
|
+
p.add_argument("--episode", metavar="ADV", help="the Story voices of one episode: a MasterAdv id or an "
|
|
1335
|
+
"episode asset (without --from: read from the catalog)")
|
|
1336
|
+
p.add_argument("--status", choices=STATUSES, help="only the rows with this status")
|
|
1337
|
+
p.add_argument("--limit", type=int, default=0, help="at most N rows (0: all)")
|
|
1338
|
+
p.add_argument("--json", action="store_true", help="the rows as JSON")
|
|
1339
|
+
|
|
1340
|
+
def done(p, fn):
|
|
1341
|
+
p.set_defaults(func=lambda args, cfg: fn(args, cfg, common), usage=p.error)
|
|
1342
|
+
|
|
1343
|
+
p = vs.add_parser("list", help="the voices matching the filters")
|
|
1344
|
+
base(p)
|
|
1345
|
+
p.add_argument("--language", choices=LANGUAGES, help="the language of the text column ([catalog] language)")
|
|
1346
|
+
done(p, cmd_list)
|
|
1347
|
+
p = vs.add_parser("search", help="the voices whose text (or cue name) contains TEXT")
|
|
1348
|
+
p.add_argument("text", help="text to look for")
|
|
1349
|
+
base(p)
|
|
1350
|
+
p.add_argument("--language", choices=LANGUAGES, help="search this language only (default: all five)")
|
|
1351
|
+
done(p, cmd_search)
|
|
1352
|
+
p = vs.add_parser("summary", help="rows per status, category and character; gaps both ways")
|
|
1353
|
+
base(p, text=False)
|
|
1354
|
+
p.add_argument("--json", action="store_true", help="the summary as JSON")
|
|
1355
|
+
done(p, cmd_summary)
|
|
1356
|
+
p = vs.add_parser("get", help="the files of one voice: a row id (MasterTalk:561, MasterAdv:<id>:<command>) or a "
|
|
1357
|
+
"sound id")
|
|
1358
|
+
p.add_argument("id", help="row id or sound id")
|
|
1359
|
+
p.add_argument("-o", "--out", required=True, help="output directory")
|
|
1360
|
+
base(p, text=False)
|
|
1361
|
+
p.add_argument("--format", choices=("flac", "ogg", "wav"), default="flac",
|
|
1362
|
+
help="flac (default: the exported files when --from has them), ogg or wav (decodes the sheet)")
|
|
1363
|
+
done(p, cmd_get)
|