Skip to content

Commit 9b929b3

Browse files
authored
Merge pull request #76 from rdhyee/feature/globe-capture-tooling
Add globe animation capture tooling
2 parents ef29989 + 194c729 commit 9b929b3

File tree

2 files changed

+302
-0
lines changed

2 files changed

+302
-0
lines changed

tools/capture_globe_rotation.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Capture a rotating globe animation from a standalone Cesium page.
4+
Produces animated WebP (via img2webp) and GIF (via ffmpeg).
5+
6+
Prerequisites:
7+
pip install playwright
8+
playwright install chromium
9+
brew install webp ffmpeg # for img2webp and ffmpeg
10+
11+
Usage:
12+
# 1. Serve this directory locally:
13+
python -m http.server 8765 --directory tools/
14+
15+
# 2. Run the capture:
16+
python tools/capture_globe_rotation.py
17+
18+
# 3. Output lands in /tmp/isamples_globe.webp (copy to assets/)
19+
20+
Tunable parameters:
21+
--frames 120 Number of frames (more = smoother but larger file)
22+
--duration 15 Loop duration in seconds (higher = slower rotation)
23+
--width 800 Viewport width
24+
--height 500 Viewport height
25+
--quality 40 WebP quality (0-100, lower = smaller file)
26+
"""
27+
28+
import asyncio
29+
import argparse
30+
import os
31+
import shutil
32+
import tempfile
33+
import math
34+
35+
36+
async def capture_globe(num_frames=120, duration_sec=15, output_path="/tmp/isamples_globe.webp",
37+
width=800, height=500, quality=40,
38+
url="http://localhost:8765/globe_capture.html"):
39+
from playwright.async_api import async_playwright
40+
41+
frame_dir = tempfile.mkdtemp(prefix="globe_frames_")
42+
43+
print(f"Capturing {num_frames} frames for {duration_sec}s animation at {width}x{height}")
44+
print(f"URL: {url}")
45+
46+
async with async_playwright() as p:
47+
browser = await p.chromium.launch(
48+
headless=True,
49+
args=['--enable-webgl', '--use-gl=swiftshader']
50+
)
51+
page = await browser.new_page(viewport={"width": width, "height": height})
52+
page.on("console", lambda msg: None) # suppress noise
53+
54+
print("Loading globe page...")
55+
await page.goto(url, wait_until="networkidle", timeout=120000)
56+
57+
# Wait for DuckDB data to load
58+
print("Waiting for Cesium + data...")
59+
try:
60+
await page.wait_for_function("() => window._dataLoaded === true", timeout=60000)
61+
print("Data loaded!")
62+
except Exception as e:
63+
print(f"Data load timeout ({e}) — checking viewer anyway")
64+
65+
# Let imagery tiles render
66+
await page.wait_for_timeout(5000)
67+
68+
# Verify viewer is accessible
69+
has_viewer = await page.evaluate("() => !!window._viewer && !!window._viewer.scene")
70+
if not has_viewer:
71+
print("ERROR: Cesium viewer not accessible")
72+
await browser.close()
73+
return
74+
75+
cluster_count = await page.evaluate("""
76+
() => {
77+
const prims = window._viewer.scene.primitives;
78+
let total = 0;
79+
for (let i = 0; i < prims.length; i++) {
80+
try { if (prims.get(i).length) total += prims.get(i).length; } catch(e) {}
81+
}
82+
return total;
83+
}
84+
""")
85+
print(f"Points on globe: {cluster_count}")
86+
87+
# Full 360° rotation spread across all frames
88+
rotation_per_frame = (2 * math.pi) / num_frames
89+
90+
print("Capturing frames...")
91+
for i in range(num_frames):
92+
await page.evaluate(f"""
93+
() => {{
94+
window._viewer.scene.camera.rotate(
95+
Cesium.Cartesian3.UNIT_Z,
96+
-{rotation_per_frame}
97+
);
98+
}}
99+
""")
100+
101+
# Wait for Cesium to render the frame
102+
await page.evaluate("""
103+
() => new Promise(resolve => {
104+
window._viewer.scene.requestRender();
105+
requestAnimationFrame(() => requestAnimationFrame(resolve));
106+
})
107+
""")
108+
await page.wait_for_timeout(40)
109+
110+
frame_path = os.path.join(frame_dir, f"frame_{i:04d}.png")
111+
await page.screenshot(path=frame_path)
112+
113+
if (i + 1) % 30 == 0 or i == 0:
114+
print(f" Frame {i+1}/{num_frames}")
115+
116+
await browser.close()
117+
118+
print(f"\nAll {num_frames} frames captured. Stitching...")
119+
120+
# ms per frame for the target duration
121+
ms_per_frame = int((duration_sec / num_frames) * 1000)
122+
123+
# Animated WebP via img2webp (ffmpeg's libwebp_anim doesn't produce
124+
# proper multi-frame WebP reliably)
125+
frame_glob = os.path.join(frame_dir, "frame_*.png")
126+
cmd_webp = (
127+
f'img2webp -loop 0 -lossy -q {quality} -d {ms_per_frame} '
128+
f'{frame_glob} -o "{output_path}"'
129+
)
130+
os.system(cmd_webp)
131+
132+
# Animated GIF fallback via ffmpeg
133+
fps = num_frames / duration_sec
134+
gif_path = output_path.replace('.webp', '.gif')
135+
cmd_gif = (
136+
f'ffmpeg -y -framerate {fps} -i "{frame_dir}/frame_%04d.png" '
137+
f'-vf "scale={width}:-1:flags=lanczos,split[s0][s1];'
138+
f'[s0]palettegen=max_colors=64[p];[s1][p]paletteuse=dither=bayer" '
139+
f'-loop 0 "{gif_path}" 2>/dev/null'
140+
)
141+
os.system(cmd_gif)
142+
143+
# Static fallback frame
144+
static_path = output_path.replace('.webp', '_static.png')
145+
shutil.copy(os.path.join(frame_dir, "frame_0000.png"), static_path)
146+
147+
# Report
148+
print("\nOutput files:")
149+
for f in [output_path, gif_path, static_path]:
150+
if os.path.exists(f):
151+
size_mb = os.path.getsize(f) / 1024 / 1024
152+
print(f" {os.path.basename(f)}: {size_mb:.1f} MB")
153+
154+
shutil.rmtree(frame_dir)
155+
print(f"\nDone! Copy {output_path} to assets/isamples_globe.webp to deploy.")
156+
157+
158+
def main():
159+
parser = argparse.ArgumentParser(description="Capture rotating globe animation")
160+
parser.add_argument("--frames", type=int, default=120, help="Number of frames (default: 120)")
161+
parser.add_argument("--duration", type=float, default=15.0, help="Animation duration in seconds (default: 15)")
162+
parser.add_argument("--output", default="/tmp/isamples_globe.webp", help="Output path")
163+
parser.add_argument("--width", type=int, default=800, help="Width in pixels (default: 800)")
164+
parser.add_argument("--height", type=int, default=500, help="Height in pixels (default: 500)")
165+
parser.add_argument("--quality", type=int, default=40, help="WebP quality 0-100 (default: 40)")
166+
parser.add_argument("--url", default="http://localhost:8765/globe_capture.html", help="Page URL")
167+
args = parser.parse_args()
168+
169+
asyncio.run(capture_globe(
170+
num_frames=args.frames,
171+
duration_sec=args.duration,
172+
output_path=args.output,
173+
width=args.width,
174+
height=args.height,
175+
quality=args.quality,
176+
url=args.url
177+
))
178+
179+
180+
if __name__ == "__main__":
181+
main()

tools/globe_capture.html

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<script src="https://cesium.com/downloads/cesiumjs/releases/1.124/Build/Cesium/Cesium.js"></script>
6+
<link href="https://cesium.com/downloads/cesiumjs/releases/1.124/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
7+
<style>
8+
html, body, #cesiumContainer {
9+
margin: 0; padding: 0;
10+
width: 100%; height: 100%;
11+
overflow: hidden;
12+
background: #000;
13+
}
14+
</style>
15+
</head>
16+
<body>
17+
<div id="cesiumContainer"></div>
18+
<script type="module">
19+
import * as duckdb from 'https://cdn.jsdelivr.net/npm/@duckdb/[email protected]/+esm';
20+
21+
// Ion token from progressive_globe.qmd
22+
Cesium.Ion.defaultAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIwNzk3NjkyMy1iNGI1LTRkN2UtODRiMy04OTYwYWE0N2M3ZTkiLCJpZCI6Njk1MTcsImlhdCI6MTYzMzU0MTQ3N30.e70dpNzOCDRLDGxRguQCC-tRzGzA-23Xgno5lNgCeB4';
23+
24+
let viewer;
25+
try {
26+
viewer = new Cesium.Viewer("cesiumContainer", {
27+
timeline: false,
28+
animation: false,
29+
baseLayerPicker: false,
30+
geocoder: false,
31+
homeButton: false,
32+
sceneModePicker: false,
33+
navigationHelpButton: false,
34+
infoBox: false,
35+
selectionIndicator: false,
36+
fullscreenButton: false,
37+
requestRenderMode: false
38+
});
39+
40+
// Remove credits / logo for clean capture
41+
try {
42+
viewer.cesiumWidget.creditContainer.style.display = "none";
43+
} catch(e) {
44+
try { viewer._cesiumWidget._creditContainer.style.display = "none"; } catch(e2) {}
45+
}
46+
47+
// Expose viewer globally for Playwright
48+
window._viewer = viewer;
49+
console.log("Viewer created successfully");
50+
} catch(e) {
51+
console.error("Viewer creation failed:", e);
52+
window._viewerError = e.message;
53+
}
54+
55+
// Set initial camera: equator-centered, matching progressive_globe default view
56+
const globalRect = Cesium.Rectangle.fromDegrees(-180, -60, 180, 80);
57+
viewer.camera.setView({ destination: globalRect });
58+
59+
// Load H3 cluster data from R2
60+
const R2 = "https://pub-a18234d962364c22a50c787b7ca09fa5.r2.dev";
61+
62+
async function loadData() {
63+
const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
64+
const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
65+
const worker_url = URL.createObjectURL(
66+
new Blob([`importScripts("${bundle.mainWorker}");`], { type: "text/javascript" })
67+
);
68+
const worker = new Worker(worker_url);
69+
const logger = new duckdb.ConsoleLogger();
70+
const db = new duckdb.AsyncDuckDB(logger, worker);
71+
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
72+
const conn = await db.connect();
73+
74+
// Load res4 data for zoomed-out view
75+
const res4 = await conn.query(`
76+
SELECT h3_cell, dominant_source, sample_count, center_lat, center_lng
77+
FROM read_parquet('${R2}/isamples_202601_h3_summary_res4.parquet')
78+
`);
79+
80+
const data = res4.toArray().map(r => ({
81+
h3: r.h3_cell,
82+
source: r.dominant_source,
83+
count: Number(r.sample_count),
84+
lat: Number(r.center_lat),
85+
lon: Number(r.center_lng)
86+
}));
87+
88+
// Color by source
89+
const sourceColors = {
90+
SESAR: Cesium.Color.fromCssColorString("#2196F3"),
91+
OPENCONTEXT: Cesium.Color.fromCssColorString("#FF9800"),
92+
GEOME: Cesium.Color.fromCssColorString("#4CAF50"),
93+
SMITHSONIAN: Cesium.Color.fromCssColorString("#E91E63")
94+
};
95+
96+
const points = new Cesium.PointPrimitiveCollection();
97+
viewer.scene.primitives.add(points);
98+
99+
const scalar = new Cesium.NearFarScalar(1.5e2, 1.5, 8.0e6, 0.5);
100+
101+
for (const d of data) {
102+
const sz = Math.min(2 + Math.log2(d.count + 1) * 1.2, 14);
103+
points.add({
104+
position: Cesium.Cartesian3.fromDegrees(d.lon, d.lat),
105+
pixelSize: sz,
106+
color: sourceColors[d.source] || Cesium.Color.WHITE,
107+
scaleByDistance: scalar
108+
});
109+
}
110+
111+
console.log(`Loaded ${data.length} clusters`);
112+
window._dataLoaded = true;
113+
}
114+
115+
loadData().catch(e => {
116+
console.error("Data load failed:", e);
117+
window._dataLoaded = true; // proceed anyway with just the globe
118+
});
119+
</script>
120+
</body>
121+
</html>

0 commit comments

Comments
 (0)