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:
Claude committed 2026-10-18 03:21:30 +00:00
1 parent 93ea088f45
commit c9421a3f33
37 files changed
+15507

No files matched your search

+12
View File
@@ -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__/
+58
View File
@@ -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
+679
View File
@@ -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)))
+62
View File
@@ -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).
+26
View File
@@ -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>
+53
View File
@@ -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"
}
}
}
+14
View File
@@ -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"
}
}
+72
View File
@@ -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'}`;
}
+296
View File
@@ -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
+512
View File
@@ -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 };
}
+323
View File
@@ -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);
}
+689
View File
@@ -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);
});
+218
View File
@@ -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 };
}
+135
View File
@@ -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))));
}
+40
View File
@@ -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');
+511
View File
@@ -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('&amp;', '&').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)
+406
View File
@@ -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('&amp;', '&').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])
+42
View File
@@ -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))
+33
View File
@@ -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
+133
View File
@@ -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)
+28
View File
@@ -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'])
+172
View File
@@ -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)
+31
View File
@@ -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))
+26
View File
@@ -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
+1
View File
@@ -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()
+91
View File
@@ -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()
+133
View File
@@ -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")
+116
View File
@@ -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
+14
View File
@@ -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"
+91
View File
@@ -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');
+19
View File
@@ -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])