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/web.py
ADDED
|
@@ -0,0 +1,1624 @@
|
|
|
1
|
+
"""Live charts -> one static site: the ournotes-player chart player plus per-chart data.
|
|
2
|
+
|
|
3
|
+
<site>/index.html, chart-list.js the player's chart-list page (the files of its PLAYER_PAGE_DIR, the
|
|
4
|
+
source import pointed at the bundle); without a query it lists the
|
|
5
|
+
charts, with ?music=<musicId>&difficulty=<d> it plays one
|
|
6
|
+
<site>/ournotes-player.element.min.js the player's built bundle (PLAYER_BUNDLES, with its source map)
|
|
7
|
+
<site>/charts.json the chart index (facts for a listing in every language, regions,
|
|
8
|
+
manifest path, sizes)
|
|
9
|
+
<site>/charts/<musicId>_<difficulty>.json chart manifest: every path the player reads -> {asset, size}, or,
|
|
10
|
+
for a large JSON object, {parts: [[key, asset, size], ...], size};
|
|
11
|
+
`regions`: the regions it serves
|
|
12
|
+
<site>/charts/<region>/<id>.json the manifest of a region whose chart inputs differ from the first
|
|
13
|
+
region's (only where its files differ)
|
|
14
|
+
<site>/assets/<sha256>.<ext> content-addressed files (text assets as UTF-8, JSON without
|
|
15
|
+
whitespace); charts share the note skins, effects, SE sheets, band
|
|
16
|
+
stages and the common parts of the scene, each stored once
|
|
17
|
+
<site>/models.json, models/<id>.json Live2D model index and manifests (webmodel.py), same entry forms and
|
|
18
|
+
the same assets/; write_index keeps what either kind references
|
|
19
|
+
<site>/live2d/ the player's Live2D model page and its bundle, when the player has
|
|
20
|
+
them (LIVE2D_PAGE_DIR, LIVE2D_BUNDLES)
|
|
21
|
+
<site>/stories.json, stories/<id>.json story index and manifests (storysite.py), same entry forms and the
|
|
22
|
+
same assets/; write_index keeps what they reference as well
|
|
23
|
+
<site>/story/ the player's story page and its bundle, when the player has them
|
|
24
|
+
(STORY_PAGE_DIR, STORY_BUNDLES)
|
|
25
|
+
|
|
26
|
+
Per music (the four difficulties share the scene, the sounds and the note assets): score + BGM decode, live sounds and
|
|
27
|
+
scene extracted once into a temporary base directory, the note assets once per build (they depend on no music); per
|
|
28
|
+
difficulty a directory of hard links to those plus its own score and start canvas, composed as `live.build` composes
|
|
29
|
+
a live directory. Per chart: the read set (READ_SET_SCRIPT of the player: the files the player reads for the whole
|
|
30
|
+
chart, its own code run in Node over the live directory; from the player's plan mode when it has one, checked
|
|
31
|
+
against the full run on a sample of charts, see ReadSets), those files collected (GLES3 shader programs only,
|
|
32
|
+
filtered shaders.json), the BGM transcoded to the web format (`audio_format`, once per music; note SE, cheers and
|
|
33
|
+
voices stay FLAC: small and shared), ingest. Musics run in parallel worker processes (catalog cache writes serialized
|
|
34
|
+
by a lock); temporary directories are deleted after use. A chart whose manifest exists is skipped unless `force`.
|
|
35
|
+
Same inputs give byte-identical outputs. `audio=False` exports no audio file (the player then runs the chart on its
|
|
36
|
+
own clock, silent).
|
|
37
|
+
|
|
38
|
+
Regions: one build can serve several regions. The regions serve the same catalog for a language, so the chart files
|
|
39
|
+
depend on a region's master data only: regions whose chart tables (LIVE_TABLES) are byte-identical share one manifest
|
|
40
|
+
(charts/<id>.json for the first region's group); another group's charts go to charts/<region>/<id>.json (region = the
|
|
41
|
+
first of the group), and such a manifest whose files equal the shared one's is dropped, its regions joining the
|
|
42
|
+
shared manifest. A region offers the charts its master data has a MasterLiveMusicScore row for. The listing texts
|
|
43
|
+
(title, band names) are in every language of the text tables (languages.LANGUAGES); `title` / `bands` in
|
|
44
|
+
[catalog] language.
|
|
45
|
+
|
|
46
|
+
Live options (`live_options`, from `--live-option`; liveoptions.py): a site can offer the Live options that select
|
|
47
|
+
files (mirrored score, note skins, effect sets and qualities, bar lines, note sound sets) besides the defaults. The
|
|
48
|
+
live directories then also carry those files (live.build's `options`), and each chart's read set is the union of the
|
|
49
|
+
default read set and the player's read sets with the settings of every offered variant (LiveOptions.read_variants;
|
|
50
|
+
the player's read-set script needs its "settings" feature); the full-simulation check of the plans runs on the
|
|
51
|
+
default settings. The manifests list the offered qualities (`options`). Without live options
|
|
52
|
+
the site holds only what the default options read.
|
|
53
|
+
|
|
54
|
+
A JSON object larger than SPLIT_MIN_BYTES is stored per top-level key: each value's text (the same minified encoding)
|
|
55
|
+
is its own asset, and the player rebuilds the file as `{"k1":` + t1 + `,"k2":` + t2 + `}`, so the values the charts
|
|
56
|
+
share (most of livescene/scene.json) are stored once.
|
|
57
|
+
"""
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import collections
|
|
61
|
+
import hashlib
|
|
62
|
+
import json
|
|
63
|
+
import multiprocessing as mp
|
|
64
|
+
import os
|
|
65
|
+
import queue
|
|
66
|
+
import re
|
|
67
|
+
import shutil
|
|
68
|
+
import struct
|
|
69
|
+
import subprocess
|
|
70
|
+
import sys
|
|
71
|
+
import tempfile
|
|
72
|
+
import threading
|
|
73
|
+
import time
|
|
74
|
+
import traceback
|
|
75
|
+
import uuid
|
|
76
|
+
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
from types import SimpleNamespace
|
|
79
|
+
|
|
80
|
+
from . import cache, cri, jsonio, languages, live, liveoptions, livenotes, livescene
|
|
81
|
+
from .config import Config, ConfigError, tool, usable_cpus, use
|
|
82
|
+
from .score import DIFFICULTIES, master_table
|
|
83
|
+
from .webaudio import DEFAULT_AUDIO_FORMAT, WEB_AUDIO
|
|
84
|
+
|
|
85
|
+
SITE_FORMAT = 2
|
|
86
|
+
AUDIO_EXT = {".flac", ".ogg", ".opus", ".wav", ".m4a", ".mp3"}
|
|
87
|
+
SPLIT_MIN_BYTES = 512 * 1024
|
|
88
|
+
# the live directories of a web build: no liveui/ (the player reads none of it), the BGM decoded once for the read
|
|
89
|
+
# set (FLAC) and, from the same samples, into the site format (bgm_options)
|
|
90
|
+
WEB_LIVEUI = False
|
|
91
|
+
BGM_DIRECT = True
|
|
92
|
+
SPLIT_KEY = re.compile(r"[A-Za-z0-9_$.\-]+") # keys whose JSON text is the same in Python and JavaScript
|
|
93
|
+
TRACE_QUALITY = 1 # the quality the read set is taken at (the player default, Middle)
|
|
94
|
+
FLOWS = ("direct",) # the start flow the player has
|
|
95
|
+
# files of the ournotes-player checkout / package the site uses
|
|
96
|
+
READ_SET_SCRIPT = "scripts/read-set.mjs" # node <script> <live dir> -> JSON list of the paths the player reads
|
|
97
|
+
PLAYER_BUNDLES = ("dist/ournotes-player.element.min.js",)
|
|
98
|
+
PLAYER_PAGE_DIR = "examples/chart-list"
|
|
99
|
+
# module specifiers of the page that name the player's sources -> the bundle copied next to the page
|
|
100
|
+
PAGE_IMPORTS = {"../../src/element.js": "./ournotes-player.element.min.js"}
|
|
101
|
+
# the player's Live2D model page (when the player has it): its files and bundle go to SITE/<LIVE2D_PAGE_SITE_DIR>/
|
|
102
|
+
LIVE2D_PAGE_DIR = "examples/live2d"
|
|
103
|
+
LIVE2D_PAGE_SITE_DIR = "live2d"
|
|
104
|
+
LIVE2D_BUNDLES = ("dist/ournotes-player.live2d.element.min.js",)
|
|
105
|
+
LIVE2D_PAGE_IMPORTS = {"../../src/live2d/define.js": "./ournotes-player.live2d.element.min.js"}
|
|
106
|
+
# the player's story page (when the player has it and its bundle): its files and bundle go to SITE/<STORY_PAGE_SITE_DIR>/
|
|
107
|
+
STORY_PAGE_DIR = "examples/story-list"
|
|
108
|
+
STORY_PAGE_SITE_DIR = "story"
|
|
109
|
+
STORY_BUNDLES = ("dist/ournotes-player.story.element.min.js",)
|
|
110
|
+
STORY_PAGE_IMPORTS = {"../../src/story/define.js": "./ournotes-player.story.element.min.js"}
|
|
111
|
+
# site data written by the build (never overwritten by page files)
|
|
112
|
+
MODELS_DIR = "models"
|
|
113
|
+
MODELS_INDEX = "models.json"
|
|
114
|
+
STORIES_DIR = "stories"
|
|
115
|
+
STORIES_INDEX = "stories.json"
|
|
116
|
+
SITE_DATA = ("assets", "charts", MODELS_DIR, "charts.json", MODELS_INDEX, STORIES_DIR, STORIES_INDEX)
|
|
117
|
+
PLAYER_VERSION_MARKER = "@PLAYER_VERSION@" # replaced in the page's files by the bundles' short hash
|
|
118
|
+
# collected files: text as UTF-8, binary as is; shader programs of the WebGL2 tier only
|
|
119
|
+
TEXT_EXT = {".json", ".glsl"}
|
|
120
|
+
BINARY_EXT = {".png", ".flac", ".ogg", ".wav", ".moc3", ".glb", ".atlas", ".skel", ".bin"} # .glb / .atlas / .skel: an Overlay story's home spot; .bin: a story's CRI Lips data (crilips.py)
|
|
121
|
+
SHADER_PLATFORM = "gles3"
|
|
122
|
+
SHADER_TYPE = "GLES3"
|
|
123
|
+
# master tables the chart build reads (score, live, liveaudio, livescene, livenotes, liveui): a region's chart inputs
|
|
124
|
+
LIVE_TABLES = ("MasterBand", "MasterCharacter", "MasterLiveJudgementSprite", "MasterLiveLaneSkin", "MasterLiveMusic",
|
|
125
|
+
"MasterLiveMusicScore", "MasterLiveNoteEffectSkin", "MasterLiveNoteSe", "MasterLiveNoteSkin",
|
|
126
|
+
"MasterLiveQualitySettings", "MasterLiveSe", "MasterLiveSettings", "MasterLiveStageVideo",
|
|
127
|
+
"MasterLiveStartCharacterVoice", "MasterMemberCard", "MasterOptionDefault", "MasterOptionRange",
|
|
128
|
+
"MasterSound", "MasterSoundCueSheet", "MasterText")
|
|
129
|
+
CHARTS_INDEX = "charts.json"
|
|
130
|
+
# documentation fields of the extractors the player does not read (live-audio.json `spec`, the voice rule notes,
|
|
131
|
+
# scene.json `slice` / `derived` notes): left out of the site's copies
|
|
132
|
+
DOC_FIELDS = {
|
|
133
|
+
"audio/live-audio.json": [("spec",), ("voice", "rule"), ("voice", "decision")],
|
|
134
|
+
"livescene/scene.json": [("slice", "bandChoice", "source"), ("slice", "bandChoice", "note"),
|
|
135
|
+
("derived", "lane", "sources"), ("derived", "fovRule", "source")],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _dump(obj) -> bytes:
|
|
140
|
+
return jsonio.dumps(obj, ensure_ascii=False, indent=1, sort_keys=True).encode("utf-8") + b"\n"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _minify(obj) -> str:
|
|
144
|
+
return jsonio.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def text_asset(path: str, s: str) -> bytes:
|
|
148
|
+
"""A text asset's stored bytes: JSON re-encoded without whitespace (the same values: JSON.parse of either text
|
|
149
|
+
gives the same result; non-finite numbers stay 1e999 / -1e999) and without the DOC_FIELDS, other text (GLSL)
|
|
150
|
+
as is, UTF-8."""
|
|
151
|
+
if path.endswith(".json"):
|
|
152
|
+
obj = json.loads(s)
|
|
153
|
+
for keys in DOC_FIELDS.get(path, ()):
|
|
154
|
+
o = obj
|
|
155
|
+
for k in keys[:-1]:
|
|
156
|
+
o = o.get(k) if isinstance(o, dict) else None
|
|
157
|
+
if isinstance(o, dict):
|
|
158
|
+
o.pop(keys[-1], None)
|
|
159
|
+
s = _minify(obj)
|
|
160
|
+
return s.encode("utf-8")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def join_parts(parts: list[tuple[str, bytes]]) -> bytes:
|
|
164
|
+
"""The text the player's AssetStore rebuilds from a split JSON file: {"k1":t1,"k2":t2}."""
|
|
165
|
+
return b"{" + b",".join(json.dumps(k).encode("ascii") + b":" + t for k, t in parts) + b"}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def split_json(data: bytes) -> list[tuple[str, bytes]] | None:
|
|
169
|
+
"""A minified JSON object larger than SPLIT_MIN_BYTES -> [(key, value text)] in key order; None when it stays
|
|
170
|
+
whole. The parts rejoin to exactly `data`."""
|
|
171
|
+
if len(data) <= SPLIT_MIN_BYTES or not data.startswith(b"{"):
|
|
172
|
+
return None
|
|
173
|
+
obj = json.loads(data)
|
|
174
|
+
if not isinstance(obj, dict) or len(obj) < 2 or not all(SPLIT_KEY.fullmatch(k) for k in obj):
|
|
175
|
+
return None
|
|
176
|
+
parts = [(k, _minify(v).encode("utf-8")) for k, v in obj.items()]
|
|
177
|
+
if join_parts(parts) != data:
|
|
178
|
+
raise RuntimeError("split JSON does not rejoin to the stored text")
|
|
179
|
+
return parts
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------- audio
|
|
183
|
+
def transcode(src: Path, fmt: str, work: Path) -> bytes:
|
|
184
|
+
"""One FLAC waveform -> the web format's bytes (bit-exact container flags: the output depends on the input only)."""
|
|
185
|
+
ext, codec = WEB_AUDIO[fmt]
|
|
186
|
+
if codec is None:
|
|
187
|
+
return Path(src).read_bytes()
|
|
188
|
+
dst = Path(work) / f"transcode-{os.getpid()}{ext}"
|
|
189
|
+
r = subprocess.run([tool("ffmpeg", "ffmpeg"), "-hide_banner", "-loglevel", "error", "-y", "-i", str(src), "-map_metadata", "-1",
|
|
190
|
+
"-fflags", "+bitexact", "-flags:a", "+bitexact", *codec, str(dst)],
|
|
191
|
+
capture_output=True, text=True)
|
|
192
|
+
if r.returncode != 0:
|
|
193
|
+
raise RuntimeError(f"ffmpeg {fmt} failed on {src}: {r.stderr[-2000:]}")
|
|
194
|
+
data = dst.read_bytes()
|
|
195
|
+
dst.unlink()
|
|
196
|
+
return data
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def mp4_priming(data: bytes) -> int:
|
|
200
|
+
"""Encoder delay of an MP4 audio track: media_time of the first edit list entry (moov/trak/edts/elst)."""
|
|
201
|
+
def boxes(b, off, end):
|
|
202
|
+
while off + 8 <= end:
|
|
203
|
+
size, kind = struct.unpack(">I4s", b[off:off + 8])
|
|
204
|
+
hdr = 8
|
|
205
|
+
if size == 1:
|
|
206
|
+
size, hdr = struct.unpack(">Q", b[off + 8:off + 16])[0], 16
|
|
207
|
+
elif size == 0:
|
|
208
|
+
size = end - off
|
|
209
|
+
yield kind, off + hdr, off + size
|
|
210
|
+
off += size
|
|
211
|
+
def find(path, off, end):
|
|
212
|
+
for kind, a, z in boxes(data, off, end):
|
|
213
|
+
if kind == path[0]:
|
|
214
|
+
return (a, z) if len(path) == 1 else find(path[1:], a, z)
|
|
215
|
+
return None
|
|
216
|
+
loc = find([b"moov", b"trak", b"edts", b"elst"], 0, len(data))
|
|
217
|
+
if loc is None:
|
|
218
|
+
return 0
|
|
219
|
+
a, _ = loc
|
|
220
|
+
version = data[a]
|
|
221
|
+
count = struct.unpack(">I", data[a + 4:a + 8])[0]
|
|
222
|
+
if count < 1:
|
|
223
|
+
return 0
|
|
224
|
+
if version == 1:
|
|
225
|
+
return max(0, struct.unpack(">q", data[a + 16:a + 24])[0])
|
|
226
|
+
return max(0, struct.unpack(">i", data[a + 12:a + 16])[0])
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _web_audio(live_dir: Path, text: dict[str, str], binary: dict[str, bytes], fmt: str, work: Path,
|
|
230
|
+
cache: dict | None = None) -> None:
|
|
231
|
+
"""The BGM layers (live-audio.json music sound) in the web format; the JSON paths follow. An AAC layer records
|
|
232
|
+
its encoder delay (`encoderDelay`, samples): a decoder that does not apply the MP4 edit list outputs that many
|
|
233
|
+
priming samples first, which the player then drops (LiveSoundManager.preload). `cache`: FLAC sha256 -> bytes."""
|
|
234
|
+
ext = WEB_AUDIO[fmt][0]
|
|
235
|
+
index = json.loads(text["live.json"])
|
|
236
|
+
la_path = index["liveAudio"]
|
|
237
|
+
la = json.loads(text[la_path])
|
|
238
|
+
renamed = {}
|
|
239
|
+
for layer in la["sounds"][str(la["music"]["soundId"])]["layers"]:
|
|
240
|
+
old = layer["file"]
|
|
241
|
+
if old not in binary or not old.endswith(".flac"):
|
|
242
|
+
raise ValueError(f"BGM layer {old}: not a packed FLAC")
|
|
243
|
+
new = old[:-len(".flac")] + ext
|
|
244
|
+
if new != old:
|
|
245
|
+
key = hashlib.sha256(binary[old]).hexdigest()
|
|
246
|
+
data = cache.get(key) if cache is not None else None
|
|
247
|
+
if data is None:
|
|
248
|
+
pre = live_dir / new # made from the same samples by the decode (bgm_options)
|
|
249
|
+
data = pre.read_bytes() if pre.is_file() else transcode(live_dir / old, fmt, work)
|
|
250
|
+
if cache is not None:
|
|
251
|
+
cache[key] = data
|
|
252
|
+
binary[new] = data
|
|
253
|
+
del binary[old]
|
|
254
|
+
renamed[old] = new
|
|
255
|
+
if fmt == "aac":
|
|
256
|
+
layer["encoderDelay"] = mp4_priming(data)
|
|
257
|
+
layer["file"] = new
|
|
258
|
+
if not renamed:
|
|
259
|
+
return
|
|
260
|
+
text[la_path] = jsonio.dumps(la, ensure_ascii=False, indent=1)
|
|
261
|
+
a = index.get("audio") or {}
|
|
262
|
+
if a.get("file") in renamed:
|
|
263
|
+
a["file"] = renamed[a["file"]]
|
|
264
|
+
for k, v in (a.get("cues") or {}).items():
|
|
265
|
+
if v in renamed:
|
|
266
|
+
a["cues"][k] = renamed[v]
|
|
267
|
+
text["live.json"] = jsonio.dumps(index, ensure_ascii=False, indent=1)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def localized_texts(master: dict) -> tuple[dict, dict]:
|
|
271
|
+
"""({language: title}, {language: [band names]}) of a live's master rows (score/master.json), every language of
|
|
272
|
+
languages.LANGUAGES the texts have (for the band names: every band has one)."""
|
|
273
|
+
title = master.get("title") or {}
|
|
274
|
+
bands = [b.get("name") or {} for b in master.get("bands") or []]
|
|
275
|
+
titles = {c: title[c] for c in languages.LANGUAGES if isinstance(title.get(c), str)}
|
|
276
|
+
names = {c: [b[c] for b in bands] for c in languages.LANGUAGES if all(isinstance(b.get(c), str) for b in bands)}
|
|
277
|
+
return titles, names
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def chart_facts(live_dir: Path, summary: dict, difficulty: str, language: str) -> dict:
|
|
281
|
+
"""What a chart listing needs, from the live directory (master rows, sounds) and the build summary: `title` and
|
|
282
|
+
`bands` in `language`, `titles` and `bandNames` in every language."""
|
|
283
|
+
rd = lambda rel: json.loads((Path(live_dir) / rel).read_text(encoding="utf-8"))
|
|
284
|
+
index = rd("live.json")
|
|
285
|
+
master = rd(index["master"])
|
|
286
|
+
la = rd(index["liveAudio"])
|
|
287
|
+
layer = la["sounds"][str(la["music"]["soundId"])]["layers"][0]
|
|
288
|
+
score_row = master["MasterLiveMusicScore"][difficulty]
|
|
289
|
+
music = master["MasterLiveMusic"]
|
|
290
|
+
titles, band_names = localized_texts(master)
|
|
291
|
+
return {
|
|
292
|
+
"title": (master.get("title") or {}).get(language),
|
|
293
|
+
"bands": [(b.get("name") or {}).get(language) for b in master.get("bands") or []],
|
|
294
|
+
"language": language,
|
|
295
|
+
"titles": titles,
|
|
296
|
+
"bandNames": band_names,
|
|
297
|
+
"bandIds": list(music.get("_bandIDs") or []),
|
|
298
|
+
"stageBand": summary["band"]["band"],
|
|
299
|
+
"level": score_row["_musicScoreLevel"],
|
|
300
|
+
"displayLevel": score_row["_musicScoreDisplayLevel"],
|
|
301
|
+
"notes": summary["judgementNoteCount"],
|
|
302
|
+
"fullComboCount": summary["fullComboCount"],
|
|
303
|
+
"durationMs": layer["samples"] * 1000 // layer["sampleRate"],
|
|
304
|
+
"sortOrder": music.get("_sortOrder"),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------- store
|
|
309
|
+
class Store:
|
|
310
|
+
"""site/assets/<sha256>.<ext>: write-once content-addressed files (safe for concurrent writers)."""
|
|
311
|
+
|
|
312
|
+
def __init__(self, site: Path):
|
|
313
|
+
self.dir = Path(site) / "assets"
|
|
314
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
self.written = 0
|
|
316
|
+
self.reused = 0
|
|
317
|
+
self._have: set[str] = set() # names this store has written or found
|
|
318
|
+
|
|
319
|
+
def put(self, path: str, data: bytes) -> dict:
|
|
320
|
+
ext = Path(path).suffix.lower().lstrip(".") or "bin"
|
|
321
|
+
name = f"{hashlib.sha256(data).hexdigest()}.{ext}"
|
|
322
|
+
dst = self.dir / name
|
|
323
|
+
if name in self._have:
|
|
324
|
+
self.reused += 1
|
|
325
|
+
elif dst.exists() and dst.stat().st_size == len(data):
|
|
326
|
+
self.reused += 1
|
|
327
|
+
self._have.add(name)
|
|
328
|
+
else:
|
|
329
|
+
tmp = cache.temp_path(dst)
|
|
330
|
+
tmp.write_bytes(data)
|
|
331
|
+
for i in range(20):
|
|
332
|
+
try:
|
|
333
|
+
os.replace(tmp, dst)
|
|
334
|
+
break
|
|
335
|
+
except PermissionError:
|
|
336
|
+
if dst.exists() and dst.stat().st_size == len(data):
|
|
337
|
+
tmp.unlink()
|
|
338
|
+
break
|
|
339
|
+
time.sleep(0.1 * (i + 1))
|
|
340
|
+
else:
|
|
341
|
+
raise RuntimeError(f"could not store {dst}")
|
|
342
|
+
self.written += 1
|
|
343
|
+
self._have.add(name)
|
|
344
|
+
return {"asset": f"assets/{name}", "size": len(data)}
|
|
345
|
+
|
|
346
|
+
def put_file(self, path: str, data: bytes) -> dict:
|
|
347
|
+
"""One file of a chart: whole, or per top-level key for a large JSON object (see split_json)."""
|
|
348
|
+
parts = split_json(data) if path.endswith(".json") else None
|
|
349
|
+
if parts is None:
|
|
350
|
+
return self.put(path, data)
|
|
351
|
+
return {"size": len(data), "parts": [[k, self.put(f"{path}#{k}.json", t)["asset"], len(t)] for k, t in parts]}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def entry_assets(e: dict) -> list[str]:
|
|
355
|
+
return [p[1] for p in e["parts"]] if "parts" in e else [e["asset"]]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
_TEXTS = cache.bucket("webtext") # (build, store, path, text) -> manifest entry
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def stored_text(store: Store, path: str, s: str, memo: str | None = None) -> dict:
|
|
362
|
+
"""store.put_file(path, text_asset(path, s)), made once per text in this process during the build `memo` (the
|
|
363
|
+
charts of a music share most files, every chart the note assets)."""
|
|
364
|
+
if memo is None:
|
|
365
|
+
return store.put_file(path, text_asset(path, s))
|
|
366
|
+
k = _TEXTS.key(memo, str(store.dir), path, s)
|
|
367
|
+
hit = _TEXTS.get(k)
|
|
368
|
+
if hit is None:
|
|
369
|
+
e = store.put_file(path, text_asset(path, s))
|
|
370
|
+
_TEXTS.put(k, json.dumps(e), 256)
|
|
371
|
+
return e
|
|
372
|
+
return json.loads(hit)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ---------------------------------------------------------------- data
|
|
376
|
+
def open_data(cfg: Config, region: str | None = None):
|
|
377
|
+
"""Catalog (fetching from the CDN of `region`), the master dir of `region` and PlayerData from the settings (as
|
|
378
|
+
the command line opens them; `region` None: [catalog] region)."""
|
|
379
|
+
from .cli import master_dir, open_catalog, player_data
|
|
380
|
+
return open_catalog(cfg, region=region), master_dir(cfg, region), player_data(cfg)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _lock_fetches(cat, lock) -> None:
|
|
384
|
+
"""Catalog downloads of this process under a lock shared by the workers (one writer per cache file); a file
|
|
385
|
+
already in the cache is returned without the lock (Catalog.cached)."""
|
|
386
|
+
for name in ("fetch", "fetch_raw"):
|
|
387
|
+
f = getattr(cat, name)
|
|
388
|
+
def locked(x, _f=f, _name=name):
|
|
389
|
+
hit = cat.cached(x) if _name == "fetch" else cat.cached_raw(x)
|
|
390
|
+
if hit is not None:
|
|
391
|
+
return hit
|
|
392
|
+
with lock:
|
|
393
|
+
return _f(x)
|
|
394
|
+
setattr(cat, name, locked)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def all_pairs(master: Path) -> list[tuple[int, str]]:
|
|
398
|
+
"""Every MasterLiveMusic x difficulty with a MasterLiveMusicScore row, in music id order."""
|
|
399
|
+
scores = {r["_id"] for r in master_table(Path(master), "MasterLiveMusicScore")}
|
|
400
|
+
out = []
|
|
401
|
+
for m in sorted(master_table(Path(master), "MasterLiveMusic"), key=lambda r: r["_id"]):
|
|
402
|
+
out += [(m["_id"], d) for d in DIFFICULTIES if m.get(f"_{d}ID") in scores]
|
|
403
|
+
return out
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------- regions
|
|
407
|
+
def site_regions(cfg: Config, regions=None, all_regions: bool = False) -> list[str]:
|
|
408
|
+
"""The regions of a build: `regions` (given order, duplicates dropped), every configured region
|
|
409
|
+
(`all_regions`, config order), else the one [catalog] region. Each needs its `[servers.<region>]` table."""
|
|
410
|
+
if all_regions:
|
|
411
|
+
names = cfg.regions()
|
|
412
|
+
if not names:
|
|
413
|
+
raise ConfigError("no region configured: add a [servers.<region>] table with `cdn` to the config file "
|
|
414
|
+
"(or set NNNOTES_SERVERS_<REGION>_CDN)")
|
|
415
|
+
else:
|
|
416
|
+
names = list(dict.fromkeys(regions or [])) or [cfg.region()]
|
|
417
|
+
known = set(cfg.regions())
|
|
418
|
+
for r in names:
|
|
419
|
+
if r not in known:
|
|
420
|
+
raise cfg.missing(f"servers.{r}", "cdn")
|
|
421
|
+
return names
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def unknown_pairs(cfg: Config, pairs, regions=None) -> list[tuple[int, str]]:
|
|
425
|
+
"""The charts of `pairs` ([(musicId, difficulty)], duplicates dropped) that no region of the site
|
|
426
|
+
(site_regions(cfg, regions)) has: no MasterLiveMusicScore row for that music and difficulty in its master data."""
|
|
427
|
+
have: set = set()
|
|
428
|
+
for md in dict.fromkeys(region_masters(cfg, site_regions(cfg, regions)).values()):
|
|
429
|
+
try:
|
|
430
|
+
have.update(all_pairs(md))
|
|
431
|
+
except FileNotFoundError as e:
|
|
432
|
+
raise ConfigError(f"master data {md}: no {Path(e.filename).name} (a directory written by "
|
|
433
|
+
f"`nnnotes master decode`)") from None
|
|
434
|
+
return [p for p in dict.fromkeys((int(m), d) for m, d in pairs) if p not in have]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def region_masters(cfg: Config, regions: list[str]) -> dict[str, Path]:
|
|
438
|
+
"""{region: decoded master dir} (cli.master_dir). With more than one region the --master flag is refused and
|
|
439
|
+
at most one region may use [paths] master (the others need their own `[servers.<region>] master`)."""
|
|
440
|
+
from .cli import master_dir
|
|
441
|
+
if len(regions) > 1 and cfg.origin("paths", "master") == "flag":
|
|
442
|
+
raise ConfigError("--master names one directory: give each region's master data as `master` in its "
|
|
443
|
+
"[servers.<region>] table of the config file (NNNOTES_SERVERS_<REGION>_MASTER)")
|
|
444
|
+
shared = [r for r in regions if cfg.master(r) == ("paths", "master")]
|
|
445
|
+
if len(shared) > 1:
|
|
446
|
+
raise cfg.missing(f"servers.{shared[1]}", "master")
|
|
447
|
+
return {r: master_dir(cfg, r) for r in regions}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def chart_inputs(master: Path) -> str:
|
|
451
|
+
"""Fingerprint of a region's chart inputs: SHA-256 over the LIVE_TABLES files of its master dir."""
|
|
452
|
+
h = hashlib.sha256()
|
|
453
|
+
for t in LIVE_TABLES:
|
|
454
|
+
f = Path(master) / f"{t}.json"
|
|
455
|
+
h.update(t.encode("ascii") + b"\0" + (f.read_bytes() if f.is_file() else b"\0missing") + b"\0")
|
|
456
|
+
return h.hexdigest()
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def region_groups(masters: dict[str, Path]) -> list[list[str]]:
|
|
460
|
+
"""The regions grouped by chart inputs (chart_inputs), groups and regions in the order of `masters`."""
|
|
461
|
+
groups: dict[str, list[str]] = {}
|
|
462
|
+
for r, m in masters.items():
|
|
463
|
+
groups.setdefault(chart_inputs(m), []).append(r)
|
|
464
|
+
return list(groups.values())
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def region_meta(cfg: Config, regions: list[str]) -> list[dict]:
|
|
468
|
+
"""The index records of `regions`: id, name (`[servers.<region>] name`, else the id) and, when configured, the
|
|
469
|
+
client languages (`[servers.<region>] languages`)."""
|
|
470
|
+
out = []
|
|
471
|
+
for r in regions:
|
|
472
|
+
rec = {"id": r, "name": cfg.get(f"servers.{r}", "name") or r}
|
|
473
|
+
langs = cfg.get_list(f"servers.{r}", "languages")
|
|
474
|
+
if langs:
|
|
475
|
+
rec["languages"] = langs
|
|
476
|
+
out.append(rec)
|
|
477
|
+
return out
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def merge_regions(old, new) -> list[str]:
|
|
481
|
+
"""`old` (a manifest's regions, may be None) followed by the regions of `new` it lacks."""
|
|
482
|
+
out = list(old or [])
|
|
483
|
+
return out + [r for r in new if r not in out]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def manifest_file(site: Path, chart_id: str, prefix: str = "") -> Path:
|
|
487
|
+
"""charts/<prefix><chart id>.json (`prefix`: "" or "<region>/")."""
|
|
488
|
+
return Path(site) / "charts" / f"{prefix}{chart_id}.json"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def chart_manifests(site: Path) -> list[Path]:
|
|
492
|
+
"""Every chart manifest: charts/*.json, then the region manifests charts/<region>/*.json."""
|
|
493
|
+
d = Path(site) / "charts"
|
|
494
|
+
return [*sorted(d.glob("*.json")), *sorted(d.glob("*/*.json"))]
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def add_regions(path: Path, regions: list[str]) -> bool:
|
|
498
|
+
"""Add `regions` to a manifest's regions; True when the file changed."""
|
|
499
|
+
man = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
500
|
+
merged = merge_regions(man.get("regions"), regions)
|
|
501
|
+
if merged == man.get("regions"):
|
|
502
|
+
return False
|
|
503
|
+
man["regions"] = merged
|
|
504
|
+
Path(path).write_bytes(_dump(man))
|
|
505
|
+
return True
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def fold_variant(site: Path, chart_id: str, prefix: str) -> bool:
|
|
509
|
+
"""Drop the region manifest charts/<prefix><id>.json when its files (and offered live options) equal those of
|
|
510
|
+
the shared charts/<id>.json; its regions join the shared manifest. True when it was dropped."""
|
|
511
|
+
var, shared = manifest_file(site, chart_id, prefix), manifest_file(site, chart_id)
|
|
512
|
+
if not (prefix and var.is_file() and shared.is_file()):
|
|
513
|
+
return False
|
|
514
|
+
v, s = json.loads(var.read_text(encoding="utf-8")), json.loads(shared.read_text(encoding="utf-8"))
|
|
515
|
+
if v["files"] != s["files"] or v.get("options") != s.get("options"):
|
|
516
|
+
return False
|
|
517
|
+
add_regions(shared, v.get("regions") or [])
|
|
518
|
+
var.unlink()
|
|
519
|
+
return True
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# ---------------------------------------------------------------- live directories
|
|
523
|
+
def _link_tree(src: Path, dst: Path) -> None:
|
|
524
|
+
shutil.copytree(src, dst, copy_function=os.link)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def bgm_options(audio_format: str, audio: bool) -> dict:
|
|
528
|
+
"""cri.decode options of a web build's BGM: the live directory keeps FLAC (the read set reads its header only:
|
|
529
|
+
level 0 unless the site stores that FLAC) and, when the site takes another format, the same samples encoded
|
|
530
|
+
into it as well (BGM_DIRECT; _web_audio then stores that file)."""
|
|
531
|
+
if not audio:
|
|
532
|
+
return {"flac_level": 0}
|
|
533
|
+
ext, codec = WEB_AUDIO[audio_format]
|
|
534
|
+
if codec is None:
|
|
535
|
+
return {}
|
|
536
|
+
return {"flac_level": 0, **({"also": (ext, codec)} if BGM_DIRECT else {})}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def build_music_dirs(cat, master: Path, player, music_id: int, difficulties: list[str], root: Path,
|
|
540
|
+
livenotes_dir: Path, band: int | None = None, leader_card: int | None = None, *,
|
|
541
|
+
language: str, fonts: str = "open", scene=None, bgm: dict | None = None,
|
|
542
|
+
options: liveoptions.LiveOptions = liveoptions.LiveOptions()) -> dict:
|
|
543
|
+
"""The live directories of one music's charts under root/<difficulty>/, composed as live.build composes one:
|
|
544
|
+
BGM decode (bgm: cri.decode options), live sounds and scene into root/base, shared by hard links; per difficulty
|
|
545
|
+
its own score/ (score.extract without audio) and live.json (liveui/ only with WEB_LIVEUI); livenotes/ linked
|
|
546
|
+
from `livenotes_dir` (livenotes.extract reads no music input). `scene`: (band part, its files) of the music's
|
|
547
|
+
band (livescene.band_part), else the scene is exported whole. `band` / `leader_card` / `language` / `fonts` /
|
|
548
|
+
`options`: as for live.build (livenotes_dir made with the same options). Returns {difficulty: (dir, summary)}."""
|
|
549
|
+
from . import liveaudio, liveui, score
|
|
550
|
+
base = root / "base"
|
|
551
|
+
base.mkdir(parents=True)
|
|
552
|
+
choice = live.resolve_band(cat, master, music_id, band=band, leader_card=leader_card)
|
|
553
|
+
first = {"audio": score.extract_audio(cat, master, music_id, base, "flac", **(bgm or {}))}
|
|
554
|
+
la = liveaudio.extract(cat, master, player, music_id, base, fmt="flac", options=options)
|
|
555
|
+
mode = "whole (no band part)" # how the scene was made: reported per chart
|
|
556
|
+
if scene is not None and scene[0]["band"] == choice["band"]:
|
|
557
|
+
_link_tree(scene[1], base / "livescene")
|
|
558
|
+
try:
|
|
559
|
+
livescene.music_part(cat, player, base, scene[0], master, music_id, band_choice=choice)
|
|
560
|
+
mode = "band"
|
|
561
|
+
except livescene.SharedSceneMismatch as e:
|
|
562
|
+
mode = f"whole ({e})"
|
|
563
|
+
_log(f"{music_id}: scene exported whole: {e}")
|
|
564
|
+
shutil.rmtree(base / "livescene")
|
|
565
|
+
if mode != "band":
|
|
566
|
+
livescene.extract(cat, player, base, master=master, music_id=music_id, band=choice["band"],
|
|
567
|
+
band_choice=choice)
|
|
568
|
+
out = {}
|
|
569
|
+
for d in difficulties:
|
|
570
|
+
pdir = root / d
|
|
571
|
+
pdir.mkdir()
|
|
572
|
+
for sub in ("audio", "livescene"):
|
|
573
|
+
_link_tree(base / sub, pdir / sub)
|
|
574
|
+
_link_tree(livenotes_dir, pdir / "livenotes")
|
|
575
|
+
s = score.extract(cat, master, music_id, d, pdir, audio=False, audio_fmt="flac", mirror=options.mirror)
|
|
576
|
+
if WEB_LIVEUI:
|
|
577
|
+
liveui.extract(cat, player, pdir, master=master, music_id=music_id, difficulty=d, language=language,
|
|
578
|
+
fonts=fonts)
|
|
579
|
+
jsonio.write_json(pdir / "live.json", live.index_doc(music_id, d, s, first["audio"], la["index"]))
|
|
580
|
+
out[d] = (pdir, {"band": choice, "judgementNoteCount": s["judgementNoteCount"],
|
|
581
|
+
"fullComboCount": s["fullComboCount"], "scene": mode})
|
|
582
|
+
return out
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def collect(live_dir: Path, files: list[str]) -> tuple[dict[str, str], dict[str, bytes]]:
|
|
586
|
+
"""The files `files` (paths relative to `live_dir`, every one must exist) as ({path: text}, {path: bytes}).
|
|
587
|
+
Every shader index (<dir>/shaders.json) among them is filtered to the listed GLES3 programs (GLSL ES 3.00);
|
|
588
|
+
other programs and SPIR-V containers of a shader directory are left out."""
|
|
589
|
+
live_dir = Path(live_dir)
|
|
590
|
+
exact = set(files)
|
|
591
|
+
wanted = exact.__contains__
|
|
592
|
+
lost = sorted(r for r in exact if not (live_dir / r).is_file())
|
|
593
|
+
if lost:
|
|
594
|
+
raise FileNotFoundError(f"{live_dir}: {len(lost)} listed files missing, e.g. {lost[:3]}")
|
|
595
|
+
text: dict[str, str] = {}
|
|
596
|
+
keep: set[str] = set()
|
|
597
|
+
shader_dirs = sorted(p.parent for p in live_dir.rglob("shaders.json"))
|
|
598
|
+
for sdir in shader_dirs:
|
|
599
|
+
rel_dir = sdir.relative_to(live_dir).as_posix()
|
|
600
|
+
if not wanted(f"{rel_dir}/shaders.json"):
|
|
601
|
+
continue
|
|
602
|
+
index = json.loads((sdir / "shaders.json").read_text(encoding="utf-8"))
|
|
603
|
+
for rec in index:
|
|
604
|
+
rec["variants"] = [v for v in rec["variants"]
|
|
605
|
+
if v["platform"] == SHADER_PLATFORM and v["type"] == SHADER_TYPE]
|
|
606
|
+
index = [rec for rec in index if wanted(f"{rel_dir}/{rec['parsed']}")]
|
|
607
|
+
for rec in index:
|
|
608
|
+
rec["variants"] = [v for v in rec["variants"] if wanted(f"{rel_dir}/{v['file']}")]
|
|
609
|
+
keep |= {f"{rel_dir}/{v['file']}" for rec in index for v in rec["variants"]}
|
|
610
|
+
text[f"{rel_dir}/shaders.json"] = jsonio.dumps(index, ensure_ascii=False)
|
|
611
|
+
binary: dict[str, bytes] = {}
|
|
612
|
+
for f in sorted(live_dir.rglob("*")):
|
|
613
|
+
if not f.is_file():
|
|
614
|
+
continue
|
|
615
|
+
rel = f.relative_to(live_dir).as_posix()
|
|
616
|
+
if rel.endswith(".html") or rel in text or not wanted(rel):
|
|
617
|
+
continue
|
|
618
|
+
sdir = next((d for d in shader_dirs if f.is_relative_to(d)), None)
|
|
619
|
+
if sdir is not None and f.suffix == ".glsl" and rel not in keep:
|
|
620
|
+
continue # other platforms / GLES 3.1 programs
|
|
621
|
+
if sdir is not None and f.suffix not in (".glsl", ".json"):
|
|
622
|
+
continue # Vulkan / SPIR-V containers
|
|
623
|
+
if f.suffix in TEXT_EXT:
|
|
624
|
+
text[rel] = f.read_text(encoding="utf-8")
|
|
625
|
+
elif f.suffix in BINARY_EXT:
|
|
626
|
+
binary[rel] = f.read_bytes()
|
|
627
|
+
else:
|
|
628
|
+
raise ValueError(f"unexpected file in the live directory: {rel}")
|
|
629
|
+
return text, binary
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def ingest(store: Store, site: Path, music_id: int, difficulty: str, live_dir: Path, summary: dict,
|
|
633
|
+
files: list[str], flows: list[str], audio_format: str, audio: bool, work: Path, bgm_cache: dict,
|
|
634
|
+
language: str, prefix: str = "", regions: list[str] | None = None, memo: str | None = None,
|
|
635
|
+
options: liveoptions.LiveOptions | None = None) -> dict:
|
|
636
|
+
"""One chart's files into the store + its manifest charts/<prefix><id>.json, serving `regions` (added to the
|
|
637
|
+
regions of the manifest it replaces). `memo`: the build's id for stored_text. `options`: the live options the
|
|
638
|
+
files carry (the manifest's `options`: LiveOptions.manifest_options)."""
|
|
639
|
+
text, binary = collect(live_dir, files)
|
|
640
|
+
facts = chart_facts(live_dir, summary, difficulty, language)
|
|
641
|
+
if audio:
|
|
642
|
+
_web_audio(live_dir, text, binary, audio_format, work, bgm_cache)
|
|
643
|
+
else:
|
|
644
|
+
binary = {k: v for k, v in binary.items() if Path(k).suffix.lower() not in AUDIO_EXT}
|
|
645
|
+
entries = {p: stored_text(store, p, s, memo) for p, s in text.items()}
|
|
646
|
+
entries.update({p: store.put(p, b) for p, b in binary.items()})
|
|
647
|
+
offered = options.manifest_options() if options else None
|
|
648
|
+
manifest = {"format": SITE_FORMAT, "musicId": music_id, "difficulty": difficulty, "audio": bool(audio),
|
|
649
|
+
"audioFormat": audio_format if audio else None, "flows": flows, "quality": TRACE_QUALITY,
|
|
650
|
+
**({"options": offered} if offered else {}), "chart": facts, "files": dict(sorted(entries.items()))}
|
|
651
|
+
path = manifest_file(site, f"{music_id}_{difficulty}", prefix)
|
|
652
|
+
old = json.loads(path.read_text(encoding="utf-8")).get("regions") if path.is_file() else None
|
|
653
|
+
if old or regions:
|
|
654
|
+
manifest["regions"] = merge_regions(old, regions or [])
|
|
655
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
656
|
+
path.write_bytes(_dump(manifest))
|
|
657
|
+
return {"id": f"{music_id}_{difficulty}", "ok": True, "files": len(entries), "bytes": sum(e["size"] for e in entries.values()),
|
|
658
|
+
"flows": flows}
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# ---------------------------------------------------------------- per music (in a worker or in this process)
|
|
662
|
+
_W: dict = {}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _worker_init(cfg: Config, lock, job: dict) -> None:
|
|
666
|
+
use(cfg)
|
|
667
|
+
configure_caches(job)
|
|
668
|
+
cat, master, player = open_data(cfg, job.get("region"))
|
|
669
|
+
if lock is not None:
|
|
670
|
+
_lock_fetches(cat, lock)
|
|
671
|
+
_W.update(cat=cat, master=master, player=player, cfg=job, scenes={})
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def configure_caches(job: dict) -> None:
|
|
675
|
+
"""The process caches of a build: the disk layer under the build's temporary root (shared by its processes)."""
|
|
676
|
+
if job.get("cache"):
|
|
677
|
+
cache.configure(directory=job["cache"])
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _log(m: str) -> None:
|
|
681
|
+
print(f"[{time.strftime('%H:%M:%S')}] {m}", file=sys.stderr, flush=True)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _data(data=None):
|
|
685
|
+
return data or (_W["cat"], _W["master"], _W["player"])
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _scene_part(cfg: dict, band: int):
|
|
689
|
+
"""(band part document, its files) of `band` from the build's scene directory, None when it has none."""
|
|
690
|
+
d = Path(cfg["scenes"]) / str(band) if cfg.get("scenes") else None
|
|
691
|
+
if d is None or not (d / livescene.BAND_DOC).is_file():
|
|
692
|
+
return None
|
|
693
|
+
parts = _W.setdefault("scenes", {})
|
|
694
|
+
if band not in parts:
|
|
695
|
+
parts[band] = json.loads((d / livescene.BAND_DOC).read_text(encoding="utf-8"))
|
|
696
|
+
return parts[band], d / "livescene"
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def band_task(band: int, music_id: int, cfg: dict | None = None, data=None) -> str:
|
|
700
|
+
"""The scene of `band` (livescene.band_part) into <scenes>/<band>/; `music_id`: a music of that band."""
|
|
701
|
+
cat, master, player = _data(data)
|
|
702
|
+
cfg = cfg or _W["cfg"]
|
|
703
|
+
out = Path(cfg["scenes"]) / str(band)
|
|
704
|
+
livescene.band_part(cat, player, out, master, music_id, band)
|
|
705
|
+
return str(out)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def extract_task(music_id: int, difficulties: list[str], cfg: dict | None = None, data=None) -> dict:
|
|
709
|
+
"""The live directories of one music under a new temporary root: {music, root, dirs {d: (dir, summary)},
|
|
710
|
+
extractSec}. The root is removed on failure (else by the caller after ingest)."""
|
|
711
|
+
cat, master, player = _data(data)
|
|
712
|
+
cfg = cfg or _W["cfg"]
|
|
713
|
+
root = Path(tempfile.mkdtemp(prefix=f"m{music_id}-", dir=cfg["tmp"]))
|
|
714
|
+
t0 = time.time()
|
|
715
|
+
try:
|
|
716
|
+
choice = live.resolve_band(cat, master, music_id, band=cfg["band"], leader_card=cfg["leaderCard"])
|
|
717
|
+
dirs = build_music_dirs(cat, master, player, music_id, difficulties, root, Path(cfg["livenotes"]),
|
|
718
|
+
band=cfg["band"], leader_card=cfg["leaderCard"], language=cfg["language"],
|
|
719
|
+
fonts=cfg.get("fonts", "open"), scene=_scene_part(cfg, choice["band"]),
|
|
720
|
+
bgm=bgm_options(cfg["audioFormat"], cfg["audio"]),
|
|
721
|
+
options=cfg.get("options") or liveoptions.LiveOptions())
|
|
722
|
+
except BaseException:
|
|
723
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
724
|
+
raise
|
|
725
|
+
return {"music": music_id, "root": str(root), "dirs": {d: (str(p), s) for d, (p, s) in dirs.items()},
|
|
726
|
+
"extractSec": round(time.time() - t0, 1)}
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def ingest_task(music_id: int, charts: list, root: str, cfg: dict | None = None) -> list[dict]:
|
|
730
|
+
"""Ingest the charts [(difficulty, dir, summary, files)] of one music; one result per chart."""
|
|
731
|
+
cfg = cfg or _W["cfg"]
|
|
732
|
+
site = Path(cfg["site"])
|
|
733
|
+
store, bgm, out = Store(site), {}, []
|
|
734
|
+
for d, pdir, summary, files in charts:
|
|
735
|
+
try:
|
|
736
|
+
r = ingest(store, site, music_id, d, Path(pdir), summary, files, list(FLOWS), cfg["audioFormat"],
|
|
737
|
+
cfg["audio"], Path(root), bgm, cfg["language"], cfg.get("prefix", ""), cfg.get("regions"),
|
|
738
|
+
memo=cfg.get("build"), options=cfg.get("options"))
|
|
739
|
+
r["scene"] = summary.get("scene")
|
|
740
|
+
out.append(r)
|
|
741
|
+
except ConfigError:
|
|
742
|
+
raise # a setting the whole build needs (Pipeline)
|
|
743
|
+
except Exception as e:
|
|
744
|
+
out.append(_failure(f"{music_id}_{d}", "ingest", e))
|
|
745
|
+
return out
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _failure(cid: str, stage: str, e: BaseException) -> dict:
|
|
749
|
+
cause = f"{type(e).__name__}: {e}"
|
|
750
|
+
_log(f"{cid}: {stage} failed: {cause[:300]}")
|
|
751
|
+
tb = "".join(traceback.format_exception(type(e), e, e.__traceback__))
|
|
752
|
+
return {"id": cid, "ok": False, "stage": stage, "error": cause[:2000], "trace": tb[-3000:]}
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
class Pipeline:
|
|
756
|
+
"""Charts of many musics through extract -> read sets -> ingest, each stage on its own executor.
|
|
757
|
+
|
|
758
|
+
`extract(music, difficulties)` / `ingest(music, charts, root)` are submitted to `workers` (an Executor: the
|
|
759
|
+
extraction processes), `read(dir, chart id)` to `readers` (threads that start Node). `window`: musics between
|
|
760
|
+
the start of their extraction and the end of their ingest. `cleanup(root)` after a music's ingest. -> one result per chart.
|
|
761
|
+
A failure fails its charts only, except a ConfigError (a setting or tool the build needs): run raises it once
|
|
762
|
+
the stages in flight have ended."""
|
|
763
|
+
|
|
764
|
+
def __init__(self, workers, readers, extract, read, ingest, cleanup, window: int, log=_log):
|
|
765
|
+
self.workers, self.readers, self.window, self.log = workers, readers, max(1, window), log
|
|
766
|
+
self.extract_fn, self.read_fn, self.ingest_fn, self.cleanup = extract, read, ingest, cleanup
|
|
767
|
+
self.events: "queue.Queue" = queue.Queue()
|
|
768
|
+
|
|
769
|
+
def run(self, musics: list[tuple[int, list[str]]]) -> list[dict]:
|
|
770
|
+
todo = list(musics)
|
|
771
|
+
todo.reverse()
|
|
772
|
+
active, results, t0 = {}, [], {}
|
|
773
|
+
|
|
774
|
+
def start():
|
|
775
|
+
while todo and len(active) < self.window:
|
|
776
|
+
m, ds = todo.pop()
|
|
777
|
+
active[m] = {"ds": ds, "files": {}, "fails": [], "left": len(ds)}
|
|
778
|
+
t0[m] = time.time()
|
|
779
|
+
f = self.workers.submit(self.extract_fn, m, ds)
|
|
780
|
+
f.add_done_callback(lambda f, m=m: self.events.put(("extracted", m, f)))
|
|
781
|
+
|
|
782
|
+
start()
|
|
783
|
+
stop = None # the ConfigError that ends the run
|
|
784
|
+
|
|
785
|
+
def failed(e) -> None:
|
|
786
|
+
nonlocal stop
|
|
787
|
+
if isinstance(e, ConfigError):
|
|
788
|
+
stop = stop or e
|
|
789
|
+
todo.clear()
|
|
790
|
+
|
|
791
|
+
while active:
|
|
792
|
+
kind, m, f, *rest = self.events.get()
|
|
793
|
+
st = active[m]
|
|
794
|
+
if stop is not None and kind == "extracted" and f.exception() is None:
|
|
795
|
+
self.cleanup(f.result()["root"])
|
|
796
|
+
del active[m]
|
|
797
|
+
continue
|
|
798
|
+
if kind == "extracted":
|
|
799
|
+
try:
|
|
800
|
+
x = f.result()
|
|
801
|
+
except Exception as e:
|
|
802
|
+
failed(e)
|
|
803
|
+
results += [_failure(f"{m}_{d}", "extract", e) for d in st["ds"]]
|
|
804
|
+
del active[m]
|
|
805
|
+
start()
|
|
806
|
+
continue
|
|
807
|
+
st.update(x=x)
|
|
808
|
+
for d in st["ds"]:
|
|
809
|
+
g = self.readers.submit(self.read_fn, x["dirs"][d][0], f"{m}_{d}")
|
|
810
|
+
g.add_done_callback(lambda g, m=m, d=d: self.events.put(("read", m, g, d)))
|
|
811
|
+
elif kind == "read":
|
|
812
|
+
d = rest[0]
|
|
813
|
+
try:
|
|
814
|
+
st["files"][d] = f.result()
|
|
815
|
+
except Exception as e:
|
|
816
|
+
failed(e)
|
|
817
|
+
st["fails"].append(_failure(f"{m}_{d}", "read set", e))
|
|
818
|
+
st["left"] -= 1
|
|
819
|
+
if st["left"] == 0 and stop is not None:
|
|
820
|
+
self.cleanup(st["x"]["root"])
|
|
821
|
+
del active[m]
|
|
822
|
+
elif st["left"] == 0:
|
|
823
|
+
x = st["x"]
|
|
824
|
+
charts = [(d, *x["dirs"][d], st["files"][d]) for d in st["ds"] if d in st["files"]]
|
|
825
|
+
g = self.workers.submit(self.ingest_fn, m, charts, x["root"])
|
|
826
|
+
g.add_done_callback(lambda g, m=m: self.events.put(("ingested", m, g)))
|
|
827
|
+
else:
|
|
828
|
+
x = st["x"]
|
|
829
|
+
try:
|
|
830
|
+
rs = f.result()
|
|
831
|
+
except Exception as e:
|
|
832
|
+
failed(e)
|
|
833
|
+
rs = [_failure(f"{m}_{d}", "ingest", e) for d in st["files"]]
|
|
834
|
+
rs += st["fails"]
|
|
835
|
+
dt = time.time() - t0[m]
|
|
836
|
+
for r in rs:
|
|
837
|
+
r.update(music=m, extractSec=x["extractSec"], musicSec=round(dt, 1))
|
|
838
|
+
self.log(f"{m}: {sum(r['ok'] for r in rs)}/{len(rs)} charts, extract {x['extractSec']:.0f} s, "
|
|
839
|
+
f"total {dt:.0f} s")
|
|
840
|
+
results += rs
|
|
841
|
+
self.cleanup(x["root"])
|
|
842
|
+
del active[m]
|
|
843
|
+
start()
|
|
844
|
+
if stop is not None:
|
|
845
|
+
raise stop
|
|
846
|
+
return results
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _pool_call(fn_name: str, *args):
|
|
850
|
+
return globals()[fn_name](*args)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
# ---------------------------------------------------------------- player
|
|
854
|
+
def check_player(player_dir) -> Path:
|
|
855
|
+
"""The ournotes-player checkout or installed package: its read-set script, built bundle and page must exist."""
|
|
856
|
+
if player_dir is None:
|
|
857
|
+
raise ConfigError("the site needs the player: give --player <ournotes-player checkout or package> "
|
|
858
|
+
"(or `player` in the [paths] table of the config file, NNNOTES_PATHS_PLAYER)")
|
|
859
|
+
d = Path(player_dir)
|
|
860
|
+
missing = [rel for rel in (READ_SET_SCRIPT, *PLAYER_BUNDLES, f"{PLAYER_PAGE_DIR}/index.html")
|
|
861
|
+
if not (d / rel).is_file()]
|
|
862
|
+
if missing:
|
|
863
|
+
raise ConfigError(f"{d} is not a built ournotes-player (missing {', '.join(missing)}; "
|
|
864
|
+
f"a checkout needs its build first)")
|
|
865
|
+
return d.resolve()
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
READ_SETS = cache.bucket("readsets", salt=cache.source_salt(__file__), disk=True)
|
|
869
|
+
READ_SET_CHECK = 10 # plan mode: the build's first chart and 1 in this many others also run the full simulation
|
|
870
|
+
READ_SET_SERVE_TIMEOUT = 600.0 # seconds a served read set may take (ReadSetServers); then its process is ended
|
|
871
|
+
_player_ids: dict = {}
|
|
872
|
+
_features: dict = {}
|
|
873
|
+
_features_lock = threading.Lock()
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def player_id(player_dir: Path) -> str:
|
|
877
|
+
"""Key of the player code a read set runs: every file of its src/, scripts/ and package.json, and the Node
|
|
878
|
+
executable (file identity and version)."""
|
|
879
|
+
player_dir = Path(player_dir)
|
|
880
|
+
node = tool("node", "node")
|
|
881
|
+
k = (str(player_dir), node)
|
|
882
|
+
if k not in _player_ids:
|
|
883
|
+
files = [p for d in ("src", "scripts") for p in sorted((player_dir / d).rglob("*")) if p.is_file()]
|
|
884
|
+
files += [player_dir / "package.json"] if (player_dir / "package.json").is_file() else []
|
|
885
|
+
version = subprocess.run([node, "--version"], capture_output=True, text=True).stdout.strip()
|
|
886
|
+
_player_ids[k] = cache.key(cache.file_id(node), version,
|
|
887
|
+
[(p.relative_to(player_dir).as_posix(), p.read_bytes()) for p in files])
|
|
888
|
+
return _player_ids[k]
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def player_features(player_dir: Path) -> frozenset:
|
|
892
|
+
"""The read-set modes the player's READ_SET_SCRIPT offers beyond the full simulation (`--features` -> JSON
|
|
893
|
+
{"features": [...]}; a script without the option exits with its usage: none). Asked once per player code."""
|
|
894
|
+
k = player_id(player_dir)
|
|
895
|
+
with _features_lock:
|
|
896
|
+
if k not in _features:
|
|
897
|
+
r = subprocess.run([tool("node", "node"), str(Path(player_dir) / READ_SET_SCRIPT), "--features"],
|
|
898
|
+
capture_output=True, text=True, encoding="utf-8")
|
|
899
|
+
feats = frozenset()
|
|
900
|
+
if r.returncode == 0:
|
|
901
|
+
try:
|
|
902
|
+
doc = json.loads(r.stdout)
|
|
903
|
+
except ValueError:
|
|
904
|
+
doc = None
|
|
905
|
+
if not (isinstance(doc, dict) and isinstance(doc.get("features"), list)):
|
|
906
|
+
raise RuntimeError(f"read set --features: expected a JSON object with a features list, got "
|
|
907
|
+
f"{r.stdout[:200]!r}")
|
|
908
|
+
feats = frozenset(str(f) for f in doc["features"])
|
|
909
|
+
_features[k] = feats
|
|
910
|
+
return _features[k]
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def live_dir_digest(live_dir: Path) -> list:
|
|
914
|
+
"""[(path relative to live_dir, sha256)] of every file of a live directory, in path order."""
|
|
915
|
+
live_dir = Path(live_dir)
|
|
916
|
+
files = sorted(p for p in live_dir.rglob("*") if p.is_file())
|
|
917
|
+
return [(p.relative_to(live_dir).as_posix(), hashlib.sha256(p.read_bytes()).digest()) for p in files]
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def read_set_cached(live_dir: Path, player_dir: Path, mode: str = "full", digest: list | None = None,
|
|
921
|
+
run=None, settings: dict | None = None) -> list[str]:
|
|
922
|
+
"""read_set, reused when the live directory's files, the player code, Node, the mode and the settings are the
|
|
923
|
+
same as for a read set stored before (READ_SETS: this process, and the disk layer of the build's cache).
|
|
924
|
+
`digest`: the live directory's live_dir_digest when the caller has it. `run`: what reads a set that is not
|
|
925
|
+
stored, with read_set's arguments and answer (default read_set; ReadSetServers.read_set answers the same)."""
|
|
926
|
+
live_dir = Path(live_dir)
|
|
927
|
+
parts = [player_id(player_dir), mode, digest if digest is not None else live_dir_digest(live_dir)]
|
|
928
|
+
if settings:
|
|
929
|
+
parts.append(json.dumps(settings, sort_keys=True))
|
|
930
|
+
k = READ_SETS.key(*parts)
|
|
931
|
+
hit = READ_SETS.get(k)
|
|
932
|
+
if hit is not None:
|
|
933
|
+
return json.loads(hit)
|
|
934
|
+
out = (run or read_set)(live_dir, player_dir, mode, **({"settings": settings} if settings else {}))
|
|
935
|
+
READ_SETS.put(k, json.dumps(out).encode("utf-8"))
|
|
936
|
+
return out
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def read_set(live_dir: Path, player_dir: Path, mode: str = "full", settings: dict | None = None) -> list[str]:
|
|
940
|
+
"""Every file of `live_dir` the player reads for the whole chart in its default state (`settings`: with those
|
|
941
|
+
Live options instead, player_features has "settings"), from the player's own code run in Node
|
|
942
|
+
(READ_SET_SCRIPT). mode "full": the simulation (no-op WebGL2 / WebAudio, every frame from the start to the
|
|
943
|
+
chart's end); "plan": the player lists the same files from the chart's contents without stepping frames
|
|
944
|
+
(player_features has "plan"; ReadSets checks it against the full simulation)."""
|
|
945
|
+
if mode not in ("full", "plan"):
|
|
946
|
+
raise ValueError(f"read set mode {mode}")
|
|
947
|
+
r = subprocess.run([tool("node", "node"), str(Path(player_dir) / READ_SET_SCRIPT), str(Path(live_dir)),
|
|
948
|
+
*(["--plan"] if mode == "plan" else []),
|
|
949
|
+
*([f"--settings={json.dumps(settings, sort_keys=True)}"] if settings else [])],
|
|
950
|
+
capture_output=True, text=True, encoding="utf-8")
|
|
951
|
+
if r.returncode != 0:
|
|
952
|
+
raise RuntimeError(f"read set of {Path(live_dir).name} failed:\n{(r.stderr or r.stdout)[-4000:]}")
|
|
953
|
+
return read_set_answer(json.loads(r.stdout), mode)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def read_set_answer(out, mode: str) -> list[str]:
|
|
957
|
+
"""The files of an answer of the read-set script (its JSON output) in `mode`, checked: a plan answered in plan
|
|
958
|
+
mode, a full simulation that reached the end of the chart, a list of paths."""
|
|
959
|
+
if mode == "plan" and not (isinstance(out, dict) and out.get("mode") == "plan"):
|
|
960
|
+
raise RuntimeError("read set --plan: the player did not answer in plan mode")
|
|
961
|
+
if isinstance(out, dict): # {files, state, frames} form
|
|
962
|
+
if mode == "full" and out.get("state", "ended") != "ended":
|
|
963
|
+
raise RuntimeError(f"read set: the chart did not end ({out.get('state')} after {out.get('frames')} frames)")
|
|
964
|
+
out = out.get("files")
|
|
965
|
+
if not isinstance(out, list) or not all(isinstance(p, str) for p in out):
|
|
966
|
+
raise RuntimeError("read set: expected a JSON list of paths")
|
|
967
|
+
return out
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
class ReadSetServerError(RuntimeError):
|
|
971
|
+
"""A read-set server that exited, answered something else than an answer, or none within the timeout (hung)."""
|
|
972
|
+
|
|
973
|
+
def __init__(self, message: str, hung: bool = False):
|
|
974
|
+
super().__init__(message)
|
|
975
|
+
self.hung = hung
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
class ReadSetServer:
|
|
979
|
+
"""One `node READ_SET_SCRIPT --serve` process (the player's feature "serve"): read sets one at a time, each asked
|
|
980
|
+
by a JSON line on its stdin and answered by one on its stdout; the last lines of its stderr are kept for errors."""
|
|
981
|
+
|
|
982
|
+
def __init__(self, player_dir: Path):
|
|
983
|
+
self.proc = subprocess.Popen([tool("node", "node"), str(Path(player_dir) / READ_SET_SCRIPT), "--serve"],
|
|
984
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
985
|
+
text=True, encoding="utf-8", errors="replace")
|
|
986
|
+
self.lines: queue.Queue = queue.Queue()
|
|
987
|
+
self.stderr: collections.deque = collections.deque(maxlen=50)
|
|
988
|
+
threading.Thread(target=self._pump, args=(self.proc.stdout, self.lines.put, True), daemon=True).start()
|
|
989
|
+
self._stderr_pump = threading.Thread(target=self._pump, args=(self.proc.stderr, self.stderr.append, False),
|
|
990
|
+
daemon=True)
|
|
991
|
+
self._stderr_pump.start()
|
|
992
|
+
|
|
993
|
+
@staticmethod
|
|
994
|
+
def _pump(stream, put, eof: bool) -> None:
|
|
995
|
+
for line in stream:
|
|
996
|
+
put(line)
|
|
997
|
+
if eof:
|
|
998
|
+
put(None)
|
|
999
|
+
|
|
1000
|
+
def ask(self, request: dict, timeout: float) -> dict:
|
|
1001
|
+
"""The answer to `request`: {"ok": true, "result"} or {"ok": false, "error"}. ReadSetServerError when the
|
|
1002
|
+
process has exited, answers something else or nothing within `timeout` seconds."""
|
|
1003
|
+
try:
|
|
1004
|
+
self.proc.stdin.write(json.dumps(request) + "\n")
|
|
1005
|
+
self.proc.stdin.flush()
|
|
1006
|
+
except OSError:
|
|
1007
|
+
pass # exited: the end of its stdout follows
|
|
1008
|
+
try:
|
|
1009
|
+
line = self.lines.get(timeout=timeout)
|
|
1010
|
+
except queue.Empty:
|
|
1011
|
+
raise ReadSetServerError(f"no answer within {timeout:.0f} s", hung=True) from None
|
|
1012
|
+
if line is None:
|
|
1013
|
+
code = self.proc.wait()
|
|
1014
|
+
self._stderr_pump.join(timeout=2)
|
|
1015
|
+
raise ReadSetServerError(f"the read-set server exited ({code}):\n"
|
|
1016
|
+
f"{''.join(self.stderr)[-4000:]}")
|
|
1017
|
+
try:
|
|
1018
|
+
doc = json.loads(line)
|
|
1019
|
+
except ValueError:
|
|
1020
|
+
doc = None
|
|
1021
|
+
if not isinstance(doc, dict) or not isinstance(doc.get("ok"), bool):
|
|
1022
|
+
raise ReadSetServerError(f"the read-set server answered {line[:200]!r}")
|
|
1023
|
+
return doc
|
|
1024
|
+
|
|
1025
|
+
def close(self, wait: float = 10.0) -> None:
|
|
1026
|
+
"""End of its stdin: the server exits (ended after `wait` seconds if it does not)."""
|
|
1027
|
+
try:
|
|
1028
|
+
self.proc.stdin.close()
|
|
1029
|
+
except OSError:
|
|
1030
|
+
pass
|
|
1031
|
+
try:
|
|
1032
|
+
self.proc.wait(timeout=wait)
|
|
1033
|
+
except subprocess.TimeoutExpired:
|
|
1034
|
+
self.kill()
|
|
1035
|
+
|
|
1036
|
+
def kill(self) -> None:
|
|
1037
|
+
self.proc.kill()
|
|
1038
|
+
self.proc.wait()
|
|
1039
|
+
try:
|
|
1040
|
+
self.proc.stdin.close()
|
|
1041
|
+
except OSError:
|
|
1042
|
+
pass
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
class ReadSetServers:
|
|
1046
|
+
"""Read-set servers for the reads of a build: a read takes an idle server or starts one (so there are at most as
|
|
1047
|
+
many as reads run at a time: one per read-set slot), and gives it back for the build's later charts; close()
|
|
1048
|
+
ends them. `read_set` has read_set's arguments and answers as read_set does. A server that exits or answers
|
|
1049
|
+
something else is replaced and the chart asked once more; a chart it answers no read set for within `timeout`
|
|
1050
|
+
seconds fails (RuntimeError, as a failed read_set) and the server is ended."""
|
|
1051
|
+
|
|
1052
|
+
def __init__(self, player_dir: Path, timeout: float = READ_SET_SERVE_TIMEOUT):
|
|
1053
|
+
self.player_dir, self.timeout = Path(player_dir), timeout
|
|
1054
|
+
self.lock = threading.Lock()
|
|
1055
|
+
self.idle: list[ReadSetServer] = []
|
|
1056
|
+
self.started = 0
|
|
1057
|
+
self.closed = False
|
|
1058
|
+
|
|
1059
|
+
def _take(self) -> ReadSetServer:
|
|
1060
|
+
with self.lock:
|
|
1061
|
+
if self.closed:
|
|
1062
|
+
raise RuntimeError("read set: the build's read-set servers are closed")
|
|
1063
|
+
while self.idle:
|
|
1064
|
+
s = self.idle.pop()
|
|
1065
|
+
if s.proc.poll() is None:
|
|
1066
|
+
return s
|
|
1067
|
+
s.kill() # exited while idle
|
|
1068
|
+
self.started += 1
|
|
1069
|
+
return ReadSetServer(self.player_dir)
|
|
1070
|
+
|
|
1071
|
+
def _give(self, s: ReadSetServer) -> None:
|
|
1072
|
+
with self.lock:
|
|
1073
|
+
if not self.closed:
|
|
1074
|
+
self.idle.append(s)
|
|
1075
|
+
return
|
|
1076
|
+
s.close()
|
|
1077
|
+
|
|
1078
|
+
def read_set(self, live_dir: Path, player_dir: Path, mode: str = "full", settings: dict | None = None) -> list[str]:
|
|
1079
|
+
if mode not in ("full", "plan"):
|
|
1080
|
+
raise ValueError(f"read set mode {mode}")
|
|
1081
|
+
name = Path(live_dir).name
|
|
1082
|
+
request = {"chart": str(Path(live_dir)), "plan": mode == "plan", **({"settings": settings} if settings else {})}
|
|
1083
|
+
for attempt in (1, 2):
|
|
1084
|
+
s = self._take()
|
|
1085
|
+
try:
|
|
1086
|
+
doc = s.ask(request, self.timeout)
|
|
1087
|
+
except ReadSetServerError as e:
|
|
1088
|
+
s.kill()
|
|
1089
|
+
if e.hung or attempt == 2:
|
|
1090
|
+
raise RuntimeError(f"read set of {name} failed: {e}") from None
|
|
1091
|
+
continue
|
|
1092
|
+
self._give(s)
|
|
1093
|
+
if not doc["ok"]:
|
|
1094
|
+
raise RuntimeError(f"read set of {name} failed:\n{str(doc.get('error'))[-4000:]}")
|
|
1095
|
+
return read_set_answer(doc.get("result"), mode)
|
|
1096
|
+
|
|
1097
|
+
def close(self) -> None:
|
|
1098
|
+
with self.lock:
|
|
1099
|
+
self.closed, idle, self.idle = True, self.idle, []
|
|
1100
|
+
for s in idle:
|
|
1101
|
+
s.close()
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
class ReadSetMismatch(RuntimeError):
|
|
1105
|
+
"""The player's plan and its full simulation read different files for a chart."""
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
class ReadSets:
|
|
1109
|
+
"""The read sets of a build. With a player whose read-set script has the plan mode, each chart's set comes
|
|
1110
|
+
from the plan; the build's first chart (expect: the first in build order, whenever its read set comes) and 1 in
|
|
1111
|
+
`check_every` others (chosen by the player code and chart id) also run the full simulation, and the two must be
|
|
1112
|
+
the same set. Which charts are checked depends on the inputs only, not on which extraction ends first, so the
|
|
1113
|
+
same build reads and caches the same sets. A difference fails that chart (ReadSetMismatch, with both
|
|
1114
|
+
differences), the full simulation reads every later chart, and the charts read from an unchecked plan until then
|
|
1115
|
+
are listed. A chart the plan cannot list (the script fails) is read by the full simulation, logged and listed in
|
|
1116
|
+
the summary (planErrors). A player without the plan mode: the full simulation for every chart. The player is
|
|
1117
|
+
asked for its modes when the first chart needs a read set. With a player that also has the serve mode, the plans
|
|
1118
|
+
are read by long-lived processes (ReadSetServers, one per read-set slot) instead of one process per chart; the
|
|
1119
|
+
full simulation always runs in a process of its own. close() ends the servers. `summary()` goes into the build
|
|
1120
|
+
result. `read(..., variants)`: the union with the chart's read sets under each of the Live option settings
|
|
1121
|
+
`variants` (the player's feature "settings"; plans in plan mode, a variant whose plan fails read by the full
|
|
1122
|
+
simulation), not checked by the full simulation."""
|
|
1123
|
+
|
|
1124
|
+
def __init__(self, player_dir: Path, log=_log, check_every: int = READ_SET_CHECK):
|
|
1125
|
+
self.player_dir = Path(player_dir)
|
|
1126
|
+
self.initial = self.mode = None
|
|
1127
|
+
self.check_every = max(1, check_every)
|
|
1128
|
+
self.log = log
|
|
1129
|
+
self.lock = threading.Lock() # counters, mode
|
|
1130
|
+
self.first: str | None = None # the chart checked as the build's first (expect)
|
|
1131
|
+
self.counts = {"plan": 0, "full": 0, "checked": 0}
|
|
1132
|
+
self.variants = 0 # read sets of option variants (read's `variants`)
|
|
1133
|
+
self.features: frozenset = frozenset()
|
|
1134
|
+
self.unchecked: list[str] = []
|
|
1135
|
+
self.mismatches: list[dict] = []
|
|
1136
|
+
self.plan_errors: list[dict] = []
|
|
1137
|
+
self.servers: ReadSetServers | None = None # the plans' servers (the player's serve mode)
|
|
1138
|
+
|
|
1139
|
+
def start(self) -> str:
|
|
1140
|
+
"""The mode (player_features), asked once."""
|
|
1141
|
+
with self.lock:
|
|
1142
|
+
if self.initial is None:
|
|
1143
|
+
features = self.features = player_features(self.player_dir)
|
|
1144
|
+
self.initial = self.mode = "plan" if "plan" in features else "full"
|
|
1145
|
+
if self.initial == "plan" and "serve" in features:
|
|
1146
|
+
self.servers = ReadSetServers(self.player_dir)
|
|
1147
|
+
return self.mode
|
|
1148
|
+
|
|
1149
|
+
def close(self) -> None:
|
|
1150
|
+
"""Ends the plans' servers; a later plan runs in a process of its own."""
|
|
1151
|
+
with self.lock:
|
|
1152
|
+
servers, self.servers = self.servers, None
|
|
1153
|
+
if servers is not None:
|
|
1154
|
+
servers.close()
|
|
1155
|
+
|
|
1156
|
+
def expect(self, chart_ids) -> None:
|
|
1157
|
+
"""The charts a build (group) reads, in build order; the first of the first call is the chart checked as the
|
|
1158
|
+
build's first."""
|
|
1159
|
+
with self.lock:
|
|
1160
|
+
if self.first is None and chart_ids:
|
|
1161
|
+
self.first = chart_ids[0]
|
|
1162
|
+
|
|
1163
|
+
def describe(self) -> str:
|
|
1164
|
+
if self.start() == "full":
|
|
1165
|
+
return "read sets: the full simulation of every chart (the player has no plan mode)"
|
|
1166
|
+
return (f"read sets: the player's plan{' (served)' if self.servers is not None else ''}; "
|
|
1167
|
+
f"{self.first or 'no first chart'} and 1 in {self.check_every} other "
|
|
1168
|
+
f"charts checked by the full simulation")
|
|
1169
|
+
|
|
1170
|
+
def need_settings(self) -> None:
|
|
1171
|
+
"""ConfigError unless the player's read-set script reads with given settings (feature "settings")."""
|
|
1172
|
+
self.start()
|
|
1173
|
+
if "settings" not in self.features:
|
|
1174
|
+
raise ConfigError(f"{self.player_dir}: its {READ_SET_SCRIPT} has no settings (feature \"settings\"), "
|
|
1175
|
+
f"which the read sets of live options need")
|
|
1176
|
+
|
|
1177
|
+
def sampled(self, chart_id: str) -> bool:
|
|
1178
|
+
h = hashlib.sha256(f"{player_id(self.player_dir)}|{chart_id}".encode("utf-8")).digest()
|
|
1179
|
+
return int.from_bytes(h[:4], "big") % self.check_every == 0
|
|
1180
|
+
|
|
1181
|
+
def read(self, live_dir, chart_id: str, variants=()) -> list[str]:
|
|
1182
|
+
live_dir = Path(live_dir)
|
|
1183
|
+
self.start()
|
|
1184
|
+
digest = live_dir_digest(live_dir)
|
|
1185
|
+
files = self._default(live_dir, chart_id, digest)
|
|
1186
|
+
if not variants:
|
|
1187
|
+
return files
|
|
1188
|
+
have = set(files)
|
|
1189
|
+
more: set[str] = set()
|
|
1190
|
+
for settings in variants:
|
|
1191
|
+
more.update(p for p in self._variant(live_dir, chart_id, digest, settings) if p not in have)
|
|
1192
|
+
with self.lock:
|
|
1193
|
+
self.variants += len(variants)
|
|
1194
|
+
return files + sorted(more)
|
|
1195
|
+
|
|
1196
|
+
def _variant(self, live_dir: Path, chart_id: str, digest: list, settings: dict) -> list[str]:
|
|
1197
|
+
"""The read set with `settings`: the plan in plan mode (the full simulation when the plan fails)."""
|
|
1198
|
+
if self.mode == "plan":
|
|
1199
|
+
servers = self.servers
|
|
1200
|
+
try:
|
|
1201
|
+
return read_set_cached(live_dir, self.player_dir, "plan", digest,
|
|
1202
|
+
run=servers.read_set if servers is not None else None, settings=settings)
|
|
1203
|
+
except RuntimeError as e:
|
|
1204
|
+
with self.lock:
|
|
1205
|
+
self.plan_errors.append({"id": chart_id, "settings": settings, "error": str(e)[-500:]})
|
|
1206
|
+
self.log(f"{chart_id}: the player's read-set plan with {json.dumps(settings, sort_keys=True)} "
|
|
1207
|
+
f"failed; the full simulation reads it")
|
|
1208
|
+
return read_set_cached(live_dir, self.player_dir, "full", digest, settings=settings)
|
|
1209
|
+
|
|
1210
|
+
def _default(self, live_dir: Path, chart_id: str, digest: list) -> list[str]:
|
|
1211
|
+
if self.mode == "plan":
|
|
1212
|
+
if chart_id == self.first or self.sampled(chart_id):
|
|
1213
|
+
return self._checked(live_dir, chart_id, digest)
|
|
1214
|
+
files = self._plan(live_dir, chart_id, digest)
|
|
1215
|
+
if files is not None:
|
|
1216
|
+
with self.lock:
|
|
1217
|
+
self.counts["plan"] += 1
|
|
1218
|
+
self.unchecked.append(chart_id)
|
|
1219
|
+
return files
|
|
1220
|
+
files = read_set_cached(live_dir, self.player_dir, "full", digest)
|
|
1221
|
+
with self.lock:
|
|
1222
|
+
self.counts["full"] += 1
|
|
1223
|
+
return files
|
|
1224
|
+
|
|
1225
|
+
def _plan(self, live_dir: Path, chart_id: str, digest: list) -> list[str] | None:
|
|
1226
|
+
"""The plan's read set, None when the player's script failed on the chart (logged, listed)."""
|
|
1227
|
+
servers = self.servers
|
|
1228
|
+
try:
|
|
1229
|
+
return read_set_cached(live_dir, self.player_dir, "plan", digest,
|
|
1230
|
+
run=servers.read_set if servers is not None else None)
|
|
1231
|
+
except RuntimeError as e:
|
|
1232
|
+
with self.lock:
|
|
1233
|
+
self.plan_errors.append({"id": chart_id, "error": str(e)[-500:]})
|
|
1234
|
+
self.log(f"{chart_id}: the player's read-set plan failed; the full simulation reads it "
|
|
1235
|
+
f"({str(e).strip().splitlines()[-1][:200] if str(e).strip() else type(e).__name__})")
|
|
1236
|
+
return None
|
|
1237
|
+
|
|
1238
|
+
def _checked(self, live_dir: Path, chart_id: str, digest: list) -> list[str]:
|
|
1239
|
+
plan = self._plan(live_dir, chart_id, digest)
|
|
1240
|
+
full = read_set_cached(live_dir, self.player_dir, "full", digest)
|
|
1241
|
+
if plan is None:
|
|
1242
|
+
with self.lock:
|
|
1243
|
+
self.counts["full"] += 1
|
|
1244
|
+
return full
|
|
1245
|
+
missing, extra = sorted(set(full) - set(plan)), sorted(set(plan) - set(full))
|
|
1246
|
+
with self.lock:
|
|
1247
|
+
self.counts["checked"] += 1
|
|
1248
|
+
if not missing and not extra:
|
|
1249
|
+
return full
|
|
1250
|
+
self.mismatches.append({"id": chart_id, "missing": missing, "extra": extra})
|
|
1251
|
+
if self.mode == "plan":
|
|
1252
|
+
self.mode = "full"
|
|
1253
|
+
self.log(f"{chart_id}: the player's read-set plan differs from its full simulation "
|
|
1254
|
+
f"({len(missing)} missing, {len(extra)} extra); the full simulation reads the other charts")
|
|
1255
|
+
raise ReadSetMismatch(f"the player's plan and its full simulation differ: missing {missing[:10]}"
|
|
1256
|
+
f"{' ...' if len(missing) > 10 else ''}, extra {extra[:10]}"
|
|
1257
|
+
f"{' ...' if len(extra) > 10 else ''}")
|
|
1258
|
+
|
|
1259
|
+
def summary(self) -> dict:
|
|
1260
|
+
"""{mode, plan, full, checked, variants?, mismatches, planErrors, unchecked}: charts read by each mode
|
|
1261
|
+
(checked: by both), read sets of option variants (when there were any); after a mismatch the charts whose
|
|
1262
|
+
set came from an unchecked plan are listed, else counted."""
|
|
1263
|
+
with self.lock:
|
|
1264
|
+
return {"mode": self.initial, **self.counts, **({"variants": self.variants} if self.variants else {}),
|
|
1265
|
+
"mismatches": list(self.mismatches), "planErrors": list(self.plan_errors),
|
|
1266
|
+
"unchecked": sorted(self.unchecked) if self.mismatches else len(self.unchecked)}
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def player_bundles(player_dir: Path, rels) -> tuple[dict[str, bytes], str]:
|
|
1270
|
+
"""The bundles `rels` of the player and their source maps ({file name: bytes}), and the bundles' short hash."""
|
|
1271
|
+
bundles = {}
|
|
1272
|
+
for rel in rels:
|
|
1273
|
+
bundles[Path(rel).name] = (player_dir / rel).read_bytes()
|
|
1274
|
+
if (player_dir / f"{rel}.map").is_file():
|
|
1275
|
+
bundles[Path(rel).name + ".map"] = (player_dir / f"{rel}.map").read_bytes()
|
|
1276
|
+
return bundles, hashlib.sha256(b"".join(bundles[Path(rel).name] for rel in rels)).hexdigest()[:16]
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def page_files(page_dir: Path, imports: dict[str, str], version: str,
|
|
1280
|
+
names: str = "PAGE_IMPORTS") -> dict[str, bytes]:
|
|
1281
|
+
"""The files of a player page directory; in its .html / .js files the module specifiers `imports` (`names`)
|
|
1282
|
+
point at the bundles copied next to the page and PLAYER_VERSION_MARKER is `version`."""
|
|
1283
|
+
page = {f.relative_to(page_dir).as_posix(): f.read_bytes() for f in sorted(page_dir.rglob("*")) if f.is_file()}
|
|
1284
|
+
for rel, data in page.items():
|
|
1285
|
+
if Path(rel).suffix not in (".html", ".js", ".mjs"):
|
|
1286
|
+
continue
|
|
1287
|
+
t = data.decode("utf-8")
|
|
1288
|
+
for src, dst in imports.items():
|
|
1289
|
+
for q in ('"', "'"):
|
|
1290
|
+
t = t.replace(f"{q}{src}{q}", f"{q}{dst}{q}")
|
|
1291
|
+
if re.search(r"""["'](?:\.\./)+src/""", t):
|
|
1292
|
+
raise RuntimeError(f"player page {rel} imports the player's sources beyond {names}")
|
|
1293
|
+
page[rel] = t.replace(PLAYER_VERSION_MARKER, version).encode("utf-8")
|
|
1294
|
+
return page
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def write_player(site: Path, player_dir: Path, stories: bool = False) -> dict:
|
|
1298
|
+
"""The player's page (every file of PLAYER_PAGE_DIR; in its .html / .js files the PAGE_IMPORTS specifiers point
|
|
1299
|
+
at the bundle and PLAYER_VERSION_MARKER is the bundles' short hash) and bundles (PLAYER_BUNDLES and their source
|
|
1300
|
+
maps) into the site root; when the player has the Live2D page, the same for LIVE2D_PAGE_DIR, LIVE2D_PAGE_IMPORTS
|
|
1301
|
+
and LIVE2D_BUNDLES into LIVE2D_PAGE_SITE_DIR; when it has the story page and its bundles, the same for
|
|
1302
|
+
STORY_PAGE_DIR, STORY_PAGE_IMPORTS and STORY_BUNDLES into STORY_PAGE_SITE_DIR (`stories`: the build adds stories,
|
|
1303
|
+
so a story page without its bundles is a ConfigError instead of being left out)."""
|
|
1304
|
+
player_dir = check_player(player_dir)
|
|
1305
|
+
bundles, version = player_bundles(player_dir, PLAYER_BUNDLES)
|
|
1306
|
+
page = page_files(player_dir / PLAYER_PAGE_DIR, PAGE_IMPORTS, version)
|
|
1307
|
+
files = {**page, **bundles}
|
|
1308
|
+
live2d = {}
|
|
1309
|
+
if (player_dir / LIVE2D_PAGE_DIR / "index.html").is_file():
|
|
1310
|
+
missing = [rel for rel in LIVE2D_BUNDLES if not (player_dir / rel).is_file()]
|
|
1311
|
+
if missing:
|
|
1312
|
+
raise ConfigError(f"{player_dir} has the Live2D page but not {', '.join(missing)} "
|
|
1313
|
+
f"(a checkout needs its build first)")
|
|
1314
|
+
live2d_bundles, live2d_version = player_bundles(player_dir, LIVE2D_BUNDLES)
|
|
1315
|
+
live2d = page_files(player_dir / LIVE2D_PAGE_DIR, LIVE2D_PAGE_IMPORTS, live2d_version, "LIVE2D_PAGE_IMPORTS")
|
|
1316
|
+
files.update({f"{LIVE2D_PAGE_SITE_DIR}/{r}": d for r, d in {**live2d, **live2d_bundles}.items()})
|
|
1317
|
+
story_page = {}
|
|
1318
|
+
if (player_dir / STORY_PAGE_DIR / "index.html").is_file():
|
|
1319
|
+
missing = [rel for rel in STORY_BUNDLES if not (player_dir / rel).is_file()]
|
|
1320
|
+
if missing and stories:
|
|
1321
|
+
raise ConfigError(f"{player_dir} has the story page but not {', '.join(missing)} "
|
|
1322
|
+
f"(a checkout needs its build first)")
|
|
1323
|
+
if not missing:
|
|
1324
|
+
story_bundles, story_version = player_bundles(player_dir, STORY_BUNDLES)
|
|
1325
|
+
story_page = page_files(player_dir / STORY_PAGE_DIR, STORY_PAGE_IMPORTS, story_version,
|
|
1326
|
+
"STORY_PAGE_IMPORTS")
|
|
1327
|
+
files.update({f"{STORY_PAGE_SITE_DIR}/{r}": d for r, d in {**story_page, **story_bundles}.items()})
|
|
1328
|
+
for rel, data in files.items():
|
|
1329
|
+
if rel.split("/", 1)[0] in SITE_DATA:
|
|
1330
|
+
raise RuntimeError(f"player page file {rel} would overwrite the site data")
|
|
1331
|
+
dst = Path(site) / rel
|
|
1332
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
1333
|
+
dst.write_bytes(data)
|
|
1334
|
+
out = {"playerVersion": version, "playerBytes": sum(map(len, bundles.values())), "pageFiles": len(page),
|
|
1335
|
+
"live2dPageFiles": len(live2d)}
|
|
1336
|
+
if story_page:
|
|
1337
|
+
out["storyPageFiles"] = len(story_page)
|
|
1338
|
+
return out
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def entry_text(site: Path, e: dict) -> bytes:
|
|
1342
|
+
"""The file text of a manifest entry (a split JSON file rejoined)."""
|
|
1343
|
+
if "parts" in e:
|
|
1344
|
+
return join_parts([(k, (site / a).read_bytes()) for k, a, _ in e["parts"]])
|
|
1345
|
+
return (site / e["asset"]).read_bytes()
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def reingest_json(site: Path) -> dict:
|
|
1349
|
+
"""Every JSON file of every chart and model stored again through text_asset (after a change of the stored-text
|
|
1350
|
+
rules); the manifests follow, unreferenced assets go with write_index."""
|
|
1351
|
+
store, changed, memo = Store(site), 0, {}
|
|
1352
|
+
for mf in [*chart_manifests(site), *sorted((site / MODELS_DIR).glob("*.json"))]:
|
|
1353
|
+
man = json.loads(mf.read_text(encoding="utf-8"))
|
|
1354
|
+
files = man["files"]
|
|
1355
|
+
for p, e in files.items():
|
|
1356
|
+
if not p.endswith(".json"):
|
|
1357
|
+
continue
|
|
1358
|
+
key = (p, json.dumps(e, sort_keys=True)) # charts share most files: each stored once
|
|
1359
|
+
if key not in memo:
|
|
1360
|
+
memo[key] = store.put_file(p, text_asset(p, entry_text(site, e).decode("utf-8")))
|
|
1361
|
+
ne = memo[key]
|
|
1362
|
+
if ne != e:
|
|
1363
|
+
files[p] = ne
|
|
1364
|
+
changed += 1
|
|
1365
|
+
mf.write_bytes(_dump(man))
|
|
1366
|
+
return {"changedEntries": changed}
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def index_meta(site: Path, charts: list[dict], language: str | None = None,
|
|
1370
|
+
regions: list[dict] | None = None) -> dict:
|
|
1371
|
+
"""The top-level keys of charts.json besides `charts`: `language` (the default listing language), `languages`
|
|
1372
|
+
(the languages of the entries' `titles`) and `regions` (id, name, languages; merged by id into those of the
|
|
1373
|
+
existing charts.json, first the ones it has); keys without a value are left out."""
|
|
1374
|
+
old = {}
|
|
1375
|
+
f = Path(site) / CHARTS_INDEX
|
|
1376
|
+
if f.is_file():
|
|
1377
|
+
try:
|
|
1378
|
+
old = json.loads(f.read_text(encoding="utf-8"))
|
|
1379
|
+
except ValueError:
|
|
1380
|
+
old = {}
|
|
1381
|
+
new = {r["id"]: r for r in regions or []}
|
|
1382
|
+
recs = [new.pop(r["id"], r) for r in old.get("regions") or [] if isinstance(r, dict) and "id" in r]
|
|
1383
|
+
recs += list(new.values())
|
|
1384
|
+
known = {c for e in charts for c in (e.get("titles") or {})}
|
|
1385
|
+
langs = [c for c in languages.LANGUAGES if c in known] + sorted(known - set(languages.LANGUAGES))
|
|
1386
|
+
meta = {"language": language or old.get("language"), "languages": langs, "regions": recs}
|
|
1387
|
+
return {k: v for k, v in meta.items() if v}
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def write_index(site: Path, language: str | None = None, regions: list[dict] | None = None) -> dict:
|
|
1391
|
+
"""site/charts.json from every chart manifest present (index_meta: `language`, `regions` of this build),
|
|
1392
|
+
site/models.json from every model manifest (webmodel.write_models_index) and site/stories.json from every story
|
|
1393
|
+
manifest (storysite.write_stories_index; its `language` and `regions` are those storysite.build wrote); assets
|
|
1394
|
+
no chart, no model and no story (common files and every language group) references removed."""
|
|
1395
|
+
from .storysite import write_stories_index
|
|
1396
|
+
from .webmodel import write_models_index
|
|
1397
|
+
order = {d: i for i, d in enumerate(DIFFICULTIES)}
|
|
1398
|
+
charts, used = [], set()
|
|
1399
|
+
(site / "assets").mkdir(parents=True, exist_ok=True)
|
|
1400
|
+
for p in chart_manifests(site):
|
|
1401
|
+
man = json.loads(p.read_text(encoding="utf-8"))
|
|
1402
|
+
for e in man["files"].values():
|
|
1403
|
+
used.update(entry_assets(e))
|
|
1404
|
+
entry = {"id": p.stem, "manifest": p.relative_to(site).as_posix(), "musicId": man["musicId"],
|
|
1405
|
+
"difficulty": man["difficulty"], "audio": man["audio"], "audioFormat": man.get("audioFormat"),
|
|
1406
|
+
"flows": man.get("flows"), "bytes": sum(f["size"] for f in man["files"].values()), **man["chart"]}
|
|
1407
|
+
if man.get("regions"):
|
|
1408
|
+
entry["regions"] = man["regions"]
|
|
1409
|
+
charts.append(entry)
|
|
1410
|
+
charts.sort(key=lambda c: (c["musicId"], order[c["difficulty"]], c["manifest"]))
|
|
1411
|
+
meta = index_meta(site, charts, language, regions)
|
|
1412
|
+
(site / CHARTS_INDEX).write_bytes(_dump({"format": SITE_FORMAT, **meta, "charts": charts}))
|
|
1413
|
+
models, model_assets = write_models_index(site)
|
|
1414
|
+
used |= model_assets
|
|
1415
|
+
stories, story_assets = write_stories_index(site)
|
|
1416
|
+
used |= story_assets
|
|
1417
|
+
removed = 0
|
|
1418
|
+
for f in (site / "assets").iterdir():
|
|
1419
|
+
if f"assets/{f.name}" not in used:
|
|
1420
|
+
f.unlink()
|
|
1421
|
+
removed += 1
|
|
1422
|
+
sizes = [f.stat().st_size for f in (site / "assets").iterdir()]
|
|
1423
|
+
out = {"charts": len(charts), "models": models, "assets": len(sizes), "assetBytes": sum(sizes),
|
|
1424
|
+
"removedAssets": removed}
|
|
1425
|
+
if stories:
|
|
1426
|
+
out["stories"] = stories
|
|
1427
|
+
return out
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
# ---------------------------------------------------------------- build
|
|
1431
|
+
def pipeline_window(workers: int, read_workers: int) -> int:
|
|
1432
|
+
"""Musics in flight in a build's Pipeline: one per extraction process, plus enough musics waiting for their read
|
|
1433
|
+
sets to keep every read-set slot busy (4 charts a music), at least 2 (each in-flight music keeps its live
|
|
1434
|
+
directories on disk until its ingest)."""
|
|
1435
|
+
return workers + max(2, -(-read_workers // 4))
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def _group_pairs(pairs, master: Path) -> list[tuple[int, str]]:
|
|
1439
|
+
"""The charts of a region group: `pairs` (None: every chart) that its master data has, in the given order."""
|
|
1440
|
+
have = all_pairs(master)
|
|
1441
|
+
if pairs is None:
|
|
1442
|
+
return have
|
|
1443
|
+
have = set(have)
|
|
1444
|
+
return [p for p in pairs if p in have]
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def _build_group(site: Path, tmp_root: Path, pairs, cfg: Config, region: str, prefix: str, regions: list[str],
|
|
1448
|
+
job: dict, force: bool, workers: int | None, log, read_workers: int | None = None,
|
|
1449
|
+
reads: "ReadSets | None" = None) -> tuple[list, list, int]:
|
|
1450
|
+
"""The charts `pairs` of one region group into charts/<prefix><id>.json (data of `region`); manifests that exist
|
|
1451
|
+
are skipped unless `force` and gain the group's `regions`. -> (results, skipped ids, workers)."""
|
|
1452
|
+
by_music: dict[int, list[str]] = {}
|
|
1453
|
+
skipped = []
|
|
1454
|
+
for music_id, difficulty in pairs:
|
|
1455
|
+
cid = f"{music_id}_{difficulty}"
|
|
1456
|
+
if manifest_file(site, cid, prefix).exists() and not force:
|
|
1457
|
+
add_regions(manifest_file(site, cid, prefix), regions)
|
|
1458
|
+
skipped.append(prefix + cid)
|
|
1459
|
+
continue
|
|
1460
|
+
ds = by_music.setdefault(music_id, [])
|
|
1461
|
+
if difficulty not in ds:
|
|
1462
|
+
ds.append(difficulty)
|
|
1463
|
+
for ds in by_music.values():
|
|
1464
|
+
ds.sort(key=DIFFICULTIES.index)
|
|
1465
|
+
cpus = usable_cpus()
|
|
1466
|
+
if workers is None:
|
|
1467
|
+
workers = max(1, min(8, len(by_music), cpus // 4))
|
|
1468
|
+
if read_workers is None:
|
|
1469
|
+
read_workers = max(1, min(16, 4 * len(by_music), cpus // 2))
|
|
1470
|
+
results = []
|
|
1471
|
+
if not by_music:
|
|
1472
|
+
return results, skipped, workers
|
|
1473
|
+
gdir = Path(tempfile.mkdtemp(prefix="global-", dir=tmp_root))
|
|
1474
|
+
try:
|
|
1475
|
+
cat, master, player = open_data(cfg, region)
|
|
1476
|
+
options = liveoptions.resolve(job.get("liveOptions"), master)
|
|
1477
|
+
livenotes.extract(cat, player, gdir, master=master, options=options)
|
|
1478
|
+
job = {**job, "livenotes": str(gdir / "livenotes"), "scenes": str(gdir / "scenes"), "region": region,
|
|
1479
|
+
"prefix": prefix, "regions": regions, "options": options}
|
|
1480
|
+
variants = options.read_variants()
|
|
1481
|
+
bands: dict[int, int] = {} # band -> a music of it (the scene is built once per band)
|
|
1482
|
+
for m in sorted(by_music):
|
|
1483
|
+
try:
|
|
1484
|
+
bands.setdefault(live.resolve_band(cat, master, m, band=job["band"],
|
|
1485
|
+
leader_card=job["leaderCard"])["band"], m)
|
|
1486
|
+
except Exception:
|
|
1487
|
+
pass # the music's extraction reports it
|
|
1488
|
+
log(f"{sum(len(ds) for ds in by_music.values())} charts of {len(by_music)} musics"
|
|
1489
|
+
f"{f' for {prefix[:-1]}' if prefix else ''}, {len(bands)} band scene(s); {workers} extraction "
|
|
1490
|
+
f"process(es) ({cri.default_workers()} stream decodes each), {read_workers} read set(s) at a time, "
|
|
1491
|
+
f"{pipeline_window(workers, read_workers)} musics in flight; caches "
|
|
1492
|
+
f"{cache.settings()['memory'] >> 20} MB per bucket, disk "
|
|
1493
|
+
f"{job.get('cache') or 'none'}")
|
|
1494
|
+
own_reads = reads is None
|
|
1495
|
+
reads = reads or ReadSets(Path(job["player"]), log)
|
|
1496
|
+
reads.expect([f"{m}_{d}" for m, ds in sorted(by_music.items()) for d in ds]) # the Pipeline's order
|
|
1497
|
+
log(reads.describe())
|
|
1498
|
+
if variants:
|
|
1499
|
+
reads.need_settings()
|
|
1500
|
+
log(f"live options: the read set of each chart adds {len(variants)} option variant(s)")
|
|
1501
|
+
|
|
1502
|
+
if workers <= 1:
|
|
1503
|
+
data = (cat, master, player)
|
|
1504
|
+
_W.update(cfg=job, scenes={})
|
|
1505
|
+
ex = ThreadPoolExecutor(1) # UnityPy stays on one thread
|
|
1506
|
+
call = {"band": lambda b, m: band_task(b, m, job, data),
|
|
1507
|
+
"extract": lambda m, ds: extract_task(m, ds, job, data),
|
|
1508
|
+
"ingest": lambda m, charts, root: ingest_task(m, charts, root, job)}
|
|
1509
|
+
submit = lambda name, *a: ex.submit(call[name], *a)
|
|
1510
|
+
ctx = None
|
|
1511
|
+
else:
|
|
1512
|
+
ctx = mp.get_context("spawn")
|
|
1513
|
+
mgr = ctx.Manager()
|
|
1514
|
+
ex = ProcessPoolExecutor(workers, mp_context=ctx, initializer=_worker_init,
|
|
1515
|
+
initargs=(cfg, mgr.Lock(), job))
|
|
1516
|
+
submit = lambda name, *a: ex.submit(_pool_call, f"{name}_task", *a)
|
|
1517
|
+
try:
|
|
1518
|
+
for b, f in [(b, submit("band", b, m)) for b, m in sorted(bands.items())]:
|
|
1519
|
+
try:
|
|
1520
|
+
f.result()
|
|
1521
|
+
except ConfigError:
|
|
1522
|
+
raise
|
|
1523
|
+
except Exception as e: # its musics export the scene whole
|
|
1524
|
+
log(f"band {b}: shared scene failed ({type(e).__name__}: {str(e)[:200]}); exported per music")
|
|
1525
|
+
shutil.rmtree(gdir / "scenes" / str(b), ignore_errors=True)
|
|
1526
|
+
with ThreadPoolExecutor(read_workers) as readers:
|
|
1527
|
+
shim = SimpleNamespace(submit=lambda fn, *a: submit({extract_task: "extract",
|
|
1528
|
+
ingest_task: "ingest"}[fn], *a))
|
|
1529
|
+
def read(live_dir, chart_id):
|
|
1530
|
+
return reads.read(live_dir, chart_id, variants)
|
|
1531
|
+
results = Pipeline(shim, readers, extract_task, read, ingest_task,
|
|
1532
|
+
lambda root: shutil.rmtree(root, ignore_errors=True),
|
|
1533
|
+
window=pipeline_window(workers, read_workers), log=log
|
|
1534
|
+
).run(sorted(by_music.items()))
|
|
1535
|
+
finally:
|
|
1536
|
+
ex.shutdown()
|
|
1537
|
+
if ctx is not None:
|
|
1538
|
+
mgr.shutdown()
|
|
1539
|
+
if own_reads:
|
|
1540
|
+
reads.close()
|
|
1541
|
+
finally:
|
|
1542
|
+
shutil.rmtree(gdir, ignore_errors=True)
|
|
1543
|
+
for r in results:
|
|
1544
|
+
if prefix:
|
|
1545
|
+
r["id"] = prefix + r["id"]
|
|
1546
|
+
return results, skipped, workers
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def build(out_dir, pairs, cfg: Config, player_dir: Path, audio_format: str = DEFAULT_AUDIO_FORMAT,
|
|
1550
|
+
audio: bool = True, force: bool = False, *, tmp_dir=None, log=None, workers: int | None = None,
|
|
1551
|
+
band: int | None = None, leader_card: int | None = None, regions: list[str] | None = None,
|
|
1552
|
+
fonts: str = "open", read_workers: int | None = None, live_options: dict | None = None) -> dict:
|
|
1553
|
+
"""Add the charts `pairs` ([(musicId, difficulty)]; None: every chart of every region's master data) to the site
|
|
1554
|
+
at `out_dir`, with the player of the ournotes-player checkout or package at `player_dir`. The data comes from the
|
|
1555
|
+
settings `cfg` (each worker process opens its own). `regions`: the regions the charts serve (default: the one
|
|
1556
|
+
[catalog] region), grouped by chart inputs (module docstring); the first region's group writes charts/<id>.json,
|
|
1557
|
+
the others charts/<region>/<id>.json unless their files equal the shared manifest's. `workers`: parallel music
|
|
1558
|
+
processes (default a quarter of the CPUs, up to 8); `read_workers`: read sets at a time (default half the CPUs,
|
|
1559
|
+
up to 16). `band` / `leader_card`: the band of every chart's stage, as for live.build (default: the band of the
|
|
1560
|
+
music's first vocal character); `fonts`: the start canvas fonts (liveui.extract, with WEB_LIVEUI);
|
|
1561
|
+
`live_options`: the Live option variants the charts offer (a liveoptions.parse_specs request; checked against
|
|
1562
|
+
every region's master data before anything is built, liveoptions.OptionSpecError)."""
|
|
1563
|
+
player_dir = check_player(player_dir)
|
|
1564
|
+
if audio_format not in WEB_AUDIO:
|
|
1565
|
+
raise ValueError(f"audio format {audio_format}: one of {', '.join(WEB_AUDIO)}")
|
|
1566
|
+
if pairs is not None:
|
|
1567
|
+
pairs = list(dict.fromkeys((int(m), d) for m, d in pairs))
|
|
1568
|
+
bad = [d for _, d in pairs if d not in DIFFICULTIES]
|
|
1569
|
+
if bad:
|
|
1570
|
+
raise ValueError(f"difficulty {bad[0]}")
|
|
1571
|
+
regions = site_regions(cfg, regions)
|
|
1572
|
+
language = languages.check(cfg.require("catalog", "language"))
|
|
1573
|
+
masters = region_masters(cfg, regions)
|
|
1574
|
+
for m in masters.values():
|
|
1575
|
+
liveoptions.resolve(live_options, m)
|
|
1576
|
+
groups = region_groups(masters)
|
|
1577
|
+
site = Path(out_dir).resolve()
|
|
1578
|
+
(site / "charts").mkdir(parents=True, exist_ok=True)
|
|
1579
|
+
Store(site)
|
|
1580
|
+
tmp_root = Path(tmp_dir).resolve() if tmp_dir else site.parent / f"{site.name}.tmp"
|
|
1581
|
+
tmp_root.mkdir(parents=True, exist_ok=True)
|
|
1582
|
+
log = log or _log
|
|
1583
|
+
t0 = time.time()
|
|
1584
|
+
job = {"site": str(site), "tmp": str(tmp_root), "audioFormat": audio_format, "audio": bool(audio),
|
|
1585
|
+
"player": str(player_dir), "language": language, "fonts": fonts, "band": band, "leaderCard": leader_card,
|
|
1586
|
+
"cache": str(tmp_root / "cache"), "build": uuid.uuid4().hex, "liveOptions": live_options or None}
|
|
1587
|
+
configure_caches(job)
|
|
1588
|
+
results, skipped, folded, used_workers = [], [], [], 1
|
|
1589
|
+
offered = set()
|
|
1590
|
+
reads = ReadSets(player_dir, log)
|
|
1591
|
+
try:
|
|
1592
|
+
for gi, group in enumerate(groups):
|
|
1593
|
+
rep = group[0]
|
|
1594
|
+
prefix = "" if gi == 0 else f"{rep}/"
|
|
1595
|
+
gpairs = _group_pairs(pairs, masters[rep])
|
|
1596
|
+
offered.update(gpairs)
|
|
1597
|
+
rs, sk, w = _build_group(site, tmp_root, gpairs, cfg, rep, prefix, group, job, force, workers, log,
|
|
1598
|
+
read_workers, reads)
|
|
1599
|
+
results += rs
|
|
1600
|
+
skipped += sk
|
|
1601
|
+
used_workers = max(used_workers, w)
|
|
1602
|
+
if prefix:
|
|
1603
|
+
folded += [prefix + f"{m}_{d}" for m, d in gpairs if fold_variant(site, f"{m}_{d}", prefix)]
|
|
1604
|
+
finally:
|
|
1605
|
+
reads.close()
|
|
1606
|
+
for m, d in pairs or []:
|
|
1607
|
+
if (m, d) not in offered:
|
|
1608
|
+
results.append({"id": f"{m}_{d}", "ok": False, "stage": "master",
|
|
1609
|
+
"error": f"no MasterLiveMusicScore row for {m}_{d} in the master data of "
|
|
1610
|
+
f"{', '.join(regions)}"})
|
|
1611
|
+
v = write_player(site, player_dir)
|
|
1612
|
+
idx = write_index(site, language, region_meta(cfg, regions))
|
|
1613
|
+
failed = [r for r in results if not r["ok"]]
|
|
1614
|
+
scenes: dict[str, int] = {} # how the charts' scenes were made (band part / whole)
|
|
1615
|
+
for r in results:
|
|
1616
|
+
if r["ok"]:
|
|
1617
|
+
scenes[r.get("scene") or "?"] = scenes.get(r.get("scene") or "?", 0) + 1
|
|
1618
|
+
if failed:
|
|
1619
|
+
(site.parent / f"{site.name}.failures.json").write_bytes(_dump(sorted(failed, key=lambda r: r["id"])))
|
|
1620
|
+
return {"site": str(site), "built": [{k: r[k] for k in ("id", "files", "bytes", "flows")} for r in results if r["ok"]],
|
|
1621
|
+
"failed": [{k: r.get(k) for k in ("id", "stage", "error")} for r in failed], "skipped": skipped,
|
|
1622
|
+
"regions": regions, "regionGroups": groups, "foldedRegionManifests": folded, "scenes": scenes,
|
|
1623
|
+
"readSets": reads.summary(),
|
|
1624
|
+
"seconds": round(time.time() - t0, 1), "workers": used_workers, **v, **idx}
|