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,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())
|