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,1618 @@
1
+ # Copyright 2026 The cuju Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Exports a recording as a video file, drawn by the renderer's own page frame by frame.
5
+
6
+ The export serves the page and the recording from a loopback server of its own and opens
7
+ `web/export.html` in a locally installed Chrome, Edge or Chromium without a window. That page runs
8
+ the browser's own export of `web/js/export/video-browser.js`, which steps the page's clocks by one
9
+ frame at a time through the capture hook of `web/js/capture.js` and encodes each frame with the
10
+ browser's video encoder into MP4 with H.264 or WebM with VP9 or VP8. The file comes back over the
11
+ debugging protocol in pieces, so the export needs the browser alone. Where the browser encodes no
12
+ H.264 and an MP4 is asked for, an installed ffmpeg encodes it instead from a picture of each frame,
13
+ as the export did before the browser could encode. The browser is found on the path or given by
14
+ option, and the export uses only Python's standard library beside it.
15
+
16
+ `cuju render video` runs an export from the command line, and the server of the page runs the same
17
+ export in a child process through `python -m cuju.render._export.video`, which then reports its
18
+ progress as JSON lines.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import base64
25
+ import contextlib
26
+ import dataclasses
27
+ import json
28
+ import math
29
+ import os
30
+ import re
31
+ import shutil
32
+ import socket
33
+ import struct
34
+ import subprocess
35
+ import sys
36
+ import tempfile
37
+ import threading
38
+ import time
39
+ import urllib.parse
40
+ import urllib.request
41
+ from collections.abc import Callable
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ from cuju.errors import ArgumentError, Error
46
+
47
+ from .._text import messages as _messages
48
+ from .._text.messages import MessageError
49
+ from . import sizes as _sizes
50
+
51
+ # The cameras that follow the match by themselves, as the page names them.
52
+ CAMERAS = ('broadcast', 'tactical', 'goal', 'ball', 'owner')
53
+ # The arenas of the page, as `web/js/world/arenas.js` lists them.
54
+ ARENAS = ('stadium', 'beach', 'rooftop', 'space', 'neon')
55
+ # The named hours of the page, as `PRESET_HOURS` of `web/js/world/sky.js`.
56
+ HOURS = {'dawn': 6.5, 'day': 15.0, 'dusk': 19.0, 'night': 22.0}
57
+ # Each quality names the constant rate factor of x264, its preset and the page's drawing quality.
58
+ QUALITIES = {
59
+ 'draft': (28, 'veryfast', 'medium'),
60
+ 'standard': (20, 'medium', 'high'),
61
+ 'high': (15, 'slow', 'high'),
62
+ }
63
+ DEFAULT_SIZE = (1280, 720)
64
+ DEFAULT_FPS = 30
65
+ # The frame rates and the limits of an export. The sizes follow `_sizes`, with even sides.
66
+ MAX_FPS = 60
67
+ DEFAULT_MAX_SECONDS = 600.0
68
+ LIMIT_SECONDS = 3600.0
69
+ DEFAULT_MAX_MEGABYTES = 2048
70
+ LIMIT_MEGABYTES = 16384
71
+ # The containers of a video, and auto for the best that the browser encodes, MP4 before WebM.
72
+ FORMATS = ('auto', 'mp4', 'webm')
73
+ SUFFIXES = {'mp4': '.mp4', 'webm': '.webm'}
74
+ # The finished file comes back from the browser in pieces of this many bytes.
75
+ _PIECE = 4 << 20
76
+ # A goal's range starts this long before the goal and ends this long after it.
77
+ GOAL_BEFORE, GOAL_AFTER = 8.0, 4.0
78
+ # The page plays this long before the first frame, so the camera and graphics have settled.
79
+ WARMUP = 1.0
80
+ # The identifier under which the export's server offers a recording file outside the library.
81
+ _FILE_IDENTIFIER = 'video-source'
82
+
83
+
84
+ class VideoError(Error, RuntimeError):
85
+ """Raised when an export cannot run or stops, with what to do about it."""
86
+
87
+
88
+ class VideoMessageError(_messages.Message, VideoError):
89
+ """A `VideoError` whose text is a message of the server, which the page translates."""
90
+
91
+ _wrapper = True
92
+
93
+
94
+ class CancelledError(VideoError):
95
+ """Raised when an export is cancelled, by Ctrl+C or by the page."""
96
+
97
+
98
+ @dataclasses.dataclass(frozen=True)
99
+ class Moment:
100
+ """A moment of a recording, in seconds from its first frame or from one of its goals.
101
+
102
+ Attributes:
103
+ seconds: The seconds from the first frame, or from the goal when `goal` is set.
104
+ goal: The number of the goal, counted from 1, or `None`.
105
+ """
106
+
107
+ seconds: float
108
+ goal: int | None = None
109
+
110
+
111
+ @dataclasses.dataclass
112
+ class Options:
113
+ """The settings of one export, each checked by `check`."""
114
+
115
+ start: Moment | None = None
116
+ end: Moment | None = None
117
+ goal: int | None = None
118
+ camera: str = 'broadcast'
119
+ width: int = DEFAULT_SIZE[0]
120
+ height: int = DEFAULT_SIZE[1]
121
+ fps: int = DEFAULT_FPS
122
+ quality: str = 'standard'
123
+ score: bool = True
124
+ annotations: bool = False
125
+ arena: str | None = None
126
+ hour: float | None = None
127
+ broadcast: bool = False
128
+ graphics: float = 1.0
129
+ safe: str | float = 'auto'
130
+ max_seconds: float = DEFAULT_MAX_SECONDS
131
+ max_megabytes: int = DEFAULT_MAX_MEGABYTES
132
+ chrome: str | None = None
133
+ ffmpeg: str | None = None
134
+ software: bool = False
135
+ sandbox: bool = True
136
+ # The container, mp4, webm or auto, or None for the one that the output's suffix names.
137
+ format: str | None = None
138
+
139
+ def check(self) -> Options:
140
+ """Returns the options after checking each, or raises `ArgumentError`."""
141
+ if self.format is not None and self.format not in FORMATS:
142
+ raise MessageError(
143
+ 'server.video.format', formats=', '.join(FORMATS), format=repr(self.format)
144
+ )
145
+ if self.camera not in CAMERAS:
146
+ raise MessageError(
147
+ 'server.video.camera', cameras=', '.join(CAMERAS), camera=repr(self.camera)
148
+ )
149
+ check_size(self.width, self.height)
150
+ fps = self.fps
151
+ if isinstance(fps, bool) or not isinstance(fps, int) or not 1 <= fps <= MAX_FPS:
152
+ raise MessageError('server.video.rate', most=MAX_FPS, fps=repr(self.fps))
153
+ if not isinstance(self.annotations, bool):
154
+ raise MessageError('server.video.annotations', annotations=repr(self.annotations))
155
+ if self.quality not in QUALITIES:
156
+ raise MessageError(
157
+ 'server.video.quality', qualities=', '.join(QUALITIES), quality=repr(self.quality)
158
+ )
159
+ if self.arena is not None and self.arena not in ARENAS:
160
+ raise MessageError(
161
+ 'server.video.arena', arenas=', '.join(ARENAS), arena=repr(self.arena)
162
+ )
163
+ if self.hour is not None and not (_number(self.hour) and 0 <= self.hour <= 24):
164
+ raise MessageError('server.video.hour', hour=repr(self.hour))
165
+ if not _number(self.graphics):
166
+ raise MessageError('server.video.scale', graphics=repr(self.graphics))
167
+ self.graphics = _sizes.parse_graphics(self.graphics)
168
+ self.safe = _sizes.parse_safe(self.safe)
169
+ if self.goal is not None and (
170
+ isinstance(self.goal, bool) or not isinstance(self.goal, int) or self.goal < 1
171
+ ):
172
+ raise MessageError('server.video.goal', goal=repr(self.goal))
173
+ if not (_number(self.max_seconds) and 0 < self.max_seconds <= LIMIT_SECONDS):
174
+ raise MessageError(
175
+ 'server.video.max_seconds',
176
+ most=f'{LIMIT_SECONDS:.0f}',
177
+ value=repr(self.max_seconds),
178
+ )
179
+ if (
180
+ isinstance(self.max_megabytes, bool)
181
+ or not isinstance(self.max_megabytes, int)
182
+ or not 1 <= self.max_megabytes <= LIMIT_MEGABYTES
183
+ ):
184
+ raise MessageError(
185
+ 'server.video.max_megabytes', most=LIMIT_MEGABYTES, value=repr(self.max_megabytes)
186
+ )
187
+ return self
188
+
189
+
190
+ def _number(value: Any) -> bool:
191
+ """Returns whether `value` is a finite number, which excludes true and false."""
192
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
193
+
194
+
195
+ def check_size(width: Any, height: Any) -> None:
196
+ """Checks a picture size as `_sizes.check_size` does, with even sides for the encoder."""
197
+ _sizes.check_size(width, height, even=True)
198
+
199
+
200
+ _SECONDS = r'\d+(?:\.\d+)?'
201
+ _MOMENT = re.compile(
202
+ rf'(?:(?P<minutes>\d+):(?P<clock>\d{{1,2}}(?:\.\d+)?)|(?P<seconds>{_SECONDS}))'
203
+ )
204
+ _GOAL = re.compile(rf'goal:?(?P<goal>\d+)(?:(?P<sign>[+-])(?P<offset>{_SECONDS}))?')
205
+
206
+
207
+ def parse_moment(text: str) -> Moment:
208
+ """Returns the moment that a range option names.
209
+
210
+ A moment is a number of seconds such as `75` or `75.5`, minutes and seconds such as `1:15`,
211
+ or a goal such as `goal:2`, optionally with seconds before or after it, as in `goal:2-8` or
212
+ `goal:2+4`.
213
+
214
+ Raises:
215
+ ArgumentError: If the text names no moment.
216
+ """
217
+ value = str(text).strip().lower()
218
+ goal = _GOAL.fullmatch(value)
219
+ if goal:
220
+ number = int(goal['goal'])
221
+ if number < 1:
222
+ raise ArgumentError(f'expected goals counted from 1, found {text!r}')
223
+ offset = float(goal['offset'] or 0)
224
+ return Moment(-offset if goal['sign'] == '-' else offset, number)
225
+ moment = _MOMENT.fullmatch(value)
226
+ if not moment:
227
+ raise ArgumentError(
228
+ f'expected seconds such as 75, minutes and seconds such as 1:15, or a goal such as '
229
+ f'goal:2 or goal:2-8, found {text!r}'
230
+ )
231
+ if moment['seconds'] is not None:
232
+ return Moment(float(moment['seconds']))
233
+ clock = float(moment['clock'])
234
+ if clock >= 60:
235
+ raise ArgumentError(f'expected seconds below 60 after the colon, found {text!r}')
236
+ return Moment(int(moment['minutes']) * 60 + clock)
237
+
238
+
239
+ def parse_size(text: str) -> tuple[int, int]:
240
+ """Returns the width and height of a size such as `1280x720`, `1080p` or `vertical`.
241
+
242
+ The sizes are those of `_sizes`, shared with the feed and the page, with even sides.
243
+ """
244
+ return _sizes.parse_size(text, even=True)
245
+
246
+
247
+ def parse_hour(text: str) -> float:
248
+ """Returns the hour of a time of day, a name such as `dusk` or an hour such as `21.5`."""
249
+ value = str(text).strip().lower()
250
+ if value in HOURS:
251
+ return HOURS[value]
252
+ try:
253
+ hour = float(value)
254
+ except ValueError:
255
+ hour = math.nan
256
+ if not 0 <= hour <= 24:
257
+ raise ArgumentError(f'expected {", ".join(HOURS)} or an hour from 0 to 24, found {text!r}')
258
+ return hour
259
+
260
+
261
+ @dataclasses.dataclass(frozen=True)
262
+ class Facts:
263
+ """What an export needs to know of a recording.
264
+
265
+ Attributes:
266
+ title: The title of the recording.
267
+ frames: The number of frames.
268
+ steps_per_second: The frames of one second of play.
269
+ goals: The second of each goal, the first frame that shows the new score.
270
+ """
271
+
272
+ title: str
273
+ frames: int
274
+ steps_per_second: float
275
+ goals: tuple[float, ...]
276
+
277
+ @property
278
+ def duration(self) -> float:
279
+ """The seconds from the first frame to the last."""
280
+ return max(0, self.frames - 1) / self.steps_per_second
281
+
282
+
283
+ def recording_facts(path: Path) -> Facts:
284
+ """Reads the title, the length and the goals of a recording file.
285
+
286
+ A goal is a frame whose score is higher on either side than the frame before, as the page
287
+ finds them.
288
+
289
+ Raises:
290
+ VideoError: If the file is not a readable recording.
291
+ """
292
+ from .._format import recording as _recording
293
+
294
+ try:
295
+ header = _recording.read_header(path)
296
+ sps = float(header.get('steps_per_second') or 10)
297
+ goals, previous, count = [], None, 0
298
+ for frame in _recording.frames(path):
299
+ score = struct.unpack_from('<2i', frame, 8)
300
+ if previous is not None and (score[0] > previous[0] or score[1] > previous[1]):
301
+ goals.append(count / sps)
302
+ previous = score
303
+ count += 1
304
+ except (OSError, EOFError, ValueError, KeyError, struct.error, Error) as error:
305
+ raise VideoMessageError('server.video.unreadable', path=path, error=error) from error
306
+ if count == 0:
307
+ raise VideoMessageError('server.video.no_frames', path=path)
308
+ return Facts(str(header.get('title') or path.stem), count, sps, tuple(goals))
309
+
310
+
311
+ def resolve_range(options: Options, facts: Facts) -> tuple[float, float]:
312
+ """Returns the start and end of the export in seconds from the first frame.
313
+
314
+ The range runs by default over the whole recording, or from `GOAL_BEFORE` seconds before to
315
+ `GOAL_AFTER` seconds after the goal of `options.goal`. A start or end given as a moment takes
316
+ the place of either, and the range is then kept within the recording.
317
+
318
+ Raises:
319
+ ArgumentError: If a goal is missing from the recording or the range holds no frames.
320
+ """
321
+
322
+ def goal_time(number: int) -> float:
323
+ if number > len(facts.goals):
324
+ raise MessageError('server.video.goal_range', count=len(facts.goals), goal=number)
325
+ return facts.goals[number - 1]
326
+
327
+ def at(moment: Moment) -> float:
328
+ return moment.seconds if moment.goal is None else goal_time(moment.goal) + moment.seconds
329
+
330
+ start, end = 0.0, facts.duration
331
+ if options.goal is not None:
332
+ moment = goal_time(options.goal)
333
+ start, end = moment - GOAL_BEFORE, moment + GOAL_AFTER
334
+ if options.start is not None:
335
+ start = at(options.start)
336
+ if options.end is not None:
337
+ end = at(options.end)
338
+ start, end = max(0.0, start), min(facts.duration, end)
339
+ if end - start < 1 / facts.steps_per_second:
340
+ raise MessageError(
341
+ 'server.video.empty_range',
342
+ duration=f'{facts.duration:.1f}',
343
+ start=f'{start:.1f}',
344
+ end=f'{end:.1f}',
345
+ )
346
+ return start, end
347
+
348
+
349
+ def frame_count(start: float, end: float, fps: int) -> int:
350
+ """Returns the number of frames of a range, one per frame interval and at least one."""
351
+ return max(1, round((end - start) * fps))
352
+
353
+
354
+ def find_program(names: tuple[str, ...], explicit: str | None, variable: str) -> str | None:
355
+ """Returns the path of a program named by option, by variable or on the path, or `None`."""
356
+ for given in (explicit, os.environ.get(variable)):
357
+ if given:
358
+ found = shutil.which(given) or (given if Path(given).is_file() else None)
359
+ if found is None:
360
+ raise VideoMessageError('server.video.program', given=given)
361
+ return found
362
+ for name in names:
363
+ found = shutil.which(name)
364
+ if found:
365
+ return found
366
+ return None
367
+
368
+
369
+ def _chrome_locations() -> list[str]:
370
+ """Returns the usual places of Chrome, Chromium and Edge outside the path."""
371
+ if sys.platform == 'win32':
372
+ roots = [
373
+ os.environ.get(key) for key in ('PROGRAMFILES', 'PROGRAMFILES(X86)', 'LOCALAPPDATA')
374
+ ]
375
+ tails = [
376
+ r'Google\Chrome\Application\chrome.exe',
377
+ r'Chromium\Application\chrome.exe',
378
+ r'Microsoft\Edge\Application\msedge.exe',
379
+ ]
380
+ return [str(Path(root) / tail) for root in roots if root for tail in tails]
381
+ if sys.platform == 'darwin':
382
+ return [
383
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
384
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
385
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
386
+ ]
387
+ return ['/opt/google/chrome/chrome', '/snap/bin/chromium']
388
+
389
+
390
+ def find_chrome(explicit: str | None = None) -> str | None:
391
+ """Returns the path of Chrome or Chromium, or `None` when this machine has neither."""
392
+ names = ('google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome')
393
+ found = find_program(names, explicit, 'CUJU_CHROME')
394
+ if found:
395
+ return found
396
+ return next((path for path in _chrome_locations() if Path(path).is_file()), None)
397
+
398
+
399
+ def find_ffmpeg(explicit: str | None = None) -> str | None:
400
+ """Returns the path of ffmpeg, or `None` when it is neither on the path nor named."""
401
+ return find_program(('ffmpeg',), explicit, 'CUJU_FFMPEG')
402
+
403
+
404
+ def tools(
405
+ options: Options, server: bool = False
406
+ ) -> tuple[str | None, str | None, list[dict[str, Any]]]:
407
+ """Returns the paths of Chrome and ffmpeg and a message for each one missing.
408
+
409
+ Each message is a label of `_messages.label`, which says how to install the program or how to
410
+ name it, so the page shows it in its own language, and its English text is in `text`.
411
+
412
+ Args:
413
+ options: The options, which may name either program.
414
+ server: Whether the messages are for the page's server, which takes the variables alone.
415
+ """
416
+ tail = '' if server else '_command'
417
+ missing = []
418
+ chrome = find_chrome(options.chrome)
419
+ if chrome is None:
420
+ missing.append(chrome_missing(server))
421
+ ffmpeg = find_ffmpeg(options.ffmpeg)
422
+ if ffmpeg is None:
423
+ missing.append(_messages.label(f'server.video.missing_ffmpeg{tail}'))
424
+ return chrome, ffmpeg, missing
425
+
426
+
427
+ def chrome_missing(server: bool = False) -> dict[str, Any]:
428
+ """Returns the message of a missing Chrome as a label, for the command or the page's server."""
429
+ return _messages.label('server.video.missing_chrome' + ('' if server else '_command'))
430
+
431
+
432
+ def requested_format(output: Path, options: Options) -> str | None:
433
+ """Returns the container that an export must write, or `None` for the browser's best.
434
+
435
+ The format `auto` leaves the choice to the browser, and the output then takes the suffix of the
436
+ container chosen. A format of mp4 or webm, or without a format the suffix of the output, asks
437
+ for that container alone.
438
+
439
+ Raises:
440
+ MessageError: If the output ends in neither .mp4 nor .webm, or in the other one's suffix.
441
+ """
442
+ suffix = output.suffix.lower()
443
+ if suffix not in SUFFIXES.values():
444
+ raise MessageError('server.video.output', name=output.name)
445
+ if options.format == 'auto':
446
+ return None
447
+ if options.format in SUFFIXES:
448
+ if SUFFIXES[options.format] != suffix:
449
+ raise MessageError(
450
+ 'server.video.output_format',
451
+ suffix=SUFFIXES[options.format],
452
+ format=options.format,
453
+ name=output.name,
454
+ )
455
+ return options.format
456
+ return 'mp4' if suffix == '.mp4' else 'webm'
457
+
458
+
459
+ def choose_encoding(
460
+ wanted: str | None, support: dict[str, Any], ffmpeg: str | None, browser: str = 'the browser'
461
+ ) -> tuple[str, str]:
462
+ """Returns how an export encodes, `browser` or `ffmpeg`, and the container it writes.
463
+
464
+ The browser encodes whatever it can, MP4 first. ffmpeg encodes an MP4 only where the browser
465
+ encodes no H.264, or cannot read back the page's graphics, and only when it is installed.
466
+
467
+ Args:
468
+ wanted: The container asked for, or `None` for the best one.
469
+ support: What the browser encodes, the codec of `mp4` and of `webm` or `None` for each, and
470
+ `pictures`, whether it reads back the page's graphics.
471
+ ffmpeg: The path of ffmpeg, or `None`.
472
+ browser: The browser's name for the messages.
473
+
474
+ Raises:
475
+ VideoMessageError: If neither the browser nor ffmpeg writes the container asked for.
476
+ """
477
+ usable = bool(support.get('pictures'))
478
+ mp4 = usable and bool(support.get('mp4'))
479
+ webm = usable and bool(support.get('webm'))
480
+ if wanted in (None, 'mp4'):
481
+ if mp4:
482
+ return 'browser', 'mp4'
483
+ if ffmpeg:
484
+ return 'ffmpeg', 'mp4'
485
+ if wanted in (None, 'webm') and webm:
486
+ return 'browser', 'webm'
487
+ if wanted == 'mp4':
488
+ found = 'server.video.no_h264_webm' if webm else 'server.video.no_h264'
489
+ raise VideoMessageError(found, browser=browser)
490
+ raise VideoMessageError(
491
+ 'server.video.no_webm' if wanted else 'server.video.no_encoder', browser=browser
492
+ )
493
+
494
+
495
+ def missing_label(missing: list[dict[str, Any]]) -> dict[str, Any]:
496
+ """Returns one label for the messages of `tools` of the missing programs, one or both."""
497
+ if len(missing) == 1:
498
+ return missing[0]
499
+ return _messages.label('server.video.missing_both', chrome=missing[0], ffmpeg=missing[1])
500
+
501
+
502
+ def view(width: int, height: int, graphics: float = 1.0) -> tuple[int, int, float]:
503
+ """Returns the page's width and height in CSS pixels and the scale that gives the picture.
504
+
505
+ The page lays out with its shorter side at 720 CSS pixels, so its graphics keep their
506
+ proportion at every size and shape, and a smaller picture lays out at its own size, as
507
+ `_sizes.layout` gives it with the scale of the graphics.
508
+ """
509
+ return _sizes.layout(width, height, graphics)
510
+
511
+
512
+ def chrome_command(
513
+ chrome: str, profile: str, options: Options, extra: tuple[str, ...] = ()
514
+ ) -> list[str]:
515
+ """Returns the command line of a headless Chrome that serves its debugging protocol.
516
+
517
+ Chrome picks a free port for the protocol and writes it into the profile. A proxy that answers
518
+ nothing keeps every address other than the loopback server out of reach.
519
+ """
520
+ css_width, css_height, _ = view(options.width, options.height, options.graphics)
521
+ flags = [
522
+ chrome,
523
+ '--headless=new',
524
+ '--mute-audio',
525
+ '--no-first-run',
526
+ '--no-default-browser-check',
527
+ '--disable-background-networking',
528
+ '--disable-component-update',
529
+ '--disable-sync',
530
+ '--disable-default-apps',
531
+ '--disable-extensions',
532
+ '--disable-background-timer-throttling',
533
+ '--disable-renderer-backgrounding',
534
+ '--disable-backgrounding-occluded-windows',
535
+ '--autoplay-policy=user-gesture-required',
536
+ '--proxy-server=http://127.0.0.1:9',
537
+ '--proxy-bypass-list=127.0.0.1',
538
+ '--remote-debugging-port=0',
539
+ f'--user-data-dir={profile}',
540
+ f'--window-size={css_width},{css_height}',
541
+ '--hide-scrollbars',
542
+ '--ignore-gpu-blocklist',
543
+ '--enable-unsafe-swiftshader',
544
+ ]
545
+ if options.software:
546
+ flags.append('--use-angle=swiftshader')
547
+ # Chrome refuses its sandbox to the root user, as in many containers.
548
+ if not options.sandbox or (hasattr(os, 'geteuid') and os.geteuid() == 0):
549
+ flags.append('--no-sandbox')
550
+ return [*flags, *extra, 'about:blank']
551
+
552
+
553
+ def ffmpeg_command(ffmpeg: str, output: Path, options: Options) -> list[str]:
554
+ """Returns the command line of ffmpeg that encodes the PNG pictures of its input into MP4."""
555
+ crf, preset, _ = QUALITIES[options.quality]
556
+ return [
557
+ ffmpeg,
558
+ '-hide_banner',
559
+ '-loglevel',
560
+ 'error',
561
+ '-y',
562
+ '-f',
563
+ 'image2pipe',
564
+ '-framerate',
565
+ str(options.fps),
566
+ '-c:v',
567
+ 'png',
568
+ '-i',
569
+ '-',
570
+ '-vf',
571
+ f'scale={options.width}:{options.height}:flags=lanczos,format=yuv420p',
572
+ '-c:v',
573
+ 'libx264',
574
+ '-preset',
575
+ preset,
576
+ '-crf',
577
+ str(crf),
578
+ '-r',
579
+ str(options.fps),
580
+ '-an',
581
+ '-movflags',
582
+ '+faststart',
583
+ '-fs',
584
+ str(options.max_megabytes * 1024 * 1024),
585
+ '-f',
586
+ 'mp4',
587
+ str(output),
588
+ ]
589
+
590
+
591
+ def preload_script(options: Options) -> str:
592
+ """Returns the script that sets the page's settings before the page itself runs.
593
+
594
+ Sound stays muted, the pointer stays free and controllers stay unseen. The page shows the
595
+ picture alone in its broadcast mode. Without `broadcast` it keeps the team sheets, the
596
+ presentation of restarts, celebrations, replays and the show after the match off, which hold
597
+ or stop play, so the video follows the match clock. The labels and the box of a recording's
598
+ annotations show only with `annotations`, and then with their numbers, actions and text.
599
+ """
600
+ _, _, drawing = QUALITIES[options.quality]
601
+ field: dict[str, Any] = {}
602
+ if options.arena is not None:
603
+ field['arena'] = options.arena
604
+ if options.hour is not None:
605
+ field['time'] = options.hour
606
+ settings: dict[str, Any] = {
607
+ 'muted': True,
608
+ 'volume': 0,
609
+ 'music': False,
610
+ 'quality': drawing,
611
+ 'theme': 'dark',
612
+ 'tvMode': True,
613
+ 'idleReturn': False,
614
+ 'pointerLock': False,
615
+ 'camera': options.camera if options.camera in CAMERAS[:3] else 'broadcast',
616
+ 'lineups': options.broadcast,
617
+ 'celebrations': options.broadcast,
618
+ 'replays': options.broadcast,
619
+ 'decisionReplays': options.broadcast,
620
+ 'transitions': options.broadcast,
621
+ 'podium': False,
622
+ 'motion': 'full',
623
+ 'annotations': dict.fromkeys(('players', 'actions', 'text'), options.annotations),
624
+ }
625
+ if field:
626
+ settings['field'] = field
627
+ lines = [
628
+ f'localStorage.setItem({json.dumps("cuju." + key)}, {json.dumps(json.dumps(value))});'
629
+ for key, value in settings.items()
630
+ ]
631
+ lines += [
632
+ "Object.defineProperty(navigator, 'getGamepads', { configurable: true, value: () => [] });",
633
+ 'Element.prototype.requestPointerLock = function () {',
634
+ " return Promise.reject(new Error('The pointer stays free while exporting.'));",
635
+ '};',
636
+ ]
637
+ return '\n'.join(lines)
638
+
639
+
640
+ # The page is ready once the recording is open, its players drawn and the shipped assets loaded.
641
+ _READY = """(() => {
642
+ const fatal = document.getElementById('fatal');
643
+ if (fatal && !fatal.hidden && fatal.textContent) return { fatal: fatal.textContent };
644
+ const app = window.cuju;
645
+ const ready = !!(window.cujuCapture && app && app.source && app.source.id === %s
646
+ && app.timeline && app.players && app.assets && (app.assets.manifest || app.assets.error)
647
+ && document.getElementById('loading').hidden);
648
+ return { ready };
649
+ })()"""
650
+
651
+ # Holds the page at the first frame of the warm-up with the camera of the export in place.
652
+ _HOLD = """(() => {
653
+ const app = cuju;
654
+ if (app.director.lineup && (%(broadcast)s === false || %(at)s > 0)) {
655
+ app.director.closeLineups(false);
656
+ }
657
+ // A short recording may play to its end while the page loads, so the card and the show of
658
+ // its end go before the first frame is held.
659
+ clearTimeout(app.fullTimeTimer);
660
+ if (app.postMatch && app.postMatch.running) app.postMatch.end();
661
+ if (app.transition && app.transition.title) app.transition.title.hide();
662
+ app.endCard = false;
663
+ app.timeline.pause();
664
+ app.setCamera(%(camera)s);
665
+ const tl = app.timeline;
666
+ tl.seek(tl.first + %(at)s * tl.sps);
667
+ app.afterSeek();
668
+ if (app.cameras.jump) app.cameras.jump();
669
+ if (%(score)s === false) {
670
+ const style = document.createElement('style');
671
+ style.textContent = '#scoreboard { display: none !important; }';
672
+ document.head.append(style);
673
+ }
674
+ return true;
675
+ })()"""
676
+
677
+ # Seeks the held frame again, since the page may have moved while it settled, freezes the page's
678
+ # clocks and plays the match from there, or leaves the team sheets to start play when they show.
679
+ _PLAY = """(() => {
680
+ const app = cuju;
681
+ const tl = app.timeline;
682
+ tl.pause();
683
+ tl.seek(tl.first + %(at)s * tl.sps);
684
+ app.afterSeek();
685
+ if (app.cameras.jump) app.cameras.jump();
686
+ cujuCapture.start();
687
+ // The first stepped frame measures its time from the frozen clock, never from the last frame
688
+ // that Chrome drew before the page settled.
689
+ app.lastTime = performance.now();
690
+ if (!app.director.lineup) tl.play(1);
691
+ return (tl.t - tl.first) / tl.sps;
692
+ })()"""
693
+
694
+ # Moves the page by one frame and returns where the match then stands, in seconds.
695
+ _STEP = '(cujuCapture.step(%s), (cuju.timeline.t - cuju.timeline.first) / cuju.timeline.sps)'
696
+
697
+
698
+ class _CDP:
699
+ """A connection to one page of Chrome over its debugging protocol, on the standard library."""
700
+
701
+ def __init__(self, url: str, timeout: float = 120) -> None:
702
+ address = urllib.parse.urlsplit(url)
703
+ self.socket = socket.create_connection((address.hostname, address.port), timeout=timeout)
704
+ key = base64.b64encode(os.urandom(16)).decode()
705
+ self.socket.sendall(
706
+ (
707
+ f'GET {address.path} HTTP/1.1\r\nHost: {address.netloc}\r\n'
708
+ f'Upgrade: websocket\r\nConnection: Upgrade\r\n'
709
+ f'Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n'
710
+ ).encode()
711
+ )
712
+ header = b''
713
+ while not header.endswith(b'\r\n\r\n'):
714
+ chunk = self.socket.recv(1)
715
+ if not chunk:
716
+ raise VideoMessageError('server.video.cdp_start')
717
+ header += chunk
718
+ if b' 101 ' not in header.split(b'\r\n')[0]:
719
+ raise VideoMessageError('server.video.cdp_refused', header=repr(header[:80]))
720
+ self.sequence = 0
721
+ self.errors: list[str] = []
722
+
723
+ def close(self) -> None:
724
+ with contextlib.suppress(OSError):
725
+ self.socket.close()
726
+
727
+ def _read(self, size: int) -> bytes:
728
+ data = bytearray()
729
+ while len(data) < size:
730
+ chunk = self.socket.recv(min(size - len(data), 1 << 20))
731
+ if not chunk:
732
+ raise VideoMessageError('server.video.cdp_closed')
733
+ data += chunk
734
+ return bytes(data)
735
+
736
+ def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
737
+ self.sequence += 1
738
+ data = json.dumps({'id': self.sequence, 'method': method, 'params': params or {}}).encode()
739
+ mask = os.urandom(4)
740
+ size = len(data)
741
+ if size < 126:
742
+ header = bytes([129, 128 | size])
743
+ elif size < 65536:
744
+ header = bytes([129, 254]) + struct.pack('!H', size)
745
+ else:
746
+ header = bytes([129, 255]) + struct.pack('!Q', size)
747
+ masked = bytes(value ^ mask[index % 4] for index, value in enumerate(data))
748
+ self.socket.sendall(header + mask + masked)
749
+ while True:
750
+ first, second = self._read(2)
751
+ size = second & 127
752
+ if size == 126:
753
+ size = struct.unpack('!H', self._read(2))[0]
754
+ elif size == 127:
755
+ size = struct.unpack('!Q', self._read(8))[0]
756
+ payload = self._read(size)
757
+ if first & 15 == 8:
758
+ raise VideoMessageError('server.video.cdp_closed')
759
+ if first & 15 != 1:
760
+ continue
761
+ message = json.loads(payload)
762
+ if message.get('method') == 'Runtime.exceptionThrown':
763
+ details = message['params']['exceptionDetails']
764
+ self.errors.append(str(details.get('exception', {}).get('description', details)))
765
+ if message.get('id') == self.sequence:
766
+ if 'error' in message:
767
+ raise VideoMessageError(
768
+ 'server.video.cdp_method', method=method, error=message['error']
769
+ )
770
+ return message.get('result', {})
771
+
772
+ def evaluate(self, expression: str) -> Any:
773
+ result = self.call(
774
+ 'Runtime.evaluate',
775
+ {'expression': expression, 'returnByValue': True, 'awaitPromise': True},
776
+ )
777
+ if 'exceptionDetails' in result:
778
+ details = result['exceptionDetails']
779
+ text = details.get('exception', {}).get('description') or details.get('text')
780
+ raise VideoMessageError('server.video.page_failed', error=text)
781
+ return result.get('result', {}).get('value')
782
+
783
+
784
+ class _NoRequests:
785
+ """Stands in for the queue of requested matches, which an export leaves alone."""
786
+
787
+ def entries(self) -> list[dict[str, Any]]:
788
+ return []
789
+
790
+ def add(self, *arguments: Any) -> dict[str, Any]:
791
+ raise ArgumentError('expected no requested matches while exporting a video')
792
+
793
+ def cancel(self, identifier: str) -> bool:
794
+ return False
795
+
796
+
797
+ @contextlib.contextmanager
798
+ def _page_server(path: Path, identifier: str) -> Any:
799
+ """Serves the page and its API on a free loopback port, with the recording at `identifier`."""
800
+ from .._http import server as _server
801
+
802
+ base = _server.make_handler(_NoRequests()) # type: ignore[arg-type]
803
+
804
+ class Handler(base): # type: ignore[misc, valid-type]
805
+ def _recording(self, name: str) -> None:
806
+ if name != identifier:
807
+ super()._recording(name)
808
+ return
809
+ size = path.stat().st_size
810
+ self.send_response(200)
811
+ self.send_header('Content-Type', 'application/octet-stream')
812
+ self.send_header('Content-Length', str(size))
813
+ self.end_headers()
814
+ with path.open('rb') as stream:
815
+ while chunk := stream.read(1 << 20):
816
+ self.wfile.write(chunk)
817
+
818
+ # The server of the renderer, whose queue of connections holds the bursts of the page.
819
+ from .._http.launch import LoopbackServer
820
+
821
+ server = LoopbackServer(('127.0.0.1', 0), Handler)
822
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
823
+ thread.start()
824
+ try:
825
+ yield f'http://127.0.0.1:{server.server_address[1]}/'
826
+ finally:
827
+ server.shutdown()
828
+ server.server_close()
829
+
830
+
831
+ def _spawn(command: list[str], **options: Any) -> subprocess.Popen:
832
+ """Starts a child process in a group of its own, so Ctrl+C reaches the export alone."""
833
+ if sys.platform == 'win32':
834
+ options['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP
835
+ else:
836
+ options['start_new_session'] = True
837
+ return subprocess.Popen(command, **options)
838
+
839
+
840
+ def _stop(process: subprocess.Popen | None, grace: float = 10) -> None:
841
+ """Ends a child process, waiting `grace` seconds before it is killed."""
842
+ if process is None or process.poll() is not None:
843
+ return
844
+ process.terminate()
845
+ try:
846
+ process.wait(grace)
847
+ except subprocess.TimeoutExpired:
848
+ process.kill()
849
+ with contextlib.suppress(subprocess.TimeoutExpired):
850
+ process.wait(grace)
851
+
852
+
853
+ def _remove(path: Path) -> None:
854
+ """Removes a file or folder, waiting a moment for a program that still holds it."""
855
+ for _ in range(20):
856
+ try:
857
+ if path.is_dir():
858
+ shutil.rmtree(path)
859
+ else:
860
+ path.unlink(missing_ok=True)
861
+ return
862
+ except OSError:
863
+ time.sleep(0.25)
864
+
865
+
866
+ class _Chrome:
867
+ """A headless Chrome with one page, started in a temporary profile that is removed after."""
868
+
869
+ def __init__(self, chrome: str, options: Options) -> None:
870
+ self.profile = Path(tempfile.mkdtemp(prefix='cuju-video-'))
871
+ self.process: subprocess.Popen | None = None
872
+ self.client: _CDP | None = None
873
+ self.command = chrome_command(chrome, str(self.profile), options)
874
+
875
+ def start(self, timeout: float = 60) -> _CDP:
876
+ self.process = _spawn(
877
+ self.command,
878
+ stdin=subprocess.DEVNULL,
879
+ stdout=subprocess.DEVNULL,
880
+ stderr=subprocess.DEVNULL,
881
+ )
882
+ marker = self.profile / 'DevToolsActivePort'
883
+ deadline = time.monotonic() + timeout
884
+ port = None
885
+ while time.monotonic() < deadline:
886
+ if self.process.poll() is not None:
887
+ raise VideoMessageError('server.video.chrome_exit', code=self.process.returncode)
888
+ with contextlib.suppress(OSError, ValueError, IndexError):
889
+ port = int(marker.read_text(encoding='utf-8').split()[0])
890
+ break
891
+ time.sleep(0.1)
892
+ if port is None:
893
+ raise VideoMessageError('server.video.chrome_port', seconds=f'{timeout:.0f}')
894
+ targets: list[dict[str, Any]] = []
895
+ while time.monotonic() < deadline and not targets:
896
+ with contextlib.suppress(OSError, ValueError):
897
+ with urllib.request.urlopen(f'http://127.0.0.1:{port}/json/list', timeout=5) as r:
898
+ targets = [t for t in json.load(r) if t.get('type') == 'page']
899
+ if not targets:
900
+ time.sleep(0.1)
901
+ if not targets:
902
+ raise VideoMessageError('server.video.chrome_page')
903
+ self.client = _CDP(targets[0]['webSocketDebuggerUrl'])
904
+ return self.client
905
+
906
+ def stop(self) -> None:
907
+ if self.client is not None:
908
+ # A connection cut off in the middle of a message closes Chrome by its process alone.
909
+ with contextlib.suppress(Exception):
910
+ self.client.socket.settimeout(3)
911
+ self.client.call('Browser.close')
912
+ self.client.close()
913
+ _stop(self.process, 5)
914
+ _remove(self.profile)
915
+
916
+
917
+ Report = Callable[[dict[str, Any]], None]
918
+
919
+
920
+ def _wait_ready(client: _CDP, identifier: str, timeout: float = 120) -> None:
921
+ """Waits until the page has opened the recording and loaded what it draws."""
922
+ expression = _READY % json.dumps(identifier)
923
+ deadline = time.monotonic() + timeout
924
+ while time.monotonic() < deadline:
925
+ state = client.evaluate(expression) or {}
926
+ if state.get('fatal'):
927
+ raise VideoMessageError('server.video.page_draw', error=state['fatal'])
928
+ if state.get('ready'):
929
+ return
930
+ time.sleep(0.2)
931
+ errors = '; '.join(client.errors[:3])
932
+ seconds = f'{timeout:.0f}'
933
+ if errors:
934
+ raise VideoMessageError('server.video.page_ready_errors', seconds=seconds, errors=errors)
935
+ raise VideoMessageError('server.video.page_ready', seconds=seconds)
936
+
937
+
938
+ def _wait_host(client: _CDP, options: Options, timeout: float = 120) -> dict[str, Any]:
939
+ """Waits until the page of the export has loaded and returns what the browser encodes."""
940
+ expression = (
941
+ 'window.cujuVideoExport ? cujuVideoExport.support('
942
+ f'{options.width}, {options.height}, {options.fps}, {json.dumps(options.quality)}) : null'
943
+ )
944
+ deadline = time.monotonic() + timeout
945
+ while time.monotonic() < deadline:
946
+ support = client.evaluate(expression)
947
+ if support:
948
+ return support
949
+ time.sleep(0.2)
950
+ errors = '; '.join(client.errors[:3])
951
+ if errors:
952
+ raise VideoMessageError('server.video.host_errors', seconds=f'{timeout:.0f}', errors=errors)
953
+ raise VideoMessageError('server.video.host', seconds=f'{timeout:.0f}')
954
+
955
+
956
+ def _browser_error(state: dict[str, Any], options: Options) -> VideoMessageError:
957
+ """Returns the error of an export that failed in the browser, from the state it left."""
958
+ found = state.get('id')
959
+ values = state.get('values') or {}
960
+ if found == 'video.browser.error.size':
961
+ return VideoMessageError('server.video.max_size', most=options.max_megabytes)
962
+ if found == 'video.browser.error.page':
963
+ return VideoMessageError('server.video.page_draw', error=values.get('error'))
964
+ if found == 'video.browser.error.timeout':
965
+ return VideoMessageError('server.video.page_ready', seconds=values.get('seconds'))
966
+ if found == 'video.browser.error.encoder':
967
+ return VideoMessageError('server.video.encoder', error=values.get('error'))
968
+ if found == 'video.browser.error.codec':
969
+ return VideoMessageError('server.video.codec')
970
+ return VideoMessageError('server.video.page_failed', error=state.get('message'))
971
+
972
+
973
+ def _draw_in_browser(
974
+ client: _CDP,
975
+ page_id: str,
976
+ title: str,
977
+ options: Options,
978
+ container: str,
979
+ span: tuple[float, float],
980
+ limit: int,
981
+ partial: Path,
982
+ summary: dict[str, Any],
983
+ report: Report,
984
+ cancelled: threading.Event,
985
+ ) -> tuple[int, bool, str]:
986
+ """Runs the browser's own export and writes the file it makes to `partial`.
987
+
988
+ Returns:
989
+ The frames written, whether the video reached its maximum length and the codec.
990
+ """
991
+ start, end = span
992
+ body: dict[str, Any] = {
993
+ 'start': start,
994
+ 'end': end,
995
+ 'camera': options.camera,
996
+ 'width': options.width,
997
+ 'height': options.height,
998
+ 'fps': options.fps,
999
+ 'quality': options.quality,
1000
+ 'score': options.score,
1001
+ 'annotations': options.annotations,
1002
+ 'scale': options.graphics,
1003
+ 'safe': options.safe,
1004
+ 'broadcast': options.broadcast,
1005
+ }
1006
+ if options.arena is not None:
1007
+ body['arena'] = options.arena
1008
+ if options.hour is not None:
1009
+ body['time'] = options.hour
1010
+ request = {
1011
+ 'match': page_id,
1012
+ 'title': title,
1013
+ 'body': body,
1014
+ 'container': container,
1015
+ 'maxFrames': limit,
1016
+ 'maxBytes': options.max_megabytes * 1024 * 1024,
1017
+ }
1018
+ client.evaluate(f'cujuVideoExport.start({json.dumps(request)})')
1019
+ report({'stage': 'drawing', 'frame': 0, 'progress': 0.0, **summary})
1020
+ reported = time.monotonic()
1021
+ asked = False
1022
+ while True:
1023
+ if cancelled.is_set() and not asked:
1024
+ client.evaluate('cujuVideoExport.cancel()')
1025
+ asked = True
1026
+ state = client.evaluate('cujuVideoExport.state()') or {}
1027
+ stage = state.get('state')
1028
+ if stage == 'cancelled':
1029
+ raise CancelledError('the export was cancelled')
1030
+ if stage == 'failed':
1031
+ raise _browser_error(state, options)
1032
+ if stage == 'done':
1033
+ break
1034
+ now = time.monotonic()
1035
+ if stage == 'running' and state.get('frame') and now - reported >= 0.5:
1036
+ reported = now
1037
+ progress = min(1.0, max(0.0, float(state.get('progress') or 0)))
1038
+ report(
1039
+ {'stage': 'drawing', 'frame': int(state['frame']), 'progress': progress, **summary}
1040
+ )
1041
+ time.sleep(0.1 if asked else 0.25)
1042
+ written = int(state.get('frames') or 0)
1043
+ report({'stage': 'drawing', 'frame': written, 'progress': 1.0, **summary})
1044
+ limited = bool(state.get('limited'))
1045
+ if limited:
1046
+ report({'stage': 'limited', 'frame': written, **summary})
1047
+ report({'stage': 'encoding', 'frame': written, **summary})
1048
+ size = int(state.get('bytes') or 0)
1049
+ with partial.open('wb') as stream:
1050
+ for offset in range(0, size, _PIECE):
1051
+ if cancelled.is_set():
1052
+ raise CancelledError('the export was cancelled')
1053
+ piece = base64.b64decode(client.evaluate(f'cujuVideoExport.read({offset}, {_PIECE})'))
1054
+ if not piece:
1055
+ raise VideoMessageError('server.video.lost')
1056
+ stream.write(piece)
1057
+ client.evaluate('cujuVideoExport.clear()')
1058
+ if partial.stat().st_size != size:
1059
+ raise VideoMessageError('server.video.bytes', size=size, found=partial.stat().st_size)
1060
+ return written, limited, str(state.get('codec') or '')
1061
+
1062
+
1063
+ def _draw_with_ffmpeg(
1064
+ client: _CDP,
1065
+ address: str,
1066
+ page_id: str,
1067
+ options: Options,
1068
+ ffmpeg: str,
1069
+ span: tuple[float, float],
1070
+ limit: int,
1071
+ partial: Path,
1072
+ summary: dict[str, Any],
1073
+ report: Report,
1074
+ cancelled: threading.Event,
1075
+ ) -> tuple[int, bool]:
1076
+ """Draws the export on the Watch page, takes a picture of each frame and encodes them with
1077
+ ffmpeg into `partial`, for an MP4 where the browser encodes no H.264.
1078
+
1079
+ Returns:
1080
+ The frames written and whether the video reached its maximum length.
1081
+ """
1082
+ start, end = span
1083
+ frames = summary['frames']
1084
+ warm = min(start, WARMUP)
1085
+ warm_frames = round(warm * options.fps)
1086
+ warm_from = start - warm_frames / options.fps
1087
+ step = 1000 / options.fps
1088
+ client.call('Page.addScriptToEvaluateOnNewDocument', {'source': preload_script(options)})
1089
+ css_width, css_height, scale = view(options.width, options.height, options.graphics)
1090
+ client.call(
1091
+ 'Emulation.setDeviceMetricsOverride',
1092
+ {'width': css_width, 'height': css_height, 'deviceScaleFactor': scale, 'mobile': False},
1093
+ )
1094
+ safe = urllib.parse.quote(str(options.safe))
1095
+ target = f'{address}?capture=1&safe={safe}#match={urllib.parse.quote(page_id)}'
1096
+ client.call('Page.navigate', {'url': target})
1097
+ _wait_ready(client, page_id)
1098
+ client.evaluate(
1099
+ _HOLD
1100
+ % {
1101
+ 'broadcast': json.dumps(options.broadcast),
1102
+ 'camera': json.dumps(options.camera),
1103
+ 'at': json.dumps(warm_from),
1104
+ 'score': json.dumps(options.score),
1105
+ }
1106
+ )
1107
+ # The page settles in real time, players rebuilt in their kits and the camera in place.
1108
+ time.sleep(1.0)
1109
+ if cancelled.is_set():
1110
+ raise CancelledError('the export was cancelled')
1111
+ client.evaluate(_PLAY % {'at': json.dumps(warm_from)})
1112
+ for _ in range(warm_frames):
1113
+ client.evaluate(_STEP % step)
1114
+ encoder = _spawn(
1115
+ ffmpeg_command(ffmpeg, partial, options),
1116
+ stdin=subprocess.PIPE,
1117
+ stdout=subprocess.DEVNULL,
1118
+ stderr=subprocess.PIPE,
1119
+ )
1120
+ try:
1121
+ stderr: list[bytes] = []
1122
+ threading.Thread(target=lambda: stderr.extend(encoder.stderr), daemon=True).start()
1123
+ report({'stage': 'drawing', 'frame': 0, 'progress': 0.0, **summary})
1124
+ reported = time.monotonic()
1125
+ total = frames if not options.broadcast else limit
1126
+ index = 0
1127
+ while index < total:
1128
+ if cancelled.is_set():
1129
+ raise CancelledError('the export was cancelled')
1130
+ position = client.evaluate(_STEP % (0 if index == 0 else step))
1131
+ if options.broadcast and index >= 1 and position >= end:
1132
+ break
1133
+ picture = client.call(
1134
+ 'Page.captureScreenshot', {'format': 'png', 'optimizeForSpeed': True}
1135
+ )
1136
+ try:
1137
+ encoder.stdin.write(base64.b64decode(picture['data']))
1138
+ except (BrokenPipeError, OSError) as error:
1139
+ encoder.wait(30)
1140
+ limit_bytes = options.max_megabytes * 1024 * 1024
1141
+ if partial.exists() and partial.stat().st_size >= 0.9 * limit_bytes:
1142
+ raise VideoMessageError(
1143
+ 'server.video.max_size', most=options.max_megabytes
1144
+ ) from error
1145
+ tail = b''.join(stderr).decode(errors='replace').strip()[-400:]
1146
+ raise VideoMessageError('server.video.ffmpeg_stopped', detail=tail) from error
1147
+ index += 1
1148
+ now = time.monotonic()
1149
+ if now - reported >= 0.5 or index == total:
1150
+ reported = now
1151
+ if options.broadcast:
1152
+ progress = (position - start) / max(1e-9, end - start)
1153
+ else:
1154
+ progress = index / total
1155
+ report(
1156
+ {
1157
+ 'stage': 'drawing',
1158
+ 'frame': index,
1159
+ 'progress': min(1.0, max(0.0, progress)),
1160
+ **summary,
1161
+ }
1162
+ )
1163
+ limited = options.broadcast and index >= total
1164
+ if limited:
1165
+ report({'stage': 'limited', 'frame': index, **summary})
1166
+ report({'stage': 'encoding', 'frame': index, **summary})
1167
+ encoder.stdin.close()
1168
+ code = encoder.wait()
1169
+ if code != 0:
1170
+ tail = b''.join(stderr).decode(errors='replace').strip()[-400:]
1171
+ raise VideoMessageError('server.video.ffmpeg_exit', code=code, detail=tail)
1172
+ return index, limited
1173
+ finally:
1174
+ if encoder.poll() is None:
1175
+ with contextlib.suppress(OSError):
1176
+ encoder.stdin.close()
1177
+ _stop(encoder, 5)
1178
+
1179
+
1180
+ def export(
1181
+ path: Path,
1182
+ output: Path,
1183
+ options: Options,
1184
+ identifier: str | None = None,
1185
+ report: Report | None = None,
1186
+ cancelled: threading.Event | None = None,
1187
+ overwrite: bool = True,
1188
+ ) -> dict[str, Any]:
1189
+ """Exports a range of a recording as a video file and returns what was written.
1190
+
1191
+ The browser encodes the video, MP4 with H.264 where it can and WebM otherwise, as `Options`
1192
+ asks through its format or the output's suffix, and an installed ffmpeg encodes an MP4 that
1193
+ the browser cannot. With the format auto the output takes the suffix of the container that the
1194
+ export writes. The video goes to a temporary file beside the output, which takes its name once
1195
+ complete, so an export that fails or is cancelled leaves no partial file behind.
1196
+
1197
+ Args:
1198
+ path: The recording file.
1199
+ output: The MP4 or WebM file to write.
1200
+ options: The checked options.
1201
+ identifier: The recording's identifier in the library, or `None` for a file outside it.
1202
+ report: Called with each report of progress, a dictionary with `stage` and, while
1203
+ drawing, `frame`, `frames` and `progress` from 0 to 1.
1204
+ cancelled: An event that cancels the export once set, beside Ctrl+C.
1205
+ overwrite: Whether the export may replace a file of the output's name.
1206
+
1207
+ Raises:
1208
+ ArgumentError: If an option or the range is invalid, or the output exists.
1209
+ VideoError: If the browser is missing, encodes nothing that is asked for, or stops, or
1210
+ the export is cancelled.
1211
+ """
1212
+ options.check()
1213
+ report = report or (lambda _: None)
1214
+ cancelled = cancelled or threading.Event()
1215
+ output = Path(output)
1216
+ wanted = requested_format(output, options)
1217
+ # The range is checked before the programs are looked for, so a mistake in it shows at once.
1218
+ facts = recording_facts(Path(path))
1219
+ start, end = resolve_range(options, facts)
1220
+ frames = frame_count(start, end, options.fps)
1221
+ limit = round(options.max_seconds * options.fps)
1222
+ if not options.broadcast and frames > limit:
1223
+ raise MessageError(
1224
+ 'server.video.max_length',
1225
+ most=f'{options.max_seconds:g}',
1226
+ start=f'{start:.1f}',
1227
+ end=f'{end:.1f}',
1228
+ length=f'{end - start:.1f}',
1229
+ )
1230
+ chrome = find_chrome(options.chrome)
1231
+ if chrome is None:
1232
+ found = chrome_missing()
1233
+ raise VideoMessageError(found['id'], **found['values'])
1234
+ # ffmpeg is optional and serves only an MP4 that the browser cannot encode.
1235
+ ffmpeg = find_ffmpeg(options.ffmpeg) if wanted != 'webm' else None
1236
+ summary = {
1237
+ 'title': facts.title,
1238
+ 'start': start,
1239
+ 'end': end,
1240
+ 'frames': frames,
1241
+ 'width': options.width,
1242
+ 'height': options.height,
1243
+ 'fps': options.fps,
1244
+ }
1245
+ report({'stage': 'starting', **summary})
1246
+ output.parent.mkdir(parents=True, exist_ok=True)
1247
+ page_id = identifier or _FILE_IDENTIFIER
1248
+ browser = _Chrome(chrome, options)
1249
+ partial: Path | None = None
1250
+ final = output
1251
+ written, limited, codec, way, container = 0, False, '', 'browser', wanted or 'mp4'
1252
+ try:
1253
+ with _page_server(Path(path), page_id) as address:
1254
+ client = browser.start()
1255
+ client.call('Runtime.enable')
1256
+ client.call('Page.enable')
1257
+ css_width, css_height, _ = view(options.width, options.height, options.graphics)
1258
+ client.call(
1259
+ 'Emulation.setDeviceMetricsOverride',
1260
+ {'width': css_width, 'height': css_height, 'deviceScaleFactor': 1, 'mobile': False},
1261
+ )
1262
+ client.call(
1263
+ 'Emulation.setEmulatedMedia',
1264
+ {
1265
+ 'features': [
1266
+ {'name': 'prefers-color-scheme', 'value': 'dark'},
1267
+ {'name': 'prefers-reduced-motion', 'value': 'no-preference'},
1268
+ ]
1269
+ },
1270
+ )
1271
+ client.call('Page.navigate', {'url': f'{address}export.html'})
1272
+ support = _wait_host(client, options)
1273
+ way, container = choose_encoding(wanted, support, ffmpeg, Path(chrome).name)
1274
+ final = output.with_suffix(SUFFIXES[container])
1275
+ if not overwrite and final.exists():
1276
+ raise ArgumentError(f'expected a new output file, {final} exists, add --overwrite')
1277
+ partial = final.with_name(f'.{final.stem}.part{final.suffix}')
1278
+ summary['format'] = container
1279
+ if cancelled.is_set():
1280
+ raise CancelledError('the export was cancelled')
1281
+ if way == 'browser':
1282
+ written, limited, codec = _draw_in_browser(
1283
+ client, page_id, facts.title, options, container, (start, end), limit,
1284
+ partial, summary, report, cancelled,
1285
+ ) # fmt: skip
1286
+ else:
1287
+ written, limited = _draw_with_ffmpeg(
1288
+ client, address, page_id, options, ffmpeg, (start, end), limit, partial,
1289
+ summary, report, cancelled,
1290
+ ) # fmt: skip
1291
+ codec = 'h264'
1292
+ errors = client.errors[:3]
1293
+ os.replace(partial, final)
1294
+ except KeyboardInterrupt as error:
1295
+ raise CancelledError('the export was cancelled') from error
1296
+ finally:
1297
+ browser.stop()
1298
+ if partial is not None and partial.exists():
1299
+ _remove(partial)
1300
+ result = {
1301
+ **summary,
1302
+ 'frames': written,
1303
+ 'seconds': written / options.fps,
1304
+ 'bytes': final.stat().st_size,
1305
+ 'path': str(final),
1306
+ 'format': container,
1307
+ 'codec': codec,
1308
+ 'encoder': way,
1309
+ 'limited': limited,
1310
+ 'page_errors': errors,
1311
+ }
1312
+ report({'stage': 'done', **result})
1313
+ return result
1314
+
1315
+
1316
+ def resolve_source(source: str) -> tuple[Path, str | None]:
1317
+ """Returns the recording file that a source names, a path or a library identifier.
1318
+
1319
+ Raises:
1320
+ ArgumentError: If the source is neither a file nor a recording of the library.
1321
+ """
1322
+ from .._store import library as _library
1323
+
1324
+ path = Path(source).expanduser()
1325
+ if path.is_file():
1326
+ return path, None
1327
+ found = _library.path_of(source)
1328
+ if found is not None:
1329
+ return found, source
1330
+ raise MessageError('server.video.source', source=repr(source))
1331
+
1332
+
1333
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
1334
+ """Adds the arguments of an export to a parser."""
1335
+ parser.add_argument('source', help='a .cjr recording file or the identifier of a recording')
1336
+ parser.add_argument(
1337
+ '-o',
1338
+ '--output',
1339
+ help='the MP4 or WebM file to write, by default the name of the source',
1340
+ )
1341
+ parser.add_argument(
1342
+ '--format',
1343
+ choices=FORMATS,
1344
+ help='the container, mp4 with H.264 or webm with VP9 or VP8, by default the suffix of '
1345
+ '--output, or auto, mp4 where the browser encodes H.264 and webm otherwise',
1346
+ )
1347
+ parser.add_argument(
1348
+ '--overwrite', action='store_true', help='replace the output file when it exists'
1349
+ )
1350
+ parser.add_argument(
1351
+ '--start',
1352
+ type=_moment,
1353
+ help='where the video starts, as seconds, minutes:seconds or goal:N with an offset',
1354
+ )
1355
+ parser.add_argument('--end', type=_moment, help='where the video ends, as --start takes it')
1356
+ parser.add_argument(
1357
+ '--goal',
1358
+ type=int,
1359
+ help=f'the range around goal N, from {GOAL_BEFORE:g} s before to {GOAL_AFTER:g} s after',
1360
+ )
1361
+ parser.add_argument('--camera', choices=CAMERAS, default='broadcast', help='the camera')
1362
+ parser.add_argument(
1363
+ '--size',
1364
+ type=_size,
1365
+ default=DEFAULT_SIZE,
1366
+ help='the picture size, as 1280x720 or a preset such as 1080p or vertical, '
1367
+ '1280x720 by default',
1368
+ )
1369
+ parser.add_argument(
1370
+ '--scale',
1371
+ type=_graphics,
1372
+ dest='graphics',
1373
+ default=1.0,
1374
+ help='the scale of the graphics, from 0.5 to 2, 1 by default',
1375
+ )
1376
+ parser.add_argument(
1377
+ '--safe',
1378
+ type=_safe,
1379
+ default='auto',
1380
+ help='the safe area of the graphics, auto, none, social or a percentage, auto by default',
1381
+ )
1382
+ parser.add_argument(
1383
+ '--fps', type=int, default=DEFAULT_FPS, help=f'frames a second, {DEFAULT_FPS} by default'
1384
+ )
1385
+ parser.add_argument(
1386
+ '--quality', choices=tuple(QUALITIES), default='standard', help='standard by default'
1387
+ )
1388
+ parser.add_argument(
1389
+ '--no-score', dest='score', action='store_false', help='hide the scoreboard and clock'
1390
+ )
1391
+ parser.add_argument('--arena', choices=ARENAS, help='the arena, by default the stadium')
1392
+ parser.add_argument(
1393
+ '--time',
1394
+ type=_hour,
1395
+ dest='hour',
1396
+ help='the time of day, as dawn, day, dusk, night or an hour from 0 to 24',
1397
+ )
1398
+ parser.add_argument(
1399
+ '--annotations',
1400
+ action='store_true',
1401
+ help="show the labels and the box of the recording's annotations",
1402
+ )
1403
+ parser.add_argument(
1404
+ '--broadcast',
1405
+ action='store_true',
1406
+ help='keep the team sheets, restarts, celebrations and replays, which lengthen the video',
1407
+ )
1408
+ parser.add_argument(
1409
+ '--max-seconds',
1410
+ type=float,
1411
+ default=DEFAULT_MAX_SECONDS,
1412
+ help=f'the longest video in seconds, {DEFAULT_MAX_SECONDS:g} by default',
1413
+ )
1414
+ parser.add_argument(
1415
+ '--max-size',
1416
+ type=int,
1417
+ dest='max_megabytes',
1418
+ default=DEFAULT_MAX_MEGABYTES,
1419
+ help=f'the largest file in MB, {DEFAULT_MAX_MEGABYTES} by default',
1420
+ )
1421
+ parser.add_argument(
1422
+ '--chrome', help='the Chrome, Edge or Chromium program that draws and encodes the video'
1423
+ )
1424
+ parser.add_argument(
1425
+ '--ffmpeg',
1426
+ help='the ffmpeg program, optional, which encodes an MP4 where the browser has no H.264',
1427
+ )
1428
+ parser.add_argument(
1429
+ '--software', action='store_true', help='draw without the graphics card, slower'
1430
+ )
1431
+ parser.add_argument(
1432
+ '--no-sandbox',
1433
+ dest='sandbox',
1434
+ action='store_false',
1435
+ help="turn off Chrome's sandbox, which Chrome needs as root or in some containers",
1436
+ )
1437
+
1438
+
1439
+ def _moment(text: str) -> Moment:
1440
+ try:
1441
+ return parse_moment(text)
1442
+ except ArgumentError as error:
1443
+ raise argparse.ArgumentTypeError(str(error)) from None
1444
+
1445
+
1446
+ def _size(text: str) -> tuple[int, int]:
1447
+ try:
1448
+ return parse_size(text)
1449
+ except ArgumentError as error:
1450
+ raise argparse.ArgumentTypeError(str(error)) from None
1451
+
1452
+
1453
+ def _graphics(text: str) -> float:
1454
+ try:
1455
+ return _sizes.parse_graphics(text)
1456
+ except ArgumentError as error:
1457
+ raise argparse.ArgumentTypeError(str(error)) from None
1458
+
1459
+
1460
+ def _safe(text: str) -> str | float:
1461
+ try:
1462
+ return _sizes.parse_safe(text)
1463
+ except ArgumentError as error:
1464
+ raise argparse.ArgumentTypeError(str(error)) from None
1465
+
1466
+
1467
+ def _hour(text: str) -> float:
1468
+ try:
1469
+ return parse_hour(text)
1470
+ except ArgumentError as error:
1471
+ raise argparse.ArgumentTypeError(str(error)) from None
1472
+
1473
+
1474
+ def options_from(namespace: argparse.Namespace) -> Options:
1475
+ """Returns the checked options of a parsed command line."""
1476
+ width, height = namespace.size
1477
+ return Options(
1478
+ start=namespace.start,
1479
+ end=namespace.end,
1480
+ goal=namespace.goal,
1481
+ camera=namespace.camera,
1482
+ width=width,
1483
+ height=height,
1484
+ fps=namespace.fps,
1485
+ quality=namespace.quality,
1486
+ score=namespace.score,
1487
+ annotations=namespace.annotations,
1488
+ arena=namespace.arena,
1489
+ hour=namespace.hour,
1490
+ broadcast=namespace.broadcast,
1491
+ graphics=namespace.graphics,
1492
+ safe=namespace.safe,
1493
+ max_seconds=namespace.max_seconds,
1494
+ max_megabytes=namespace.max_megabytes,
1495
+ chrome=namespace.chrome,
1496
+ ffmpeg=namespace.ffmpeg,
1497
+ software=namespace.software,
1498
+ sandbox=namespace.sandbox,
1499
+ format=getattr(namespace, 'format', None),
1500
+ ).check()
1501
+
1502
+
1503
+ def _clock(seconds: float) -> str:
1504
+ whole = int(seconds)
1505
+ return f'{whole // 60}:{whole % 60:02d}' + (f'.{int(seconds * 10) % 10}' if seconds % 1 else '')
1506
+
1507
+
1508
+ def _console(stream: Any) -> Report:
1509
+ """Returns a report of progress for a terminal, one updating line, or a line a tenth."""
1510
+ live = stream.isatty()
1511
+ state = {'tenth': -1, 'started': time.monotonic()}
1512
+
1513
+ def report(entry: dict[str, Any]) -> None:
1514
+ stage = entry['stage']
1515
+ if stage == 'starting':
1516
+ print(
1517
+ f'Exporting {entry["title"]} from {_clock(entry["start"])} to '
1518
+ f'{_clock(entry["end"])}, {entry["frames"]} frames of {entry["width"]}x'
1519
+ f'{entry["height"]} at {entry["fps"]} frames a second',
1520
+ file=stream,
1521
+ flush=True,
1522
+ )
1523
+ elif stage == 'drawing' and not entry['frame']:
1524
+ state['started'] = time.monotonic()
1525
+ elif stage == 'drawing':
1526
+ progress = entry['progress']
1527
+ elapsed = time.monotonic() - state['started']
1528
+ left = elapsed / progress - elapsed if progress > 0 else 0
1529
+ line = f'frame {entry["frame"]}, {progress:.0%}, about {left:.0f} s left'
1530
+ if live:
1531
+ print(f'\r{line} ', end='', file=stream, flush=True)
1532
+ elif int(progress * 10) > state['tenth']:
1533
+ state['tenth'] = int(progress * 10)
1534
+ print(line, file=stream, flush=True)
1535
+ elif stage == 'limited':
1536
+ print(
1537
+ '\nthe video reached its maximum length before the end of the range',
1538
+ file=stream,
1539
+ flush=True,
1540
+ )
1541
+ elif stage == 'encoding' and live:
1542
+ print(file=stream, flush=True)
1543
+
1544
+ return report
1545
+
1546
+
1547
+ def run(namespace: argparse.Namespace) -> int:
1548
+ """Runs the export of a parsed command line, printing progress, and returns the exit status."""
1549
+ options = options_from(namespace)
1550
+ path, identifier = resolve_source(namespace.source)
1551
+ if namespace.output:
1552
+ output = Path(namespace.output)
1553
+ if output.exists() and not namespace.overwrite:
1554
+ raise ArgumentError(f'expected a new output file, {output} exists, add --overwrite')
1555
+ else:
1556
+ # Without a file the video takes the name of the source and the suffix of its container.
1557
+ output = Path(f'{path.stem}{SUFFIXES.get(options.format or "", ".mp4")}')
1558
+ if options.format is None:
1559
+ options.format = 'auto'
1560
+ try:
1561
+ result = export(
1562
+ path, output, options, identifier, _console(sys.stderr), overwrite=namespace.overwrite
1563
+ )
1564
+ except (CancelledError, KeyboardInterrupt):
1565
+ print('\ncuju render: the export was cancelled, nothing was written', file=sys.stderr)
1566
+ return 130
1567
+ for error in result['page_errors']:
1568
+ print(f'cuju render: the page reported {error}', file=sys.stderr)
1569
+ print(
1570
+ f'saved {result["path"]}, {result["frames"]} frames, {result["seconds"]:.1f} s, '
1571
+ f'{file_size(result["bytes"])}'
1572
+ )
1573
+ return 0
1574
+
1575
+
1576
+ def file_size(count: int) -> str:
1577
+ """Returns the size of a file for people, in KB below a megabyte and in MB from there."""
1578
+ if count < 1e6:
1579
+ return f'{max(1, round(count / 1e3))} KB'
1580
+ return f'{count / 1e6:.1f} MB'
1581
+
1582
+
1583
+ def main(arguments: list[str] | None = None) -> int:
1584
+ """Runs one export for the page's server and reports its progress as JSON lines.
1585
+
1586
+ A line `cancel` on the standard input, or its end, cancels the export.
1587
+ """
1588
+ parser = argparse.ArgumentParser(prog='python -m cuju.render._export.video')
1589
+ add_arguments(parser)
1590
+ namespace = parser.parse_args(arguments)
1591
+
1592
+ def emit(entry: dict[str, Any]) -> None:
1593
+ print(json.dumps(entry), flush=True)
1594
+
1595
+ cancelled = threading.Event()
1596
+
1597
+ def watch() -> None:
1598
+ for line in sys.stdin:
1599
+ if line.strip() == 'cancel':
1600
+ break
1601
+ cancelled.set()
1602
+
1603
+ threading.Thread(target=watch, daemon=True).start()
1604
+ try:
1605
+ options = options_from(namespace)
1606
+ path, identifier = resolve_source(namespace.source)
1607
+ export(path, Path(namespace.output), options, identifier, emit, cancelled)
1608
+ except CancelledError:
1609
+ emit({'stage': 'cancelled'})
1610
+ return 130
1611
+ except (Error, OSError) as error:
1612
+ emit({'stage': 'failed', 'error': str(error), **_messages.id_fields(error)})
1613
+ return 1
1614
+ return 0
1615
+
1616
+
1617
+ if __name__ == '__main__':
1618
+ sys.exit(main())