cuju-render 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (382) hide show
  1. cuju/render/__init__.py +759 -0
  2. cuju/render/__main__.py +1065 -0
  3. cuju/render/_export/__init__.py +3 -0
  4. cuju/render/_export/feed.py +542 -0
  5. cuju/render/_export/sizes.py +157 -0
  6. cuju/render/_export/video.py +1618 -0
  7. cuju/render/_export/video_jobs.py +408 -0
  8. cuju/render/_format/__init__.py +3 -0
  9. cuju/render/_format/annotations.py +235 -0
  10. cuju/render/_format/codec.py +307 -0
  11. cuju/render/_format/keep.py +121 -0
  12. cuju/render/_format/recording.py +860 -0
  13. cuju/render/_format/writer.py +54 -0
  14. cuju/render/_guide/__init__.py +3 -0
  15. cuju/render/_guide/doctor.py +523 -0
  16. cuju/render/_guide/help.py +62 -0
  17. cuju/render/_guide/tutorial.py +254 -0
  18. cuju/render/_http/__init__.py +3 -0
  19. cuju/render/_http/commands.py +57 -0
  20. cuju/render/_http/headers.py +103 -0
  21. cuju/render/_http/launch.py +500 -0
  22. cuju/render/_http/launchers.py +408 -0
  23. cuju/render/_http/pwa.py +128 -0
  24. cuju/render/_http/server.py +2105 -0
  25. cuju/render/_http/window.py +360 -0
  26. cuju/render/_moments/__init__.py +3 -0
  27. cuju/render/_moments/captions.py +284 -0
  28. cuju/render/_moments/events.py +1123 -0
  29. cuju/render/_moments/search.py +694 -0
  30. cuju/render/_packages/__init__.py +3 -0
  31. cuju/render/_packages/glsl.py +746 -0
  32. cuju/render/_packages/loudness.py +334 -0
  33. cuju/render/_packages/pack.py +1959 -0
  34. cuju/render/_packages/presentation.py +1376 -0
  35. cuju/render/_packages/vocabulary.py +609 -0
  36. cuju/render/_playing/__init__.py +3 -0
  37. cuju/render/_playing/achievements.py +463 -0
  38. cuju/render/_playing/bench.py +609 -0
  39. cuju/render/_playing/challenges.py +1813 -0
  40. cuju/render/_playing/demo.py +323 -0
  41. cuju/render/_playing/draw.py +211 -0
  42. cuju/render/_playing/errors.py +20 -0
  43. cuju/render/_playing/game.py +645 -0
  44. cuju/render/_playing/limits.py +339 -0
  45. cuju/render/_playing/play.py +926 -0
  46. cuju/render/_playing/policy_worker.py +250 -0
  47. cuju/render/_playing/strict.py +810 -0
  48. cuju/render/_registry/__init__.py +3 -0
  49. cuju/render/_registry/clips.py +394 -0
  50. cuju/render/_registry/joints.py +108 -0
  51. cuju/render/_registry/teams.py +2058 -0
  52. cuju/render/_store/__init__.py +3 -0
  53. cuju/render/_store/home.py +70 -0
  54. cuju/render/_store/library.py +604 -0
  55. cuju/render/_store/samples.py +163 -0
  56. cuju/render/_text/__init__.py +3 -0
  57. cuju/render/_text/languages.py +637 -0
  58. cuju/render/_text/messages.py +1396 -0
  59. cuju/render/_tournaments/__init__.py +3 -0
  60. cuju/render/_tournaments/results.py +547 -0
  61. cuju/render/_tournaments/tournament.py +2742 -0
  62. cuju/render/_tournaments/tournament_cli.py +587 -0
  63. cuju/render/_tournaments/tournament_runner.py +1975 -0
  64. cuju/render/_version.py +6 -0
  65. cuju/render/_watch/__init__.py +3 -0
  66. cuju/render/_watch/grid.py +265 -0
  67. cuju/render/_watch/live.py +664 -0
  68. cuju/render/_watch/notebook.py +79 -0
  69. cuju/render/_watch/show.py +171 -0
  70. cuju/render/samples/sample-11v11.cjr +0 -0
  71. cuju/render/samples/sample-5v5.cjr +0 -0
  72. cuju/render/samples/teams/starter/main.py +33 -0
  73. cuju/render/scenarios/benchmark_full_game_11_vs_11_easy.py +46 -0
  74. cuju/render/scenarios/benchmark_full_game_11_vs_11_hard.py +46 -0
  75. cuju/render/scenarios/benchmark_full_game_11_vs_11_medium.py +46 -0
  76. cuju/render/scenarios/benchmark_full_game_5_vs_5_easy.py +34 -0
  77. cuju/render/scenarios/benchmark_full_game_5_vs_5_hard.py +34 -0
  78. cuju/render/scenarios/benchmark_full_game_5_vs_5_medium.py +34 -0
  79. cuju/render/web/assets/adboard_polygon.webp +0 -0
  80. cuju/render/web/assets/adboard_your_ad_here.webp +0 -0
  81. cuju/render/web/assets/animations/kaykit-rig-medium-combatmelee.glb +0 -0
  82. cuju/render/web/assets/animations/kaykit-rig-medium-general.glb +0 -0
  83. cuju/render/web/assets/animations/kaykit-rig-medium-movementadvanced.glb +0 -0
  84. cuju/render/web/assets/animations/kaykit-rig-medium-movementbasic.glb +0 -0
  85. cuju/render/web/assets/animations/kaykit-rig-medium-simulation.glb +0 -0
  86. cuju/render/web/assets/animations/kaykit-rig-medium-special.glb +0 -0
  87. cuju/render/web/assets/ball.glb +0 -0
  88. cuju/render/web/assets/challenges/3_vs_1_with_keeper.mp4 +0 -0
  89. cuju/render/web/assets/challenges/3_vs_1_with_keeper.webp +0 -0
  90. cuju/render/web/assets/challenges/corner.mp4 +0 -0
  91. cuju/render/web/assets/challenges/corner.webp +0 -0
  92. cuju/render/web/assets/challenges/counterattack_easy.mp4 +0 -0
  93. cuju/render/web/assets/challenges/counterattack_easy.webp +0 -0
  94. cuju/render/web/assets/challenges/counterattack_hard.mp4 +0 -0
  95. cuju/render/web/assets/challenges/counterattack_hard.webp +0 -0
  96. cuju/render/web/assets/challenges/empty_goal.mp4 +0 -0
  97. cuju/render/web/assets/challenges/empty_goal.webp +0 -0
  98. cuju/render/web/assets/challenges/empty_goal_close.mp4 +0 -0
  99. cuju/render/web/assets/challenges/empty_goal_close.webp +0 -0
  100. cuju/render/web/assets/challenges/manifest.json +375 -0
  101. cuju/render/web/assets/challenges/one_on_one.mp4 +0 -0
  102. cuju/render/web/assets/challenges/one_on_one.webp +0 -0
  103. cuju/render/web/assets/challenges/one_on_one_hard.mp4 +0 -0
  104. cuju/render/web/assets/challenges/one_on_one_hard.webp +0 -0
  105. cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.mp4 +0 -0
  106. cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.webp +0 -0
  107. cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.mp4 +0 -0
  108. cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.webp +0 -0
  109. cuju/render/web/assets/challenges/run_to_score.mp4 +0 -0
  110. cuju/render/web/assets/challenges/run_to_score.webp +0 -0
  111. cuju/render/web/assets/challenges/run_to_score_with_keeper.mp4 +0 -0
  112. cuju/render/web/assets/challenges/run_to_score_with_keeper.webp +0 -0
  113. cuju/render/web/assets/challenges/single_goal_versus_lazy.mp4 +0 -0
  114. cuju/render/web/assets/challenges/single_goal_versus_lazy.webp +0 -0
  115. cuju/render/web/assets/crowd.webp +0 -0
  116. cuju/render/web/assets/crowd_normal.webp +0 -0
  117. cuju/render/web/assets/cuju-logo-384.png +0 -0
  118. cuju/render/web/assets/cuju-logo.png +0 -0
  119. cuju/render/web/assets/cuju-logo.svg +46 -0
  120. cuju/render/web/assets/cuju-mark-light.svg +33 -0
  121. cuju/render/web/assets/cuju-mark.svg +33 -0
  122. cuju/render/web/assets/goal.glb +0 -0
  123. cuju/render/web/assets/grass.webp +0 -0
  124. cuju/render/web/assets/hair_black.png +0 -0
  125. cuju/render/web/assets/hair_blonde.png +0 -0
  126. cuju/render/web/assets/hair_brown.png +0 -0
  127. cuju/render/web/assets/hair_darkblonde.png +0 -0
  128. cuju/render/web/assets/hair_long01.glb +0 -0
  129. cuju/render/web/assets/hair_long02.glb +0 -0
  130. cuju/render/web/assets/hair_medium01.glb +0 -0
  131. cuju/render/web/assets/hair_red.png +0 -0
  132. cuju/render/web/assets/hair_short01.glb +0 -0
  133. cuju/render/web/assets/hair_short02.glb +0 -0
  134. cuju/render/web/assets/icons/cuju-192.png +0 -0
  135. cuju/render/web/assets/icons/cuju-512.png +0 -0
  136. cuju/render/web/assets/icons/cuju-maskable-512.png +0 -0
  137. cuju/render/web/assets/kit_goalkeeper.webp +0 -0
  138. cuju/render/web/assets/kit_mask.png +0 -0
  139. cuju/render/web/assets/kit_normal.webp +0 -0
  140. cuju/render/web/assets/manifest.json +601 -0
  141. cuju/render/web/assets/models/kaykit-barbarian.glb +0 -0
  142. cuju/render/web/assets/models/kaykit-barbarian.json +23 -0
  143. cuju/render/web/assets/models/kaykit-knight.glb +0 -0
  144. cuju/render/web/assets/models/kaykit-knight.json +23 -0
  145. cuju/render/web/assets/models/kaykit-mage.glb +0 -0
  146. cuju/render/web/assets/models/kaykit-mage.json +23 -0
  147. cuju/render/web/assets/models/kaykit-mannequin-medium.glb +0 -0
  148. cuju/render/web/assets/models/kaykit-mannequin-medium.json +23 -0
  149. cuju/render/web/assets/models/kaykit-ranger.glb +0 -0
  150. cuju/render/web/assets/models/kaykit-ranger.json +23 -0
  151. cuju/render/web/assets/models/kaykit-rogue-hooded.glb +0 -0
  152. cuju/render/web/assets/models/kaykit-rogue-hooded.json +23 -0
  153. cuju/render/web/assets/models/kaykit-rogue.glb +0 -0
  154. cuju/render/web/assets/models/kaykit-rogue.json +23 -0
  155. cuju/render/web/assets/models/kaykit-skeleton-mage.glb +0 -0
  156. cuju/render/web/assets/models/kaykit-skeleton-mage.json +23 -0
  157. cuju/render/web/assets/models/kaykit-skeleton-minion.glb +0 -0
  158. cuju/render/web/assets/models/kaykit-skeleton-minion.json +23 -0
  159. cuju/render/web/assets/models/kaykit-skeleton-rogue.glb +0 -0
  160. cuju/render/web/assets/models/kaykit-skeleton-rogue.json +23 -0
  161. cuju/render/web/assets/models/kaykit-skeleton-warrior.glb +0 -0
  162. cuju/render/web/assets/models/kaykit-skeleton-warrior.json +23 -0
  163. cuju/render/web/assets/pitch.webp +0 -0
  164. cuju/render/web/assets/pitch_normal.webp +0 -0
  165. cuju/render/web/assets/player.glb +0 -0
  166. cuju/render/web/assets/shoe.webp +0 -0
  167. cuju/render/web/assets/shoe_sole.png +0 -0
  168. cuju/render/web/assets/skin_01.png +0 -0
  169. cuju/render/web/assets/skin_02.png +0 -0
  170. cuju/render/web/assets/skin_03.png +0 -0
  171. cuju/render/web/assets/skin_04.png +0 -0
  172. cuju/render/web/assets/sounds/applause.ogg +0 -0
  173. cuju/render/web/assets/sounds/beach-wave-1.ogg +0 -0
  174. cuju/render/web/assets/sounds/beach-wave-2.ogg +0 -0
  175. cuju/render/web/assets/sounds/beach-wave-3.ogg +0 -0
  176. cuju/render/web/assets/sounds/beach-wave-4.ogg +0 -0
  177. cuju/render/web/assets/sounds/city-horn.ogg +0 -0
  178. cuju/render/web/assets/sounds/city-loop.ogg +0 -0
  179. cuju/render/web/assets/sounds/city-siren.ogg +0 -0
  180. cuju/render/web/assets/sounds/crowd-cheer.ogg +0 -0
  181. cuju/render/web/assets/sounds/crowd-loop.ogg +0 -0
  182. cuju/render/web/assets/sounds/crowd-ooh.ogg +0 -0
  183. cuju/render/web/assets/sounds/gulls.ogg +0 -0
  184. cuju/render/web/assets/sounds/neon-sting.ogg +0 -0
  185. cuju/render/web/assets/sounds/rain-loop.ogg +0 -0
  186. cuju/render/web/assets/sounds/sounds.json +46 -0
  187. cuju/render/web/assets/sounds/space-beep.ogg +0 -0
  188. cuju/render/web/assets/sounds/space-hum.ogg +0 -0
  189. cuju/render/web/assets/sounds/space-radio.ogg +0 -0
  190. cuju/render/web/assets/sounds/whistle-long.ogg +0 -0
  191. cuju/render/web/assets/sounds/whistle-short.ogg +0 -0
  192. cuju/render/web/assets/sounds/wind-loop.ogg +0 -0
  193. cuju/render/web/easy.css +357 -0
  194. cuju/render/web/export.html +23 -0
  195. cuju/render/web/feed.css +262 -0
  196. cuju/render/web/feed.html +28 -0
  197. cuju/render/web/flat.css +120 -0
  198. cuju/render/web/home.css +837 -0
  199. cuju/render/web/index.html +373 -0
  200. cuju/render/web/js/boot.js +50 -0
  201. cuju/render/web/js/broadcast/audio.js +2141 -0
  202. cuju/render/web/js/broadcast/celebrate.js +1238 -0
  203. cuju/render/web/js/broadcast/decisions.js +609 -0
  204. cuju/render/web/js/broadcast/hud.js +352 -0
  205. cuju/render/web/js/broadcast/show.js +3614 -0
  206. cuju/render/web/js/broadcast/transition.js +2231 -0
  207. cuju/render/web/js/broadcast/voices.js +153 -0
  208. cuju/render/web/js/capture.js +101 -0
  209. cuju/render/web/js/core/accessibility.js +90 -0
  210. cuju/render/web/js/core/api.js +429 -0
  211. cuju/render/web/js/core/comfort.js +135 -0
  212. cuju/render/web/js/core/i18n.js +705 -0
  213. cuju/render/web/js/core/icons.js +167 -0
  214. cuju/render/web/js/core/input-settings.js +156 -0
  215. cuju/render/web/js/core/keys.js +277 -0
  216. cuju/render/web/js/core/language-ui.js +125 -0
  217. cuju/render/web/js/core/menu.js +853 -0
  218. cuju/render/web/js/core/names.js +113 -0
  219. cuju/render/web/js/core/page-structure.js +110 -0
  220. cuju/render/web/js/core/scenarios.js +109 -0
  221. cuju/render/web/js/core/spoilers.js +236 -0
  222. cuju/render/web/js/core/typeahead.js +38 -0
  223. cuju/render/web/js/export/dom-picture.js +330 -0
  224. cuju/render/web/js/export/export-host.js +97 -0
  225. cuju/render/web/js/export/feed-dialog.js +191 -0
  226. cuju/render/web/js/export/feed-params.js +266 -0
  227. cuju/render/web/js/export/feed.js +363 -0
  228. cuju/render/web/js/export/mp4-mux.js +295 -0
  229. cuju/render/web/js/export/photo-core.js +313 -0
  230. cuju/render/web/js/export/photo.js +1314 -0
  231. cuju/render/web/js/export/safe-frame.js +59 -0
  232. cuju/render/web/js/export/size-fields.js +103 -0
  233. cuju/render/web/js/export/sizes.js +178 -0
  234. cuju/render/web/js/export/video-boot.js +55 -0
  235. cuju/render/web/js/export/video-browser.js +325 -0
  236. cuju/render/web/js/export/video-export.js +417 -0
  237. cuju/render/web/js/export/video-plan.js +207 -0
  238. cuju/render/web/js/export/video-range.js +185 -0
  239. cuju/render/web/js/export/webm-mux.js +198 -0
  240. cuju/render/web/js/feed-boot.js +151 -0
  241. cuju/render/web/js/feed-frame.js +101 -0
  242. cuju/render/web/js/help/doctor.js +488 -0
  243. cuju/render/web/js/help/firstrun.js +379 -0
  244. cuju/render/web/js/help/help-center.js +438 -0
  245. cuju/render/web/js/help/install-offer.js +164 -0
  246. cuju/render/web/js/help/install-state.js +104 -0
  247. cuju/render/web/js/help/lessons-core.js +207 -0
  248. cuju/render/web/js/help/lessons.js +463 -0
  249. cuju/render/web/js/help/palette.js +584 -0
  250. cuju/render/web/js/help/pwa.js +257 -0
  251. cuju/render/web/js/help/tour-core.js +241 -0
  252. cuju/render/web/js/help/tour.js +461 -0
  253. cuju/render/web/js/main.js +2513 -0
  254. cuju/render/web/js/pages/draw.js +188 -0
  255. cuju/render/web/js/pages/fieldpage.js +530 -0
  256. cuju/render/web/js/pages/home.js +521 -0
  257. cuju/render/web/js/pages/matches.js +779 -0
  258. cuju/render/web/js/pages/moments-ui.js +359 -0
  259. cuju/render/web/js/pages/queue-filters.js +63 -0
  260. cuju/render/web/js/pages/recording-groups.js +80 -0
  261. cuju/render/web/js/pages/recordings.js +1069 -0
  262. cuju/render/web/js/play/bench.js +347 -0
  263. cuju/render/web/js/play/challenges.js +1302 -0
  264. cuju/render/web/js/play/medals.js +127 -0
  265. cuju/render/web/js/play/play.js +1953 -0
  266. cuju/render/web/js/play/teasers.js +196 -0
  267. cuju/render/web/js/play/touch.js +350 -0
  268. cuju/render/web/js/play/tournament-layout.js +487 -0
  269. cuju/render/web/js/play/tournament-results.js +441 -0
  270. cuju/render/web/js/play/tournament-strict.js +560 -0
  271. cuju/render/web/js/play/tournament.js +2585 -0
  272. cuju/render/web/js/players/animate.js +972 -0
  273. cuju/render/web/js/players/clips.js +224 -0
  274. cuju/render/web/js/players/kaykit.js +162 -0
  275. cuju/render/web/js/players/kits.js +676 -0
  276. cuju/render/web/js/players/number-patch.js +82 -0
  277. cuju/render/web/js/players/players.js +2694 -0
  278. cuju/render/web/js/players/recolor.js +893 -0
  279. cuju/render/web/js/players/retarget.js +325 -0
  280. cuju/render/web/js/players/rig-numbers.js +174 -0
  281. cuju/render/web/js/presentation/animator.js +231 -0
  282. cuju/render/web/js/presentation/assign.js +85 -0
  283. cuju/render/web/js/presentation/behaviors.js +440 -0
  284. cuju/render/web/js/presentation/budget.js +209 -0
  285. cuju/render/web/js/presentation/catalog.js +1674 -0
  286. cuju/render/web/js/presentation/clocks.js +72 -0
  287. cuju/render/web/js/presentation/commands.js +301 -0
  288. cuju/render/web/js/presentation/contract.js +240 -0
  289. cuju/render/web/js/presentation/effects.js +904 -0
  290. cuju/render/web/js/presentation/flash.js +605 -0
  291. cuju/render/web/js/presentation/freeze-worklet.js +22 -0
  292. cuju/render/web/js/presentation/glsl.js +464 -0
  293. cuju/render/web/js/presentation/host.js +323 -0
  294. cuju/render/web/js/presentation/index.js +2066 -0
  295. cuju/render/web/js/presentation/loudness-worklet.js +235 -0
  296. cuju/render/web/js/presentation/loudness.js +740 -0
  297. cuju/render/web/js/presentation/moments.js +536 -0
  298. cuju/render/web/js/presentation/post.js +327 -0
  299. cuju/render/web/js/presentation/preview.js +180 -0
  300. cuju/render/web/js/presentation/protocol.js +174 -0
  301. cuju/render/web/js/presentation/reach.js +158 -0
  302. cuju/render/web/js/presentation/rigging.js +130 -0
  303. cuju/render/web/js/presentation/sandbox.js +157 -0
  304. cuju/render/web/js/presentation/shaders.js +235 -0
  305. cuju/render/web/js/presentation/stage.js +402 -0
  306. cuju/render/web/js/presentation/teamaudio.js +722 -0
  307. cuju/render/web/js/presentation/tracks.js +197 -0
  308. cuju/render/web/js/presentation/values.js +81 -0
  309. cuju/render/web/js/recording/annotation-data.js +203 -0
  310. cuju/render/web/js/recording/captions.js +491 -0
  311. cuju/render/web/js/recording/codec.js +185 -0
  312. cuju/render/web/js/recording/decode-worker.js +70 -0
  313. cuju/render/web/js/recording/decode.js +94 -0
  314. cuju/render/web/js/recording/episodes.js +46 -0
  315. cuju/render/web/js/recording/format.js +461 -0
  316. cuju/render/web/js/recording/live.js +348 -0
  317. cuju/render/web/js/recording/moments-query.js +59 -0
  318. cuju/render/web/js/recording/moments.js +149 -0
  319. cuju/render/web/js/recording/pose.js +86 -0
  320. cuju/render/web/js/recording/timeline.js +275 -0
  321. cuju/render/web/js/teams/art.js +50 -0
  322. cuju/render/web/js/teams/clip-editor.js +293 -0
  323. cuju/render/web/js/teams/editor.js +1979 -0
  324. cuju/render/web/js/teams/library.js +344 -0
  325. cuju/render/web/js/teams/painter.js +633 -0
  326. cuju/render/web/js/teams/surprise-ui.js +195 -0
  327. cuju/render/web/js/teams/surprise.js +497 -0
  328. cuju/render/web/js/teams/team-check.js +127 -0
  329. cuju/render/web/js/watch/annotations.js +270 -0
  330. cuju/render/web/js/watch/flat.js +366 -0
  331. cuju/render/web/js/watch/grid.js +436 -0
  332. cuju/render/web/js/watch/pitch2d.js +289 -0
  333. cuju/render/web/js/watch/player-tools.js +192 -0
  334. cuju/render/web/js/watch/presets.js +201 -0
  335. cuju/render/web/js/world/arenas.js +997 -0
  336. cuju/render/web/js/world/ball.js +204 -0
  337. cuju/render/web/js/world/cameras.js +982 -0
  338. cuju/render/web/js/world/crowd.js +376 -0
  339. cuju/render/web/js/world/field.js +227 -0
  340. cuju/render/web/js/world/framing.js +145 -0
  341. cuju/render/web/js/world/occlusion.js +48 -0
  342. cuju/render/web/js/world/pitch.js +703 -0
  343. cuju/render/web/js/world/scene.js +395 -0
  344. cuju/render/web/js/world/sky.js +325 -0
  345. cuju/render/web/js/world/stadium.js +1262 -0
  346. cuju/render/web/js/world/vendor-sky.js +367 -0
  347. cuju/render/web/js/world/weather.js +264 -0
  348. cuju/render/web/languages/en.json +4750 -0
  349. cuju/render/web/lessons.json +137 -0
  350. cuju/render/web/manifest.webmanifest +23 -0
  351. cuju/render/web/style.css +8399 -0
  352. cuju/render/web/sw.js +235 -0
  353. cuju/render/web/vendor/fabric/LICENSE +16 -0
  354. cuju/render/web/vendor/fabric/fabric.min.mjs +1 -0
  355. cuju/render/web/vendor/lucide/LICENSE +43 -0
  356. cuju/render/web/vendor/lucide/icons.js +141 -0
  357. cuju/render/web/vendor/three/LICENSE +21 -0
  358. cuju/render/web/vendor/three/build/three.core.js +60586 -0
  359. cuju/render/web/vendor/three/build/three.module.js +19719 -0
  360. cuju/render/web/vendor/three/examples/jsm/loaders/GLTFLoader.js +4925 -0
  361. cuju/render/web/vendor/three/examples/jsm/postprocessing/EffectComposer.js +365 -0
  362. cuju/render/web/vendor/three/examples/jsm/postprocessing/MaskPass.js +195 -0
  363. cuju/render/web/vendor/three/examples/jsm/postprocessing/OutputPass.js +148 -0
  364. cuju/render/web/vendor/three/examples/jsm/postprocessing/Pass.js +191 -0
  365. cuju/render/web/vendor/three/examples/jsm/postprocessing/RenderPass.js +193 -0
  366. cuju/render/web/vendor/three/examples/jsm/postprocessing/ShaderPass.js +135 -0
  367. cuju/render/web/vendor/three/examples/jsm/postprocessing/UnrealBloomPass.js +541 -0
  368. cuju/render/web/vendor/three/examples/jsm/shaders/CopyShader.js +52 -0
  369. cuju/render/web/vendor/three/examples/jsm/shaders/LuminosityHighPassShader.js +68 -0
  370. cuju/render/web/vendor/three/examples/jsm/shaders/OutputShader.js +103 -0
  371. cuju/render/web/vendor/three/examples/jsm/utils/BufferGeometryUtils.js +1501 -0
  372. cuju/render/web/vendor/three/examples/jsm/utils/SkeletonUtils.js +496 -0
  373. cuju_render-0.1.0.dist-info/METADATA +116 -0
  374. cuju_render-0.1.0.dist-info/RECORD +382 -0
  375. cuju_render-0.1.0.dist-info/WHEEL +4 -0
  376. cuju_render-0.1.0.dist-info/licenses/LICENSE +202 -0
  377. cuju_render-0.1.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +396 -0
  378. cuju_render-0.1.0.dist-info/licenses/LICENSES/CC0-1.0.txt +127 -0
  379. cuju_render-0.1.0.dist-info/licenses/LICENSES/ISC.txt +16 -0
  380. cuju_render-0.1.0.dist-info/licenses/LICENSES/MIT.txt +24 -0
  381. cuju_render-0.1.0.dist-info/licenses/LICENSES/Unlicense.txt +13 -0
  382. cuju_render-0.1.0.dist-info/licenses/NOTICE +35 -0
