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/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}
|