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.
Files changed (79) hide show
  1. nnnotes/__init__.py +7 -0
  2. nnnotes/__main__.py +4 -0
  3. nnnotes/acb.py +190 -0
  4. nnnotes/addressables.py +413 -0
  5. nnnotes/adv.py +243 -0
  6. nnnotes/advcommand.py +29 -0
  7. nnnotes/advmedia.py +178 -0
  8. nnnotes/advscene.py +113 -0
  9. nnnotes/advui.py +1154 -0
  10. nnnotes/advvideo.py +684 -0
  11. nnnotes/atoms/__init__.py +151 -0
  12. nnnotes/atoms/astc.py +52 -0
  13. nnnotes/atoms/gltf.py +290 -0
  14. nnnotes/atoms/mesh.py +289 -0
  15. nnnotes/atoms/png.py +28 -0
  16. nnnotes/atoms/reader.py +269 -0
  17. nnnotes/atoms/sniff.py +198 -0
  18. nnnotes/atoms/sprite.py +236 -0
  19. nnnotes/atoms/texture.py +80 -0
  20. nnnotes/cache.py +289 -0
  21. nnnotes/catalog.py +373 -0
  22. nnnotes/catalogdb.py +457 -0
  23. nnnotes/census.py +276 -0
  24. nnnotes/cli.py +744 -0
  25. nnnotes/cli_assets.py +1539 -0
  26. nnnotes/config.py +238 -0
  27. nnnotes/contract.py +497 -0
  28. nnnotes/cri.py +371 -0
  29. nnnotes/crikey.py +71 -0
  30. nnnotes/crilips.py +265 -0
  31. nnnotes/cristages.py +558 -0
  32. nnnotes/deckdata.py +462 -0
  33. nnnotes/export.py +1462 -0
  34. nnnotes/gameapi.py +318 -0
  35. nnnotes/jsonio.py +98 -0
  36. nnnotes/languages.py +44 -0
  37. nnnotes/layout.py +596 -0
  38. nnnotes/link.py +418 -0
  39. nnnotes/live.py +115 -0
  40. nnnotes/live2d.py +235 -0
  41. nnnotes/liveaudio.py +331 -0
  42. nnnotes/livenotes.py +291 -0
  43. nnnotes/liveoptions.py +250 -0
  44. nnnotes/livescene.py +450 -0
  45. nnnotes/liveui.py +328 -0
  46. nnnotes/master.py +251 -0
  47. nnnotes/modelstages.py +424 -0
  48. nnnotes/motion.py +131 -0
  49. nnnotes/objexport.py +1564 -0
  50. nnnotes/orchestrate.py +630 -0
  51. nnnotes/plan.py +218 -0
  52. nnnotes/player.py +392 -0
  53. nnnotes/room.py +391 -0
  54. nnnotes/score.py +1139 -0
  55. nnnotes/shader.py +217 -0
  56. nnnotes/spot.py +201 -0
  57. nnnotes/stages.py +285 -0
  58. nnnotes/store.py +518 -0
  59. nnnotes/story.py +142 -0
  60. nnnotes/storyfonts.py +1258 -0
  61. nnnotes/storyhost.py +1190 -0
  62. nnnotes/storysite.py +662 -0
  63. nnnotes/storyvoices.py +102 -0
  64. nnnotes/textstyle.py +355 -0
  65. nnnotes/tmpfont.py +636 -0
  66. nnnotes/typetrees/6000.3.12f1.json +19 -0
  67. nnnotes/unity.py +368 -0
  68. nnnotes/viewrules.json +441 -0
  69. nnnotes/views.py +966 -0
  70. nnnotes/voices.py +1363 -0
  71. nnnotes/web.py +1624 -0
  72. nnnotes/webaudio.py +14 -0
  73. nnnotes/webmodel.py +397 -0
  74. nnnotes-0.1.0.dist-info/METADATA +200 -0
  75. nnnotes-0.1.0.dist-info/RECORD +79 -0
  76. nnnotes-0.1.0.dist-info/WHEEL +5 -0
  77. nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
  78. nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/storysite.py ADDED
