mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01KqP857pippaUs6konSDfe8
This commit is contained in:
37 files changed
+15507
No files matched your search
@@ -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__/
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)))
|
||||
@@ -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=<now-48h> + 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).
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>God's Eye View — First Light</title>
|
||||
<style>
|
||||
@font-face { font-family: 'JetBrains Mono'; font-weight: 400; src: url(node_modules/@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'JetBrains Mono'; font-weight: 500; src: url(node_modules/@fontsource/jetbrains-mono/files/jetbrains-mono-latin-500-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Inter'; font-weight: 200; src: url(node_modules/@fontsource/inter/files/inter-latin-200-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Inter'; font-weight: 300; src: url(node_modules/@fontsource/inter/files/inter-latin-300-normal.woff2) format('woff2'); }
|
||||
@font-face { font-family: 'Inter'; font-weight: 500; src: url(node_modules/@fontsource/inter/files/inter-latin-500-normal.woff2) format('woff2'); }
|
||||
html, body { margin: 0; background: #000; overflow: hidden; }
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
"three": "./node_modules/three/build/three.module.js",
|
||||
"satellite.js": "./node_modules/satellite.js/dist/satellite.es.js"
|
||||
} }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c"></canvas>
|
||||
<script type="module" src="src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+53
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'}`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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 <out_json> [<detail_json>]
|
||||
"""
|
||||
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'<predefinedLocation\s+id="([^"]+)"[^>]*>([\s\S]*?)</predefinedLocation>', lx):
|
||||
la = re.search(r'<latitude>\s*(-?\d+(?:\.\d+)?)\s*</latitude>', m.group(2), re.I)
|
||||
lo = re.search(r'<longitude>\s*(-?\d+(?:\.\d+)?)\s*</longitude>', 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'<trafficView\b[^>]*>([\s\S]*?)</trafficView>', ix):
|
||||
r = re.search(r'<linearPredefinedLocationReference\b[^>]*\bid="([^"]+)"', m.group(1), re.I)
|
||||
u = re.search(r'<urlLinkAddress>\s*([^<\s]+)\s*</urlLinkAddress>', 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'<gml:featureMember>([\s\S]*?)</gml:featureMember>', x):
|
||||
b = m.group(1)
|
||||
co = re.search(r'<gml:coordinates[^>]*>([^<]+)</gml:coordinates>', b)
|
||||
lk = re.search(r'<cite:link>([^<]*)</cite:link>', 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)
|
||||
@@ -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 <work>/frames/*.jpg + <work>/candidates.json.
|
||||
Stage 2 (cctv_select.py) filters, crops to 640x360 and writes cctv/index.json.
|
||||
|
||||
Usage: python3 -I cctv_fetch.py <work_dir>
|
||||
"""
|
||||
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'<gml:coordinates[^>]*>([-\d.]+),([-\d.]+)</gml:coordinates>.*?<cite:carretera>(.*?)</cite:carretera>.*?<cite:municipi>(.*?)</cite:municipi>.*?<cite:link>(.*?)</cite:link>.*?<cite:font>(.*?)</cite:font>', 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])
|
||||
@@ -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 <work_dir> <out_cctv_dir>
|
||||
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))
|
||||
Executable
+33
@@ -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 <raw_dir> then:
|
||||
# python3 -I process_basic.py <raw_dir> ../../data
|
||||
# python3 -I process_tle.py <raw_dir> ../../data
|
||||
# python3 -I process_vessels.py <raw_dir> ../../data
|
||||
# <venv with eccodes,numpy,requests>/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 <raw_dir>
|
||||
# python -I cctv_fetch.py <work_dir> && python -I cctv_select.py <work_dir> ../../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
|
||||
@@ -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 <out_data_dir> <raw_dir>"""
|
||||
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)
|
||||
@@ -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 <gfs file base url (no .idx)> <out wind.json>"""
|
||||
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'])
|
||||
@@ -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 <raw_dir> <out_data_dir>
|
||||
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)
|
||||
@@ -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 <raw_dir> <out_dir>"""
|
||||
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])
|
||||
@@ -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 <raw_dir> <out_dir>"""
|
||||
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))
|
||||
Executable
+26
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
cache/
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Deliverable 4: Esri World Imagery 'synthetic screenshots' of iconic places.
|
||||
|
||||
For each location: <slug>_wide.jpg (zoom 13) and <slug>_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()
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Executable
+14
@@ -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<TOTAL; s+=CH)); do
|
||||
e=$((s+CH)); f=out/chunks_$TAG/c_$(printf %05d $s).mp4
|
||||
[ -s "$f" ] && continue
|
||||
node tools/render.mjs --w $W --h $H --from $s --to $e --video "$f.tmp.mp4" --crf 14 --preset slow && mv "$f.tmp.mp4" "$f"
|
||||
done
|
||||
ls out/chunks_$TAG/c_*.mp4 | sed "s|^|file '$PWD/|; 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"
|
||||
@@ -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');
|
||||
@@ -0,0 +1,19 @@
|
||||
# usage: python3 tools/sheet.py <dir> <out_prefix> [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])
|
||||
Reference in new issue
Block a user