@@ -0,0 +1,860 @@
1
+ # Copyright 2026 The cuju Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Turns GRF states into the frames of a recording, and writes and reads recording files.
5
+
6
+ A frame holds what a viewer draws for one step, the world transform of every joint of every player,
7
+ the ball, the score, the clock and the game mode, all in the frame of the pitch. The format is in
8
+ the repository's `docs/rendering.md`, under Recording format.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import functools
14
+ import gzip
15
+ import json
16
+ import math
17
+ import struct
18
+ import zlib
19
+ from collections.abc import Iterator
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import numpy as np
24
+
25
+ from cuju import __version__
26
+ from cuju import state as _state
27
+ from cuju.errors import StateError
28
+
29
+ from .._text import messages as _messages
30
+ from . import annotations as _annotations
31
+ from . import codec as _codec
32
+
33
+ MAGIC = b'CUJREC1\n'
34
+ # The version that `write` gives a recording by default. Version 1 stores the frames one after
35
+ # another, and version 2 stores them coded in sections, about a third of the size.
36
+ VERSION = 2
37
+ VERSIONS = (1, 2)
38
+ # The tag and length that open every section of a recording of version 2.
39
+ _SECTION = struct.Struct('<4sI')
40
+ FRAMES_TAG = b'FRMS'
41
+ # The limits that the readers hold a recording to, far above any recording that cuju writes. A
42
+ # header of 22 players takes about 4 KB, GRF plays at most 11 a side, and the longest match, one
43
+ # of a tournament, lasts 5000 steps, 22 MB once decompressed in version 1 and 41 MB in version 2.
44
+ MAX_HEADER_BYTES = 1 << 20
45
+ MAX_PLAYERS = 32
46
+ MAX_FRAMES = 50_000
47
+ MAX_BYTES = 256 << 20
48
+ # The frames of version 2 that the reader decodes at once, ten keyframe intervals.
49
+ _DECODE_FRAMES = 10 * _codec.KEYFRAME_INTERVAL
50
+ # The bytes that the reader passes over at once in a section that it skips.
51
+ _SKIP_BYTES = 1 << 20
52
+ # The messages that a recording names its title and its teams by, those of the names that cuju
53
+ # makes, so a header names no other message of the page.
54
+ _LABEL_PREFIXES = ('server.names.', 'server.tournament.label.')
55
+ NODES = (
56
+ 'player',
57
+ 'body',
58
+ 'middle',
59
+ 'neck',
60
+ 'left_shoulder',
61
+ 'left_elbow',
62
+ 'right_shoulder',
63
+ 'right_elbow',
64
+ 'left_thigh',
65
+ 'left_knee',
66
+ 'left_ankle',
67
+ 'right_thigh',
68
+ 'right_knee',
69
+ 'right_ankle',
70
+ )
71
+ # GRF: the skeleton of `player.object`, in pre-order, so every parent comes before its children.
72
+ PARENTS = (-1, 0, 1, 2, 2, 4, 2, 6, 1, 8, 9, 1, 11, 12)
73
+ # GRF: a node holds a position, a rotation, a scale and a local mode, 44 bytes in all.
74
+ _NODE_BYTES = 44
75
+ _NODE_SPAN = _NODE_BYTES * len(NODES)
76
+ _LOCAL_MODE_RELATIVE = 0
77
+ # GRF: `e_PlayerRole_GK`, the first role.
78
+ _GOALKEEPER = 0
79
+ # The head of a frame, eight integers, the ball's seven floats and four integers of the referee.
80
+ _FRAME_HEAD = struct.Struct('<8i7f4i')
81
+ # A team without a name of its own takes the name of its side, which the page shows in its own
82
+ # language, with the English text in `name` as before.
83
+ _DEFAULT_TEAMS = (
84
+ {
85
+ **_messages.named('name', _messages.label('server.names.left')),
86
+ 'color': [214, 48, 49],
87
+ 'secondary': [255, 255, 255],
88
+ },
89
+ {
90
+ **_messages.named('name', _messages.label('server.names.right')),
91
+ 'color': [9, 132, 227],
92
+ 'secondary': [255, 255, 255],
93
+ },
94
+ )
95
+
96
+
97
+ # A joint's position is stored in steps of 2 mm, which spans 65 m either way of the center spot,
98
+ # and its rotation in steps of 1 / 32767, so each joint takes seven 16-bit integers.
99
+ POSITION_SCALE = 500
100
+ ROTATION_SCALE = 32767
101
+
102
+
103
+ def frame_bytes(players: int) -> int:
104
+ """Returns the size of one frame of a recording of `players` players."""
105
+ return _FRAME_HEAD.size + players * len(NODES) * 14 + 3 * (-(-players // 4) * 4)
106
+
107
+
108
+ def _quantize(nodes: np.ndarray) -> np.ndarray:
109
+ """Returns joints `[..., 7]` as the 16-bit integers that a recording stores."""
110
+ scale = np.array([POSITION_SCALE] * 3 + [ROTATION_SCALE] * 4, np.float64)
111
+ return np.clip(np.rint(nodes * scale), -32767, 32767).astype('<i2')
112
+
113
+
114
+ # GRF: the state names each type as C++'s `typeid` does, and stores it little-endian.
115
+ _DTYPES = {
116
+ 'f': ('<f4', ()),
117
+ 'i': ('<i4', ()),
118
+ 'j': ('<u4', ()),
119
+ 'b': ('u1', ()),
120
+ 'c': ('i1', ()),
121
+ 'm': ('<u8', ()),
122
+ 'N7blunted7Vector3E': ('<f4', (3,)),
123
+ 'N7blunted10QuaternionE': ('<f4', (4,)),
124
+ }
125
+
126
+
127
+ @functools.cache
128
+ def _layout() -> dict[str, _state.Field]:
129
+ """Returns the layout of the match in memory, read once."""
130
+ return _state.layout()
131
+
132
+
133
+ def _field(path: str) -> _state.Field:
134
+ """Returns the `Field` of a path of the layout."""
135
+ return _layout()[path]
136
+
137
+
138
+ def _dtype(field: _state.Field) -> tuple[str, tuple[int, ...]]:
139
+ """Returns the numpy type and shape of a field of the layout."""
140
+ dtype, shape = _DTYPES.get(field.type, (None, ()))
141
+ # GRF: an enumeration is a C++ `enum`, four bytes on every platform it runs on.
142
+ if dtype is None and field.size == 4 and field.type[:1].isdigit():
143
+ dtype = '<i4'
144
+ if dtype is None or np.dtype(dtype).itemsize * int(np.prod(shape)) != field.size:
145
+ return 'u1', (field.size,)
146
+ return dtype, shape
147
+
148
+
149
+ class StateReader:
150
+ """Reads the frames of a recording out of the states of one scenario's matches.
151
+
152
+ The reader gathers every field it needs from a state in one call into the library, and derives
153
+ the world transform of each joint from the local ones, as GRF's renderer does.
154
+
155
+ Attributes:
156
+ players: The number of players of the matches.
157
+ """
158
+
159
+ _HEAD = (
160
+ ('reverse', 'environment.scenario.reverseTeamProcessing'),
161
+ ('first', 'match.firstTeam'),
162
+ ('left', 'environment.scenario.leftTeamSize'),
163
+ ('right', 'environment.scenario.rightTeamSize'),
164
+ ('step', 'environment.step'),
165
+ ('goals_first', 'match.goalCount[0]'),
166
+ ('goals_second', 'match.goalCount[1]'),
167
+ ('in_set_piece', 'match.inSetPiece'),
168
+ ('set_piece', 'match.referee.buffer.desiredSetPiece'),
169
+ ('touch_team', 'match.lastTouchTeamID'),
170
+ ('touch_first', 'match.teams[0].lastTouchPlayer'),
171
+ ('touch_second', 'match.teams[1].lastTouchPlayer'),
172
+ ('clock', 'match.matchTimeMilliseconds'),
173
+ ('actual', 'match.actualTimeMilliseconds'),
174
+ ('ball_position', 'match.ball.positionBuffer'),
175
+ ('ball_rotation', 'match.ball.orientationBuffer'),
176
+ ('foul_player', 'match.referee.foul.foulPlayer'),
177
+ ('foul_victim', 'match.referee.foul.foulVictim'),
178
+ ('foul_type', 'match.referee.foul.foulType'),
179
+ ('offside_count', 'match.referee.offsidePlayers.count'),
180
+ ('offside_first', 'match.referee.offsidePlayers[0]'),
181
+ ('buffer_active', 'match.referee.buffer.active'),
182
+ ('scorer', 'match.lastGoalScorer'),
183
+ )
184
+ _PLAYER = (
185
+ ('active', 'base.isActive'),
186
+ ('cards', 'cards'),
187
+ ('possession', 'hasPossession'),
188
+ ('controller', 'base.externalController'),
189
+ ('action', 'base.humanoid.currentAnimation.functionType'),
190
+ ('touch_time', 'base.lastTouchTimeMilliseconds'),
191
+ ('touch_type', 'base.lastTouchType'),
192
+ )
193
+
194
+ def __init__(self, players: int) -> None:
195
+ """Prepares the fields of matches with `players` players in all."""
196
+ self.players = players
197
+ fields, layout = [], []
198
+ for name, path in self._HEAD:
199
+ fields.append(_field(path))
200
+ layout.append((name, *_dtype(fields[-1])))
201
+ for player in range(players):
202
+ prefix = f'match.players[{player}]'
203
+ first = _field(f'{prefix}.base.humanoid.nodes[0].position')
204
+ # The nodes lie one after another in memory, so one field covers all fourteen.
205
+ fields.append(
206
+ _state.Field(f'{prefix}.base.humanoid.nodes', first.offset, _NODE_SPAN, 'nodes')
207
+ )
208
+ layout.append((f'nodes{player}', 'u1', (_NODE_SPAN,)))
209
+ for name, path in self._PLAYER:
210
+ fields.append(_field(f'{prefix}.{path}'))
211
+ layout.append((f'{name}{player}', *_dtype(fields[-1])))
212
+ self._fields = fields
213
+ self._dtype = np.dtype(layout)
214
+ known = _layout()
215
+ self._roles = {
216
+ (team, index): known[path]
217
+ for team, side in enumerate(('left', 'right'))
218
+ for index in range(players)
219
+ if (path := f'environment.scenario.{side}Team[{index}].role') in known
220
+ }
221
+
222
+ def _values(self, state: bytes, frame: str) -> np.void:
223
+ """Returns the gathered fields of a state as one structured record."""
224
+ return np.frombuffer(_state.gather(state, self._fields, frame), self._dtype, 1)[0]
225
+
226
+ def _order(self, values: np.void) -> list[tuple[int, int]]:
227
+ """Returns the team and the index in its team of each player of the state.
228
+
229
+ GRF: the state lists the team processed first before the other, and its ID flips with the
230
+ reverse team processing.
231
+ """
232
+ first = int(values['first']) ^ int(bool(values['reverse']))
233
+ sizes = (int(values['left']), int(values['right']))
234
+ if sum(sizes) != self.players:
235
+ raise StateError(f'expected a match of {self.players} players, found {sizes}')
236
+ return [(first, index) for index in range(sizes[first])] + [
237
+ (1 - first, index) for index in range(sizes[1 - first])
238
+ ]
239
+
240
+ def lineup(self, state: bytes) -> list[dict[str, Any]]:
241
+ """Returns the header entry of each player, its team, index, role and control.
242
+
243
+ Args:
244
+ state: A state of the match, as `get_state` returns it.
245
+ """
246
+ values = self._values(state, 'rest')
247
+ order = self._order(values)
248
+ roles = _state.read(state, [self._roles[entry] for entry in order])
249
+ return [
250
+ {
251
+ 'team': team,
252
+ 'index': index,
253
+ 'goalkeeper': int(role) == _GOALKEEPER,
254
+ 'controlled': int(values[f'controller{player}']) >= 0,
255
+ }
256
+ for player, ((team, index), role) in enumerate(zip(order, roles, strict=True))
257
+ ]
258
+
259
+ def frame(self, state: bytes, flags: int = 0) -> bytes:
260
+ """Returns the frame of a state, as a recording stores it.
261
+
262
+ Args:
263
+ state: A state of the match, as `get_state` returns it.
264
+ flags: The frame's flags, 1 when the episode ends on this step and 2 when an episode
265
+ ended just before it, as an automatic reset shows.
266
+ """
267
+ values = self._values(state, 'pitch')
268
+ order = self._order(values)
269
+ first = order[0][0] if order else 0
270
+ score = [0, 0]
271
+ score[first], score[1 - first] = int(values['goals_first']), int(values['goals_second'])
272
+ # GRF: `teams` holds the team processed first at index 0, as the goal counts do.
273
+ touch = {first: int(values['touch_first']), 1 - first: int(values['touch_second'])}
274
+ touch_team = int(values['touch_team'])
275
+ nodes = _world_transforms(
276
+ np.stack([values[f'nodes{player}'] for player in range(self.players)])
277
+ )
278
+ padded = -(-self.players // 4) * 4
279
+ states = np.zeros(padded, np.uint8)
280
+ actions = np.zeros(padded, np.uint8)
281
+ touches = np.zeros(padded, np.uint8)
282
+ # GRF: a step runs ten ticks of 10 ms, so a touch of this step lies within its last 100 ms.
283
+ step_start = int(values['actual']) - 100
284
+ owner = -1
285
+ for player in range(self.players):
286
+ cards = int(values[f'cards{player}'])
287
+ # GRF: the engine moves a gamer's controller to the designated player as play moves,
288
+ # so 8 marks the player that an agent or a person controls on this step.
289
+ states[player] = (
290
+ (1 if values[f'active{player}'] else 0)
291
+ | (2 if cards > 0 else 0)
292
+ | (4 if cards > 1 else 0)
293
+ | (8 if int(values[f'controller{player}']) >= 0 else 0)
294
+ )
295
+ actions[player] = int(values[f'action{player}']) & 0xFF
296
+ if int(values[f'touch_time{player}']) > step_start:
297
+ touches[player] = int(values[f'touch_type{player}']) + 1
298
+ team = order[player][0]
299
+ # GRF: the raw observation's owner, a player in possession whose team touched last.
300
+ if values[f'possession{player}'] and touch_team == team and touch[team] == player:
301
+ owner = player
302
+ game_mode = int(values['set_piece']) if values['in_set_piece'] else 0
303
+ called = (
304
+ bool(values['buffer_active'])
305
+ and int(values['set_piece']) == 3
306
+ and int(values['foul_type']) == 0
307
+ and touch_team in touch
308
+ )
309
+ offside = (
310
+ touch[touch_team]
311
+ if called
312
+ else (int(values['offside_first']) if int(values['offside_count']) > 0 else -1)
313
+ )
314
+ flags |= 4 if called else 0
315
+ flags |= (int(values['scorer']) + 1) << 8
316
+ head = _FRAME_HEAD.pack(
317
+ # GRF: the engine counts -1 after a reset and adds one per step.
318
+ int(values['step']) + 1,
319
+ game_mode,
320
+ score[0],
321
+ score[1],
322
+ touch_team if owner >= 0 else -1,
323
+ owner,
324
+ int(values['clock']),
325
+ int(flags),
326
+ *values['ball_position'].tolist(),
327
+ *values['ball_rotation'].tolist(),
328
+ # GRF: a foul type of 1 is a foul, 2 a yellow card and 3 a red card, and both players
329
+ # index the match's players, as the header lists them.
330
+ int(values['foul_player']) if int(values['foul_type']) else -1,
331
+ int(values['foul_victim']) if int(values['foul_type']) else -1,
332
+ int(values['foul_type']),
333
+ offside,
334
+ )
335
+ return head + b''.join(
336
+ (_quantize(nodes).tobytes(), states.tobytes(), actions.tobytes(), touches.tobytes())
337
+ )
338
+
339
+
340
+ def _multiply(first: np.ndarray, second: np.ndarray) -> np.ndarray:
341
+ """Returns the Hamilton products of quaternions stored x, y, z, w, as GRF multiplies them."""
342
+ x1, y1, z1, w1 = np.moveaxis(first, -1, 0)
343
+ x2, y2, z2, w2 = np.moveaxis(second, -1, 0)
344
+ return np.stack(
345
+ (
346
+ w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2,
347
+ w1 * y2 + y1 * w2 + z1 * x2 - x1 * z2,
348
+ w1 * z2 + z1 * w2 + x1 * y2 - y1 * x2,
349
+ w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2,
350
+ ),
351
+ -1,
352
+ )
353
+
354
+
355
+ def _rotate(quaternion: np.ndarray, vector: np.ndarray) -> np.ndarray:
356
+ """Returns each `vector` rotated by its `quaternion`, with GRF's two cross products."""
357
+ part = quaternion[..., :3]
358
+ first = np.cross(part, vector)
359
+ second = np.cross(part, first)
360
+ return vector + first * (2.0 * quaternion[..., 3:]) + second * 2.0
361
+
362
+
363
+ def _world_transforms(blocks: np.ndarray) -> np.ndarray:
364
+ """Returns `[players, nodes, 7]`, the world position and rotation of every node of each player.
365
+
366
+ Speed: the nodes come in pre-order, so each node needs its parent alone, and every player goes
367
+ at once.
368
+
369
+ Args:
370
+ blocks: `[players, nodes * 44]` bytes, the fourteen nodes of each player as the state holds
371
+ them, each a position, a rotation, a scale and a local mode.
372
+ """
373
+ count = blocks.shape[0]
374
+ records = np.ascontiguousarray(blocks, np.uint8).reshape(count, len(NODES), _NODE_BYTES)
375
+ floats = records[..., :40].copy().view('<f4').astype(np.float64)
376
+ modes = records[..., 40:44].copy().view('<i4')[..., 0]
377
+ positions, rotations, scales = floats[..., 0:3], floats[..., 3:7], floats[..., 7:10]
378
+ world = np.empty((count, len(NODES), 7))
379
+ world_scale = np.empty((count, len(NODES), 3))
380
+ for node, parent in enumerate(PARENTS):
381
+ world[:, node, 3:] = rotations[:, node]
382
+ world_scale[:, node] = scales[:, node]
383
+ world[:, node, :3] = positions[:, node]
384
+ if parent < 0:
385
+ continue
386
+ relative = modes[:, node] == _LOCAL_MODE_RELATIVE
387
+ if not relative.any():
388
+ continue
389
+ rotation = _multiply(world[:, parent, 3:], rotations[:, node])
390
+ rotation /= np.linalg.norm(rotation, axis=-1, keepdims=True)
391
+ scale = world_scale[:, parent] * scales[:, node]
392
+ position = (
393
+ _rotate(world[:, parent, 3:], world_scale[:, parent] * positions[:, node])
394
+ + world[:, parent, :3]
395
+ )
396
+ world[relative, node, 3:] = rotation[relative]
397
+ world_scale[relative, node] = scale[relative]
398
+ world[relative, node, :3] = position[relative]
399
+ return world.astype(np.float32)
400
+
401
+
402
+ def header(
403
+ title: str | dict[str, Any],
404
+ scenario: str,
405
+ lineup: list[dict[str, Any]],
406
+ teams: list[dict[str, Any]] | None = None,
407
+ frame_count: int | None = None,
408
+ timing: dict[str, Any] | None = None,
409
+ provenance: dict[str, Any] | None = None,
410
+ ) -> bytes:
411
+ """Returns the magic, the header length and the header of a recording, as version 1 has it.
412
+
413
+ A live stream sends the header as it is, and `write` turns it into the version it writes.
414
+
415
+ Args:
416
+ title: The title that the viewer shows, text that a person gave or a label that
417
+ `_messages.label` returns for a title that cuju made, which the header keeps as its
418
+ English `title` with `title_id` and `title_values` beside it.
419
+ scenario: The name of the scenario.
420
+ lineup: The entry of each player, as `StateReader.lineup` returns it.
421
+ teams: The name, `color` and `secondary` color of the left and right team, each optional.
422
+ A name is text or a label, as the title is, and a team without one takes the name of
423
+ its side, Left or Right, as a label.
424
+ frame_count: The number of frames, or None for a live stream.
425
+ timing: The optional scenario timing fields.
426
+ provenance: What is known of where the recording comes from, such as its source, seed
427
+ and configuration. The version of cuju joins it, and a field left out stays unknown.
428
+ """
429
+ given = [*(teams or ()), {}, {}]
430
+ sides = []
431
+ for side in range(2):
432
+ team = {**_DEFAULT_TEAMS[side], **given[side]}
433
+ if 'name' in given[side]:
434
+ # A name given as text is a person's and travels alone, a label keeps its message.
435
+ team = {key: value for key, value in team.items() if key not in _NAME_FIELDS}
436
+ team.update(_name_fields(given[side]))
437
+ sides.append(team)
438
+ document = {
439
+ 'version': 1,
440
+ 'engine_scorer': True,
441
+ 'offside_called': True,
442
+ **(timing or {}),
443
+ **_messages.named('title', title),
444
+ 'scenario': scenario,
445
+ 'steps_per_second': 10,
446
+ 'nodes': list(NODES),
447
+ 'parents': list(PARENTS),
448
+ 'players': lineup,
449
+ 'teams': sides,
450
+ 'frame_bytes': frame_bytes(len(lineup)),
451
+ 'frame_count': frame_count,
452
+ 'provenance': {**(provenance or {}), 'cuju_version': __version__},
453
+ }
454
+ # JavaScript holds integers exactly only up to 2**53 - 1, so a larger seed travels as text.
455
+ seed = document['provenance'].get('seed')
456
+ if isinstance(seed, int) and not isinstance(seed, bool) and seed > 2**53 - 1:
457
+ document['provenance']['seed'] = str(seed)
458
+ return _encode_header(document)
459
+
460
+
461
+ # The fields of a name that cuju made, beside its English text.
462
+ _NAME_FIELDS = ('name_id', 'name_values')
463
+
464
+
465
+ def _name_fields(team: dict[str, Any]) -> dict[str, Any]:
466
+ """Returns the name of a team as a header keeps it, its text and, for a label, its message.
467
+
468
+ The name is text, a label that `_messages.label` returns, or text beside the `name_id` and
469
+ `name_values` of an entry of the registry, such as the built-in AI's.
470
+ """
471
+ name = team.get('name')
472
+ if isinstance(name, dict):
473
+ return _messages.named('name', name)
474
+ found = {'name': name}
475
+ if isinstance(team.get('name_id'), str) and isinstance(team.get('name_values'), dict):
476
+ found.update(name_id=team['name_id'], name_values=dict(team['name_values']))
477
+ return found
478
+
479
+
480
+ def label_of(document: dict[str, Any], field: str) -> dict[str, Any] | str | None:
481
+ """Returns a name of a header or summary as a label when it holds a valid one, else as text.
482
+
483
+ A file of before, or a name that a person gave, holds the text alone and returns it.
484
+ """
485
+ text = document.get(field)
486
+ found = {
487
+ 'id': document.get(f'{field}_id'),
488
+ 'text': text,
489
+ 'values': document.get(f'{field}_values'),
490
+ }
491
+ return found if _messages.valid_label(found) else text
492
+
493
+
494
+ def _document(head: bytes) -> dict[str, Any]:
495
+ """Returns the header of the bytes that `header` returns."""
496
+ return json.loads(head[len(MAGIC) + 4 :])
497
+
498
+
499
+ def _encode_header(document: dict[str, Any]) -> bytes:
500
+ """Returns the magic, the length and a header, padded so that what follows aligns to four."""
501
+ text = json.dumps(document, separators=(',', ':')).encode()
502
+ # Spaces after the JSON align the frames to four bytes, so a reader views their floats in place.
503
+ text += b' ' * (-(len(MAGIC) + 4 + len(text)) % 4)
504
+ return MAGIC + struct.pack('<I', len(text)) + text
505
+
506
+
507
+ def _section(tag: bytes, payload: bytes) -> bytes:
508
+ """Returns a section of version 2, its tag, its length, the payload and zeros to four bytes."""
509
+ return _SECTION.pack(tag, len(payload)) + payload + bytes(-len(payload) % 4)
510
+
511
+
512
+ def encode(
513
+ head: bytes,
514
+ frames: list[bytes],
515
+ version: int = VERSION,
516
+ annotations: list[dict[str, Any]] | None = None,
517
+ ) -> bytes:
518
+ """Returns a recording before compression, in version 1 or 2.
519
+
520
+ Args:
521
+ head: The magic, length and header, as `header` returns them.
522
+ frames: The frames, each as `StateReader.frame` returns it.
523
+ version: The version of the file.
524
+ annotations: The records of the annotations, which version 2 keeps in a section of its own
525
+ and version 1 has no room for.
526
+
527
+ Raises:
528
+ StateError: If the version is unknown.
529
+ """
530
+ if version == 1:
531
+ return head + b''.join(frames)
532
+ if version != 2:
533
+ raise StateError(f'expected a recording version from {VERSIONS}, found {version!r}')
534
+ document = _document(head)
535
+ players = len(document['players'])
536
+ document.update(version=2, frame_count=len(frames), keyframe_interval=_codec.KEYFRAME_INTERVAL)
537
+ data = _encode_header(document) + _section(FRAMES_TAG, _codec.encode(frames, players))
538
+ if annotations:
539
+ data += _section(_annotations.TAG, _annotations.encode(annotations))
540
+ return data
541
+
542
+
543
+ def write(
544
+ path: Path,
545
+ head: bytes,
546
+ frames: list[bytes],
547
+ version: int = VERSION,
548
+ annotations: list[dict[str, Any]] | None = None,
549
+ ) -> None:
550
+ """Writes a recording, gzip-compressed, through a temporary file.
551
+
552
+ Args:
553
+ path: The path of the file.
554
+ head: The magic, length and header, as `header` returns them.
555
+ frames: The frames, each as `StateReader.frame` returns it.
556
+ version: The version of the file, 2 by default, or 1 for readers of cuju 0.2.0.
557
+ annotations: The records of the annotations, kept by version 2 alone.
558
+ """
559
+ data = encode(head, frames, version, annotations)
560
+ path = Path(path)
561
+ temporary = path.with_name(path.name + '.part')
562
+ with gzip.open(temporary, 'wb', compresslevel=6) as stream:
563
+ stream.write(data)
564
+ temporary.replace(path)
565
+
566
+
567
+ def _whole(value: Any) -> bool:
568
+ """Returns whether a value of a header is a whole number, which `True` is not."""
569
+ return isinstance(value, int) and not isinstance(value, bool)
570
+
571
+
572
+ def validate_header(document: Any) -> dict[str, Any]:
573
+ """Returns a header after checking that every field the readers use holds its type.
574
+
575
+ A field that a header may leave out, such as the title, the teams or the provenance, may also
576
+ be null, and the readers then take its default.
577
+
578
+ Raises:
579
+ StateError: If the header is not an object, names a version that this module does not
580
+ read, holds players, a frame size, a number of frames or a keyframe interval that does
581
+ not fit its version or the limits of a recording, or holds a field of the wrong type.
582
+ """
583
+ if not isinstance(document, dict):
584
+ raise StateError(f'expected a header object, found {type(document).__name__}')
585
+ version = document.get('version', 1)
586
+ if not _whole(version) or version not in VERSIONS:
587
+ raise StateError(f'expected a recording version from {VERSIONS}, found {version!r}')
588
+ players = document.get('players')
589
+ if not isinstance(players, list) or len(players) > MAX_PLAYERS:
590
+ raise StateError(f'expected a list of at most {MAX_PLAYERS} players in the header')
591
+ for player in players:
592
+ if not (
593
+ isinstance(player, dict)
594
+ and _whole(player.get('team'))
595
+ and player['team'] in (0, 1)
596
+ and _whole(player.get('index'))
597
+ and 0 <= player['index'] < MAX_PLAYERS
598
+ and all(
599
+ isinstance(player.get(key, False), bool) for key in ('goalkeeper', 'controlled')
600
+ )
601
+ ):
602
+ raise StateError(
603
+ 'expected each player of the header with its team, 0 or 1, its index and flags, '
604
+ f'found {str(player)[:80]}'
605
+ )
606
+ size = document.get('frame_bytes')
607
+ if (version == 1 or size is not None) and not (
608
+ _whole(size) and size == frame_bytes(len(players))
609
+ ):
610
+ raise StateError(
611
+ f'expected frames of {frame_bytes(len(players))} bytes for {len(players)} players, '
612
+ f'found {size!r}'
613
+ )
614
+ count = document.get('frame_count')
615
+ if (version == 2 or count is not None) and not (_whole(count) and 0 <= count <= MAX_FRAMES):
616
+ raise StateError(f'expected a number of frames from 0 to {MAX_FRAMES}, found {count!r}')
617
+ interval = document.get('keyframe_interval')
618
+ if version == 2 and not (_whole(interval) and interval == _codec.KEYFRAME_INTERVAL):
619
+ raise StateError(
620
+ f'expected a keyframe interval of {_codec.KEYFRAME_INTERVAL}, found {interval!r}'
621
+ )
622
+ for field, kinds in (('title', str), ('scenario', str), ('provenance', dict)):
623
+ if document.get(field) is not None and not isinstance(document[field], kinds):
624
+ raise StateError(f'expected the {field} of the header as {kinds.__name__}')
625
+ speed = document.get('steps_per_second')
626
+ if speed is not None and not (
627
+ isinstance(speed, int | float)
628
+ and not isinstance(speed, bool)
629
+ and math.isfinite(speed)
630
+ and 0 < speed <= 1000
631
+ ):
632
+ raise StateError(f'expected steps per second above 0 and at most 1000, found {speed!r}')
633
+ second = document.get('second_half')
634
+ if second is not None and not (_whole(second) and second >= 0):
635
+ raise StateError(f'expected the step of the second half, found {second!r}')
636
+ teams = document.get('teams')
637
+ if teams is not None and not (
638
+ isinstance(teams, list)
639
+ and all(
640
+ isinstance(team, dict)
641
+ and all(
642
+ team.get(key) is None or isinstance(team[key], kind)
643
+ for key, kind in (('name', str), ('id', str), ('style', dict))
644
+ )
645
+ for team in teams
646
+ )
647
+ ):
648
+ raise StateError('expected the teams of the header, each with a name as text')
649
+ # A label of a message that no recording carries leaves its name as the text beside it.
650
+ for holder, field in [(document, 'title'), *((team, 'name') for team in teams or ())]:
651
+ fields = (f'{field}_id', f'{field}_values')
652
+ if any(key in holder for key in fields) and not _carried(
653
+ {
654
+ 'id': holder.get(fields[0]),
655
+ 'text': holder.get(field),
656
+ 'values': holder.get(fields[1]),
657
+ }
658
+ ):
659
+ for key in fields:
660
+ holder.pop(key, None)
661
+ return document
662
+
663
+
664
+ def _carried(label: Any) -> bool:
665
+ """Returns whether a label names a message of the names of cuju, as each label of its values."""
666
+
667
+ def named(value: dict[str, Any]) -> bool:
668
+ return 'kind' in value or (
669
+ value['id'].startswith(_LABEL_PREFIXES)
670
+ and all(isinstance(item, str) or named(item) for item in value['values'].values())
671
+ )
672
+
673
+ try:
674
+ return _messages.valid_label(label) and named(label)
675
+ except RecursionError:
676
+ return False
677
+
678
+
679
+ class _Budget:
680
+ """Reads a decompressed recording, counting its bytes against the limit of a recording.
681
+
682
+ A read never asks for more than the limit leaves, so a length that a file gives reserves no
683
+ more memory than the limit allows, and a fault of the compression turns into a `StateError`.
684
+ """
685
+
686
+ def __init__(self, stream: Any) -> None:
687
+ self._stream = stream
688
+ self.left = MAX_BYTES
689
+
690
+ def read(self, size: int) -> bytes:
691
+ try:
692
+ data = self._stream.read(min(size, self.left + 1))
693
+ except (EOFError, zlib.error, gzip.BadGzipFile) as error:
694
+ raise StateError(f'expected a whole gzip stream, found {error}') from None
695
+ self.left -= len(data)
696
+ if self.left < 0:
697
+ raise StateError(
698
+ f'expected a recording of at most {MAX_BYTES >> 20} MiB once decompressed'
699
+ )
700
+ return data
701
+
702
+
703
+ def _constant(name: str) -> Any:
704
+ """Refuses NaN and the infinities, which Python reads in JSON and a page does not."""
705
+ raise ValueError(f'expected numbers of JSON, found {name}')
706
+
707
+
708
+ def _parse_header(stream: Any) -> dict[str, Any]:
709
+ """Returns the header at the start of `stream`, after checking the magic and its fields."""
710
+ start = stream.read(len(MAGIC) + 4)
711
+ if start[: len(MAGIC)] != MAGIC:
712
+ raise StateError('expected a recording of cuju, found other bytes')
713
+ if len(start) < len(MAGIC) + 4:
714
+ raise StateError('expected the length of a header, found the recording cut short')
715
+ (length,) = struct.unpack_from('<I', start, len(MAGIC))
716
+ if length > MAX_HEADER_BYTES:
717
+ raise StateError(f'expected a header of at most {MAX_HEADER_BYTES} bytes, found {length}')
718
+ text = stream.read(length)
719
+ if len(text) < length:
720
+ raise StateError('expected a whole header, found the recording cut short')
721
+ try:
722
+ document = json.loads(text, parse_constant=_constant)
723
+ except (ValueError, RecursionError) as error:
724
+ raise StateError(f'expected a header of JSON, found {error}') from None
725
+ return validate_header(document)
726
+
727
+
728
+ def read_header(path: Path) -> dict[str, Any]:
729
+ """Returns the header of a recording file, reading only its start."""
730
+ with gzip.open(path, 'rb') as stream:
731
+ return _parse_header(_Budget(stream))
732
+
733
+
734
+ def _sections(stream: Any, tags: tuple[bytes, ...]) -> dict[bytes, bytes]:
735
+ """Returns the sections of version 2 of `tags` that follow the header in `stream`, by tag.
736
+
737
+ The first section of each tag counts, and the reader passes over any other in chunks.
738
+ """
739
+ found = {}
740
+ while head := stream.read(_SECTION.size):
741
+ if len(head) < _SECTION.size:
742
+ raise StateError('expected a whole section of the recording, found it cut short')
743
+ tag, length = _SECTION.unpack(head)
744
+ rest = length + (-length % 4)
745
+ if tag in tags and tag not in found:
746
+ payload = stream.read(length)
747
+ if len(payload) < length:
748
+ raise StateError('expected a whole section of the recording, found it cut short')
749
+ found[tag] = payload
750
+ rest -= length
751
+ while rest > 0:
752
+ skipped = len(stream.read(min(rest, _SKIP_BYTES)))
753
+ if skipped == 0:
754
+ raise StateError('expected a whole section of the recording, found it cut short')
755
+ rest -= skipped
756
+ return found
757
+
758
+
759
+ def _coded_frames(document: dict[str, Any], sections: dict[bytes, bytes]) -> tuple[bytes, int]:
760
+ """Returns the frames section of a recording of version 2 and the number of its players."""
761
+ players = len(document['players'])
762
+ data = sections.get(FRAMES_TAG)
763
+ if (
764
+ not players
765
+ or data is None
766
+ or len(data) != _codec.section_bytes(players, document['frame_count'])
767
+ ):
768
+ raise StateError('expected the frames of a recording of version 2, found them malformed')
769
+ return data, players
770
+
771
+
772
+ def annotations(path: Path) -> list[dict[str, Any]]:
773
+ """Returns the records of the annotations of a recording file, none for version 1."""
774
+ with gzip.open(path, 'rb') as raw:
775
+ stream = _Budget(raw)
776
+ if _parse_header(stream).get('version', 1) == 1:
777
+ return []
778
+ payload = _sections(stream, (_annotations.TAG,)).get(_annotations.TAG)
779
+ return _annotations.decode(payload) if payload is not None else []
780
+
781
+
782
+ def frames(path: Path, start: int = 0, stop: int | None = None) -> Iterator[bytes]:
783
+ """Yields the frames of a recording file one after another, each as version 1 stores it.
784
+
785
+ A file of version 2 decodes from the keyframe before `start`, so the last frames come quickly,
786
+ and a thousand frames at a time, so a long recording takes little memory beyond its file.
787
+
788
+ Args:
789
+ path: The path of the file.
790
+ start: The first frame to yield.
791
+ stop: The frame after the last to yield, by default the end.
792
+
793
+ Raises:
794
+ StateError: If the file is not a recording or reaches past the limits of one.
795
+ """
796
+ with gzip.open(path, 'rb') as raw:
797
+ stream = _Budget(raw)
798
+ document = _parse_header(stream)
799
+ if document.get('version', 1) == 1:
800
+ size = document['frame_bytes']
801
+ index = 0
802
+ # A last frame cut short is left out, as the page leaves it out.
803
+ while (stop is None or index < stop) and len(chunk := stream.read(size)) == size:
804
+ if index >= MAX_FRAMES:
805
+ raise StateError(f'expected a recording of at most {MAX_FRAMES} frames')
806
+ if index >= start:
807
+ yield chunk
808
+ index += 1
809
+ return
810
+ data, players = _coded_frames(document, _sections(stream, (FRAMES_TAG,)))
811
+ count = document['frame_count']
812
+ end = count if stop is None else max(0, min(stop, count))
813
+ at = max(0, start)
814
+ while at < end:
815
+ upto = min(end, (at // _DECODE_FRAMES + 1) * _DECODE_FRAMES)
816
+ for row in _codec.decode(data, players, count, _codec.KEYFRAME_INTERVAL, at, upto):
817
+ yield row.tobytes()
818
+ at = upto
819
+
820
+
821
+ def ending(path: Path) -> tuple[int, bytes | None]:
822
+ """Returns the number of frames of a recording file and its last frame, `None` for none.
823
+
824
+ A file of version 2 decodes its last frame alone, and one of version 1 reads one frame at a
825
+ time, so neither holds more than its file and a few frames at once.
826
+
827
+ Raises:
828
+ StateError: If the file is not a recording or reaches past the limits of one.
829
+ """
830
+ document = read_header(path)
831
+ count, last = 0, None
832
+ if document.get('version', 1) == 2:
833
+ count = document['frame_count']
834
+ for frame in frames(path, start=max(0, count - 1)):
835
+ last = frame
836
+ return count, last
837
+ for frame in frames(path):
838
+ count, last = count + 1, frame
839
+ return count, last
840
+
841
+
842
+ def scenario_timing(state: bytes) -> dict[str, Any]:
843
+ """Returns the scenario timing without changing the state."""
844
+ duration, second, score, possession, out = _state.read(
845
+ state,
846
+ [
847
+ 'environment.scenario.gameDuration',
848
+ 'environment.scenario.secondHalf',
849
+ 'environment.scenario.endEpisodeOnScore',
850
+ 'environment.scenario.endEpisodeOnPossessionChange',
851
+ 'environment.scenario.endEpisodeOnOutOfPlay',
852
+ ],
853
+ )
854
+ duration, second = int(duration), int(second)
855
+ half_ms = (1800 * second) & 0xFFFFFFFF
856
+ return {
857
+ 'duration': duration,
858
+ 'second_half': second if 0 < second < duration and half_ms < 0x80000000 else None,
859
+ 'full_match': duration >= 3000 and not any((score, possession, out)),
860
+ }