@@ -0,0 +1,662 @@
1
+ """Story episodes -> the story part of an ournotes-player site (web.py builds the charts, webmodel.py the models of the
2
+ same site). The format is the player's docs/story-data-format.md.
3
+
4
+ <site>/stories.json story index: per manifest the story facts (titles and story groups in every
5
+ language, commands, languages), manifest path, sizes, regions
6
+ <site>/stories/<advId>.json story manifest: `files` (the common files) and `languages` (per language
7
+ its own files), every path -> {asset, size} or, for a split JSON object,
8
+ {parts: [[key, asset, size], ...], size} (as a chart manifest)
9
+ <site>/stories/<region>/<advId>.json
10
+ the manifest of a region whose story inputs differ from the first region's
11
+ (only where its files differ)
12
+ <site>/story/ the player's story page and bundle (web.write_player)
13
+ <site>/assets/<sha256>.<ext> content-addressed files, shared with the charts and models
14
+
15
+ Per story: the story directory of story.build without its UI (audio: FLAC, and with a web format other than FLAC the
16
+ same samples encoded into it by the decode, web.WEB_AUDIO; the site stores the web format and cues.json names it
17
+ with its encoder delay), then per language the story UI (advui.extract in that language, fonts open) and its fonts
18
+ (storyfonts: generated from the language's font file, or the game's font assets with fonts="game"); an Overlay story
19
+ (MasterAdv._playbackMode 1) built with open fonts also gets its host screen (storyhost: host/ and per language
20
+ ui/simple/, the manifest's `host`). The files the
21
+ player reads are stored (shader directories: GLES3 programs only, as web.collect; cue sheets: cues.json and the files
22
+ it names), scene.json and ui/ui.json split per top-level key whatever their size; files whose bytes are equal in
23
+ every language are common, the others go to their language's group (ui/fonts.json, ui/languages.json, the host talk
24
+ window's ui/simple/fonts.json and the glyph pages always do). Stories run in parallel worker processes (catalog cache
25
+ writes serialized by a lock); a story whose
26
+ manifest exists is skipped unless `force`. Same inputs and library versions give byte-identical outputs.
27
+
28
+ Regions: as for charts, the regions are grouped by the story inputs of their master data (STORY_TABLES); the first
29
+ group writes stories/<id>.json, another group stories/<region>/<id>.json unless its files equal the shared manifest's
30
+ (then its regions join the shared one). A region offers the stories of its MasterAdv.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import json
36
+ import multiprocessing as mp
37
+ import shutil
38
+ import tempfile
39
+ import time
40
+ import traceback
41
+ import uuid
42
+ from pathlib import Path
43
+
44
+ from . import adv, advui, jsonio, languages, master, story, storyfonts, storyhost
45
+ from .config import Config, ConfigError, usable_cpus, use
46
+ from .web import (SPLIT_KEY, STORIES_DIR, STORIES_INDEX, WEB_AUDIO, Store, _dump, _lock_fetches, _log, _minify,
47
+ check_player, collect, configure_caches, entry_assets, join_parts, merge_regions, mp4_priming,
48
+ open_data, region_masters, region_meta, site_regions, text_asset)
49
+
50
+ STORIES_FORMAT = "ournotes.stories/1"
51
+ MANIFEST_FORMAT = "ournotes.story-manifest/1"
52
+ FONT_SOURCES = storyfonts.SOURCES
53
+ EMOJI_FONT = "emoji" # the emoji font's key in --font / [paths] fonts
54
+ # master tables a story build reads (adv, advmedia, the story groups): a region's story inputs
55
+ STORY_TABLES = ("MasterAdv", "MasterAdvChat", "MasterCharacter", "MasterCharacterFriendship", "MasterHomeSpot",
56
+ "MasterStoryChapter", "MasterStoryEpisode", "MasterStoryFriendshipEpisode",
57
+ "MasterStoryHomeSpotTapTalkEpisode", "MasterStoryLiveResultEpisode", "MasterText")
58
+ # files stored split per top-level key whatever their size (their parts are shared between stories / languages)
59
+ SPLIT_ALWAYS = ("scene.json", "ui/ui.json")
60
+ # per-language files that go to the language group even when equal in every language
61
+ LANGUAGE_FILES = (f"ui/{storyfonts.FONTS_DOC}", f"ui/{storyfonts.LANGUAGE_DOC}",
62
+ f"ui/{storyhost.SIMPLE_DIR}/{storyfonts.FONTS_DOC}")
63
+ LANGUAGE_DIRS = (f"ui/{storyfonts.PAGES_DIR}/", f"ui/{storyhost.SIMPLE_DIR}/{storyfonts.PAGES_DIR}/")
64
+ STREAMS_DOC = "streams.json" # the cue sheets' every-stream list: not read by the player
65
+
66
+
67
+ # ---------------------------------------------------------------- master data
68
+ def all_stories(master_dir: Path) -> list[int]:
69
+ """Every MasterAdv id, in id order."""
70
+ return sorted(r["_id"] for r in master.table(Path(master_dir), "MasterAdv"))
71
+
72
+
73
+ def unknown_stories(cfg: Config, ids, regions=None) -> list[int]:
74
+ """The story ids of `ids` (duplicates dropped) that no region of the site (site_regions(cfg, regions)) has: no
75
+ MasterAdv row in its master data."""
76
+ have: set = set()
77
+ for md in dict.fromkeys(region_masters(cfg, site_regions(cfg, regions)).values()):
78
+ try:
79
+ have.update(all_stories(md))
80
+ except FileNotFoundError as e:
81
+ raise ConfigError(f"master data {md}: no {Path(e.filename).name} (a directory written by "
82
+ f"`nnnotes master decode`)") from None
83
+ return [i for i in dict.fromkeys(int(i) for i in ids) if i not in have]
84
+
85
+
86
+ def story_inputs(master_dir: Path) -> str:
87
+ """Fingerprint of a region's story inputs: SHA-256 over the STORY_TABLES files of its master dir."""
88
+ h = hashlib.sha256()
89
+ for t in STORY_TABLES:
90
+ f = Path(master_dir) / f"{t}.json"
91
+ h.update(t.encode("ascii") + b"\0" + (f.read_bytes() if f.is_file() else b"\0missing") + b"\0")
92
+ return h.hexdigest()
93
+
94
+
95
+ def region_groups(masters: dict[str, Path]) -> list[list[str]]:
96
+ """The regions grouped by story inputs, groups and regions in the order of `masters`."""
97
+ groups: dict[str, list[str]] = {}
98
+ for r, m in masters.items():
99
+ groups.setdefault(story_inputs(m), []).append(r)
100
+ return list(groups.values())
101
+
102
+
103
+ def story_groups(master_dir: Path) -> dict[int, list[dict]]:
104
+ """{advId: [group]}: the rows of the story tables that name an episode (docs/story-data-format.md, story
105
+ facts), in table order (MasterStoryEpisode, MasterStoryFriendshipEpisode, MasterStoryHomeSpotTapTalkEpisode,
106
+ MasterStoryLiveResultEpisode, MasterHomeSpot), rows of a table by id. Names in every language with a text;
107
+ a table the master data lacks gives no groups."""
108
+ md = Path(master_dir)
109
+
110
+ def table(name: str) -> list[dict]:
111
+ try:
112
+ return sorted(master.table(md, name), key=lambda r: r["_id"])
113
+ except FileNotFoundError:
114
+ return []
115
+ texts = {r["_id"]: r for r in table("MasterText")}
116
+
117
+ def names(text_id) -> dict:
118
+ row = texts.get(text_id)
119
+ return {c: t for c, t in languages.texts(row).items() if isinstance(t, str) and t} if row else {}
120
+ chars = {r["_id"]: r for r in table("MasterCharacter")}
121
+
122
+ def character(cid: int) -> dict:
123
+ return {"id": cid, "names": names((chars.get(cid) or {}).get("_nameTextID"))}
124
+ chapters = {r["_id"]: r for r in table("MasterStoryChapter")}
125
+ friendships = {r["_id"]: r for r in table("MasterCharacterFriendship")}
126
+ spots = {r["_id"]: r for r in table("MasterHomeSpot")}
127
+
128
+ def spot(sid: int) -> dict:
129
+ return {"id": sid, "names": names((spots.get(sid) or {}).get("_nameTextId"))}
130
+ out: dict[int, list[dict]] = {}
131
+ for r in table("MasterStoryEpisode"):
132
+ ch = chapters.get(r["_chapterId"]) or {}
133
+ g = {"kind": "chapter", "id": r["_id"], "episodeNumber": r["_episodeNumber"],
134
+ "chapter": {"id": r["_chapterId"], "names": names(ch.get("_nameTextId")), "bandId": ch.get("_bandId", 0),
135
+ "special": bool(ch.get("_isSpecialStory"))},
136
+ "characters": [character(r["_characterId"])] if r.get("_characterId") else []}
137
+ out.setdefault(r["_advId"], []).append(g)
138
+ for r in table("MasterStoryFriendshipEpisode"):
139
+ f = friendships.get(r["_characterFriendshipId"]) or {}
140
+ ids = [f[k] for k in ("_masterCharacterIdA", "_masterCharacterIdB") if f.get(k)]
141
+ out.setdefault(r["_advId"], []).append({"kind": "friendship", "id": r["_id"],
142
+ "episodeNumber": r["_episodeNumber"],
143
+ "characters": [character(c) for c in ids]})
144
+ for r in table("MasterStoryHomeSpotTapTalkEpisode"):
145
+ out.setdefault(r["_advId"], []).append({"kind": "spotTalk", "id": r["_id"], "spot": spot(r["_spotId"]),
146
+ "characters": [character(r["_characterId"])]
147
+ if r.get("_characterId") else []})
148
+ for r in table("MasterStoryLiveResultEpisode"):
149
+ out.setdefault(r["_advId"], []).append({"kind": "liveResult", "id": r["_id"],
150
+ "characters": [character(c) for c in r.get("_characterIds") or []]})
151
+ for r in table("MasterHomeSpot"):
152
+ if r.get("_advId"):
153
+ out.setdefault(r["_advId"], []).append({"kind": "spot", "id": r["_id"], "spot": spot(r["_id"]),
154
+ "characters": []})
155
+ return out
156
+
157
+
158
+ # ---------------------------------------------------------------- facts
159
+ def run_commands(episode: dict, scene: dict) -> list[str]:
160
+ """The command names the player runs: the rows without IgnoreData and the player settings' initialize and
161
+ finalize rows (Command values, adv.COMMAND), sorted."""
162
+ names = {c["cmd"] for c in episode["commands"] if not c.get("IgnoreData")}
163
+ ps = scene["settings"]["playerSettings"]
164
+ for r in list(ps.get("_initializeEpisodes") or []) + list(ps.get("_finalizeEpisodes") or []):
165
+ names.add(adv.COMMAND.get(r["Command"], f"Cmd{r['Command']}"))
166
+ return sorted(names)
167
+
168
+
169
+ def needs_motion_sync(episode: dict) -> bool:
170
+ """A Talk row (without IgnoreData) that maps a voice onto a character's lip sync: TargetName and VoiceIDs,
171
+ IgnoreLipSync not set."""
172
+ return any(c["cmd"] == "Talk" and c.get("TargetName") and c.get("VoiceIDs") and not c.get("IgnoreLipSync")
173
+ and not c.get("IgnoreData") for c in episode["commands"])
174
+
175
+
176
+ def story_facts(episode: dict, scene: dict, groups: list[dict], story_languages: list[str], language: str) -> dict:
177
+ """The story facts of a manifest (docs/story-data-format.md)."""
178
+ title = episode.get("title") or {}
179
+ row = episode["master"]
180
+ return {"advId": episode["advId"], "asset": episode["asset"], "sheetName": row.get("_sheetName", ""),
181
+ "playbackMode": row["_playbackMode"],
182
+ "titles": {c: title[languages.column(c)[1:]] for c in languages.LANGUAGES
183
+ if isinstance(title.get(languages.column(c)[1:]), str) and title[languages.column(c)[1:]]},
184
+ "groups": groups, "commands": run_commands(episode, scene), "commandCount": episode["commandCount"],
185
+ "language": language, "languages": list(story_languages)}
186
+
187
+
188
+ # ---------------------------------------------------------------- one story
189
+ def font_file(job: dict, language: str) -> storyfonts.FontFile:
190
+ """The FontFile of `language` (job fontFiles), read once per process; a language without one is a ConfigError
191
+ naming the setting (storyfonts.no_font_file)."""
192
+ fonts = _W.setdefault("fontFiles", {})
193
+ if language not in job["fontFiles"]:
194
+ return storyfonts.no_font_file(language)
195
+ path = job["fontFiles"][language]
196
+ if path not in fonts:
197
+ fonts[path] = storyfonts.FontFile(path)
198
+ return fonts[path]
199
+
200
+
201
+ def emoji_font(job: dict) -> storyfonts.EmojiFont | None:
202
+ """The EmojiFont of the job (emojiFile), read once per process; None without one."""
203
+ path = job.get("emojiFile")
204
+ if path is None:
205
+ return None
206
+ fonts = _W.setdefault("emojiFonts", {})
207
+ if path not in fonts:
208
+ fonts[path] = storyfonts.EmojiFont(path)
209
+ return fonts[path]
210
+
211
+
212
+ def build_dirs(cat, master_dir: Path, player, adv_id: int, work: Path, job: dict) -> dict:
213
+ """The story directory (work/story, no ui/) and per language its ui/ with the fonts (work/lang/<language>/ui).
214
+ -> {story, languages {language: dir}, episode, scene}."""
215
+ sdir = Path(work) / "story"
216
+ fmt = job["audioFormat"]
217
+ opts = None
218
+ if job["audio"] and WEB_AUDIO[fmt][1] is not None:
219
+ opts = {"flac_level": 0, "also": (WEB_AUDIO[fmt][0], WEB_AUDIO[fmt][1])}
220
+ story.build(cat, master_dir, player, adv_id, sdir, audio_format="flac", audio=job["audio"], fonts="open",
221
+ ui=False, audio_options=opts)
222
+ episode = json.loads((sdir / "episode.json").read_text(encoding="utf-8"))
223
+ scene = json.loads((sdir / "scene.json").read_text(encoding="utf-8"))
224
+ dirs = {}
225
+ for lang in job["languages"]:
226
+ ldir = Path(work) / "lang" / lang
227
+ advui.extract(cat, player, episode, ldir, fonts="open", language=lang, master=master_dir)
228
+ if job["fonts"] == "game":
229
+ gdir = Path(work) / "game" / lang
230
+ advui.extract(cat, player, episode, gdir, fonts="game", language=lang, master=master_dir)
231
+ storyfonts.game_fonts(cat, player, gdir / "ui", ldir / "ui", lang, episode, master_dir)
232
+ shutil.rmtree(gdir)
233
+ else:
234
+ storyfonts.open_fonts(cat, player, episode, ldir / "ui", lang, font_file(job, lang), master_dir,
235
+ emoji=emoji_font(job), font_of=lambda code: font_file(job, code))
236
+ dirs[lang] = ldir
237
+ share_ui_shaders(dirs)
238
+ return {"story": sdir, "languages": dirs, "episode": episode, "scene": scene}
239
+
240
+
241
+ def share_ui_shaders(dirs: dict[str, Path]) -> None:
242
+ """Give every language of a build the UI shaders any of them has: a text shader only some languages need (the
243
+ sprite shader of a language whose texts draw emoji sprites) is written for all of them, so that the UI shader
244
+ directories stay common to the languages (collect_story). A shader is copied from a language that has it (the
245
+ same dump); the index stays in name order when it is. A build of one language is left as it is."""
246
+ if len(dirs) < 2:
247
+ return
248
+ subs = sorted({p.parent.relative_to(d).as_posix() for d in dirs.values()
249
+ for p in Path(d).glob("ui/**/shaders/shaders.json")})
250
+ for sub in subs:
251
+ have = {lang: Path(d) / sub for lang, d in dirs.items() if (Path(d) / sub / "shaders.json").is_file()}
252
+ index = {lang: json.loads((sd / "shaders.json").read_text(encoding="utf-8")) for lang, sd in have.items()}
253
+ source = {} # shader name -> (language directory, index record)
254
+ for lang, recs in index.items():
255
+ for r in recs:
256
+ source.setdefault(r["name"], (have[lang], r))
257
+ for lang, sd in have.items():
258
+ recs = index[lang]
259
+ names = [r["name"] for r in recs]
260
+ add = [n for n in sorted(source) if n not in names]
261
+ if not add:
262
+ continue
263
+ for n in add:
264
+ src_dir, rec = source[n]
265
+ for rel in [rec["parsed"], *(v["file"] for v in rec["variants"])]:
266
+ (sd / rel).parent.mkdir(parents=True, exist_ok=True)
267
+ shutil.copyfile(src_dir / rel, sd / rel)
268
+ recs.append(rec)
269
+ if names == sorted(names):
270
+ recs.sort(key=lambda r: r["name"])
271
+ jsonio.write_json(sd / "shaders.json", recs)
272
+
273
+
274
+ def _files(root: Path, sub: str = "") -> list[str]:
275
+ base = Path(root) / sub if sub else Path(root)
276
+ return sorted(p.relative_to(root).as_posix() for p in base.rglob("*") if p.is_file()) if base.is_dir() else []
277
+
278
+
279
+ def collect_story(dirs: dict, audio: bool, audio_format: str) -> tuple[dict, dict]:
280
+ """The files of a story -> (common {path: text or bytes}, {language: {path: text or bytes}}): the story
281
+ directory (web.collect: shader directories reduced to their GLES3 programs), each cue sheet's cues.json and the
282
+ files it names (in the web format with its encoder delay when that is not FLAC), the videos, and per language
283
+ its ui/; ui/ files equal in every language are common (except LANGUAGE_FILES / LANGUAGE_DIRS)."""
284
+ sdir = dirs["story"]
285
+ rest = [p for p in _files(sdir) if not p.startswith(("audio/", "videos/", "ui/"))]
286
+ text, binary = collect(sdir, rest)
287
+ common: dict = {**text, **binary}
288
+ if audio:
289
+ ext = WEB_AUDIO[audio_format][0]
290
+ for sheet in sorted(p.name for p in (sdir / "audio").iterdir()) if (sdir / "audio").is_dir() else []:
291
+ d = sdir / "audio" / sheet
292
+ cues = json.loads((d / "cues.json").read_text(encoding="utf-8"))
293
+ for name, e in cues.items():
294
+ f = d / (Path(e["file"]).stem + ext)
295
+ data = f.read_bytes()
296
+ if ext != ".flac":
297
+ e["file"] = f.name
298
+ if audio_format == "aac":
299
+ e["encoderDelay"] = mp4_priming(data)
300
+ common[f"audio/{sheet}/{f.name}"] = data
301
+ common[f"audio/{sheet}/cues.json"] = jsonio.dumps(cues, ensure_ascii=True, indent=1)
302
+ for rel in _files(sdir, "videos"):
303
+ p = sdir / rel
304
+ common[rel] = p.read_text(encoding="utf-8") if rel.endswith(".json") else p.read_bytes()
305
+ per: dict[str, dict] = {}
306
+ for lang, ldir in dirs["languages"].items():
307
+ t, b = collect(ldir, _files(ldir, "ui"))
308
+ per[lang] = {**t, **b}
309
+ groups: dict[str, dict] = {lang: {} for lang in per}
310
+ for path in sorted({p for files in per.values() for p in files}):
311
+ values = [files.get(path) for files in per.values()]
312
+ own = path in LANGUAGE_FILES or path.startswith(LANGUAGE_DIRS)
313
+ if not own and all(v is not None for v in values) and all(v == values[0] for v in values):
314
+ if path in common:
315
+ raise RuntimeError(f"{path}: in the story directory and in the UI")
316
+ common[path] = values[0]
317
+ continue
318
+ for lang, files in per.items():
319
+ if path in files:
320
+ groups[lang][path] = files[path]
321
+ elif not own:
322
+ raise RuntimeError(f"{path}: in the UI of some languages only")
323
+ return common, groups
324
+
325
+
326
+ def split_always(data: bytes) -> list[tuple[str, bytes]] | None:
327
+ """A minified JSON object -> [(key, value text)] (as web.split_json, whatever the size); None when it cannot be
328
+ split (not an object of two or more keys whose JSON text is the same in Python and JavaScript)."""
329
+ if not data.startswith(b"{"):
330
+ return None
331
+ obj = json.loads(data)
332
+ if not isinstance(obj, dict) or len(obj) < 2 or not all(SPLIT_KEY.fullmatch(k) for k in obj):
333
+ return None
334
+ parts = [(k, _minify(v).encode("utf-8")) for k, v in obj.items()]
335
+ if join_parts(parts) != data:
336
+ raise RuntimeError("split JSON does not rejoin to the stored text")
337
+ return parts
338
+
339
+
340
+ def put_file(store: Store, path: str, data) -> dict:
341
+ """One file into the store: text through web.text_asset (SPLIT_ALWAYS files split per top-level key, other
342
+ JSON as web.Store.put_file), binary as is."""
343
+ if isinstance(data, bytes):
344
+ return store.put(path, data)
345
+ b = text_asset(path, data)
346
+ parts = split_always(b) if path in SPLIT_ALWAYS else None
347
+ if parts is None:
348
+ return store.put_file(path, b)
349
+ return {"size": len(b), "parts": [[k, store.put(f"{path}#{k}.json", t)["asset"], len(t)] for k, t in parts]}
350
+
351
+
352
+ def manifest_file(site: Path, adv_id: int, prefix: str = "") -> Path:
353
+ """stories/<prefix><advId>.json (`prefix`: "" or "<region>/")."""
354
+ return Path(site) / STORIES_DIR / f"{prefix}{adv_id}.json"
355
+
356
+
357
+ def story_manifests(site: Path) -> list[Path]:
358
+ """Every story manifest: stories/*.json, then the region manifests stories/<region>/*.json."""
359
+ d = Path(site) / STORIES_DIR
360
+ return [*sorted(d.glob("*.json")), *sorted(d.glob("*/*.json"))]
361
+
362
+
363
+ def manifest_assets(man: dict) -> set[str]:
364
+ """The assets a story manifest references: its common files and every language group's."""
365
+ used: set[str] = set()
366
+ for files in [man["files"], *(g["files"] for g in man["languages"].values())]:
367
+ for e in files.values():
368
+ used.update(entry_assets(e))
369
+ return used
370
+
371
+
372
+ def ingest(store: Store, site: Path, adv_id: int, dirs: dict, job: dict, groups: list[dict]) -> dict:
373
+ """One story's files into the store + its manifest stories/<prefix><advId>.json, serving job `regions` (added
374
+ to the regions of the manifest it replaces)."""
375
+ common, per = collect_story(dirs, job["audio"], job["audioFormat"])
376
+ episode, scene = dirs["episode"], dirs["scene"]
377
+ facts = story_facts(episode, scene, groups, job["languages"], job["language"])
378
+ files = {p: put_file(store, p, d) for p, d in sorted(common.items())}
379
+ langs = {lang: {"files": {p: put_file(store, p, d) for p, d in sorted(per[lang].items())}}
380
+ for lang in job["languages"]}
381
+ manifest = {"format": MANIFEST_FORMAT, "advId": adv_id, "story": facts, "language": job["language"],
382
+ "audio": bool(job["audio"]), "audioFormat": job["audioFormat"] if job["audio"] else None,
383
+ "fonts": job["fonts"],
384
+ "requires": {"commands": facts["commands"], "cubismCore": True,
385
+ "motionSync": needs_motion_sync(episode)},
386
+ "files": files, "languages": langs}
387
+ if dirs.get("host"): # Overlay stories: storyhost.build's entry (host/host.json, ui/simple/)
388
+ manifest["host"] = dirs["host"]
389
+ path = manifest_file(site, adv_id, job.get("prefix", ""))
390
+ old = json.loads(path.read_text(encoding="utf-8")).get("regions") if path.is_file() else None
391
+ if old or job.get("regions"):
392
+ manifest["regions"] = merge_regions(old, job.get("regions") or [])
393
+ path.parent.mkdir(parents=True, exist_ok=True)
394
+ path.write_bytes(_dump(manifest))
395
+ sizes = {"common": sum(e["size"] for e in files.values()),
396
+ "languages": {lang: sum(e["size"] for e in g["files"].values()) for lang, g in langs.items()}}
397
+ return {"id": str(adv_id), "ok": True, "files": len(files) + sum(len(g["files"]) for g in langs.values()),
398
+ "bytes": sizes["common"] + sum(sizes["languages"].values()), "size": sizes}
399
+
400
+
401
+ # ---------------------------------------------------------------- per story (in a worker or in this process)
402
+ _W: dict = {}
403
+
404
+
405
+ def _worker_init(cfg: Config, lock, job: dict) -> None:
406
+ use(cfg)
407
+ configure_caches(job)
408
+ cat, master_dir, player = open_data(cfg, job.get("region"))
409
+ if lock is not None:
410
+ _lock_fetches(cat, lock)
411
+ _W.update(cat=cat, master=master_dir, player=player, job=job, groups=story_groups(master_dir))
412
+
413
+
414
+ def story_task(adv_id: int, job: dict | None = None, data=None, groups: dict | None = None) -> dict:
415
+ """One story: its directories under a new temporary directory, ingest; the directory is deleted. `data`:
416
+ (catalog, master dir, PlayerData), `groups`: story_groups of that master dir (default: the worker's)."""
417
+ job = job or _W["job"]
418
+ cat, master_dir, player = data or (_W["cat"], _W["master"], _W["player"])
419
+ groups = _W["groups"] if groups is None else groups
420
+ site = Path(job["site"])
421
+ work = Path(tempfile.mkdtemp(prefix=f"s{adv_id}-", dir=job["tmp"]))
422
+ t0, stage = time.time(), "export"
423
+ try:
424
+ dirs = build_dirs(cat, master_dir, player, adv_id, work, job)
425
+ if job["fonts"] == "open": # the host talk window has open fonts only: no host data with game fonts
426
+ stage = "host"
427
+ dirs["host"] = storyhost.build(
428
+ cat, master_dir, player, dirs["episode"], dirs["story"], dirs["languages"], groups.get(adv_id, []),
429
+ fonts="open", font_files={lang: font_file(job, lang) for lang in job["languages"]},
430
+ font_of=lambda code: font_file(job, code))
431
+ stage = "ingest"
432
+ r = ingest(Store(site), site, adv_id, dirs, job, groups.get(adv_id, []))
433
+ return {**r, "id": job.get("prefix", "") + r["id"], "seconds": round(time.time() - t0, 1)}
434
+ except ConfigError:
435
+ raise
436
+ except Exception as e:
437
+ cause = f"{type(e).__name__}: {e}"
438
+ _log(f"{adv_id}: {stage} failed: {cause[:300]}")
439
+ return {"id": job.get("prefix", "") + str(adv_id), "ok": False, "stage": stage, "error": cause[:2000],
440
+ "trace": traceback.format_exc()[-3000:]}
441
+ finally:
442
+ shutil.rmtree(work, ignore_errors=True)
443
+
444
+
445
+ def _pool_task(adv_id: int) -> dict:
446
+ return story_task(adv_id)
447
+
448
+
449
+ def fold_variant(site: Path, adv_id: int, prefix: str) -> bool:
450
+ """Drop the region manifest stories/<prefix><id>.json when its files (common and per language) equal those
451
+ of the shared stories/<id>.json; its regions join the shared manifest. True when it was dropped."""
452
+ var, shared = manifest_file(site, adv_id, prefix), manifest_file(site, adv_id)
453
+ if not (prefix and var.is_file() and shared.is_file()):
454
+ return False
455
+ v = json.loads(var.read_text(encoding="utf-8"))
456
+ s = json.loads(shared.read_text(encoding="utf-8"))
457
+ if (v["files"], v["languages"]) != (s["files"], s["languages"]):
458
+ return False
459
+ merged = merge_regions(s.get("regions"), v.get("regions") or [])
460
+ if merged != s.get("regions"):
461
+ s["regions"] = merged
462
+ shared.write_bytes(_dump(s))
463
+ var.unlink()
464
+ return True
465
+
466
+
467
+ def add_regions(path: Path, regions: list[str]) -> bool:
468
+ """Add `regions` to a story manifest's regions; True when the file changed."""
469
+ man = json.loads(Path(path).read_text(encoding="utf-8"))
470
+ merged = merge_regions(man.get("regions"), regions)
471
+ if merged == man.get("regions"):
472
+ return False
473
+ man["regions"] = merged
474
+ Path(path).write_bytes(_dump(man))
475
+ return True
476
+
477
+
478
+ # ---------------------------------------------------------------- index
479
+ def write_stories_index(site: Path, language: str | None = None, regions: list[dict] | None = None) -> tuple[int, set]:
480
+ """site/stories.json from every story manifest present (no stories directory: no stories.json): entries in
481
+ advId, manifest order; `language` (default listing language) and `regions` of this build, else those of the
482
+ existing index. Returns the number of stories and the assets their manifests reference."""
483
+ site = Path(site)
484
+ index = site / STORIES_INDEX
485
+ if not (site / STORIES_DIR).is_dir():
486
+ index.unlink(missing_ok=True)
487
+ return 0, set()
488
+ old = {}
489
+ if index.is_file():
490
+ try:
491
+ old = json.loads(index.read_text(encoding="utf-8"))
492
+ except ValueError:
493
+ old = {}
494
+ entries, used = [], set()
495
+ for p in story_manifests(site):
496
+ man = json.loads(p.read_text(encoding="utf-8"))
497
+ used |= manifest_assets(man)
498
+ size = {"common": sum(e["size"] for e in man["files"].values()),
499
+ "languages": {lang: sum(e["size"] for e in g["files"].values())
500
+ for lang, g in man["languages"].items()}}
501
+ e = {"id": str(man["advId"]), "manifest": p.relative_to(site).as_posix(), "size": size,
502
+ "audio": man["audio"], "audioFormat": man["audioFormat"], "fonts": man["fonts"], **man["story"]}
503
+ if man.get("regions"):
504
+ e["regions"] = man["regions"]
505
+ entries.append(e)
506
+ entries.sort(key=lambda e: (e["advId"], e["manifest"]))
507
+ new = {r["id"]: r for r in regions or []}
508
+ recs = [new.pop(r["id"], r) for r in old.get("regions") or [] if isinstance(r, dict) and "id" in r]
509
+ recs += list(new.values())
510
+ known = {c for e in entries for c in [*e.get("titles", {}), *e.get("languages", [])]}
511
+ langs = [c for c in languages.LANGUAGES if c in known]
512
+ meta = {"language": language or old.get("language"), "languages": langs, "regions": recs}
513
+ doc = {"format": STORIES_FORMAT, **{k: v for k, v in meta.items() if v}, "stories": entries}
514
+ index.write_bytes(_dump(doc))
515
+ return len(entries), used
516
+
517
+
518
+ # ---------------------------------------------------------------- build
519
+ def font_files(cfg: Config, story_languages: list[str], flags: dict[str, str] | None = None) -> dict[str, str]:
520
+ """{language: font file path} of the story languages and of the other languages that have one (the fallback
521
+ chains of the open font assets draw from them: storyfonts.open_font_set): `flags` ({language: path} from
522
+ --font), else `[paths] fonts.<language>`; a story language without one, and a given file that does not exist,
523
+ is a ConfigError naming the setting."""
524
+ flags = dict(flags or {})
525
+ out = {}
526
+ for lang in languages.LANGUAGES:
527
+ p = Path(flags[lang]) if lang in flags else cfg.path("paths.fonts", lang)
528
+ if p is None and lang not in story_languages:
529
+ continue
530
+ if p is None:
531
+ raise ConfigError(f"the story text of {lang} needs a font file: give it as `{lang}` in the "
532
+ f"[paths.fonts] table of the config file (`fonts.{lang}` in [paths]), the environment "
533
+ f"variable NNNOTES_PATHS_FONTS_{lang.upper().replace('-', '_')} or --font {lang}=PATH")
534
+ if not p.is_file():
535
+ raise ConfigError(f"font file of {lang} not found (--font {lang} / paths.fonts.{lang})")
536
+ out[lang] = str(p.resolve())
537
+ return out
538
+
539
+
540
+ def emoji_file(cfg: Config, flags: dict[str, str] | None = None) -> str | None:
541
+ """The colour emoji font the emoji sprites are drawn from: `flags` ["emoji"] (--font emoji=PATH), else
542
+ `[paths] fonts.emoji`; None when neither is set (the sprites then keep their layout without images). A given
543
+ file that does not exist is a ConfigError."""
544
+ flags = dict(flags or {})
545
+ p = Path(flags[EMOJI_FONT]) if EMOJI_FONT in flags else cfg.path("paths.fonts", EMOJI_FONT)
546
+ if p is None:
547
+ return None
548
+ if not p.is_file():
549
+ raise ConfigError(f"emoji font file not found (--font {EMOJI_FONT} / paths.fonts.{EMOJI_FONT})")
550
+ return str(p.resolve())
551
+
552
+
553
+ def check_languages(codes) -> list[str]:
554
+ """The story languages `codes` (None: every language) in the order of languages.LANGUAGES, checked."""
555
+ if not codes:
556
+ return list(languages.LANGUAGES)
557
+ bad = [c for c in codes if c not in languages.LANGUAGES]
558
+ if bad:
559
+ raise ValueError(f"story language {bad[0]}: one of {', '.join(languages.LANGUAGES)}")
560
+ return [c for c in languages.LANGUAGES if c in set(codes)]
561
+
562
+
563
+ def _run_group(site: Path, ids: list[int], cfg: Config, job: dict, workers: int, log) -> list[dict]:
564
+ """The stories `ids` of one region group (job region / prefix / regions) -> one result per story."""
565
+ results = []
566
+ if workers <= 1 or len(ids) <= 1:
567
+ data = open_data(cfg, job["region"])
568
+ groups = story_groups(data[1])
569
+ _W.update(job=job)
570
+ for i in ids:
571
+ results.append(story_task(i, job, data=data, groups=groups))
572
+ return results
573
+ ctx = mp.get_context("spawn")
574
+ with ctx.Manager() as mgr:
575
+ with ctx.Pool(workers, initializer=_worker_init, initargs=(cfg, mgr.Lock(), job)) as pool:
576
+ for r in pool.imap_unordered(_pool_task, ids):
577
+ results.append(r)
578
+ if len(results) % 20 == 0:
579
+ log(f"{len(results)}/{len(ids)} stories")
580
+ return results
581
+
582
+
583
+ def build(out_dir, adv_ids, cfg: Config, player_dir, audio_format: str = "aac", audio: bool = True,
584
+ force: bool = False, *, tmp_dir=None, log=None, workers: int | None = None, regions: list[str] | None = None,
585
+ story_languages=None, fonts: str = "open", fonts_flags: dict[str, str] | None = None) -> dict:
586
+ """Add the stories `adv_ids` (MasterAdv ids; None: every story of every region's master data) to the site at
587
+ `out_dir`, with the player of the ournotes-player checkout or package at `player_dir` (its page files are
588
+ written, the indexes rebuilt). The data comes from the settings `cfg` (each worker process opens its own).
589
+ `audio_format`: web.WEB_AUDIO format of every waveform (`audio` False: none); `story_languages`: the language
590
+ groups (default every language); `fonts`: "open" (glyphs from the font file of each language: `fonts_flags`
591
+ {language: path}, else `[paths] fonts.<language>`; the emoji sprites from `fonts_flags` ["emoji"], else
592
+ `[paths] fonts.emoji`, when given) or "game"; `regions`: as for web.build (module docstring);
593
+ `workers`: parallel story processes (default a quarter of the CPUs, up to 8)."""
594
+ from .web import write_index, write_player
595
+ from .tmpfont import require_extra
596
+ player_dir = check_player(player_dir)
597
+ if audio_format not in WEB_AUDIO:
598
+ raise ValueError(f"audio format {audio_format}: one of {', '.join(WEB_AUDIO)}")
599
+ if fonts not in FONT_SOURCES:
600
+ raise ValueError(f"fonts {fonts!r}: one of {', '.join(FONT_SOURCES)}")
601
+ require_extra()
602
+ langs = check_languages(story_languages)
603
+ base = languages.check(cfg.require("catalog", "language"))
604
+ default = base if base in langs else langs[0]
605
+ files = font_files(cfg, langs, fonts_flags) if fonts == "open" else {}
606
+ emoji = emoji_file(cfg, fonts_flags) if fonts == "open" else None
607
+ regions = site_regions(cfg, regions)
608
+ masters = region_masters(cfg, regions)
609
+ groups = region_groups(masters)
610
+ site = Path(out_dir).resolve()
611
+ (site / STORIES_DIR).mkdir(parents=True, exist_ok=True)
612
+ Store(site)
613
+ tmp_root = Path(tmp_dir).resolve() if tmp_dir else site.parent / f"{site.name}.tmp"
614
+ tmp_root.mkdir(parents=True, exist_ok=True)
615
+ log = log or _log
616
+ t0 = time.time()
617
+ job0 = {"site": str(site), "tmp": str(tmp_root), "audioFormat": audio_format, "audio": bool(audio),
618
+ "languages": langs, "language": default, "fonts": fonts, "fontFiles": files, "emojiFile": emoji,
619
+ "cache": str(tmp_root / "cache"), "build": uuid.uuid4().hex}
620
+ configure_caches(job0)
621
+ ids = None if adv_ids is None else list(dict.fromkeys(int(i) for i in adv_ids))
622
+ results, skipped, folded, offered, used_workers = [], [], [], set(), 1
623
+ for gi, group in enumerate(groups):
624
+ rep = group[0]
625
+ prefix = "" if gi == 0 else f"{rep}/"
626
+ have = all_stories(masters[rep])
627
+ gids = have if ids is None else [i for i in ids if i in set(have)]
628
+ offered.update(gids)
629
+ todo = []
630
+ for i in gids:
631
+ if manifest_file(site, i, prefix).exists() and not force:
632
+ add_regions(manifest_file(site, i, prefix), group)
633
+ skipped.append(prefix + str(i))
634
+ else:
635
+ todo.append(i)
636
+ if not todo:
637
+ continue
638
+ w = workers if workers is not None else max(1, min(8, len(todo), usable_cpus() // 4))
639
+ used_workers = max(used_workers, w)
640
+ job = {**job0, "region": rep, "prefix": prefix, "regions": group}
641
+ log(f"{len(todo)} stories{f' for {rep}' if prefix else ''}, {w} worker(s), languages {', '.join(langs)}, "
642
+ f"fonts {fonts}, audio {audio_format if audio else 'none'}")
643
+ results += _run_group(site, todo, cfg, job, w, log)
644
+ if prefix:
645
+ folded += [prefix + str(i) for i in todo if fold_variant(site, i, prefix)]
646
+ for i in ids or []:
647
+ if i not in offered:
648
+ results.append({"id": str(i), "ok": False, "stage": "master",
649
+ "error": f"no MasterAdv row {i} in the master data of {', '.join(regions)}"})
650
+ v = write_player(site, player_dir, stories=True)
651
+ write_stories_index(site, base, region_meta(cfg, regions))
652
+ idx = write_index(site)
653
+ results.sort(key=lambda r: r["id"])
654
+ failed = [r for r in results if not r["ok"]]
655
+ if failed:
656
+ (site.parent / f"{site.name}.story-failures.json").write_bytes(_dump(failed))
657
+ return {"site": str(site),
658
+ "storiesBuilt": [{k: r[k] for k in ("id", "files", "bytes", "size")} for r in results if r["ok"]],
659
+ "storiesFailed": [{k: r.get(k) for k in ("id", "stage", "error")} for r in failed],
660
+ "storiesSkipped": skipped, "storyRegionGroups": groups, "foldedStoryManifests": folded,
661
+ "storyLanguages": langs, "storySeconds": round(time.time() - t0, 1), "storyWorkers": used_workers,
662
+ **v, **idx}