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.
- cuju/render/__init__.py +759 -0
- cuju/render/__main__.py +1065 -0
- cuju/render/_export/__init__.py +3 -0
- cuju/render/_export/feed.py +542 -0
- cuju/render/_export/sizes.py +157 -0
- cuju/render/_export/video.py +1618 -0
- cuju/render/_export/video_jobs.py +408 -0
- cuju/render/_format/__init__.py +3 -0
- cuju/render/_format/annotations.py +235 -0
- cuju/render/_format/codec.py +307 -0
- cuju/render/_format/keep.py +121 -0
- cuju/render/_format/recording.py +860 -0
- cuju/render/_format/writer.py +54 -0
- cuju/render/_guide/__init__.py +3 -0
- cuju/render/_guide/doctor.py +523 -0
- cuju/render/_guide/help.py +62 -0
- cuju/render/_guide/tutorial.py +254 -0
- cuju/render/_http/__init__.py +3 -0
- cuju/render/_http/commands.py +57 -0
- cuju/render/_http/headers.py +103 -0
- cuju/render/_http/launch.py +500 -0
- cuju/render/_http/launchers.py +408 -0
- cuju/render/_http/pwa.py +128 -0
- cuju/render/_http/server.py +2105 -0
- cuju/render/_http/window.py +360 -0
- cuju/render/_moments/__init__.py +3 -0
- cuju/render/_moments/captions.py +284 -0
- cuju/render/_moments/events.py +1123 -0
- cuju/render/_moments/search.py +694 -0
- cuju/render/_packages/__init__.py +3 -0
- cuju/render/_packages/glsl.py +746 -0
- cuju/render/_packages/loudness.py +334 -0
- cuju/render/_packages/pack.py +1959 -0
- cuju/render/_packages/presentation.py +1376 -0
- cuju/render/_packages/vocabulary.py +609 -0
- cuju/render/_playing/__init__.py +3 -0
- cuju/render/_playing/achievements.py +463 -0
- cuju/render/_playing/bench.py +609 -0
- cuju/render/_playing/challenges.py +1813 -0
- cuju/render/_playing/demo.py +323 -0
- cuju/render/_playing/draw.py +211 -0
- cuju/render/_playing/errors.py +20 -0
- cuju/render/_playing/game.py +645 -0
- cuju/render/_playing/limits.py +339 -0
- cuju/render/_playing/play.py +926 -0
- cuju/render/_playing/policy_worker.py +250 -0
- cuju/render/_playing/strict.py +810 -0
- cuju/render/_registry/__init__.py +3 -0
- cuju/render/_registry/clips.py +394 -0
- cuju/render/_registry/joints.py +108 -0
- cuju/render/_registry/teams.py +2058 -0
- cuju/render/_store/__init__.py +3 -0
- cuju/render/_store/home.py +70 -0
- cuju/render/_store/library.py +604 -0
- cuju/render/_store/samples.py +163 -0
- cuju/render/_text/__init__.py +3 -0
- cuju/render/_text/languages.py +637 -0
- cuju/render/_text/messages.py +1396 -0
- cuju/render/_tournaments/__init__.py +3 -0
- cuju/render/_tournaments/results.py +547 -0
- cuju/render/_tournaments/tournament.py +2742 -0
- cuju/render/_tournaments/tournament_cli.py +587 -0
- cuju/render/_tournaments/tournament_runner.py +1975 -0
- cuju/render/_version.py +6 -0
- cuju/render/_watch/__init__.py +3 -0
- cuju/render/_watch/grid.py +265 -0
- cuju/render/_watch/live.py +664 -0
- cuju/render/_watch/notebook.py +79 -0
- cuju/render/_watch/show.py +171 -0
- cuju/render/samples/sample-11v11.cjr +0 -0
- cuju/render/samples/sample-5v5.cjr +0 -0
- cuju/render/samples/teams/starter/main.py +33 -0
- cuju/render/scenarios/benchmark_full_game_11_vs_11_easy.py +46 -0
- cuju/render/scenarios/benchmark_full_game_11_vs_11_hard.py +46 -0
- cuju/render/scenarios/benchmark_full_game_11_vs_11_medium.py +46 -0
- cuju/render/scenarios/benchmark_full_game_5_vs_5_easy.py +34 -0
- cuju/render/scenarios/benchmark_full_game_5_vs_5_hard.py +34 -0
- cuju/render/scenarios/benchmark_full_game_5_vs_5_medium.py +34 -0
- cuju/render/web/assets/adboard_polygon.webp +0 -0
- cuju/render/web/assets/adboard_your_ad_here.webp +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-combatmelee.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-general.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-movementadvanced.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-movementbasic.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-simulation.glb +0 -0
- cuju/render/web/assets/animations/kaykit-rig-medium-special.glb +0 -0
- cuju/render/web/assets/ball.glb +0 -0
- cuju/render/web/assets/challenges/3_vs_1_with_keeper.mp4 +0 -0
- cuju/render/web/assets/challenges/3_vs_1_with_keeper.webp +0 -0
- cuju/render/web/assets/challenges/corner.mp4 +0 -0
- cuju/render/web/assets/challenges/corner.webp +0 -0
- cuju/render/web/assets/challenges/counterattack_easy.mp4 +0 -0
- cuju/render/web/assets/challenges/counterattack_easy.webp +0 -0
- cuju/render/web/assets/challenges/counterattack_hard.mp4 +0 -0
- cuju/render/web/assets/challenges/counterattack_hard.webp +0 -0
- cuju/render/web/assets/challenges/empty_goal.mp4 +0 -0
- cuju/render/web/assets/challenges/empty_goal.webp +0 -0
- cuju/render/web/assets/challenges/empty_goal_close.mp4 +0 -0
- cuju/render/web/assets/challenges/empty_goal_close.webp +0 -0
- cuju/render/web/assets/challenges/manifest.json +375 -0
- cuju/render/web/assets/challenges/one_on_one.mp4 +0 -0
- cuju/render/web/assets/challenges/one_on_one.webp +0 -0
- cuju/render/web/assets/challenges/one_on_one_hard.mp4 +0 -0
- cuju/render/web/assets/challenges/one_on_one_hard.webp +0 -0
- cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.mp4 +0 -0
- cuju/render/web/assets/challenges/pass_and_shoot_with_keeper.webp +0 -0
- cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.mp4 +0 -0
- cuju/render/web/assets/challenges/run_pass_and_shoot_with_keeper.webp +0 -0
- cuju/render/web/assets/challenges/run_to_score.mp4 +0 -0
- cuju/render/web/assets/challenges/run_to_score.webp +0 -0
- cuju/render/web/assets/challenges/run_to_score_with_keeper.mp4 +0 -0
- cuju/render/web/assets/challenges/run_to_score_with_keeper.webp +0 -0
- cuju/render/web/assets/challenges/single_goal_versus_lazy.mp4 +0 -0
- cuju/render/web/assets/challenges/single_goal_versus_lazy.webp +0 -0
- cuju/render/web/assets/crowd.webp +0 -0
- cuju/render/web/assets/crowd_normal.webp +0 -0
- cuju/render/web/assets/cuju-logo-384.png +0 -0
- cuju/render/web/assets/cuju-logo.png +0 -0
- cuju/render/web/assets/cuju-logo.svg +46 -0
- cuju/render/web/assets/cuju-mark-light.svg +33 -0
- cuju/render/web/assets/cuju-mark.svg +33 -0
- cuju/render/web/assets/goal.glb +0 -0
- cuju/render/web/assets/grass.webp +0 -0
- cuju/render/web/assets/hair_black.png +0 -0
- cuju/render/web/assets/hair_blonde.png +0 -0
- cuju/render/web/assets/hair_brown.png +0 -0
- cuju/render/web/assets/hair_darkblonde.png +0 -0
- cuju/render/web/assets/hair_long01.glb +0 -0
- cuju/render/web/assets/hair_long02.glb +0 -0
- cuju/render/web/assets/hair_medium01.glb +0 -0
- cuju/render/web/assets/hair_red.png +0 -0
- cuju/render/web/assets/hair_short01.glb +0 -0
- cuju/render/web/assets/hair_short02.glb +0 -0
- cuju/render/web/assets/icons/cuju-192.png +0 -0
- cuju/render/web/assets/icons/cuju-512.png +0 -0
- cuju/render/web/assets/icons/cuju-maskable-512.png +0 -0
- cuju/render/web/assets/kit_goalkeeper.webp +0 -0
- cuju/render/web/assets/kit_mask.png +0 -0
- cuju/render/web/assets/kit_normal.webp +0 -0
- cuju/render/web/assets/manifest.json +601 -0
- cuju/render/web/assets/models/kaykit-barbarian.glb +0 -0
- cuju/render/web/assets/models/kaykit-barbarian.json +23 -0
- cuju/render/web/assets/models/kaykit-knight.glb +0 -0
- cuju/render/web/assets/models/kaykit-knight.json +23 -0
- cuju/render/web/assets/models/kaykit-mage.glb +0 -0
- cuju/render/web/assets/models/kaykit-mage.json +23 -0
- cuju/render/web/assets/models/kaykit-mannequin-medium.glb +0 -0
- cuju/render/web/assets/models/kaykit-mannequin-medium.json +23 -0
- cuju/render/web/assets/models/kaykit-ranger.glb +0 -0
- cuju/render/web/assets/models/kaykit-ranger.json +23 -0
- cuju/render/web/assets/models/kaykit-rogue-hooded.glb +0 -0
- cuju/render/web/assets/models/kaykit-rogue-hooded.json +23 -0
- cuju/render/web/assets/models/kaykit-rogue.glb +0 -0
- cuju/render/web/assets/models/kaykit-rogue.json +23 -0
- cuju/render/web/assets/models/kaykit-skeleton-mage.glb +0 -0
- cuju/render/web/assets/models/kaykit-skeleton-mage.json +23 -0
- cuju/render/web/assets/models/kaykit-skeleton-minion.glb +0 -0
- cuju/render/web/assets/models/kaykit-skeleton-minion.json +23 -0
- cuju/render/web/assets/models/kaykit-skeleton-rogue.glb +0 -0
- cuju/render/web/assets/models/kaykit-skeleton-rogue.json +23 -0
- cuju/render/web/assets/models/kaykit-skeleton-warrior.glb +0 -0
- cuju/render/web/assets/models/kaykit-skeleton-warrior.json +23 -0
- cuju/render/web/assets/pitch.webp +0 -0
- cuju/render/web/assets/pitch_normal.webp +0 -0
- cuju/render/web/assets/player.glb +0 -0
- cuju/render/web/assets/shoe.webp +0 -0
- cuju/render/web/assets/shoe_sole.png +0 -0
- cuju/render/web/assets/skin_01.png +0 -0
- cuju/render/web/assets/skin_02.png +0 -0
- cuju/render/web/assets/skin_03.png +0 -0
- cuju/render/web/assets/skin_04.png +0 -0
- cuju/render/web/assets/sounds/applause.ogg +0 -0
- cuju/render/web/assets/sounds/beach-wave-1.ogg +0 -0
- cuju/render/web/assets/sounds/beach-wave-2.ogg +0 -0
- cuju/render/web/assets/sounds/beach-wave-3.ogg +0 -0
- cuju/render/web/assets/sounds/beach-wave-4.ogg +0 -0
- cuju/render/web/assets/sounds/city-horn.ogg +0 -0
- cuju/render/web/assets/sounds/city-loop.ogg +0 -0
- cuju/render/web/assets/sounds/city-siren.ogg +0 -0
- cuju/render/web/assets/sounds/crowd-cheer.ogg +0 -0
- cuju/render/web/assets/sounds/crowd-loop.ogg +0 -0
- cuju/render/web/assets/sounds/crowd-ooh.ogg +0 -0
- cuju/render/web/assets/sounds/gulls.ogg +0 -0
- cuju/render/web/assets/sounds/neon-sting.ogg +0 -0
- cuju/render/web/assets/sounds/rain-loop.ogg +0 -0
- cuju/render/web/assets/sounds/sounds.json +46 -0
- cuju/render/web/assets/sounds/space-beep.ogg +0 -0
- cuju/render/web/assets/sounds/space-hum.ogg +0 -0
- cuju/render/web/assets/sounds/space-radio.ogg +0 -0
- cuju/render/web/assets/sounds/whistle-long.ogg +0 -0
- cuju/render/web/assets/sounds/whistle-short.ogg +0 -0
- cuju/render/web/assets/sounds/wind-loop.ogg +0 -0
- cuju/render/web/easy.css +357 -0
- cuju/render/web/export.html +23 -0
- cuju/render/web/feed.css +262 -0
- cuju/render/web/feed.html +28 -0
- cuju/render/web/flat.css +120 -0
- cuju/render/web/home.css +837 -0
- cuju/render/web/index.html +373 -0
- cuju/render/web/js/boot.js +50 -0
- cuju/render/web/js/broadcast/audio.js +2141 -0
- cuju/render/web/js/broadcast/celebrate.js +1238 -0
- cuju/render/web/js/broadcast/decisions.js +609 -0
- cuju/render/web/js/broadcast/hud.js +352 -0
- cuju/render/web/js/broadcast/show.js +3614 -0
- cuju/render/web/js/broadcast/transition.js +2231 -0
- cuju/render/web/js/broadcast/voices.js +153 -0
- cuju/render/web/js/capture.js +101 -0
- cuju/render/web/js/core/accessibility.js +90 -0
- cuju/render/web/js/core/api.js +429 -0
- cuju/render/web/js/core/comfort.js +135 -0
- cuju/render/web/js/core/i18n.js +705 -0
- cuju/render/web/js/core/icons.js +167 -0
- cuju/render/web/js/core/input-settings.js +156 -0
- cuju/render/web/js/core/keys.js +277 -0
- cuju/render/web/js/core/language-ui.js +125 -0
- cuju/render/web/js/core/menu.js +853 -0
- cuju/render/web/js/core/names.js +113 -0
- cuju/render/web/js/core/page-structure.js +110 -0
- cuju/render/web/js/core/scenarios.js +109 -0
- cuju/render/web/js/core/spoilers.js +236 -0
- cuju/render/web/js/core/typeahead.js +38 -0
- cuju/render/web/js/export/dom-picture.js +330 -0
- cuju/render/web/js/export/export-host.js +97 -0
- cuju/render/web/js/export/feed-dialog.js +191 -0
- cuju/render/web/js/export/feed-params.js +266 -0
- cuju/render/web/js/export/feed.js +363 -0
- cuju/render/web/js/export/mp4-mux.js +295 -0
- cuju/render/web/js/export/photo-core.js +313 -0
- cuju/render/web/js/export/photo.js +1314 -0
- cuju/render/web/js/export/safe-frame.js +59 -0
- cuju/render/web/js/export/size-fields.js +103 -0
- cuju/render/web/js/export/sizes.js +178 -0
- cuju/render/web/js/export/video-boot.js +55 -0
- cuju/render/web/js/export/video-browser.js +325 -0
- cuju/render/web/js/export/video-export.js +417 -0
- cuju/render/web/js/export/video-plan.js +207 -0
- cuju/render/web/js/export/video-range.js +185 -0
- cuju/render/web/js/export/webm-mux.js +198 -0
- cuju/render/web/js/feed-boot.js +151 -0
- cuju/render/web/js/feed-frame.js +101 -0
- cuju/render/web/js/help/doctor.js +488 -0
- cuju/render/web/js/help/firstrun.js +379 -0
- cuju/render/web/js/help/help-center.js +438 -0
- cuju/render/web/js/help/install-offer.js +164 -0
- cuju/render/web/js/help/install-state.js +104 -0
- cuju/render/web/js/help/lessons-core.js +207 -0
- cuju/render/web/js/help/lessons.js +463 -0
- cuju/render/web/js/help/palette.js +584 -0
- cuju/render/web/js/help/pwa.js +257 -0
- cuju/render/web/js/help/tour-core.js +241 -0
- cuju/render/web/js/help/tour.js +461 -0
- cuju/render/web/js/main.js +2513 -0
- cuju/render/web/js/pages/draw.js +188 -0
- cuju/render/web/js/pages/fieldpage.js +530 -0
- cuju/render/web/js/pages/home.js +521 -0
- cuju/render/web/js/pages/matches.js +779 -0
- cuju/render/web/js/pages/moments-ui.js +359 -0
- cuju/render/web/js/pages/queue-filters.js +63 -0
- cuju/render/web/js/pages/recording-groups.js +80 -0
- cuju/render/web/js/pages/recordings.js +1069 -0
- cuju/render/web/js/play/bench.js +347 -0
- cuju/render/web/js/play/challenges.js +1302 -0
- cuju/render/web/js/play/medals.js +127 -0
- cuju/render/web/js/play/play.js +1953 -0
- cuju/render/web/js/play/teasers.js +196 -0
- cuju/render/web/js/play/touch.js +350 -0
- cuju/render/web/js/play/tournament-layout.js +487 -0
- cuju/render/web/js/play/tournament-results.js +441 -0
- cuju/render/web/js/play/tournament-strict.js +560 -0
- cuju/render/web/js/play/tournament.js +2585 -0
- cuju/render/web/js/players/animate.js +972 -0
- cuju/render/web/js/players/clips.js +224 -0
- cuju/render/web/js/players/kaykit.js +162 -0
- cuju/render/web/js/players/kits.js +676 -0
- cuju/render/web/js/players/number-patch.js +82 -0
- cuju/render/web/js/players/players.js +2694 -0
- cuju/render/web/js/players/recolor.js +893 -0
- cuju/render/web/js/players/retarget.js +325 -0
- cuju/render/web/js/players/rig-numbers.js +174 -0
- cuju/render/web/js/presentation/animator.js +231 -0
- cuju/render/web/js/presentation/assign.js +85 -0
- cuju/render/web/js/presentation/behaviors.js +440 -0
- cuju/render/web/js/presentation/budget.js +209 -0
- cuju/render/web/js/presentation/catalog.js +1674 -0
- cuju/render/web/js/presentation/clocks.js +72 -0
- cuju/render/web/js/presentation/commands.js +301 -0
- cuju/render/web/js/presentation/contract.js +240 -0
- cuju/render/web/js/presentation/effects.js +904 -0
- cuju/render/web/js/presentation/flash.js +605 -0
- cuju/render/web/js/presentation/freeze-worklet.js +22 -0
- cuju/render/web/js/presentation/glsl.js +464 -0
- cuju/render/web/js/presentation/host.js +323 -0
- cuju/render/web/js/presentation/index.js +2066 -0
- cuju/render/web/js/presentation/loudness-worklet.js +235 -0
- cuju/render/web/js/presentation/loudness.js +740 -0
- cuju/render/web/js/presentation/moments.js +536 -0
- cuju/render/web/js/presentation/post.js +327 -0
- cuju/render/web/js/presentation/preview.js +180 -0
- cuju/render/web/js/presentation/protocol.js +174 -0
- cuju/render/web/js/presentation/reach.js +158 -0
- cuju/render/web/js/presentation/rigging.js +130 -0
- cuju/render/web/js/presentation/sandbox.js +157 -0
- cuju/render/web/js/presentation/shaders.js +235 -0
- cuju/render/web/js/presentation/stage.js +402 -0
- cuju/render/web/js/presentation/teamaudio.js +722 -0
- cuju/render/web/js/presentation/tracks.js +197 -0
- cuju/render/web/js/presentation/values.js +81 -0
- cuju/render/web/js/recording/annotation-data.js +203 -0
- cuju/render/web/js/recording/captions.js +491 -0
- cuju/render/web/js/recording/codec.js +185 -0
- cuju/render/web/js/recording/decode-worker.js +70 -0
- cuju/render/web/js/recording/decode.js +94 -0
- cuju/render/web/js/recording/episodes.js +46 -0
- cuju/render/web/js/recording/format.js +461 -0
- cuju/render/web/js/recording/live.js +348 -0
- cuju/render/web/js/recording/moments-query.js +59 -0
- cuju/render/web/js/recording/moments.js +149 -0
- cuju/render/web/js/recording/pose.js +86 -0
- cuju/render/web/js/recording/timeline.js +275 -0
- cuju/render/web/js/teams/art.js +50 -0
- cuju/render/web/js/teams/clip-editor.js +293 -0
- cuju/render/web/js/teams/editor.js +1979 -0
- cuju/render/web/js/teams/library.js +344 -0
- cuju/render/web/js/teams/painter.js +633 -0
- cuju/render/web/js/teams/surprise-ui.js +195 -0
- cuju/render/web/js/teams/surprise.js +497 -0
- cuju/render/web/js/teams/team-check.js +127 -0
- cuju/render/web/js/watch/annotations.js +270 -0
- cuju/render/web/js/watch/flat.js +366 -0
- cuju/render/web/js/watch/grid.js +436 -0
- cuju/render/web/js/watch/pitch2d.js +289 -0
- cuju/render/web/js/watch/player-tools.js +192 -0
- cuju/render/web/js/watch/presets.js +201 -0
- cuju/render/web/js/world/arenas.js +997 -0
- cuju/render/web/js/world/ball.js +204 -0
- cuju/render/web/js/world/cameras.js +982 -0
- cuju/render/web/js/world/crowd.js +376 -0
- cuju/render/web/js/world/field.js +227 -0
- cuju/render/web/js/world/framing.js +145 -0
- cuju/render/web/js/world/occlusion.js +48 -0
- cuju/render/web/js/world/pitch.js +703 -0
- cuju/render/web/js/world/scene.js +395 -0
- cuju/render/web/js/world/sky.js +325 -0
- cuju/render/web/js/world/stadium.js +1262 -0
- cuju/render/web/js/world/vendor-sky.js +367 -0
- cuju/render/web/js/world/weather.js +264 -0
- cuju/render/web/languages/en.json +4750 -0
- cuju/render/web/lessons.json +137 -0
- cuju/render/web/manifest.webmanifest +23 -0
- cuju/render/web/style.css +8399 -0
- cuju/render/web/sw.js +235 -0
- cuju/render/web/vendor/fabric/LICENSE +16 -0
- cuju/render/web/vendor/fabric/fabric.min.mjs +1 -0
- cuju/render/web/vendor/lucide/LICENSE +43 -0
- cuju/render/web/vendor/lucide/icons.js +141 -0
- cuju/render/web/vendor/three/LICENSE +21 -0
- cuju/render/web/vendor/three/build/three.core.js +60586 -0
- cuju/render/web/vendor/three/build/three.module.js +19719 -0
- cuju/render/web/vendor/three/examples/jsm/loaders/GLTFLoader.js +4925 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/EffectComposer.js +365 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/MaskPass.js +195 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/OutputPass.js +148 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/Pass.js +191 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/RenderPass.js +193 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/ShaderPass.js +135 -0
- cuju/render/web/vendor/three/examples/jsm/postprocessing/UnrealBloomPass.js +541 -0
- cuju/render/web/vendor/three/examples/jsm/shaders/CopyShader.js +52 -0
- cuju/render/web/vendor/three/examples/jsm/shaders/LuminosityHighPassShader.js +68 -0
- cuju/render/web/vendor/three/examples/jsm/shaders/OutputShader.js +103 -0
- cuju/render/web/vendor/three/examples/jsm/utils/BufferGeometryUtils.js +1501 -0
- cuju/render/web/vendor/three/examples/jsm/utils/SkeletonUtils.js +496 -0
- cuju_render-0.1.0.dist-info/METADATA +116 -0
- cuju_render-0.1.0.dist-info/RECORD +382 -0
- cuju_render-0.1.0.dist-info/WHEEL +4 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSE +202 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +396 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/CC0-1.0.txt +127 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/ISC.txt +16 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/MIT.txt +24 -0
- cuju_render-0.1.0.dist-info/licenses/LICENSES/Unlicense.txt +13 -0
- cuju_render-0.1.0.dist-info/licenses/NOTICE +35 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// Copyright 2026 The cuju Authors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// The Export video dialog of the Watch page. A browser with a video encoder draws a range of the
|
|
5
|
+
// open recording and encodes it itself, as video-browser.js does, with nothing else installed. A
|
|
6
|
+
// browser without one asks the server to draw the range with Chrome and ffmpeg, as `cuju render
|
|
7
|
+
// video` does, and follows the export's progress. Either way the dialog offers the file once it is
|
|
8
|
+
// ready. When neither can export, the dialog says in short what would make it work.
|
|
9
|
+
|
|
10
|
+
import { ARENA_LABELS } from '../world/arenas.js';
|
|
11
|
+
import { labelNodes, language, serverNodes, serverText, t, tn } from '../core/i18n.js';
|
|
12
|
+
import { h } from '../teams/library.js';
|
|
13
|
+
import { STATE_ICONS, icon, labelText, relabel, symbolOnly } from '../core/icons.js';
|
|
14
|
+
import { teamName } from '../core/names.js';
|
|
15
|
+
import { sizeFields } from './size-fields.js';
|
|
16
|
+
import {
|
|
17
|
+
VIDEO_CAMERAS, VIDEO_MAX_SECONDS, VIDEO_QUALITIES, VIDEO_RATES, checkRange, exportRequest,
|
|
18
|
+
formatMoment, goalRange, progressText,
|
|
19
|
+
} from './video-range.js';
|
|
20
|
+
import { BrowserExport, browserSupport } from './video-browser.js';
|
|
21
|
+
|
|
22
|
+
async function call(method, path, json) {
|
|
23
|
+
const init = { method, cache: 'no-store', headers: {} };
|
|
24
|
+
if (json !== undefined) {
|
|
25
|
+
init.body = JSON.stringify(json);
|
|
26
|
+
init.headers['Content-Type'] = 'application/json';
|
|
27
|
+
}
|
|
28
|
+
let res;
|
|
29
|
+
try {
|
|
30
|
+
res = await fetch(path, init);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error(t('video.error.unreachable'));
|
|
33
|
+
}
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
let value = null;
|
|
36
|
+
try {
|
|
37
|
+
value = text ? JSON.parse(text) : null;
|
|
38
|
+
} catch {
|
|
39
|
+
value = null;
|
|
40
|
+
}
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
const error = new Error((value && serverText(value)) || t('video.error.status', { status: res.status }));
|
|
43
|
+
error.status = res.status;
|
|
44
|
+
// The answer itself, whose message shows with its marks, such as a command as code.
|
|
45
|
+
error.answer = value && typeof value === 'object' && serverText(value) ? value : null;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The nodes of an error of call, its message with its marks, else its text.
|
|
52
|
+
const errorNodes = (err) => (err.answer ? serverNodes(err.answer) : [err.message]);
|
|
53
|
+
|
|
54
|
+
// One node of several, for a placeholder of tn.
|
|
55
|
+
function fragment(nodes) {
|
|
56
|
+
const out = document.createDocumentFragment();
|
|
57
|
+
out.append(...nodes);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The symbol of the state of an export before its line of progress, hidden from screen readers,
|
|
62
|
+
// since the words of the line say the same.
|
|
63
|
+
const stateSymbol = (state) => (STATE_ICONS[state] ? [icon(STATE_ICONS[state], 'icon text-symbol')] : []);
|
|
64
|
+
|
|
65
|
+
// A note of the dialog with its symbol before its words.
|
|
66
|
+
const symbolNote = (symbol, text) => h('p', { class: 'muted small ve-symbol-note' }, icon(symbol, 'icon text-symbol'), text);
|
|
67
|
+
|
|
68
|
+
const hourText = (hour) => {
|
|
69
|
+
const whole = Math.floor(hour);
|
|
70
|
+
return `${String(whole).padStart(2, '0')}:${String(Math.round((hour - whole) * 60)).padStart(2, '0')}`;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export class VideoExport {
|
|
74
|
+
constructor(app) {
|
|
75
|
+
this.app = app;
|
|
76
|
+
this.entry = null;
|
|
77
|
+
this.timer = null;
|
|
78
|
+
// The export of the browser that runs, which goes on while the dialog is closed, and the
|
|
79
|
+
// last file it made with its address.
|
|
80
|
+
this.job = null;
|
|
81
|
+
this.result = null;
|
|
82
|
+
this.fileUrl = null;
|
|
83
|
+
this.mode = null;
|
|
84
|
+
this.dialog = h('dialog', { class: 'video-export', 'aria-labelledby': 'video-export-title' });
|
|
85
|
+
document.getElementById('app').appendChild(this.dialog);
|
|
86
|
+
this.dialog.addEventListener('close', () => this.stopPolling());
|
|
87
|
+
this.button = document.getElementById('video-export-btn');
|
|
88
|
+
if (this.button) {
|
|
89
|
+
this.button.addEventListener('click', () => this.open());
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The recording of the library that Watch shows, or null for a file, a live source or none.
|
|
94
|
+
recording() {
|
|
95
|
+
const source = this.app.source;
|
|
96
|
+
return source && source.kind === 'match' && this.app.timeline ? source : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async open() {
|
|
100
|
+
if (this.dialog.open) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (document.pointerLockElement) {
|
|
104
|
+
document.exitPointerLock();
|
|
105
|
+
}
|
|
106
|
+
if (this.recording()) {
|
|
107
|
+
this.app.act('pause');
|
|
108
|
+
}
|
|
109
|
+
this.render(null);
|
|
110
|
+
this.dialog.showModal();
|
|
111
|
+
const source = this.recording();
|
|
112
|
+
if (!source) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
let browser = { supported: false, reason: 'encoder' };
|
|
116
|
+
try {
|
|
117
|
+
browser = await browserSupport();
|
|
118
|
+
} catch {
|
|
119
|
+
// A browser whose encoder fails to answer exports through the server.
|
|
120
|
+
}
|
|
121
|
+
if (!this.dialog.open) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (browser.supported) {
|
|
125
|
+
this.mode = 'browser';
|
|
126
|
+
this.render({ available: true, browser });
|
|
127
|
+
if (this.entry && this.entry.browser && (this.job || this.entry.match === source.id)) {
|
|
128
|
+
this.follow(this.entry);
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
let status;
|
|
133
|
+
try {
|
|
134
|
+
status = await call('GET', '/api/video');
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const label = err.answer ? { id: err.answer.error_id, text: err.message, values: err.answer.error_values } : err.message;
|
|
137
|
+
status = { available: false, missing: [err.message], missing_labels: [err.status === 404 ? t('video.error.no_export') : label] };
|
|
138
|
+
}
|
|
139
|
+
if (!this.dialog.open) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.mode = 'server';
|
|
143
|
+
this.render({ ...status, browser });
|
|
144
|
+
// An export of this recording that runs or finished in this server's lifetime shows again.
|
|
145
|
+
try {
|
|
146
|
+
const list = await call('GET', '/api/videos');
|
|
147
|
+
const latest = (list || []).find((e) => e.match === source.id);
|
|
148
|
+
if (latest && this.dialog.open) {
|
|
149
|
+
this.follow(latest);
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// The list is a convenience, the dialog works without it.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
close() {
|
|
157
|
+
if (this.dialog.open) {
|
|
158
|
+
this.dialog.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
render(status) {
|
|
163
|
+
const d = this.dialog;
|
|
164
|
+
d.replaceChildren();
|
|
165
|
+
const head = h('div', { class: 've-head' },
|
|
166
|
+
h('h2', { id: 'video-export-title' }, t('video.title')),
|
|
167
|
+
h('button', { class: 'icon-btn', type: 'button', 'aria-label': t('video.close'), title: t('video.close.title'), onclick: () => this.close() }, icon('close')));
|
|
168
|
+
d.append(head);
|
|
169
|
+
const source = this.recording();
|
|
170
|
+
if (!source) {
|
|
171
|
+
d.append(h('p', {}, t('video.no_recording')),
|
|
172
|
+
h('div', { class: 've-actions' }, h('button', { class: 'btn', type: 'button', ...symbolOnly(t('video.close'), t('video.close.title')), onclick: () => this.close() }, icon('close'))));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!status) {
|
|
176
|
+
d.append(h('p', { class: 'muted' }, t('video.asking')));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!status.available) {
|
|
180
|
+
// Neither this browser nor the server can export, so the dialog says briefly what
|
|
181
|
+
// would make either work, and the server's own list waits behind a disclosure.
|
|
182
|
+
d.append(h('p', {}, tn(status.browser && status.browser.reason === 'pictures' ? 'video.browser.pictures' : 'video.browser.none')),
|
|
183
|
+
h('p', {}, tn('video.browser.how')),
|
|
184
|
+
(status.missing || []).length
|
|
185
|
+
? h('details', { class: 've-missing' }, h('summary', {}, icon('server', 'icon text-symbol'), t('video.browser.server_lacks')),
|
|
186
|
+
h('ul', {}, status.missing.map((m, i) => h('li', {}, labelNodes((status.missing_labels && status.missing_labels[i]) || m)))))
|
|
187
|
+
: null,
|
|
188
|
+
h('div', { class: 've-actions' }, h('button', { class: 'btn', type: 'button', ...symbolOnly(t('video.close'), t('video.close.title')), onclick: () => this.close() }, icon('close'))));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const tl = this.app.timeline;
|
|
192
|
+
const annotated = !!(tl.store.annotations && tl.store.annotations.size > 0);
|
|
193
|
+
const now = (tl.t - tl.first) / tl.sps;
|
|
194
|
+
const duration = (tl.last - tl.first) / tl.sps;
|
|
195
|
+
this.duration = duration;
|
|
196
|
+
const start = h('input', { id: 've-start', type: 'text', inputmode: 'decimal', value: formatMoment(now), 'aria-describedby': 've-range-note' });
|
|
197
|
+
const end = h('input', { id: 've-end', type: 'text', inputmode: 'decimal', value: formatMoment(Math.min(duration, now + 15)), 'aria-describedby': 've-range-note' });
|
|
198
|
+
const here = () => formatMoment((this.app.timeline.t - this.app.timeline.first) / this.app.timeline.sps);
|
|
199
|
+
const goals = (tl.store.goals || []).map((g, i) => {
|
|
200
|
+
const at = (g.index - tl.first) / tl.sps;
|
|
201
|
+
const team = teamName(source.header.teams[g.team]) || t(g.team ? 'video.goal.right' : 'video.goal.left');
|
|
202
|
+
return { at, label: t('video.goal.option', { number: i + 1, moment: formatMoment(at), team }) };
|
|
203
|
+
});
|
|
204
|
+
const goal = h('select', { id: 've-goal', 'aria-label': t('video.goal.label') },
|
|
205
|
+
h('option', { value: '' }, goals.length ? t('video.goal.around') : t('video.goal.none')),
|
|
206
|
+
goals.map((g, i) => h('option', { value: String(i) }, g.label)));
|
|
207
|
+
goal.disabled = !goals.length;
|
|
208
|
+
goal.addEventListener('change', () => {
|
|
209
|
+
const g = goals[Number(goal.value)];
|
|
210
|
+
if (g) {
|
|
211
|
+
const range = goalRange(g.at, duration);
|
|
212
|
+
start.value = formatMoment(range.start);
|
|
213
|
+
end.value = formatMoment(range.end);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
const select = (id, label, options, value) => h('label', { class: 've-field' }, h('span', {}, label),
|
|
217
|
+
h('select', { id }, options.map((o) => h('option', { value: o.id, selected: o.id === value }, o.label))));
|
|
218
|
+
const camera = VIDEO_CAMERAS.some((c) => c.id === this.app.cameras.mode) ? this.app.cameras.mode : 'broadcast';
|
|
219
|
+
const field = this.app.field || {};
|
|
220
|
+
const arena = ARENA_LABELS[field.arena] || ARENA_LABELS.stadium;
|
|
221
|
+
const hour = typeof field.time === 'number' ? hourText(field.time) : '15:00';
|
|
222
|
+
this.note = h('p', { id: 've-range-note', class: 've-note', role: 'status', 'aria-live': 'polite' },
|
|
223
|
+
t('video.range.note', { end: formatMoment(duration), count: VIDEO_MAX_SECONDS / 60 }));
|
|
224
|
+
this.progress = h('progress', { max: '1', value: '0', hidden: true, 'aria-label': t('video.progress.label') });
|
|
225
|
+
this.status = h('p', { class: 've-status', role: 'status', 'aria-live': 'polite' });
|
|
226
|
+
this.exportButton = h('button', { class: 'btn primary', type: 'button', onclick: () => this.start() }, icon('film'), labelText(t('video.export')));
|
|
227
|
+
this.cancelButton = h('button', { class: 'btn', type: 'button', hidden: true, ...symbolOnly(t('video.cancel')), onclick: () => this.cancel() }, icon('stop'));
|
|
228
|
+
this.download = h('a', { class: 'btn primary', hidden: true }, icon('download'), t('video.download'));
|
|
229
|
+
this.sizes = sizeFields('ve', { value: '720p', limits: { even: true } });
|
|
230
|
+
d.append(
|
|
231
|
+
h('div', { class: 've-range' },
|
|
232
|
+
// A label of the field alone, so the button beside it stays out of its name.
|
|
233
|
+
h('div', { class: 've-field' }, h('span', {}, h('label', { for: 've-start' }, t('video.start'))), start,
|
|
234
|
+
h('button', { class: 'btn small', type: 'button', ...symbolOnly(t('video.start.here')), onclick: () => { start.value = here(); } }, icon('crosshair'))),
|
|
235
|
+
h('div', { class: 've-field' }, h('span', {}, h('label', { for: 've-end' }, t('video.end'))), end,
|
|
236
|
+
h('button', { class: 'btn small', type: 'button', ...symbolOnly(t('video.end.here')), onclick: () => { end.value = here(); } }, icon('crosshair'))),
|
|
237
|
+
h('label', { class: 've-field' }, h('span', {}, t('video.goal')), goal)),
|
|
238
|
+
this.note,
|
|
239
|
+
h('div', { class: 've-grid' },
|
|
240
|
+
...this.sizes.fields,
|
|
241
|
+
h('label', { class: 've-field' }, h('span', {}, t('video.fps')),
|
|
242
|
+
h('select', { id: 've-fps' }, VIDEO_RATES.map((r) => h('option', { value: String(r), selected: r === 30 }, t('video.fps.option', { rate: r }))))),
|
|
243
|
+
select('ve-quality', t('video.quality'), VIDEO_QUALITIES, 'standard'),
|
|
244
|
+
select('ve-camera', t('video.camera'), VIDEO_CAMERAS, camera),
|
|
245
|
+
h('label', { class: 'check' }, h('input', { id: 've-score', type: 'checkbox', checked: true }), t('video.score')),
|
|
246
|
+
// The labels and the box of the annotations, off by default as in a clean picture,
|
|
247
|
+
// offered for a recording that has them.
|
|
248
|
+
annotated ? h('label', { class: 'check' }, h('input', { id: 've-annotations', type: 'checkbox' }), t('video.annotations')) : null),
|
|
249
|
+
h('p', { class: 'muted small' }, t('video.look', { arena, hour })),
|
|
250
|
+
status.browser && status.browser.supported
|
|
251
|
+
? symbolNote('monitor', t('video.browser.note'))
|
|
252
|
+
: symbolNote('server', t('video.browser.server_note')),
|
|
253
|
+
this.progress,
|
|
254
|
+
this.status,
|
|
255
|
+
h('div', { class: 've-actions' }, this.exportButton, this.cancelButton, this.download,
|
|
256
|
+
h('button', { class: 'btn', type: 'button', ...symbolOnly(t('video.close'), t('video.close.title')), onclick: () => this.close() }, icon('close'))));
|
|
257
|
+
this.inputs = { start, end };
|
|
258
|
+
start.focus();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
choices() {
|
|
262
|
+
const value = (id) => this.dialog.querySelector(`#${id}`).value;
|
|
263
|
+
const size = this.sizes.read();
|
|
264
|
+
return {
|
|
265
|
+
size: { width: size.width, height: size.height },
|
|
266
|
+
scale: size.scale,
|
|
267
|
+
safe: size.safe,
|
|
268
|
+
fps: Number(value('ve-fps')),
|
|
269
|
+
quality: value('ve-quality'),
|
|
270
|
+
camera: value('ve-camera'),
|
|
271
|
+
score: this.dialog.querySelector('#ve-score').checked,
|
|
272
|
+
annotations: !!(this.dialog.querySelector('#ve-annotations') || {}).checked,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async start() {
|
|
277
|
+
const source = this.recording();
|
|
278
|
+
if (!source || !this.inputs) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const range = checkRange(this.inputs.start.value, this.inputs.end.value, this.duration);
|
|
282
|
+
if (range.error) {
|
|
283
|
+
this.note.textContent = range.error;
|
|
284
|
+
this.note.classList.add('error');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
let choices;
|
|
288
|
+
try {
|
|
289
|
+
choices = this.choices();
|
|
290
|
+
} catch (err) {
|
|
291
|
+
this.note.textContent = err.message;
|
|
292
|
+
this.note.classList.add('error');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
this.note.classList.remove('error');
|
|
296
|
+
this.exportButton.disabled = true;
|
|
297
|
+
if (this.mode === 'browser') {
|
|
298
|
+
this.startBrowser(source, exportRequest(range, choices, this.app.field));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const entry = await call('POST', `/api/matches/${encodeURIComponent(source.id)}/video`, exportRequest(range, choices, this.app.field));
|
|
303
|
+
this.follow(entry);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
this.exportButton.disabled = false;
|
|
306
|
+
this.status.replaceChildren(...stateSymbol('failed'), ...(err.status === 409 ? [...errorNodes(err), '.'] : tn('video.error.start', { error: fragment(errorNodes(err)) })));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Draws and encodes the export in this browser, showing its progress as the server's entries
|
|
311
|
+
// show.
|
|
312
|
+
async startBrowser(source, body) {
|
|
313
|
+
if (this.job) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (this.fileUrl) {
|
|
317
|
+
URL.revokeObjectURL(this.fileUrl);
|
|
318
|
+
this.fileUrl = null;
|
|
319
|
+
}
|
|
320
|
+
const base = { browser: true, match: source.id, start: body.start, end: body.end };
|
|
321
|
+
const job = new BrowserExport({
|
|
322
|
+
match: source.id,
|
|
323
|
+
title: (source.header && source.header.title) || source.id,
|
|
324
|
+
body,
|
|
325
|
+
lang: language().code,
|
|
326
|
+
report: (entry) => {
|
|
327
|
+
if (this.job === job) {
|
|
328
|
+
this.follow({ ...base, ...entry });
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
this.job = job;
|
|
333
|
+
this.follow({ ...base, state: 'running', frame: 0, progress: 0 });
|
|
334
|
+
let entry;
|
|
335
|
+
try {
|
|
336
|
+
const result = await job.run();
|
|
337
|
+
this.fileUrl = URL.createObjectURL(result.blob);
|
|
338
|
+
this.result = result;
|
|
339
|
+
entry = { ...base, state: 'done', bytes: result.bytes, filename: result.filename, url: this.fileUrl };
|
|
340
|
+
} catch (err) {
|
|
341
|
+
if (err && err.id === 'cancelled') {
|
|
342
|
+
entry = { ...base, state: 'cancelled' };
|
|
343
|
+
} else {
|
|
344
|
+
const known = !!(err && typeof err.id === 'string' && err.id.startsWith('video.'));
|
|
345
|
+
entry = {
|
|
346
|
+
...base,
|
|
347
|
+
state: 'failed',
|
|
348
|
+
error: known ? t(err.id, err.values) : String((err && err.message) || err),
|
|
349
|
+
error_id: known ? err.id : null,
|
|
350
|
+
error_values: known ? err.values : null,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
this.job = null;
|
|
355
|
+
this.follow(entry);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async cancel() {
|
|
359
|
+
if (this.job) {
|
|
360
|
+
this.cancelButton.disabled = true;
|
|
361
|
+
this.job.cancel();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (!this.entry) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.cancelButton.disabled = true;
|
|
368
|
+
try {
|
|
369
|
+
await call('DELETE', `/api/videos/${encodeURIComponent(this.entry.id)}`);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
this.status.replaceChildren(...errorNodes(err));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Shows an export entry and asks for it again while it runs.
|
|
376
|
+
follow(entry) {
|
|
377
|
+
this.entry = entry;
|
|
378
|
+
if (!this.status) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const running = entry.state === 'running';
|
|
382
|
+
this.progress.hidden = entry.state !== 'running' && entry.state !== 'done';
|
|
383
|
+
this.progress.value = entry.state === 'done' ? 1 : entry.progress || 0;
|
|
384
|
+
// A failed export names its reason with its marks, such as a program as code.
|
|
385
|
+
const reason = entry.state === 'failed' && serverText(entry);
|
|
386
|
+
this.status.replaceChildren(...stateSymbol(entry.state), ...(reason ? tn('video.progress.failed_reason', { error: fragment(serverNodes(entry)) }) : [progressText(entry)]));
|
|
387
|
+
this.exportButton.disabled = running;
|
|
388
|
+
relabel(this.exportButton, entry.state === 'done' ? t('video.export.again') : t('video.export'));
|
|
389
|
+
this.cancelButton.hidden = !running;
|
|
390
|
+
this.cancelButton.disabled = !!(this.job && this.job.cancelled);
|
|
391
|
+
this.download.hidden = entry.state !== 'done';
|
|
392
|
+
if (entry.state === 'done') {
|
|
393
|
+
this.download.href = entry.url || `/api/videos/${encodeURIComponent(entry.id)}/file`;
|
|
394
|
+
this.download.setAttribute('download', entry.filename || 'match.mp4');
|
|
395
|
+
}
|
|
396
|
+
this.stopPolling();
|
|
397
|
+
if (running && this.dialog.open && !entry.browser) {
|
|
398
|
+
this.timer = setTimeout(async () => {
|
|
399
|
+
try {
|
|
400
|
+
const next = await call('GET', `/api/videos/${encodeURIComponent(entry.id)}`);
|
|
401
|
+
if (this.dialog.open && this.entry && this.entry.id === entry.id) {
|
|
402
|
+
this.follow(next);
|
|
403
|
+
}
|
|
404
|
+
} catch (err) {
|
|
405
|
+
this.status.replaceChildren(...(err.status === 404 ? [t('video.error.gone')] : errorNodes(err)));
|
|
406
|
+
this.exportButton.disabled = false;
|
|
407
|
+
this.cancelButton.hidden = true;
|
|
408
|
+
}
|
|
409
|
+
}, 700);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
stopPolling() {
|
|
414
|
+
clearTimeout(this.timer);
|
|
415
|
+
this.timer = null;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Copyright 2026 The cuju Authors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// The plan of a video export that the page draws and encodes itself, with nothing beyond the
|
|
5
|
+
// browser. It holds the parts that need no browser, so a test runs them on Node, the frames of a
|
|
6
|
+
// range and the steps before them, the settings of the page that draws them, the codecs to try
|
|
7
|
+
// and their bit rates, and the name of the file. The frames, the warm-up and the settings follow
|
|
8
|
+
// src/cuju/render/_export/video.py, which draws the same picture with Chrome and ffmpeg for the
|
|
9
|
+
// command cuju render video and for browsers without a video encoder.
|
|
10
|
+
|
|
11
|
+
// The page plays this long before the first frame, so the camera and graphics have settled, as
|
|
12
|
+
// WARMUP of video.py.
|
|
13
|
+
export const WARMUP = 1;
|
|
14
|
+
// The page settles this long in real time at the first frame of the warm-up, its players rebuilt
|
|
15
|
+
// in their kits and the camera in place.
|
|
16
|
+
export const SETTLE_MS = 1000;
|
|
17
|
+
// The largest file of an export, in bytes, as DEFAULT_MAX_MEGABYTES of video.py.
|
|
18
|
+
export const MAX_BYTES = 2048 * 1024 * 1024;
|
|
19
|
+
// A key frame starts every this many seconds, so a player seeks the file quickly.
|
|
20
|
+
export const KEY_SECONDS = 2;
|
|
21
|
+
|
|
22
|
+
// Each quality names the drawing quality of the page, as QUALITIES of video.py, and the bits that
|
|
23
|
+
// the encoder spends on each pixel of each frame.
|
|
24
|
+
export const QUALITY_DRAWING = Object.freeze({ draft: 'medium', standard: 'high', high: 'high' });
|
|
25
|
+
export const QUALITY_BITS = Object.freeze({ draft: 0.05, standard: 0.1, high: 0.2 });
|
|
26
|
+
// The bit rate stays within these bounds, in bits a second.
|
|
27
|
+
export const MIN_BITRATE = 250e3;
|
|
28
|
+
export const MAX_BITRATE = 60e6;
|
|
29
|
+
|
|
30
|
+
// The cameras that the stored setting of the page remembers, the others follow by the rig alone.
|
|
31
|
+
const REMEMBERED = ['broadcast', 'tactical', 'goal'];
|
|
32
|
+
|
|
33
|
+
// The frames of a range from start to end seconds at fps frames a second, one per frame interval
|
|
34
|
+
// and at least one, as frame_count of video.py.
|
|
35
|
+
export function frameCount(start, end, fps) {
|
|
36
|
+
return Math.max(1, Math.round((end - start) * fps));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The plan of an export, the frames to encode, the frames of the warm-up before them, the second
|
|
40
|
+
// the warm-up starts from and the milliseconds of one step, as export of video.py takes them.
|
|
41
|
+
export function exportPlan(start, end, fps) {
|
|
42
|
+
const warm = Math.min(start, WARMUP);
|
|
43
|
+
const warmFrames = Math.round(warm * fps);
|
|
44
|
+
return {
|
|
45
|
+
frames: frameCount(start, end, fps),
|
|
46
|
+
warmFrames,
|
|
47
|
+
warmFrom: start - warmFrames / fps,
|
|
48
|
+
step: 1000 / fps,
|
|
49
|
+
keyEvery: Math.max(1, Math.round(KEY_SECONDS * fps)),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The time of frame index in microseconds, as the encoder takes it, and the length of one frame.
|
|
54
|
+
export function frameTime(index, fps) {
|
|
55
|
+
return Math.round((index * 1e6) / fps);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function frameDuration(index, fps) {
|
|
59
|
+
return frameTime(index + 1, fps) - frameTime(index, fps);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The stored settings of the page that draws an export, as preload_script of video.py writes
|
|
63
|
+
// them. Sound stays muted, the pointer free and the page in its broadcast mode, without the team
|
|
64
|
+
// sheets, the presentation of restarts, celebrations, replays and the show after the match, so the
|
|
65
|
+
// video follows the match clock, unless broadcast keeps them as Watch presents the match. The
|
|
66
|
+
// labels and the box of a recording's annotations show only when asked for, and the offer of the
|
|
67
|
+
// 2D view never shows.
|
|
68
|
+
export function exportSettings(options) {
|
|
69
|
+
const annotations = options.annotations === true;
|
|
70
|
+
const broadcast = options.broadcast === true;
|
|
71
|
+
const settings = {
|
|
72
|
+
muted: true,
|
|
73
|
+
volume: 0,
|
|
74
|
+
music: false,
|
|
75
|
+
quality: QUALITY_DRAWING[options.quality] || 'high',
|
|
76
|
+
theme: 'dark',
|
|
77
|
+
tvMode: true,
|
|
78
|
+
idleReturn: false,
|
|
79
|
+
pointerLock: false,
|
|
80
|
+
camera: REMEMBERED.includes(options.camera) ? options.camera : 'broadcast',
|
|
81
|
+
lineups: broadcast,
|
|
82
|
+
celebrations: broadcast,
|
|
83
|
+
replays: broadcast,
|
|
84
|
+
decisionReplays: broadcast,
|
|
85
|
+
transitions: broadcast,
|
|
86
|
+
podium: false,
|
|
87
|
+
motion: 'full',
|
|
88
|
+
tips: { off: true },
|
|
89
|
+
view2dDeclined: true,
|
|
90
|
+
touchControls: 'off',
|
|
91
|
+
annotations: { players: annotations, actions: annotations, text: annotations },
|
|
92
|
+
};
|
|
93
|
+
const field = {};
|
|
94
|
+
if (typeof options.arena === 'string') {
|
|
95
|
+
field.arena = options.arena;
|
|
96
|
+
}
|
|
97
|
+
if (typeof options.time === 'number' && Number.isFinite(options.time)) {
|
|
98
|
+
field.time = Math.min(24, Math.max(0, options.time));
|
|
99
|
+
}
|
|
100
|
+
if (Object.keys(field).length) {
|
|
101
|
+
settings.field = field;
|
|
102
|
+
}
|
|
103
|
+
return settings;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The address of the page that draws an export, relative to the page, from the body of an export
|
|
107
|
+
// request, the scale of its layout, the language and the recording. The page reads the settings
|
|
108
|
+
// from the parameter export, as video-boot.js does.
|
|
109
|
+
export function exportAddress(body, { ratio, lang, match }) {
|
|
110
|
+
const params = new URLSearchParams();
|
|
111
|
+
params.set('capture', '1');
|
|
112
|
+
params.set('export', JSON.stringify(exportSettings(body)));
|
|
113
|
+
params.set('safe', String(body.safe ?? 'auto'));
|
|
114
|
+
params.set('ratio', String(Math.round(ratio * 1e4) / 1e4));
|
|
115
|
+
if (lang) {
|
|
116
|
+
params.set('lang', lang);
|
|
117
|
+
}
|
|
118
|
+
return `./?${params.toString()}#match=${encodeURIComponent(match)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The bit rate of a quality at a size and frame rate, in bits a second.
|
|
122
|
+
export function bitrate(quality, width, height, fps) {
|
|
123
|
+
const bits = QUALITY_BITS[quality] || QUALITY_BITS.standard;
|
|
124
|
+
return Math.round(Math.min(MAX_BITRATE, Math.max(MIN_BITRATE, width * height * fps * bits)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The levels of H.264, each with its largest rate of macroblocks a second, its largest frame in
|
|
128
|
+
// macroblocks and its largest bit rate of the High profile in bits a second.
|
|
129
|
+
const AVC_LEVELS = [
|
|
130
|
+
[30, 40500, 1620, 12.5e6],
|
|
131
|
+
[31, 108000, 3600, 17.5e6],
|
|
132
|
+
[32, 216000, 5120, 25e6],
|
|
133
|
+
[40, 245760, 8192, 25e6],
|
|
134
|
+
[41, 245760, 8192, 62.5e6],
|
|
135
|
+
[42, 522240, 8704, 62.5e6],
|
|
136
|
+
[50, 589824, 22080, 168.75e6],
|
|
137
|
+
[51, 983040, 36864, 300e6],
|
|
138
|
+
[52, 2073600, 36864, 300e6],
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
// The lowest level of H.264 that holds a size, a frame rate and a bit rate.
|
|
142
|
+
export function avcLevel(width, height, fps, rate = 0) {
|
|
143
|
+
const across = Math.ceil(width / 16);
|
|
144
|
+
const down = Math.ceil(height / 16);
|
|
145
|
+
const frame = across * down;
|
|
146
|
+
for (const [level, perSecond, largest, bits] of AVC_LEVELS) {
|
|
147
|
+
const side = Math.sqrt(largest * 8);
|
|
148
|
+
if (frame <= largest && frame * fps <= perSecond && across <= side && down <= side && rate <= bits) {
|
|
149
|
+
return level;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return 52;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The level of VP9 that holds a size and a frame rate, by the samples a second of each level.
|
|
156
|
+
export function vp9Level(width, height, fps) {
|
|
157
|
+
const samples = width * height * fps;
|
|
158
|
+
const levels = [[31, 36864000], [40, 83558400], [41, 160432128], [50, 311951360], [51, 588251136]];
|
|
159
|
+
const found = levels.find(([, most]) => samples <= most);
|
|
160
|
+
return found ? found[0] : 52;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const hex = (value) => value.toString(16).padStart(2, '0').toUpperCase();
|
|
164
|
+
|
|
165
|
+
// The encoders to try, best first. MP4 with H.264 plays everywhere, in the High, Main and
|
|
166
|
+
// Constrained Baseline profiles, and WebM with VP9 or VP8 follows for a browser without H.264.
|
|
167
|
+
// A container, mp4 or webm, keeps the encoders of that container alone.
|
|
168
|
+
export function encoderCandidates(width, height, fps, quality, container = null) {
|
|
169
|
+
const rate = bitrate(quality, width, height, fps);
|
|
170
|
+
const level = hex(avcLevel(width, height, fps, rate));
|
|
171
|
+
const base = { width, height, bitrate: rate, framerate: fps, bitrateMode: 'variable', latencyMode: 'quality' };
|
|
172
|
+
const vp9 = String(vp9Level(width, height, fps)).padStart(2, '0');
|
|
173
|
+
const all = [
|
|
174
|
+
{ container: 'mp4', config: { ...base, codec: `avc1.6400${level}`, avc: { format: 'avc' } } },
|
|
175
|
+
{ container: 'mp4', config: { ...base, codec: `avc1.4D00${level}`, avc: { format: 'avc' } } },
|
|
176
|
+
{ container: 'mp4', config: { ...base, codec: `avc1.42E0${level}`, avc: { format: 'avc' } } },
|
|
177
|
+
{ container: 'webm', config: { ...base, codec: `vp09.00.${vp9}.08` } },
|
|
178
|
+
{ container: 'webm', config: { ...base, codec: 'vp8' } },
|
|
179
|
+
];
|
|
180
|
+
return container ? all.filter((c) => c.container === container) : all;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The first encoder that the browser supports, asked of isSupported for each candidate, or null.
|
|
184
|
+
export async function chooseEncoder(candidates, isSupported) {
|
|
185
|
+
for (const candidate of candidates) {
|
|
186
|
+
try {
|
|
187
|
+
const answer = await isSupported(candidate.config);
|
|
188
|
+
if (answer && answer.supported) {
|
|
189
|
+
return candidate;
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// A codec that the browser cannot name counts as missing.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The name that a downloaded video suggests, from the title and the range, as _filename of
|
|
199
|
+
// video_jobs.py, with the extension of its container.
|
|
200
|
+
export function videoFileName(title, start, end, container = 'mp4') {
|
|
201
|
+
const stem = String(title ?? '').replace(/[^A-Za-z0-9 _.,-]+/g, '').replace(/^[ .]+|[ .]+$/g, '') || 'match';
|
|
202
|
+
const moment = (value) => {
|
|
203
|
+
const whole = Math.trunc(value);
|
|
204
|
+
return `${Math.trunc(whole / 60)}m${String(whole % 60).padStart(2, '0')}`;
|
|
205
|
+
};
|
|
206
|
+
return `${stem.slice(0, 60)} ${moment(start)}-${moment(end)}.${container === 'webm' ? 'webm' : 'mp4'}`;
|
|
207
|
+
}
|