From c9421a3f33b9a00a7e219af7c7bb40566f903ea6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:21:30 +0000 Subject: [PATCH] feat(intro): add the First Light intro sequence A deterministic WebGL film of God's Eye View coming online: a seed on Null Island grows into a culture that draws the planet, indexes the surface tile by tile, ingests the live feeds and outgrows its dish, ending on the logo. Renders 16:9 and 9:16 frame by frame in headless Chromium; the procedural score is locked to the same clock. Data and imagery are fetched by the scripts in tools/ and are not committed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KqP857pippaUs6konSDfe8 --- intro-sequence/.gitignore | 12 + intro-sequence/README.md | 58 + intro-sequence/audio/cues.json | 8965 +++++++++++++++++ intro-sequence/audio/score.py | 679 ++ intro-sequence/data/SOURCES.md | 62 + intro-sequence/index.html | 26 + intro-sequence/package-lock.json | 53 + intro-sequence/package.json | 14 + intro-sequence/src/geo.js | 72 + intro-sequence/src/globe.js | 296 + intro-sequence/src/hud.js | 1357 +++ intro-sequence/src/layers.js | 512 + intro-sequence/src/lines.js | 323 + intro-sequence/src/main.js | 689 ++ intro-sequence/src/post.js | 218 + intro-sequence/src/timeline.js | 135 + intro-sequence/tools/export-cues.mjs | 40 + intro-sequence/tools/fetch/cctv_count.py | 511 + intro-sequence/tools/fetch/cctv_fetch.py | 406 + intro-sequence/tools/fetch/cctv_select.py | 42 + intro-sequence/tools/fetch/fetch_basic.sh | 33 + intro-sequence/tools/fetch/fetch_bonus.py | 133 + intro-sequence/tools/fetch/fetch_wind.py | 28 + intro-sequence/tools/fetch/process_basic.py | 172 + intro-sequence/tools/fetch/process_tle.py | 31 + intro-sequence/tools/fetch/process_vessels.py | 38 + intro-sequence/tools/finalize.sh | 26 + intro-sequence/tools/imagery/.gitignore | 1 + intro-sequence/tools/imagery/build_clouds.py | 76 + intro-sequence/tools/imagery/build_day.py | 91 + intro-sequence/tools/imagery/build_hero.py | 133 + intro-sequence/tools/imagery/build_night.py | 20 + .../tools/imagery/build_quadtree_tiles.py | 15 + intro-sequence/tools/imagery/common.py | 116 + intro-sequence/tools/render-all.sh | 14 + intro-sequence/tools/render.mjs | 91 + intro-sequence/tools/sheet.py | 19 + 37 files changed, 15507 insertions(+) create mode 100644 intro-sequence/.gitignore create mode 100644 intro-sequence/README.md create mode 100644 intro-sequence/audio/cues.json create mode 100644 intro-sequence/audio/score.py create mode 100644 intro-sequence/data/SOURCES.md create mode 100644 intro-sequence/index.html create mode 100644 intro-sequence/package-lock.json create mode 100644 intro-sequence/package.json create mode 100644 intro-sequence/src/geo.js create mode 100644 intro-sequence/src/globe.js create mode 100644 intro-sequence/src/hud.js create mode 100644 intro-sequence/src/layers.js create mode 100644 intro-sequence/src/lines.js create mode 100644 intro-sequence/src/main.js create mode 100644 intro-sequence/src/post.js create mode 100644 intro-sequence/src/timeline.js create mode 100644 intro-sequence/tools/export-cues.mjs create mode 100644 intro-sequence/tools/fetch/cctv_count.py create mode 100644 intro-sequence/tools/fetch/cctv_fetch.py create mode 100644 intro-sequence/tools/fetch/cctv_select.py create mode 100755 intro-sequence/tools/fetch/fetch_basic.sh create mode 100644 intro-sequence/tools/fetch/fetch_bonus.py create mode 100644 intro-sequence/tools/fetch/fetch_wind.py create mode 100644 intro-sequence/tools/fetch/process_basic.py create mode 100644 intro-sequence/tools/fetch/process_tle.py create mode 100644 intro-sequence/tools/fetch/process_vessels.py create mode 100755 intro-sequence/tools/finalize.sh create mode 100644 intro-sequence/tools/imagery/.gitignore create mode 100644 intro-sequence/tools/imagery/build_clouds.py create mode 100644 intro-sequence/tools/imagery/build_day.py create mode 100644 intro-sequence/tools/imagery/build_hero.py create mode 100644 intro-sequence/tools/imagery/build_night.py create mode 100644 intro-sequence/tools/imagery/build_quadtree_tiles.py create mode 100644 intro-sequence/tools/imagery/common.py create mode 100755 intro-sequence/tools/render-all.sh create mode 100644 intro-sequence/tools/render.mjs create mode 100644 intro-sequence/tools/sheet.py diff --git a/intro-sequence/.gitignore b/intro-sequence/.gitignore new file mode 100644 index 000000000..10ec81feb --- /dev/null +++ b/intro-sequence/.gitignore @@ -0,0 +1,12 @@ +# Fetched snapshots and renders are regenerated by tools/; imagery and camera +# stills carry third-party terms and are not redistributed with the repo. +node_modules/ +/out/ +data/*.json +data/*.txt +!data/SOURCES.md +/imagery/ +/cctv/ +audio/*.wav +tools/imagery/cache/ +__pycache__/ diff --git a/intro-sequence/README.md b/intro-sequence/README.md new file mode 100644 index 000000000..813c6f4fe --- /dev/null +++ b/intro-sequence/README.md @@ -0,0 +1,58 @@ +# God's Eye View — First Light + +The intro sequence: God's Eye View coming online for the first time. A seed of +light on Null Island grows into a culture that turns out to be drawing the +planet. It then indexes the surface tile by tile, ingests every public feed, +and outgrows the dish it was grown in. + +Every number on screen comes from a real snapshot: 5,550 aircraft from OpenSky, +15,928 satellites from CelesTrak, 246,411 fire detections from FIRMS, 10,120 +quakes from USGS, GFS wind, 1,948 subsea cable segments, AIS from Kystverket and +Digitraffic, and 117 real public camera frames. Those cameras come from a +catalog of 16,607 across 18 keyless agencies. The imagery is Esri World Imagery +and NASA GIBS, fetched keyless. The score is synthesised from scratch and +frame-locked to the same clock as the picture. + +## Acts + +| Time | Act | What happens | +|---|---|---| +| 0–5 s | Void | Boot console types, then implodes into a point | +| 5–16 s | Seed / culture | Heartbeat, petri-dish ring. Mycelium grows out from 0°N 0°E; real night lights and coastlines light up as the front passes; the culture learns city names | +| 16–30 s | Indexing | Quadtree tiles z3→z5 index in with rising resolution; the sun model is applied; 24 synthetic Esri captures ("enhance" z13→z18); full-frame Sobel feature extraction over Midtown | +| 30–52 s | Ingestion | Aircraft, vessels, military, satellites, quakes, fires, wind and cloud, subsea cables, public cameras. Each feed has its own visual and sonic signature. Ends on the camera wall | +| 52–60 s | Saturation | Every layer at once, data arcs, the digital-twin lattice, and the dish ring's return; containment falls, the ring shatters, OMNIPRESENT | +| 61–72 s | Resolve | The planet becomes the iris of the logo. GOD'S EYE VIEW · NO PLACE LEFT BEHIND · ONLINE | + +## Rebuild + +```sh +npm install # three, playwright-core, satellite.js, fonts +python3 -m http.server 8765 --bind 127.0.0.1 & +# data + imagery snapshots (see data/SOURCES.md) +bash tools/fetch/fetch_basic.sh # plus the other scripts in tools/fetch, tools/imagery +node tools/export-cues.mjs # film clock -> audio/cues.json +tools/render-all.sh 1920 1080 16x9 # deterministic frames -> out/video_16x9.mp4 +tools/render-all.sh 1080 1920 9x16 +tools/finalize.sh # score, -14 LUFS, mux -> out/final/ +``` + +Single stills for review: +`node tools/render.mjs --w 1920 --h 1080 --frames 12s,30s,58.4s --out out/stills` + +## Real-time / loading screen + +`index.html?play` runs the same frames live in a GPU browser, driven by the audio +clock (the first click starts the sound). `?w=1080&h=1920&play` gives the +vertical cut. + +## Layout + +- `src/timeline.js`: the clock. Every visual and audio event keys off it. +- `src/main.js`: asset loading, camera choreography, per-frame state. +- `src/globe.js`: planet shader (substrate → growth → quadtree indexing → lit Earth), clouds, atmosphere. +- `src/lines.js`: coastline and mycelium growth fronts, cables, arcs, twin lattice. +- `src/layers.js`: aircraft, vessels, satellites, quakes, fires, wind particles, stars. +- `src/hud.js`: all typography and 2D: boot console, dish, captures, feed stack, camera wall, title. +- `src/post.js`: HDR composite, dual-filter bloom, lens, grain, glitch. +- `audio/score.py`: procedural score and sound design. diff --git a/intro-sequence/audio/cues.json b/intro-sequence/audio/cues.json new file mode 100644 index 000000000..5688c9904 --- /dev/null +++ b/intro-sequence/audio/cues.json @@ -0,0 +1,8965 @@ +{ + "T": { + "cursor": 0.9, + "boot": 1.4, + "collapse": 4.9, + "seed": 5.35, + "beat1": 6.1, + "beat2": 7.35, + "dishDraw": 6.3, + "dishDone": 8.3, + "growStart": 8.7, + "coastReached": 13.4, + "growEnd": 15.6, + "substrate": 16, + "indexStart": 17, + "zWave": [ + 17, + 19, + 20.8 + ], + "capStart": 18.3, + "capRapid": 23.4, + "capFull": 26.6, + "indexed": 30, + "air": 30.6, + "sea": 32.2, + "mil": 34.2, + "orbit": 37, + "quake": 40.2, + "fire": 42.2, + "wind": 44, + "cable": 45.9, + "cctv": 47.4, + "cctvWall": 49, + "rapid": 50.4, + "cctvOut": 52.4, + "saturate": 52.6, + "arcs": 53.4, + "twin": 55.2, + "dishBreak": 58.4, + "omni": 59, + "cut": 60.5, + "title": 61.2, + "eyeOpen": 62, + "wordmark": 63.3, + "tagline": 65, + "online": 66.6, + "fadeOut": 70 + }, + "DURATION": 72, + "feeds": [ + { + "key": "air", + "t": 30.6 + }, + { + "key": "sea", + "t": 32.2 + }, + { + "key": "mil", + "t": 34.2 + }, + { + "key": "orbit", + "t": 37 + }, + { + "key": "quake", + "t": 40.2 + }, + { + "key": "fire", + "t": 42.2 + }, + { + "key": "wind", + "t": 44 + }, + { + "key": "cable", + "t": 45.9 + }, + { + "key": "cctv", + "t": 47.4 + } + ], + "caps": [ + { + "t": 18.3, + "slow": true, + "lon": -73.9855 + }, + { + "t": 20.05, + "slow": true, + "lon": 55.139 + }, + { + "t": 21.650000000000002, + "slow": true, + "lon": 103.769 + }, + { + "t": 23.4, + "slow": false, + "lon": 139.7005 + }, + { + "t": 23.72, + "slow": false, + "lon": 2.295 + }, + { + "t": 24.0295, + "slow": false, + "lon": 31.1342 + }, + { + "t": 24.3285, + "slow": false, + "lon": 4.05 + }, + { + "t": 24.616999999999997, + "slow": false, + "lon": -118.4085 + }, + { + "t": 24.894999999999996, + "slow": false, + "lon": 114.17 + }, + { + "t": 25.162499999999994, + "slow": false, + "lon": -79.5915 + }, + { + "t": 25.419499999999996, + "slow": false, + "lon": 151.2153 + }, + { + "t": 25.665999999999997, + "slow": false, + "lon": -80.6041 + }, + { + "t": 25.901999999999997, + "slow": false, + "lon": -0.4543 + }, + { + "t": 26.127499999999998, + "slow": false, + "lon": -43.2302 + }, + { + "t": 26.342499999999998, + "slow": false, + "lon": 12.3388 + }, + { + "t": 26.546999999999997, + "slow": false, + "lon": -115.1728 + }, + { + "t": 26.740999999999996, + "slow": false, + "lon": 12.4539 + }, + { + "t": 26.924499999999995, + "slow": false, + "lon": 55.2744 + }, + { + "t": 27.097499999999993, + "slow": false, + "lon": -122.4783 + }, + { + "t": 27.259999999999994, + "slow": false, + "lon": 37.6175 + }, + { + "t": 27.411999999999995, + "slow": false, + "lon": 116.3917 + }, + { + "t": 27.553499999999996, + "slow": false, + "lon": 72.84 + }, + { + "t": 27.684499999999996, + "slow": false, + "lon": 32.3228 + }, + { + "t": 27.804999999999996, + "slow": false, + "lon": -77.0563 + } + ], + "tiles": [ + { + "t": 18.433022656268314, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 18.704108543694456, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 18.8865153865448, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 19.157023467008017, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 19.232235360492815, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 19.04488475882949, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 18.517740838825148, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 18.747078738366973, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 18.507273712783228, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 18.69064193243056, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 18.62712570353368, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 18.683705862587104, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 18.69113666349866, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 18.635690963303844, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 18.59444918332742, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 18.425942565503195, + "z": 3, + "pan": -1, + "vis": false + }, + { + "t": 18.4852451791265, + "z": 3, + "pan": -0.75, + "vis": false + }, + { + "t": 18.243476237321225, + "z": 3, + "pan": -0.75, + "vis": true + }, + { + "t": 18.187474696544484, + "z": 3, + "pan": -0.75, + "vis": true + }, + { + "t": 18.2847042624304, + "z": 3, + "pan": -0.75, + "vis": true + }, + { + "t": 18.227861271187095, + "z": 3, + "pan": -0.75, + "vis": false + }, + { + "t": 18.172777733535927, + "z": 3, + "pan": -0.75, + "vis": false + }, + { + "t": 18.549859492113697, + "z": 3, + "pan": -0.75, + "vis": false + }, + { + "t": 18.327942451431813, + "z": 3, + "pan": -0.75, + "vis": false + }, + { + "t": 18.537594300148125, + "z": 3, + "pan": -0.25, + "vis": true + }, + { + "t": 18.41950810238452, + "z": 3, + "pan": -0.25, + "vis": true + }, + { + "t": 17.881253573793995, + "z": 3, + "pan": -0.25, + "vis": true + }, + { + "t": 17.552609617048628, + "z": 3, + "pan": -0.25, + "vis": true + }, + { + "t": 17.60455928775236, + "z": 3, + "pan": -0.25, + "vis": true + }, + { + "t": 18.11022407820406, + "z": 3, + "pan": -0.25, + "vis": true + }, + { + "t": 18.08926028736422, + "z": 3, + "pan": -0.25, + "vis": false + }, + { + "t": 18.311245826534098, + "z": 3, + "pan": -0.25, + "vis": false + }, + { + "t": 18.348156333384296, + "z": 3, + "pan": 0.25, + "vis": true + }, + { + "t": 18.244999561474398, + "z": 3, + "pan": 0.25, + "vis": true + }, + { + "t": 17.894740912844146, + "z": 3, + "pan": 0.25, + "vis": true + }, + { + "t": 17.68990942750349, + "z": 3, + "pan": 0.25, + "vis": true + }, + { + "t": 17.67371821426022, + "z": 3, + "pan": 0.25, + "vis": true + }, + { + "t": 17.93430857486806, + "z": 3, + "pan": 0.25, + "vis": true + }, + { + "t": 18.338913602930514, + "z": 3, + "pan": 0.25, + "vis": false + }, + { + "t": 18.4096879483487, + "z": 3, + "pan": 0.25, + "vis": false + }, + { + "t": 18.29408567428872, + "z": 3, + "pan": 0.75, + "vis": true + }, + { + "t": 18.462124819506865, + "z": 3, + "pan": 0.75, + "vis": true + }, + { + "t": 18.382861055951285, + "z": 3, + "pan": 0.75, + "vis": true + }, + { + "t": 18.205801292538823, + "z": 3, + "pan": 0.75, + "vis": true + }, + { + "t": 18.344382116211825, + "z": 3, + "pan": 0.75, + "vis": true + }, + { + "t": 18.401652083672648, + "z": 3, + "pan": 0.75, + "vis": true + }, + { + "t": 18.331715643670286, + "z": 3, + "pan": 0.75, + "vis": false + }, + { + "t": 18.440838145796892, + "z": 3, + "pan": 0.75, + "vis": false + }, + { + "t": 18.39378344329528, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 18.414548929558954, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 18.801492565679624, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 18.670774339785993, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 18.835077515600204, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 18.499566788712187, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 18.701810449500194, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 18.659651314866377, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 18.520916503521367, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 18.541721333545393, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 18.830772953483162, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 19.40451275854894, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 19.417604740611935, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 18.825060062027248, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 18.557446475550197, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 18.562825609652563, + "z": 3, + "pan": 1, + "vis": false + }, + { + "t": 20.57209215193696, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.51334989854444, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.59946108186852, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.743224119277997, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.822975059202854, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 21.079167628184234, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 21.195709748511046, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 21.458699627670935, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 21.589129288894323, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 21.28699873217459, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 21.163644170251438, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.86831092371902, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.7494193062202, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.598843827235047, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.473591966357745, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.493193542189676, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.4233140733512, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.45671998908888, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.710662951899728, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.731788858036676, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.84653636590994, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.902575648181504, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 21.192690821053223, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 21.354752518562616, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 21.279786056886817, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.96779143602579, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.99367871709223, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.78770651146925, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.823026080243867, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.51790262804561, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.414026741883358, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.530687902794178, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.48485892841499, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.540154652621066, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.613937457751817, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.66691658186678, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.76367930282072, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.888508087066423, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.82163309254432, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.918165174040077, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.95172354755017, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.92230821307737, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.774256992644354, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.71654495542484, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.686898981485907, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.41899364242405, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.412327584315943, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.42414860713374, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.514289502469747, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.391559487864903, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.383855103794495, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.54107882806413, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.416061681666125, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.408610010923287, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.670603576826363, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.526606201983817, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.472183741210543, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.507336194508582, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.557104328293853, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.45571107749451, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.47188330233696, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.62642127777709, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.525280740297127, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.539165833360876, + "z": 4, + "pan": -1, + "vis": false + }, + { + "t": 20.516355178336724, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.317919114459553, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.330599831274586, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.368834090080398, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.423531278975553, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.3868054034561, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.459720031574705, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.184243569887837, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.14533460536571, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.342993138009287, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.366144707735813, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.32505265421538, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.492537536257423, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.55036947533262, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.459870829472244, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.580488964310078, + "z": 4, + "pan": -0.875, + "vis": false + }, + { + "t": 20.398678788551255, + "z": 4, + "pan": -0.625, + "vis": true + }, + { + "t": 20.32121806477447, + "z": 4, + "pan": -0.625, + "vis": true + }, + { + "t": 20.226726632540014, + "z": 4, + "pan": -0.625, + "vis": true + }, + { + "t": 20.272125112917347, + "z": 4, + "pan": -0.625, + "vis": true + }, + { + "t": 20.376918429206988, + "z": 4, + "pan": -0.625, + "vis": true + }, + { + "t": 20.155102054677133, + "z": 4, + "pan": -0.625, + "vis": true + }, + { + "t": 19.94729991690967, + "z": 4, + "pan": -0.625, + "vis": true + }, + { + "t": 19.97604490947066, + "z": 4, + "pan": -0.625, + "vis": true + }, + { + "t": 20.069219982483958, + "z": 4, + "pan": -0.625, + "vis": true + }, + { + "t": 19.92682513595003, + "z": 4, + "pan": -0.625, + "vis": true + }, + { + "t": 20.247952779204898, + "z": 4, + "pan": -0.625, + "vis": false + }, + { + "t": 20.356119299764895, + "z": 4, + "pan": -0.625, + "vis": false + }, + { + "t": 20.437676129495916, + "z": 4, + "pan": -0.625, + "vis": false + }, + { + "t": 20.205603154614565, + "z": 4, + "pan": -0.625, + "vis": false + }, + { + "t": 20.486444732875892, + "z": 4, + "pan": -0.625, + "vis": false + }, + { + "t": 20.450255602682823, + "z": 4, + "pan": -0.625, + "vis": false + }, + { + "t": 20.29837830050432, + "z": 4, + "pan": -0.375, + "vis": true + }, + { + "t": 20.332098668548824, + "z": 4, + "pan": -0.375, + "vis": true + }, + { + "t": 20.146738361985886, + "z": 4, + "pan": -0.375, + "vis": true + }, + { + "t": 20.261470220783316, + "z": 4, + "pan": -0.375, + "vis": true + }, + { + "t": 20.137570033506126, + "z": 4, + "pan": -0.375, + "vis": true + }, + { + "t": 20.073834444300587, + "z": 4, + "pan": -0.375, + "vis": true + }, + { + "t": 19.84652229958318, + "z": 4, + "pan": -0.375, + "vis": true + }, + { + "t": 19.59902895948362, + "z": 4, + "pan": -0.375, + "vis": true + }, + { + "t": 19.590020151035784, + "z": 4, + "pan": -0.375, + "vis": true + }, + { + "t": 19.819495874240427, + "z": 4, + "pan": -0.375, + "vis": true + }, + { + "t": 20.004165176438324, + "z": 4, + "pan": -0.375, + "vis": true + }, + { + "t": 20.04988314875365, + "z": 4, + "pan": -0.375, + "vis": true + }, + { + "t": 20.155765719119486, + "z": 4, + "pan": -0.375, + "vis": false + }, + { + "t": 20.29839101782335, + "z": 4, + "pan": -0.375, + "vis": false + }, + { + "t": 20.465733707488436, + "z": 4, + "pan": -0.375, + "vis": false + }, + { + "t": 20.41399572255754, + "z": 4, + "pan": -0.375, + "vis": false + }, + { + "t": 20.484260991495486, + "z": 4, + "pan": -0.125, + "vis": true + }, + { + "t": 20.437899893553872, + "z": 4, + "pan": -0.125, + "vis": true + }, + { + "t": 20.2728277701215, + "z": 4, + "pan": -0.125, + "vis": true + }, + { + "t": 20.246705242704017, + "z": 4, + "pan": -0.125, + "vis": true + }, + { + "t": 20.01269707241847, + "z": 4, + "pan": -0.125, + "vis": true + }, + { + "t": 19.928389684330604, + "z": 4, + "pan": -0.125, + "vis": true + }, + { + "t": 19.768938284939093, + "z": 4, + "pan": -0.125, + "vis": true + }, + { + "t": 19.30253729639488, + "z": 4, + "pan": -0.125, + "vis": true + }, + { + "t": 19.294838937025347, + "z": 4, + "pan": -0.125, + "vis": true + }, + { + "t": 19.745843206840316, + "z": 4, + "pan": -0.125, + "vis": true + }, + { + "t": 19.864299999186905, + "z": 4, + "pan": -0.125, + "vis": true + }, + { + "t": 19.933210668544433, + "z": 4, + "pan": -0.125, + "vis": true + }, + { + "t": 20.151822120070452, + "z": 4, + "pan": -0.125, + "vis": true + }, + { + "t": 20.13695004049185, + "z": 4, + "pan": -0.125, + "vis": false + }, + { + "t": 20.286625445179705, + "z": 4, + "pan": -0.125, + "vis": false + }, + { + "t": 20.317589824385525, + "z": 4, + "pan": -0.125, + "vis": false + }, + { + "t": 20.23471573876286, + "z": 4, + "pan": 0.125, + "vis": true + }, + { + "t": 20.282958517925504, + "z": 4, + "pan": 0.125, + "vis": true + }, + { + "t": 20.29129856337057, + "z": 4, + "pan": 0.125, + "vis": true + }, + { + "t": 20.050194586739774, + "z": 4, + "pan": 0.125, + "vis": true + }, + { + "t": 20.169462162225336, + "z": 4, + "pan": 0.125, + "vis": true + }, + { + "t": 19.917238811661438, + "z": 4, + "pan": 0.125, + "vis": true + }, + { + "t": 19.588972883995897, + "z": 4, + "pan": 0.125, + "vis": true + }, + { + "t": 19.43451950988791, + "z": 4, + "pan": 0.125, + "vis": true + }, + { + "t": 19.517577056717244, + "z": 4, + "pan": 0.125, + "vis": true + }, + { + "t": 19.53814552448372, + "z": 4, + "pan": 0.125, + "vis": true + }, + { + "t": 19.935009994614294, + "z": 4, + "pan": 0.125, + "vis": true + }, + { + "t": 20.053348438837947, + "z": 4, + "pan": 0.125, + "vis": true + }, + { + "t": 20.10019595700675, + "z": 4, + "pan": 0.125, + "vis": true + }, + { + "t": 20.409898476106704, + "z": 4, + "pan": 0.125, + "vis": false + }, + { + "t": 20.267673524320852, + "z": 4, + "pan": 0.125, + "vis": false + }, + { + "t": 20.385545838819255, + "z": 4, + "pan": 0.125, + "vis": false + }, + { + "t": 20.3344152777116, + "z": 4, + "pan": 0.375, + "vis": true + }, + { + "t": 20.1974865709402, + "z": 4, + "pan": 0.375, + "vis": true + }, + { + "t": 20.42727414238933, + "z": 4, + "pan": 0.375, + "vis": true + }, + { + "t": 20.25371246661122, + "z": 4, + "pan": 0.375, + "vis": true + }, + { + "t": 20.189428978837096, + "z": 4, + "pan": 0.375, + "vis": true + }, + { + "t": 19.87110704195918, + "z": 4, + "pan": 0.375, + "vis": true + }, + { + "t": 19.919012020924285, + "z": 4, + "pan": 0.375, + "vis": true + }, + { + "t": 19.66140115464659, + "z": 4, + "pan": 0.375, + "vis": true + }, + { + "t": 19.628071772849577, + "z": 4, + "pan": 0.375, + "vis": true + }, + { + "t": 19.819023875525644, + "z": 4, + "pan": 0.375, + "vis": true + }, + { + "t": 19.901092828326796, + "z": 4, + "pan": 0.375, + "vis": true + }, + { + "t": 20.152756001611777, + "z": 4, + "pan": 0.375, + "vis": true + }, + { + "t": 20.150380725774415, + "z": 4, + "pan": 0.375, + "vis": false + }, + { + "t": 20.153916333327548, + "z": 4, + "pan": 0.375, + "vis": false + }, + { + "t": 20.457469998282214, + "z": 4, + "pan": 0.375, + "vis": false + }, + { + "t": 20.227739941458225, + "z": 4, + "pan": 0.375, + "vis": false + }, + { + "t": 20.420297995696323, + "z": 4, + "pan": 0.625, + "vis": true + }, + { + "t": 20.506935245172492, + "z": 4, + "pan": 0.625, + "vis": true + }, + { + "t": 20.46932740008255, + "z": 4, + "pan": 0.625, + "vis": true + }, + { + "t": 20.1531202605363, + "z": 4, + "pan": 0.625, + "vis": true + }, + { + "t": 20.32387123005442, + "z": 4, + "pan": 0.625, + "vis": true + }, + { + "t": 20.060798122646027, + "z": 4, + "pan": 0.625, + "vis": true + }, + { + "t": 19.97224476053462, + "z": 4, + "pan": 0.625, + "vis": true + }, + { + "t": 19.96880708629648, + "z": 4, + "pan": 0.625, + "vis": true + }, + { + "t": 19.92258510639837, + "z": 4, + "pan": 0.625, + "vis": true + }, + { + "t": 20.133578820845404, + "z": 4, + "pan": 0.625, + "vis": true + }, + { + "t": 20.271098087547088, + "z": 4, + "pan": 0.625, + "vis": true + }, + { + "t": 20.14172723517672, + "z": 4, + "pan": 0.625, + "vis": true + }, + { + "t": 20.17853230583783, + "z": 4, + "pan": 0.625, + "vis": false + }, + { + "t": 20.24370535000293, + "z": 4, + "pan": 0.625, + "vis": false + }, + { + "t": 20.48886923529993, + "z": 4, + "pan": 0.625, + "vis": false + }, + { + "t": 20.30978802603518, + "z": 4, + "pan": 0.625, + "vis": false + }, + { + "t": 20.338883880018653, + "z": 4, + "pan": 0.875, + "vis": true + }, + { + "t": 20.493753543614755, + "z": 4, + "pan": 0.875, + "vis": true + }, + { + "t": 20.310142185323997, + "z": 4, + "pan": 0.875, + "vis": true + }, + { + "t": 20.456807925771514, + "z": 4, + "pan": 0.875, + "vis": true + }, + { + "t": 20.38401425943972, + "z": 4, + "pan": 0.875, + "vis": true + }, + { + "t": 20.270199726112853, + "z": 4, + "pan": 0.875, + "vis": true + }, + { + "t": 20.4247340733134, + "z": 4, + "pan": 0.875, + "vis": true + }, + { + "t": 20.140970173704897, + "z": 4, + "pan": 0.875, + "vis": true + }, + { + "t": 20.14417596868028, + "z": 4, + "pan": 0.875, + "vis": true + }, + { + "t": 20.43435145824168, + "z": 4, + "pan": 0.875, + "vis": true + }, + { + "t": 20.230710006939052, + "z": 4, + "pan": 0.875, + "vis": false + }, + { + "t": 20.350936130220234, + "z": 4, + "pan": 0.875, + "vis": false + }, + { + "t": 20.430141386499216, + "z": 4, + "pan": 0.875, + "vis": false + }, + { + "t": 20.534368541955057, + "z": 4, + "pan": 0.875, + "vis": false + }, + { + "t": 20.424391490198218, + "z": 4, + "pan": 0.875, + "vis": false + }, + { + "t": 20.57593341655391, + "z": 4, + "pan": 0.875, + "vis": false + }, + { + "t": 20.32240043408858, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.587723195024843, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.625462444980812, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.51780556554647, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.571638517647028, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.700427803877236, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.58807274084886, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.713722787462363, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.586338606625727, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.50592019833804, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.547887283947265, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.464329636044877, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.45572832225591, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.492997224282053, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.500489612655542, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.580398490045464, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.578552089972565, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.456647637793655, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.51760978608596, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.53239622114092, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.8143499943219, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.688749350337222, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.991557378850846, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 21.035671741186924, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.881191915076887, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.828117900527896, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.891257604754486, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.707898596531095, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.71698517111149, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.468146468815902, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.39822466831335, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.51116946828528, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.626647230000284, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.528237182793273, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.697046324497492, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.61445565088469, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.904247245917926, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.88201275718759, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 21.18112585198909, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 21.225091686762656, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 21.378711504188328, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 21.04198530426783, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.80308077537413, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.832554898957717, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.550002938773503, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.69280217829802, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.531232671447054, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.636882353506422, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.452277037968845, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.44028941047949, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.502139983839893, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.70443534841505, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.78226978584133, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.96553061645101, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 21.25815835521531, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 21.4705606034716, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 21.479387397921816, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 21.28463873856357, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.94828242967634, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.782675187970362, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.722494339433737, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.77647040474623, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.43227342028844, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 20.46191463667781, + "z": 4, + "pan": 1, + "vis": false + }, + { + "t": 22.409585883269962, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.394344663490525, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.325239474697355, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.367929038510745, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.37870420183137, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.338395189490825, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.510796843216518, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.557449390721477, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.557324815944604, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.762076224895818, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.718559632429884, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.77244626116346, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.034190917579583, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.145487552869, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.25135378010516, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.29298672330349, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.37910926616505, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.213693403115, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.23404425602711, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.044992706461052, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.90946513019305, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.731795581607738, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.697557259796895, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.61902293099393, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.49536458591876, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.35843103841501, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.324772550559317, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.491298643048353, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.487880783670143, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.23629608178578, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.431618350726875, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.32307665065479, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.28245264150435, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.206360150859783, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.39745623345171, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.280934698819248, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.288799389659466, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.31649850438139, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.351798913952464, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.452936591862976, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.577885253134625, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.7280669031909, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.733379088990706, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.716873600210945, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.97621119804002, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.14808450368325, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.219534103966268, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.272046347760188, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.20791228787214, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.151245095046566, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.075640545731897, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.025499120312574, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.762006573451842, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.77435711319995, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.640776807624047, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.48644020853612, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.60733659823281, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.391357332232978, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.33847544194347, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.3066213781899, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.246031621261412, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.282855153174395, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.337604121550804, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.409541663163726, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.226033675250203, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.400284150441045, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.299931149082497, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.429782367448404, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.42510703370462, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.398221132492914, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.486739858386684, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.56144842275553, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.61734377265104, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.65547673426912, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.588183342183658, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.734344508971603, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.8605155589679, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.00465694768112, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.95852505976148, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.102436329455657, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.09176224162006, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.96609707182242, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.030475059638615, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.864985495254274, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.75706054515445, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.62914547826304, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.675090694677355, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.6552038329558, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.6175545829567, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.38493838875076, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.451225216919383, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.496357218027686, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.22554825909559, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.36807925763095, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.23667835888589, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.330673983591662, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.210956712642506, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.343464218576738, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.409451698389034, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.35602060838516, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.296262414487135, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.422030565608154, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.383453339717686, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.3884091093098, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.581584106370205, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.46364040336255, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.642111578178575, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.656544738517127, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.855859210235323, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.793960729565757, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.845604801460706, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 23.05220019562872, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.916184691418053, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.917221604199366, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.823209839253703, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.863077311502135, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.62139514673308, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.5645942933438, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.614092110106537, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.43966812006349, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.454125429952082, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.52788717940987, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.42334818477825, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.255212340606672, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.34641820587595, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.304002923036634, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.19564775017332, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.270772551188518, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.23755736476289, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.28631190923107, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.226524360476834, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.310275926743113, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.40304619603832, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.388909573417546, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.293193967036544, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.43544996624245, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.472776273272572, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.405520102946937, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.64930841874347, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.48935340788403, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.720420834377585, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.776210397195456, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.889654422883797, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.873212313389494, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.68305400437604, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.76349978599716, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.714059432435604, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.777953251591228, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.610889497224196, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.584848180210425, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.460743246387167, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.342003088839547, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.368680453935745, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.32145831330138, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.255827115210504, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.33396740995828, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.35926796671609, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.341132628496883, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.214923849377353, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.23017297703631, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.327888665788358, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.193359545756294, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.270888850434744, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.381374462625644, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.347157285836953, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.276183990120817, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.227001520919504, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.29699137209787, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.331547045506348, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.330811348827773, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.553099126161406, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.443066170878048, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.54400149301462, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.598338605016647, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.571914599435413, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.742825913702248, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.591473166568512, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.61357188810788, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.583005929495318, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.4759633232259, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.568038036895448, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.371081027985014, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.34608775638392, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.289833488868695, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.44828785126632, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.26752263162794, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.317785010827947, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.33176834235033, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.24272533622034, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.395804448486878, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.261285179614507, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.33882433545282, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.34294997165441, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.15364976218186, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.134242579479444, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.368184118058206, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.26498074091589, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.332386725194475, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.21175230594658, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.19696735449107, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.353540980550605, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.431513444078686, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.455273450486853, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.450527104312407, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.364727330360743, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.528139516851645, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.348809044056974, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.580748761148538, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.36254518249702, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.489369247658455, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.5274635027061, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.427644158912386, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.37220771511685, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.48571784354426, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.275550679833163, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.306341998558054, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.258532154751226, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.19584124118805, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.31630815040451, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.35766594837902, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.191215461204074, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.149284411892612, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.27745537684771, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.32551936857337, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.151153999964876, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.11539129073067, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.207320320403976, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.135655987152912, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.171654785962776, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.3355847374923, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.214312293243715, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.32284277685754, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.30954606528891, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.17442775513799, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.320757612132912, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.380001877517994, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.316575958568688, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.411247844336636, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.288595373534644, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.29079506195926, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.3269392633623, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.26624226176217, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.330397419388646, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.308013936427013, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.312942542200656, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.195200963640023, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.371159509451196, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.197780506426664, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.152579904819348, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.279564250262766, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.17911295508434, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.20668569037959, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.284330536488532, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.256142314451406, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.355715971602137, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.332981367661173, + "z": 5, + "pan": -1, + "vis": false + }, + { + "t": 22.268415967139415, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.107313459632802, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.144395495539552, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.121657043256747, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.088247971435155, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.252784029214855, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.19984448144182, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.293358668545352, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.08457633315221, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.073491878662246, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.220843202245508, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.138974833627774, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.255652931665036, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.1248530199878, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.17433633128626, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.174843765678943, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.022762331878592, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.209745117704294, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.097752026624228, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.17438907070083, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.245201192881893, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.264559781718017, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.063045590534077, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.26162026524242, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.15789282085387, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.175543623550197, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.25064553414102, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.273599696579737, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.260780792122635, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.213074573301846, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.11348275761338, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.212075485338435, + "z": 5, + "pan": -0.9375, + "vis": false + }, + { + "t": 22.318411107010416, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.090537290376876, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.26864672929405, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.17759437846628, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.063078673231598, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.1038298888969, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.037544289408057, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.034065102215667, + "z": 5, + "pan": -0.8125, + "vis": true + }, + { + "t": 22.18510361982908, + "z": 5, + "pan": -0.8125, + "vis": true + }, + { + "t": 21.9906195822948, + "z": 5, + "pan": -0.8125, + "vis": true + }, + { + "t": 21.997832729749653, + "z": 5, + "pan": -0.8125, + "vis": true + }, + { + "t": 22.009646163326227, + "z": 5, + "pan": -0.8125, + "vis": true + }, + { + "t": 21.928389307000508, + "z": 5, + "pan": -0.8125, + "vis": true + }, + { + "t": 21.966141067885133, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.10199357181097, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 21.902452168171525, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 21.992010487710505, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.021163397409342, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 21.964922399542854, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.106287277736364, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.017155640121306, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.08495371260412, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.00685720422728, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.030952747820912, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.209525736266652, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.1541437712544, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.288018668085098, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.07687895847926, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.2954473605909, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.291175159679124, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.192677226821036, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.250162549514084, + "z": 5, + "pan": -0.8125, + "vis": false + }, + { + "t": 22.163291961089733, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 22.077323403775107, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 22.1051727040708, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 22.26726547606045, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 22.030439919231554, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 22.15624676365103, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 22.14819841054509, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 22.00662076947821, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 21.9763535885021, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 22.057254059933918, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 21.99562863289281, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 22.05940502433768, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 21.988089420683153, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 21.76460545248618, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 21.944227837957992, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 21.752391185902653, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 21.904787870353417, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 21.73534247243374, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 21.944438036986845, + "z": 5, + "pan": -0.6875, + "vis": true + }, + { + "t": 21.972715374085602, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 21.932748927764862, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 22.007690486344977, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 22.124109282287645, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 21.931926760880778, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 22.100911891881445, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 22.04329342937394, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 22.19404920498045, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 22.206960310586183, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 22.227810428082385, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 22.225204414352024, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 22.08607306408079, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 22.31075957142064, + "z": 5, + "pan": -0.6875, + "vis": false + }, + { + "t": 22.06265118483371, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 22.160380132733334, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 22.19252745617625, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 22.177291879924326, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 22.180460046726033, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 22.116411387764604, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.950833777672212, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 22.1039711582923, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.93791451340902, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.952316628202396, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.915128268532236, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.895807885267697, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.81134134697069, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.6323425337917, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.78907805770846, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.651575999128802, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.638715851293043, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.777904563006096, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.622855692222757, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.776134209730266, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.86228740116063, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.883294437558575, + "z": 5, + "pan": -0.5625, + "vis": true + }, + { + "t": 21.894762501557217, + "z": 5, + "pan": -0.5625, + "vis": false + }, + { + "t": 21.882047039897238, + "z": 5, + "pan": -0.5625, + "vis": false + }, + { + "t": 22.04979033791387, + "z": 5, + "pan": -0.5625, + "vis": false + }, + { + "t": 22.15456640223613, + "z": 5, + "pan": -0.5625, + "vis": false + }, + { + "t": 22.038196924848016, + "z": 5, + "pan": -0.5625, + "vis": false + }, + { + "t": 22.103932236942875, + "z": 5, + "pan": -0.5625, + "vis": false + }, + { + "t": 22.056376397385076, + "z": 5, + "pan": -0.5625, + "vis": false + }, + { + "t": 22.091966003854996, + "z": 5, + "pan": -0.5625, + "vis": false + }, + { + "t": 22.061505333545394, + "z": 5, + "pan": -0.5625, + "vis": false + }, + { + "t": 22.21546303877922, + "z": 5, + "pan": -0.5625, + "vis": false + }, + { + "t": 22.152653608288404, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 22.172688324750712, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 22.08511934965574, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 22.061339572673514, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.973094932697148, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 22.025200230022648, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.906757382439228, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.953253722701803, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.88740711781693, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.95855549532102, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.840957419630925, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.653307595197063, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.606652741704092, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.6496151150121, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.62420932275093, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.51972849053759, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.55699868089864, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.531654991480224, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.677236262109616, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.70881426952387, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.62564460138495, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.93346990418704, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.875608360599273, + "z": 5, + "pan": -0.4375, + "vis": true + }, + { + "t": 21.924635461463442, + "z": 5, + "pan": -0.4375, + "vis": false + }, + { + "t": 22.110657544716332, + "z": 5, + "pan": -0.4375, + "vis": false + }, + { + "t": 21.94872286194603, + "z": 5, + "pan": -0.4375, + "vis": false + }, + { + "t": 22.127319911127422, + "z": 5, + "pan": -0.4375, + "vis": false + }, + { + "t": 22.195390092170246, + "z": 5, + "pan": -0.4375, + "vis": false + }, + { + "t": 22.18620208710057, + "z": 5, + "pan": -0.4375, + "vis": false + }, + { + "t": 22.25213036821924, + "z": 5, + "pan": -0.4375, + "vis": false + }, + { + "t": 22.20987482168221, + "z": 5, + "pan": -0.4375, + "vis": false + }, + { + "t": 22.060015583588246, + "z": 5, + "pan": -0.4375, + "vis": false + }, + { + "t": 22.039006853667765, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 22.190342489026882, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 22.192157883256208, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.981766164048295, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 22.011482552071154, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 22.139285789882834, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.890417356659242, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 22.04020479029856, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.78468172073202, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.872797321763795, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.747634179119274, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.601300236389484, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.550832692771866, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.384425562841162, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.379839618548036, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.261086798962037, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.419685939092044, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.444088220406005, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.35432362642705, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.587929036618135, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.544046041963433, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.846029446421127, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.788390869325838, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 21.855924730021965, + "z": 5, + "pan": -0.3125, + "vis": true + }, + { + "t": 22.01709726131601, + "z": 5, + "pan": -0.3125, + "vis": false + }, + { + "t": 21.90821088835348, + "z": 5, + "pan": -0.3125, + "vis": false + }, + { + "t": 22.089026002888048, + "z": 5, + "pan": -0.3125, + "vis": false + }, + { + "t": 22.116872226804542, + "z": 5, + "pan": -0.3125, + "vis": false + }, + { + "t": 22.21636941208023, + "z": 5, + "pan": -0.3125, + "vis": false + }, + { + "t": 22.020395299977714, + "z": 5, + "pan": -0.3125, + "vis": false + }, + { + "t": 22.174229367475796, + "z": 5, + "pan": -0.3125, + "vis": false + }, + { + "t": 22.178543193844803, + "z": 5, + "pan": -0.3125, + "vis": false + }, + { + "t": 22.22480149296444, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 22.119814789120326, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 22.18622182687066, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 22.073647236860214, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 22.154465110420613, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.904030874930257, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 22.024303352288268, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 22.031609997667662, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.95368797451686, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.78911527741237, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.716642119341074, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.549936005130984, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.54596911914296, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.441603852800267, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.206704408416687, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.179324635353154, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.195635746113695, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.358614853728, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.479113632662354, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.616101120526164, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.505667341064925, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.807972789825783, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.913068169418175, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.963240201073432, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.926761558774952, + "z": 5, + "pan": -0.1875, + "vis": true + }, + { + "t": 21.998924910363304, + "z": 5, + "pan": -0.1875, + "vis": false + }, + { + "t": 21.967403992109375, + "z": 5, + "pan": -0.1875, + "vis": false + }, + { + "t": 22.103437562150525, + "z": 5, + "pan": -0.1875, + "vis": false + }, + { + "t": 22.154880416239514, + "z": 5, + "pan": -0.1875, + "vis": false + }, + { + "t": 22.053415834672435, + "z": 5, + "pan": -0.1875, + "vis": false + }, + { + "t": 22.12260813147281, + "z": 5, + "pan": -0.1875, + "vis": false + }, + { + "t": 22.113194169867718, + "z": 5, + "pan": -0.1875, + "vis": false + }, + { + "t": 22.10852292219315, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 22.165738861374393, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 22.10269505189025, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 22.042594511616308, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 22.023249011426582, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 22.13086396921896, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.86124213254495, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 22.005474191899992, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.77235965585769, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.660245418408138, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.574435000525632, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.486764204702045, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.517111663516456, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.44667881139106, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.256483360793997, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 20.95639747655562, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.081329510370033, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.11187109378825, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.282522243565175, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.352819163319523, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.552927403684837, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.62105389868829, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.706728384199646, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.799298320829045, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 22.01286855605102, + "z": 5, + "pan": -0.0625, + "vis": true + }, + { + "t": 21.871429060767806, + "z": 5, + "pan": -0.0625, + "vis": false + }, + { + "t": 22.11857810017855, + "z": 5, + "pan": -0.0625, + "vis": false + }, + { + "t": 21.991418841566126, + "z": 5, + "pan": -0.0625, + "vis": false + }, + { + "t": 22.136654451669468, + "z": 5, + "pan": -0.0625, + "vis": false + }, + { + "t": 22.05118464883849, + "z": 5, + "pan": -0.0625, + "vis": false + }, + { + "t": 22.0946841575023, + "z": 5, + "pan": -0.0625, + "vis": false + }, + { + "t": 22.267923917500998, + "z": 5, + "pan": -0.0625, + "vis": false + }, + { + "t": 22.039282530804503, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 22.217853399132437, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.98465709785735, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 22.04921514120829, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 22.139408339862324, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 22.065054575560776, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.978581907368884, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.97053643526471, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.79363694682555, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.69623033604141, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.673258283070677, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.629986304547263, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.413225159724018, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.21412125736775, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.056508393767874, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 20.99749767558907, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.052249244029678, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.103557184142108, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.25346726967563, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.56207430891343, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.52113267567002, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.806701876127125, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.68917706597914, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.778880898696862, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.948077609069333, + "z": 5, + "pan": 0.0625, + "vis": true + }, + { + "t": 21.9119294029841, + "z": 5, + "pan": 0.0625, + "vis": false + }, + { + "t": 21.89439610818007, + "z": 5, + "pan": 0.0625, + "vis": false + }, + { + "t": 21.961047094415417, + "z": 5, + "pan": 0.0625, + "vis": false + }, + { + "t": 22.020064578573894, + "z": 5, + "pan": 0.0625, + "vis": false + }, + { + "t": 22.15809621122537, + "z": 5, + "pan": 0.0625, + "vis": false + }, + { + "t": 22.133589734433713, + "z": 5, + "pan": 0.0625, + "vis": false + }, + { + "t": 22.197316088039617, + "z": 5, + "pan": 0.0625, + "vis": false + }, + { + "t": 22.26354391118189, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 22.24623445862139, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 22.138502517352382, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 22.076758683186558, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 22.030111932281883, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 22.140396842023492, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.95145004785755, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.866150193023184, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.933805439355673, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.902993781431512, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.719741909324895, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.733471188849403, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.398123466293033, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.32292945854643, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.30332353214876, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.22942104676818, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.164853388756246, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.327984138607775, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.43681832947639, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.38287702119915, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.55745300822645, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.63295199317289, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.687068549255642, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.80710847165075, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.82868148978918, + "z": 5, + "pan": 0.1875, + "vis": true + }, + { + "t": 21.9898159904993, + "z": 5, + "pan": 0.1875, + "vis": false + }, + { + "t": 22.06302108723655, + "z": 5, + "pan": 0.1875, + "vis": false + }, + { + "t": 22.0419644419659, + "z": 5, + "pan": 0.1875, + "vis": false + }, + { + "t": 22.072328417808084, + "z": 5, + "pan": 0.1875, + "vis": false + }, + { + "t": 22.11044797548349, + "z": 5, + "pan": 0.1875, + "vis": false + }, + { + "t": 22.0574081812234, + "z": 5, + "pan": 0.1875, + "vis": false + }, + { + "t": 22.16394589825487, + "z": 5, + "pan": 0.1875, + "vis": false + }, + { + "t": 22.19202345001897, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 22.077051843638422, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 22.080434956459875, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 22.132567947256554, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.97750957234532, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.938413007554672, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.923478644741323, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.97002379425214, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.8791334008889, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.89975663845571, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.775719484769034, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.62555069704809, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.58912326877122, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.39525148820385, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.468363177725177, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.445182952285304, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.29772680218445, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.48135919611845, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.56869967509127, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.467659155457035, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.664538752227934, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.725159708443016, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.804284561928007, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.944113492855326, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.945456054712537, + "z": 5, + "pan": 0.3125, + "vis": true + }, + { + "t": 21.944742472735822, + "z": 5, + "pan": 0.3125, + "vis": false + }, + { + "t": 22.02938513630739, + "z": 5, + "pan": 0.3125, + "vis": false + }, + { + "t": 21.978933869592264, + "z": 5, + "pan": 0.3125, + "vis": false + }, + { + "t": 22.158013206259504, + "z": 5, + "pan": 0.3125, + "vis": false + }, + { + "t": 22.197399121999283, + "z": 5, + "pan": 0.3125, + "vis": false + }, + { + "t": 22.10446817767177, + "z": 5, + "pan": 0.3125, + "vis": false + }, + { + "t": 22.12989195254653, + "z": 5, + "pan": 0.3125, + "vis": false + }, + { + "t": 22.167189421862307, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 22.163626754878354, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 22.237320514793524, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 22.225382615065733, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 22.171440609842794, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.942708660378088, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.92449822016917, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 22.063197213384832, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 22.024413379909458, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.807484647279363, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.81668929037077, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.714002255546063, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.6871703101186, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.695325932328153, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.562782947875803, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.53602504192772, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.376879011310326, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.5522981479004, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.583502362994707, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.75705467955059, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.68254785561983, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.933896121086466, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.856399416760425, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.971989380032436, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 21.909434444149454, + "z": 5, + "pan": 0.4375, + "vis": true + }, + { + "t": 22.06625513076096, + "z": 5, + "pan": 0.4375, + "vis": false + }, + { + "t": 22.119315060549738, + "z": 5, + "pan": 0.4375, + "vis": false + }, + { + "t": 21.996708240656535, + "z": 5, + "pan": 0.4375, + "vis": false + }, + { + "t": 22.144666135088688, + "z": 5, + "pan": 0.4375, + "vis": false + }, + { + "t": 22.142957315014563, + "z": 5, + "pan": 0.4375, + "vis": false + }, + { + "t": 22.21792478574098, + "z": 5, + "pan": 0.4375, + "vis": false + }, + { + "t": 22.12014868336707, + "z": 5, + "pan": 0.4375, + "vis": false + }, + { + "t": 22.178878439486056, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 22.039639141236457, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 22.049369527621245, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 22.186626218903353, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 22.057400512309783, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 22.075509289044163, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.98227386438006, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 22.10759174435882, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.8782669079253, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 22.04395214025954, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.924427638483653, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.946413436422823, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.867804388420655, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.776319351030153, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.578935428904565, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.551865233373668, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.54636877922067, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.686012417645326, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.745969782664673, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.8264619117492, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.76764440264511, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.858232047599696, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.9667636410696, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 21.913651851629115, + "z": 5, + "pan": 0.5625, + "vis": true + }, + { + "t": 22.005550130956365, + "z": 5, + "pan": 0.5625, + "vis": false + }, + { + "t": 22.12723964264411, + "z": 5, + "pan": 0.5625, + "vis": false + }, + { + "t": 22.075048210229475, + "z": 5, + "pan": 0.5625, + "vis": false + }, + { + "t": 22.169512876388858, + "z": 5, + "pan": 0.5625, + "vis": false + }, + { + "t": 22.058720402090785, + "z": 5, + "pan": 0.5625, + "vis": false + }, + { + "t": 22.263062426288094, + "z": 5, + "pan": 0.5625, + "vis": false + }, + { + "t": 22.115905482797032, + "z": 5, + "pan": 0.5625, + "vis": false + }, + { + "t": 22.117718223940397, + "z": 5, + "pan": 0.5625, + "vis": false + }, + { + "t": 22.150084762403473, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 22.152856650756156, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 22.263688896445036, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 22.075489672068937, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 22.078653288215563, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 22.188691805336365, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 22.081660761533175, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 22.08132102075005, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 21.936534239783526, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 21.897157610950778, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 21.900622526737305, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 22.001128045499968, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 21.96078406888921, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 21.803699749188205, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 21.921299989286037, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 21.91168369158254, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 21.881789330197833, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 21.801570514212383, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 21.844135160425573, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 21.94143075735729, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 21.89193962027899, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 21.95159898782739, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 22.13834534927146, + "z": 5, + "pan": 0.6875, + "vis": true + }, + { + "t": 22.087886864415253, + "z": 5, + "pan": 0.6875, + "vis": false + }, + { + "t": 22.142838531692668, + "z": 5, + "pan": 0.6875, + "vis": false + }, + { + "t": 22.156856940354906, + "z": 5, + "pan": 0.6875, + "vis": false + }, + { + "t": 22.100585479820555, + "z": 5, + "pan": 0.6875, + "vis": false + }, + { + "t": 22.150711849010847, + "z": 5, + "pan": 0.6875, + "vis": false + }, + { + "t": 22.244508143345353, + "z": 5, + "pan": 0.6875, + "vis": false + }, + { + "t": 22.186123620781995, + "z": 5, + "pan": 0.6875, + "vis": false + }, + { + "t": 22.235456261403993, + "z": 5, + "pan": 0.6875, + "vis": false + }, + { + "t": 22.142849259362414, + "z": 5, + "pan": 0.6875, + "vis": false + }, + { + "t": 22.16672312554686, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.270299612874044, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.191087013602417, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.249059833658727, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.093610699087083, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.25465614326591, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.20568619004401, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.038889840216957, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.187838853046173, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.152492968578162, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.100473188684255, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 21.987813384178924, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 21.943310947621733, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.040120256540536, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 21.92911578908677, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 21.893945071918242, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 21.91085141487421, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.070763619895747, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.056509575202465, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 21.993512952195868, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.162756876605815, + "z": 5, + "pan": 0.8125, + "vis": true + }, + { + "t": 22.15015816896411, + "z": 5, + "pan": 0.8125, + "vis": false + }, + { + "t": 21.985990634770083, + "z": 5, + "pan": 0.8125, + "vis": false + }, + { + "t": 22.146078007091035, + "z": 5, + "pan": 0.8125, + "vis": false + }, + { + "t": 22.12187048211447, + "z": 5, + "pan": 0.8125, + "vis": false + }, + { + "t": 22.09870796666796, + "z": 5, + "pan": 0.8125, + "vis": false + }, + { + "t": 22.2461909589282, + "z": 5, + "pan": 0.8125, + "vis": false + }, + { + "t": 22.209887002602475, + "z": 5, + "pan": 0.8125, + "vis": false + }, + { + "t": 22.078965329662715, + "z": 5, + "pan": 0.8125, + "vis": false + }, + { + "t": 22.215917490882862, + "z": 5, + "pan": 0.8125, + "vis": false + }, + { + "t": 22.16987157800708, + "z": 5, + "pan": 0.8125, + "vis": false + }, + { + "t": 22.191036578533055, + "z": 5, + "pan": 0.8125, + "vis": false + }, + { + "t": 22.212128058226664, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.239411807138044, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.155798947947943, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.137214274888464, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.216482080741443, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.18461386468087, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.08557795928823, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.216059645322918, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.235163657346085, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.1428843987576, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.236342152164564, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.05665075221971, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.266424667415073, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.031481048718575, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.157431949946496, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.1753258227538, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.11885054509132, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.056591542103064, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.136275510694187, + "z": 5, + "pan": 0.9375, + "vis": true + }, + { + "t": 22.258268574065735, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.25412952868941, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.139455798453305, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.183047489721393, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.23096161812892, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.16749247592479, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.12999902423017, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.228731009776826, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.216234095656446, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.098020799965482, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.24823527735704, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.287483006366173, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.21583412727384, + "z": 5, + "pan": 0.9375, + "vis": false + }, + { + "t": 22.221723006241128, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.12974843008111, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.136075631948454, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.35130259596161, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.302054503656535, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.35134760319089, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.298175707137812, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.29242427544756, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.13545568769469, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.327275540479327, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.352660094172087, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.239197459183774, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.223674678981038, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.228610512377916, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.404216117013373, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.425283919995323, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.402901319958687, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.361427369606847, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.41541561760151, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.36571458413145, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.360831216964222, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.20388770458263, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.383737950816638, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.171511950662104, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.308074391044954, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.220159687842344, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.301826371345086, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.23212712444089, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.126082172835186, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.250976905289686, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.224243556052308, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.295811984842498, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.275038195153375, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.272235977048506, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.326921982958265, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.347071986050874, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.377699115481153, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.21653080357688, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.280295269488537, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.34667333981054, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.222005184892126, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.40633106468678, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.434822497616157, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.34616688566866, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.314053043188572, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.38038707684635, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.5644798534836, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.54743801725159, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.380346706412716, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.567926536211, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.554371753140007, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.40385509780465, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.356506933850916, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.36570053936462, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.2530264847576, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.239238598529212, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.284444747013584, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.217437363200226, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.240657582668298, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.322363888138977, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.29978735135958, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.357942127504696, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.223794115160853, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.14713432683215, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.371496106510055, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.166174836139167, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.227492345847956, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.373503589802297, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.19219431727379, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.2787092811871, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.280224034109334, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.376821844146892, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.492565831783743, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.37759880470268, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.48029473838087, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.625150151924927, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.53555419825784, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.485471056964787, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.63763547481, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.541715567872416, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.55111415780354, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.675501357368322, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.55180423215032, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.620684553306035, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.488747799600034, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.37235967868323, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.538460924867366, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.431895244575607, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.344618549565542, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.370119438072727, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.29377045909484, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.235722787809067, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.3681174801847, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.318285288872822, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.285435071790662, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.269223634789135, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.391902576001705, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.23949294095116, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.175518791192157, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.433344948352048, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.36563226244199, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.387990263129925, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.27745844383665, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.299912712150785, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.414414866803135, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.62131212261366, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.558119488977464, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.51602155005366, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.561923626997036, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.621841436576148, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.64382375851363, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.732897523782363, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.85797665772449, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.887641959908244, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.734398705423274, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.674639163728482, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.497476154237514, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.658313160613805, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.471664062134277, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.383505873776233, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.38774278657627, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.3390856774796, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.344717672892106, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.44109873965669, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.276073524146444, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.369882603743633, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.302595820955002, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.323744523458096, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.226821141103986, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.40087432581277, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.37237022103821, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.24233088917804, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.241167137762158, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.28949246800387, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.352397366165647, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.43299634194746, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.56577728183615, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.501402258295318, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.74856921350256, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.719657080376948, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.73958899526953, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.92447666089974, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.949824203038087, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.99408580408805, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.032946812085715, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.024735958817967, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.785439164461874, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.67827351482716, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.69439234771663, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.50935522862444, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.589910289412597, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.440336060735643, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.343605868628938, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.376515228020658, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.313874758462717, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.301600176003372, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.280044017115145, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.296576023057057, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.361130875613522, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.2231284386871, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.353417320934497, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.311923333193697, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.276773763666846, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.329333907425315, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.35233041385155, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.38406424757318, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.44036343448253, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.53616014212301, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.64409153005346, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.51520842446965, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.642287542598055, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.833901050522606, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.786472336418566, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.92005777096873, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.162794527287314, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.126522335982997, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.150363829607215, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.0569836071494, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.934594437068792, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.84869198976728, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.766468290108953, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.695202368422315, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.615806237542508, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.61503692936408, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.37745312767109, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.564567121794205, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.39338780660728, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.482001559123805, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.425994324143655, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.32447548242549, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.22997263818977, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.391814212168764, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.396416297591934, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.21483495930334, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.361081279649238, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.296248465008343, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.48871496586044, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.511654975992236, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.475175028952112, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.551005174530413, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.461285388867847, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.707437677387894, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.686312916833657, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.7452296387756, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.890628532724314, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.162595815410754, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.099558284071378, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.328222481640907, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.240713322193233, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.08964965540118, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.98028771751804, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.033302115936298, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.715503752765, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.734187561600535, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.580294003259443, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.661742245516866, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.579062561956817, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.469300966589064, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.442294575300743, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.49695509594644, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.41911393304724, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.271903621617383, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.203257832048855, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.21243970111498, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.25922651669402, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.39057866309626, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.308397116517455, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.411661732103877, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.405795273816388, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.439174915900367, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.3801419325705, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.496917870677525, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.657246962535417, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.61278231415403, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.701925855948733, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.753031163527247, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.10232477482099, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.054271855542414, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.244121773833694, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.460068684119808, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.3448319835848, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.350164823800068, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 23.131594656010172, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.949174174218918, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.82116031342641, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.741334756349286, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.671717813484644, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.687462212367365, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.498412871010625, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.534091013624447, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.431476265664706, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.369376374082222, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.24656051599304, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.26278456621472, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.31624586329465, + "z": 5, + "pan": 1, + "vis": false + }, + { + "t": 22.40617346739395, + "z": 5, + "pan": 1, + "vis": false + } + ], + "boot": [ + { + "t": 1.4, + "ch": "G", + "line": 0 + }, + { + "t": 1.418181818181818, + "ch": "E", + "line": 0 + }, + { + "t": 1.4363636363636363, + "ch": "V", + "line": 0 + }, + { + "t": 1.4545454545454544, + "ch": ":", + "line": 0 + }, + { + "t": 1.4727272727272727, + "ch": "/", + "line": 0 + }, + { + "t": 1.4909090909090907, + "ch": "/", + "line": 0 + }, + { + "t": 1.509090909090909, + "ch": "O", + "line": 0 + }, + { + "t": 1.5272727272727271, + "ch": "B", + "line": 0 + }, + { + "t": 1.5454545454545454, + "ch": "S", + "line": 0 + }, + { + "t": 1.5636363636363635, + "ch": "E", + "line": 0 + }, + { + "t": 1.5818181818181818, + "ch": "R", + "line": 0 + }, + { + "t": 1.5999999999999999, + "ch": "V", + "line": 0 + }, + { + "t": 1.6181818181818182, + "ch": "E", + "line": 0 + }, + { + "t": 1.6363636363636362, + "ch": "R", + "line": 0 + }, + { + "t": 1.6545454545454543, + "ch": "-", + "line": 0 + }, + { + "t": 1.6727272727272726, + "ch": "1", + "line": 0 + }, + { + "t": 1.690909090909091, + "ch": " ", + "line": 0 + }, + { + "t": 1.709090909090909, + "ch": " ", + "line": 0 + }, + { + "t": 1.727272727272727, + "ch": "r", + "line": 0 + }, + { + "t": 1.7454545454545454, + "ch": "e", + "line": 0 + }, + { + "t": 1.7636363636363637, + "ch": "v", + "line": 0 + }, + { + "t": 1.7818181818181817, + "ch": " ", + "line": 0 + }, + { + "t": 1.7999999999999998, + "ch": "0", + "line": 0 + }, + { + "t": 1.8181818181818181, + "ch": ".", + "line": 0 + }, + { + "t": 1.8363636363636362, + "ch": "2", + "line": 0 + }, + { + "t": 1.8545454545454545, + "ch": ".", + "line": 0 + }, + { + "t": 1.8727272727272726, + "ch": "1", + "line": 0 + }, + { + "t": 2.15, + "ch": "a", + "line": 1 + }, + { + "t": 2.168181818181818, + "ch": "l", + "line": 1 + }, + { + "t": 2.186363636363636, + "ch": "l", + "line": 1 + }, + { + "t": 2.2045454545454546, + "ch": "o", + "line": 1 + }, + { + "t": 2.2227272727272727, + "ch": "c", + "line": 1 + }, + { + "t": 2.2409090909090907, + "ch": "a", + "line": 1 + }, + { + "t": 2.259090909090909, + "ch": "t", + "line": 1 + }, + { + "t": 2.2772727272727273, + "ch": "i", + "line": 1 + }, + { + "t": 2.2954545454545454, + "ch": "n", + "line": 1 + }, + { + "t": 2.3136363636363635, + "ch": "g", + "line": 1 + }, + { + "t": 2.3318181818181816, + "ch": " ", + "line": 1 + }, + { + "t": 2.35, + "ch": "s", + "line": 1 + }, + { + "t": 2.368181818181818, + "ch": "u", + "line": 1 + }, + { + "t": 2.3863636363636362, + "ch": "b", + "line": 1 + }, + { + "t": 2.4045454545454543, + "ch": "s", + "line": 1 + }, + { + "t": 2.422727272727273, + "ch": "t", + "line": 1 + }, + { + "t": 2.440909090909091, + "ch": "r", + "line": 1 + }, + { + "t": 2.459090909090909, + "ch": "a", + "line": 1 + }, + { + "t": 2.477272727272727, + "ch": "t", + "line": 1 + }, + { + "t": 2.495454545454545, + "ch": "e", + "line": 1 + }, + { + "t": 2.5136363636363637, + "ch": " ", + "line": 1 + }, + { + "t": 2.5318181818181817, + "ch": ".", + "line": 1 + }, + { + "t": 2.55, + "ch": ".", + "line": 1 + }, + { + "t": 2.568181818181818, + "ch": ".", + "line": 1 + }, + { + "t": 2.5863636363636364, + "ch": ".", + "line": 1 + }, + { + "t": 2.6045454545454545, + "ch": ".", + "line": 1 + }, + { + "t": 2.6227272727272726, + "ch": ".", + "line": 1 + }, + { + "t": 2.6409090909090907, + "ch": ".", + "line": 1 + }, + { + "t": 2.659090909090909, + "ch": ".", + "line": 1 + }, + { + "t": 2.6772727272727272, + "ch": " ", + "line": 1 + }, + { + "t": 2.6954545454545453, + "ch": "1", + "line": 1 + }, + { + "t": 2.7136363636363634, + "ch": ".", + "line": 1 + }, + { + "t": 2.7318181818181815, + "ch": "0", + "line": 1 + }, + { + "t": 2.75, + "ch": "0", + "line": 1 + }, + { + "t": 2.768181818181818, + "ch": "0", + "line": 1 + }, + { + "t": 2.786363636363636, + "ch": "0", + "line": 1 + }, + { + "t": 2.8045454545454547, + "ch": " ", + "line": 1 + }, + { + "t": 2.8227272727272728, + "ch": " ", + "line": 1 + }, + { + "t": 2.840909090909091, + "ch": "O", + "line": 1 + }, + { + "t": 2.859090909090909, + "ch": "K", + "line": 1 + }, + { + "t": 2.75, + "ch": "l", + "line": 2 + }, + { + "t": 2.768181818181818, + "ch": "o", + "line": 2 + }, + { + "t": 2.786363636363636, + "ch": "a", + "line": 2 + }, + { + "t": 2.8045454545454547, + "ch": "d", + "line": 2 + }, + { + "t": 2.8227272727272728, + "ch": "i", + "line": 2 + }, + { + "t": 2.840909090909091, + "ch": "n", + "line": 2 + }, + { + "t": 2.859090909090909, + "ch": "g", + "line": 2 + }, + { + "t": 2.8772727272727274, + "ch": " ", + "line": 2 + }, + { + "t": 2.8954545454545455, + "ch": "p", + "line": 2 + }, + { + "t": 2.9136363636363636, + "ch": "r", + "line": 2 + }, + { + "t": 2.9318181818181817, + "ch": "i", + "line": 2 + }, + { + "t": 2.95, + "ch": "o", + "line": 2 + }, + { + "t": 2.9681818181818183, + "ch": "r", + "line": 2 + }, + { + "t": 2.9863636363636363, + "ch": "s", + "line": 2 + }, + { + "t": 3.0045454545454544, + "ch": " ", + "line": 2 + }, + { + "t": 3.0227272727272725, + "ch": ".", + "line": 2 + }, + { + "t": 3.040909090909091, + "ch": ".", + "line": 2 + }, + { + "t": 3.059090909090909, + "ch": ".", + "line": 2 + }, + { + "t": 3.077272727272727, + "ch": ".", + "line": 2 + }, + { + "t": 3.0954545454545457, + "ch": ".", + "line": 2 + }, + { + "t": 3.1136363636363638, + "ch": ".", + "line": 2 + }, + { + "t": 3.131818181818182, + "ch": ".", + "line": 2 + }, + { + "t": 3.15, + "ch": ".", + "line": 2 + }, + { + "t": 3.168181818181818, + "ch": ".", + "line": 2 + }, + { + "t": 3.1863636363636365, + "ch": ".", + "line": 2 + }, + { + "t": 3.2045454545454546, + "ch": ".", + "line": 2 + }, + { + "t": 3.2227272727272727, + "ch": ".", + "line": 2 + }, + { + "t": 3.2409090909090907, + "ch": ".", + "line": 2 + }, + { + "t": 3.259090909090909, + "ch": ".", + "line": 2 + }, + { + "t": 3.2772727272727273, + "ch": " ", + "line": 2 + }, + { + "t": 3.2954545454545454, + "ch": "N", + "line": 2 + }, + { + "t": 3.3136363636363635, + "ch": "O", + "line": 2 + }, + { + "t": 3.331818181818182, + "ch": "N", + "line": 2 + }, + { + "t": 3.35, + "ch": "E", + "line": 2 + }, + { + "t": 3.5, + "ch": ">", + "line": 3 + }, + { + "t": 3.5384615384615383, + "ch": " ", + "line": 3 + }, + { + "t": 3.576923076923077, + "ch": "s", + "line": 3 + }, + { + "t": 3.6153846153846154, + "ch": "e", + "line": 3 + }, + { + "t": 3.6538461538461537, + "ch": "e", + "line": 3 + }, + { + "t": 3.6923076923076925, + "ch": "d", + "line": 3 + }, + { + "t": 3.730769230769231, + "ch": " ", + "line": 3 + }, + { + "t": 3.769230769230769, + "ch": "-", + "line": 3 + }, + { + "t": 3.8076923076923075, + "ch": "-", + "line": 3 + }, + { + "t": 3.8461538461538463, + "ch": "o", + "line": 3 + }, + { + "t": 3.8846153846153846, + "ch": "r", + "line": 3 + }, + { + "t": 3.923076923076923, + "ch": "i", + "line": 3 + }, + { + "t": 3.9615384615384617, + "ch": "g", + "line": 3 + }, + { + "t": 4, + "ch": "i", + "line": 3 + }, + { + "t": 4.038461538461538, + "ch": "n", + "line": 3 + }, + { + "t": 4.076923076923077, + "ch": " ", + "line": 3 + }, + { + "t": 4.115384615384615, + "ch": "0", + "line": 3 + }, + { + "t": 4.153846153846154, + "ch": ".", + "line": 3 + }, + { + "t": 4.1923076923076925, + "ch": "0", + "line": 3 + }, + { + "t": 4.230769230769231, + "ch": "0", + "line": 3 + }, + { + "t": 4.269230769230769, + "ch": "0", + "line": 3 + }, + { + "t": 4.3076923076923075, + "ch": "0", + "line": 3 + }, + { + "t": 4.346153846153846, + "ch": "°", + "line": 3 + }, + { + "t": 4.384615384615385, + "ch": "N", + "line": 3 + }, + { + "t": 4.423076923076923, + "ch": " ", + "line": 3 + }, + { + "t": 4.461538461538462, + "ch": "0", + "line": 3 + }, + { + "t": 4.5, + "ch": ".", + "line": 3 + }, + { + "t": 4.538461538461538, + "ch": "0", + "line": 3 + }, + { + "t": 4.576923076923077, + "ch": "0", + "line": 3 + }, + { + "t": 4.615384615384615, + "ch": "0", + "line": 3 + }, + { + "t": 4.653846153846153, + "ch": "0", + "line": 3 + }, + { + "t": 4.6923076923076925, + "ch": "°", + "line": 3 + }, + { + "t": 4.730769230769231, + "ch": "E", + "line": 3 + } + ], + "status": [ + 53.4, + 54.5, + 55.6, + 56.7, + 57.7 + ], + "rapid": [ + 50.4, + 50.62, + 50.839999999999996, + 51.059999999999995, + 51.28, + 51.5, + 51.72 + ] +} \ No newline at end of file diff --git a/intro-sequence/audio/score.py b/intro-sequence/audio/score.py new file mode 100644 index 000000000..e1980da5a --- /dev/null +++ b/intro-sequence/audio/score.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python3 +"""God's Eye View — First Light: procedural score and sound design. + +Everything here is synthesised from scratch (no samples) and placed against +audio/cues.json, which tools/export-cues.mjs exports from src/timeline.js, so +every hit lands on its frame. + + python3 audio/score.py -> audio/score.wav (48 kHz, 24-bit stereo) +""" +import json +import os +import numpy as np +from scipy import signal +from scipy.io import wavfile + +SR = 48000 +HERE = os.path.dirname(os.path.abspath(__file__)) +C = json.load(open(os.path.join(HERE, 'cues.json'))) +T = C['T'] +DUR = C['DURATION'] +N = int(SR * DUR) +rng = np.random.default_rng(20261018) + +# ---------------------------------------------------------------- buses +def bus(): + return np.zeros((2, N), dtype=np.float64) + +B = {k: bus() for k in ('drone', 'music', 'perc', 'hits', 'sfx', 'ui', 'verb')} + + +def place(dst, sig, t, gain=1.0, pan=0.0, verb=0.0): + """Mix mono/stereo `sig` into bus `dst` at time t (s), equal-power pan.""" + if sig.ndim == 1: + p = (pan + 1) * np.pi / 4 + sig = np.vstack([sig * np.cos(p), sig * np.sin(p)]) + i0 = int(round(t * SR)) + if i0 >= N: + return + s0 = max(0, -i0) + i0 = max(0, i0) + n = min(sig.shape[1] - s0, N - i0) + if n <= 0: + return + seg = sig[:, s0:s0 + n] * gain + B[dst][:, i0:i0 + n] += seg + if verb > 0: + B['verb'][:, i0:i0 + n] += seg * verb + + +def tt(dur): + return np.arange(int(dur * SR)) / SR + + +def note(name): + names = {'C': -9, 'C#': -8, 'Db': -8, 'D': -7, 'D#': -6, 'Eb': -6, 'E': -5, 'F': -4, 'F#': -3, 'Gb': -3, 'G': -2, 'G#': -1, 'Ab': -1, 'A': 0, 'A#': 1, 'Bb': 1, 'B': 2} + n, o = name[:-1], int(name[-1]) + return 440.0 * 2 ** ((names[n] + (o - 4) * 12) / 12) + + +def sos_lp(fc, order=2): + return signal.butter(order, min(fc, SR * 0.45), 'low', fs=SR, output='sos') + + +def sos_hp(fc, order=2): + return signal.butter(order, max(fc, 10), 'high', fs=SR, output='sos') + + +def sos_bp(lo, hi, order=2): + return signal.butter(order, [max(lo, 10), min(hi, SR * 0.45)], 'band', fs=SR, output='sos') + + +def filt(x, sos): + return signal.sosfilt(sos, x, axis=-1) + + +def sweep_filter(x, cut_fn, kind='low', block=256, order=2): + """Time-varying Butterworth: coefficients per block, state carried across.""" + y = np.zeros_like(x) + zi = None + for b in range(0, len(x), block): + tc = b / SR + fc = float(cut_fn(tc)) + if kind == 'low': + sos = sos_lp(fc, order) + elif kind == 'high': + sos = sos_hp(fc, order) + else: + bw = fc * 0.35 + sos = sos_bp(fc - bw, fc + bw, order) + if zi is None: + zi = signal.sosfilt_zi(sos) * 0 + y[b:b + block], zi = signal.sosfilt(sos, x[b:b + block], zi=zi) + return y + + +def saw(f, dur, phase=0.0): + t = tt(dur) + if np.isscalar(f): + ph = f * t + phase + else: + ph = np.cumsum(f) / SR + phase + return 2 * (ph - np.floor(ph + 0.5)) + + +def sine(f, dur, phase=0.0): + t = tt(dur) + if np.isscalar(f): + return np.sin(2 * np.pi * f * t + phase) + return np.sin(2 * np.pi * np.cumsum(f) / SR + phase) + + +def noise(dur): + return rng.standard_normal(int(dur * SR)) + + +def env_exp(dur, decay, attack=0.002): + t = tt(dur) + a = np.minimum(1, t / max(attack, 1e-4)) + return a * np.exp(-t * decay) + + +def env_ar(dur, attack, release, curve=2.0): + t = tt(dur) + up = np.clip(t / max(attack, 1e-4), 0, 1) ** curve + down = np.clip((dur - t) / max(release, 1e-4), 0, 1) ** curve + return np.minimum(up, down) + + +# ---------------------------------------------------------------- instruments +def click(freq=4000, dur=0.012, q=0.5): + x = noise(dur) * env_exp(dur, 900, 0.0003) + return filt(x, sos_bp(freq * (1 - q * 0.5), freq * (1 + q))) + + +def blip(f, dur=0.05, harm=0.25, decay=60): + t = tt(dur) + e = env_exp(dur, decay, 0.001) + return (np.sin(2 * np.pi * f * t) + harm * np.sin(4 * np.pi * f * t)) * e + + +def thump(f0=70, f1=34, dur=0.9, decay=5.0, punch=24): + t = tt(dur) + f = f1 + (f0 - f1) * np.exp(-t * punch) + body = sine(f, dur) * env_exp(dur, decay, 0.001) + tr = filt(noise(0.02), sos_lp(2500)) * env_exp(0.02, 200) + out = body + out[: len(tr)] += tr * 0.4 + return out + + +def braam(dur=4.0, notes=('D1', 'A1', 'D2', 'A2'), bright=2600, gain=1.0, decay=1.6): + """Detuned saw stack through a fast-opening, slow-closing lowpass + drive.""" + n = int(dur * SR) + acc = np.zeros(n) + for nm in notes: + f = note(nm) + for d in (-0.012, -0.005, 0.0, 0.006, 0.013): + acc += saw(f * (1 + d), dur, rng.random()) + acc /= len(notes) * 3.0 + cut = lambda tc: 160 + (bright - 160) * min(1.0, tc / 0.07) * np.exp(-max(0.0, tc - 0.07) * decay) + y = sweep_filter(acc, cut, 'low', order=2) + y = np.tanh(y * 2.2) * env_ar(dur, 0.012, dur * 0.85, 1.2) + sub = sine(note(notes[0]) * np.ones(n) * (1 + 0.5 * np.exp(-tt(dur) * 18)), dur) * env_exp(dur, 1.3, 0.004) + imp = filt(noise(0.6), sos_lp(900)) * env_exp(0.6, 9) + out = y * 0.85 + sub * 0.9 + out[: len(imp)] += imp * 0.5 + return out * gain + + +def whoosh(dur=1.2, f0=400, f1=4000, peak=0.5): + x = noise(dur) + t0 = tt(dur) + cut = lambda tc: f0 * (f1 / f0) ** min(1.0, tc / dur) + y = sweep_filter(x, cut, 'band', order=2) + shape = np.exp(-((t0 / dur - peak) ** 2) / 0.05) + return y * shape + + +def riser(dur=3.0, f0=200, f1=6000): + x = noise(dur) + cut = lambda tc: f0 * (f1 / f0) ** ((tc / dur) ** 1.6) + y = sweep_filter(x, cut, 'band', order=2) + t0 = tt(dur) + tone = sine(f0 * (f1 / f0 / 8) ** ((t0 / dur) ** 1.6) * np.ones_like(t0), dur) * 0.2 + return (y + tone) * (t0 / dur) ** 2.2 + + +def shepard(dur, rate_oct_per_s, base=40.0, layers=9, accel=0.0): + t0 = tt(dur) + pos = rate_oct_per_s * t0 + 0.5 * accel * t0 ** 2 + out = np.zeros_like(t0) + for k in range(layers): + o = (k + pos) % layers + f = base * 2 ** o + a = np.exp(-((o - layers / 2) ** 2) / (2 * (layers / 5) ** 2)) + ph = np.cumsum(f) / SR + out += np.sin(2 * np.pi * ph) * a + return out / layers * 2 + + +def fm_bell(f, dur=2.0, ratio=3.5, index=4.0, decay=2.5): + t = tt(dur) + e = env_exp(dur, decay, 0.002) + mod = np.sin(2 * np.pi * f * ratio * t) * index * np.exp(-t * 3) + return np.sin(2 * np.pi * f * t + mod) * e + + +def pluck(f, dur=0.22, cutoff=1800, decay=14): + x = saw(f, dur, rng.random()) + 0.5 * saw(f * 1.005, dur, rng.random()) + e = env_exp(dur, decay, 0.002) + cut = lambda tc: 120 + cutoff * np.exp(-tc * 22) + return sweep_filter(x * e, cut, 'low', block=128, order=2) * 0.7 + + +def pad(freqs, dur, cutoff=900, attack=1.2, release=1.5): + n = int(dur * SR) + acc = np.zeros(n) + for f in freqs: + for d in (-0.004, 0.0, 0.0045): + acc += saw(f * (1 + d), dur, rng.random()) + acc /= len(freqs) * 3 + y = filt(acc, sos_lp(cutoff, 2)) + return y * env_ar(dur, attack, release, 1.5) + + +def static(dur, lo=1500, hi=9000): + return filt(noise(dur), sos_bp(lo, hi)) + + +def stereo_noise_bed(dur, lo, hi): + return np.vstack([filt(noise(dur), sos_bp(lo, hi)), filt(noise(dur), sos_bp(lo, hi))]) + + +# =================================================================== ACT 0 +# room tone that the whole film sits on +bed = stereo_noise_bed(DUR, 30, 140) * 0.05 +ramp_bed = np.clip(tt(DUR) / 3.0, 0, 1) +B['drone'] += bed * ramp_bed + +# sub drone: D1 + A1, breathing; intensity rises with the film, dies at the cut +t_all = tt(DUR) +intensity = np.interp(t_all, [0, 5, 9, 16, 30, 52, 60.4, 60.5, 61.2, 66, 72], [0.0, 0.25, 0.45, 0.6, 0.7, 0.85, 1.0, 0.0, 0.0, 0.7, 0.0]) +dr = (np.sin(2 * np.pi * note('D1') * t_all) * 0.6 + np.sin(2 * np.pi * note('A1') * 1.001 * t_all) * 0.25 + np.sin(2 * np.pi * note('D2') * 0.999 * t_all) * 0.18) +dr *= 0.85 + 0.15 * np.sin(2 * np.pi * 0.11 * t_all) +B['drone'] += np.vstack([dr, dr]) * intensity * 0.35 + +# electrical hum under the boot console +hum_e = np.interp(t_all, [0.4, 1.2, T['collapse'], T['seed']], [0, 1, 1, 0], left=0, right=0) +hum = (sine(100, DUR) * 0.3 + sine(150, DUR) * 0.12 + filt(noise(DUR), sos_bp(90, 400)) * 0.05) * hum_e +place('sfx', hum, 0, 0.18) + +# keystrokes +for k in C['boot']: + if k['ch'] == ' ': + continue + f = 3500 + rng.random() * 2500 + place('ui', click(f, 0.01), k['t'], 0.22 + rng.random() * 0.08, pan=(rng.random() - 0.5) * 0.3) +# "OK" confirmation +place('ui', blip(1760, 0.09) * 0.6 + blip(2640, 0.09) * 0.3, T['boot'] + 0.75 + 41 / 55, 0.25, verb=0.2) +# cursor appears +place('ui', blip(1200, 0.03), T['cursor'], 0.12) + +# collapse: suck-in riser then silence, then the seed ignites +place('sfx', riser(T['seed'] - T['collapse'] + 0.05, 300, 9000)[::-1][::-1] * 1.0, T['collapse'], 0.35) +rev = whoosh(0.6, 6000, 300, 0.85) +place('sfx', rev, T['collapse'] - 0.15, 0.4) +place('hits', thump(90, 32, 1.4, 3.5), T['seed'], 0.85, verb=0.3) +place('ui', fm_bell(note('D6'), 2.5, 2.0, 1.5, 1.6), T['seed'], 0.16, verb=0.6) + +# heartbeat +for bt, g in ((T['beat1'], 0.9), (T['beat2'], 1.0)): + place('hits', thump(62, 30, 0.7, 7.5, 30), bt, g * 1.4, verb=0.25) + place('hits', thump(52, 28, 0.6, 9, 30), bt + 0.19, g * 0.55, verb=0.2) + +# dish ticks: 120 ticks swept around the ring, panned with the sweep +for i in range(120): + k = i / 120 + # easeInOut timing of the sweep + u = np.interp(k, np.linspace(0, 1, 400), [((4 * x ** 3) if x < 0.5 else 1 - (-2 * x + 2) ** 3 / 2) for x in np.linspace(0, 1, 400)]) + tk = T['dishDraw'] + u * (T['dishDone'] - T['dishDraw']) + loud = 0.12 if i % 10 == 0 else 0.05 + place('ui', click(6500 if i % 10 else 3800, 0.008), tk, loud, pan=np.sin(k * 2 * np.pi) * 0.8) + +# =================================================================== ACT 1 growth +gs, ge = T['growStart'], T['growEnd'] +# granular crackle, density following exponential growth +for _ in range(2600): + x = rng.random() ** 0.45 # skewed late + tc = gs + x * (ge - gs + 0.3) + k = (np.exp(3 * (tc - gs) / (ge - gs)) - 1) / (np.exp(3) - 1) + if rng.random() > 0.15 + 0.85 * k: + continue + f = 1800 + rng.random() * 7000 + place('sfx', click(f, 0.006 + rng.random() * 0.01, 0.8), tc, 0.05 + 0.1 * rng.random() * (0.4 + k), pan=rng.uniform(-0.9, 0.9)) +# organic tone: saw cluster gliding up an octave, filter opening +dur = ge - gs + 0.6 +tg = tt(dur) +glide = note('D2') * 2 ** (((tg / dur) ** 1.8)) +cl = (saw(glide, dur) + saw(glide * 1.007, dur) + saw(glide * 1.5, dur) * 0.4) +cl = sweep_filter(cl, lambda tc: 150 + 2200 * (tc / dur) ** 2, 'low', order=2) +cl *= env_ar(dur, 2.5, 0.35, 1.4) * 0.18 +place('music', cl, gs, 1.0, verb=0.25) +# city pings as the culture learns names +for i in range(30): + tc = 10.2 + (i / 30) ** 0.8 * 5.2 + rng.random() * 0.1 + place('ui', blip(2000 + rng.choice([0, 300, 500, 700, 1000]), 0.06, 0.2, 45), tc, 0.08, pan=rng.uniform(-0.8, 0.8), verb=0.25) +# reverse swell into the substrate hit +place('sfx', riser(1.6, 120, 5000), T['substrate'] - 1.6, 0.3) + +# =================================================================== hits +place('hits', braam(4.5, ('D1', 'A1', 'D2'), 1800, 1.0, 1.4), T['substrate'], 0.75, verb=0.35) + +# =================================================================== ACT 2 indexing +BPM = 120.0 +beat = 60 / BPM +s16 = beat / 4 +grid0 = 17.0 + +pattern = ['D3', 'D3', 'F3', 'D3', 'A3', 'D3', 'C4', 'D3', 'D3', 'D3', 'F3', 'D3', 'G3', 'D3', 'A3', 'F3'] +prog = [('D', ['D3', 'F3', 'A3']), ('Bb', ['Bb2', 'D3', 'F3']), ('G', ['G2', 'Bb2', 'D3']), ('A', ['A2', 'C#3', 'E3'])] + + +def ostinato(t0, t1, cutoff_fn, gain_fn, transpose=0, every=1): + i = 0 + t = t0 + while t < t1 - 1e-6: + if i % every == 0: + nm = pattern[i % 16] + bar = int((t - grid0) / (beat * 8)) % 4 + f = note(nm) * 2 ** (transpose / 12) + # follow the chord root on bar changes + if prog[bar][0] == 'Bb' and nm == 'A3': + f = note('Bb3') + if prog[bar][0] == 'A' and nm == 'C4': + f = note('C#4') + acc = 1.0 if i % 4 == 0 else 0.7 + place('music', pluck(f, 0.25, cutoff_fn(t)), t, gain_fn(t) * acc, pan=0.25 if i % 2 else -0.25, verb=0.12) + i += 1 + t += s16 + + +ostinato(17.0, 29.9, lambda t: 500 + 2200 * ((t - 17) / 13) ** 1.5, lambda t: 0.06 + 0.1 * (t - 17) / 13) + +# pads under indexing +for k, t0 in enumerate(np.arange(17.0, 30.0, 4.0)): + nm, ch = prog[k % 4] + place('music', pad([note(x) for x in ch], 4.3, 700 + k * 120, 1.0, 1.2), t0, 0.12, verb=0.3) + +# tile flips: a spray of pitched blips, hemisphere in view only +scale = [note(x) for x in ('D6', 'F6', 'G6', 'A6', 'C7', 'D7')] +for tl in C['tiles']: + if not tl['vis'] or tl['t'] > 26.5: + continue + z = tl['z'] + if z == 5 and rng.random() > 0.35: + continue + f = scale[rng.integers(0, len(scale))] / (2 if z == 3 else 1) * (1 if z < 5 else 1.5) + g = {3: 0.07, 4: 0.04, 5: 0.025}[z] + place('ui', blip(f, 0.05, 0.1, 70), tl['t'], g, pan=tl['pan'] * 0.8, verb=0.1) + if z == 3: + place('ui', click(3000, 0.012), tl['t'], 0.06, pan=tl['pan'] * 0.8) + + +def shutter(t, g=1.0, pan=0.3): + place('sfx', click(3800, 0.008, 0.6), t, 0.5 * g, pan) + place('sfx', click(2200, 0.014, 0.6), t + 0.045, 0.4 * g, pan) + place('sfx', thump(160, 90, 0.05, 60, 80), t, 0.15 * g, pan) + place('ui', blip(2637, 0.05, 0.1, 60), t + 0.07, 0.12 * g, pan) + + +for cp in C['caps']: + if cp['slow']: + t0 = cp['t'] + shutter(t0, 1.0, -0.35) + # the enhance: rising sweep with data chatter + d = 1.05 + tz = tt(d) + f = 300 * (8 ** ((tz / d) ** 1.5)) + sw = sine(f, d) * 0.5 + sine(f * 1.5, d) * 0.15 + sw *= env_ar(d, 0.1, 0.15) * (0.7 + 0.3 * np.sin(2 * np.pi * 18 * tz)) + place('sfx', sw, t0 + 0.28, 0.06, -0.3, verb=0.2) + for j in range(16): + place('ui', blip(1500 + rng.random() * 3000, 0.02, 0, 120), t0 + 0.3 + j * 0.06, 0.05, rng.uniform(-0.6, 0.2)) + place('sfx', whoosh(0.5, 800, 5000, 0.5), t0 + 1.35, 0.18, 0.4) + place('ui', click(5000, 0.01), t0 + 1.75, 0.2, 0.6) + else: + shutter(cp['t'], 0.55, 0.6) + +# full-frame feature extraction +cf = T['capFull'] +place('sfx', whoosh(0.7, 300, 3000, 0.4), cf, 0.3) +place('hits', thump(80, 40, 0.8, 6), cf + 0.5, 0.35, verb=0.3) +scan_d = 1.6 +ts = tt(scan_d) +scan = (sine(220, scan_d) + 0.5 * sine(440 * (1 + 0.002 * np.sin(2 * np.pi * 6 * ts)), scan_d) + 0.3 * sine(1320, scan_d)) * env_ar(scan_d, 0.08, 0.2) +scan *= 0.6 + 0.4 * np.sin(2 * np.pi * 12 * ts) +place('sfx', scan, cf + 0.75, 0.07, 0, verb=0.2) +for j in range(70): + place('ui', click(5000 + rng.random() * 3000, 0.006), cf + 0.75 + j * (1.6 / 70), 0.06, rng.uniform(-0.5, 0.5)) +# collapse into the planet +place('sfx', riser(0.9, 300, 8000), T['indexed'] - 0.9, 0.4) +place('sfx', whoosh(0.5, 6000, 200, 0.8), T['indexed'] - 0.45, 0.35) + +# SURFACE INDEXED +place('hits', braam(5.0, ('D1', 'A1', 'D2', 'F2'), 2600, 1.0, 1.1), T['indexed'], 0.95, verb=0.4) + +# =================================================================== ACT 3 ingestion +ostinato(30.5, 52.4, lambda t: 900 + 2600 * min(1, (t - 30.5) / 18), lambda t: 0.09 + 0.06 * (t - 30.5) / 22) +# bass +for k, t0 in enumerate(np.arange(30.5, 52.4, beat * 2)): + bar = int((t0 - grid0) / (beat * 8)) % 4 + root = {'D': 'D1', 'Bb': 'Bb0', 'G': 'G1', 'A': 'A1'}[prog[bar][0]] + b = saw(note(root), 0.9) + saw(note(root) * 1.004, 0.9) + b = filt(b, sos_lp(260)) * env_exp(0.9, 3.5, 0.005) + place('music', b, t0, 0.22) +for k, t0 in enumerate(np.arange(30.5, 52.4, 4.0)): + nm, ch = prog[k % 4] + place('music', pad([note(x) for x in ch] + [note(ch[0][:-1] + str(int(ch[0][-1]) + 1))], 4.3, 1100, 0.8, 1.2), t0, 0.12, verb=0.3) +# percussion: low taiko-ish hits + ticks +for i, t0 in enumerate(np.arange(30.5, 52.4, beat)): + if i % 4 in (0, 2) or (i % 8 == 7): + place('perc', thump(95, 45, 0.5, 9, 40), t0, 0.45 if i % 4 == 0 else 0.3, verb=0.12) + place('perc', click(9000, 0.02, 0.4), t0 + beat / 2, 0.06) + if i % 4 == 2: + sn = filt(noise(0.2), sos_bp(900, 4000)) * env_exp(0.2, 22) + sine(190, 0.2) * env_exp(0.2, 30) * 0.5 + place('perc', sn, t0, 0.13, verb=0.25) + + +def feed_sig(key, t): + """Each feed gets its own sonic signature as it comes online.""" + place('ui', blip(1760, 0.06, 0.2, 40) + blip(2349, 0.06, 0.1, 40) * 0.6, t, 0.15, -0.5, verb=0.15) + place('ui', click(4500, 0.01), t, 0.25, -0.5) + if key == 'air': + for j in range(500): + x = rng.random() ** 0.7 * 1.9 + place('ui', click(5000 + rng.random() * 4000, 0.004), t + x, 0.05 + rng.random() * 0.05, rng.uniform(-0.9, 0.9)) + place('sfx', whoosh(1.4, 500, 7000, 0.35), t, 0.18, 0.3) + elif key == 'sea': + ping = sine(1150 * np.exp(-tt(2.2) * 0.05), 2.2) * env_exp(2.2, 2.2, 0.003) + place('sfx', ping, t + 0.05, 0.22, 0.2, verb=0.8) + place('sfx', ping, t + 1.05, 0.1, -0.3, verb=0.8) + place('sfx', filt(noise(2.0), sos_lp(400)) * env_ar(2.0, 0.5, 1.2), t, 0.2) + elif key == 'mil': + for j in range(3): + tone = (np.sign(np.sin(2 * np.pi * 740 * tt(0.12))) * 0.3 + sine(740, 0.12)) * env_ar(0.12, 0.005, 0.03) + place('ui', filt(tone, sos_lp(3000)), t + 0.1 + j * 0.22, 0.12, 0.3) + tone2 = (np.sign(np.sin(2 * np.pi * 554 * tt(0.12))) * 0.3 + sine(554, 0.12)) * env_ar(0.12, 0.005, 0.03) + place('ui', filt(tone2, sos_lp(3000)), t + 0.21 + j * 0.22, 0.1, 0.3) + elif key == 'orbit': + for j in range(26): + f = scale[rng.integers(0, len(scale))] * rng.choice([0.5, 1, 1]) + place('music', fm_bell(f, 2.5, 3.5, 2.5, 2.2), t + rng.random() * 2.2, 0.035, rng.uniform(-0.9, 0.9), verb=0.6) + place('sfx', whoosh(2.8, 2500, 200, 0.4), t + 0.2, 0.3, 0) + place('hits', thump(60, 30, 1.6, 2.5), t + 0.1, 0.4, verb=0.3) + elif key == 'quake': + for j, dt in enumerate((0.05, 0.42, 0.95)): + place('hits', thump(55, 26, 1.4, 3.2, 18), t + dt, 0.6 - j * 0.12, rng.uniform(-0.5, 0.5), verb=0.25) + place('sfx', filt(noise(2.5), sos_lp(90)) * env_ar(2.5, 0.2, 1.8), t, 0.9) + elif key == 'fire': + d = 2.2 + for j in range(900): + x = rng.random() ** 1.3 * d + g = (1 - x / d) * 0.12 + place('sfx', click(800 + rng.random() * 5000, 0.004 + rng.random() * 0.01, 0.9), t + x, g * rng.random(), rng.uniform(-0.8, 0.8)) + place('sfx', filt(noise(d), sos_bp(200, 1500)) * env_ar(d, 0.05, 1.6), t, 0.08) + elif key == 'wind': + d = 3.0 + w = sweep_filter(noise(d), lambda tc: 250 + 900 * np.sin(np.pi * tc / d), 'band', order=2) + w2 = sweep_filter(noise(d), lambda tc: max(180, 300 + 700 * np.sin(np.pi * tc / d + 0.6)), 'band', order=2) + place('sfx', np.vstack([w, w2]) * env_ar(d, 0.6, 1.6), t, 0.4) + elif key == 'cable': + d = 1.8 + buzz = filt(saw(60, d) + 0.5 * saw(120.5, d), sos_bp(300, 2500)) * env_ar(d, 0.02, 1.0) + gate = (np.sin(2 * np.pi * 7 * tt(d)) > 0.2).astype(float) + place('sfx', buzz * (0.4 + 0.6 * gate), t, 0.08, 0.2) + place('sfx', sine(3000 * np.exp(-tt(0.25) * 12) + 150, 0.25) * env_exp(0.25, 12), t, 0.1, -0.2) + elif key == 'cctv': + for j in range(9): + tc = t + 0.25 + j * 0.12 + place('sfx', click(2500, 0.01), tc, 0.3, rng.uniform(-0.7, 0.7)) + place('sfx', static(0.12) * env_exp(0.12, 25), tc, 0.05, rng.uniform(-0.7, 0.7)) + + +for fd in C['feeds']: + feed_sig(fd['key'], fd['t']) + +# camera moves +place('sfx', whoosh(2.3, 200, 2500, 0.5), 33.6, 0.22, -0.4) +place('sfx', whoosh(2.8, 2200, 180, 0.5), 37.2, 0.22, 0.4) +place('sfx', whoosh(2.0, 300, 3000, 0.5), 40.4, 0.15, -0.2) + +# THE WALL +wall_t = T['cctvWall'] +place('hits', braam(3.5, ('D1', 'A1', 'D2'), 1900, 0.9, 1.6), wall_t, 0.85, verb=0.3) +for j in range(100): + tc = wall_t + (rng.random() ** 0.8) * 0.85 + rng.random() * 0.12 + place('sfx', click(2000 + rng.random() * 2500, 0.012), tc, 0.18, rng.uniform(-0.95, 0.95)) + place('sfx', static(0.08) * env_exp(0.08, 30), tc, 0.04, rng.uniform(-0.95, 0.95)) +wd = T['cctvOut'] - wall_t + 0.3 +wbed = stereo_noise_bed(wd, 800, 7000) * env_ar(wd, 0.4, 0.25) +place('sfx', wbed, wall_t, 0.05) +murmur = (sine(15625 / 2, wd) * 0.02 + filt(noise(wd), sos_bp(100, 300)) * 0.4) * env_ar(wd, 0.3, 0.2) +place('sfx', murmur, wall_t, 0.12) +# the watcher hopping cell to cell (7 Hz) +for j in range(int((T['cctvOut'] - wall_t - 0.8) * 7)): + tc = wall_t + 0.8 + j / 7 + place('ui', blip(3136, 0.025, 0.0, 90), tc, 0.06, rng.uniform(-0.8, 0.8)) +# rapid list +for j, tc in enumerate(C['rapid']): + place('ui', blip(note('D6') * 2 ** (j / 12 * 2), 0.06, 0.2, 45), tc, 0.12, -0.5, verb=0.15) +# collapse +place('sfx', whoosh(0.6, 5000, 150, 0.7), T['cctvOut'] - 0.1, 0.4) +place('hits', thump(80, 30, 1.6, 2.8), T['cctvOut'] + 0.4, 0.7, verb=0.35) + +# =================================================================== ACT 4 saturation +sat0, cut = T['saturate'], T['cut'] +# pulse intensifies: 16ths, then doubled at the octave +ostinato(52.5, cut, lambda t: 2400 + 3000 * (t - 52.5) / 8, lambda t: 0.12 + 0.06 * (t - 52.5) / 8) +ostinato(55.5, cut, lambda t: 3000, lambda t: 0.05 + 0.06 * (t - 55.5) / 5, transpose=12) +for i, t0 in enumerate(np.arange(52.5, cut, beat)): + k = (t0 - 52.5) / 8 + place('perc', thump(100, 42, 0.55, 8, 40), t0, 0.5 + 0.25 * k, verb=0.15) + if t0 > 55.0: + place('perc', thump(100, 42, 0.4, 10, 40), t0 + beat / 2, 0.3 + 0.2 * k) + if i % 2 == 1: + sn = filt(noise(0.25), sos_bp(900, 5000)) * env_exp(0.25, 16) + sine(190, 0.25) * env_exp(0.25, 26) * 0.5 + place('perc', sn, t0, 0.18 + 0.12 * k, verb=0.3) + # 32nd-note snare roll in the last bar + if t0 > 58.4: + for q in range(4): + sn = filt(noise(0.08), sos_bp(1200, 6000)) * env_exp(0.08, 40) + place('perc', sn, t0 + q * beat / 4, 0.1 + 0.05 * q) +for k, t0 in enumerate(np.arange(52.5, cut, 2.0)): + nm, ch = prog[k % 4] + place('music', pad([note(x) for x in ch] + [note(x[:-1] + str(int(x[-1]) + 1)) for x in ch], 2.2, 1600 + k * 300, 0.3, 0.6), t0, 0.14, verb=0.3) +# Shepard riser across the whole act +sd = cut - sat0 +place('music', shepard(sd, 0.45, 40, 9, 0.12) * env_ar(sd, 2.5, 0.01, 1.3), sat0, 0.22, verb=0.2) +place('sfx', riser(sd, 150, 9000), sat0, 0.25) +# status lines type in +for st in C['status']: + for j in range(14): + place('ui', click(4000 + rng.random() * 2000, 0.008), st + j * 0.028, 0.12, 0.4) + place('ui', blip(2093, 0.05, 0.2, 50), st + 0.42, 0.1, 0.4) +# lattice building shimmer +for j in range(400): + tc = T['twin'] + rng.random() ** 0.7 * (T['dishBreak'] - T['twin']) + place('ui', blip(4000 + rng.random() * 5000, 0.015, 0, 150), tc, 0.025, rng.uniform(-1, 1), verb=0.3) +# containment alarm +for j, tc in enumerate(np.arange(56.6, T['dishBreak'], 0.36)): + k = (tc - 56.6) / (T['dishBreak'] - 56.6) + tone = filt((np.sign(np.sin(2 * np.pi * 880 * tt(0.16))) * 0.4 + sine(880, 0.16)), sos_lp(2500)) * env_ar(0.16, 0.01, 0.05) + place('ui', tone, tc, 0.04 + 0.08 * k, 0.0, verb=0.2) + place('ui', filt((np.sign(np.sin(2 * np.pi * 660 * tt(0.16))) * 0.4 + sine(660, 0.16)), sos_lp(2500)) * env_ar(0.16, 0.01, 0.05), tc + 0.18, 0.04 + 0.08 * k, verb=0.2) +# the breach: impact + shattering ring +db = T['dishBreak'] +place('hits', braam(3.0, ('D1', 'A1', 'D2', 'A2'), 3200, 1.0, 1.2), db, 1.0, verb=0.4) +place('hits', thump(120, 28, 2.5, 1.8, 14), db, 0.9) +for j in range(220): + tc = db + (rng.random() ** 2.2) * 1.1 + f = 2500 + rng.random() * 7000 + gr = filt(noise(0.25), sos_bp(f * 0.97, f * 1.03)) * env_exp(0.25, 10 + rng.random() * 25) + place('sfx', gr, tc, 0.18 * (1 - (tc - db) / 1.2), rng.uniform(-1, 1), verb=0.3) +# OMNIPRESENT +om = T['omni'] +place('hits', braam(2.5, ('D1', 'D2', 'A2', 'D3'), 3800, 1.0, 0.8), om, 0.9, verb=0.45) +place('ui', fm_bell(note('A6'), 1.5, 2.0, 1.2, 2.0), om, 0.08, verb=0.5) + +# =================================================================== ACT 5 title +ti = T['title'] +# the hush after the cut, then a warm low bloom +d = DUR - ti +warm = (sine(note('D1'), d) * 0.6 + sine(note('A1') * 1.002, d) * 0.3 + sine(note('D2') * 0.998, d) * 0.2) +warm *= np.interp(tt(d), [0, 0.8, 2.0, d - 3.5, d], [0, 0.4, 0.7, 0.6, 0]) +place('drone', warm, ti, 0.35) +air = stereo_noise_bed(d, 2000, 12000) * np.interp(tt(d), [0, 1.5, d - 3, d], [0, 1, 1, 0]) +place('drone', air, ti, 0.012) +# eye opens +eo = T['eyeOpen'] +place('sfx', riser(0.9, 400, 7000), eo - 0.9, 0.18) +place('hits', thump(70, 34, 2.5, 2.2), eo, 0.55, verb=0.4) +for f, g in ((note('D6'), 0.06), (note('A6'), 0.05), (note('E7'), 0.035), (note('D5'), 0.05)): + place('music', fm_bell(f, 4.5, 2.0, 0.8, 0.8), eo, g, rng.uniform(-0.5, 0.5), verb=0.8) +# wordmark: decode chatter then the warm hit +wm = T['wordmark'] +for j in range(40): + place('ui', click(3000 + rng.random() * 4000, 0.007), wm + j * 0.022, 0.08, rng.uniform(-0.4, 0.4)) +place('hits', braam(6.0, ('D1', 'A1', 'D2', 'E2'), 1400, 1.0, 0.5), wm + 0.85, 0.7, verb=0.5) +place('music', pad([note(x) for x in ('D3', 'A3', 'E4', 'F#4', 'A4')], 7.5, 1400, 1.5, 3.5), wm + 0.85, 0.16, verb=0.5) +# tagline keystrokes +tg_t = T['tagline'] +for j, ch in enumerate('NO PLACE LEFT BEHIND.'): + if ch != ' ': + place('ui', click(3800 + rng.random() * 1500, 0.009), tg_t + j / 22, 0.14) +# ONLINE +on = T['online'] +place('ui', blip(note('A5'), 0.12, 0.3, 18), on, 0.16, verb=0.5) +place('ui', blip(note('D6'), 0.35, 0.3, 8), on + 0.12, 0.16, verb=0.6) +place('hits', thump(60, 30, 3.5, 1.4, 10), on, 0.6, verb=0.5) +place('music', pad([note(x) for x in ('D2', 'A2', 'D3', 'E3', 'A3')], DUR - on, 900, 0.8, 3.5), on, 0.12, verb=0.4) + +# =================================================================== reverb +def make_ir(rt60=3.4, pre=0.025): + n = int((rt60 + pre) * SR) + t0 = np.arange(n) / SR + ir = np.zeros((2, n)) + for c in range(2): + x = rng.standard_normal(n) * np.exp(-6.9 * t0 / rt60) + # darker tail: blend toward a lowpassed copy over time + lp = filt(x, sos_lp(2500)) + mix = np.clip(t0 / 1.5, 0, 1) + ir[c] = x * (1 - mix) + lp * mix + ir[c, : int(pre * SR)] = 0 + return ir / np.sqrt(np.sum(ir ** 2) / 2) + + +ir = make_ir() +wet = np.vstack([signal.fftconvolve(B['verb'][c], ir[c])[:N] for c in range(2)]) + +# =================================================================== mix + master +# pre-hit dips: the bed drops out for a beat so each impact lands in a hole +dip = np.ones(N) +for ht, depth in ((T['substrate'], 0.3), (T['indexed'], 0.25), (T['cctvWall'], 0.4), (T['cctvOut'] + 0.4, 0.4), (T['dishBreak'], 0.2), (T['omni'], 0.45), (T['wordmark'] + 0.85, 0.4)): + a, b, c = int((ht - 0.28) * SR), int((ht - 0.03) * SR), int((ht + 0.5) * SR) + dip[a:b] = np.minimum(dip[a:b], np.linspace(1, depth, b - a)) + dip[b:c] = np.minimum(dip[b:c], np.linspace(depth, 1, c - b) ** 0.7) +bed_mix = (B['drone'] * 1.0 + B['music'] * 0.9 + B['perc'] * 0.8 + B['sfx'] * 0.9 + B['ui'] * 0.85) * dip +mix = bed_mix + B['hits'] * 1.25 + wet * 0.32 +mix = filt(mix, sos_hp(24, 2)) + +# glitch stutter into the cut, then hard silence until the title +def stutter(x, a, b, slice_s=0.045): + ia, ib = int(a * SR), int(b * SR) + sl = int(slice_s * SR) + src = x[:, ia:ia + sl].copy() + for i in range(ia, ib, sl): + n = min(sl, ib - i) + x[:, i:i + n] = src[:, :n] * (0.9 if ((i - ia) // sl) % 2 == 0 else 0.6) + return x + + +mix = stutter(mix, cut - 0.22, cut, 0.04) +ic = int(cut * SR) +it = int(T['title'] * SR) +fade = int(0.004 * SR) +mix[:, ic - fade:ic] *= np.linspace(1, 0, fade) +mix[:, ic:it] = 0 +# collapse glitch: tiny stutter as the console implodes +mix = stutter(mix, T['collapse'] + 0.02, T['collapse'] + 0.14, 0.03) + +# glue: slow RMS compressor, then soft clip and a lookahead peak limiter +def rms_env(x, win_s=0.05): + w = int(win_s * SR) + p = np.mean(x ** 2, axis=0) + k = np.ones(w) / w + return np.sqrt(np.convolve(p, k, mode='same') + 1e-12) + + +env = rms_env(mix, 0.08) +thr = 10 ** (-15 / 20) +ratio = 1.8 +gain = np.where(env > thr, (thr * (env / thr) ** (1 / ratio)) / env, 1.0) +gain = signal.sosfiltfilt(sos_lp(8), gain) +mix *= gain +mix = np.tanh(mix * 1.6) / 1.6 + +look = int(0.004 * SR) +peak = np.max(np.abs(mix), axis=0) +peak = np.lib.stride_tricks.sliding_window_view(np.pad(peak, (0, look)), look + 1).max(axis=1)[:N] +ceiling = 10 ** (-1.2 / 20) +lg = np.minimum(1.0, ceiling / np.maximum(peak, 1e-9)) +lg = np.minimum.reduce([lg, np.roll(lg, 1), np.roll(lg, -1)]) +lg = signal.sosfiltfilt(sos_lp(60), lg) +mix *= np.minimum(lg, 1.0) +mix *= ceiling / max(1e-9, np.max(np.abs(mix))) + +out = os.path.join(HERE, 'score.wav') +wavfile.write(out, SR, (mix.T * (2 ** 31 - 1)).astype(np.int32)) +print('wrote', out, f'{DUR}s', 'peak', np.max(np.abs(mix))) diff --git a/intro-sequence/data/SOURCES.md b/intro-sequence/data/SOURCES.md new file mode 100644 index 000000000..cb269138c --- /dev/null +++ b/intro-sequence/data/SOURCES.md @@ -0,0 +1,62 @@ +# Intro-sequence data snapshot: sources + +All files are real downloads from public keyless endpoints, fetched on **2026-10-18 between 02:24 and 02:42 UTC**. Nothing here is synthesized. The scripts only filter, convert units and round. Helper scripts are in `../tools/fetch/` (`fetch_basic.sh`, `process_basic.py`, `process_tle.py`, `process_vessels.py`, `fetch_wind.py`, `fetch_bonus.py`, `cctv_fetch.py`, `cctv_select.py`). + +JSON files are compact. Tabular layers use `{"cols":[...],"rows":[[...],...]}`, and each file's `cols` lists its columns. Coordinates are `[lon, lat]` in WGS84. + +| File | Rows | Source URL(s) | Fetched (UTC) | Notes / licence | +|---|---|---|---|---| +| `flights.json` | 5,550 airborne aircraft | https://opensky-network.org/api/states/all | 02:24:18 (`t`=1792290258) | OpenSky Network state vectors, anonymous tier. Airborne only (`on_ground=false`), non-null positions. `alt_m` is geometric altitude, or barometric when geometric is missing. Licence: OpenSky Network, non-commercial/research terms; credit "The OpenSky Network, https://opensky-network.org". | +| `military.json` | 45 airborne aircraft | https://api.adsb.lol/v2/mil, plus https://opendata.adsb.fi/api/v2/mil (merged and de-duplicated by ICAO hex; adsb.lol wins) | 02:24 | Includes the `type` column (ICAO type code, e.g. C17, K35R). alt ft→m, gs kt→m/s. `country` is null because these feeds do not provide it. The count is low because the snapshot was taken at a quiet hour. A re-poll at 02:42 gave fewer (30 and 31 airborne). adsb.lol data is ODbL 1.0; adsb.fi open data. airplanes.live /v2/mil returned 403 and was not used. | +| `tle.txt` | 15,928 satellites (3-line TLE) | https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=3le | 02:26 | CelesTrak "active" group. The FORMAT=tle request kept failing with connection resets after about 11 s through the proxy, while FORMAT=3le answered in 4 s. Element epochs range from day 264 to 293 of 2026. A descriptive User-Agent is required (as in server/providers/space/celestrak.js). Credit: CelesTrak / Dr T.S. Kelso. Data originates from 18th SDS / space-track.org. | +| `tle_groups.json` | stations 17, starlink 10,651, gps-ops 32, geo 567, weather 72 | `gp.php?GROUP={stations,starlink,gps-ops,geo,weather}&FORMAT=tle` | 02:24–02:27 | `groups_norad`: NORAD ids per group, to join against `tle.txt` (columns 3–7 of line 1). `notable`: name→group for the first entries of each group (ISS (ZARYA), CSS (TIANHE), GOES, NOAA, GPS, …). | +| `quakes.json` | 10,120 events | https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojson | 02:24 (`t`=generated ms) | USGS ANSS ComCat. Covers 2026-09-18 02:26 to 2026-10-18 02:20 UTC, max M7.7. Sorted by time. Public domain (USGS). | +| `fires.json` | 246,411 hotspots | https://firms.modaps.eosdis.nasa.gov/data/active_fire/suomi-npp-viirs-c2/csv/SUOMI_VIIRS_C2_Global_24h.csv (111,551); https://firms.modaps.eosdis.nasa.gov/data/active_fire/noaa-20-viirs-c2/csv/J1_VIIRS_C2_Global_24h.csv (118,991); https://firms.modaps.eosdis.nasa.gov/data/active_fire/modis-c6.1/csv/MODIS_C6_1_Global_24h.csv (15,869) | 02:24 | Rows are `[lon, lat, frp_MW, brightness_K]`. Brightness is VIIRS `bright_ti4` or MODIS `brightness`. Despite the "24h" names, the acquisitions run from 2026-10-16 00:11 to 2026-10-17 23:56 UTC. SNPP and NOAA-20 overlap, so the same fires appear twice. NASA FIRMS (LANCE); free use with credit "NASA FIRMS". | +| `vessels.json` | 4,385 vessels | https://kystdatahuset.kystverket.no/ws/api/ais/realtime/geojson (Norway, 3,272 with a position); https://meri.digitraffic.fi/api/ais/v1/locations?from= + https://meri.digitraffic.fi/api/ais/v1/vessels for names (Finland/Baltic, 1,113 after de-duplication against Norway) | 02:28–02:29 | **Not global.** Coverage is the Norwegian coast and EEZ including Svalbard (Kystverket Kystdatahuset open API; NLOD licence) plus Finnish waters and the northern Baltic (Fintraffic Digitraffic; CC BY 4.0). Kystverket returns short trails, and the script uses the last vertex as the current position, checked against COG. `sog_kn`/`cog_deg` are null when AIS sends "not available". No keyless global AIS source was found. AISStream (the repo's source) needs a key, BarentsWatch needs OAuth (401), the Danish DMA host has an expired TLS certificate, and Kystverket's raw TCP NMEA feed cannot pass the HTTPS-only proxy. | +| `wind.json` | 360×181 grid (65,160 cells) | https://noaa-gfs-bdp-pds.s3.amazonaws.com/gfs.20261017/18/atmos/gfs.t18z.pgrb2.1p00.f006 (byte ranges from its `.idx`, UGRD/VGRD at "10 m above ground") | 02:34 | NOAA GFS 1.0° run 2026-10-17 18Z, +6 h forecast, valid 2026-10-18 00Z. This is the same AWS Open Data bucket and `.idx` range approach as server/providers/wind/gfs.js, decoded with ecCodes (pip `eccodes`). The grid is re-indexed so `lo1=-180, la1=90, dx=dy=1`, row-major from north to south, m/s. The 00Z run of 10-18 was not yet published. Public domain (NOAA). | +| `cables.json` | 1,948 polylines (732 cables) | https://www.submarinecablemap.com/api/v3/cable/cable-geo.json | 02:24 | Each LineString part is a separate polyline, rounded to 2 decimals. © TeleGeography, Submarine Cable Map (CC BY-SA 4.0); attribute TeleGeography. | +| `coastline.json` | 1,429 lines, 60,392 vertices | https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_coastline.geojson | 02:24 | Natural Earth 1:50m coastline, rounded to 2 decimals with consecutive duplicate points removed. Public domain. | +| `borders.json` | 288 rings | https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson | 02:24 | Natural Earth 1:110m countries, each polygon ring as a line. Public domain. | +| `cities.json` | 3,000 | https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_populated_places_simple.geojson | 02:24 | Top 3,000 by `pop_max`. Natural Earth, public domain. | +| `airports.json` (bonus) | 5,280 (1,176 large, 4,104 medium) | https://davidmegginson.github.io/ourairports-data/airports.csv | 02:24 | OurAirports large and medium airports. `iata` falls back to the ICAO ident when there is no IATA code. `size` is L or M. Public domain. | +| `radio.json` (bonus) | 12,983 stations | https://de1.api.radio-browser.info/json/stations/search?has_geo_info=true&hidebroken=true&limit=100000 | 02:40 | Radio Browser community directory (the repo's radio provider). Stations with geo coordinates only. Public domain / open. | +| `bikes.json` (bonus) | 31,176 docks | 18 GBFS `station_information.json` feeds (Citi Bike, Divvy, Bay Wheels, Capital Bikeshare via gbfs.lyft.com; Bluebikes; Toronto, Barcelona, Madrid, São Paulo, Rio, Santiago, Bogotá via publicbikesystem.net; BIXI; Ecobici; Vélib'; Indego and LA Metro via bcycle; Oslo via urbansharing) + https://api.nextbike.net/maps/nextbike-live.json (all nextbike cities, 16,926 stations) | 02:41 | Per-system counts are in `systems`. Operator open-data terms apply (mostly GBFS open licences). | +| `launch_sites.json` (bonus) | 249 pads | https://ll.thespacedevs.com/2.3.0/pads/?limit=100&mode=list (paged) | 02:42 | Launch Library 2 (The Space Devs), sorted by total launches. Free tier. | +| `power_plants.json` (bonus) | 34,936 plants | https://raw.githubusercontent.com/wri/global-power-plant-database/master/output_database/global_power_plant_database.csv | 02:42 | WRI Global Power Plant Database v1.3 (static, 2021). CC BY 4.0. | + +Not produced: **military installations**. The repo's provider queries OSM Overpass by viewport only and rejects global requests. A global Overpass pull would be slow and against that design. **Datacenters**: the repo has no keyless source. + +## CCTV (`../cctv/`) + +`cctv/cam_001.jpg` … `cam_117.jpg`: 117 real frames, each fetched 2026-10-18 02:38:46–02:39:30 UTC and cover-cropped and resized to 640×360 (JPEG q88). `cctv/index.json` gives `file, name, city, country, lat, lon, source`, plus `fetched_utc`, `orig_size` and the frame `url`. + +About 235 frames were downloaded from 20 sources, and every one was reviewed on contact sheets. Placeholders were rejected: Caltrans "Temporarily Unavailable", Vegvesen "Kamera er midlertidig ute av drift", Tallinn "NB! ÜHENDUS KAAMERAGA PUUDUB", Barcelona "Interrupció temporal de la imatge", and one all-black King County frame. Frames whose average hash repeats within the set were also dropped as placeholders, as were frames under 320 px wide or with luminance mean ≤25 or std ≤15. + +Local time at capture: daylight in Sydney, NZ, Hong Kong and Singapore; evening or night in the Americas; pre-dawn night in Europe. The Hong Kong TD frames carry the department's own privacy-mask polygons. + +| Source | Frames | Country | Catalog endpoint (frames from) | +|---|---|---|---| +| Live Traffic NSW | 12 | Australia | https://data.livetraffic.com/cameras/traffic-cam.json (webcams.transport.nsw.gov.au) | +| NZTA Waka Kotahi | 12 | New Zealand | https://trafficnz.info/service/traffic/rest/4/cameras/all (trafficnz.info/camera/*.jpg) | +| HK Transport Department | 10 | Hong Kong | https://static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml (tdcctv.data.one.gov.hk) | +| LTA Singapore | 6 | Singapore | https://api.data.gov.sg/v1/transport/traffic-images (the feed listed only 8 cameras) | +| NYC DOT | 9 | USA | https://webcams.nyctmc.org/api/cameras (…/{id}/image) | +| Caltrans CCTV | 6 | USA | https://cwwp2.dot.ca.gov/data/d{4,7,11,3}/cctv/cctvStatusD0X.json | +| WSDOT | 6 | USA | https://data.wsdot.wa.gov/travelcenter/Cameras.json (images.wsdot.wa.gov only) | +| King County DOT | 3 | USA | ArcGIS TRAFFICCAMERA_POINT_2029 FeatureServer (info.kingcounty.gov) | +| TxDOT ITS | 4 | USA | https://its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict?districtCode=… (base64 JSON snapshot; Dallas and San Antonio worked, Houston and El Paso returned null) | +| City of Austin | 4 | USA | https://data.austintexas.gov/api/views/b4k4-adkb/rows.json (cctv.austinmobility.io) | +| DriveBC | 6 | Canada | https://www.drivebc.ca/api/webcams/ | +| City of Toronto RESCU | 6 | Canada | https://opendata.toronto.ca/transportation/tmc/rescucameraimages/Data/tmcearthcameras.json | +| City of Calgary | 4 | Canada | https://data.calgary.ca/resource/k7p9-kppz.json | +| TfL JamCams | 8 | UK | https://api.tfl.gov.uk/Place/Type/JamCam (jamcams.tfl.gov.uk S3) | +| Fintraffic Digitraffic | 4 | Finland | https://tie.digitraffic.fi/api/weathercam/v1/stations (weathercam.digitraffic.fi) | +| Statens vegvesen | 4 | Norway | ogckart-sn1.atlas.vegvesen.no datex_3_1:CctvSimple (kamera.atlas.vegvesen.no) | +| City of Tallinn | 3 | Estonia | repo config/cctv_sources.tallinn.json (ristmikud.tallinn.ee) | +| Transpordiamet Tarktee | 3 | Estonia | tarktee.transpordiamet.ee DATEX2 roadCameraLocations + roadCameraImages | +| Via Lietuva eismoinfo | 4 | Lithuania | https://eismoinfo.lt/eismoinfo-backend/camera-info-table + layer-static-features/VKR | +| Servei Català de Trànsit catalog | 3 | Spain | https://www.gencat.cat/transit/opendata/cameres.xml (the 3 kept frames are Barcelona IMI cameras on www.bcn.cat; SCT's own mct.gencat.cat host would not answer over HTTPS) | + +Skipped: Ontario 511 (now answers "Invalid Key"), DelDOT (video only), Warendorf (http-only host, and the proxy allows HTTPS only). + +The frames are public agency imagery, used here under each agency's open-data or terms-of-use conditions. Credit the agency named in `source`. TfL: "Powered by TfL Open Data". NSW: © Transport for NSW. HK: © HKSAR Transport Department (data.gov.hk). Singapore: LTA via data.gov.sg (Singapore Open Data Licence). NZ: © NZ Transport Agency Waka Kotahi (CC BY 4.0). diff --git a/intro-sequence/index.html b/intro-sequence/index.html new file mode 100644 index 000000000..fbd1ec731 --- /dev/null +++ b/intro-sequence/index.html @@ -0,0 +1,26 @@ + + + + +God's Eye View — First Light + + + + + + + + diff --git a/intro-sequence/package-lock.json b/intro-sequence/package-lock.json new file mode 100644 index 000000000..89b7ee76d --- /dev/null +++ b/intro-sequence/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "gev-intro-sequence", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gev-intro-sequence", + "dependencies": { + "@fontsource/inter": "^5.1.1", + "@fontsource/jetbrains-mono": "^5.1.1", + "playwright-core": "^1.56.1", + "satellite.js": "^5.0.0", + "three": "^0.169.0" + } + }, + "node_modules/@fontsource/inter": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.1.1.tgz", + "integrity": "sha512-weN3E+rq0Xb3Z93VHJ+Rc7WOQX9ETJPTAJ+gDcaMHtjft67L58sfS65rAjC5tZUXQ2FdZ/V1/sSzCwZ6v05kJw==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.1.1.tgz", + "integrity": "sha512-5rwvmdQQpXev4LlBX1P+7h2dguu6iwW/9Npjde4+DEq+HgpVJI/7QY8DI1NVVFdvLtXZNP+vO2L/5BQED6FUhA==", + "license": "OFL-1.1" + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/satellite.js": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/satellite.js/-/satellite.js-5.0.0.tgz", + "integrity": "sha512-ie3yiJ2LJAJIhVUKdYhgp7V0btXKAMImDjRnuaNfJGl8rjwP2HwVIh4HLFcpiXYEiYwXc5fqh5+yZqCe6KIwWw==", + "license": "MIT" + }, + "node_modules/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", + "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", + "license": "MIT" + } + } +} diff --git a/intro-sequence/package.json b/intro-sequence/package.json new file mode 100644 index 000000000..afd6768d0 --- /dev/null +++ b/intro-sequence/package.json @@ -0,0 +1,14 @@ +{ + "name": "gev-intro-sequence", + "private": true, + "type": "module", + "description": "God's Eye View: the intro sequence where the system comes online. Deterministic WebGL renderer + procedural score.", + "scripts": {}, + "dependencies": { + "@fontsource/inter": "^5.1.1", + "@fontsource/jetbrains-mono": "^5.1.1", + "playwright-core": "^1.56.1", + "satellite.js": "^5.0.0", + "three": "^0.169.0" + } +} diff --git a/intro-sequence/src/geo.js b/intro-sequence/src/geo.js new file mode 100644 index 000000000..39b0ba337 --- /dev/null +++ b/intro-sequence/src/geo.js @@ -0,0 +1,72 @@ +// Geodesy for the film. Convention (matches the globe shader): +// x = cos(lat) cos(lon), y = sin(lat), z = -cos(lat) sin(lon) (Y up, radius 1) +export const D2R = Math.PI / 180; +export const R2D = 180 / Math.PI; +export const EARTH_KM = 6371; + +export function llToVec(lon, lat, r = 1, out = [0, 0, 0]) { + const la = lat * D2R; + const lo = lon * D2R; + const c = Math.cos(la); + out[0] = r * c * Math.cos(lo); + out[1] = r * Math.sin(la); + out[2] = -r * c * Math.sin(lo); + return out; +} + +export function vecToLL(x, y, z) { + const r = Math.hypot(x, y, z); + return [Math.atan2(-z, x) * R2D, Math.asin(y / r) * R2D]; +} + +// Central angle in radians between two lon/lat points. +export function angDist(lon1, lat1, lon2, lat2) { + const p1 = lat1 * D2R; + const p2 = lat2 * D2R; + const dp = p2 - p1; + const dl = (lon2 - lon1) * D2R; + const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2; + return 2 * Math.asin(Math.min(1, Math.sqrt(a))); +} + +// Destination point given start, bearing (deg) and central angle (rad). +export function destination(lon, lat, bearingDeg, ang) { + const p1 = lat * D2R; + const l1 = lon * D2R; + const b = bearingDeg * D2R; + const p2 = Math.asin(Math.sin(p1) * Math.cos(ang) + Math.cos(p1) * Math.sin(ang) * Math.cos(b)); + const l2 = l1 + Math.atan2(Math.sin(b) * Math.sin(ang) * Math.cos(p1), Math.cos(ang) - Math.sin(p1) * Math.sin(p2)); + return [((l2 * R2D + 540) % 360) - 180, p2 * R2D]; +} + +export function bearing(lon1, lat1, lon2, lat2) { + const p1 = lat1 * D2R; + const p2 = lat2 * D2R; + const dl = (lon2 - lon1) * D2R; + const y = Math.sin(dl) * Math.cos(p2); + const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl); + return (Math.atan2(y, x) * R2D + 360) % 360; +} + +// Subsolar point (lon, lat) for a JS Date — NOAA low-precision solar position. +export function subsolar(date) { + const jd = date.getTime() / 86400000 + 2440587.5; + const n = jd - 2451545.0; + const L = (280.46 + 0.9856474 * n) % 360; + const g = ((357.528 + 0.9856003 * n) % 360) * D2R; + const lambda = (L + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * D2R; + const eps = (23.439 - 0.0000004 * n) * D2R; + const decl = Math.asin(Math.sin(eps) * Math.sin(lambda)); + const ra = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)); + const gmst = (280.46061837 + 360.98564736629 * n) % 360; + let lon = ra * R2D - gmst; + lon = ((lon + 540) % 360) - 180; + return [lon, decl * R2D]; +} + +export function fmtLat(lat, dp = 4) { + return `${Math.abs(lat).toFixed(dp)}°${lat >= 0 ? 'N' : 'S'}`; +} +export function fmtLon(lon, dp = 4) { + return `${Math.abs(lon).toFixed(dp)}°${lon >= 0 ? 'E' : 'W'}`; +} diff --git a/intro-sequence/src/globe.js b/intro-sequence/src/globe.js new file mode 100644 index 000000000..bcbcf4899 --- /dev/null +++ b/intro-sequence/src/globe.js @@ -0,0 +1,296 @@ +import * as THREE from 'three'; + +// The planet: one shader that walks the surface through every state the film +// needs — dark substrate, the growth front lighting up night cities, quadtree +// tiles indexing in with rising resolution, then the lit, living Earth. + +const COMMON = /* glsl */ ` + #define PI 3.141592653589793 + float hash3(vec3 p) { + p = fract(p * 0.3183099 + vec3(0.71, 0.113, 0.419)); + p *= 17.0; + return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); + } + vec3 llDir(float lon, float lat) { + float c = cos(lat); + return vec3(c * cos(lon), sin(lat), -c * sin(lon)); + } + // u wraps 1 -> 0 at the antimeridian; take derivatives from whichever of u or + // u+0.5 is continuous here so mip selection doesn't spike into a seam line + void seamGrad(vec2 uv, out vec2 gx, out vec2 gy) { + vec2 a = uv; + vec2 b = vec2(fract(uv.x + 0.5), uv.y); + vec2 ax = dFdx(a), ay = dFdy(a), bx = dFdx(b), by = dFdy(b); + gx = vec2(abs(ax.x) < abs(bx.x) ? ax.x : bx.x, ax.y); + gy = vec2(abs(ay.x) < abs(by.x) ? ay.x : by.x, ay.y); + } +`; + +export function createGlobe({ dayTex, nightTex, cloudTex }) { + const uniforms = { + uDay: { value: dayTex }, + uNight: { value: nightTex }, + uTime: { value: 0 }, + uSeed: { value: new THREE.Vector3(1, 0, 0) }, + uSun: { value: new THREE.Vector3(1, 0, 0) }, + uFront: { value: -1 }, // growth front, radians from the seed + uHolo: { value: 0 }, // substrate grid visibility + uLights: { value: 0 }, // night-light "neurons" during growth + uWave: { value: new THREE.Vector3(1e3, 1e3, 1e3) }, + uWaveSpread: { value: 2.6 }, + uImagery: { value: 0 }, // forces imagery fully on (after indexing) + uLit: { value: 0 }, // 0 = flat imagery, 1 = real sun lighting + uTexel: { value: new THREE.Vector2(1 / 8192, 1 / 4096) }, + uGridAmt: { value: 1 }, + uTint: { value: 0 }, // cyan analysis tint on the whole surface + uDim: { value: 1 }, + }; + + const mat = new THREE.ShaderMaterial({ + uniforms, + vertexShader: /* glsl */ ` + varying vec3 vObj; + varying vec3 vWorldN; + varying vec3 vView; + void main() { + vObj = position; + vec4 wp = modelMatrix * vec4(position, 1.0); + vWorldN = normalize(mat3(modelMatrix) * position); + vView = normalize(cameraPosition - wp.xyz); + gl_Position = projectionMatrix * viewMatrix * wp; + } + `, + fragmentShader: /* glsl */ ` + ${COMMON} + uniform sampler2D uDay, uNight; + uniform float uTime, uFront, uHolo, uLights, uWaveSpread, uImagery, uLit, uGridAmt, uTint, uDim; + uniform vec3 uSeed, uSun, uWave; + uniform vec2 uTexel; + varying vec3 vObj; + varying vec3 vWorldN; + varying vec3 vView; + + float mercY(float lat) { + lat = clamp(lat, -1.4844, 1.4844); + return 0.5 - log(tan(PI * 0.25 + lat * 0.5)) / (2.0 * PI); + } + float invMercY(float my) { + return atan(sinh(PI * (1.0 - 2.0 * my))); + } + + // Per-level quadtree state at this fragment. + // returns x = phase since this tile revealed, y = edge line (0..1) + vec2 tileState(float z, float waveT, vec2 uv, float my, float jitter) { + float n = exp2(z); + vec2 tc = vec2(uv.x * n, my * n); + vec2 ti = floor(tc); + vec2 f = fract(tc); + float lonc = ((ti.x + 0.5) / n) * 2.0 * PI - PI; + float latc = invMercY((ti.y + 0.5) / n); + float d = acos(clamp(dot(uSeed, llDir(lonc, latc)), -1.0, 1.0)); + float tRev = waveT + d / PI * uWaveSpread + hash3(vec3(ti, z)) * jitter; + vec2 tcb = vec2(fract(uv.x + 0.5) * n, tc.y); + vec2 w = vec2(min(fwidth(tc.x), fwidth(tcb.x)), fwidth(tc.y)); + vec2 e = min(f, 1.0 - f) / max(w, vec2(1e-5)); + float line = 1.0 - smoothstep(0.0, 1.25, min(e.x, e.y)); + return vec2(uTime - tRev, line); + } + + void main() { + vec3 n = normalize(vObj); + float lat = asin(clamp(n.y, -1.0, 1.0)); + float lon = atan(-n.z, n.x); + vec2 uv = vec2(lon / (2.0 * PI) + 0.5, lat / PI + 0.5); + float my = mercY(lat); + vec3 N = normalize(vWorldN); + vec3 V = normalize(vView); + float dSeed = acos(clamp(dot(n, uSeed), -1.0, 1.0)); + + // ---------- substrate (pre-imagery) + float gLat = abs(fract(lat * 180.0 / PI / 10.0 + 0.5) - 0.5) * 10.0; + float gLon = abs(fract(lon * 180.0 / PI / 10.0 + 0.5) - 0.5) * 10.0 * cos(lat); + float gw = fwidth(lat * 180.0 / PI) * 1.2; + float gwl = min(fwidth(lon), fwidth(mod(lon + 2.0 * PI, 2.0 * PI))) * 180.0 / PI * 1.2 * cos(lat); + float grid = max(1.0 - smoothstep(0.0, gw, gLat), 1.0 - smoothstep(0.0, max(gwl, 1e-4), gLon)); + float behind = smoothstep(uFront + 0.02, uFront - 0.10, dSeed); + vec3 base = vec3(0.0018, 0.0035, 0.006) * behind; + vec3 holo = base + vec3(0.0, 0.35, 0.42) * grid * 0.10 * uHolo * behind; + // growth front: a thin bright ring with a soft wake + float ring = exp(-pow((dSeed - uFront) * 55.0, 2.0)) * step(0.0, uFront); + float wake = exp(-max(uFront - dSeed, 0.0) * 6.0) * behind; + holo += vec3(0.1, 0.85, 1.0) * (ring * 0.55 + wake * 0.035) * uHolo; + + // real night lights, colonised by the front: the "neurons" + vec2 gx, gy; + seamGrad(uv, gx, gy); + float nl = textureGrad(uNight, uv, gx, gy).r; + float nlc = pow(max(nl - 0.06, 0.0) * 1.07, 1.7); + float fresh = exp(-max(uFront - dSeed, 0.0) * 3.0); + holo += mix(vec3(0.55, 0.95, 1.0), vec3(1.0), fresh) * nlc * (1.2 + fresh * 2.5) * behind * uLights; + + // ---------- quadtree indexing + vec2 s3 = tileState(3.0, uWave.x, uv, my, 0.35); + vec2 s4 = tileState(4.0, uWave.y, uv, my, 0.30); + vec2 s5 = tileState(5.0, uWave.z, uv, my, 0.25); + float reveal = max(smoothstep(0.05, 0.42, s3.x), uImagery); + // resolution climbs as each finer wave passes + float bias = mix(4.5, 0.0, max(smoothstep(0.0, 0.5, s5.x), uImagery)); + bias = min(bias, mix(4.5, 2.2, smoothstep(0.0, 0.5, s4.x))); + float bs = exp2(bias); + vec2 bgx = gx * bs, bgy = gy * bs; + vec3 day = textureGrad(uDay, uv, bgx, bgy).rgb; + + // analysis edges (luminance gradient) — only while a tile is being indexed + float ph0 = s3.x; + float edgeAmt0 = step(0.0, ph0) * exp(-max(ph0 - 0.1, 0.0) * 2.2) * (1.0 - uImagery); + float edge = 0.0; + if (edgeAmt0 > 0.002) { + float l0 = dot(textureGrad(uDay, uv + vec2(uTexel.x, 0.0), bgx, bgy).rgb, vec3(0.3, 0.59, 0.11)); + float l1 = dot(textureGrad(uDay, uv - vec2(uTexel.x, 0.0), bgx, bgy).rgb, vec3(0.3, 0.59, 0.11)); + float l2 = dot(textureGrad(uDay, uv + vec2(0.0, uTexel.y), bgx, bgy).rgb, vec3(0.3, 0.59, 0.11)); + float l3 = dot(textureGrad(uDay, uv - vec2(0.0, uTexel.y), bgx, bgy).rgb, vec3(0.3, 0.59, 0.11)); + edge = clamp(length(vec2(l0 - l1, l2 - l3)) * 9.0, 0.0, 1.0); + } + + // ---------- lighting + float sd = dot(n, uSun); + float dayF = smoothstep(-0.10, 0.20, sd); + vec3 lit = day * (0.035 + 1.25 * pow(max(sd, 0.0), 0.75)) * dayF + day * 0.012; + float ocean = smoothstep(0.015, 0.06, day.b - day.r) * (1.0 - smoothstep(0.08, 0.25, dot(day, vec3(0.33)))); + vec3 Hh = normalize(normalize(mat3(1.0) * uSun) + V); + float graze = smoothstep(0.15, 0.5, dot(N, V)); + float spec = (pow(max(dot(N, Hh), 0.0), 240.0) * 0.4 + pow(max(dot(N, Hh), 0.0), 30.0) * 0.03) * ocean * dayF * graze; + vec3 cityGlow = vec3(1.0, 0.72, 0.42) * pow(nl, 1.9) * 1.6 * (1.0 - dayF); + float fres = pow(1.0 - max(dot(N, V), 0.0), 3.0); + vec3 haze = vec3(0.25, 0.5, 1.0) * fres * (0.15 + 0.85 * dayF) * 0.7; + vec3 earthLit = lit + vec3(1.0, 0.92, 0.8) * spec + cityGlow + haze; + vec3 earthFlat = day * 0.6; + vec3 earth = mix(earthFlat, earthLit, uLit); + + // reveal treatment: flash, edges, then settle + float ph = s3.x; + float flash = step(0.0, ph) * exp(-max(ph, 0.0) * 9.0); + float edgeAmt = step(0.0, ph) * exp(-max(ph - 0.1, 0.0) * 2.2) * (1.0 - uImagery); + vec3 surf = mix(holo, earth, reveal); + surf += vec3(0.0, 0.8, 1.0) * edge * edgeAmt * 0.9; + surf += vec3(0.6, 0.95, 1.0) * flash * 0.10 * (1.0 - uImagery); + surf = mix(surf, vec3(dot(surf, vec3(0.3, 0.59, 0.11))) * vec3(0.4, 1.0, 1.15), uTint); + + // tile edges: faint before, bright at their own wave, then gone + float g3 = s3.y * (step(ph, 0.0) * 0.18 * uHolo * behind + step(0.0, ph) * exp(-max(ph, 0.0) * 1.6) * 1.3); + float g4 = s4.y * step(0.0, s4.x) * exp(-max(s4.x, 0.0) * 2.0) * 0.9; + float g5 = s5.y * step(0.0, s5.x) * exp(-max(s5.x, 0.0) * 2.6) * 0.7; + surf += vec3(0.25, 0.9, 1.0) * max(g3, max(g4, g5)) * uGridAmt; + + gl_FragColor = vec4(surf * uDim, 1.0); + } + `, + }); + + const geo = new THREE.SphereGeometry(1, 384, 192); + const mesh = new THREE.Mesh(geo, mat); + + // Clouds — real GIBS-derived opacity, lit by the same sun. + const cloudU = { + uClouds: { value: cloudTex }, + uSun: uniforms.uSun, + uAmt: { value: 0 }, + uTime: uniforms.uTime, + }; + const cloudMat = new THREE.ShaderMaterial({ + uniforms: cloudU, + transparent: true, + depthWrite: false, + vertexShader: /* glsl */ ` + varying vec3 vObj; + varying vec3 vN; + varying vec3 vView; + void main() { + vObj = position; + vec4 wp = modelMatrix * vec4(position, 1.0); + vN = normalize(mat3(modelMatrix) * position); + vView = normalize(cameraPosition - wp.xyz); + gl_Position = projectionMatrix * viewMatrix * wp; + } + `, + fragmentShader: /* glsl */ ` + #define PI 3.141592653589793 + uniform sampler2D uClouds; + uniform vec3 uSun; + uniform float uAmt, uTime; + varying vec3 vObj; + varying vec3 vN; + varying vec3 vView; + void main() { + vec3 n = normalize(vObj); + float lat = asin(clamp(n.y, -1.0, 1.0)); + float lon = atan(-n.z, n.x) - uTime * 0.0015; + vec2 uv = vec2(fract(lon / (2.0 * PI) + 0.5), lat / PI + 0.5); + vec2 ua = uv, ub = vec2(fract(uv.x + 0.5), uv.y); + vec2 ax = dFdx(ua), ay = dFdy(ua), bx = dFdx(ub), by = dFdy(ub); + vec2 gx = vec2(abs(ax.x) < abs(bx.x) ? ax.x : bx.x, ax.y); + vec2 gy = vec2(abs(ay.x) < abs(by.x) ? ay.x : by.x, ay.y); + float c = textureGrad(uClouds, uv, gx, gy).r; + c = smoothstep(0.08, 0.85, c); + float sd = dot(n, uSun); + float dayF = smoothstep(-0.12, 0.25, sd); + vec3 col = vec3(1.0, 0.98, 0.95) * (0.02 + 1.1 * dayF * (0.35 + 0.65 * max(sd, 0.0))); + float edge = pow(1.0 - max(dot(normalize(vN), normalize(vView)), 0.0), 2.0); + gl_FragColor = vec4(col, c * uAmt * (0.92 - edge * 0.4)); + } + `, + }); + const clouds = new THREE.Mesh(new THREE.SphereGeometry(1.0065, 256, 128), cloudMat); + + // Atmosphere shell — limb glow, warm-to-blue at the terminator. + const atmoU = { uSun: uniforms.uSun, uAmt: { value: 0 }, uCyan: { value: 0 } }; + const atmoMat = new THREE.ShaderMaterial({ + uniforms: atmoU, + side: THREE.BackSide, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + vertexShader: /* glsl */ ` + varying vec3 vN; + varying vec3 vWP; + void main() { + vec4 wp = modelMatrix * vec4(position, 1.0); + vWP = wp.xyz; + vN = normalize(mat3(modelMatrix) * position); + gl_Position = projectionMatrix * viewMatrix * wp; + } + `, + fragmentShader: /* glsl */ ` + uniform vec3 uSun; + uniform float uAmt, uCyan; + varying vec3 vN; + varying vec3 vWP; + void main() { + vec3 V = normalize(cameraPosition - vWP); + vec3 N = normalize(vN); + // distance of this view ray from the planet centre, in radii + vec3 ro = cameraPosition; + vec3 rd = -V; + float b = dot(ro, rd); + float h = length(ro - b * rd); + float shell = 1.065; + float t = clamp((shell - h) / (shell - 1.0), 0.0, 1.0); + float glow = pow(t, 2.6) * (1.0 - smoothstep(0.985, 1.0, t) * 0.0); + if (h < 1.0) glow *= 0.0; + // light side vs dark side of the limb + vec3 limbP = normalize(ro + rd * b); + float sd = dot(limbP, uSun); + float lit = smoothstep(-0.35, 0.4, sd); + vec3 col = mix(vec3(0.02, 0.08, 0.14), vec3(0.35, 0.62, 1.0), lit); + col += vec3(1.0, 0.45, 0.2) * exp(-pow(sd * 6.0, 2.0)) * 0.35; + col = mix(col, vec3(0.1, 0.9, 1.0) * 0.8, uCyan); + gl_FragColor = vec4(col * glow * uAmt * 1.05, 1.0); + } + `, + }); + const atmo = new THREE.Mesh(new THREE.SphereGeometry(1.065, 128, 64), atmoMat); + + const group = new THREE.Group(); + group.add(mesh, clouds, atmo); + return { group, mesh, uniforms, cloudU, atmoU }; +} diff --git a/intro-sequence/src/hud.js b/intro-sequence/src/hud.js new file mode 100644 index 000000000..cf01e161b --- /dev/null +++ b/intro-sequence/src/hud.js @@ -0,0 +1,1357 @@ +import { T, FEEDS, CAPTURE_SLOW, clamp, lerp, ramp, smooth, win, easeInOut, easeOut, easeIn, expoOut, count, hash1, mulberry32 } from './timeline.js'; +import { angDist, fmtLat, fmtLon, llToVec, EARTH_KM, D2R } from './geo.js'; + +// Everything typographic and 2D: boot console, the dish, captures, the feed +// stack, the camera wall, the title. Drawn to a canvas that the post chain +// composites into the HDR frame so it blooms with the scene. + +const CY = '#00f6ff'; +const WH = '#eafcff'; +const DIM = 'rgba(150,215,230,0.62)'; +const FAINT = 'rgba(120,200,220,0.28)'; +const AMB = '#ffb547'; +const RED = '#ff3b3b'; +const GLYPHS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=/<>'; + +const EYE_PATH = + 'M567.5,623c-163.183,0-303.026-99.084-361.823-239.922C257.508,238.76,401.037,135,570,135c166.451,0,308.222,100.7,361.954,241.667C874.771,520.905,733.152,623,567.5,623Z'; +const EYE_INNER_RING = 'M567,156c124.816,0,226,101.184,226,226S691.816,608,567,608,341,506.816,341,382,442.184,156,567,156Z'; +const EYE_WINGS = + 'M816,256.213A272.836,272.836,0,0,1,846,381M793.574,488.677a249.545,249.545,0,0,0,3.239-208.3A303.384,303.384,0,0,0,816,256.213a274.468,274.468,0,0,1-1.516,252.475q-0.927,1.758-1.88,3.5-1.632-2.238-3.306-4.446Q801.826,497.884,793.574,488.677ZM345.5,490.85q-8.055,9.164-15.37,18.966a274.353,274.353,0,0,1-3.748-250.343q7.347,10.012,15.469,19.393A249.512,249.512,0,0,0,345.5,490.85Z'; +const EYE_CAGE = + 'M578.4,368.9h76.237a314.2,314.2,0,0,0-11.945-78.026H578.4V368.9ZM654.634,387.1H578.4V464.94h64.345A314.251,314.251,0,0,0,654.634,387.1Zm90.813,0H671.861a333.4,333.4,0,0,1-11.206,77.836h63.112A182.117,182.117,0,0,0,745.447,387.1Zm-32.528,95.444H655.363a288.421,288.421,0,0,1-32.3,67.191A177.214,177.214,0,0,0,712.919,482.548Zm-115.67,73.48a263.416,263.416,0,0,0,39.828-73.48H578.4v75.476A173.846,173.846,0,0,0,597.249,556.028ZM560.6,558.036V482.548H497.674a263.837,263.837,0,0,0,39.135,72.575A174.279,174.279,0,0,0,560.6,558.036ZM510.434,547.83a289.038,289.038,0,0,1-31.078-65.282H426.1A177.5,177.5,0,0,0,510.434,547.83Zm-95.208-82.89H474.1A333.353,333.353,0,0,1,462.878,387.1H393.555A182.127,182.127,0,0,0,415.226,464.94ZM393.555,368.9h69.323a333.4,333.4,0,0,1,11.268-78.026H415.332A182.13,182.13,0,0,0,393.555,368.9Zm32.676-95.633h53.185a289.064,289.064,0,0,1,31-65.087A177.487,177.487,0,0,0,426.231,273.263Zm134.372-75.3a174.068,174.068,0,0,0-23.8,2.914,263.8,263.8,0,0,0-39.06,72.384H560.6v-75.3Zm0,92.905H492.051A314.088,314.088,0,0,0,480.106,368.9h80.5V290.87Zm-80.5,96.234A314.1,314.1,0,0,0,492,464.94h68.6V387.1h-80.5ZM578.4,197.977v75.286h58.612a263.412,263.412,0,0,0-39.751-73.289A174.011,174.011,0,0,0,578.4,197.977Zm44.672,8.286a288.6,288.6,0,0,1,32.231,67h57.491A177.213,177.213,0,0,0,623.069,206.263Zm100.6,84.607H660.6A333.3,333.3,0,0,1,671.861,368.9h73.586A182.129,182.129,0,0,0,723.667,290.87Z'; +const EYE_C = { x: 568.5, y: 379, w: 727, h: 488, iris: 195 }; + +const fmt = (n) => Math.round(n).toLocaleString('en-US'); +const pad = (n, k) => String(n).padStart(k, '0'); + +export function createHUD(opts) { + const { W, H, PORTRAIT, heroes, cams, counts, rapidList, pick, capT, snapT, sunLL } = opts; + const u = Math.min(W, H) / 1080; + const M = Math.round((PORTRAIT ? 52 : 58) * u); + const canvas = document.createElement('canvas'); + // main.js hands this canvas to post; we draw into the one it created + let ctx = null; + + const mono = (s, w = 400) => `${w} ${Math.round(s * u)}px "JetBrains Mono"`; + const sans = (s, w = 300) => `${w} ${Math.round(s * u)}px Inter`; + + // ---------------------------------------------------------- eye layout + const eyeW = PORTRAIT ? W * 0.8 : W * 0.37; + const es = eyeW / EYE_C.w; + const eyeLayout = { cx: W / 2, cy: PORTRAIT ? H * 0.4 : H * 0.42, s: es, irisR: EYE_C.iris * es * 0.985 }; + const eyePaths = { outer: new Path2D(EYE_PATH), ring: new Path2D(EYE_INNER_RING), wings: new Path2D(EYE_WINGS), cage: new Path2D(EYE_CAGE) }; + + // ---------------------------------------------------------- precompute + // feature-extraction overlay for the full-frame capture (real Sobel on Esri pixels) + const fullHero = heroes.find((h) => h.slug === 'manhattan') || heroes[0]; + let edgeCanvas = null; + let edgeCount = 0; + if (fullHero) { + const im = fullHero.close; + const c = document.createElement('canvas'); + c.width = im.width; + c.height = im.height; + const x = c.getContext('2d'); + x.drawImage(im, 0, 0); + const src = x.getImageData(0, 0, c.width, c.height); + const out = x.createImageData(c.width, c.height); + const w = c.width; + const L = new Float32Array(w * c.height); + for (let i = 0; i < L.length; i++) L[i] = src.data[i * 4] * 0.3 + src.data[i * 4 + 1] * 0.59 + src.data[i * 4 + 2] * 0.11; + for (let y = 1; y < c.height - 1; y++) + for (let xx = 1; xx < w - 1; xx++) { + const i = y * w + xx; + const gx = -L[i - w - 1] - 2 * L[i - 1] - L[i + w - 1] + L[i - w + 1] + 2 * L[i + 1] + L[i + w + 1]; + const gy = -L[i - w - 1] - 2 * L[i - w] - L[i - w + 1] + L[i + w - 1] + 2 * L[i + w] + L[i + w + 1]; + const g = Math.min(255, Math.hypot(gx, gy) * 0.9); + if (g > 90) edgeCount++; + const a = g > 40 ? g : 0; + out.data[i * 4] = 40; + out.data[i * 4 + 1] = 240; + out.data[i * 4 + 2] = 255; + out.data[i * 4 + 3] = a; + } + x.putImageData(out, 0, 0); + edgeCanvas = c; + } + + // graded CCTV stills: slight desaturation, crushed blacks, scanlines baked in + const camTiles = cams.map((c) => { + const cw = 320; + const ch = 180; + const cv = document.createElement('canvas'); + cv.width = cw; + cv.height = ch; + const x = cv.getContext('2d'); + x.filter = 'saturate(0.55) contrast(1.15) brightness(0.86)'; + const im = c.img; + const s = Math.max(cw / im.width, ch / im.height); + x.drawImage(im, (cw - im.width * s) / 2, (ch - im.height * s) / 2, im.width * s, im.height * s); + x.filter = 'none'; + x.fillStyle = 'rgba(0,0,0,0.16)'; + for (let y = 0; y < ch; y += 3) x.fillRect(0, y, cw, 1); + return { ...c, tile: cv }; + }); + + // ---------------------------------------------------------- primitives + function text(s, x, y, { font = mono(14), color = WH, align = 'left', alpha = 1, spacing = 0, baseline = 'alphabetic' } = {}) { + if (alpha <= 0.002 || !s) return 0; + ctx.save(); + ctx.globalAlpha *= alpha; + ctx.font = font; + ctx.letterSpacing = `${spacing}px`; + ctx.fillStyle = color; + ctx.textAlign = align; + ctx.textBaseline = baseline; + ctx.fillText(s, x, y); + const w = ctx.measureText(s).width; + ctx.restore(); + return w; + } + function measure(s, font, spacing = 0) { + ctx.save(); + ctx.font = font; + ctx.letterSpacing = `${spacing}px`; + const w = ctx.measureText(s).width; + ctx.restore(); + return w; + } + // characters resolve out of noise, left to right + function scramble(s, t, start, dur = 0.6, seed = 1) { + if (t < start) return ''; + const k = (t - start) / dur; + if (k >= 1) return s; + const n = s.length; + const fr = Math.floor(t * 30); + let out = ''; + for (let i = 0; i < n; i++) { + const ch = s[i]; + const reveal = i / n; + if (k > reveal + 0.25 || ch === ' ') out += ch; + else if (k > reveal - 0.1) out += GLYPHS[Math.floor(hash1(i * 13.1 + fr * 7.7 + seed) * GLYPHS.length)]; + else out += ''; + } + return out; + } + function typed(s, t, start, cps = 40) { + if (t < start) return ''; + return s.slice(0, Math.floor((t - start) * cps)); + } + function line(x1, y1, x2, y2, color = CY, alpha = 1, w = 1) { + if (alpha <= 0.002) return; + ctx.save(); + ctx.globalAlpha *= alpha; + ctx.strokeStyle = color; + ctx.lineWidth = w * u; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + ctx.restore(); + } + function brackets(x, y, w, h, len, color = CY, alpha = 1, lw = 1.2) { + if (alpha <= 0.002) return; + ctx.save(); + ctx.globalAlpha *= alpha; + ctx.strokeStyle = color; + ctx.lineWidth = lw * u; + ctx.beginPath(); + const l = Math.min(len, w / 2, h / 2); + ctx.moveTo(x, y + l); ctx.lineTo(x, y); ctx.lineTo(x + l, y); + ctx.moveTo(x + w - l, y); ctx.lineTo(x + w, y); ctx.lineTo(x + w, y + l); + ctx.moveTo(x + w, y + h - l); ctx.lineTo(x + w, y + h); ctx.lineTo(x + w - l, y + h); + ctx.moveTo(x + l, y + h); ctx.lineTo(x, y + h); ctx.lineTo(x, y + h - l); + ctx.stroke(); + ctx.restore(); + } + function glowDot(x, y, r, color = '200,250,255', alpha = 1) { + ctx.save(); + ctx.globalAlpha *= alpha; + const g = ctx.createRadialGradient(x, y, 0, x, y, r); + g.addColorStop(0, `rgba(255,255,255,1)`); + g.addColorStop(0.15, `rgba(${color},0.9)`); + g.addColorStop(0.4, `rgba(${color},0.25)`); + g.addColorStop(1, `rgba(${color},0)`); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + // white-box "Samaritan" label: black text knocked out of a bright plate + function plate(s, x, y, { font = mono(14, 500), padX = 10, padY = 7, align = 'left', alpha = 1, spacing = 1, inverse = true } = {}) { + if (alpha <= 0.002) return; + const w = measure(s, font, spacing * u); + const fs = parseFloat(font.split(' ')[1]); + const bx = align === 'center' ? x - w / 2 - padX * u : align === 'right' ? x - w - padX * u * 2 : x; + const bw = w + padX * u * 2; + const bh = fs + padY * u * 2; + ctx.save(); + ctx.globalAlpha *= alpha; + ctx.fillStyle = inverse ? '#bfe9f0' : 'rgba(0,0,0,0.78)'; + ctx.fillRect(bx, y - bh / 2, bw, bh); + ctx.restore(); + text(s, bx + padX * u, y + fs * 0.36, { font, color: inverse ? '#000' : WH, alpha, spacing: spacing * u }); + return bw; + } + function drawCover(im, x, y, w, h, scale = 1, cx = 0.5, cy = 0.5) { + const s = Math.max(w / im.width, h / im.height) * scale; + const dw = im.width * s; + const dh = im.height * s; + ctx.drawImage(im, x + (w - dw) * cx, y + (h - dh) * cy, dw, dh); + } + + // ---------------------------------------------------------- tile ids (mirrors the globe shader) + function hash3(x, y, z) { + const f = (v) => v - Math.floor(v); + let px = f(x * 0.3183099 + 0.71) * 17; + let py = f(y * 0.3183099 + 0.113) * 17; + let pz = f(z * 0.3183099 + 0.419) * 17; + return f(px * py * pz * (px + py + pz)); + } + const tileList = []; + for (const [z, wave, jit] of [[3, T.zWave[0], 0.35], [4, T.zWave[1], 0.3]]) { + const n = 2 ** z; + for (let x = 0; x < n; x++) + for (let y = 0; y < n; y++) { + const lon = ((x + 0.5) / n) * 360 - 180; + const my = (y + 0.5) / n; + const lat = (Math.atan(Math.sinh(Math.PI * (1 - 2 * my))) * 180) / Math.PI; + const d = angDist(0, 0, lon, lat); + const tRev = wave + (d / Math.PI) * 2.6 + hash3(x, y, z) * jit; + tileList.push({ z, x, y, lon, lat, tRev }); + } + } + + // ---------------------------------------------------------- layout + const sheet = (() => { + const n = Math.max(1, heroes.length); + const cols = PORTRAIT ? 6 : 4; + const rows = Math.ceil(n / cols); + const gap = 6 * u; + const sw = PORTRAIT ? W - 2 * M : W * 0.215; + const tw = (sw - gap * (cols - 1)) / cols; + const th = tw * (2 / 3); + const sh = rows * th + (rows - 1) * gap; + const x0 = PORTRAIT ? M : W - M - sw; + const y0 = PORTRAIT ? H - M - 70 * u - sh : H * 0.2; + const slots = []; + for (let i = 0; i < n; i++) slots.push({ x: x0 + (i % cols) * (tw + gap), y: y0 + Math.floor(i / cols) * (th + gap), w: tw, h: th }); + return { x0, y0, sw, sh, slots, tw, th }; + })(); + const bigCard = PORTRAIT + ? { x: M, y: H * 0.17, w: W - 2 * M, h: (W - 2 * M) * (2 / 3) } + : { x: M, y: H * 0.4, w: W * 0.255, h: W * 0.255 * (2 / 3) }; + + const stack = PORTRAIT + ? { x: M, y: H * 0.665, w: W - 2 * M, row: 38 * u } + : { x: M, y: H * 0.27, w: 500 * u, row: 40 * u }; + + // camera wall grid + const wall = (() => { + const cols = PORTRAIT ? 5 : 10; + const rows = PORTRAIT ? 16 : 10; + const cw = W / cols; + const ch = H / rows; + const cells = []; + const r = mulberry32(1234); + for (let j = 0; j < rows; j++) + for (let i = 0; i < cols; i++) { + const cx = (i + 0.5) * cw; + const cy = (j + 0.5) * ch; + const d = Math.hypot((cx - W / 2) / W, (cy - H / 2) / H); + cells.push({ x: i * cw, y: j * ch, w: cw, h: ch, d, cam: 0, r: r(), r2: r() }); + } + // assign cameras: unique as far as they go, nearest-to-centre first + const order = cells.slice().sort((a, b) => a.d - b.d); + order.forEach((c, k) => (c.cam = cams.length ? k % cams.length : 0)); + const maxD = Math.max(...cells.map((c) => c.d)); + return { cells, maxD, cw, ch }; + })(); + + // ---------------------------------------------------------- scene pieces + function chromeAlpha(t) { + return smooth(t, T.substrate, T.substrate + 1.2) * (1 - smooth(t, T.cut - 0.1, T.cut)); + } + + function stateName(t) { + if (t < T.indexStart) return 'SUBSTRATE LOCK'; + if (t < T.indexed) return 'INDEXING'; + if (t < T.saturate) return 'INGESTING'; + if (t < T.omni) return 'SATURATING'; + return 'OMNIPRESENT'; + } + + function drawChrome(S) { + const t = S.t; + const a = chromeAlpha(t) * (1 - 0.85 * win(t, T.cctvWall, T.cctvOut + 0.2, 0.15, 0.2)); + if (a <= 0) return; + const L = 26 * u; + brackets(M * 0.55, M * 0.55, W - M * 1.1, H - M * 1.1, L, CY, a * 0.7, 1.4); + // edge rulers + ctx.save(); + ctx.globalAlpha = a * 0.35; + ctx.strokeStyle = CY; + ctx.lineWidth = 1; + const n = PORTRAIT ? 40 : 24; + for (let i = 1; i < n; i++) { + const y = M * 1.4 + ((H - M * 2.8) * i) / n; + const len = i % 4 === 0 ? 10 * u : 5 * u; + ctx.beginPath(); + ctx.moveTo(M * 0.55, y); + ctx.lineTo(M * 0.55 + len, y); + ctx.moveTo(W - M * 0.55, y); + ctx.lineTo(W - M * 0.55 - len, y); + ctx.stroke(); + } + ctx.restore(); + + const top = M + 18 * u; + text('GEV // OBSERVER-1', M + 6 * u, top, { font: mono(13, 500), color: CY, alpha: a, spacing: 2 * u }); + const st = stateName(t); + text('STATE ▸ ' + scramble(st, t, stateChangeTime(t), 0.5, 3), M + 6 * u, top + 22 * u, { font: mono(13), color: WH, alpha: a, spacing: 1.5 * u }); + + const now = new Date(snapT.getTime() + t * 1000); + const iso = now.toISOString().replace('T', ' ').replace('Z', ' UTC'); + text(iso, W - M - 6 * u, top, { font: mono(13), color: WH, alpha: a, align: 'right', spacing: 1 * u }); + const tt = `T+00:00:${pad(Math.floor(t), 2)}.${pad(Math.floor((t % 1) * 100), 2)}`; + text(tt, W - M - 6 * u, top + 22 * u, { font: mono(13), color: DIM, alpha: a, align: 'right', spacing: 1 * u }); + + const bot = H - M - 8 * u; + const alt = (S.cam.dist - 1) * EARTH_KM; + text(`CAM ${fmtLat(S.cam.lat, 4)} ${fmtLon(((S.cam.lon + 540) % 360) - 180, 4)} · ALT ${fmt(alt)} KM`, M + 6 * u, bot, { font: mono(12), color: DIM, alpha: a, spacing: 1 * u }); + text(`SUN ${fmtLat(sunLL[1], 1)} ${fmtLon(sunLL[0], 1)} · SUBSOLAR`, M + 6 * u, bot - 20 * u, { font: mono(12), color: FAINT, alpha: a, spacing: 1 * u }); + + const feedsOn = FEEDS.filter((f) => t >= f.t).length + (t >= T.rapid ? rapidList.length : 0); + const total = FEEDS.length + rapidList.length; + let sig = 0; + for (const f of FEEDS) if (t >= f.t) sig += count(t, f.t, f.t + 1.4, 0, counts[f.key] || 0); + if (t >= T.rapid) for (const [, v] of rapidList) sig += v; + text(`FEEDS ${pad(feedsOn, 2)}/${pad(total, 2)} · SIGNALS ${fmt(sig)}`, W - M - 6 * u, bot, { font: mono(12), color: DIM, alpha: a, align: 'right', spacing: 1 * u }); + } + function stateChangeTime(t) { + const ts = [T.substrate, T.indexStart, T.indexed, T.saturate, T.omni]; + let last = ts[0]; + for (const x of ts) if (t >= x) last = x; + return last; + } + + // ---------------------------------------------------------- ACT 0 + function drawBoot(S) { + const t = S.t; + if (t > T.seed + 0.1) return; + const lines = [ + ['GEV://OBSERVER-1', ' rev 0.2.1', T.boot], + ['allocating substrate ', '........ 1.0000 OK', T.boot + 0.75], + ['loading priors ', '.............. NONE', T.boot + 1.35], + ['> seed --origin ', '0.0000°N 0.0000°E', T.boot + 2.1], + ]; + const font = mono(17); + const lh = 30 * u; + const bw = measure('allocating substrate ........ 1.0000 OK', font, 0.5 * u); + const x0 = W / 2 - bw / 2; + const y0 = H / 2 - lh * 1.5; + const k = ramp(t, T.collapse, T.seed); + ctx.save(); + // collapse: the whole console implodes into the seed + const sx = 1 - easeIn(k); + const sy = Math.max(0.002, 1 - easeOut(Math.min(1, k * 1.6))); + ctx.translate(W / 2, H / 2); + ctx.scale(Math.max(0.001, sx), sy); + ctx.translate(-W / 2, -H / 2); + const bright = 1 + k * 2; + lines.forEach(([a, b, st], i) => { + const full = a + b; + const cps = i === 3 ? 26 : 55; + const s = typed(full, t, st, cps); + const y = y0 + i * lh; + const ca = i === 0 ? CY : i === 3 ? WH : DIM; + text(s, x0, y, { font, color: ca, spacing: 0.5 * u, alpha: Math.min(1, bright) }); + // ok tokens light up + if (i === 1 && s.length === full.length) text('OK', x0 + measure(full.slice(0, -2), font, 0.5 * u), y, { font, color: CY, spacing: 0.5 * u }); + }); + // cursor + let cy = y0; + let cx = x0; + for (let i = lines.length - 1; i >= 0; i--) { + const [a, b, st] = lines[i]; + if (t >= st) { + const cps = i === 3 ? 26 : 55; + const s = typed(a + b, t, st, cps); + cy = y0 + i * lh; + cx = x0 + measure(s, font, 0.5 * u) + 3 * u; + break; + } + } + const blink = Math.floor(t * 2.2) % 2 === 0 || (t > T.boot && t < T.boot + 3.2); + if (t > T.cursor && blink && k < 0.05) { + ctx.fillStyle = CY; + ctx.globalAlpha = 0.9; + ctx.fillRect(cx, cy - 15 * u, 9 * u, 18 * u); + } + ctx.restore(); + // the line of light the console collapses into + if (k > 0 && k < 1) { + const w = bw * (1 - easeIn(k)) * 1.1; + ctx.save(); + ctx.globalAlpha = Math.sin(k * Math.PI) * 0.9; + const g = ctx.createLinearGradient(W / 2 - w / 2, 0, W / 2 + w / 2, 0); + g.addColorStop(0, 'rgba(0,246,255,0)'); + g.addColorStop(0.5, 'rgba(220,255,255,1)'); + g.addColorStop(1, 'rgba(0,246,255,0)'); + ctx.fillStyle = g; + ctx.fillRect(W / 2 - w / 2, H / 2 - 1.5 * u, w, 3 * u); + ctx.restore(); + } + } + + // ---------------------------------------------------------- ACT 1 + function drawSeed(S) { + const t = S.t; + if (t < T.seed - 0.1 || t > T.indexStart + 1.5) return; + const p = S.project(0, 0, 1.001); + const beat = (b) => (t >= b ? Math.exp(-(t - b) * 5) : 0); + const pulse = beat(T.beat1) + beat(T.beat2) * 1.2 + beat(T.seed) * 1.5; + const fade = 1 - smooth(t, T.growStart + 1.5, T.substrate + 0.5); + const r = (34 + pulse * 70 + Math.sin(t * 3) * 2) * u; + glowDot(p.x, p.y, r, '120,245,255', fade); + glowDot(p.x, p.y, (5 + pulse * 5) * u, '255,255,255', fade); + // heartbeat shock rings + for (const b of [T.beat1, T.beat2]) { + const k = (t - b) / 1.1; + if (k > 0 && k < 1) { + ctx.save(); + ctx.globalAlpha = (1 - k) * 0.6 * fade; + ctx.strokeStyle = CY; + ctx.lineWidth = 1.2 * u; + ctx.beginPath(); + ctx.arc(p.x, p.y, easeOut(k) * S.limbR * 0.9, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + } + const la = win(t, T.seed + 0.35, T.growStart + 1.6, 0.2, 0.6); + const lx = p.x + 26 * u; + line(p.x + 8 * u, p.y - 8 * u, lx, p.y - 26 * u, CY, la * 0.7); + line(lx, p.y - 26 * u, lx + 120 * u, p.y - 26 * u, CY, la * 0.7); + text(scramble('SEED 0x00000001', t, T.seed + 0.4, 0.5), lx + 4 * u, p.y - 33 * u, { font: mono(13, 500), color: WH, alpha: la, spacing: 1.5 * u }); + text(scramble('NULL ISLAND · 0.0000°N 0.0000°E', t, T.seed + 0.7, 0.6, 2), lx + 4 * u, p.y - 10 * u, { font: mono(11), color: DIM, alpha: la, spacing: 1 * u }); + } + + function dishRing(cx, cy, r, prog, alpha, rot, { ticks = true, labels = true, breakK = 0 } = {}) { + if (alpha <= 0.002) return; + ctx.save(); + ctx.translate(cx, cy); + ctx.globalAlpha = alpha; + ctx.strokeStyle = CY; + const start = -Math.PI / 2; + if (breakK <= 0) { + ctx.lineWidth = 1.5 * u; + ctx.beginPath(); + ctx.arc(0, 0, r, start, start + prog * Math.PI * 2); + ctx.stroke(); + ctx.globalAlpha = alpha * 0.35; + ctx.setLineDash([2 * u, 6 * u]); + ctx.beginPath(); + ctx.arc(0, 0, r + 14 * u, start + rot, start + rot + prog * Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + if (ticks) { + const N = 120; + for (let i = 0; i < N * prog; i++) { + const a = start + rot + (i / N) * Math.PI * 2; + const long = i % 10 === 0; + const l = (long ? 12 : 5) * u; + ctx.globalAlpha = alpha * (long ? 0.85 : 0.4); + ctx.beginPath(); + ctx.moveTo(Math.cos(a) * (r + 3 * u), Math.sin(a) * (r + 3 * u)); + ctx.lineTo(Math.cos(a) * (r + 3 * u + l), Math.sin(a) * (r + 3 * u + l)); + ctx.stroke(); + if (labels && long && i % 30 === 0) { + ctx.globalAlpha = alpha * 0.7; + ctx.font = mono(10); + ctx.fillStyle = CY; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const rr = r + 30 * u; + ctx.fillText(pad(i * 3, 3), Math.cos(a) * rr, Math.sin(a) * rr); + } + } + } + } else { + // shattering: arc segments fly outward and spin away + const N = 48; + for (let i = 0; i < N; i++) { + const a0 = start + (i / N) * Math.PI * 2; + const a1 = a0 + (Math.PI * 2) / N * 0.82; + const hr = hash1(i * 3.7); + const push = easeOut(breakK) * (60 + hr * 380) * u; + const spin = (hash1(i * 9.1) - 0.5) * breakK * 0.8; + ctx.globalAlpha = alpha * (1 - breakK) * (0.6 + hr * 0.4); + ctx.lineWidth = (1.5 + hr * 2.5) * u; + ctx.beginPath(); + const am = (a0 + a1) / 2; + const ox = Math.cos(am) * push; + const oy = Math.sin(am) * push; + ctx.arc(ox, oy, r, a0 + spin, a1 + spin); + ctx.stroke(); + } + } + ctx.restore(); + } + + function drawDish(S) { + const t = S.t; + if (t < T.dishDraw || t > T.indexStart + 4) return; + const prog = easeInOut(ramp(t, T.dishDraw, T.dishDone)); + const a = 1 - smooth(t, T.indexStart + 0.5, T.indexStart + 3.5); + const r = S.limbR + 10 * u; + dishRing(S.center.x, S.center.y, r, prog, a, t * 0.02); + // label at the top of the dish + const ta = win(t, T.dishDraw + 0.6, T.indexStart + 3, 0.4, 0.6); + text(scramble('SUBSTRATE', t, T.dishDraw + 0.6, 0.6), S.center.x, S.center.y - r - 46 * u, { font: mono(12, 500), color: CY, align: 'center', alpha: ta, spacing: 6 * u }); + } + + function drawGrowth(S) { + const t = S.t; + if (t < T.growStart || t > T.indexStart + 1.5) return; + const a = win(t, T.growStart + 0.1, T.substrate + 0.15, 0.4, 0.3); + const f = Math.max(0, Math.min(Math.PI, S.front)); + const frac = (1 - Math.cos(f)) / 2; + const nodes = Math.floor(1 + 4.8e6 * Math.pow(frac, 1.15)); + const dbl = 0.42 / (1 + (t - T.growStart) * 0.6); + const y = PORTRAIT ? S.center.y + S.limbR + 110 * u : H - M - 52 * u; + const parts = [ + ['NODES', fmt(nodes)], + ['FRONT', ((Math.min(f, Math.PI) * 180) / Math.PI).toFixed(1).padStart(5, '0') + '°'], + ['DOUBLING', dbl.toFixed(3) + ' S'], + ['MEDIUM', 'UNKNOWN'], + ]; + if (t > T.coastReached - 1.2) parts[3][1] = scramble('LAND / SEA', t, T.coastReached - 1.2, 0.5); + if (t > T.growEnd - 0.6) parts[3][1] = scramble('PLANETARY', t, T.growEnd - 0.6, 0.5); + const font = mono(13); + const gap = 46 * u; + const widths = parts.map(([k, v]) => measure(k + ' ' + v, font, 1.2 * u)); + const total = widths.reduce((s, w) => s + w, 0) + gap * (parts.length - 1); + if (PORTRAIT) { + // two rows in portrait + parts.forEach(([k, v], i) => { + const col = i % 2; + const row = Math.floor(i / 2); + const x = W / 2 + (col === 0 ? -20 * u : 20 * u); + const yy = y + row * 26 * u; + text(k, x - (col === 0 ? measure(v, font, 1.2 * u) + 14 * u : 0), yy, { font, color: DIM, align: col === 0 ? 'right' : 'left', alpha: a, spacing: 1.2 * u }); + text(v, col === 0 ? x : x + measure(k, font, 1.2 * u) + 14 * u, yy, { font, color: WH, align: col === 0 ? 'right' : 'left', alpha: a, spacing: 1.2 * u }); + }); + } else { + let x = W / 2 - total / 2; + parts.forEach(([k, v], i) => { + const kw = text(k + ' ', x, y, { font, color: DIM, alpha: a, spacing: 1.2 * u }); + text(v, x + kw, y, { font, color: WH, alpha: a, spacing: 1.2 * u }); + x += widths[i] + gap; + }); + } + + // the culture learning place names as the front passes them + const la = 1 - smooth(t, T.substrate, T.indexStart + 1); + let shown = 0; + for (let i = 0; i < pick.cities.length && shown < 26; i++) { + const c = pick.cities[i]; + const d = angDist(0, 0, c[0], c[1]); + const k = S.front - d; + if (k < 0 || k > 0.75) continue; + const p = S.project(c[0], c[1], 1.002); + if (!p.vis || p.facing < 0.25) continue; + if (i > 90) continue; + shown++; + const age = k / 0.75; + const alpha = la * Math.min(1, k * 14) * (1 - smooth(age, 0.55, 1)); + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = CY; + ctx.lineWidth = 1 * u; + ctx.beginPath(); + ctx.arc(p.x, p.y, (3 + 10 * Math.exp(-k * 18)) * u, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + const name = c[3].toUpperCase(); + text(scramble(name, S.t, S.t - k * 2.2, 0.35, i), p.x + 7 * u, p.y - 6 * u, { font: mono(11, 500), color: WH, alpha, spacing: 1.2 * u }); + text(`${fmt(c[2])}`, p.x + 7 * u, p.y + 8 * u, { font: mono(9), color: DIM, alpha: alpha * 0.8, spacing: 0.8 * u }); + } + + // SUBSTRATE IDENTIFIED + const sa = win(t, T.substrate, T.indexStart + 1.2, 0.05, 0.6); + if (sa > 0) { + const yy = PORTRAIT ? S.center.y + S.limbR + 130 * u : H - M - 62 * u; + text(scramble('SUBSTRATE IDENTIFIED', t, T.substrate, 0.45), W / 2, yy, { font: sans(30, 300), color: WH, align: 'center', alpha: sa, spacing: 12 * u }); + text(scramble('EARTH · 510,072,000 KM² · 1 OBSERVER', t, T.substrate + 0.25, 0.5, 5), W / 2, yy + 34 * u, { font: mono(13), color: CY, align: 'center', alpha: sa, spacing: 2.5 * u }); + } + } + + // ---------------------------------------------------------- ACT 2 + function drawIndexing(S) { + const t = S.t; + if (t < T.indexStart || t > T.indexed + 1.6) return; + const a = win(t, T.indexStart, T.capFull + 0.3, 0.3, 0.3); + const x = M + 6 * u; + const y = PORTRAIT ? H * 0.1 + 40 * u : H * 0.2; + if (a > 0) { + text(scramble('INDEXING SURFACE', t, T.indexStart, 0.6), x, y, { font: sans(30, 300), color: WH, alpha: a, spacing: 9 * u }); + text(scramble('ESRI WORLD IMAGERY · WEB MERCATOR QUADTREE', t, T.indexStart + 0.3, 0.7, 2), x, y + 28 * u, { font: mono(12), color: DIM, alpha: a, spacing: 1.5 * u }); + const zk = ramp(t, T.indexStart + 0.4, T.capFull); + const z = Math.round(lerp(3, 19, Math.pow(zk, 0.8))); + const tiles = (Math.pow(4, z + 1) - 1) / 3; + const gsd = 156543.03 / 2 ** z; + const rows = [ + ['LEVEL', `Z${pad(z, 2)}`], + ['TILES', fmt(tiles)], + ['GSD', gsd >= 1 ? `${fmt(gsd)} M/PX` : `${gsd.toFixed(2)} M/PX`], + ['SOLAR', t > 22.8 ? scramble('MODEL APPLIED', t, 22.8, 0.5) : 'PENDING'], + ]; + rows.forEach(([k, v], i) => { + const yy = y + (60 + i * 22) * u; + const ra = a * smooth(t, T.indexStart + 0.5 + i * 0.12, T.indexStart + 0.8 + i * 0.12); + text(k, x, yy, { font: mono(13), color: DIM, alpha: ra, spacing: 1.5 * u }); + text(v, x + 92 * u, yy, { font: mono(13, 500), color: i === 3 && t > 22.8 ? CY : WH, alpha: ra, spacing: 1.5 * u }); + }); + } + + // tile ids riding the quadtree front + const ta = 1 - smooth(t, 23.5, 25); + if (ta > 0) + for (const tl of tileList) { + const ph = t - tl.tRev; + if (ph < 0 || ph > 0.55) continue; + const p = S.project(tl.lon, tl.lat, 1.0); + if (!p.vis || p.facing < 0.3) continue; + const al = ta * (1 - ph / 0.55) * (tl.z === 3 ? 1 : 0.7); + text(`${tl.z}/${tl.x}/${tl.y}`, p.x, p.y, { font: mono(tl.z === 3 ? 11 : 9, 500), color: tl.z === 3 ? WH : CY, align: 'center', alpha: al, spacing: 1 * u, baseline: 'middle' }); + } + + drawCaptures(S); + } + + function capLabel(h, i) { + const gsd = (156543.03 * Math.cos(h.lat * D2R)) / 2 ** (h.close_zoom || 18); + return { + id: `CAPTURE ${pad(i + 1, 4)}`, + name: h.name.toUpperCase(), + coord: `${fmtLat(h.lat, 4)} ${fmtLon(h.lon, 4)}`, + gsd: `${gsd.toFixed(2)} M/PX`, + }; + } + + function drawCaptures(S) { + const t = S.t; + if (!heroes.length || t < T.capStart - 0.4) return; + const sheetA = smooth(t, T.capStart - 0.4, T.capStart + 0.2) * (1 - smooth(t, T.capFull + 2.6, T.capFull + 3.0)); + // empty slots + if (sheetA > 0) { + sheet.slots.forEach((s, i) => { + const filled = t >= capT[i] + (i < CAPTURE_SLOW ? 1.75 : 0); + ctx.save(); + ctx.globalAlpha = sheetA * (filled ? 0 : 0.45); + ctx.strokeStyle = FAINT; + ctx.lineWidth = 1; + ctx.strokeRect(s.x + 0.5, s.y + 0.5, s.w - 1, s.h - 1); + ctx.restore(); + }); + const done = capT.filter((c, i) => t >= c + (i < CAPTURE_SLOW ? 1.75 : 0.0)).length; + const ly = sheet.y0 - 14 * u; + text('SYNTHETIC CAPTURES', sheet.x0, ly, { font: mono(12, 500), color: CY, alpha: sheetA, spacing: 2 * u }); + text(`${pad(done, 2)}/${pad(heroes.length, 2)}`, sheet.x0 + sheet.sw, ly, { font: mono(12), color: WH, alpha: sheetA, align: 'right', spacing: 2 * u }); + } + + const fullIdx = Math.max(0, heroes.indexOf(fullHero)); + // filled thumbnails + heroes.forEach((h, i) => { + const s = sheet.slots[i]; + const t0 = capT[i]; + const slow = i < CAPTURE_SLOW; + const landT = slow ? t0 + 1.75 : t0; + if (t < landT) return; + if (i === fullIdx && t >= T.capFull && t < T.capFull + 3.0) return; + const k = t - landT; + ctx.save(); + ctx.globalAlpha = sheetA; + drawCover(h.close, s.x, s.y, s.w, s.h); + // fresh-capture flash + const fl = Math.exp(-k * 9); + if (fl > 0.01) { + ctx.fillStyle = `rgba(230,255,255,${fl * 0.85})`; + ctx.fillRect(s.x, s.y, s.w, s.h); + } + ctx.restore(); + brackets(s.x, s.y, s.w, s.h, 7 * u, CY, sheetA * (0.35 + fl), 1); + // rapid captures: a viewfinder ping on the globe + if (!slow && k < 0.45) { + const p = S.project(h.lon, h.lat, 1.0); + if (p.vis) { + const sz = (34 - 20 * easeOut(k / 0.45)) * u; + brackets(p.x - sz / 2, p.y - sz / 2, sz, sz, 6 * u, WH, (1 - k / 0.45) * sheetA, 1.2); + line(p.x, p.y, s.x + s.w / 2, s.y + s.h / 2, CY, (1 - k / 0.45) * 0.35 * sheetA, 0.8); + } + } + }); + + // slow captures: the enhance + for (let i = 0; i < Math.min(CAPTURE_SLOW, heroes.length); i++) { + const t0 = capT[i]; + const k = t - t0; + if (k < 0 || k > 1.75) continue; + const h = heroes[i]; + const lab = capLabel(h, i); + const p = S.project(h.lon, h.lat, 1.0); + // viewfinder lock on the globe + if (k < 0.5 && p.vis) { + const sz = (90 - 60 * easeOut(k / 0.5)) * u; + brackets(p.x - sz / 2, p.y - sz / 2, sz, sz, 10 * u, WH, 1 - k / 0.5, 1.4); + } + // card rect: appear → hold → fly to slot + const s = sheet.slots[i]; + const fly = easeInOut(ramp(k, 1.4, 1.75)); + const open = easeOut(ramp(k, 0.05, 0.3)); + const R = { + x: lerp(bigCard.x, s.x, fly), + y: lerp(bigCard.y, s.y, fly), + w: lerp(bigCard.w, s.w, fly), + h: lerp(bigCard.h * open, s.h, fly), + }; + R.y += (bigCard.h - R.h) * 0.5 * (1 - fly); + if (p.vis && fly < 0.05) line(p.x, p.y, R.x + R.w, R.y + R.h / 2, CY, 0.4 * open, 0.8); + ctx.save(); + ctx.beginPath(); + ctx.rect(R.x, R.y, R.w, R.h); + ctx.clip(); + ctx.fillStyle = '#000'; + ctx.fillRect(R.x, R.y, R.w, R.h); + // z13 → z18: 32x exponential zoom, handing off to the native close crop + const zk = easeInOut(ramp(k, 0.3, 1.3)); + const sc = Math.pow(32, zk); + ctx.globalAlpha = 1; + drawCover(h.wide, R.x, R.y, R.w, R.h, sc); + const cf = smooth(sc, 7, 22); + if (cf > 0) { + ctx.globalAlpha = cf; + drawCover(h.close, R.x, R.y, R.w, R.h, Math.max(1, sc / 32)); + } + ctx.globalAlpha = 1; + const fl = Math.exp(-Math.max(0, k - 0.05) * 10) * (k > 0.05 ? 1 : 0); + ctx.fillStyle = `rgba(230,255,255,${fl * 0.9})`; + ctx.fillRect(R.x, R.y, R.w, R.h); + ctx.restore(); + const ia = open * (1 - fly); + brackets(R.x, R.y, R.w, R.h, 18 * u, CY, open, 1.5); + // crosshair + const cx = R.x + R.w / 2; + const cy = R.y + R.h / 2; + line(cx - 16 * u, cy, cx - 5 * u, cy, WH, ia * 0.8); + line(cx + 5 * u, cy, cx + 16 * u, cy, WH, ia * 0.8); + line(cx, cy - 16 * u, cx, cy - 5 * u, WH, ia * 0.8); + line(cx, cy + 5 * u, cx, cy + 16 * u, WH, ia * 0.8); + plate(scramble(lab.id, t, t0 + 0.1, 0.3), R.x + 12 * u, R.y + 22 * u, { font: mono(12, 500), alpha: ia, spacing: 1.5 }); + const zl = `Z${Math.round(lerp(13, 18, zk))}`; + text(zl, R.x + R.w - 14 * u, R.y + 27 * u, { font: mono(13, 500), color: WH, align: 'right', alpha: ia, spacing: 1.5 * u }); + text(scramble(lab.name, t, t0 + 0.25, 0.4, i), R.x + 14 * u, R.y + R.h - 34 * u, { font: sans(20, 500), color: WH, alpha: ia, spacing: 4 * u }); + text(`${lab.coord} · ${lab.gsd}`, R.x + 14 * u, R.y + R.h - 14 * u, { font: mono(11), color: WH, alpha: ia * 0.85, spacing: 1 * u }); + } + + // full-frame: feature extraction + if (fullHero && t >= T.capFull && t < T.indexed + 0.1) { + const k = t - T.capFull; + const s = sheet.slots[fullIdx]; + const ex = easeInOut(ramp(k, 0, 0.55)); + const out = easeIn(ramp(t, T.indexed - 0.5, T.indexed)); + let R = { x: lerp(s.x, 0, ex), y: lerp(s.y, 0, ex), w: lerp(s.w, W, ex), h: lerp(s.h, H, ex) }; + if (out > 0) { + const cx = S.center.x; + const cy = S.center.y; + const f = 1 - out; + R = { x: lerp(cx, 0, f), y: lerp(cy, 0, f), w: W * f, h: H * f }; + } + ctx.save(); + ctx.beginPath(); + ctx.rect(R.x, R.y, R.w, R.h); + ctx.clip(); + ctx.globalAlpha = 1 - out * 0.6; + const kb = 1 + k * 0.025; + drawCover(fullHero.close, R.x, R.y, R.w, R.h, kb); + // scan: edges in cyan above the line, image dimmed + const scanY = R.y + R.h * easeInOut(ramp(k, 0.75, 2.35)); + ctx.fillStyle = 'rgba(0,8,14,0.55)'; + ctx.fillRect(R.x, R.y, R.w, scanY - R.y); + ctx.save(); + ctx.beginPath(); + ctx.rect(R.x, R.y, R.w, scanY - R.y); + ctx.clip(); + ctx.globalCompositeOperation = 'lighter'; + drawCover(edgeCanvas, R.x, R.y, R.w, R.h, kb); + ctx.restore(); + // scanline + if (k > 0.75 && k < 2.4) { + const g = ctx.createLinearGradient(0, scanY - 40 * u, 0, scanY + 2 * u); + g.addColorStop(0, 'rgba(0,246,255,0)'); + g.addColorStop(1, 'rgba(160,255,255,0.75)'); + ctx.fillStyle = g; + ctx.fillRect(R.x, scanY - 40 * u, R.w, 42 * u); + ctx.fillStyle = '#fff'; + ctx.fillRect(R.x, scanY - 1 * u, R.w, 2 * u); + } + // analysis grid + ctx.globalAlpha = 0.12 * ex * (1 - out); + ctx.strokeStyle = CY; + ctx.lineWidth = 1; + const gs = 90 * u; + for (let gx = R.x; gx < R.x + R.w; gx += gs) { + ctx.beginPath(); + ctx.moveTo(gx, R.y); + ctx.lineTo(gx, R.y + R.h); + ctx.stroke(); + } + for (let gy = R.y; gy < R.y + R.h; gy += gs) { + ctx.beginPath(); + ctx.moveTo(R.x, gy); + ctx.lineTo(R.x + R.w, gy); + ctx.stroke(); + } + ctx.restore(); + const ia = ex * (1 - out); + const lab = capLabel(fullHero, fullIdx); + const bx = M + 6 * u; + const by = PORTRAIT ? H * 0.12 : H * 0.16; + plate('FEATURE EXTRACTION', bx, by, { font: mono(14, 500), alpha: ia, spacing: 2.5 }); + text(`${lab.name} · Z18 · ${lab.gsd}`, bx, by + 40 * u, { font: mono(13), color: WH, alpha: ia, spacing: 1.5 * u }); + text(lab.coord, bx, by + 62 * u, { font: mono(13), color: DIM, alpha: ia, spacing: 1.5 * u }); + const ec = count(t, T.capFull + 0.75, T.capFull + 2.35, 0, edgeCount, (x) => x); + text('EDGE FEATURES', bx, by + 100 * u, { font: mono(12), color: DIM, alpha: ia, spacing: 2 * u }); + text(fmt(ec), bx, by + 140 * u, { font: sans(44, 200), color: WH, alpha: ia, spacing: 2 * u }); + brackets(M * 0.55 + 10 * u, M * 0.55 + 10 * u, W - M * 1.1 - 20 * u, H - M * 1.1 - 20 * u, 40 * u, WH, ia * 0.6, 1.5); + } + + // SURFACE INDEXED + const ia = win(t, T.indexed, T.indexed + 1.5, 0.05, 0.5); + if (ia > 0) { + const p = S.center; + text(scramble('SURFACE INDEXED', t, T.indexed, 0.4), W / 2, PORTRAIT ? H * 0.2 : H * 0.17, { font: sans(32, 300), color: WH, align: 'center', alpha: ia, spacing: 14 * u }); + text(`${fmt(366503875925)} TILES · Z0–Z19`, W / 2, (PORTRAIT ? H * 0.2 : H * 0.17) + 34 * u, { font: mono(13), color: CY, align: 'center', alpha: ia, spacing: 2.5 * u }); + } + } + + // ---------------------------------------------------------- ACT 3 + const callCache = {}; + function callout(S, key, lon, lat, r, title, sub, side = 1, alpha = 1, color = WH) { + const p = r > 1.3 ? S.projectVec(llToVec(lon, lat, r)) : S.project(lon, lat, r); + if (r <= 1.3 && (!p.vis || p.facing < 0.15)) return false; + if (p.x < M || p.x > W - M || p.y < M * 2 || p.y > H - M * 2) return false; + drawCallout(p.x, p.y, title, sub, side, alpha, color); + return true; + } + function drawCallout(x, y, title, sub, side, alpha, color = WH) { + const dx = 46 * u * side; + const dy = -38 * u; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = color; + ctx.lineWidth = 1 * u; + ctx.beginPath(); + ctx.arc(x, y, 5 * u, 0, Math.PI * 2); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x + 4 * u * side, y - 4 * u); + ctx.lineTo(x + dx, y + dy); + ctx.lineTo(x + dx + 70 * u * side, y + dy); + ctx.stroke(); + ctx.restore(); + const tx = x + dx + (side > 0 ? 4 * u : -4 * u); + text(title, tx, y + dy - 7 * u, { font: mono(13, 500), color, alpha, align: side > 0 ? 'left' : 'right', spacing: 1.2 * u }); + text(sub, tx, y + dy + 15 * u, { font: mono(11), color: DIM, alpha, align: side > 0 ? 'left' : 'right', spacing: 1 * u }); + } + + function drawFeeds(S) { + const t = S.t; + if (t < T.air - 0.6 || t > T.cut) return; + const wallDim = 1 - win(t, T.cctvWall - 0.1, T.cctvOut + 0.3, 0.2, 0.3); + const a = smooth(t, T.air - 0.6, T.air) * (1 - smooth(t, T.saturate - 0.1, T.saturate + 0.7)) * wallDim; + // a soft dark gutter keeps the stack legible over the planet + if (a > 0) { + ctx.save(); + ctx.globalAlpha = a * 0.75; + const g = PORTRAIT ? ctx.createLinearGradient(0, H, 0, H * 0.55) : ctx.createLinearGradient(0, 0, W * 0.42, 0); + g.addColorStop(0, 'rgba(0,3,6,0.92)'); + g.addColorStop(0.55, 'rgba(0,3,6,0.6)'); + g.addColorStop(1, 'rgba(0,3,6,0)'); + ctx.fillStyle = g; + if (PORTRAIT) ctx.fillRect(0, H * 0.55, W, H * 0.45); + else ctx.fillRect(0, 0, W * 0.42, H); + ctx.restore(); + } + + // header + stack + const x = stack.x; + let y = stack.y; + if (a > 0) { + text(scramble('INGESTION', t, T.air - 0.5, 0.5), x, y - 34 * u, { font: sans(26, 300), color: WH, alpha: a, spacing: 9 * u }); + text('LIVE PUBLIC FEEDS', x + stack.w, y - 36 * u, { font: mono(11), color: DIM, alpha: a, align: 'right', spacing: 2 * u }); + line(x, y - 22 * u, x + stack.w, y - 22 * u, CY, a * 0.5); + FEEDS.forEach((f, i) => { + if (t < f.t) return; + const k = t - f.t; + const ry = y + i * stack.row; + const ra = a * Math.min(1, k * 8); + const flash = Math.exp(-k * 5); + if (flash > 0.02) { + ctx.save(); + ctx.globalAlpha = ra * flash; + ctx.fillStyle = '#f2fdff'; + ctx.fillRect(x - 6 * u, ry - stack.row * 0.62, stack.w + 12 * u, stack.row * 0.86); + ctx.restore(); + } + const ink = flash > 0.45 ? '#001014' : null; + const n = count(t, f.t, f.t + 1.3, 0, counts[f.key] || 0); + text(pad(i + 1, 2), x, ry, { font: mono(12), color: ink || DIM, alpha: ra, spacing: 1 * u }); + text(scramble(f.label, t, f.t, 0.35, i), x + 34 * u, ry, { font: mono(14, 500), color: ink || WH, alpha: ra, spacing: 1.6 * u }); + text(f.code, x + stack.w * (PORTRAIT ? 0.45 : 0.47), ry, { font: mono(11), color: ink || CY, alpha: ra * 0.9, spacing: 1 * u }); + text(fmt(n), x + stack.w - 62 * u, ry, { font: mono(14, 500), color: ink || WH, alpha: ra, align: 'right', spacing: 1 * u }); + const blink = 0.55 + 0.45 * Math.sin(t * 6 + i); + ctx.save(); + ctx.globalAlpha = ra * blink; + ctx.fillStyle = ink || (f.key === 'quake' ? RED : f.key === 'fire' ? '#ff7a1f' : f.key === 'mil' ? AMB : CY); + ctx.beginPath(); + ctx.arc(x + stack.w - 46 * u, ry - 5 * u, 3.2 * u, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + text('LIVE', x + stack.w, ry, { font: mono(11, 500), color: ink || WH, alpha: ra, align: 'right', spacing: 1 * u }); + text(f.src, x + 34 * u, ry + 14 * u, { font: mono(9), color: FAINT, alpha: ra, spacing: 1 * u }); + }); + // rapid tail — the long list keeps scrolling in + if (t >= T.rapid) { + const by = y + FEEDS.length * stack.row + 6 * u; + rapidList.forEach(([name, v], i) => { + const st = T.rapid + i * 0.22; + if (t < st) return; + const ry = by + i * 20 * u; + const ra = a * Math.min(1, (t - st) * 10); + text(`+ ${name}`, x + 34 * u, ry, { font: mono(11), color: DIM, alpha: ra, spacing: 1 * u }); + text(fmt(count(t, st, st + 0.5, 0, v)), x + stack.w - 62 * u, ry, { font: mono(11), color: WH, alpha: ra, align: 'right', spacing: 1 * u }); + }); + } + } + + // caption for the feed that just arrived + const cur = FEEDS.filter((f) => t >= f.t).pop(); + if (cur && t < T.cctvWall) { + const idx = FEEDS.indexOf(cur); + const next = FEEDS[idx + 1]?.t ?? T.cctvWall; + const ca = win(t, cur.t, next, 0.08, 0.15) * a; + const n = count(t, cur.t, cur.t + 1.3, 0, counts[cur.key] || 0); + const unit = { air: 'AIRBORNE CONTACTS', mil: 'MILITARY TRANSPONDERS', sea: 'VESSELS UNDER WAY', orbit: 'OBJECTS IN ORBIT', quake: 'EVENTS · 30 DAYS', fire: 'THERMAL DETECTIONS · 24 H', wind: 'GRID CELLS · 10 M WIND', cable: 'CABLE SEGMENTS', cctv: 'PUBLIC CAMERAS' }[cur.key]; + if (PORTRAIT) { + const cy = M + 110 * u; + text(scramble(cur.label, t, cur.t, 0.35, 9), W / 2, cy, { font: sans(40, 200), color: WH, align: 'center', alpha: ca, spacing: 12 * u }); + text(`${fmt(n)} ${unit}`, W / 2, cy + 36 * u, { font: mono(14), color: CY, align: 'center', alpha: ca, spacing: 2 * u }); + text(cur.src, W / 2, cy + 58 * u, { font: mono(11), color: DIM, align: 'center', alpha: ca, spacing: 2 * u }); + } else { + const cx = W - M - 6 * u; + const cy = H - M - 112 * u; + text(scramble(cur.label, t, cur.t, 0.35, 9), cx, cy, { font: sans(46, 200), color: WH, align: 'right', alpha: ca, spacing: 14 * u }); + text(`${fmt(n)} ${unit} · ${cur.src}`, cx, cy + 34 * u, { font: mono(13), color: CY, align: 'right', alpha: ca, spacing: 2 * u }); + } + } + + // callouts on real records + const ca = (s, e) => win(t, s, e, 0.25, 0.3) * wallDim; + { + const al = ca(T.air + 0.6, T.orbit + 0.4); + if (al > 0) { + let n = 0; + for (const r of pick.flights) { + if (n >= 3) break; + const [lon, lat] = S.flightPos(r, 140); + const ok = callout(S, 'f' + r[5], lon, lat, 1.02, r[5], `FL${pad(Math.round(r[2] * 3.2808 / 100), 3)} · ${Math.round(r[4] * 1.944)} KT · HDG ${pad(Math.round(r[3]), 3)}`, n % 2 ? -1 : 1, al); + if (ok) n++; + } + } + } + { + const al = ca(T.mil + 0.4, T.orbit + 0.6); + if (al > 0) { + let n = 0; + for (const r of pick.mil) { + if (n >= 2) break; + const [lon, lat] = S.flightPos(r, 140); + if (callout(S, 'm' + r[5], lon, lat, 1.02, `${r[5]} · ${r[7]}`, `${fmt(r[2])} M · ${Math.round(r[4] * 1.944)} KT`, n % 2 ? 1 : -1, al, AMB)) n++; + } + } + } + { + const al = ca(T.sea + 0.4, T.sea + 2.6); + if (al > 0) { + let n = 0; + for (const r of pick.ships) { + if (n >= 3) break; + const [lon, lat] = S.shipPos(r); + if (callout(S, 's' + r[4], lon, lat, 1.002, r[5], `MMSI ${r[4]} · ${r[2].toFixed(1)} KN`, n % 2 ? -1 : 1, al, '#7dffd6')) n++; + } + } + } + { + const al = ca(T.orbit + 0.8, T.fire + 1); + if (al > 0) { + for (const [id, name] of [[25544, 'ISS (ZARYA)'], [48274, 'TIANHE (CSS)'], [20580, 'HUBBLE']]) { + const p = S.satPos(id); + if (!p) continue; + const sp = S.projectVec(p); + const r = Math.hypot(...p); + // hide when behind the planet + const cam = S.camPos; + if (sp.z > 1 || sp.x < M || sp.x > W - M || sp.y < M * 2 || sp.y > H - M * 2) continue; + if (occluded(p, cam)) continue; + drawCallout(sp.x, sp.y, name, `NORAD ${id} · ${fmt((r - 1) * EARTH_KM)} KM`, sp.x > S.center.x ? 1 : -1, al, '#d9d2ff'); + } + } + } + { + const al = ca(T.quake + 0.6, T.wind + 0.5); + if (al > 0) { + let n = 0; + for (const q of pick.quakeTop) { + if (n >= 2) break; + if (callout(S, 'q', q[0], q[1], 1.002, `M${q[3].toFixed(1)}`, q[5].toUpperCase().slice(0, 34), n % 2 ? -1 : 1, al, '#ff7a7a')) n++; + } + } + } + { + const al = ca(T.fire + 0.6, T.wind + 0.8); + if (al > 0) { + let n = 0; + for (const f of pick.fireTop) { + if (n >= 1) break; + if (callout(S, 'fi', f[0], f[1], 1.002, `FRP ${fmt(f[2])} MW`, `${fmtLat(f[1], 2)} ${fmtLon(f[0], 2)} · VIIRS`, -1, al, '#ffa260')) n++; + } + } + } + } + function occluded(p, cam) { + // ray from camera to point hits the unit sphere first? + const dx = p[0] - cam.x; + const dy = p[1] - cam.y; + const dz = p[2] - cam.z; + const L = Math.hypot(dx, dy, dz); + const rx = dx / L; + const ry = dy / L; + const rz = dz / L; + const b = cam.x * rx + cam.y * ry + cam.z * rz; + const c = cam.x ** 2 + cam.y ** 2 + cam.z ** 2 - 1; + const disc = b * b - c; + if (disc < 0) return false; + const tHit = -b - Math.sqrt(disc); + return tHit > 0 && tHit < L; + } + + // ---------------------------------------------------------- CCTV + function camCell(c, x, y, w, h, alpha, t, opts = {}) { + if (!c) return; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.clip(); + drawCover(c.tile, x, y, w, h); + // live noise band rolling through + const band = ((t * 0.35 + (opts.r || 0)) % 1) * h * 1.6 - h * 0.3; + ctx.fillStyle = 'rgba(255,255,255,0.05)'; + ctx.fillRect(x, y + band, w, h * 0.08); + ctx.restore(); + if (w > 90 * u) { + const fs = Math.max(7, Math.min(10, w / 26 / u)); + text((c.name || '').toUpperCase().slice(0, Math.floor(w / (fs * 0.62 * u)) - 2), x + 5 * u, y + 12 * u, { font: mono(fs, 500), color: WH, alpha: alpha * 0.9, spacing: 0.5 * u }); + const now = new Date(snapT.getTime() + t * 1000); + text(now.toISOString().slice(11, 19), x + 5 * u, y + h - 6 * u, { font: mono(fs * 0.9), color: WH, alpha: alpha * 0.75, spacing: 0.5 * u }); + if (Math.floor(t * 2 + (opts.r || 0) * 2) % 2 === 0) { + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = RED; + ctx.beginPath(); + ctx.arc(x + w - 24 * u, y + 9 * u, 2.5 * u, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + text('REC', x + w - 5 * u, y + 12 * u, { font: mono(fs * 0.9, 500), color: WH, alpha: alpha * 0.8, align: 'right' }); + text((c.city || c.country || '').toUpperCase(), x + w - 5 * u, y + h - 6 * u, { font: mono(fs * 0.9), color: CY, alpha: alpha * 0.85, align: 'right', spacing: 0.5 * u }); + } + } + + function tvOn(k) { + // CRT power-on: a bright line opens vertically + return { open: easeOut(clamp(k / 0.14)), glow: Math.exp(-k * 10) }; + } + + function drawCCTV(S) { + const t = S.t; + if (!camTiles.length || t < T.cctv || t > T.cctvOut + 0.8) return; + // tethered feeds pinned to the globe + if (t < T.cctvWall + 0.3) { + const a = 1 - smooth(t, T.cctvWall - 0.1, T.cctvWall + 0.25); + const vis = []; + for (const c of camTiles) { + const p = S.project(c.lon, c.lat, 1.001); + if (p.vis && p.facing > 0.3) vis.push({ c, p }); + if (vis.length >= (PORTRAIT ? 6 : 9)) break; + } + // pins for every camera we hold + for (const c of camTiles) { + const p = S.project(c.lon, c.lat, 1.001); + if (!p.vis) continue; + const k = t - T.cctv - hash1(c.lat * 7.1) * 0.6; + if (k < 0) continue; + glowDot(p.x, p.y, 7 * u, '255,255,255', a * Math.min(1, k * 6)); + } + const tw = (PORTRAIT ? 170 : 176) * u; + const th = tw * 9 / 16; + vis.forEach(({ c, p }, i) => { + const st = T.cctv + 0.25 + i * 0.12; + const k = t - st; + if (k < 0) return; + const ang = -Math.PI / 2 + (i - (vis.length - 1) / 2) * 0.45; + const dist = (150 + (i % 3) * 40) * u; + let bx = p.x + Math.cos(ang) * dist - tw / 2; + let by = p.y + Math.sin(ang) * dist - th / 2 - 30 * u; + bx = clamp(bx, M, W - M - tw); + by = clamp(by, M * 2, H - M * 2 - th); + const { open, glow } = tvOn(k); + line(p.x, p.y, bx + tw / 2, by + th, WH, a * 0.5 * Math.min(1, k * 8), 0.8); + const hh = th * open; + camCell(c, bx, by + (th - hh) / 2, tw, Math.max(1, hh), a, t, { r: i * 0.17 }); + if (glow > 0.02) { + ctx.save(); + ctx.globalAlpha = a * glow; + ctx.fillStyle = '#e8ffff'; + ctx.fillRect(bx, by + (th - hh) / 2, tw, Math.max(2 * u, hh)); + ctx.restore(); + } + brackets(bx - 3 * u, by - 3 * u, tw + 6 * u, th + 6 * u, 8 * u, CY, a * 0.7, 1); + }); + } + + // THE WALL + if (t >= T.cctvWall - 0.05) { + const outK = ramp(t, T.cctvOut, T.cctvOut + 0.55); + const swapR = mulberry32(Math.floor(t * 6) + 99); + for (const cell of wall.cells) { + const st = T.cctvWall + (cell.d / wall.maxD) * 0.85 + cell.r * 0.12; + const k = t - st; + if (k < 0) continue; + // collapse toward the centre, nearest first + const ck = clamp((outK * 1.6 - (1 - cell.d / wall.maxD) * 0.6)); + const sc = 1 - easeIn(ck); + if (sc <= 0.01) continue; + const cxp = lerp(W / 2, cell.x + cell.w / 2, sc); + const cyp = lerp(H / 2, cell.y + cell.h / 2, sc); + const g = 1.5 * u; + const w = (cell.w - g * 2) * sc; + let h = (cell.h - g * 2) * sc; + const { open, glow } = tvOn(k); + h *= open; + // feeds occasionally cut to another camera + let camIdx = cell.cam; + const cut = Math.floor((t - T.cctvWall) * 1.6 + cell.r2 * 7); + if (cell.r2 > 0.55) camIdx = (cell.cam + cut * 17) % camTiles.length; + const x = cxp - w / 2; + const y = cyp - h / 2; + camCell(camTiles[camIdx], x, y, w, Math.max(1, h), sc, t, { r: cell.r }); + if (glow > 0.02) { + ctx.save(); + ctx.globalAlpha = glow; + ctx.fillStyle = '#e8ffff'; + ctx.fillRect(x, y, w, Math.max(2 * u, h)); + ctx.restore(); + } + } + // the watcher: a selection reticle hopping cell to cell + if (t > T.cctvWall + 0.8 && outK === 0) { + const hop = Math.floor((t - T.cctvWall) * 7); + const cell = wall.cells[Math.floor(hash1(hop * 3.3) * wall.cells.length)]; + const c = camTiles[cell.cam]; + brackets(cell.x + 3 * u, cell.y + 3 * u, cell.w - 6 * u, cell.h - 6 * u, 12 * u, CY, 1, 2); + const tag = `ID-${pad(cell.cam + 1, 4)} · ${(c.city || '').toUpperCase()}`; + plate(tag, cell.x + 6 * u, cell.y + cell.h + 12 * u, { font: mono(10, 500), alpha: 1, spacing: 1, padX: 6, padY: 4 }); + } + // centre plate over the wall + if (outK === 0 && t > T.cctvWall + 0.5) { + const pa = smooth(t, T.cctvWall + 0.5, T.cctvWall + 0.7); + const cy = H / 2; + const shown = count(t, T.cctvWall + 0.5, T.cctvOut - 0.6, cams.length, counts.cctv, expoOut); + const bw = (PORTRAIT ? 620 : 700) * u; + const bh = 150 * u; + ctx.save(); + ctx.globalAlpha = pa * 0.88; + ctx.fillStyle = '#000'; + ctx.fillRect(W / 2 - bw / 2, cy - bh / 2, bw, bh); + ctx.restore(); + brackets(W / 2 - bw / 2, cy - bh / 2, bw, bh, 14 * u, CY, pa, 1.5); + text('OPTICAL NETWORK', W / 2, cy - 30 * u, { font: mono(13, 500), color: CY, align: 'center', alpha: pa, spacing: 6 * u }); + text(fmt(shown), W / 2, cy + 26 * u, { font: sans(56, 200), color: WH, align: 'center', alpha: pa, spacing: 4 * u }); + text(`PUBLIC CAMERAS · ${counts.cctvSources} AGENCIES · ${counts.cctvCountries} COUNTRIES`, W / 2, cy + 56 * u, { font: mono(11), color: DIM, align: 'center', alpha: pa, spacing: 2 * u }); + } + } + } + + // ---------------------------------------------------------- ACT 4 + function drawSaturation(S) { + const t = S.t; + if (t < T.saturate || t > T.cut) return; + const a = smooth(t, T.saturate + 0.3, T.saturate + 1.2) * (1 - smooth(t, T.cut - 0.15, T.cut)); + // observation counter, top centre + let total = 0; + for (const f of FEEDS) total += counts[f.key] || 0; + for (const [, v] of rapidList) total += v; + const live = total + Math.floor((t - T.saturate) * 48211); + // portrait stacks it above the ring; landscape hangs it in the left gutter + const cx0 = PORTRAIT ? W / 2 : M + 6 * u; + const al0 = PORTRAIT ? 'center' : 'left'; + const ty = PORTRAIT ? H * 0.13 : H * 0.26; + text('OBSERVATIONS IN VIEW', cx0, ty - 50 * u, { font: mono(12, 500), color: CY, align: al0, alpha: a, spacing: 5 * u }); + text(fmt(live), cx0, ty + 12 * u, { font: sans(PORTRAIT ? 62 : 58, 200), color: WH, align: al0, alpha: a, spacing: 3 * u }); + if (!PORTRAIT) line(cx0, ty + 34 * u, cx0 + 300 * u, ty + 34 * u, CY, a * 0.5); + + // status lines + const lines = [ + [53.4, 'FEEDS', `${pad(FEEDS.length + rapidList.length, 2)}/${pad(FEEDS.length + rapidList.length, 2)} LIVE`], + [54.5, 'COVERAGE', '510,072,000 KM²'], + [55.6, 'TWIN', 'SYNCHRONISED'], + [56.7, 'LATENCY', `${212 - Math.floor(ramp(t, 56.7, 58) * 170)} MS`], + [57.7, 'MODEL', 'EXCEEDS SUBSTRATE'], + ]; + const font = mono(13); + const lx = PORTRAIT ? W / 2 - 230 * u : W - M - 446 * u; + const ly = PORTRAIT ? H * 0.78 : H * 0.62; + lines.forEach(([st, k, v], i) => { + if (t < st) return; + const la = a * Math.min(1, (t - st) * 8) * (1 - smooth(t, T.omni - 0.2, T.omni + 0.1)); + const y = ly + i * 24 * u; + text(k, lx, y, { font, color: DIM, alpha: la, spacing: 2 * u }); + text('.'.repeat(14), lx + 110 * u, y, { font, color: FAINT, alpha: la, spacing: 2 * u }); + text(scramble(v, t, st, 0.4, i), lx + 440 * u, y, { font: mono(13, 500), color: i === 4 ? CY : WH, alpha: la, align: 'right', spacing: 2 * u }); + }); + + // the dish returns, too small now + const dr = S.ringR; + const dA = smooth(t, T.twin - 0.6, T.twin + 0.6); + const breakK = ramp(t, T.dishBreak, T.dishBreak + 1.1); + dishRing(S.center.x, S.center.y, dr, 1, dA * a * (breakK > 0 ? 1 : 0.9), -t * 0.05, { breakK, labels: true }); + if (t < T.dishBreak) { + text('SUBSTRATE BOUNDARY', S.center.x, S.center.y - dr - 46 * u, { font: mono(11, 500), color: CY, align: 'center', alpha: dA * a, spacing: 5 * u }); + // strain readout as the lattice presses on the ring + const strain = clamp(S.latticeR / dr) * 100; + text(`CONTAINMENT ${(100 - strain * 0.999).toFixed(1)}%`, S.center.x, S.center.y + dr + 52 * u, { font: mono(11), color: strain > 88 ? RED : DIM, align: 'center', alpha: dA * a, spacing: 3 * u }); + } + + // OMNIPRESENT + const oa = smooth(t, T.omni, T.omni + 0.12) * (1 - smooth(t, T.cut - 0.1, T.cut)); + if (oa > 0) { + const y = PORTRAIT ? H * 0.8 : H * 0.78; + text('OBSERVER STATE', W / 2, y - 46 * u, { font: mono(12, 500), color: CY, align: 'center', alpha: oa, spacing: 6 * u }); + plate(scramble('OMNIPRESENT', t, T.omni, 0.35), W / 2, y + 4 * u, { font: sans(PORTRAIT ? 34 : 38, 500), align: 'center', alpha: oa, spacing: 14, padX: 26, padY: 14 }); + } + } + + // ---------------------------------------------------------- ACT 5 + function drawTitle(S) { + const t = S.t; + if (t < T.title) return; + const { cx, cy, s } = eyeLayout; + const fadeEnd = 1 - smooth(t, T.fadeOut, 72); + ctx.save(); + ctx.translate(cx - EYE_C.x * s, cy - EYE_C.y * s); + ctx.scale(s, s); + // outer eye: drawn on, then filled + const dk = easeInOut(ramp(t, T.eyeOpen, T.eyeOpen + 1.2)); + if (dk > 0) { + const len = 2400; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.setLineDash([len * dk, len]); + ctx.strokeStyle = 'rgba(80,190,255,0.95)'; + ctx.lineWidth = 7; + ctx.globalAlpha = fadeEnd; + ctx.stroke(eyePaths.outer); + ctx.setLineDash([]); + ctx.globalAlpha = smooth(t, T.eyeOpen + 0.8, T.eyeOpen + 1.6) * 0.42 * fadeEnd; + ctx.fillStyle = 'rgba(68,84,114,0.6)'; + ctx.fill(eyePaths.outer, 'evenodd'); + ctx.globalAlpha = smooth(t, T.eyeOpen + 0.5, T.eyeOpen + 1.4) * fadeEnd; + ctx.fillStyle = 'rgba(70,170,230,0.55)'; + ctx.fill(eyePaths.wings); + // iris ring + ctx.globalAlpha = smooth(t, T.eyeOpen + 0.3, T.eyeOpen + 1.0) * fadeEnd; + ctx.strokeStyle = CY; + ctx.lineWidth = 3.5; + ctx.beginPath(); + ctx.arc(EYE_C.x - 0.5, EYE_C.y - 1, EYE_C.iris, 0, Math.PI * 2); + ctx.stroke(); + // cage over the planet + ctx.globalAlpha = smooth(t, T.eyeOpen + 0.9, T.eyeOpen + 1.8) * 0.09 * fadeEnd; + ctx.fillStyle = '#ffffff'; + ctx.fill(eyePaths.cage); + } + ctx.restore(); + + const eyeBottom = cy + (EYE_C.h / 2) * s; + const wy = eyeBottom + (PORTRAIT ? 120 : 92) * u; + const wa = smooth(t, T.wordmark, T.wordmark + 0.3) * fadeEnd; + const wsize = PORTRAIT ? 64 : 70; + text(scramble("GOD'S EYE VIEW", t, T.wordmark, 0.9, 4), W / 2 + 10 * u, wy, { font: sans(wsize, 200), color: WH, align: 'center', alpha: wa, spacing: (PORTRAIT ? 18 : 26) * u }); + const ta = smooth(t, T.tagline, T.tagline + 0.2) * fadeEnd; + text(typed('NO PLACE LEFT BEHIND.', t, T.tagline, 22), W / 2, wy + 52 * u, { font: mono(17), color: DIM, align: 'center', alpha: ta, spacing: 7 * u }); + const oa = smooth(t, T.online, T.online + 0.15) * fadeEnd; + if (oa > 0) { + const y = wy + (PORTRAIT ? 130 : 112) * u; + const blink = 0.5 + 0.5 * Math.sin((t - T.online) * 5); + const label = scramble('ONLINE', t, T.online, 0.4); + const w = measure('ONLINE', mono(15, 500), 6 * u); + ctx.save(); + ctx.globalAlpha = oa * (0.4 + blink * 0.6); + ctx.fillStyle = CY; + ctx.beginPath(); + ctx.arc(W / 2 - w / 2 - 18 * u, y - 5 * u, 4.5 * u, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + text(label, W / 2 + 4 * u, y, { font: mono(15, 500), color: CY, align: 'center', alpha: oa, spacing: 6 * u }); + text('PUBLIC SIGNALS · OPEN SOURCE · RUNS IN YOUR BROWSER', W / 2, y + 30 * u, { font: mono(10), color: FAINT, align: 'center', alpha: oa * smooth(t, T.online + 0.6, T.online + 1.2), spacing: 2.5 * u }); + } + // sources, as the feeds came in + const ca = smooth(t, T.online + 0.8, T.online + 1.6) * fadeEnd; + if (ca > 0) { + const credits = [ + 'IMAGERY ESRI WORLD IMAGERY (ESRI, MAXAR, EARTHSTAR GEOGRAPHICS) · NASA GIBS BLACK MARBLE · VIIRS', + 'FEEDS OPENSKY · ADSB.LOL · ADSB.FI · KYSTVERKET · DIGITRAFFIC · CELESTRAK · USGS · NASA FIRMS · NOAA GFS', + 'TELEGEOGRAPHY · NATURAL EARTH · OURAIRPORTS · RADIO-BROWSER · WRI · PUBLIC TRAFFIC CAMERAS OF 18 AGENCIES', + ]; + const fs = PORTRAIT ? 8.5 : 9; + const lh = 15 * u; + const y0 = H - M - 6 * u - (credits.length - 1) * lh; + credits.forEach((c, i) => text(c, W / 2, y0 + i * lh, { font: mono(fs), color: 'rgba(150,205,220,0.62)', align: 'center', alpha: ca, spacing: (PORTRAIT ? 0.6 : 1.4) * u })); + } + } + + // ---------------------------------------------------------- frame + function draw(S) { + ctx = S.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, W, H); + ctx.globalAlpha = 1; + drawBoot(S); + drawSeed(S); + drawDish(S); + drawGrowth(S); + drawIndexing(S); + drawFeeds(S); + drawCCTV(S); + drawSaturation(S); + drawChrome(S); + drawTitle(S); + } + + return { draw, eyeLayout, canvas }; +} diff --git a/intro-sequence/src/layers.js b/intro-sequence/src/layers.js new file mode 100644 index 000000000..b3b1d08f1 --- /dev/null +++ b/intro-sequence/src/layers.js @@ -0,0 +1,512 @@ +import * as THREE from 'three'; +import { llToVec, D2R, EARTH_KM } from './geo.js'; +import { mulberry32 } from './timeline.js'; + +// Point and particle layers for the live feeds. Every layer exposes +// `update(t, ctx)`; reveal timing and motion are pure functions of film time. + +const GC = /* glsl */ ` + vec3 llDir(float lon, float lat) { + float c = cos(lat); + return vec3(c * cos(lon), sin(lat), -c * sin(lon)); + } + // great-circle destination: start (lon,lat) rad, bearing rad, distance rad + vec2 gcDest(vec2 ll, float b, float d) { + float lat2 = asin(sin(ll.y) * cos(d) + cos(ll.y) * sin(d) * cos(b)); + float lon2 = ll.x + atan(sin(b) * sin(d) * cos(ll.y), cos(d) - sin(ll.y) * sin(lat2)); + return vec2(lon2, lat2); + } +`; + +// Moving contacts (aircraft, vessels): heads as points, trails as segments. +// rows: [lon, lat, alt_m, track_deg, vel_ms] +export function createMovers(rows, { color, trailColor, size = 2.2, altScale = 8, trail = 0.010, revealSpread = 1.4, seed = 1, gain = 1 }) { + const rnd = mulberry32(seed); + const n = rows.length; + const ll = new Float32Array(n * 2); + const hv = new Float32Array(n * 2); + const alt = new Float32Array(n); + const rr = new Float32Array(n); + rows.forEach((r, i) => { + ll[i * 2] = r[0] * D2R; + ll[i * 2 + 1] = r[1] * D2R; + hv[i * 2] = (r[3] || 0) * D2R; + hv[i * 2 + 1] = (r[4] || 0) / (EARTH_KM * 1000); // rad per second + alt[i] = 1 + ((r[2] || 0) / (EARTH_KM * 1000)) * altScale + 0.0015; + rr[i] = rnd(); + }); + + const uniforms = { + uSim: { value: 0 }, // simulated seconds since the feed went live + uReveal: { value: 0 }, // 0..1 + spread + uAlpha: { value: 0 }, + uGain: { value: gain }, + uPx: { value: 1 }, + uSize: { value: size }, + uTrail: { value: trail }, + uColor: { value: new THREE.Color(color) }, + uTrailColor: { value: new THREE.Color(trailColor || color) }, + uSpread: { value: revealSpread }, + }; + const vs = (isTrail) => /* glsl */ ` + ${GC} + attribute vec2 aLL; + attribute vec2 aHV; + attribute float aAlt; + attribute float aR; + ${isTrail ? 'attribute float aEnd;' : ''} + uniform float uSim, uReveal, uPx, uSize, uTrail, uSpread; + varying float vA; + varying float vFacing; + void main() { + float d = aHV.y * uSim; + ${isTrail ? 'd -= aEnd * max(uTrail, aHV.y * 40.0);' : ''} + vec2 p = gcDest(aLL, aHV.x, d); + vec3 pos = llDir(p.x, p.y) * aAlt; + vec4 wp = modelMatrix * vec4(pos, 1.0); + vFacing = dot(normalize(wp.xyz), normalize(cameraPosition - wp.xyz)); + float rv = clamp((uReveal - aR * uSpread) * 6.0, 0.0, 1.0); + vA = rv ${isTrail ? '* (1.0 - aEnd)' : ''}; + gl_Position = projectionMatrix * viewMatrix * wp; + ${isTrail ? '' : 'gl_PointSize = uSize * uPx * (1.0 + 2.5 * exp(-(uReveal - aR * uSpread) * 10.0) * step(0.0, uReveal - aR * uSpread));'} + } + `; + const fs = (isTrail) => /* glsl */ ` + uniform vec3 ${isTrail ? 'uTrailColor' : 'uColor'}; + uniform float uAlpha, uGain; + varying float vA; + varying float vFacing; + void main() { + float back = smoothstep(-0.02, 0.12, vFacing); + ${ + isTrail + ? 'gl_FragColor = vec4(uTrailColor * vA * uAlpha * uGain * back * 0.9, 1.0);' + : `vec2 c = gl_PointCoord - 0.5; + float r = length(c); + float disc = smoothstep(0.5, 0.15, r); + if (vA * back <= 0.001) discard; + gl_FragColor = vec4(uColor * disc * vA * uAlpha * uGain * back, 1.0);` + } + } + `; + const mk = (isTrail) => + new THREE.ShaderMaterial({ + uniforms, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + vertexShader: vs(isTrail), + fragmentShader: fs(isTrail), + }); + + const gp = new THREE.BufferGeometry(); + gp.setAttribute('position', new THREE.BufferAttribute(new Float32Array(n * 3), 3)); + gp.setAttribute('aLL', new THREE.BufferAttribute(ll, 2)); + gp.setAttribute('aHV', new THREE.BufferAttribute(hv, 2)); + gp.setAttribute('aAlt', new THREE.BufferAttribute(alt, 1)); + gp.setAttribute('aR', new THREE.BufferAttribute(rr, 1)); + gp.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 2); + const heads = new THREE.Points(gp, mk(false)); + + // trails: two vertices per contact, aEnd 0 (head) / 1 (tail) + const dup = (src, k) => { + const out = new Float32Array(src.length * 2); + for (let i = 0; i < src.length / k; i++) + for (let j = 0; j < k; j++) { + out[i * 2 * k + j] = src[i * k + j]; + out[i * 2 * k + k + j] = src[i * k + j]; + } + return out; + }; + const gt = new THREE.BufferGeometry(); + gt.setAttribute('position', new THREE.BufferAttribute(new Float32Array(n * 6), 3)); + gt.setAttribute('aLL', new THREE.BufferAttribute(dup(ll, 2), 2)); + gt.setAttribute('aHV', new THREE.BufferAttribute(dup(hv, 2), 2)); + gt.setAttribute('aAlt', new THREE.BufferAttribute(dup(alt, 1), 1)); + gt.setAttribute('aR', new THREE.BufferAttribute(dup(rr, 1), 1)); + const ends = new Float32Array(n * 2); + for (let i = 0; i < n; i++) ends[i * 2 + 1] = 1; + gt.setAttribute('aEnd', new THREE.BufferAttribute(ends, 1)); + gt.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 2); + const trails = new THREE.LineSegments(gt, mk(true)); + + const group = new THREE.Group(); + group.add(trails, heads); + return { group, uniforms, count: n }; +} + +// Static or pulsing point events (fires, quakes, cities, cameras). +// rows: [lon, lat, weight] +export function createEvents(rows, { color, size = 3, mode = 'flicker', r = 1.002, spread = 1.2, seed = 3, gain = 1, weightFn = (w) => w }) { + const rnd = mulberry32(seed); + const n = rows.length; + const pos = new Float32Array(n * 3); + const w = new Float32Array(n); + const rr = new Float32Array(n); + const v = [0, 0, 0]; + rows.forEach((row, i) => { + llToVec(row[0], row[1], r, v); + pos.set(v, i * 3); + w[i] = weightFn(row[2] ?? 1); + rr[i] = rnd(); + }); + const uniforms = { + uT: { value: 0 }, + uReveal: { value: 0 }, + uAlpha: { value: 0 }, + uGain: { value: gain }, + uPx: { value: 1 }, + uSize: { value: size }, + uColor: { value: new THREE.Color(color) }, + uSpread: { value: spread }, + }; + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + g.setAttribute('aW', new THREE.BufferAttribute(w, 1)); + g.setAttribute('aR', new THREE.BufferAttribute(rr, 1)); + const ringMode = mode === 'ring'; + const m = new THREE.ShaderMaterial({ + uniforms, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + vertexShader: /* glsl */ ` + attribute float aW; + attribute float aR; + uniform float uT, uReveal, uPx, uSize, uSpread; + varying float vA; + varying float vPh; + varying float vW; + varying float vFacing; + void main() { + vec4 wp = modelMatrix * vec4(position, 1.0); + vFacing = dot(normalize(wp.xyz), normalize(cameraPosition - wp.xyz)); + float k = uReveal - aR * uSpread; + vA = clamp(k * 5.0, 0.0, 1.0); + vPh = k; + vW = aW; + gl_Position = projectionMatrix * viewMatrix * wp; + ${ + ringMode + ? 'gl_PointSize = uSize * uPx * (0.6 + aW) * 9.0;' + : 'gl_PointSize = uSize * uPx * (0.7 + aW) * (1.0 + 3.0 * exp(-max(k, 0.0) * 8.0) * step(0.0, k));' + } + } + `, + fragmentShader: /* glsl */ ` + uniform vec3 uColor; + uniform float uT, uAlpha, uGain; + varying float vA; + varying float vPh; + varying float vW; + varying float vFacing; + void main() { + float back = smoothstep(-0.02, 0.12, vFacing); + if (vA * back <= 0.001) discard; + vec2 c = gl_PointCoord - 0.5; + float r = length(c) * 2.0; + ${ + ringMode + ? `float ph = fract(vPh * 0.55 + vW * 0.37); + float ringR = ph; + float ring = exp(-pow((r - ringR) * 14.0, 2.0)) * (1.0 - ph); + float core = smoothstep(0.16, 0.0, r); + float first = exp(-max(vPh, 0.0) * 3.0); + float a = ring * (0.8 + first) + core * 1.4;` + : `float flick = 0.65 + 0.35 * sin(uT * (9.0 + vW * 7.0) + vW * 91.0); + float a = smoothstep(1.0, 0.0, r) * smoothstep(1.0, 0.25, r) * 1.5 * flick;` + } + gl_FragColor = vec4(uColor * a * vA * uAlpha * uGain * back, 1.0); + } + `, + }); + const pts = new THREE.Points(g, m); + return { group: pts, uniforms, count: n }; +} + +// Satellites: positions arrive from satellite.js each frame. +export function createSatellites(count, { color = '#d6d0ff', size = 1.7 } = {}) { + const pos = new Float32Array(count * 3); + const rr = new Float32Array(count); + const kind = new Float32Array(count); + const rnd = mulberry32(11); + for (let i = 0; i < count; i++) rr[i] = rnd(); + const g = new THREE.BufferGeometry(); + const pa = new THREE.BufferAttribute(pos, 3); + pa.setUsage(THREE.DynamicDrawUsage); + g.setAttribute('position', pa); + g.setAttribute('aR', new THREE.BufferAttribute(rr, 1)); + g.setAttribute('aK', new THREE.BufferAttribute(kind, 1)); + g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 20); + const uniforms = { + uReveal: { value: 0 }, + uAlpha: { value: 0 }, + uPx: { value: 1 }, + uSize: { value: size }, + uColor: { value: new THREE.Color(color) }, + }; + const m = new THREE.ShaderMaterial({ + uniforms, + transparent: true, + depthWrite: false, + depthTest: true, + blending: THREE.AdditiveBlending, + vertexShader: /* glsl */ ` + attribute float aR; + attribute float aK; + uniform float uReveal, uPx, uSize; + varying float vA; + varying float vK; + void main() { + float k = uReveal - aR * 1.3; + vA = clamp(k * 5.0, 0.0, 1.0); + vK = aK; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + float big = aK > 0.5 ? 1.6 : 1.0; + gl_PointSize = uSize * uPx * big * (1.0 + 3.0 * exp(-max(k, 0.0) * 9.0) * step(0.0, k)); + } + `, + fragmentShader: /* glsl */ ` + uniform vec3 uColor; + uniform float uAlpha; + varying float vA; + varying float vK; + void main() { + if (vA <= 0.001) discard; + float r = length(gl_PointCoord - 0.5) * 2.0; + float a = smoothstep(1.0, 0.1, r); + vec3 c = mix(uColor * 0.75, vec3(0.75, 1.0, 1.0) * 1.4, step(0.5, vK)); + gl_FragColor = vec4(c * a * vA * uAlpha, 1.0); + } + `, + }); + const pts = new THREE.Points(g, m); + return { group: pts, uniforms, positions: pos, attr: pa, kind, count }; +} + +// Orbit rings for a handful of notable objects. +export function createOrbitRings(paths, color = '#bfb6ff') { + const pos = []; + const u = []; + for (const path of paths) { + for (let i = 0; i < path.length - 1; i++) { + pos.push(...path[i], ...path[i + 1]); + u.push(i / (path.length - 1), (i + 1) / (path.length - 1)); + } + } + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + g.setAttribute('aU', new THREE.Float32BufferAttribute(u, 1)); + const m = new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + uniforms: { uDraw: { value: 0 }, uAlpha: { value: 0 }, uColor: { value: new THREE.Color(color) } }, + vertexShader: /* glsl */ ` + attribute float aU; + varying float vU; + varying float vNear; + void main() { + vU = aU; + vec4 mv = modelViewMatrix * vec4(position, 1.0); + vNear = smoothstep(1.2, 4.5, -mv.z); + gl_Position = projectionMatrix * mv; + } + `, + fragmentShader: /* glsl */ ` + uniform float uDraw, uAlpha; + uniform vec3 uColor; + varying float vU; + varying float vNear; + void main() { + float k = uDraw - vU; + if (k < 0.0) discard; + float hot = exp(-k * 30.0) * 2.0; + gl_FragColor = vec4((uColor * 0.4 + vec3(1.0) * hot) * uAlpha * vNear, 1.0); + } + `, + }); + return new THREE.LineSegments(g, m); +} + +// Wind: particles advected through a real GFS u/v field, drawn as fading trails. +export function createWind(field, { count = 7000, trailLen = 14, seed = 21 } = {}) { + const { nx, ny, lo1, la1, dx, dy, u, v } = field; + const sample = (lon, lat) => { + let x = (((lon - lo1) % 360) + 360) % 360 / dx; + let y = (la1 - lat) / dy; + const x0 = Math.floor(x); + const y0 = Math.max(0, Math.min(ny - 2, Math.floor(y))); + const fx = x - x0; + const fy = Math.max(0, Math.min(1, y - y0)); + const i00 = y0 * nx + (x0 % nx); + const i10 = y0 * nx + ((x0 + 1) % nx); + const i01 = (y0 + 1) * nx + (x0 % nx); + const i11 = (y0 + 1) * nx + ((x0 + 1) % nx); + const bl = (arr) => (arr[i00] * (1 - fx) + arr[i10] * fx) * (1 - fy) + (arr[i01] * (1 - fx) + arr[i11] * fx) * fy; + return [bl(u), bl(v)]; + }; + const SEGS = trailLen - 1; + const pos = new Float32Array(count * SEGS * 2 * 3); + const alpha = new Float32Array(count * SEGS * 2); + const g = new THREE.BufferGeometry(); + const pa = new THREE.BufferAttribute(pos, 3); + pa.setUsage(THREE.DynamicDrawUsage); + const aa = new THREE.BufferAttribute(alpha, 1); + aa.setUsage(THREE.DynamicDrawUsage); + g.setAttribute('position', pa); + g.setAttribute('aA', aa); + g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 2); + const uniforms = { uAlpha: { value: 0 }, uColor: { value: new THREE.Color('#a6dcff') } }; + const m = new THREE.ShaderMaterial({ + uniforms, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + vertexShader: /* glsl */ ` + attribute float aA; + varying float vA; + varying float vFacing; + void main() { + vA = aA; + vec4 wp = modelMatrix * vec4(position, 1.0); + vFacing = dot(normalize(wp.xyz), normalize(cameraPosition - wp.xyz)); + gl_Position = projectionMatrix * viewMatrix * wp; + } + `, + fragmentShader: /* glsl */ ` + uniform vec3 uColor; + uniform float uAlpha; + varying float vA; + varying float vFacing; + void main() { + float back = smoothstep(0.0, 0.15, vFacing); + gl_FragColor = vec4(uColor * vA * uAlpha * back, 1.0); + } + `, + }); + const lines = new THREE.LineSegments(g, m); + + // particle state + const P = { lon: new Float32Array(count), lat: new Float32Array(count), age: new Float32Array(count), life: new Float32Array(count), spd: new Float32Array(count) }; + const hist = new Float32Array(count * trailLen * 2); + let rnd; + let simT = -1; + const spawn = (i) => { + const lon = rnd() * 360 - 180; + const lat = Math.asin(rnd() * 2 - 1) * (180 / Math.PI) * 0.92; + P.lon[i] = lon; + P.lat[i] = lat; + P.age[i] = 0; + P.life[i] = 1.2 + rnd() * 2.2; + for (let k = 0; k < trailLen; k++) { + hist[(i * trailLen + k) * 2] = lon; + hist[(i * trailLen + k) * 2 + 1] = lat; + } + }; + const reset = () => { + rnd = mulberry32(seed); + for (let i = 0; i < count; i++) { + spawn(i); + P.age[i] = rnd() * P.life[i]; + } + simT = 0; + }; + const DT = 1 / 30; + const K = 0.85; // deg per (m/s) per second of film — visual speed + const step = () => { + for (let i = 0; i < count; i++) { + const [uu, vv] = sample(P.lon[i], P.lat[i]); + P.spd[i] = Math.hypot(uu, vv); + const c = Math.max(0.2, Math.cos(P.lat[i] * D2R)); + P.lon[i] += (uu * K * DT) / c; + P.lat[i] += vv * K * DT; + if (P.lon[i] > 180) P.lon[i] -= 360; + if (P.lon[i] < -180) P.lon[i] += 360; + P.age[i] += DT; + const base = i * trailLen * 2; + hist.copyWithin(base + 2, base, base + (trailLen - 1) * 2); + hist[base] = P.lon[i]; + hist[base + 1] = P.lat[i]; + if (P.age[i] > P.life[i] || Math.abs(P.lat[i]) > 84) spawn(i); + } + simT += DT; + }; + const a = [0, 0, 0]; + const b = [0, 0, 0]; + function update(tSinceStart) { + if (simT < 0 || tSinceStart < simT - 1e-6) reset(); + while (simT < tSinceStart) step(); + let o = 0; + let q = 0; + for (let i = 0; i < count; i++) { + const base = i * trailLen * 2; + const life = P.age[i] / P.life[i]; + const fade = Math.min(1, life * 5) * Math.min(1, (1 - life) * 4); + const sp = Math.min(1, P.spd[i] / 18); + for (let k = 0; k < SEGS; k++) { + const lo1 = hist[base + k * 2]; + const la1 = hist[base + k * 2 + 1]; + const lo2 = hist[base + (k + 1) * 2]; + const la2 = hist[base + (k + 1) * 2 + 1]; + const jump = Math.abs(lo2 - lo1) > 90; + llToVec(lo1, la1, 1.004, a); + llToVec(jump ? lo1 : lo2, jump ? la1 : la2, 1.004, b); + pos[o++] = a[0]; pos[o++] = a[1]; pos[o++] = a[2]; + pos[o++] = b[0]; pos[o++] = b[1]; pos[o++] = b[2]; + const f1 = (1 - k / SEGS) * fade * (0.25 + sp); + const f2 = (1 - (k + 1) / SEGS) * fade * (0.25 + sp); + alpha[q++] = f1; + alpha[q++] = f2; + } + } + pa.needsUpdate = true; + aa.needsUpdate = true; + } + return { group: lines, uniforms, update, count }; +} + +export function createStars(n = 2600, seed = 77) { + const rnd = mulberry32(seed); + const pos = new Float32Array(n * 3); + const mag = new Float32Array(n); + for (let i = 0; i < n; i++) { + const z = rnd() * 2 - 1; + const t = rnd() * Math.PI * 2; + const s = Math.sqrt(1 - z * z); + pos[i * 3] = 400 * s * Math.cos(t); + pos[i * 3 + 1] = 400 * z; + pos[i * 3 + 2] = 400 * s * Math.sin(t); + mag[i] = Math.pow(rnd(), 6); + } + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + g.setAttribute('aM', new THREE.BufferAttribute(mag, 1)); + const uniforms = { uAlpha: { value: 0 }, uPx: { value: 1 } }; + const m = new THREE.ShaderMaterial({ + uniforms, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + vertexShader: /* glsl */ ` + attribute float aM; + uniform float uPx; + varying float vM; + void main() { + vM = aM; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + gl_PointSize = (1.0 + aM * 2.2) * uPx; + } + `, + fragmentShader: /* glsl */ ` + uniform float uAlpha; + varying float vM; + void main() { + float r = length(gl_PointCoord - 0.5) * 2.0; + float a = smoothstep(1.0, 0.0, r); + gl_FragColor = vec4(vec3(0.8, 0.9, 1.0) * a * (0.12 + vM * 0.9) * uAlpha, 1.0); + } + `, + }); + return { group: new THREE.Points(g, m), uniforms }; +} diff --git a/intro-sequence/src/lines.js b/intro-sequence/src/lines.js new file mode 100644 index 000000000..e0e662e4f --- /dev/null +++ b/intro-sequence/src/lines.js @@ -0,0 +1,323 @@ +import * as THREE from 'three'; +import { llToVec, angDist, destination, bearing, D2R } from './geo.js'; +import { mulberry32 } from './timeline.js'; + +// Line layers that reveal along a scalar "arrival" attribute (radians from the +// seed, or arc length), with a hot tip at the front and a configurable wake. +export function frontLineMaterial({ color, alpha = 1, after = 0.25, tip = 3.0, decay = 4.0, additive = true }) { + return new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: true, + blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending, + uniforms: { + uColor: { value: new THREE.Color(color) }, + uFront: { value: -1 }, + uAlpha: { value: alpha }, + uAfter: { value: after }, + uTip: { value: tip }, + uDecay: { value: decay }, + uFadeBack: { value: 1 }, + }, + vertexShader: /* glsl */ ` + attribute float aDist; + varying float vDist; + varying float vFacing; + void main() { + vDist = aDist; + vec4 wp = modelMatrix * vec4(position, 1.0); + vec3 n = normalize(wp.xyz); + vFacing = dot(n, normalize(cameraPosition - wp.xyz)); + gl_Position = projectionMatrix * viewMatrix * wp; + } + `, + fragmentShader: /* glsl */ ` + uniform vec3 uColor; + uniform float uFront, uAlpha, uAfter, uTip, uDecay, uFadeBack; + varying float vDist; + varying float vFacing; + void main() { + float behind = uFront - vDist; + if (behind < 0.0) discard; + float a = uAfter + (1.0 - uAfter) * exp(-behind * uDecay); + float hot = exp(-behind * 60.0) * uTip; + float back = mix(1.0, smoothstep(-0.15, 0.25, vFacing), uFadeBack); + vec3 c = uColor * (a + hot) + vec3(1.0) * hot * 0.6; + gl_FragColor = vec4(c * uAlpha * back, 1.0); + } + `, + }); +} + +function segmentsFromPolylines(lines, r, distFn) { + const pos = []; + const dist = []; + const a = [0, 0, 0]; + const b = [0, 0, 0]; + for (const line of lines) { + for (let i = 0; i < line.length - 1; i++) { + const [lo1, la1] = line[i]; + const [lo2, la2] = line[i + 1]; + if (Math.abs(lo2 - lo1) > 180) continue; // antimeridian jump + llToVec(lo1, la1, r, a); + llToVec(lo2, la2, r, b); + pos.push(...a, ...b); + dist.push(distFn(lo1, la1, i, line), distFn(lo2, la2, i + 1, line)); + } + } + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + g.setAttribute('aDist', new THREE.Float32BufferAttribute(dist, 1)); + return g; +} + +export function createCoastline(lines, seedLL, color = '#3ff0ff') { + const g = segmentsFromPolylines(lines, 1.0008, (lo, la) => angDist(seedLL[0], seedLL[1], lo, la)); + const m = frontLineMaterial({ color, after: 0.32, tip: 5.0, decay: 3.0 }); + return new THREE.LineSegments(g, m); +} + +export function createBorders(lines, seedLL, color = '#1aa6c4') { + const g = segmentsFromPolylines(lines, 1.0006, (lo, la) => angDist(seedLL[0], seedLL[1], lo, la)); + const m = frontLineMaterial({ color, after: 0.18, tip: 1.5, decay: 3.0 }); + return new THREE.LineSegments(g, m); +} + +// Mycelium: a branching random walk out from the seed. Each vertex carries its +// path length so the network visibly *grows* rather than appearing. +export function createMycelium(seedLL, { primaries = 22, maxSegs = 60000, seed = 7 } = {}) { + const rnd = mulberry32(seed); + const pos = []; + const dist = []; + const a = [0, 0, 0]; + const b = [0, 0, 0]; + const stack = []; + for (let i = 0; i < primaries; i++) { + stack.push({ lon: seedLL[0], lat: seedLL[1], brg: (360 * i) / primaries + rnd() * 12, len: 0, depth: 0, life: 150 + rnd() * 120 }); + } + let segs = 0; + while (stack.length && segs < maxSegs) { + const br = stack.pop(); + let { lon, lat, brg, len } = br; + const step = (0.5 + rnd() * 0.5) * D2R * (br.depth === 0 ? 1.2 : 0.9); + for (let s = 0; s < br.life && segs < maxSegs; s++) { + // outward bias keeps the culture expanding rather than knotting + const away = (bearing(lon, lat, seedLL[0], seedLL[1]) + 180) % 360; + let diff = ((away - brg + 540) % 360) - 180; + brg += diff * 0.04 + (rnd() - 0.5) * 26; + const [nl, nt] = destination(lon, lat, brg, step); + if (Math.abs(nl - lon) < 180) { + llToVec(lon, lat, 1.0012, a); + llToVec(nl, nt, 1.0012, b); + pos.push(...a, ...b); + dist.push(len * 0.82, (len + step) * 0.82); + segs++; + } + lon = nl; + lat = nt; + len += step; + if (len > Math.PI * 1.05) break; + const pBranch = br.depth < 5 ? 0.07 : 0.02; + if (rnd() < pBranch) { + stack.push({ lon, lat, brg: brg + (rnd() < 0.5 ? -1 : 1) * (22 + rnd() * 45), len, depth: br.depth + 1, life: (br.life - s) * (0.35 + rnd() * 0.5) }); + } + } + } + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + g.setAttribute('aDist', new THREE.Float32BufferAttribute(dist, 1)); + const m = frontLineMaterial({ color: '#2fe8ff', after: 0.07, tip: 4.0, decay: 2.2 }); + const obj = new THREE.LineSegments(g, m); + obj.userData.segments = segs; + return obj; +} + +// Subsea cables: drawn on, then light pulses ride them. +export function createCables(cables) { + const pos = []; + const arc = []; + const rnd = []; + const a = [0, 0, 0]; + const b = [0, 0, 0]; + const r = mulberry32(99); + for (const line of cables) { + let L = 0; + const off = r(); + for (let i = 0; i < line.length - 1; i++) { + const [lo1, la1] = line[i]; + const [lo2, la2] = line[i + 1]; + if (Math.abs(lo2 - lo1) > 180) continue; + const d = angDist(lo1, la1, lo2, la2); + llToVec(lo1, la1, 1.0015, a); + llToVec(lo2, la2, 1.0015, b); + pos.push(...a, ...b); + arc.push(L, L + d); + rnd.push(off, off); + L += d; + } + } + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + g.setAttribute('aArc', new THREE.Float32BufferAttribute(arc, 1)); + g.setAttribute('aRnd', new THREE.Float32BufferAttribute(rnd, 1)); + const m = new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + uniforms: { uT: { value: 0 }, uDraw: { value: 0 }, uAlpha: { value: 0 }, uColor: { value: new THREE.Color('#2bd9ff') } }, + vertexShader: /* glsl */ ` + attribute float aArc; + attribute float aRnd; + varying float vArc; + varying float vRnd; + varying float vFacing; + void main() { + vArc = aArc; vRnd = aRnd; + vec4 wp = modelMatrix * vec4(position, 1.0); + vFacing = dot(normalize(wp.xyz), normalize(cameraPosition - wp.xyz)); + gl_Position = projectionMatrix * viewMatrix * wp; + } + `, + fragmentShader: /* glsl */ ` + uniform float uT, uDraw, uAlpha; + uniform vec3 uColor; + varying float vArc; + varying float vRnd; + varying float vFacing; + void main() { + float drawn = uDraw * 3.2 - vArc - vRnd * 0.4; + if (drawn < 0.0) discard; + float tip = exp(-drawn * 30.0) * 3.0; + float p = fract(vArc * 3.0 - uT * 0.9 + vRnd); + float pulse = pow(smoothstep(0.85, 1.0, p), 3.0) * 2.5; + float back = smoothstep(-0.1, 0.25, vFacing); + vec3 c = uColor * (0.28 + pulse) + vec3(1.0) * (tip + pulse * 0.4); + gl_FragColor = vec4(c * uAlpha * back, 1.0); + } + `, + }); + return new THREE.LineSegments(g, m); +} + +// Great-circle arcs that lift off the surface; a bright head travels along. +export function createArcs(pairs, seed = 5) { + const rnd = mulberry32(seed); + const pos = []; + const u = []; + const t0 = []; + const SEG = 48; + const a = [0, 0, 0]; + const b = [0, 0, 0]; + for (const [p, q] of pairs) { + const d = angDist(p[0], p[1], q[0], q[1]); + if (d < 0.05) continue; + const brg = bearing(p[0], p[1], q[0], q[1]); + const lift = 0.015 + d * 0.07; + const start = rnd(); + let prev = null; + for (let i = 0; i <= SEG; i++) { + const f = i / SEG; + const [lo, la] = destination(p[0], p[1], brg, d * f); + const h = 1.002 + Math.sin(Math.PI * f) * lift; + llToVec(lo, la, h, b); + if (prev) { + pos.push(...prev, ...b); + u.push((i - 1) / SEG, f); + t0.push(start, start); + } + prev = [...b]; + } + } + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + g.setAttribute('aU', new THREE.Float32BufferAttribute(u, 1)); + g.setAttribute('aT0', new THREE.Float32BufferAttribute(t0, 1)); + const m = new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + uniforms: { uT: { value: 0 }, uAlpha: { value: 0 }, uColor: { value: new THREE.Color('#5ff6ff') } }, + vertexShader: /* glsl */ ` + attribute float aU; + attribute float aT0; + varying float vU; + varying float vT0; + void main() { + vU = aU; vT0 = aT0; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: /* glsl */ ` + uniform float uT, uAlpha; + uniform vec3 uColor; + varying float vU; + varying float vT0; + void main() { + float head = uT * 0.55 - vT0 * 1.6; // each arc launches at its own time + float k = head - vU; + if (k < 0.0) discard; + float trail = exp(-k * 3.5); + float hot = exp(-k * 40.0) * 2.0; + vec3 c = uColor * (0.15 + trail) + vec3(1.0) * hot; + gl_FragColor = vec4(c * uAlpha, 1.0); + } + `, + }); + return new THREE.LineSegments(g, m); +} + +// The digital twin's lattice: a geodesic shell that grows outward. +export function createLattice() { + const ico = new THREE.IcosahedronGeometry(1, 4); + const edges = new THREE.WireframeGeometry(ico); + const p = edges.getAttribute('position'); + const rnd = []; + // build order: a sweep out from the seed (+X, Null Island) with a little jitter, + // so the shell visibly constructs itself the way the culture grew + for (let i = 0; i < p.count; i += 2) { + const mx = (p.getX(i) + p.getX(i + 1)) / 2; + const my = (p.getY(i) + p.getY(i + 1)) / 2; + const mz = (p.getZ(i) + p.getZ(i + 1)) / 2; + const ang = Math.acos(Math.max(-1, Math.min(1, mx / Math.hypot(mx, my, mz)))) / Math.PI; + const h = Math.sin(mx * 91.7 + my * 47.3 + mz * 13.1) * 43758.5; + const v = Math.min(0.999, ang * 0.9 + (h - Math.floor(h)) * 0.08); + rnd.push(v, v); + } + edges.setAttribute('aRnd', new THREE.Float32BufferAttribute(rnd, 1)); + const m = new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + uniforms: { uAlpha: { value: 0 }, uBuild: { value: 0 }, uT: { value: 0 }, uColor: { value: new THREE.Color('#38e8ff') } }, + vertexShader: /* glsl */ ` + attribute float aRnd; + varying float vRnd; + varying vec3 vP; + varying float vFacing; + void main() { + vRnd = aRnd; vP = normalize(position); + vec4 wp = modelMatrix * vec4(position, 1.0); + vFacing = dot(normalize(wp.xyz), normalize(cameraPosition - wp.xyz)); + gl_Position = projectionMatrix * viewMatrix * wp; + } + `, + fragmentShader: /* glsl */ ` + uniform float uAlpha, uBuild, uT; + uniform vec3 uColor; + varying float vRnd; + varying vec3 vP; + varying float vFacing; + void main() { + if (vRnd > uBuild) discard; + float fresh = exp(-(uBuild - vRnd) * 18.0); + float shimmer = 0.6 + 0.4 * sin(uT * 3.0 + vRnd * 40.0); + // silhouette-weighted: the shell reads as a sphere, not a net over the planet + float rim = 1.0 - abs(vFacing); + float back = mix(0.18, 1.0, pow(rim, 1.5)); + vec3 c = uColor * (0.3 * shimmer + fresh * 1.6) + vec3(1.0) * fresh * 0.7; + gl_FragColor = vec4(c * uAlpha * back, 1.0); + } + `, + }); + return new THREE.LineSegments(edges, m); +} diff --git a/intro-sequence/src/main.js b/intro-sequence/src/main.js new file mode 100644 index 000000000..26e1a957f --- /dev/null +++ b/intro-sequence/src/main.js @@ -0,0 +1,689 @@ +import * as THREE from 'three'; +import * as satellite from 'satellite.js'; +import { T, FPS, FEEDS, captureTimes, clamp, lerp, ramp, smooth, win, easeInOut, expoOut, mulberry32 } from './timeline.js'; +import { llToVec, subsolar, angDist, D2R, R2D, EARTH_KM } from './geo.js'; +import { createGlobe } from './globe.js'; +import { createCoastline, createBorders, createMycelium, createCables, createArcs, createLattice } from './lines.js'; +import { createMovers, createEvents, createSatellites, createOrbitRings, createWind, createStars } from './layers.js'; +import { createPost } from './post.js'; +import { createHUD } from './hud.js'; + +const qs = new URLSearchParams(location.search); +const W = +qs.get('w') || 1920; +const H = +qs.get('h') || 1080; +const PORTRAIT = H > W; +const SEED_LL = [0, 0]; // `seed --origin null` — Null Island + +const status = (m) => { + window.__status = m; + console.log('[gev]', m); +}; + +async function json(path) { + try { + const r = await fetch(path); + if (!r.ok) throw new Error(r.status); + return await r.json(); + } catch (e) { + console.warn('missing', path, e.message); + return null; + } +} +async function text(path) { + try { + const r = await fetch(path); + return r.ok ? await r.text() : null; + } catch { + return null; + } +} +function tex(path, { srgb = true, mips = true } = {}) { + return new Promise((res) => { + new THREE.TextureLoader().load( + path, + (t) => { + if (srgb) t.colorSpace = THREE.SRGBColorSpace; + t.anisotropy = 8; + t.generateMipmaps = mips; + t.minFilter = mips ? THREE.LinearMipmapLinearFilter : THREE.LinearFilter; + t.wrapS = THREE.RepeatWrapping; + res(t); + }, + undefined, + () => { + console.warn('missing texture', path); + const d = new THREE.DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1); + d.needsUpdate = true; + res(d); + }, + ); + }); +} +function img(path) { + return new Promise((res) => { + const im = new Image(); + im.onload = () => res(im); + im.onerror = () => res(null); + im.src = path; + }); +} + +// ------------------------------------------------------------------ boot +async function init() { + status('loading'); + const canvas = document.getElementById('c'); + canvas.width = W; + canvas.height = H; + canvas.style.width = W + 'px'; + canvas.style.height = H + 'px'; + + await document.fonts.ready; + await Promise.all([ + document.fonts.load('400 20px "JetBrains Mono"'), + document.fonts.load('500 20px "JetBrains Mono"'), + document.fonts.load('200 20px Inter'), + document.fonts.load('300 20px Inter'), + document.fonts.load('500 20px Inter'), + ]); + + const [flights, mil, vessels, quakes, fires, wind, cables, coast, borders, cities, airports, radio, bikes, tleText, tleGroups, heroIdx, camIdx, extra, plants, pads] = + await Promise.all([ + json('data/flights.json'), + json('data/military.json'), + json('data/vessels.json'), + json('data/quakes.json'), + json('data/fires.json'), + json('data/wind.json'), + json('data/cables.json'), + json('data/coastline.json'), + json('data/borders.json'), + json('data/cities.json'), + json('data/airports.json'), + json('data/radio.json'), + json('data/bikes.json'), + text('data/tle.txt'), + json('data/tle_groups.json'), + json('imagery/hero/index.json'), + json('cctv/index.json'), + json('data/extra_counts.json'), + json('data/power_plants.json'), + json('data/launch_sites.json'), + ]); + + const [dayTex, nightTex, cloudTex] = await Promise.all([ + tex('imagery/earth_day.jpg'), + tex('imagery/earth_night.jpg'), + tex('imagery/clouds.jpg', { srgb: false }), + ]); + + // Hero captures (Esri synthetic screenshots) and CCTV stills + const heroes = []; + for (const h of heroIdx || []) { + const [wide, close] = await Promise.all([img(`imagery/hero/${h.slug}_wide.jpg`), img(`imagery/hero/${h.slug}_close.jpg`)]); + if (wide && close) heroes.push({ ...h, wide, close }); + } + const cams = []; + for (const c of camIdx || []) { + const im = await img(`cctv/${c.file}`); + if (im) cams.push({ ...c, img: im }); + } + status(`assets: ${heroes.length} heroes, ${cams.length} cams`); + + // ---------------------------------------------------------------- scene + const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' }); + renderer.setPixelRatio(1); + renderer.setSize(W, H, false); + renderer.autoClear = false; + + const scene = new THREE.Scene(); + const FOV = 26; + const camera = new THREE.PerspectiveCamera(FOV, W / H, 0.005, 3000); + + const snapT = new Date((flights?.t || Date.now() / 1000) * 1000); + const sunLL = subsolar(snapT); + const sunV = new THREE.Vector3(...llToVec(sunLL[0], sunLL[1])); + + const globe = createGlobe({ dayTex, nightTex, cloudTex }); + globe.uniforms.uSeed.value.set(...llToVec(SEED_LL[0], SEED_LL[1])); + globe.uniforms.uSun.value.copy(sunV); + globe.uniforms.uWave.value.set(...T.zWave); + scene.add(globe.group); + + const stars = createStars(); + scene.add(stars.group); + + const coastline = coast ? createCoastline(coast.lines, SEED_LL) : null; + const borderL = borders ? createBorders(borders.lines, SEED_LL) : null; + const myc = createMycelium(SEED_LL); + [coastline, borderL, myc].forEach((o) => o && scene.add(o)); + + const cableL = cables ? createCables(cables.cables) : null; + if (cableL) scene.add(cableL); + + // live feeds + const L = {}; + if (flights) L.air = createMovers(flights.rows, { color: '#e8fbff', trailColor: '#4fd8ff', size: 2.0, altScale: 10, seed: 2 }); + if (mil) L.mil = createMovers(mil.rows, { color: '#ffbf5a', trailColor: '#ff9a2a', size: 4.0, altScale: 10, revealSpread: 0.6, seed: 4 }); + if (vessels) L.sea = createMovers(vessels.rows.map((r) => [r[0], r[1], 0, r[3], (r[2] || 0) * 0.5144]), { color: '#52ffc8', trailColor: '#1fd6a0', size: 1.5, gain: 0.45, altScale: 0, seed: 6 }); + const quakeRowsX = (quakes?.rows || []).filter((r) => r[3] >= 2.5).map((r) => [r[0], r[1], r[3]]); + const quakeRows = quakeRowsX; + L.quake = createEvents(quakeRows, { color: '#ff3d3d', size: 1.6, mode: 'ring', weightFn: (m) => clamp((m - 2.4) / 4.5, 0.05, 1.3), spread: 1.6 }); + const fireRows = (fires?.rows || []).map((r) => [r[0], r[1], r[2]]); + L.fire = createEvents(fireRows, { color: '#ff6a14', size: 1.15, gain: 0.32, mode: 'flicker', weightFn: (f) => clamp(Math.log10(1 + f) / 2.5, 0, 1.2), spread: 1.4 }); + if (wind) L.wind = createWind(wind, { count: PORTRAIT ? 6000 : 7000 }); + Object.values(L).forEach((l) => scene.add(l.group)); + + // satellites + const sats = []; + if (tleText) { + const lines = tleText.split(/\r?\n/).map((s) => s.trimEnd()).filter(Boolean); + for (let i = 0; i < lines.length - 1; i++) { + if (lines[i].startsWith('1 ') && lines[i + 1].startsWith('2 ')) { + const name = i > 0 && !lines[i - 1].startsWith('2 ') && !lines[i - 1].startsWith('1 ') ? lines[i - 1].replace(/^0 /, '').trim() : ''; + try { + const rec = satellite.twoline2satrec(lines[i], lines[i + 1]); + sats.push({ rec, name, norad: +lines[i].slice(2, 7) || 0 }); + } catch {} + i++; + } + } + } + const stationSet = new Set(tleGroups?.groups_norad?.stations || [25544]); + const satL = createSatellites(Math.max(1, sats.length)); + sats.forEach((s, i) => (satL.kind[i] = stationSet.has(s.norad) ? 1 : 0)); + satL.group.geometry.getAttribute('aK').needsUpdate = true; + scene.add(satL.group); + const satByNorad = new Map(sats.map((s, i) => [s.norad, i])); + + // orbit rings in an ECI-aligned group, rotated by -GMST each frame + const ringGroup = new THREE.Group(); + scene.add(ringGroup); + // LEO rings for the orbital beat; GPS/GEO rings only once the camera is far out + const ringIds = [25544, 20580, 48274, 41866, 51850, 40105, 28474, 43226]; + const ringPaths = []; + const highPaths = []; + for (const id of ringIds) { + const i = satByNorad.get(id); + if (i === undefined) continue; + const rec = sats[i].rec; + const period = (2 * Math.PI) / rec.no; // minutes + const path = []; + for (let k = 0; k <= 240; k++) { + const d = new Date(snapT.getTime() + (k / 240) * period * 60000); + const pv = satellite.propagate(rec, d); + if (!pv.position) continue; + const p = pv.position; + path.push([p.x / EARTH_KM, p.z / EARTH_KM, -p.y / EARTH_KM]); + } + if (path.length > 10) (Math.hypot(...path[0]) < 1.3 ? ringPaths : highPaths).push(path); + } + const rings = createOrbitRings(ringPaths); + const ringsHigh = createOrbitRings(highPaths, '#a9a2ff'); + ringGroup.add(rings, ringsHigh); + + // data arcs for saturation: between big cities, cameras and cable ends + const arcPairs = []; + { + const r = mulberry32(42); + const pool = (cities?.rows || []).slice(0, 400).map((c) => [c[0], c[1]]); + for (let i = 0; i < 150 && pool.length > 2; i++) { + const a = pool[Math.floor(Math.pow(r(), 1.6) * pool.length)]; + const b = pool[Math.floor(r() * pool.length)]; + if (angDist(a[0], a[1], b[0], b[1]) > 0.15) arcPairs.push([a, b]); + } + } + const arcs = createArcs(arcPairs); + scene.add(arcs); + + const lattice = createLattice(); + scene.add(lattice); + + const pxScale = Math.min(W, H) / 1080; + for (const l of [...Object.values(L), satL, stars]) if (l.uniforms.uPx) l.uniforms.uPx.value = pxScale; + + // ---------------------------------------------------------------- HUD + post + const hudCanvas = document.createElement('canvas'); + hudCanvas.width = W; + hudCanvas.height = H; + const post = createPost(renderer, W, H, hudCanvas); + const hudCtx = hudCanvas.getContext('2d'); + + const counts = { + air: flights?.rows.length || 0, + mil: mil?.rows.length || 0, + sea: vessels?.rows.length || 0, + orbit: sats.length, + quake: quakes?.rows.length || 0, + fire: fires?.rows.length || 0, + wind: wind ? wind.nx * wind.ny : 0, + cable: cables?.cables.length || 0, + cctv: extra?.cctv_catalog || cams.length, + cities: cities?.rows.length || 0, + cctvCountries: extra?.cctv_countries || new Set(cams.map((c) => c.country)).size, + cctvSources: extra?.cctv_sources ? Object.keys(extra.cctv_sources).length : new Set(cams.map((c) => c.source)).size, + }; + const rapidList = []; + if (airports) rapidList.push(['AIRPORTS', airports.rows.length]); + if (radio) rapidList.push(['RADIO STATIONS', radio.rows.length]); + if (bikes) rapidList.push(['BIKESHARE STATIONS', bikes.rows.length]); + if (plants) rapidList.push(['POWER PLANTS', plants.rows.length]); + if (pads) rapidList.push(['LAUNCH PADS', pads.rows.length]); + rapidList.push(['POPULATED PLACES', counts.cities]); + rapidList.push(['LAND BORDERS', borders?.lines.length || 0]); + + // notable callouts — real records picked from the feeds + const pick = { + flights: pickFlights(flights), + mil: (mil?.rows || []).filter((r) => r[5] && r[7] && r[2] > 1500 && r[4] > 80).slice(0, 8), + ships: (vessels?.rows || []).filter((r) => r[5] && r[2] > 6).slice(0, 40), + quakeTop: (quakes?.rows || []).slice().sort((a, b) => b[3] - a[3]).slice(0, 4), + fireTop: (fires?.rows || []).slice().sort((a, b) => b[2] - a[2]).slice(0, 3), + cities: (cities?.rows || []).slice(0, 220), + }; + + const capT = captureTimes(heroes.length); + const hud = createHUD({ W, H, PORTRAIT, heroes, cams, counts, rapidList, pick, capT, snapT, sunLL }); + + // ---------------------------------------------------------------- camera director + // Keys: [t, lon, lat, g (globe radius / half the short side), shiftX, shiftY, roll] + const sx = PORTRAIT ? 0 : 0.26; + const sy = PORTRAIT ? 0.2 : 0; + const KEYS = [ + [0, 0, 0, 0.6, 0, 0, 0], + [8.7, 0, 0, 0.6, 0, 0, 0], + [16.6, -1.5, 1.5, 0.62, 0, 0, 0], + [19.5, 4, 6, 0.7, 0, 0, -0.01], + [24.5, 18, 16, 0.75, 0, 0, -0.02], + [30.0, 24, 36, 0.98, sx * 0.5, sy * 0.5, -0.02], + [32.5, 14, 58, 1.85, sx, sy, -0.03], + [33.7, 8, 59, 1.9, sx, sy, -0.03], + [35.9, -55, 43, 1.0, sx, sy, -0.01], + [37.2, -82, 38, 0.92, sx, sy, 0.0], + [40.0, -110, 26, 0.42, sx, sy, 0.02], + [42.6, -150, 18, 0.62, sx, sy, 0.02], + [44.4, -190, 2, 0.7, sx, sy, 0.01], + [46.4, -215, 4, 0.78, sx, sy, 0.0], + [49.0, -228, 14, 0.85, sx, sy, 0.0], + [52.4, -236, 20, 0.85, sx * 0.4, sy * 0.4, 0.0], + [56.0, -258, 24, 0.5, 0, 0, 0.0], + [60.5, -288, 22, 0.16, 0, 0, 0.0], + ]; + // Title framing: the planet becomes the iris of the logo. + const eye = hud.eyeLayout; + + function camAt(t) { + if (t >= T.title - 0.05) { + const g = eye.irisR / (Math.min(W, H) / 2); + const k = t - T.title; + return { lon: 108 + k * 1.6, lat: 12, g, shiftX: 0, shiftY: (H / 2 - eye.cy) / (Math.min(W, H) / 2), roll: 0 }; + } + let i = 0; + while (i < KEYS.length - 2 && t > KEYS[i + 1][0]) i++; + const k0 = KEYS[Math.max(0, i - 1)]; + const k1 = KEYS[i]; + const k2 = KEYS[i + 1]; + const k3 = KEYS[Math.min(KEYS.length - 1, i + 2)]; + const u = clamp((t - k1[0]) / (k2[0] - k1[0])); + const out = []; + for (let c = 1; c < 7; c++) { + const dt = k2[0] - k1[0]; + const m1 = ((k2[c] - k0[c]) / Math.max(1e-3, k2[0] - k0[0])) * dt; + const m2 = ((k3[c] - k1[c]) / Math.max(1e-3, k3[0] - k1[0])) * dt; + const u2 = u * u; + const u3 = u2 * u; + out[c] = (2 * u3 - 3 * u2 + 1) * k1[c] + (u3 - 2 * u2 + u) * m1 + (-2 * u3 + 3 * u2) * k2[c] + (u3 - u2) * m2; + } + // breathing drift so the shot never feels locked off + const drift = smooth(t, 8, 12); + return { + lon: out[1] + Math.sin(t * 0.21) * 0.6 * drift, + lat: out[2] + Math.sin(t * 0.17 + 1.3) * 0.4 * drift, + g: out[3], + shiftX: out[4], + shiftY: out[5], + roll: out[6], + }; + } + + const HITS = [ + [T.collapse, 0.5], + [T.substrate, 0.7], + [T.indexed, 0.9], + [T.cctvWall, 0.5], + [T.cctvOut, 0.6], + [T.dishBreak, 1.0], + [T.eyeOpen, 0.4], + ]; + function impulse(t, decay = 6) { + let v = 0; + for (const [h, a] of HITS) if (t >= h) v += a * Math.exp(-(t - h) * decay); + return v; + } + + function placeCamera(t) { + const c = camAt(t); + const minDim = Math.min(W, H); + const tanHalf = Math.tan((FOV * D2R) / 2); + const tanA = (c.g * minDim) / H * tanHalf; + const dist = 1 / Math.sin(Math.atan(tanA)); + const shake = impulse(t, 9) * 0.0025; + const sh1 = Math.sin(t * 91.0) * shake; + const sh2 = Math.cos(t * 77.0) * shake; + camera.position.set(...llToVec(c.lon, c.lat, dist)); + camera.up.set(0, 1, 0); + camera.lookAt(0, 0, 0); + camera.rotateZ(c.roll + sh1 * 2); + camera.near = Math.max(0.002, dist - 30); + camera.far = dist + 2000; + // shift the globe on screen without changing perspective + const offX = -c.shiftX * (minDim / 2) + sh1 * W; + const offY = c.shiftY * (minDim / 2) + sh2 * H; + camera.setViewOffset(W, H, offX, offY, W, H); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(); + return { ...c, dist }; + } + + // ---------------------------------------------------------------- per-frame state + const tmpV = new THREE.Vector3(); + function project(lon, lat, r = 1) { + tmpV.set(...llToVec(lon, lat, r)); + const n = tmpV.clone().normalize(); + const toCam = camera.position.clone().sub(tmpV).normalize(); + const facing = r <= 1.2 ? n.dot(toCam) : 1; + tmpV.project(camera); + return { x: (tmpV.x * 0.5 + 0.5) * W, y: (-tmpV.y * 0.5 + 0.5) * H, vis: facing > 0.05 && tmpV.z < 1, facing }; + } + function projectVec(v) { + tmpV.set(v[0], v[1], v[2]); + tmpV.project(camera); + return { x: (tmpV.x * 0.5 + 0.5) * W, y: (-tmpV.y * 0.5 + 0.5) * H, z: tmpV.z }; + } + + function frontAngle(t) { + if (t < T.growStart) return -1; + const x = ramp(t, T.growStart, T.growEnd); + const k = 3; + const base = Math.PI * (Math.exp(k * x) - 1) / (Math.exp(k) - 1); + return base + Math.max(0, t - T.growEnd) * 0.6; + } + + const satScratch = { idx: -1 }; + function updateSatellites(t, simDate) { + const gmst = satellite.gstime(simDate); + ringGroup.rotation.y = -gmst; + if (satScratch.t === t) return gmst; + satScratch.t = t; + const p = satL.positions; + for (let i = 0; i < sats.length; i++) { + const pv = satellite.propagate(sats[i].rec, simDate); + const e = pv && pv.position; + if (!e || isNaN(e.x)) { + p[i * 3] = p[i * 3 + 1] = p[i * 3 + 2] = 0; + continue; + } + const ecf = satellite.eciToEcf(e, gmst); + p[i * 3] = ecf.x / EARTH_KM; + p[i * 3 + 1] = ecf.z / EARTH_KM; + p[i * 3 + 2] = -ecf.y / EARTH_KM; + } + satL.attr.needsUpdate = true; + return gmst; + } + + function feedAlpha(t, start) { + // everything stays on through saturation, then clears for the title + return smooth(t, start, start + 0.25) * (1 - smooth(t, T.cut - 0.05, T.cut)); + } + + function renderFrame(frame) { + const t = frame / FPS; + const cam = placeCamera(t); + const G = globe.uniforms; + const front = frontAngle(t); + const titleOn = t >= T.title - 0.05; + + G.uTime.value = t; + G.uFront.value = titleOn ? 10 : front; + G.uHolo.value = smooth(t, T.growStart - 0.4, T.growStart + 1.2) * (1 - smooth(t, T.indexed - 1, T.indexed + 1)); + G.uLights.value = smooth(t, T.growStart, T.growStart + 0.8); + G.uImagery.value = titleOn ? 1 : smooth(t, 26.5, 29.5); + G.uLit.value = titleOn ? 1 : smooth(t, 22.6, 26.0); + G.uGridAmt.value = titleOn ? 0 : 1; + G.uDim.value = 1 - 0.35 * win(t, T.cctvWall - 0.2, T.cctvOut + 0.3, 0.3, 0.3); + globe.cloudU.uAmt.value = titleOn ? 0.6 : smooth(t, T.wind, T.wind + 1.6) * 0.62; + globe.atmoU.uAmt.value = titleOn ? 1 : smooth(t, 21.0, 25.0); + globe.group.children[1].visible = globe.cloudU.uAmt.value > 0.0005; + globe.group.children[2].visible = globe.atmoU.uAmt.value > 0.0005; + globe.group.visible = !(t >= T.cut && t < T.title - 0.05) && t >= T.seed - 0.1; + + stars.uniforms.uAlpha.value = titleOn ? 0.5 : smooth(t, T.substrate - 0.5, T.substrate + 2.5) * 0.85 * (t < T.cut ? 1 : 0); + + const vecFade = 1 - smooth(t, T.indexStart + 1.5, T.indexed - 2); + if (coastline) { + coastline.material.uniforms.uFront.value = front; + coastline.material.uniforms.uAlpha.value = 0.95 * vecFade; + coastline.visible = vecFade > 0.001 && !titleOn; + } + if (borderL) { + borderL.material.uniforms.uFront.value = front - 0.25; + borderL.material.uniforms.uAlpha.value = 0.7 * vecFade; + borderL.visible = vecFade > 0.001 && !titleOn; + } + myc.material.uniforms.uFront.value = front; + myc.material.uniforms.uAlpha.value = 0.85 * (1 - smooth(t, T.substrate, T.indexStart + 3)); + myc.visible = t > T.growStart && t < T.indexStart + 3.2; + + // feeds + const dens = clamp((cam.g - 0.12) / 0.75, 0.22, 1); + const reveal = (start, dur = 1.6, spread = 1.4) => ((t - start) * (1 + spread)) / dur; + const setMover = (l, start, accel) => { + if (!l) return; + l.uniforms.uSim.value = Math.max(0, t - start) * accel; + l.uniforms.uReveal.value = reveal(start, 1.6, l.uniforms.uSpread.value); + l.uniforms.uAlpha.value = feedAlpha(t, start) * dens; + l.group.visible = t >= start && t < T.cut; + }; + setMover(L.air, T.air, 140); + setMover(L.mil, T.mil, 140); + setMover(L.sea, T.sea, 1800); + const setEv = (l, start, dur = 1.8) => { + if (!l) return; + l.uniforms.uT.value = t; + l.uniforms.uReveal.value = reveal(start, dur, l.uniforms.uSpread.value); + l.uniforms.uAlpha.value = feedAlpha(t, start) * dens; + l.group.visible = t >= start && t < T.cut; + }; + setEv(L.quake, T.quake, 1.6); + setEv(L.fire, T.fire, 1.5); + if (L.fire) L.fire.uniforms.uAlpha.value *= dens; + if (L.wind) { + const on = t >= T.wind && t < T.cut; + L.wind.group.visible = on; + if (on) { + L.wind.update(t - T.wind); + L.wind.uniforms.uAlpha.value = feedAlpha(t, T.wind) * 2.2 * dens; + } + } + if (cableL) { + const u = cableL.material.uniforms; + u.uT.value = t; + u.uDraw.value = ramp(t, T.cable, T.cable + 1.8); + u.uAlpha.value = feedAlpha(t, T.cable) * 0.9; + cableL.visible = t >= T.cable && t < T.cut; + } + + // satellites + rings + const satOn = t >= T.orbit && t < T.cut; + const simDate = new Date(snapT.getTime() + Math.max(0, t - T.orbit) * 45 * 1000); + satL.group.visible = satOn; + rings.visible = satOn; + let gmst = satellite.gstime(simDate); + if (satOn) { + gmst = updateSatellites(t, simDate); + satL.uniforms.uReveal.value = reveal(T.orbit, 1.8, 1.3); + satL.uniforms.uAlpha.value = feedAlpha(t, T.orbit) * Math.max(0.45, dens); + rings.material.uniforms.uDraw.value = easeInOut(ramp(t, T.orbit + 0.3, T.orbit + 2.4)); + rings.material.uniforms.uAlpha.value = feedAlpha(t, T.orbit) * 0.8; + } + ringsHigh.visible = t >= T.saturate + 1 && t < T.cut; + ringsHigh.material.uniforms.uDraw.value = easeInOut(ramp(t, T.saturate + 1, T.saturate + 3.5)); + ringsHigh.material.uniforms.uAlpha.value = feedAlpha(t, T.saturate + 1) * 0.7; + + // saturation: arcs + lattice + arcs.visible = t >= T.arcs && t < T.cut; + arcs.material.uniforms.uT.value = t - T.arcs; + arcs.material.uniforms.uAlpha.value = feedAlpha(t, T.arcs) * 0.9; + const twinOn = t >= T.twin && t < T.cut; + lattice.visible = twinOn; + if (twinOn) { + lattice.scale.setScalar(latticeScale(t, cam.dist)); + lattice.rotation.y = t * 0.05; + const lu = lattice.material.uniforms; + lu.uBuild.value = ramp(t, T.twin, T.twin + 2.6); + lu.uT.value = t; + lu.uAlpha.value = smooth(t, T.twin, T.twin + 0.6) * (1 - smooth(t, T.cut - 0.4, T.cut)) * 0.7; + } + + // ---- HUD + const S = { + t, + frame, + cam, + front, + gmst, + simDate, + project, + projectVec, + satPos: (norad) => { + const i = satByNorad.get(norad); + if (i === undefined) return null; + const p = satL.positions; + return [p[i * 3], p[i * 3 + 1], p[i * 3 + 2]]; + }, + limbR: limbRadiusPx(cam.dist), + center: project(...[cam.lon, cam.lat], 0), // screen position of the globe centre + flightPos: (row, accel) => { + const sim = Math.max(0, t - T.air) * accel; + return advance(row, sim); + }, + shipPos: (row) => advance([row[0], row[1], 0, row[3], (row[2] || 0) * 0.5144], Math.max(0, t - T.sea) * 1800), + }; + S.center = projectVec([0, 0, 0]); + S.ctx = hudCtx; + S.camPos = camera.position; + S.latticeR = latticePx(t, cam.dist); + S.ringR = ringR0; + hud.draw(S); + + // ---- post + const glitch = + win(t, T.collapse - 0.05, T.collapse + 0.35, 0.02, 0.2) * 0.8 + + win(t, T.substrate - 0.02, T.substrate + 0.25, 0.02, 0.2) * 0.5 + + win(t, T.indexed - 0.02, T.indexed + 0.3, 0.02, 0.25) * 0.7 + + win(t, T.cctvWall - 0.02, T.cctvWall + 0.2, 0.02, 0.15) * 0.5 + + win(t, T.cctvOut - 0.05, T.cctvOut + 0.3, 0.02, 0.25) * 0.8 + + win(t, T.dishBreak - 0.02, T.dishBreak + 0.4, 0.02, 0.3) * 1.0 + + win(t, T.cut - 0.25, T.cut, 0.1, 0.01) * 1.0 + + win(t, T.eyeOpen - 0.02, T.eyeOpen + 0.2, 0.02, 0.15) * 0.4; + const flash = + Math.exp(-Math.max(0, t - T.substrate) * 12) * 0.035 * (t >= T.substrate ? 1 : 0) + + Math.exp(-Math.max(0, t - T.indexed) * 12) * 0.05 * (t >= T.indexed ? 1 : 0) + + Math.exp(-Math.max(0, t - T.dishBreak) * 10) * 0.06 * (t >= T.dishBreak ? 1 : 0) + + Math.exp(-Math.max(0, t - T.eyeOpen) * 10) * 0.05 * (t >= T.eyeOpen ? 1 : 0); + let fade = smooth(t, 0, 0.6); + if (t >= T.cut && t < T.title) fade = 0; + fade *= 1 - smooth(t, T.fadeOut, DURATION_END); + post.render(scene, camera, { + time: t, + frame, + bloom: t < T.seed ? 0.7 : lerp(0.85, 1.15, smooth(t, T.saturate, T.cut)), + bloomThresh: 0.78, + exposure: 1.0, + aberr: titleOn ? 0.00025 + glitch * 0.004 : 0.0012 + 0.002 * smooth(t, T.saturate, T.cut) + glitch * 0.004, + grain: 0.05, + vignette: 0.6, + glitch, + scan: t < T.seed ? 0.7 : 0.12, + flash, + fade, + hudGain: 1.0, + }); + } + + const RING_R = Math.min(W, H) * (PORTRAIT ? 0.43 : 0.4); + function latticePx(t, dist) { + const limb = limbRadiusPx(dist) * 1.12; + if (t < T.dishBreak) return lerp(limb, RING_R, Math.pow(easeInOut(ramp(t, T.twin, T.dishBreak)), 1.2)); + return RING_R * (1 + 1.6 * Math.pow(ramp(t, T.dishBreak, T.cut), 0.8)); + } + function latticeScale(t, dist) { + const px = latticePx(t, dist); + const tanA = (px / (H / 2)) * Math.tan((FOV * D2R) / 2); + return dist * Math.sin(Math.atan(tanA)); + } + function limbRadiusPx(dist) { + const tanA = Math.tan(Math.asin(1 / dist)); + return (tanA / Math.tan((FOV * D2R) / 2)) * (H / 2); + } + + function advance(row, sim) { + // row: [lon, lat, alt, track, vel(m/s)] -> [lon, lat] after sim seconds + const d = ((row[4] || 0) / (EARTH_KM * 1000)) * sim; + const b = (row[3] || 0) * D2R; + const la = row[1] * D2R; + const lo = row[0] * D2R; + const lat2 = Math.asin(Math.sin(la) * Math.cos(d) + Math.cos(la) * Math.sin(d) * Math.cos(b)); + const lon2 = lo + Math.atan2(Math.sin(b) * Math.sin(d) * Math.cos(la), Math.cos(d) - Math.sin(la) * Math.sin(lat2)); + return [lon2 * R2D, lat2 * R2D, row[2] || 0]; + } + + // the dish's second coming is sized so the twin breaches it exactly on cue + const ringR0 = RING_R; + + window.GEV = { renderFrame, ready: true, W, H, frames: Math.round(DURATION_END * FPS), counts, heroes: heroes.length, cams: cams.length, capT }; + status('ready'); + if (qs.has('play')) startPlayback(renderFrame); +} + +const DURATION_END = 72; + +// Real-time mode (?play): the same deterministic frames, driven by the audio +// clock, for use as a live loading sequence on a GPU. Browsers block audio +// until a gesture, so the first click starts it. +function startPlayback(renderFrame) { + const audio = new Audio('audio/score.wav'); + const cv = document.getElementById('c'); + cv.style.cssText = 'width:100vw;height:100vh;object-fit:contain;display:block'; + const gate = document.createElement('div'); + gate.textContent = 'CLICK TO INITIALISE'; + gate.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;color:#00f6ff;font:500 14px "JetBrains Mono";letter-spacing:6px;cursor:pointer;background:#000'; + document.body.appendChild(gate); + gate.addEventListener('click', () => { + gate.remove(); + audio.play().catch(() => {}); + const t0 = performance.now(); + const tick = () => { + const t = audio.currentTime > 0 ? audio.currentTime : (performance.now() - t0) / 1000; + if (t < DURATION_END) { + renderFrame(Math.min(DURATION_END * FPS - 1, Math.floor(t * FPS))); + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }); +} + +function pickFlights(f) { + if (!f) return []; + // long-haul, high, fast, with recognisable callsigns + return f.rows + .filter((r) => r[5] && r[2] > 9000 && r[4] > 220 && /^[A-Z]{3}\d/.test(r[5])) + .sort((a, b) => b[4] - a[4]) + .slice(0, 120); +} + +init().catch((e) => { + status('error: ' + e.message); + console.error(e); +}); diff --git a/intro-sequence/src/post.js b/intro-sequence/src/post.js new file mode 100644 index 000000000..63d96a6f0 --- /dev/null +++ b/intro-sequence/src/post.js @@ -0,0 +1,218 @@ +import * as THREE from 'three'; + +// HDR scene -> HUD composited in -> dual-filter bloom -> lens + film finish. +const QUAD_VS = /* glsl */ ` + varying vec2 vUv; + void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); } +`; + +export function createPost(renderer, W, H, hudCanvas) { + const rtOpts = { type: THREE.HalfFloatType, format: THREE.RGBAFormat, depthBuffer: false }; + const scene = new THREE.WebGLRenderTarget(W, H, { type: THREE.HalfFloatType, samples: 4 }); + const merged = new THREE.WebGLRenderTarget(W, H, rtOpts); + const levels = []; + let w = W; + let h = H; + for (let i = 0; i < 6; i++) { + w = Math.max(2, Math.floor(w / 2)); + h = Math.max(2, Math.floor(h / 2)); + levels.push({ down: new THREE.WebGLRenderTarget(w, h, rtOpts), up: new THREE.WebGLRenderTarget(w, h, rtOpts) }); + } + + const hudTex = new THREE.CanvasTexture(hudCanvas); + hudTex.colorSpace = THREE.SRGBColorSpace; + hudTex.minFilter = THREE.LinearFilter; + hudTex.generateMipmaps = false; + + const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2)); + const qScene = new THREE.Scene(); + qScene.add(quad); + const qCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + + const mergeMat = new THREE.ShaderMaterial({ + uniforms: { tScene: { value: null }, tHud: { value: hudTex }, uHudGain: { value: 1.25 } }, + vertexShader: QUAD_VS, + fragmentShader: /* glsl */ ` + uniform sampler2D tScene, tHud; + uniform float uHudGain; + varying vec2 vUv; + void main() { + vec3 s = texture2D(tScene, vUv).rgb; + vec4 hud = texture2D(tHud, vUv); + // canvas uploads un-premultiplied: straight-alpha over-composite + vec3 c = s * (1.0 - hud.a) + hud.rgb * hud.a * uHudGain; + gl_FragColor = vec4(c, 1.0); + } + `, + depthTest: false, + }); + + const downMat = new THREE.ShaderMaterial({ + uniforms: { tSrc: { value: null }, uTexel: { value: new THREE.Vector2() }, uThresh: { value: 0 }, uFirst: { value: 0 } }, + vertexShader: QUAD_VS, + fragmentShader: /* glsl */ ` + uniform sampler2D tSrc; + uniform vec2 uTexel; + uniform float uThresh, uFirst; + varying vec2 vUv; + vec3 pre(vec3 c) { + if (uFirst < 0.5) return c; + float l = max(c.r, max(c.g, c.b)); + float k = max(l - uThresh, 0.0) / max(l, 1e-4); + return min(c * k, vec3(40.0)); + } + void main() { + vec2 o = uTexel; + vec3 c = pre(texture2D(tSrc, vUv).rgb) * 4.0; + c += pre(texture2D(tSrc, vUv + vec2(-o.x, -o.y)).rgb); + c += pre(texture2D(tSrc, vUv + vec2( o.x, -o.y)).rgb); + c += pre(texture2D(tSrc, vUv + vec2(-o.x, o.y)).rgb); + c += pre(texture2D(tSrc, vUv + vec2( o.x, o.y)).rgb); + gl_FragColor = vec4(c / 8.0, 1.0); + } + `, + depthTest: false, + }); + + const upMat = new THREE.ShaderMaterial({ + uniforms: { tSrc: { value: null }, tBase: { value: null }, uTexel: { value: new THREE.Vector2() } }, + vertexShader: QUAD_VS, + fragmentShader: /* glsl */ ` + uniform sampler2D tSrc, tBase; + uniform vec2 uTexel; + varying vec2 vUv; + void main() { + vec2 o = uTexel; + vec3 c = texture2D(tSrc, vUv + vec2(-o.x * 2.0, 0.0)).rgb; + c += texture2D(tSrc, vUv + vec2(-o.x, o.y)).rgb * 2.0; + c += texture2D(tSrc, vUv + vec2(0.0, o.y * 2.0)).rgb; + c += texture2D(tSrc, vUv + vec2(o.x, o.y)).rgb * 2.0; + c += texture2D(tSrc, vUv + vec2(o.x * 2.0, 0.0)).rgb; + c += texture2D(tSrc, vUv + vec2(o.x, -o.y)).rgb * 2.0; + c += texture2D(tSrc, vUv + vec2(0.0, -o.y * 2.0)).rgb; + c += texture2D(tSrc, vUv + vec2(-o.x, -o.y)).rgb * 2.0; + gl_FragColor = vec4(c / 12.0 + texture2D(tBase, vUv).rgb, 1.0); + } + `, + depthTest: false, + }); + + const finalMat = new THREE.ShaderMaterial({ + uniforms: { + tSrc: { value: null }, + tBloom: { value: null }, + uBloom: { value: 0.9 }, + uExposure: { value: 1 }, + uTime: { value: 0 }, + uFrame: { value: 0 }, + uAberr: { value: 0.0015 }, + uGrain: { value: 0.045 }, + uVignette: { value: 0.55 }, + uGlitch: { value: 0 }, + uScan: { value: 0 }, + uFlash: { value: 0 }, + uFade: { value: 1 }, + uRes: { value: new THREE.Vector2(W, H) }, + }, + vertexShader: QUAD_VS, + fragmentShader: /* glsl */ ` + uniform sampler2D tSrc, tBloom; + uniform float uBloom, uExposure, uTime, uFrame, uAberr, uGrain, uVignette, uGlitch, uScan, uFlash, uFade; + uniform vec2 uRes; + varying vec2 vUv; + float h21(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } + vec3 aces(vec3 x) { + const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14; + return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); + } + void main() { + vec2 uv = vUv; + // block glitch: horizontal slices displaced on transitions + if (uGlitch > 0.0) { + float row = floor(uv.y * 48.0); + float blk = floor(uv.x * 6.0); + float r = h21(vec2(row, floor(uFrame * 0.5))); + float r2 = h21(vec2(blk, row + floor(uFrame))); + if (r < uGlitch * 0.55) uv.x += (r2 - 0.5) * 0.08 * uGlitch; + if (r2 < uGlitch * 0.12) uv.y += (r - 0.5) * 0.02; + } + vec2 d = uv - 0.5; + float rr = dot(d, d); + vec2 ab = d * rr * uAberr * 7.0 + vec2(uGlitch * 0.006, 0.0); + vec3 col; + col.r = texture2D(tSrc, uv + ab).r; + col.g = texture2D(tSrc, uv).g; + col.b = texture2D(tSrc, uv - ab).b; + vec3 bl; + bl.r = texture2D(tBloom, uv + ab * 1.5).r; + bl.g = texture2D(tBloom, uv).g; + bl.b = texture2D(tBloom, uv - ab * 1.5).b; + col += bl * uBloom; + col *= uExposure; + col += vec3(0.75, 0.95, 1.0) * uFlash; + col = aces(col); + // vignette + float vig = smoothstep(0.95, 0.18, length(d * vec2(uRes.x / uRes.y, 1.0) * 0.82)); + col *= mix(1.0, vig, uVignette); + // scanlines (boot / CRT moments) + col *= 1.0 - uScan * 0.22 * (0.5 + 0.5 * sin(gl_FragCoord.y * 3.14159 * 0.5)); + col = pow(col, vec3(1.0 / 2.2)); + // grain after encoding so it lives in the shadows too + float g = h21(gl_FragCoord.xy + fract(uFrame * 0.6180339) * 913.0) - 0.5; + col += g * uGrain * (1.0 - 0.6 * dot(col, vec3(0.33))); + col *= uFade; + gl_FragColor = vec4(col, 1.0); + } + `, + depthTest: false, + }); + + function pass(mat, target) { + quad.material = mat; + renderer.setRenderTarget(target); + renderer.render(qScene, qCam); + } + + function render(sceneObj, camera, params) { + renderer.setRenderTarget(scene); + renderer.setClearColor(0x000000, 1); + renderer.clear(); + renderer.render(sceneObj, camera); + + hudTex.needsUpdate = true; + mergeMat.uniforms.tScene.value = scene.texture; + mergeMat.uniforms.uHudGain.value = params.hudGain ?? 1.2; + pass(mergeMat, merged); + + let src = merged; + for (let i = 0; i < levels.length; i++) { + downMat.uniforms.tSrc.value = src.texture; + downMat.uniforms.uTexel.value.set(0.5 / src.width, 0.5 / src.height); + downMat.uniforms.uFirst.value = i === 0 ? 1 : 0; + downMat.uniforms.uThresh.value = params.bloomThresh ?? 0.55; + pass(downMat, levels[i].down); + src = levels[i].down; + } + let up = levels[levels.length - 1].down; + for (let i = levels.length - 2; i >= 0; i--) { + upMat.uniforms.tSrc.value = up.texture; + upMat.uniforms.tBase.value = levels[i].down.texture; + upMat.uniforms.uTexel.value.set(0.5 / up.width, 0.5 / up.height); + pass(upMat, levels[i].up); + up = levels[i].up; + } + + const u = finalMat.uniforms; + u.tSrc.value = merged.texture; + u.tBloom.value = up.texture; + for (const k of ['bloom', 'exposure', 'aberr', 'grain', 'vignette', 'glitch', 'scan', 'flash', 'fade']) { + const key = 'u' + k[0].toUpperCase() + k.slice(1); + if (params[k] !== undefined) u[key].value = params[k]; + } + u.uTime.value = params.time; + u.uFrame.value = params.frame; + pass(finalMat, null); + } + + return { render }; +} diff --git a/intro-sequence/src/timeline.js b/intro-sequence/src/timeline.js new file mode 100644 index 000000000..f9247be2e --- /dev/null +++ b/intro-sequence/src/timeline.js @@ -0,0 +1,135 @@ +// The film's clock. Everything visual and audible keys off these numbers, so the +// renderer and the score (audio/score.py reads cues.json, exported from here) +// stay frame-locked. Pure module: no DOM, safe to import from Node. + +export const FPS = 30; +export const DURATION = 72; +export const FRAMES = FPS * DURATION; + +export const T = { + // ACT 0 — the void + cursor: 0.9, + boot: 1.4, + collapse: 4.9, + seed: 5.35, + beat1: 6.1, + beat2: 7.35, + dishDraw: 6.3, + dishDone: 8.3, + + // ACT 1 — the culture grows; the growth turns out to be drawing the planet + growStart: 8.7, + coastReached: 13.4, + growEnd: 15.6, + substrate: 16.0, + + // ACT 2 — indexing the surface + indexStart: 17.0, + zWave: [17.0, 19.0, 20.8], // z3, z4, z5 quadtree waves + capStart: 18.3, + capRapid: 23.4, + capFull: 26.6, + indexed: 30.0, + + // ACT 3 — ingestion, one feed at a time, accelerating + air: 30.6, + sea: 32.2, + mil: 34.2, + orbit: 37.0, + quake: 40.2, + fire: 42.2, + wind: 44.0, + cable: 45.9, + cctv: 47.4, + cctvWall: 49.0, + rapid: 50.4, + cctvOut: 52.4, + + // ACT 4 — saturation; the twin outgrows the dish + saturate: 52.6, + arcs: 53.4, + twin: 55.2, + dishBreak: 58.4, + omni: 59.0, + cut: 60.5, + + // ACT 5 — resolve + title: 61.2, + eyeOpen: 62.0, + wordmark: 63.3, + tagline: 65.0, + online: 66.6, + fadeOut: 70.0, +}; + +// Feeds as they register in the stack (ACT 3). `key` matches a layer. +export const FEEDS = [ + { key: 'air', t: T.air, code: 'ADS-B', label: 'AIRCRAFT', src: 'OPENSKY NETWORK' }, + { key: 'sea', t: T.sea, code: 'AIS', label: 'VESSELS', src: 'KYSTVERKET · DIGITRAFFIC' }, + { key: 'mil', t: T.mil, code: 'ADS-B', label: 'MILITARY', src: 'ADSB.LOL · ADSB.FI' }, + { key: 'orbit', t: T.orbit, code: 'TLE', label: 'SATELLITES', src: 'CELESTRAK' }, + { key: 'quake', t: T.quake, code: 'SEIS', label: 'EARTHQUAKES', src: 'USGS' }, + { key: 'fire', t: T.fire, code: 'IR', label: 'ACTIVE FIRES', src: 'NASA FIRMS' }, + { key: 'wind', t: T.wind, code: 'NWP', label: 'WIND · CLOUD', src: 'NOAA GFS · GIBS' }, + { key: 'cable', t: T.cable, code: 'GEO', label: 'SUBSEA CABLES', src: 'TELEGEOGRAPHY' }, + { key: 'cctv', t: T.cctv, code: 'OPT', label: 'PUBLIC CAMERAS', src: '18 AGENCIES' }, +]; + +// Synthetic captures in ACT 2: the first few take the slow "enhance" path, the +// rest fire rapidly into the contact sheet, then one blows up full frame. +export const CAPTURE_SLOW = 3; +export function captureTimes(count) { + const out = []; + const slot = [T.capStart, T.capStart + 1.75, T.capStart + 3.35]; + for (let i = 0; i < Math.min(CAPTURE_SLOW, count); i++) out.push(slot[i]); + const rapidN = count - CAPTURE_SLOW; + // Accelerating rapid-fire: intervals shrink from ~0.32 s to ~0.11 s. + let t = T.capRapid; + for (let i = 0; i < rapidN; i++) { + out.push(t); + const k = i / Math.max(1, rapidN - 1); + t += 0.32 - 0.21 * k; + } + return out; +} + +// ------------------------------------------------------------------ helpers +export const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x)); +export const lerp = (a, b, t) => a + (b - a) * t; +export const ramp = (t, a, b) => clamp((t - a) / (b - a)); +export const smooth = (t, a, b) => { + const x = ramp(t, a, b); + return x * x * (3 - 2 * x); +}; +export const easeInOut = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2); +export const easeOut = (x) => 1 - Math.pow(1 - x, 3); +export const easeIn = (x) => x * x * x; +export const expoOut = (x) => (x >= 1 ? 1 : 1 - Math.pow(2, -10 * x)); +export const expoInOut = (x) => + x <= 0 ? 0 : x >= 1 ? 1 : x < 0.5 ? Math.pow(2, 20 * x - 10) / 2 : (2 - Math.pow(2, -20 * x + 10)) / 2; + +// Window: 0 before a, ramps up over `inDur`, holds, ramps down to 0 ending at b. +export function win(t, a, b, inDur = 0.3, outDur = 0.3) { + return Math.min(smooth(t, a, a + inDur), 1 - smooth(t, b - outDur, b)); +} + +export function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function hash1(n) { + const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; + return s - Math.floor(s); +} + +// Counter that eases toward `to` and keeps visibly ticking while it does. +export function count(t, a, b, from, to, curve = expoOut) { + return Math.round(lerp(from, to, curve(ramp(t, a, b)))); +} diff --git a/intro-sequence/tools/export-cues.mjs b/intro-sequence/tools/export-cues.mjs new file mode 100644 index 000000000..5e951245d --- /dev/null +++ b/intro-sequence/tools/export-cues.mjs @@ -0,0 +1,40 @@ +// Export the film clock for the score: every visual event the audio locks to. +import fs from 'node:fs'; +import { T, FEEDS, DURATION, captureTimes, CAPTURE_SLOW } from '../src/timeline.js'; +import { angDist } from '../src/geo.js'; + +const heroes = JSON.parse(fs.readFileSync('imagery/hero/index.json', 'utf8')); +const caps = captureTimes(heroes.length).map((t, i) => ({ t, slow: i < CAPTURE_SLOW, lon: heroes[i].lon })); + +// tile reveal times — same formula as the globe shader and HUD +const f = (v) => v - Math.floor(v); +const hash3 = (x, y, z) => { + const px = f(x * 0.3183099 + 0.71) * 17, py = f(y * 0.3183099 + 0.113) * 17, pz = f(z * 0.3183099 + 0.419) * 17; + return f(px * py * pz * (px + py + pz)); +}; +const tiles = []; +[[3, T.zWave[0], 0.35], [4, T.zWave[1], 0.3], [5, T.zWave[2], 0.25]].forEach(([z, wave, jit]) => { + const n = 2 ** z; + for (let x = 0; x < n; x++) + for (let y = 0; y < n; y++) { + const lon = ((x + 0.5) / n) * 360 - 180; + const lat = (Math.atan(Math.sinh(Math.PI * (1 - 2 * ((y + 0.5) / n)))) * 180) / Math.PI; + // only the hemisphere facing the camera is heard + const d = angDist(0, 0, lon, lat); + tiles.push({ t: wave + (d / Math.PI) * 2.6 + hash3(x, y, z) * jit, z, pan: Math.max(-1, Math.min(1, lon / 90)), vis: angDist(10, 8, lon, lat) < 1.4 }); + } +}); + +// boot console keystrokes +const boot = []; +[['GEV://OBSERVER-1 rev 0.2.1', T.boot, 55], ['allocating substrate ........ 1.0000 OK', T.boot + 0.75, 55], ['loading priors .............. NONE', T.boot + 1.35, 55], ['> seed --origin 0.0000°N 0.0000°E', T.boot + 2.1, 26]].forEach(([s, st, cps], line) => { + for (let i = 0; i < s.length; i++) { + const t = st + i / cps; + if (t < T.collapse) boot.push({ t, ch: s[i], line }); + } +}); + +const status = [53.4, 54.5, 55.6, 56.7, 57.7]; +const rapid = Array.from({ length: 7 }, (_, i) => T.rapid + i * 0.22); +fs.writeFileSync('audio/cues.json', JSON.stringify({ T, DURATION, feeds: FEEDS.map((x) => ({ key: x.key, t: x.t })), caps, tiles, boot, status, rapid }, null, 1)); +console.log('cues:', caps.length, 'captures,', tiles.length, 'tiles,', boot.length, 'keystrokes'); diff --git a/intro-sequence/tools/fetch/cctv_count.py b/intro-sequence/tools/fetch/cctv_count.py new file mode 100644 index 000000000..b23646b75 --- /dev/null +++ b/intro-sequence/tools/fetch/cctv_count.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +"""Count distinct cameras in every keyless CCTV catalog the app supports. + +Fetches each source's FULL public catalog (no anchor selection, no +DEFAULT_*_MAX_SOURCES caps) and counts distinct cameras two ways: + raw - distinct camera ids listed in the catalog at all + valid - distinct ids passing the app's own validity filters (in service / + online / official still or stream URL / plausible coordinates), + i.e. what server/providers/cctv/* would accept before capping. +The headline number written to data/extra_counts.json is the conservative +`valid` count. Sources that fail are skipped, never estimated. + +Usage: python3 -I cctv_count.py [] +""" +import json, math, re, sys, time +from concurrent.futures import ThreadPoolExecutor +from urllib.parse import urlparse, parse_qs +import requests + +UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' +REPO = '/home/user/gods-eye-view' + + +def sess(): + s = requests.Session() + s.headers.update({'User-Agent': UA, 'Digitraffic-User': 'gods-eye-view'}) + return s + + +def get(url, timeout=60, **kw): + r = sess().get(url, timeout=timeout, **kw) + r.raise_for_status() + return r + + +def fnum(v): + try: + f = float(v) + return f if math.isfinite(f) else None + except (TypeError, ValueError): + return None + + +def inbox(lat, lon, la0, la1, lo0, lo1): + return lat is not None and lon is not None and la0 <= lat <= la1 and lo0 <= lon <= lo1 + + +SRC = {} + + +def source(label, country): + def deco(fn): + SRC[label] = (fn, country) + return fn + return deco + + +@source('TfL JamCams', 'United Kingdom') +def tfl(): + d = get('https://api.tfl.gov.uk/Place/Type/JamCam').json() + raw, ok = set(), set() + for p in d: + raw.add(p['id']) + props = {a['key']: a['value'] for a in p.get('additionalProperties', [])} + if str(props.get('available')).lower() != 'true': + continue + if not (props.get('imageUrl') or '').startswith('https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/'): + continue + if fnum(p.get('lat')) is None or fnum(p.get('lon')) is None: + continue + ok.add(p['id']) + return raw, ok, 'one Place per camera; available=true + official S3 still' + + +@source('NYC DOT', 'United States') +def nyc(): + d = get('https://webcams.nyctmc.org/api/cameras').json() + raw = {c['id'] for c in d} + ok = {c['id'] for c in d if str(c.get('isOnline')).strip().lower() == 'true' + and inbox(fnum(c.get('latitude')), fnum(c.get('longitude')), 40.4, 41.0, -74.3, -73.6)} + return raw, ok, 'one row per camera; isOnline=true' + + +@source('Caltrans', 'United States') +def caltrans(): + raw, ok = set(), set() + notes = [] + + def one(dist): + err = None + for attempt in range(5): # cwwp2 resets connections under parallel load + try: + return dist, get(f'https://cwwp2.dot.ca.gov/data/d{dist}/cctv/cctvStatusD{dist:02d}.json').json() + except Exception as e: + err = e + time.sleep(1 + attempt) + return dist, err + with ThreadPoolExecutor(3) as ex: + res = list(ex.map(one, range(1, 13))) + for dist, d in res: + if isinstance(d, Exception): + notes.append(f'D{dist} failed: {str(d)[:60]}') + continue + for i, row in enumerate(d.get('data', [])): + c = row.get('cctv') or {} + loc = c.get('location') or {} + name = (loc.get('locationName') or '').strip() + m = re.match(r'^([A-Za-z0-9_-]+)\s*--', name) + key = f"d{dist}-{(m.group(1) if m else c.get('index') or name or i)}".lower() + raw.add(key) + if str(c.get('inService')).lower() != 'true': + continue + if fnum(loc.get('latitude')) is None or fnum(loc.get('longitude')) is None: + continue + url = ((c.get('imageData') or {}).get('static') or {}).get('currentImageURL') or '' + if not url.startswith('https://cwwp2.dot.ca.gov/'): + continue + ok.add(key) + return raw, ok, 'all 12 districts; inService=true + cwwp2 still. ' + '; '.join(notes) + + +@source('WSDOT', 'United States') +def wsdot(): + d = json.loads(get('https://data.wsdot.wa.gov/travelcenter/Cameras.json').content.decode('cp1252')) + raw, ok = set(), set() + for f in d['features']: + a = f.get('attributes') or {} + cid = a.get('CameraID') + if not isinstance(cid, int) or cid <= 0: + continue + raw.add(cid) + if (a.get('ImageURL') or '').strip().startswith('https://images.wsdot.wa.gov/') and fnum((f.get('geometry') or {}).get('x')) is not None: + ok.add(cid) + return raw, ok, 'one feature per CameraID; official images.wsdot.wa.gov still' + + +@source('King County DOT', 'United States') +def kingco(): + d = get('https://services.arcgis.com/Ej0PsM5Aw677QF1W/arcgis/rest/services/TRAFFICCAMERA_POINT_2029/FeatureServer/0/query?where=1%3D1&outFields=AssetID,Location,ImageURL,Live,Model,Manufacturer,Owner,CurrentStatus&f=geojson').json() + raw, ok = set(), set() + for f in d['features']: + p = f.get('properties') or {} + aid = p.get('AssetID') + if not isinstance(aid, int) or aid <= 0: + continue + raw.add(aid) + if p.get('Live') != 1 or str(p.get('CurrentStatus') or '').strip() != 'Active': + continue + if str(p.get('Owner') or '').strip() == 'WSDOT': + continue # WSDOT-owned rows are WSDOT cameras (counted under WSDOT) + url = (p.get('ImageURL') or '').strip().replace('http://info.kingcounty.gov/', 'https://info.kingcounty.gov/') + if not url.startswith('https://info.kingcounty.gov/'): + continue + ok.add(aid) + note = 'Live=1 + Active, WSDOT-owned rows excluded (avoid double count)' + if d.get('exceededTransferLimit') or (d.get('properties') or {}).get('exceededTransferLimit'): + note += ' WARNING: transfer limit exceeded' + return raw, ok, note + + +@source('DriveBC', 'Canada') +def drivebc(): + d = get('https://www.drivebc.ca/api/webcams/').json() + raw, ok = set(), set() + for c in d: + cid = c.get('id') + if not isinstance(cid, int) or cid <= 0: + continue + raw.add(cid) + if c.get('is_on') is not True or c.get('should_appear') is not True: + continue + coords = (c.get('location') or {}).get('coordinates') or [None, None] + if not inbox(fnum(coords[1]), fnum(coords[0]), 48.0, 60.5, -139.5, -113.5): + continue + ok.add(cid) + return raw, ok, 'one webcam per id; is_on + should_appear' + + +@source('Fintraffic Digitraffic', 'Finland') +def fintraffic(): + d = get('https://tie.digitraffic.fi/api/weathercam/v1/stations', headers={'Accept-Encoding': 'gzip'}).json() + raw, ok, presets = set(), set(), 0 + for f in d['features']: + p = f.get('properties') or {} + sid = str(p.get('id') or '') + if not sid: + continue + raw.add(sid) + if str(p.get('collectionStatus') or '').upper() != 'GATHERING': + continue + live = [pr for pr in p.get('presets') or [] if pr.get('inCollection') is True and re.fullmatch(r'C\d{7}', str(pr.get('id'))) and str(pr['id']).startswith(sid)] + if live: + ok.add(sid) + presets += len(live) + return raw, ok, f'counted per STATION (camera site), not per preset view; {presets} live presets (the app treats each preset as a camera)' + + +@source('Statens vegvesen', 'Norway') +def vegvesen(): + d = get('https://ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items?f=application/geo%2Bjson&limit=5000', + headers={'Accept': 'application/geo+json,application/json'}).json() + raw, ok = set(), set() + for f in d['features']: + p = f.get('properties') or {} + rid = str(p.get('cameraId') or '').strip() + if not re.fullmatch(r'[A-Za-z0-9_-]{1,40}', rid): + continue + raw.add(rid) + av = str(p.get('status.stillImageAvailability') or '').strip() + if av and av != 'videoOrImagesAvailable': + continue + if (p.get('stillImageUrl') or '') != f'https://kamera.atlas.vegvesen.no/api/images/{rid}': + continue + c = (f.get('geometry') or {}).get('coordinates') or [None, None] + if not inbox(fnum(c[1]), fnum(c[0]), 57.5, 71.5, 4.0, 31.5): + continue + ok.add(rid) + note = f"numberMatched={d.get('numberMatched')} numberReturned={d.get('numberReturned')}" + return raw, ok, 'stillImageAvailability ok + official still; ' + note + + +@source('City of Tallinn', 'Estonia') +def tallinn(): + d = json.load(open(f'{REPO}/config/cctv_sources.tallinn.json')) + raw, ok = set(), set() + for c in d: + cid = str(c.get('id') or '').strip() + if not cid: + continue + raw.add(cid) + url = c.get('snapshotUrl') or c.get('url') or '' + if inbox(fnum(c.get('lat')), fnum(c.get('lon')), 59.2, 59.7, 24.3, 25.4) and url.startswith('https://ristmikud.tallinn.ee/'): + ok.add(url) + return raw, ok, 'curated repo catalog (config/cctv_sources.tallinn.json) of ristmikud.tallinn.ee stills; distinct frame URLs' + + +@source('Transpordiamet Tarktee', 'Estonia') +def tarktee(): + lx = get('https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraLocations', headers={'Accept': 'application/xml'}).text + ix = get('https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages', headers={'Accept': 'application/xml'}).text + locs = {} + for m in re.finditer(r']*>([\s\S]*?)', lx): + la = re.search(r'\s*(-?\d+(?:\.\d+)?)\s*', m.group(2), re.I) + lo = re.search(r'\s*(-?\d+(?:\.\d+)?)\s*', m.group(2), re.I) + if la and lo: + locs[m.group(1)] = (float(la.group(1)), float(lo.group(1))) + imgs = {} + for m in re.finditer(r']*>([\s\S]*?)', ix): + r = re.search(r']*\bid="([^"]+)"', m.group(1), re.I) + u = re.search(r'\s*([^<\s]+)\s*', m.group(1), re.I) + if r and u: + imgs[r.group(1)] = u.group(1) + raw = set(locs) | set(imgs) + ok = set() + for lid, (lat, lon) in locs.items(): + u = imgs.get(lid, '') + if not u.startswith('https://tarktee.transpordiamet.ee/images/'): + continue + if not inbox(lat, lon, 57.4, 59.9, 21.5, 28.4): + continue + n = re.search(r'/images/(\d+)/', u) + ok.add(n.group(1) if n else lid) + return raw, ok, 'DATEX location joined to image; distinct camera numbers' + + +@source('Via Lietuva eismoinfo', 'Lithuania') +def lithuania(): + rows = get('https://eismoinfo.lt/eismoinfo-backend/camera-info-table', headers={'Accept': 'application/json'}).json() + layers = get('https://eismoinfo.lt/eismoinfo-backend/layer-static-features/VKR', headers={'Accept': 'application/json'}).json() + feats = {} + for L in layers: + if L.get('layer') == 'VKR': + feats = {str(f.get('id')): f for f in L.get('features') or [] if f} + raw, ok = set(), set() + for r in rows: + rid = str(r.get('id') or '') + if not re.fullmatch(r'[1-9]\d{0,8}', rid): + continue + raw.add(rid) + f = feats.get(rid) + try: + lat, lon = f['points'][0]['point'] + except Exception: + continue + if not inbox(fnum(lat), fnum(lon), 53.8, 56.5, 20.8, 26.9): + continue + u = urlparse(str(r.get('image') or '')) + if f'{u.scheme}://{u.netloc}' != 'https://eismoinfo.lt' or u.path != '/eismoinfo-backend/image-provider/camera/last' or parse_qs(u.query).get('id', [''])[0] != rid: + continue + ok.add(rid) + return raw, ok, 'camera table joined to VKR layer; official still endpoint' + + +@source('Live Traffic NSW', 'Australia') +def nsw(): + d = get('https://data.livetraffic.com/cameras/traffic-cam.json').json() + raw, ok = set(), set() + for f in d['features']: + p = f.get('properties') or {} + fid = str(f.get('id') or p.get('id') or p.get('title') or '').strip() + if not fid: + continue + raw.add(fid) + href = (p.get('href') or '').replace('http://', 'https://') + c = (f.get('geometry') or {}).get('coordinates') or [None, None] + if href.startswith('https://webcams.transport.nsw.gov.au/') and inbox(fnum(c[1]), fnum(c[0]), -38.0, -28.0, 140.0, 154.5): + ok.add(fid) + return raw, ok, 'one feature per camera; official webcams.transport.nsw.gov.au still' + + +@source('City of Calgary', 'Canada') +def calgary(): + d = get('https://data.calgary.ca/resource/k7p9-kppz.json?$limit=5000').json() + raw, ok = set(), set() + for c in d: + url = ((c.get('camera_url') or {}).get('url') or '').strip() + if not url: + continue + u = re.sub(r'^http://', 'https://', url) + m = re.search(r'loc(\d+)\.jpg$', urlparse(u).path, re.I) + key = m.group(1) if m else urlparse(u).path + raw.add(key) + coords = (c.get('point') or {}).get('coordinates') or [None, None] + if u.startswith('https://trafficcam.calgary.ca/') and inbox(fnum(coords[1]), fnum(coords[0]), 50.7, 51.4, -114.5, -113.7): + ok.add(key) + return raw, ok, f'{len(d)} rows ($limit=5000); distinct trafficcam.calgary.ca image ids' + + +@source('City of Toronto RESCU', 'Canada') +def toronto(): + t = get('https://opendata.toronto.ca/transportation/tmc/rescucameraimages/Data/tmcearthcameras.json').text + d = json.loads(t[t.index('(') + 1: t.rindex(')')]) + raw, ok = set(), set() + for c in d['Data']: + n = str(c.get('Number') or '').strip() + if not n: + continue + raw.add(n) + if re.fullmatch(r'\d{1,6}', n) and inbox(fnum(c.get('Latitude')), fnum(c.get('Longitude')), 43.4, 44.0, -79.8, -79.0): + ok.add(n) + return raw, ok, 'one row per numbered RESCU camera' + + +@source('Servei Català de Trànsit', 'Spain') +def catalonia(): + x = get('https://www.gencat.cat/transit/opendata/cameres.xml').text + hosts = {'mct.gencat.cat': '/mct2bo/', 'www.bcn.cat': '/transit/imatges/', 'emap.terrassa.cat': '/it_terrassa/'} + raw, ok = set(), set() + for m in re.finditer(r'([\s\S]*?)', x): + b = m.group(1) + co = re.search(r']*>([^<]+)', b) + lk = re.search(r'([^<]*)', b) + if not co or not lk: + continue + link = lk.group(1).replace('&', '&').strip() + u = urlparse(link) + cam = parse_qs(u.query).get('sctidcam', [''])[0] + key = f'sct:{cam}' if u.hostname == 'mct.gencat.cat' and cam else f'{u.hostname}{u.path}' + raw.add(key) + lon, lat = (fnum(v) for v in co.group(1).split(',')[:2]) + if not inbox(lat, lon, 40.4, 42.95, 0.1, 3.4): + continue + d = hosts.get(u.hostname or '') + if not d or not u.path.startswith(d) or u.username or u.password or u.port: + continue + f = u.path[len(d):] + if not f or '/' in f: + continue + if u.hostname == 'mct.gencat.cat' and (f != 'RenderService' or not re.fullmatch(r'[a-z0-9]+\.(gif|jpe?g)', cam, re.I)): + continue + ok.add(key) + return raw, ok, 'SCT + Barcelona IMI + Terrassa; keyed by frame file (feed fid is unstable)' + + +@source('City of Austin', 'United States') +def austin(): + d = get('https://data.austintexas.gov/api/views/b4k4-adkb/rows.json?accessType=DOWNLOAD').json() + cols = [c['fieldName'] for c in d['meta']['view']['columns']] + raw, ok = set(), set() + for row in d['data']: + r = dict(zip(cols, row)) + cid = str(r.get('camera_id') or '').strip() + if not cid: + continue + raw.add(cid) + st = (r.get('camera_status') or '').strip().upper() + if st and st != 'TURNED_ON': + continue + m = re.match(r'POINT \(([-\d.]+) ([-\d.]+)\)', r.get('location') or '') + if not m or not inbox(fnum(m.group(2)), fnum(m.group(1)), 29.9, 30.8, -98.2, -97.3): + continue + ok.add(cid) + return raw, ok, 'camera_status TURNED_ON' + + +TXDOT = 'ABL AMA ATL AUS BMT BWD BRY CHS CRP DAL ELP FTW HOU LRD LBB LFK ODA PAR PHR SJT SAT TYL WAC WFS YKM'.split() + + +@source('TxDOT ITS', 'United States') +def txdot(): + def one(code): + for attempt in range(2): + try: + return code, get(f'https://its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict?districtCode={code}', + headers={'Accept': 'application/json'}, timeout=60).json() + except Exception as e: + err = e + return code, err + with ThreadPoolExecutor(8) as ex: + res = list(ex.map(one, TXDOT)) + raw, ok, notes, per = set(), set(), [], {} + for code, d in res: + if isinstance(d, Exception): + notes.append(f'{code} failed: {str(d)[:50]}') + continue + n = 0 + for rows in (d.get('roadwayCctvStatuses') or {}).values(): + for r in rows or []: + icd = str(r.get('icd_Id') or '').strip() + if not icd: + continue + raw.add(icd) + if r.get('statusDescription') != 'Device Online' or r.get('hasSnapshot') is False: + continue + lat, lon = r.get('latitude'), r.get('longitude') + if not (isinstance(lat, (int, float)) and isinstance(lon, (int, float)) and inbox(lat, lon, 25.5, 36.7, -106.8, -93.3)): + continue + if icd not in ok: + n += 1 + ok.add(icd) + per[code] = n + return raw, ok, f'all 25 districts; Device Online + snapshot; per-district {per}. ' + '; '.join(notes) + + +@source('Ontario 511', 'Canada') +def ontario(): + d = get('https://511on.ca/api/v2/get/cameras?format=json&lang=en', headers={'Accept': 'application/json'}).json() + raw, ok, views = set(), set(), 0 + for r in d: + rid = str(r.get('Id') or r.get('id') or '').strip() + if not rid: + continue + raw.add(rid) + if not inbox(fnum(r.get('Latitude')), fnum(r.get('Longitude')), 41.0, 57.5, -95.6, -74.0): + continue + good = [] + for v in r.get('Views') or r.get('views') or []: + if str(v.get('Status') or '').strip().lower() != 'enabled': + continue + u = urlparse(str(v.get('Url') or '').strip()) + if u.scheme == 'https' and (u.hostname == '511on.ca' or (u.hostname or '').endswith('.traveliq.co')) and re.fullmatch(r'/map/Cctv/[A-Za-z0-9_.-]+', u.path): + good.append(v) + if good: + ok.add(rid) + views += len(good) + return raw, ok, f'counted per camera row, not per view; {views} enabled views' + + +@source('DelDOT', 'United States') +def deldot(): + d = get('https://tmc.deldot.gov/json/videocamera.json', headers={'Accept': 'application/json'}).json() + raw, ok = set(), set() + for r in d.get('videoCameras') or []: + cid = str(r.get('id') or '').strip() + if not cid: + continue + raw.add(cid) + if str(r.get('status')).lower() != 'active': + continue + if not inbox(fnum(r.get('lat')), fnum(r.get('lon')), 38.4, 39.9, -75.85, -75.0): + continue + u = urlparse(str((r.get('urls') or {}).get('m3u8s') or '')) + if u.scheme != 'https' or u.hostname != 'video.deldot.gov' or u.port not in (None, 443) or u.username or not re.fullmatch(r'/live/[A-Za-z0-9_.-]+/playlist\.m3u8', u.path): + continue + if not re.fullmatch(r'[A-Za-z0-9_-]{1,80}', cid): + continue + ok.add(cid) + return raw, ok, 'status active + official HLS stream' + + +def run(label): + fn, country = SRC[label] + t0 = time.time() + try: + raw, ok, note = fn() + if not ok: + raise RuntimeError('catalog parsed to zero cameras') + return label, dict(country=country, raw=len(raw), valid=len(ok), note=note, secs=round(time.time() - t0, 1)) + except Exception as e: + return label, dict(country=country, error=f'{type(e).__name__}: {str(e)[:200]}', secs=round(time.time() - t0, 1)) + + +if __name__ == '__main__': + out = sys.argv[1] + detail = sys.argv[2] if len(sys.argv) > 2 else None + with ThreadPoolExecutor(8) as ex: + results = dict(ex.map(run, list(SRC))) + counted = {k: v for k, v in results.items() if 'valid' in v} + for k, v in results.items(): + print(f"{k:28s} {v.get('country', ''):15s} raw={v.get('raw', '-'):>6} valid={v.get('valid', '-'):>6} {v.get('error') or v.get('note')}") + total = sum(v['valid'] for v in counted.values()) + res = { + 'cctv_catalog': total, + 'cctv_sources': {k: v['valid'] for k, v in counted.items()}, + 'cctv_countries': len({v['country'] for v in counted.values()}), + 'fetched_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + } + print('TOTAL valid', total, 'raw', sum(v['raw'] for v in counted.values()), 'countries', res['cctv_countries']) + json.dump(res, open(out, 'w'), ensure_ascii=False, indent=2) + if detail: + json.dump(results, open(detail, 'w'), ensure_ascii=False, indent=2) diff --git a/intro-sequence/tools/fetch/cctv_fetch.py b/intro-sequence/tools/fetch/cctv_fetch.py new file mode 100644 index 000000000..9757819f7 --- /dev/null +++ b/intro-sequence/tools/fetch/cctv_fetch.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +"""Download real current still frames from keyless public traffic-camera feeds. + +Stage 1 (this script): build candidate lists from each source's public catalog +(endpoints mirror server/providers/cctv/*), sample a spread of cameras, download +frames, compute quality metrics, write /frames/*.jpg + /candidates.json. +Stage 2 (cctv_select.py) filters, crops to 640x360 and writes cctv/index.json. + +Usage: python3 -I cctv_fetch.py +""" +import base64, hashlib, io, json, math, random, re, sys, time +import xml.etree.ElementTree as ET +from concurrent.futures import ThreadPoolExecutor +import requests +from PIL import Image, ImageStat, ImageFilter + +WORK = sys.argv[1] +import os +os.makedirs(f'{WORK}/frames', exist_ok=True) +UA_BROWSER = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' +S = requests.Session() +S.headers.update({'User-Agent': UA_BROWSER, 'Digitraffic-User': 'gods-eye-intro'}) +rng = random.Random(42) + + +def get(url, **kw): + kw.setdefault('timeout', 40) + r = S.get(url, **kw) + r.raise_for_status() + return r + + +def C(source, name, city, country, lat, lon, url, kind='image'): + return dict(source=source, name=name.strip(), city=city, country=country, lat=round(float(lat), 5), lon=round(float(lon), 5), url=url, kind=kind) + + +def nearest(cands, anchors, n): + """Spread a pick across anchors: round-robin, nearest cameras to each anchor first, randomized a bit.""" + buckets = [] + for (alat, alon, label) in anchors: + b = sorted(cands, key=lambda c: (c['lat'] - alat) ** 2 + ((c['lon'] - alon) * math.cos(math.radians(alat))) ** 2)[:max(n * 3, 12)] + rng.shuffle(b) + for c in b: + c['city'] = label if c.get('city') in (None, '', '?') else c['city'] + buckets.append(b) + out, seen = [], set() + while len(out) < n and any(buckets): + for b in buckets: + while b: + c = b.pop() + if c['url'] not in seen: + seen.add(c['url']); out.append(c); break + return out[:n] + + +def sample(cands, n): + cands = list(cands) + rng.shuffle(cands) + return cands[:n] + + +SOURCES = {} + + +def source(fn): + SOURCES[fn.__name__] = fn + return fn + + +@source +def tfl(n): + d = get('https://api.tfl.gov.uk/Place/Type/JamCam').json() + out = [] + for p in d: + props = {a['key']: a['value'] for a in p.get('additionalProperties', [])} + if props.get('available') != 'true' or not props.get('imageUrl'): + continue + out.append(C('TfL JamCams', p['commonName'], 'London', 'United Kingdom', p['lat'], p['lon'], props['imageUrl'])) + return nearest(out, [(51.5074, -0.1278, 'London')], n) + + +@source +def nyc(n): + d = get('https://webcams.nyctmc.org/api/cameras').json() + out = [C('NYC DOT', c['name'], f"New York ({c.get('area')})", 'United States', c['latitude'], c['longitude'], + f"https://webcams.nyctmc.org/api/cameras/{c['id']}/image") for c in d if str(c.get('isOnline')).lower() == 'true'] + return nearest(out, [(40.758, -73.9855, 'New York'), (40.7128, -74.006, 'New York'), (40.6782, -73.9442, 'New York'), (40.7282, -73.7949, 'New York')], n) + + +@source +def caltrans(n): + out = [] + for dist, city in [(4, 'San Francisco Bay Area'), (7, 'Los Angeles'), (11, 'San Diego'), (3, 'Sacramento')]: + try: + d = get(f'https://cwwp2.dot.ca.gov/data/d{dist}/cctv/cctvStatusD{dist:02d}.json').json() + except Exception as e: + print('caltrans', dist, e); continue + for row in d['data']: + c = row['cctv'] + url = (c.get('imageData', {}).get('static') or {}).get('currentImageURL') + if c.get('inService') != 'true' or not url: + continue + loc = c['location'] + out.append(C('Caltrans CCTV', loc['locationName'], loc.get('nearbyPlace') or city, 'United States', loc['latitude'], loc['longitude'], url)) + return nearest(out, [(37.7793, -122.4193, 'San Francisco'), (34.0537, -118.2428, 'Los Angeles'), (32.7157, -117.1611, 'San Diego'), (38.5816, -121.4944, 'Sacramento')], n) + + +@source +def wsdot(n): + d = json.loads(get('https://data.wsdot.wa.gov/travelcenter/Cameras.json').content.decode('cp1252')) + out = [] + for f in d['features']: + a, g = f['attributes'], f['geometry'] + url = a.get('ImageURL') or '' + if not url.startswith('https://images.wsdot.wa.gov/'): + continue + lon = g['x'] / 6378137 * 180 / math.pi + lat = (2 * math.atan(math.exp(g['y'] / 6378137)) - math.pi / 2) * 180 / math.pi + out.append(C('WSDOT', a['CameraTitle'], '', 'United States', lat, lon, url)) + return nearest(out, [(47.6062, -122.3321, 'Seattle'), (47.6588, -117.426, 'Spokane')], n) + + +@source +def kingco(n): + d = get('https://services.arcgis.com/Ej0PsM5Aw677QF1W/arcgis/rest/services/TRAFFICCAMERA_POINT_2029/FeatureServer/0/query?where=1%3D1&outFields=AssetID,Location,ImageURL,Live,Model,Manufacturer,Owner,CurrentStatus&f=geojson').json() + out = [] + for f in d['features']: + p = f['properties'] + url = (p.get('ImageURL') or '').replace('http://info.kingcounty.gov/', 'https://info.kingcounty.gov/') + if p.get('CurrentStatus') != 'Active' or not url.startswith('https://info.kingcounty.gov/'): + continue + lon, lat = f['geometry']['coordinates'][:2] + out.append(C('King County DOT', p['Location'], 'King County, WA', 'United States', lat, lon, url)) + return sample(out, n) + + +@source +def drivebc(n): + d = get('https://www.drivebc.ca/api/webcams/').json() + out = [] + for c in d: + if not c.get('is_on') or c.get('marked_stale') or not c.get('should_appear', True): + continue + lon, lat = c['location']['coordinates'][:2] + out.append(C('DriveBC', c['name'], c.get('region_name') or 'British Columbia', 'Canada', lat, lon, f"https://www.drivebc.ca/images/{c['id']}.jpg")) + return nearest(out, [(49.2827, -123.1207, 'Vancouver'), (48.4284, -123.3656, 'Victoria'), (50.1163, -122.9574, 'Whistler')], n) + + +@source +def calgary(n): + d = get('https://data.calgary.ca/resource/k7p9-kppz.json?$limit=500').json() + out = [] + for c in d: + url = (c.get('camera_url') or {}).get('url', '').replace('http://', 'https://') + if not url.startswith('https://trafficcam.calgary.ca/'): + continue + lon, lat = c['point']['coordinates'][:2] + out.append(C('City of Calgary', c['camera_location'], 'Calgary', 'Canada', lat, lon, url)) + return nearest(out, [(51.0461, -114.0626, 'Calgary')], n) + + +@source +def toronto(n): + t = get('https://opendata.toronto.ca/transportation/tmc/rescucameraimages/Data/tmcearthcameras.json').text + d = json.loads(t[t.index('(') + 1: t.rindex(')')]) + out = [C('City of Toronto RESCU', c['Name'].title(), 'Toronto', 'Canada', c['Latitude'], c['Longitude'], + f"https://opendata.toronto.ca/transportation/tmc/rescucameraimages/CameraImages/loc{c['Number']}.jpg") + for c in d['Data'] if re.fullmatch(r'\d+', str(c['Number']))] + return nearest(out, [(43.6532, -79.3832, 'Toronto')], n) + + +@source +def txdot(n): + out = [] + for code, city in [('HOU', 'Houston'), ('DAL', 'Dallas'), ('AUS', 'Austin'), ('SAT', 'San Antonio'), ('ELP', 'El Paso')]: + try: + d = get(f'https://its.txdot.gov/its/DistrictIts/GetCctvStatusListByDistrict?districtCode={code}').json() + except Exception as e: + print('txdot', code, e); continue + seen = set() + for rows in d['roadwayCctvStatuses'].values(): + for r in rows: + if r.get('statusDescription') != 'Device Online' or r.get('hasSnapshot') is False or r['icd_Id'] in seen: + continue + if not isinstance(r.get('latitude'), (int, float)) or not r['latitude']: + continue + seen.add(r['icd_Id']) + url = 'https://its.txdot.gov/its/DistrictIts/GetCctvSnapshotByIcdId?' + requests.compat.urlencode({'icdId': r['icd_Id'], 'districtCode': code}) + out.append(C('TxDOT ITS', r['name'], city, 'United States', r['latitude'], r['longitude'], url, kind='txdot')) + return nearest(out, [(29.7604, -95.3698, 'Houston'), (32.7767, -96.797, 'Dallas'), (29.4241, -98.4936, 'San Antonio'), (31.7619, -106.485, 'El Paso')], n) + + +@source +def austin(n): + d = get('https://data.austintexas.gov/api/views/b4k4-adkb/rows.json?accessType=DOWNLOAD').json() + cols = [c['fieldName'] for c in d['meta']['view']['columns']] + out = [] + for row in d['data']: + r = dict(zip(cols, row)) + if (r.get('camera_status') or '').strip().upper() != 'TURNED_ON': + continue + m = re.match(r'POINT \(([-\d.]+) ([-\d.]+)\)', r.get('location') or '') + if not m or not r.get('camera_id'): + continue + out.append(C('City of Austin', (r.get('location_name') or '').strip(), 'Austin', 'United States', m.group(2), m.group(1), + f"https://cctv.austinmobility.io/image/{r['camera_id']}.jpg")) + return nearest(out, [(30.2672, -97.7431, 'Austin')], n) + + +@source +def fintraffic(n): + d = get('https://tie.digitraffic.fi/api/weathercam/v1/stations').json() + out = [] + for f in d['features']: + p = f['properties'] + if p.get('collectionStatus') != 'GATHERING': + continue + lon, lat = f['geometry']['coordinates'][:2] + for pr in p.get('presets', []): + if pr.get('inCollection'): + out.append(C('Fintraffic Digitraffic', p['name'].replace('_', ' '), '', 'Finland', lat, lon, f"https://weathercam.digitraffic.fi/{pr['id']}.jpg")) + break + return nearest(out, [(60.1699, 24.9384, 'Helsinki'), (61.4978, 23.761, 'Tampere'), (65.0121, 25.4651, 'Oulu'), (66.5039, 25.7294, 'Rovaniemi'), (60.4518, 22.2666, 'Turku')], n) + + +@source +def vegvesen(n): + d = get('https://ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items?f=application/geo%2Bjson&limit=5000').json() + out = [] + for f in d['features']: + p = f['properties'] + url = p.get('stillImageUrl') or '' + if not url.startswith('https://kamera.atlas.vegvesen.no/api/images/'): + continue + lon, lat = f['geometry']['coordinates'][:2] + name = ' - '.join(x for x in [p.get('description'), p.get('roadNumber')] if x) + out.append(C('Statens vegvesen', name, '', 'Norway', lat, lon, url)) + return nearest(out, [(59.9139, 10.7522, 'Oslo'), (60.3913, 5.3221, 'Bergen'), (63.4305, 10.3951, 'Trondheim'), (69.6492, 18.9553, 'Tromsø'), (58.97, 5.7331, 'Stavanger')], n) + + +@source +def tallinn(n): + d = json.load(open('/home/user/gods-eye-view/config/cctv_sources.tallinn.json')) + out = [C('City of Tallinn', c['name'], 'Tallinn', 'Estonia', c['lat'], c['lon'], c['snapshotUrl']) for c in d if c.get('snapshotUrl', '').startswith('https://ristmikud.tallinn.ee/')] + return sample(out, n) + + +@source +def tarktee(n): + ns = {'d': 'http://datex2.eu/schema/2/2_0'} + loc = ET.fromstring(get('https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraLocations').content) + locs = {} + for pl in loc.iter('{http://datex2.eu/schema/2/2_0}predefinedLocation'): + lat = pl.find('.//d:latitude', ns); lon = pl.find('.//d:longitude', ns); nm = pl.find('.//d:value', ns) + if lat is not None and lon is not None: + locs[pl.get('id')] = (float(lat.text), float(lon.text), nm.text if nm is not None else '') + img = ET.fromstring(get('https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages').content) + out = [] + for tv in img.iter('{http://datex2.eu/schema/2/2_0}linearTrafficView'): + ref = tv.find('d:linearPredefinedLocationReference', ns) + u = tv.find('.//d:urlLinkAddress', ns) + if ref is None or u is None or ref.get('id') not in locs: + continue + lat, lon, nm = locs[ref.get('id')] + out.append(C('Transpordiamet Tarktee', nm, '', 'Estonia', lat, lon, u.text)) + return nearest(out, [(59.437, 24.753, 'Tallinn'), (58.378, 26.729, 'Tartu'), (58.3859, 24.4971, 'Pärnu')], n) + + +@source +def lithuania(n): + rows = get('https://eismoinfo.lt/eismoinfo-backend/camera-info-table').json() + layer = get('https://eismoinfo.lt/eismoinfo-backend/layer-static-features/VKR').json() + feats = {str(f.get('id')): f for L in layer for f in L.get('features', [])} + out = [] + for r in rows: + f = feats.get(str(r.get('id'))) + try: + lat, lon = f['points'][0]['point'] + except Exception: + continue + out.append(C('Via Lietuva eismoinfo', r['name'], '', 'Lithuania', lat, lon, f"https://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id={r['id']}")) + return nearest(out, [(54.6872, 25.2797, 'Vilnius'), (54.8985, 23.9036, 'Kaunas'), (55.7033, 21.1443, 'Klaipėda')], n) + + +@source +def catalonia(n): + x = get('https://www.gencat.cat/transit/opendata/cameres.xml').text + out = [] + for m in re.finditer(r']*>([-\d.]+),([-\d.]+).*?(.*?).*?(.*?).*?(.*?).*?(.*?)', x, re.S): + lon, lat, road, muni, link, font = m.groups() + link = link.replace('&', '&').replace('http://', 'https://') + if font not in ('SCT', 'IMI', 'Terrassa'): + continue + city = 'Barcelona' if font == 'IMI' else ('Terrassa' if font == 'Terrassa' else muni) + out.append(C('Servei Català de Trànsit', f'{road} {muni}', city, 'Spain', lat, lon, link)) + return nearest(out, [(41.3874, 2.1686, 'Barcelona'), (41.5636, 2.0111, 'Terrassa'), (41.45, 2.2, 'Barcelona area')], n) + + +@source +def nsw(n): + d = get('https://data.livetraffic.com/cameras/traffic-cam.json').json() + out = [] + for f in d['features']: + p = f['properties'] + href = (p.get('href') or '').replace('http://', 'https://') + if not href.startswith('https://webcams.transport.nsw.gov.au/'): + continue + lon, lat = f['geometry']['coordinates'][:2] + out.append(C('Live Traffic NSW', p['title'], '', 'Australia', lat, lon, href)) + return nearest(out, [(-33.8688, 151.2093, 'Sydney'), (-32.9283, 151.7817, 'Newcastle'), (-34.4278, 150.8931, 'Wollongong'), (-33.8, 151.0, 'Western Sydney')], n) + + +@source +def singapore(n): + d = get('https://api.data.gov.sg/v1/transport/traffic-images').json() + out = [C('LTA Singapore (data.gov.sg)', f"Camera {c['camera_id']}", 'Singapore', 'Singapore', c['location']['latitude'], c['location']['longitude'], c['image']) + for c in d['items'][0]['cameras']] + return sample(out, n) + + +@source +def hongkong(n): + x = ET.fromstring(get('https://static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml').content) + out = [] + for im in x.iter('image'): + g = lambda k: (im.findtext(k) or '').strip() + if not g('url').startswith('https://tdcctv.data.one.gov.hk/'): + continue + out.append(C('HK Transport Department', re.sub(r'\s*\[.*?\]$', '', g('description')), f"Hong Kong ({g('district')})", 'Hong Kong', g('latitude'), g('longitude'), g('url'))) + return sample(out, n) + + +@source +def nz(n): + d = get('https://trafficnz.info/service/traffic/rest/4/cameras/all', headers={'Accept': 'application/json'}).json() + out = [] + for c in d['response']['camera']: + if c.get('offline') or c.get('underMaintenance'): + continue + out.append(C('NZTA Waka Kotahi', (c.get('name') or '').strip() + (' - ' + c['description'] if c.get('description') else ''), + (c.get('region') or {}).get('name', ''), 'New Zealand', c['latitude'], c['longitude'], 'https://trafficnz.info' + c['imageUrl'])) + return nearest(out, [(-36.8485, 174.7633, 'Auckland'), (-41.2865, 174.7762, 'Wellington'), (-43.5321, 172.6362, 'Christchurch'), (-37.787, 175.279, 'Hamilton'), (-45.0312, 168.6626, 'Queenstown')], n) + + +TARGETS = dict(nsw=26, hongkong=24, singapore=18, nz=20, nyc=20, caltrans=18, wsdot=12, kingco=6, drivebc=14, calgary=8, + toronto=10, txdot=10, austin=8, tfl=14, fintraffic=10, vegvesen=10, tallinn=6, tarktee=6, lithuania=8, catalonia=10) + + +def fetch_frame(c): + try: + hdr = {} + if c['kind'] == 'txdot': + r = S.get(c['url'], timeout=30, headers={'Accept': 'application/json'}) + r.raise_for_status() + js = r.json() + b64 = js.get('snippet') or js.get('Snippet') or '' + data = base64.b64decode(b64.split(',')[-1]) + else: + r = S.get(c['url'], timeout=30) + r.raise_for_status() + data = r.content + im = Image.open(io.BytesIO(data)) + im.load() + im = im.convert('RGB') + g = im.convert('L') + st = ImageStat.Stat(g) + edges = ImageStat.Stat(g.resize((320, 180)).filter(ImageFilter.FIND_EDGES)).mean[0] + sat = ImageStat.Stat(im.convert('HSV').split()[1]).mean[0] + small = g.resize((16, 9)) + px = list(small.tobytes()) + ahash = ''.join('1' if p > sum(px) / len(px) else '0' for p in px) + h = hashlib.md5(c['url'].encode()).hexdigest()[:12] + path = f'{WORK}/frames/{h}.jpg' + im.save(path, quality=95) + c.update(ok=True, file=path, w=im.width, h=im.height, mean=round(st.mean[0], 1), std=round(st.stddev[0], 1), + edges=round(edges, 2), sat=round(sat, 1), ahash=ahash, bytes=len(data), fetched=time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())) + except Exception as e: + c.update(ok=False, err=str(e)[:160]) + return c + + +if __name__ == '__main__': + only = sys.argv[2].split(',') if len(sys.argv) > 2 else list(SOURCES) + cands = [] + for name in only: + try: + got = SOURCES[name](TARGETS.get(name, 8)) + print(f'{name}: {len(got)} candidates') + for g in got: + g['src_key'] = name + cands += got + except Exception as e: + print(f'{name}: CATALOG FAILED {e}') + with ThreadPoolExecutor(24) as ex: + res = list(ex.map(fetch_frame, cands)) + prev = [] + pth = f'{WORK}/candidates.json' + if os.path.exists(pth): + prev = [p for p in json.load(open(pth)) if p['src_key'] not in only] + json.dump(prev + res, open(pth, 'w'), ensure_ascii=False, indent=0) + from collections import Counter + print('ok per source', Counter(c['src_key'] for c in res if c['ok'])) + print('fail per source', Counter(c['src_key'] for c in res if not c['ok'])) + for c in res: + if not c['ok']: + print(' FAIL', c['src_key'], c['url'][:90], c['err'][:100]) diff --git a/intro-sequence/tools/fetch/cctv_select.py b/intro-sequence/tools/fetch/cctv_select.py new file mode 100644 index 000000000..850ab3fd4 --- /dev/null +++ b/intro-sequence/tools/fetch/cctv_select.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Stage 2: pick a spread of good frames from cctv_fetch.py output, cover-crop to 640x360, write cctv/index.json. +Usage: python3 -I cctv_select.py +EXCLUDE lists frames rejected on visual review of the contact sheets (placeholders such as +'Temporarily Unavailable', 'Kamera er midlertidig ute av drift', 'NB! ÜHENDUS KAAMERAGA PUUDUB', +'Interrupció temporal de la imatge', and an all-black King County frame).""" +import json, os, sys +from collections import Counter +from PIL import Image +W, OUT = sys.argv[1], sys.argv[2] +ok = json.load(open(f'{W}/ok.json')) +EXCLUDE = {40, 43, 45, 46, 49, 66, 126, 139, 148, 150} +PREFER = {'hongkong': [187, 195, 197, 198, 201, 205, 206, 196, 200, 202]} # fewest privacy-mask polygons +QUOTA = dict(nsw=12, hongkong=10, singapore=6, nz=12, nyc=9, caltrans=6, wsdot=6, kingco=3, drivebc=6, calgary=4, toronto=6, + txdot=4, austin=4, tfl=8, fintraffic=4, vegvesen=4, tallinn=3, tarktee=3, lithuania=4, catalonia=3) +hash_count = Counter(x['ahash'] for x in ok) +good = [x for x in ok if x['i'] not in EXCLUDE and hash_count[x['ahash']] == 1 and x['w'] >= 320 and x['mean'] > 25 and x['std'] > 15] +pick = [] +for src, q in QUOTA.items(): + pool = [x for x in good if x['src_key'] == src] + pref = [x for i in PREFER.get(src, []) for x in pool if x['i'] == i] + rest = sorted([x for x in pool if x not in pref], key=lambda x: -(x['std'] * x['edges'])) + pick += (pref + rest)[:q] +os.makedirs(OUT, exist_ok=True) +for f in os.listdir(OUT): + if f.startswith('cam_') and f.endswith('.jpg'): + os.remove(os.path.join(OUT, f)) +index = [] +for n, x in enumerate(pick, 1): + im = Image.open(x['file']).convert('RGB') + w, h = im.size + s = max(640 / w, 360 / h) + im = im.resize((max(640, round(w * s)), max(360, round(h * s))), Image.LANCZOS) + w, h = im.size + l, t = (w - 640) // 2, (h - 360) // 2 + im = im.crop((l, t, l + 640, t + 360)) + fn = f'cam_{n:03d}.jpg' + im.save(os.path.join(OUT, fn), quality=88) + index.append(dict(file=fn, name=x['name'], city=x['city'], country=x['country'], lat=x['lat'], lon=x['lon'], source=x['source'], + fetched_utc=x['fetched'], orig_size=[x['w'], x['h']], url=x['url'])) +json.dump(index, open(os.path.join(OUT, 'index.json'), 'w'), ensure_ascii=False, indent=1) +print(len(index), Counter(i['country'] for i in index)) diff --git a/intro-sequence/tools/fetch/fetch_basic.sh b/intro-sequence/tools/fetch/fetch_basic.sh new file mode 100755 index 000000000..61d345bb3 --- /dev/null +++ b/intro-sequence/tools/fetch/fetch_basic.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Re-fetch the raw keyless feeds used by process_basic.py / process_tle.py / process_vessels.py. +# Usage: fetch_basic.sh then: +# python3 -I process_basic.py ../../data +# python3 -I process_tle.py ../../data +# python3 -I process_vessels.py ../../data +# /python -I fetch_wind.py https://noaa-gfs-bdp-pds.s3.amazonaws.com/gfs.YYYYMMDD/HH/atmos/gfs.tHHz.pgrb2.1p00.f006 ../../data/wind.json +# python -I fetch_bonus.py ../../data +# python -I cctv_fetch.py && python -I cctv_select.py ../../cctv (after rebuilding ok.json/contact sheets and re-reviewing EXCLUDE) +set -u +R="$1"; mkdir -p "$R"; cd "$R" +date -u +%Y-%m-%dT%H:%M:%SZ > fetch_start.txt +CT='gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)' # CelesTrak 403s bulk groups without a descriptive UA +f() { curl -sS -m 300 --compressed "${@:3}" -o "$1" -w "$1 %{http_code} %{size_download}\n" "$2"; } +f opensky.json https://opensky-network.org/api/states/all & +f mil.json https://api.adsb.lol/v2/mil & +f mil_adsbfi.json https://opendata.adsb.fi/api/v2/mil & +f quakes.geojson https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojson & +f viirs_snpp.csv https://firms.modaps.eosdis.nasa.gov/data/active_fire/suomi-npp-viirs-c2/csv/SUOMI_VIIRS_C2_Global_24h.csv & +f viirs_n20.csv https://firms.modaps.eosdis.nasa.gov/data/active_fire/noaa-20-viirs-c2/csv/J1_VIIRS_C2_Global_24h.csv & +f modis.csv https://firms.modaps.eosdis.nasa.gov/data/active_fire/modis-c6.1/csv/MODIS_C6_1_Global_24h.csv & +f cables.json https://www.submarinecablemap.com/api/v3/cable/cable-geo.json & +f coast50.geojson https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_coastline.geojson & +f countries110.geojson https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson & +f places.geojson https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_populated_places_simple.geojson & +f airports.csv https://davidmegginson.github.io/ourairports-data/airports.csv & +f kystverket_ais.json https://kystdatahuset.kystverket.no/ws/api/ais/realtime/geojson & +f dt_ais_from.json "https://meri.digitraffic.fi/api/ais/v1/locations?from=$(( ($(date +%s)-172800)*1000 ))" -H "Digitraffic-User: gods-eye-intro" & +f digitraffic_vessels.json https://meri.digitraffic.fi/api/ais/v1/vessels -H "Digitraffic-User: gods-eye-intro" & +wait +# CelesTrak: sequential (parallel requests get connection resets); FORMAT=3le endpoint answered when FORMAT=tle timed out +f tle_active3.txt "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=3le" -A "$CT" +for g in stations starlink gps-ops geo weather; do sleep 3; f tle_$g.txt "https://celestrak.org/NORAD/elements/gp.php?GROUP=$g&FORMAT=tle" -A "$CT"; done diff --git a/intro-sequence/tools/fetch/fetch_bonus.py b/intro-sequence/tools/fetch/fetch_bonus.py new file mode 100644 index 000000000..14e604f27 --- /dev/null +++ b/intro-sequence/tools/fetch/fetch_bonus.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Bonus layers: radio stations (Radio Browser), GBFS bike stations, launch pads (Launch Library 2), +power plants (WRI Global Power Plant Database). Usage: python3 -I fetch_bonus.py """ +import csv, io, json, os, sys, time, zipfile +import requests + +OUT, RAW = sys.argv[1], sys.argv[2] +only = sys.argv[3].split(',') if len(sys.argv) > 3 else ['radio', 'gbfs', 'pads', 'power'] +S = requests.Session() +S.headers['User-Agent'] = 'GodsEyeView/1.0 intro-sequence data snapshot' +log = {} + + +def dump(name, obj): + with open(os.path.join(OUT, name), 'w') as f: + json.dump(obj, f, separators=(',', ':'), ensure_ascii=False) + + +if 'radio' in only: + rows, used = [], None + for host in ['https://de1.api.radio-browser.info', 'https://de2.api.radio-browser.info', 'https://nl1.api.radio-browser.info']: + try: + r = S.get(f'{host}/json/stations/search', params={'has_geo_info': 'true', 'hidebroken': 'true', 'limit': 100000}, timeout=180, + headers={'User-Agent': 'GodsEyeView/1.0 (Radio Browser directory client)'}) + r.raise_for_status() + data = r.json(); used = host + break + except Exception as e: + print('radio', host, e) + seen = set() + for s in data: + lat, lon = s.get('geo_lat'), s.get('geo_long') + if lat is None or lon is None or (abs(lat) < 0.01 and abs(lon) < 0.01): + continue + key = (round(lon, 3), round(lat, 3), s['name'].strip()) + if key in seen: + continue + seen.add(key) + rows.append([round(lon, 4), round(lat, 4), s['name'].strip(), s.get('countrycode') or '']) + dump('radio.json', {'cols': ['lon', 'lat', 'name', 'cc'], 'rows': rows}) + log['radio'] = dict(url=f'{used}/json/stations/search?has_geo_info=true&hidebroken=true&limit=100000', rows=len(rows)) + print('radio', len(rows)) + +if 'gbfs' in only: + SYSTEMS = { + 'Citi Bike (New York)': 'https://gbfs.lyft.com/gbfs/2.3/bkn/en/station_information.json', + 'Divvy (Chicago)': 'https://gbfs.lyft.com/gbfs/2.3/chi/en/station_information.json', + 'Bay Wheels (SF Bay)': 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_information.json', + 'Capital Bikeshare (DC)': 'https://gbfs.lyft.com/gbfs/2.3/dca-cabi/en/station_information.json', + 'Bluebikes (Boston)': 'https://gbfs.bluebikes.com/gbfs/en/station_information.json', + 'Bike Share Toronto': 'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + 'BIXI (Montreal)': 'https://gbfs.velobixi.com/gbfs/en/station_information.json', + 'Ecobici (Mexico City)': 'https://gbfs.mex.lyftbikes.com/gbfs/en/station_information.json', + 'Velib Metropole (Paris)': 'https://velib-metropole-opendata.smovengo.cloud/opendata/Velib_Metropole/station_information.json', + 'Bicing (Barcelona)': 'https://barcelona.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + 'BiciMAD (Madrid)': 'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + 'Bike Sampa (Sao Paulo)': 'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + 'Bike Rio (Rio de Janeiro)': 'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + 'Bike Santiago': 'https://santiago.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + 'Tembici Bogota': 'https://bogota.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + 'Indego (Philadelphia)': 'https://gbfs.bcycle.com/bcycle_indego/station_information.json', + 'Metro Bike Share (LA)': 'https://gbfs.bcycle.com/bcycle_lametro/station_information.json', + 'Oslo Bysykkel': 'https://gbfs.urbansharing.com/oslobysykkel.no/station_information.json', + } + rows, per = [], {} + for name, url in SYSTEMS.items(): + try: + d = S.get(url, timeout=60, headers={'Client-Identifier': 'gods-eye-view'}).json() + st = d['data']['stations'] + for s in st: + rows.append([round(float(s['lon']), 5), round(float(s['lat']), 5)]) + per[name] = len(st) + except Exception as e: + per[name] = f'FAILED {str(e)[:80]}' + # nextbike: one keyless global live feed covering every nextbike city + try: + d = S.get('https://api.nextbike.net/maps/nextbike-live.json', timeout=120).json() + n = 0 + for c in d['countries']: + for city in c.get('cities', []): + for p in city.get('places', []): + if p.get('spot'): # stations only, not free-floating bikes + rows.append([round(p['lng'], 5), round(p['lat'], 5)]); n += 1 + per['nextbike (all cities, global)'] = n + except Exception as e: + per['nextbike'] = f'FAILED {e}' + dump('bikes.json', {'cols': ['lon', 'lat'], 'systems': per, 'rows': rows}) + log['gbfs'] = dict(systems=per, urls=list(SYSTEMS.values()) + ['https://api.nextbike.net/maps/nextbike-live.json'], rows=len(rows)) + print('bikes', len(rows), per) + +if 'pads' in only: + url = 'https://ll.thespacedevs.com/2.3.0/pads/?limit=100&mode=list' + rows = [] + while url: + r = S.get(url, timeout=60) + r.raise_for_status() + d = r.json() + for p in d['results']: + try: + lat, lon = float(p['latitude']), float(p['longitude']) + except (TypeError, ValueError, KeyError): + continue + loc = p.get('location') or {} + rows.append([round(lon, 4), round(lat, 4), p['name'], loc.get('name') if isinstance(loc, dict) else None, + p.get('total_launch_count') or 0, bool(p.get('active'))]) + url = d.get('next') + time.sleep(1) + rows.sort(key=lambda x: -x[4]) + dump('launch_sites.json', {'cols': ['lon', 'lat', 'pad', 'location', 'total_launches', 'active'], 'rows': rows}) + log['pads'] = dict(url='https://ll.thespacedevs.com/2.3.0/pads/?limit=100&mode=list (paged)', rows=len(rows)) + print('pads', len(rows)) + +if 'power' in only: + url = 'https://raw.githubusercontent.com/wri/global-power-plant-database/master/output_database/global_power_plant_database.csv' + r = S.get(url, timeout=180) + r.raise_for_status() + rows = [] + for row in csv.DictReader(io.StringIO(r.content.decode('utf-8'))): + try: + rows.append([round(float(row['longitude']), 4), round(float(row['latitude']), 4), round(float(row['capacity_mw']), 1), row['primary_fuel'], row['name'], row['country']]) + except ValueError: + pass + rows.sort(key=lambda x: -x[2]) + dump('power_plants.json', {'cols': ['lon', 'lat', 'capacity_mw', 'fuel', 'name', 'iso3'], 'rows': rows}) + log['power'] = dict(url=url, rows=len(rows)) + print('power', len(rows)) + +prev = {} +p = os.path.join(RAW, 'bonus_log.json') +if os.path.exists(p): + prev = json.load(open(p)) +prev.update(log) +json.dump(prev, open(p, 'w'), indent=1) diff --git a/intro-sequence/tools/fetch/fetch_wind.py b/intro-sequence/tools/fetch/fetch_wind.py new file mode 100644 index 000000000..f1454281a --- /dev/null +++ b/intro-sequence/tools/fetch/fetch_wind.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""GFS 1.0 deg 10 m wind U/V from NOAA's AWS Open Data bucket (noaa-gfs-bdp-pds) via .idx byte +ranges, same approach as server/providers/wind/gfs.js; decoded with python `eccodes` (pip). +Usage: python fetch_wind.py """ +import json, sys, requests, numpy as np, eccodes +base, out = sys.argv[1], sys.argv[2] +lines = [l.split(':') for l in requests.get(base + '.idx', timeout=60).text.strip().split('\n')] +def field(var): + i = next(k for k, p in enumerate(lines) if p[3] == var and p[4] == '10 m above ground') + s, e = int(lines[i][1]), int(lines[i + 1][1]) - 1 + b = requests.get(base, headers={'Range': f'bytes={s}-{e}'}, timeout=120).content + h = eccodes.codes_new_from_message(b) + g = {k: eccodes.codes_get(h, k) for k in ['Ni', 'Nj', 'dataDate', 'dataTime', 'step']} + g.update({k: eccodes.codes_get_double(h, k) for k in ['longitudeOfFirstGridPointInDegrees', 'latitudeOfFirstGridPointInDegrees', 'iDirectionIncrementInDegrees', 'jDirectionIncrementInDegrees']}) + g['values'] = eccodes.codes_get_values(h).reshape(g['Nj'], g['Ni']) + eccodes.codes_release(h) + return g +U, V = field('UGRD'), field('VGRD') +assert U['longitudeOfFirstGridPointInDegrees'] == 0 and U['latitudeOfFirstGridPointInDegrees'] == 90 +dx = U['iDirectionIncrementInDegrees']; shift = int(round(180 / dx)) +u = np.roll(U['values'], -shift, axis=1); v = np.roll(V['values'], -shift, axis=1) # lon 0..359 -> -180..179 +ny, nx = u.shape +json.dump({'source': 'NOAA GFS 1.0deg ' + base.split('/')[-1], 'refTime': f"{U['dataDate']} {U['dataTime']:04d}Z", 'forecastHour': U['step'], + 'level': '10 m above ground', 'units': 'm/s', 'nx': nx, 'ny': ny, 'lo1': -180, 'la1': 90, 'dx': dx, 'dy': U['jDirectionIncrementInDegrees'], + 'u': [round(float(x), 2) for x in u.ravel()], 'v': [round(float(x), 2) for x in v.ravel()]}, + open(out, 'w'), separators=(',', ':')) +sp = np.hypot(u, v) +print(nx, ny, 'max', sp.max().round(1), 'mean', sp.mean().round(2), U['dataDate'], U['dataTime'], 'f', U['step']) diff --git a/intro-sequence/tools/fetch/process_basic.py b/intro-sequence/tools/fetch/process_basic.py new file mode 100644 index 000000000..bb896f200 --- /dev/null +++ b/intro-sequence/tools/fetch/process_basic.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Convert raw fetched feeds (see fetch_basic.sh) into compact intro-sequence JSON. + +Usage: python3 -I process_basic.py +All inputs are real downloads; this only filters, reprojects units and rounds. +""" +import csv, json, os, sys, time + +RAW, OUT = sys.argv[1], sys.argv[2] +meta = {} + + +def r(x, n): + return None if x is None else round(float(x), n) + + +def dump(name, obj): + p = os.path.join(OUT, name) + with open(p, 'w') as f: + json.dump(obj, f, separators=(',', ':'), ensure_ascii=False) + print(f'{name}: {os.path.getsize(p)/1e6:.2f} MB') + + +def raw(name): + return os.path.join(RAW, name) + + +# 1. flights (OpenSky) +o = json.load(open(raw('opensky.json'))) +rows = [] +for s in o['states']: + lon, lat, on_ground = s[5], s[6], s[8] + if lon is None or lat is None or on_ground: + continue + alt = s[13] if s[13] is not None else s[7] + rows.append([r(lon, 4), r(lat, 4), r(alt, 0), r(s[10], 1), r(s[9], 1), + (s[1] or '').strip(), s[2]]) +dump('flights.json', {'t': o['time'], 'cols': ['lon', 'lat', 'alt_m', 'track_deg', 'vel_ms', 'callsign', 'country'], 'rows': rows}) +meta['flights'] = len(rows) + +# 2. military (adsb.lol /v2/mil, unioned with adsb.fi /v2/mil, dedup by hex; adsb.lol wins) +FT, KT = 0.3048, 0.514444 +seen, rows, tmax = set(), [], 0 +for fname in ['mil.json', 'mil_adsbfi.json']: + if not os.path.exists(raw(fname)): + continue + d = json.load(open(raw(fname))) + tmax = max(tmax, int((d.get('now') or 0) / (1000 if (d.get('now') or 0) > 1e11 else 1))) + for a in d.get('ac') or d.get('aircraft') or []: + lat, lon = a.get('lat'), a.get('lon') + if lat is None or lon is None or a['hex'] in seen: + continue + alt = a.get('alt_geom', a.get('alt_baro')) + if a.get('alt_baro') == 'ground' or alt == 'ground': + continue # airborne only + seen.add(a['hex']) + track = a.get('track', a.get('true_heading')) + gs = a.get('gs') + rows.append([r(lon, 4), r(lat, 4), r(alt * FT, 0) if isinstance(alt, (int, float)) else None, + r(track, 1), r(gs * KT, 1) if gs is not None else None, + (a.get('flight') or '').strip(), None, a.get('t')]) +dump('military.json', {'t': tmax, 'cols': ['lon', 'lat', 'alt_m', 'track_deg', 'vel_ms', 'callsign', 'country', 'type'], 'rows': rows, + 'note': 'country not provided by adsb.lol/adsb.fi feeds (null)'}) +meta['military'] = len(rows) + +# 4. quakes +q = json.load(open(raw('quakes.geojson'))) +rows = [] +for f in q['features']: + c, p = f['geometry']['coordinates'], f['properties'] + if p.get('mag') is None: + continue + rows.append([r(c[0], 3), r(c[1], 3), r(c[2], 1), r(p['mag'], 2), p['time'], p.get('place') or '']) +rows.sort(key=lambda x: x[4]) +dump('quakes.json', {'t': q['metadata']['generated'], 'cols': ['lon', 'lat', 'depth_km', 'mag', 'time_ms', 'place'], 'rows': rows}) +meta['quakes'] = len(rows) + +# 5. fires (FIRMS 24h global: SNPP VIIRS, NOAA-20 VIIRS, MODIS) +rows, per = [], {} +for fname, bcol, tag in [('viirs_snpp.csv', 'bright_ti4', 'VIIRS_SNPP'), ('viirs_n20.csv', 'bright_ti4', 'VIIRS_NOAA20'), ('modis.csv', 'brightness', 'MODIS')]: + if not os.path.exists(raw(fname)): + continue + n = 0 + with open(raw(fname), newline='') as fh: + for row in csv.DictReader(fh): + try: + rows.append([r(row['longitude'], 3), r(row['latitude'], 3), r(row['frp'], 1), r(row[bcol], 1)]) + n += 1 + except (KeyError, ValueError): + pass + per[tag] = n +dump('fires.json', {'cols': ['lon', 'lat', 'frp', 'brightness'], 'sources': per, 'rows': rows}) +meta['fires'] = len(rows) +meta['fires_per'] = per + +# 8. cables +c = json.load(open(raw('cables.json'))) +lines = [] +for f in c['features']: + g = f['geometry'] + parts = g['coordinates'] if g['type'] == 'MultiLineString' else [g['coordinates']] + for part in parts: + pl = [[r(x, 2), r(y, 2)] for x, y in (pt[:2] for pt in part)] + if len(pl) >= 2: + lines.append(pl) +dump('cables.json', {'cables': lines}) +meta['cables'] = (len(c['features']), len(lines)) + + +# 9. coastline + borders +def lines_of(geo): + out = [] + for f in geo['features']: + g = f['geometry'] + if g is None: + continue + t, co = g['type'], g['coordinates'] + if t == 'LineString': + parts = [co] + elif t == 'MultiLineString': + parts = co + elif t == 'Polygon': + parts = co + elif t == 'MultiPolygon': + parts = [ring for poly in co for ring in poly] + else: + parts = [] + for part in parts: + pl, last = [], None + for x, y in (pt[:2] for pt in part): + pt = [r(x, 2), r(y, 2)] + if pt != last: + pl.append(pt) + last = pt + if len(pl) >= 2: + out.append(pl) + return out + + +coast = lines_of(json.load(open(raw('coast50.geojson')))) +dump('coastline.json', {'lines': coast}) +borders = lines_of(json.load(open(raw('countries110.geojson')))) +dump('borders.json', {'lines': borders}) +meta['coast'] = len(coast) +meta['borders'] = len(borders) + +# 10. cities +pp = json.load(open(raw('places.geojson'))) +rows = [] +for f in pp['features']: + p = f['properties'] + lon, lat = f['geometry']['coordinates'][:2] + rows.append([r(lon, 3), r(lat, 3), int(p.get('pop_max') or 0), p.get('name') or p.get('nameascii'), p.get('adm0name') or p.get('sov0name')]) +rows.sort(key=lambda x: -x[2]) +rows = rows[:3000] +dump('cities.json', {'cols': ['lon', 'lat', 'pop_max', 'name', 'country'], 'rows': rows}) +meta['cities'] = len(rows) + +# 11. airports (OurAirports large + medium) +rows = [] +with open(raw('airports.csv'), newline='', encoding='utf-8') as fh: + for row in csv.DictReader(fh): + if row['type'] not in ('large_airport', 'medium_airport'): + continue + rows.append([r(row['longitude_deg'], 4), r(row['latitude_deg'], 4), row['iata_code'] or row['ident'], row['name'], + 'L' if row['type'] == 'large_airport' else 'M']) +rows.sort(key=lambda x: x[4]) +dump('airports.json', {'cols': ['lon', 'lat', 'iata', 'name', 'size'], 'rows': rows}) +meta['airports'] = (len(rows), sum(1 for x in rows if x[4] == 'L')) + +json.dump(meta, open(os.path.join(RAW, 'meta_basic.json'), 'w')) +print(meta) diff --git a/intro-sequence/tools/fetch/process_tle.py b/intro-sequence/tools/fetch/process_tle.py new file mode 100644 index 000000000..f5648f6c8 --- /dev/null +++ b/intro-sequence/tools/fetch/process_tle.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""tle.txt (3-line TLEs, CelesTrak GROUP=active) + tle_groups.json (NORAD ids per group). +Usage: python3 -I process_tle.py """ +import json, os, sys +RAW, OUT = sys.argv[1], sys.argv[2] +def parse(path): + L = [l.rstrip() for l in open(path) if l.strip()] + out = [] + i = 0 + while i + 2 < len(L) + 0 and i + 2 <= len(L) - 1: + if L[i+1].startswith('1 ') and L[i+2].startswith('2 '): + name = L[i][2:] if L[i].startswith('0 ') else L[i] + out.append((name.strip(), L[i+1], L[i+2])); i += 3 + else: + i += 1 + return out +act = parse(os.path.join(RAW, 'tle_active3.txt')) +with open(os.path.join(OUT, 'tle.txt'), 'w') as f: + for n, a, b in act: + f.write(f'{n}\n{a}\n{b}\n') +groups, notable = {}, {} +for g in ['stations', 'starlink', 'gps-ops', 'geo', 'weather']: + p = os.path.join(RAW, f'tle_{g}.txt') + if os.path.exists(p) and os.path.getsize(p) > 0: + sats = parse(p) + groups[g] = sorted(int(a[2:7]) for _, a, _ in sats) + for n, _, _ in sats[: (40 if g != 'starlink' else 3)]: + notable[n] = g +epochs = sorted(a[18:32] for _, a, _ in act) +json.dump({'groups_norad': groups, 'notable': notable}, open(os.path.join(OUT, 'tle_groups.json'), 'w'), separators=(',', ':')) +print(len(act), {k: len(v) for k, v in groups.items()}, 'epoch range', epochs[0], epochs[-1]) diff --git a/intro-sequence/tools/fetch/process_vessels.py b/intro-sequence/tools/fetch/process_vessels.py new file mode 100644 index 000000000..e5569c357 --- /dev/null +++ b/intro-sequence/tools/fetch/process_vessels.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""vessels.json from keyless AIS: Fintraffic Digitraffic (Finland/Baltic) + Kystverket Kystdatahuset realtime (Norway). +Usage: python3 -I process_vessels.py """ +import json, os, sys, time +RAW, OUT = sys.argv[1], sys.argv[2] +rows, src = {}, {} +# Norway (Kystverket) - LineString trails, last coordinate = latest position +k = json.load(open(os.path.join(RAW, 'kystverket_ais.json'))) +for f in k['features']: + c, p = f['geometry']['coordinates'], f['properties'] + if not c: + continue + lon, lat = c[-1] + cog = p.get('cog') + cog = None if cog is None or cog >= 360 else round(cog, 1) + sog = p.get('speed') + sog = None if sog is None or sog >= 102.3 else round(sog, 1) + rows[p['mmsi']] = [round(lon, 4), round(lat, 4), sog, cog, p['mmsi'], (p.get('ship_name') or '').strip() or None] + src[p['mmsi']] = 'NO' +# Finland / Baltic (Digitraffic) +names = {v['mmsi']: (v.get('name') or '').strip() or None for v in json.load(open(os.path.join(RAW, 'digitraffic_vessels.json')))} +d = json.load(open(os.path.join(RAW, 'dt_ais_from.json'))) +for f in d['features']: + p = f['properties'] + lon, lat = f['geometry']['coordinates'][:2] + if p['mmsi'] in rows: + continue + cog = p.get('cog'); cog = None if cog is None or cog >= 360 else round(cog, 1) + sog = p.get('sog'); sog = None if sog is None or sog >= 102.3 else round(sog, 1) + rows[p['mmsi']] = [round(lon, 4), round(lat, 4), sog, cog, p['mmsi'], names.get(p['mmsi'])] + src[p['mmsi']] = 'FI' +out = list(rows.values()) +json.dump({'t': int(time.time()), 'cols': ['lon', 'lat', 'sog_kn', 'cog_deg', 'mmsi', 'name'], + 'coverage': 'Norwegian coast/EEZ (Kystverket) + Finnish waters & Baltic Sea (Fintraffic Digitraffic); not global', + 'counts': {'kystverket_norway': sum(1 for v in src.values() if v == 'NO'), 'digitraffic_finland_baltic': sum(1 for v in src.values() if v == 'FI')}, + 'rows': out}, open(os.path.join(OUT, 'vessels.json'), 'w'), separators=(',', ':'), ensure_ascii=False) +lons = [r[0] for r in out]; lats = [r[1] for r in out] +print(len(out), 'bbox', min(lons), max(lons), min(lats), max(lats)) diff --git a/intro-sequence/tools/finalize.sh b/intro-sequence/tools/finalize.sh new file mode 100755 index 000000000..90bea8216 --- /dev/null +++ b/intro-sequence/tools/finalize.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Loudness-normalise the score (-14 LUFS, -1 dBTP) and mux it into both cuts. +# tools/finalize.sh (after tools/render-all.sh has produced out/video_*.mp4) +set -euo pipefail +cd "$(dirname "$0")/.." +python3 audio/score.py +# two-pass loudnorm +stats=$(ffmpeg -hide_banner -i audio/score.wav -af loudnorm=I=-14:TP=-1:LRA=11:print_format=json -f null - 2>&1 | sed -n '/^{/,/^}/p') +mi=$(echo "$stats" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['input_i'])") +mtp=$(echo "$stats" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['input_tp'])") +mlra=$(echo "$stats" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['input_lra'])") +mth=$(echo "$stats" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['input_thresh'])") +ffmpeg -y -loglevel error -i audio/score.wav -af "loudnorm=I=-14:TP=-1:LRA=11:measured_I=$mi:measured_TP=$mtp:measured_LRA=$mlra:measured_thresh=$mth:linear=true,aresample=48000" -c:a pcm_s24le audio/score_norm.wav +mkdir -p out/final +for tag in 16x9 9x16; do + [ -s out/video_$tag.mp4 ] || { echo "missing out/video_$tag.mp4"; continue; } + # master: the CRF 14 picture untouched + 320k AAC + ffmpeg -y -loglevel error -i out/video_$tag.mp4 -i audio/score_norm.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 320k -ar 48000 -shortest -movflags +faststart \ + -metadata title="God's Eye View — First Light ($tag)" out/final/GodsEyeView_FirstLight_${tag}_master.mp4 + # delivery: lighter encode for posting/sharing + ffmpeg -y -loglevel error -i out/video_$tag.mp4 -i audio/score_norm.wav -map 0:v -map 1:a -c:v libx264 -preset slow -crf 19 -tune grain -pix_fmt yuv420p \ + -colorspace bt709 -color_primaries bt709 -color_trc bt709 -c:a aac -b:a 256k -ar 48000 -shortest -movflags +faststart \ + -metadata title="God's Eye View — First Light ($tag)" out/final/GodsEyeView_FirstLight_${tag}.mp4 + echo "finalized $tag" +done +ls -la out/final diff --git a/intro-sequence/tools/imagery/.gitignore b/intro-sequence/tools/imagery/.gitignore new file mode 100644 index 000000000..e934adfd1 --- /dev/null +++ b/intro-sequence/tools/imagery/.gitignore @@ -0,0 +1 @@ +cache/ diff --git a/intro-sequence/tools/imagery/build_clouds.py b/intro-sequence/tools/imagery/build_clouds.py new file mode 100644 index 000000000..23ba246ab --- /dev/null +++ b/intro-sequence/tools/imagery/build_clouds.py @@ -0,0 +1,76 @@ +"""Deliverable 3: clouds.jpg (4096x2048) grayscale cloud opacity map for one recent day. + +Source: NASA GIBS daily VIIRS Corrected Reflectance True Colour for the same UTC +date from three satellites (NOAA-20, NOAA-21, Suomi NPP), EPSG:4326 level 3 +(10x5 tiles of 512px = 5120x2560), downsampled to 4096x2048. + +Cloud metric per satellite: + whiteness = min(R,G,B) (clouds are bright in every channel) + greyness = 1 - saturation (clouds are colourless) + c = ramp(whiteness) * smoothstep(greyness) +The same metric computed on the Esri base map (earth_day_4k.jpg) is subtracted +to suppress bright deserts, salt flats, snow and ice that would otherwise read +as cloud. The three per-satellite results are combined with a per-pixel median +(nan-aware), which removes swath-edge haze / glint streaks that only one +satellite shows, and fills swath gaps. Remaining no-data (polar night) -> 0. + +Usage: python3 build_clouds.py [YYYY-MM-DD] +""" +import os +import sys +import numpy as np +from PIL import Image +from common import OUT, gibs_mosaic + +Image.MAX_IMAGE_PIXELS = None +DATE = sys.argv[1] if len(sys.argv) > 1 else "2026-10-15" +LAYERS = [ + "VIIRS_NOAA20_CorrectedReflectance_TrueColor", + "VIIRS_NOAA21_CorrectedReflectance_TrueColor", + "VIIRS_SNPP_CorrectedReflectance_TrueColor", +] +W, H = 4096, 2048 + + +def smooth(x, a, b): + t = np.clip((x - a) / (b - a), 0, 1) + return t * t * (3 - 2 * t) + + +def metric(rgb): + rgb = rgb.astype(np.float32) / 255.0 + mn = rgb.min(2) + mx = rgb.max(2) + sat = (mx - mn) / (mx + 1e-4) + white = np.clip((mn - 0.14) / 0.86, 0, 1) ** 1.15 # linear-ish: keeps texture inside thick cloud + return white * smooth(1.0 - sat, 0.65, 0.95), mx + + +def main(): + base = np.asarray(Image.open(os.path.join(OUT, "earth_day_4k.jpg")).convert("RGB")) + base_c, _ = metric(base) + # dilate the base metric a few px so small Esri/VIIRS coastline misregistration + # does not leave bright outlines along desert and ice coasts + from PIL import ImageFilter + base_c = np.asarray(Image.fromarray((base_c * 255).astype(np.uint8)).filter(ImageFilter.MaxFilter(3)).filter(ImageFilter.GaussianBlur(2))).astype(np.float32) / 255 + stack = [] + for L in LAYERS: + m = gibs_mosaic(L, DATE, "250m", 3, 5, 10, "jpg").resize((W, H), Image.LANCZOS) + c, mx = metric(np.asarray(m)) + c = np.clip(c - base_c * 1.1, 0, 1) + c[mx < 0.03] = np.nan # no-data (black) + stack.append(c) + stack = np.stack(stack) + with np.errstate(all="ignore"): + import warnings + warnings.simplefilter("ignore") + cloud = np.nanmedian(stack, axis=0) + cloud = np.nan_to_num(cloud, nan=0.0) + cloud = np.clip((cloud - 0.06) / 0.94, 0, 1) # black point: kill faint surface/haze residue + img = Image.fromarray(np.clip(cloud * 255 + 0.5, 0, 255).astype(np.uint8), "L") + img.save(os.path.join(OUT, "clouds.jpg"), quality=92, optimize=True, progressive=True) + print("done", DATE, "mean opacity %.3f" % cloud.mean()) + + +if __name__ == "__main__": + main() diff --git a/intro-sequence/tools/imagery/build_day.py b/intro-sequence/tools/imagery/build_day.py new file mode 100644 index 000000000..989b911f3 --- /dev/null +++ b/intro-sequence/tools/imagery/build_day.py @@ -0,0 +1,91 @@ +"""Deliverable 1: earth_day.jpg (8192x4096) + earth_day_4k.jpg (4096x2048). + +Esri World Imagery z5 (32x32 tiles, 8192x8192 Web Mercator) reprojected to +equirectangular with bilinear sampling. Because both grids are 8192 px wide and +linear in longitude, the horizontal axis maps 1:1 and only latitude needs +resampling (linear interpolation between Mercator rows = bilinear). + +Polar caps beyond the Mercator limit (+-85.0511 deg) come from NASA GIBS +BlueMarble_ShadedRelief_Bathymetry (EPSG:4326, level 4 = 10240x5120; its shaded +bathymetry matches Esri's small-scale ocean rendering), colour-matched to Esri +with a least-squares affine colour transform fitted on co-registered overlap pixels and cross-faded between 80 and 84.5 deg. +""" +import os +import numpy as np +from PIL import Image +from common import OUT, esri_jobs, fetch_many, decode, gibs_mosaic + +Image.MAX_IMAGE_PIXELS = None +W, H = 8192, 4096 +Z = 5 +N = 2 ** Z +TS = 256 +MERC = N * TS # 8192 +MERC_LIMIT = np.degrees(np.arctan(np.sinh(np.pi))) # 85.0511 +FADE_A, FADE_B = 80.0, 84.5 # pure Esri below A, pure Blue Marble above B +CAP_LAYER = os.environ.get("CAP_LAYER", "BlueMarble_ShadedRelief_Bathymetry") + + +def esri_mercator(): + coords = [(x, y) for y in range(N) for x in range(N)] + res = fetch_many(esri_jobs(Z, coords), desc="esri z5") + merc = np.zeros((MERC, MERC, 3), np.uint8) + for (x, y), b in res.items(): + if b is None: + raise SystemExit(f"missing esri tile {x},{y}") + merc[y * TS:(y + 1) * TS, x * TS:(x + 1) * TS] = np.asarray(decode(b)) + return merc + + +def main(): + merc = esri_mercator() + print("reprojecting") + lat = 90.0 - (np.arange(H) + 0.5) * 180.0 / H + latc = np.clip(lat, -MERC_LIMIT + 1e-6, MERC_LIMIT - 1e-6) + yn = (1.0 - np.log(np.tan(np.pi / 4 + np.radians(latc) / 2)) / np.pi) / 2.0 + v = yn * MERC - 0.5 + v0 = np.clip(np.floor(v).astype(int), 0, MERC - 1) + v1 = np.clip(v0 + 1, 0, MERC - 1) + f = np.clip(v - np.floor(v), 0, 1).astype(np.float32)[:, None, None] + eq = np.empty((H, W, 3), np.float32) + for s in range(0, H, 256): + e = s + 256 + eq[s:e] = merc[v0[s:e]].astype(np.float32) * (1 - f[s:e]) + merc[v1[s:e]].astype(np.float32) * f[s:e] + del merc + + print("fetching polar caps:", CAP_LAYER) + bm = gibs_mosaic(CAP_LAYER, None, "500m", 4, 10, 20, "jpg") + bm = np.asarray(bm.resize((W, H), Image.LANCZOS)).astype(np.float32) + + # colour-match Blue Marble to Esri per hemisphere: fit an affine 3x3+offset colour + # transform (least squares) on co-registered pixels in the 76-84.5 deg overlap band + alat = np.abs(lat) + band = (alat > 76) & (alat < 84.5) + t = np.clip((alat - FADE_A) / (FADE_B - FADE_A), 0, 1) + t = (t * t * (3 - 2 * t)).astype(np.float32)[:, None, None] # smoothstep + out = eq.copy() + for hemi in (lat > 0, lat < 0): + rows = band & hemi + a = eq[rows][:, ::4].reshape(-1, 3) + b = bm[rows][:, ::4].reshape(-1, 3) + X = np.hstack([b, np.ones((len(b), 1), np.float32)]) + # ridge-regularised toward identity: near-grey ice makes the plain fit ill-conditioned + lam = len(b) * 50.0 + D = np.diag([1.0, 1.0, 1.0, 0.0]) + I0 = np.vstack([np.eye(3), np.zeros((1, 3))]) + X64, a64 = X.astype(np.float64), a.astype(np.float64) + M = np.linalg.solve(X64.T @ X64 + lam * D, X64.T @ a64 + lam * D @ I0) + print(" colour transform\n", M.round(3)) + h = bm[hemi] + bm[hemi] = (h.reshape(-1, 3) @ M[:3] + M[3]).reshape(h.shape) + out[hemi] = eq[hemi] * (1 - t[hemi]) + bm[hemi] * t[hemi] + + img = Image.fromarray(np.clip(out + 0.5, 0, 255).astype(np.uint8)) + os.makedirs(OUT, exist_ok=True) + img.save(os.path.join(OUT, "earth_day.jpg"), quality=92, optimize=True, progressive=True) + img.resize((4096, 2048), Image.LANCZOS).save(os.path.join(OUT, "earth_day_4k.jpg"), quality=92, optimize=True, progressive=True) + print("done") + + +if __name__ == "__main__": + main() diff --git a/intro-sequence/tools/imagery/build_hero.py b/intro-sequence/tools/imagery/build_hero.py new file mode 100644 index 000000000..1fdc69c3b --- /dev/null +++ b/intro-sequence/tools/imagery/build_hero.py @@ -0,0 +1,133 @@ +"""Deliverable 4: Esri World Imagery 'synthetic screenshots' of iconic places. + +For each location: _wide.jpg (zoom 13) and _close.jpg (zoom 18, +falling back to 17/16 when Esri serves "Map data not yet available" +placeholder tiles), both 1536x1024 centred on the point. Writes index.json. + +Usage: python3 build_hero.py [--contact DIR] (contact sheets for visual QA) + Per-location overrides: CLOSE_ZOOM_OVERRIDES below. +""" +import io +import json +import math +import os +import sys +import numpy as np +from PIL import Image +from common import OUT, esri_jobs, fetch_many + +HERO = os.path.join(OUT, "hero") +CW, CH = 1536, 1024 +TS = 256 + +LOCATIONS = [ + ("manhattan", "Midtown Manhattan", 40.7580, -73.9855), + ("palm_jumeirah", "Palm Jumeirah", 25.1124, 55.1390), + ("singapore_port", "Port of Singapore (Pasir Panjang Terminal)", 1.2730, 103.7690), # was 1.2650,103.8180 (Keppel Bay residential, no port), + ("shibuya", "Shibuya Crossing", 35.6595, 139.7005), + ("arc_de_triomphe", "Arc de Triomphe", 48.8738, 2.2950), + ("giza", "Giza Pyramids", 29.9792, 31.1342), + ("rotterdam_port", "Port of Rotterdam", 51.9500, 4.0500), + ("lax", "Los Angeles International Airport", 33.9416, -118.4085), + ("hong_kong", "Victoria Harbour, Hong Kong", 22.2930, 114.1700), + ("panama_canal", "Panama Canal, Miraflores Locks", 8.9966, -79.5915), # was 9.0800,-79.6800 (blurry jungle/lake shore), + ("sydney_opera", "Sydney Opera House", -33.8568, 151.2153), + ("kennedy_lc39a", "Kennedy Space Center LC-39A", 28.6083, -80.6041), + ("heathrow", "London Heathrow Airport", 51.4700, -0.4543), + ("rio_maracana", "Maracana Stadium, Rio de Janeiro", -22.9122, -43.2302), + ("venice", "Venice", 45.4340, 12.3388), + ("las_vegas_strip", "Las Vegas Strip", 36.1147, -115.1728), + ("vatican", "Vatican City", 41.9022, 12.4539), + ("burj_khalifa", "Burj Khalifa, Dubai", 25.1972, 55.2744), + ("golden_gate", "Golden Gate Bridge", 37.8199, -122.4783), + ("kremlin", "Moscow Kremlin", 55.7520, 37.6175), + ("tiananmen", "Tiananmen Square, Beijing", 39.9037, 116.3917), # was 39.9055,116.3976 (east of the square), + ("mumbai_port", "Mumbai Port", 18.9400, 72.8400), + ("suez_canal", "Suez Canal, Ismailia", 30.6141, 32.3228), # was 30.5850,32.2650 (Ismailia city blocks, canal off-frame), + ("pentagon", "The Pentagon", 38.8719, -77.0563), +] + +WIDE_ZOOM = 13 +CLOSE_ZOOM = 18 +CLOSE_ZOOM_OVERRIDES = {} # slug -> zoom, set after visual QA if needed +DROP = set() # slugs rejected after visual QA + + +def world_px(lat, lon, z): + n = TS * 2 ** z + x = (lon + 180.0) / 360.0 * n + s = math.sin(math.radians(lat)) + y = (0.5 - math.log((1 + s) / (1 - s)) / (4 * math.pi)) * n + return x, y + + +def is_placeholder(tile): + a = np.asarray(tile).astype(np.float32) + m = a.reshape(-1, 3).mean(0) + return a.std() < 8 and abs(m[0] - m[1]) < 4 and abs(m[1] - m[2]) < 4 + + +def render(lat, lon, z): + """Return (image, placeholder_fraction).""" + cx, cy = world_px(lat, lon, z) + x0, y0 = int(round(cx - CW / 2)), int(round(cy - CH / 2)) + tx0, ty0 = x0 // TS, y0 // TS + tx1, ty1 = (x0 + CW - 1) // TS, (y0 + CH - 1) // TS + n = 2 ** z + coords = [(tx % n, ty) for ty in range(ty0, ty1 + 1) for tx in range(tx0, tx1 + 1)] + data = fetch_many(esri_jobs(z, coords), desc=f"z{z}") + canvas = Image.new("RGB", ((tx1 - tx0 + 1) * TS, (ty1 - ty0 + 1) * TS)) + ph = 0 + for ty in range(ty0, ty1 + 1): + for tx in range(tx0, tx1 + 1): + b = data.get((tx % n, ty)) + if b is None: + ph += 1 + continue + t = Image.open(io.BytesIO(b)).convert("RGB") + if is_placeholder(t): + ph += 1 + canvas.paste(t, ((tx - tx0) * TS, (ty - ty0) * TS)) + crop = canvas.crop((x0 - tx0 * TS, y0 - ty0 * TS, x0 - tx0 * TS + CW, y0 - ty0 * TS + CH)) + return crop, ph / len(coords) + + +def main(): + os.makedirs(HERO, exist_ok=True) + contact = None + if "--contact" in sys.argv: + contact = sys.argv[sys.argv.index("--contact") + 1] + os.makedirs(contact, exist_ok=True) + index = [] + for slug, name, lat, lon in LOCATIONS: + if slug in DROP: + for k in ("wide", "close"): + p = os.path.join(HERO, f"{slug}_{k}.jpg") + if os.path.exists(p): + os.remove(p) + print(f"{slug}: dropped") + continue + wide, _ = render(lat, lon, WIDE_ZOOM) + z = CLOSE_ZOOM_OVERRIDES.get(slug, CLOSE_ZOOM) + while True: + close, ph = render(lat, lon, z) + if ph == 0 or z <= 16: + break + print(f" {slug}: {ph:.0%} placeholder tiles at z{z}, falling back") + z -= 1 + wide.save(os.path.join(HERO, f"{slug}_wide.jpg"), quality=92, optimize=True) + close.save(os.path.join(HERO, f"{slug}_close.jpg"), quality=92, optimize=True) + index.append({"slug": slug, "name": name, "lat": lat, "lon": lon, "close_zoom": z}) + print(f"{slug}: close z{z}") + if contact: + sheet = Image.new("RGB", (1536, 512)) + sheet.paste(wide.resize((768, 512), Image.LANCZOS), (0, 0)) + sheet.paste(close.resize((768, 512), Image.LANCZOS), (768, 0)) + sheet.save(os.path.join(contact, f"{slug}.jpg"), quality=85) + with open(os.path.join(HERO, "index.json"), "w") as f: + json.dump(index, f, indent=2) + print(f"{len(index)} locations written") + + +if __name__ == "__main__": + main() diff --git a/intro-sequence/tools/imagery/build_night.py b/intro-sequence/tools/imagery/build_night.py new file mode 100644 index 000000000..74c7aadf5 --- /dev/null +++ b/intro-sequence/tools/imagery/build_night.py @@ -0,0 +1,20 @@ +"""Deliverable 2: earth_night.jpg (8192x4096) + earth_night_4k.jpg (4096x2048). + +NASA Black Marble (layer VIIRS_Black_Marble, 2016 annual composite) from GIBS +EPSG:4326 level 4 (20x10 tiles of 512px = 10240x5120), Lanczos-downsampled. +Colour kept as served. +""" +import os +from PIL import Image +from common import OUT, gibs_mosaic + +Image.MAX_IMAGE_PIXELS = None +LAYER = os.environ.get("NIGHT_LAYER", "VIIRS_Black_Marble") +TIME = os.environ.get("NIGHT_TIME", "2016-01-01") +EXT = "png" if LAYER == "VIIRS_Black_Marble" else "jpg" + +m = gibs_mosaic(LAYER, TIME if LAYER == "VIIRS_Black_Marble" else None, "500m", 4, 10, 20, EXT) +big = m.resize((8192, 4096), Image.LANCZOS) +big.save(os.path.join(OUT, "earth_night.jpg"), quality=92, optimize=True, progressive=True) +big.resize((4096, 2048), Image.LANCZOS).save(os.path.join(OUT, "earth_night_4k.jpg"), quality=92, optimize=True, progressive=True) +print("done", LAYER, TIME) diff --git a/intro-sequence/tools/imagery/build_quadtree_tiles.py b/intro-sequence/tools/imagery/build_quadtree_tiles.py new file mode 100644 index 000000000..4f69f339a --- /dev/null +++ b/intro-sequence/tools/imagery/build_quadtree_tiles.py @@ -0,0 +1,15 @@ +"""Deliverable 5: Esri World Imagery z3 (8x8) and z4 (16x16) tiles for the quadtree indexing effect.""" +import os, shutil +from common import OUT, esri_jobs, fetch_many, esri_tile_path + +for z in (3, 4): + n = 2 ** z + d = os.path.join(OUT, f"tiles_z{z}") + os.makedirs(d, exist_ok=True) + coords = [(x, y) for y in range(n) for x in range(n)] + res = fetch_many(esri_jobs(z, coords), desc=f"esri z{z}") + bad = [k for k, v in res.items() if v is None] + for x, y in coords: + if res[(x, y)] is not None: + shutil.copyfile(esri_tile_path(z, x, y), os.path.join(d, f"z{z}_{x}_{y}.jpg")) + print(f"z{z}: {len(coords)-len(bad)} tiles written, {len(bad)} failed") diff --git a/intro-sequence/tools/imagery/common.py b/intro-sequence/tools/imagery/common.py new file mode 100644 index 000000000..55118a4c2 --- /dev/null +++ b/intro-sequence/tools/imagery/common.py @@ -0,0 +1,116 @@ +"""Shared helpers: polite cached tile fetching for Esri World Imagery and NASA GIBS.""" +import io +import os +import sys +import time +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +import requests +from PIL import Image + +HERE = os.path.dirname(os.path.abspath(__file__)) +CACHE = os.path.join(HERE, "cache") +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +OUT = os.path.join(ROOT, "imagery") + +MAX_WORKERS = 8 +UA = "gods-eye-view-intro/1.0 (texture prep; contact: project maintainer)" + +ESRI = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" +GIBS4326 = "https://gibs.earthdata.nasa.gov/wmts/epsg4326/best/{layer}/default/{time}/{tms}/{z}/{row}/{col}.{ext}" + +_local = threading.local() + + +def _session(): + s = getattr(_local, "s", None) + if s is None: + s = requests.Session() + s.headers["User-Agent"] = UA + _local.s = s + return s + + +def fetch(url, cache_path, retries=6): + """Return raw bytes for url, caching to cache_path. Retries with backoff.""" + if os.path.exists(cache_path) and os.path.getsize(cache_path) > 0: + with open(cache_path, "rb") as f: + return f.read() + os.makedirs(os.path.dirname(cache_path), exist_ok=True) + last = None + for attempt in range(retries): + try: + r = _session().get(url, timeout=60) + if r.status_code == 200 and r.content: + tmp = cache_path + ".part" + with open(tmp, "wb") as f: + f.write(r.content) + os.replace(tmp, cache_path) + return r.content + if r.status_code in (400, 404): + last = f"HTTP {r.status_code}" + break + last = f"HTTP {r.status_code}" + except Exception as e: # network hiccup + last = repr(e) + time.sleep(min(30, 1.5 * 2 ** attempt)) + raise RuntimeError(f"failed {url}: {last}") + + +def fetch_many(jobs, desc=""): + """jobs: list of (key, url, cache_path). Returns {key: bytes or None}.""" + out = {} + done = 0 + with ThreadPoolExecutor(MAX_WORKERS) as ex: + futs = {ex.submit(fetch, u, p): k for k, u, p in jobs} + for f in as_completed(futs): + k = futs[f] + try: + out[k] = f.result() + except Exception as e: + print(f" ! {e}", file=sys.stderr) + out[k] = None + done += 1 + if done % 100 == 0 or done == len(jobs): + print(f" {desc} {done}/{len(jobs)}", flush=True) + return out + + +def esri_tile_path(z, x, y): + return os.path.join(CACHE, "esri", str(z), str(y), f"{x}.jpg") + + +def esri_jobs(z, coords): + return [((x, y), ESRI.format(z=z, x=x, y=y), esri_tile_path(z, x, y)) for x, y in coords] + + +def gibs_path(layer, time_, tms, z, row, col, ext): + return os.path.join(CACHE, "gibs", layer, time_ or "default", tms, str(z), str(row), f"{col}.{ext}") + + +def gibs_jobs(layer, time_, tms, z, rows, cols, ext): + t = time_ or "default" + return [((r, c), GIBS4326.format(layer=layer, time=t, tms=tms, z=z, row=r, col=c, ext=ext), + gibs_path(layer, time_, tms, z, r, c, ext)) for r in range(rows) for c in range(cols)] + + +def decode(b, mode="RGB"): + return Image.open(io.BytesIO(b)).convert(mode) + + +def gibs_mosaic(layer, time_, tms, z, rows, cols, ext, mode="RGB"): + """Fetch and stitch a full-globe GIBS EPSG:4326 level (512px tiles).""" + jobs = gibs_jobs(layer, time_, tms, z, rows, cols, ext) + data = fetch_many(jobs, desc=f"{layer} z{z}") + W, H = cols * 512, rows * 512 + canvas = Image.new(mode, (W, H)) + missing = 0 + for (r, c), b in data.items(): + if b is None: + missing += 1 + continue + canvas.paste(decode(b, mode), (c * 512, r * 512)) + if missing: + print(f" {missing} tiles missing for {layer}", file=sys.stderr) + return canvas diff --git a/intro-sequence/tools/render-all.sh b/intro-sequence/tools/render-all.sh new file mode 100755 index 000000000..c63a67fbc --- /dev/null +++ b/intro-sequence/tools/render-all.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Render one format in 8 s chunks (skips chunks already on disk), then concat. +# tools/render-all.sh 1920 1080 16x9 +set -u +W=$1; H=$2; TAG=$3 +CH=240; TOTAL=2160 +mkdir -p out/chunks_$TAG +for ((s=0; s out/chunks_$TAG/list.txt +ffmpeg -y -loglevel error -f concat -safe 0 -i out/chunks_$TAG/list.txt -c copy out/video_$TAG.mp4 && echo "CONCAT OK $TAG" diff --git a/intro-sequence/tools/render.mjs b/intro-sequence/tools/render.mjs new file mode 100644 index 000000000..cc6636361 --- /dev/null +++ b/intro-sequence/tools/render.mjs @@ -0,0 +1,91 @@ +// Deterministic frame renderer: drives src/main.js frame by frame in headless +// Chromium and either writes stills or pipes straight into ffmpeg. +// +// node tools/render.mjs --w 1920 --h 1080 --frames 480,900,1500 --out out/stills +// node tools/render.mjs --w 1920 --h 1080 --from 0 --to 2160 --video out/video_16x9.mp4 +import { chromium } from 'playwright-core'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const args = Object.fromEntries( + process.argv + .slice(2) + .join(' ') + .split('--') + .filter(Boolean) + .map((s) => { + const [k, ...v] = s.trim().split(' '); + return [k, v.join(' ') || true]; + }), +); +const W = +args.w || 1920; +const H = +args.h || 1080; +const URL = `http://127.0.0.1:${args.port || 8765}/index.html?w=${W}&h=${H}`; +const CHROME = '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; + +const browser = await chromium.launch({ + executablePath: CHROME, + args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--disable-gpu-vsync', '--js-flags=--max-old-space-size=8192'], +}); +const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 }); +page.on('console', (m) => { + const t = m.text(); + if (/error|warn|missing|\[gev\]/i.test(t)) console.log(' page:', t.slice(0, 300)); +}); +page.on('pageerror', (e) => console.log(' PAGE ERROR:', e.message)); +await page.goto(URL); +await page.waitForFunction(() => window.__status === 'ready' || String(window.__status).startsWith('error'), null, { timeout: 600000, polling: 500 }); +const st = await page.evaluate(() => window.__status); +if (st !== 'ready') { + console.error('init failed:', st); + process.exit(1); +} +const info = await page.evaluate(() => ({ counts: GEV.counts, heroes: GEV.heroes, cams: GEV.cams, frames: GEV.frames })); +console.log('ready', JSON.stringify(info)); + +const cdp = await page.context().newCDPSession(page); +async function grab(format = 'png') { + const r = await cdp.send('Page.captureScreenshot', { format, quality: format === 'jpeg' ? 96 : undefined, fromSurface: true, captureBeyondViewport: false }); + return Buffer.from(r.data, 'base64'); +} + +let frames = []; +if (args.frames) frames = String(args.frames).split(',').map((x) => (x.includes('s') ? Math.round(parseFloat(x) * 30) : +x)); +else { + const from = +(args.from || 0); + const to = +(args.to || info.frames); + const step = +(args.step || 1); + for (let f = from; f < to; f += step) frames.push(f); +} + +let ff = null; +if (args.video) { + fs.mkdirSync(path.dirname(args.video), { recursive: true }); + ff = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', '30', '-c:v', 'png', '-i', '-', '-c:v', 'libx264', '-preset', args.preset || 'slow', '-crf', String(args.crf || 14), '-pix_fmt', 'yuv420p', '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709', '-movflags', '+faststart', args.video], { stdio: ['pipe', 'inherit', 'inherit'] }); +} else { + fs.mkdirSync(args.out || 'out/stills', { recursive: true }); +} + +const t0 = Date.now(); +for (let i = 0; i < frames.length; i++) { + const f = frames[i]; + await page.evaluate((fr) => window.GEV.renderFrame(fr), f); + const buf = await grab('png'); + if (ff) { + if (!ff.stdin.write(buf)) await new Promise((r) => ff.stdin.once('drain', r)); + } else { + fs.writeFileSync(path.join(args.out || 'out/stills', `${args.prefix || 'f'}_${String(f).padStart(5, '0')}.png`), buf); + } + if (i % 30 === 0 || i === frames.length - 1) { + const el = (Date.now() - t0) / 1000; + const per = el / (i + 1); + console.log(`frame ${f} (${i + 1}/${frames.length}) ${per.toFixed(2)} s/frame, eta ${((frames.length - i - 1) * per / 60).toFixed(1)} min`); + } +} +if (ff) { + ff.stdin.end(); + await new Promise((r) => ff.on('close', r)); +} +await browser.close(); +console.log('done in', ((Date.now() - t0) / 60000).toFixed(1), 'min'); diff --git a/intro-sequence/tools/sheet.py b/intro-sequence/tools/sheet.py new file mode 100644 index 000000000..f963bcb27 --- /dev/null +++ b/intro-sequence/tools/sheet.py @@ -0,0 +1,19 @@ +# usage: python3 tools/sheet.py [per_sheet] [cell_w] +import sys, glob +from PIL import Image +d, outp = sys.argv[1], sys.argv[2] +per = int(sys.argv[3]) if len(sys.argv) > 3 else 4 +cw = int(sys.argv[4]) if len(sys.argv) > 4 else 960 +fs = sorted(glob.glob(d + '/*.png')) +for s in range(0, len(fs), per): + group = fs[s:s + per] + ims = [Image.open(f) for f in group] + w0, h0 = ims[0].size + ch = int(cw * h0 / w0) + cols = 2 if w0 > h0 else 4 + rows = (len(ims) + cols - 1) // cols + sheet = Image.new('RGB', (cw * cols, ch * rows)) + for i, im in enumerate(ims): + sheet.paste(im.resize((cw, ch), Image.LANCZOS), ((i % cols) * cw, (i // cols) * ch)) + sheet.save(f'{outp}_{s // per:02d}.jpg', quality=88) + print(f'{outp}_{s // per:02d}.jpg', [f.split('/')[-1] for f in group])