Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions tools/capture_globe_rotation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
Capture a rotating globe animation from a standalone Cesium page.
Produces animated WebP (via img2webp) and GIF (via ffmpeg).

Prerequisites:
pip install playwright
playwright install chromium
brew install webp ffmpeg # for img2webp and ffmpeg

Usage:
# 1. Serve this directory locally:
python -m http.server 8765 --directory tools/

# 2. Run the capture:
python tools/capture_globe_rotation.py

# 3. Output lands in /tmp/isamples_globe.webp (copy to assets/)

Tunable parameters:
--frames 120 Number of frames (more = smoother but larger file)
--duration 15 Loop duration in seconds (higher = slower rotation)
--width 800 Viewport width
--height 500 Viewport height
--quality 40 WebP quality (0-100, lower = smaller file)
"""

import asyncio
import argparse
import os
import shutil
import tempfile
import math


async def capture_globe(num_frames=120, duration_sec=15, output_path="/tmp/isamples_globe.webp",
width=800, height=500, quality=40,
url="http://localhost:8765/globe_capture.html"):
from playwright.async_api import async_playwright

frame_dir = tempfile.mkdtemp(prefix="globe_frames_")

print(f"Capturing {num_frames} frames for {duration_sec}s animation at {width}x{height}")
print(f"URL: {url}")

async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=['--enable-webgl', '--use-gl=swiftshader']
)
page = await browser.new_page(viewport={"width": width, "height": height})
page.on("console", lambda msg: None) # suppress noise

print("Loading globe page...")
await page.goto(url, wait_until="networkidle", timeout=120000)

# Wait for DuckDB data to load
print("Waiting for Cesium + data...")
try:
await page.wait_for_function("() => window._dataLoaded === true", timeout=60000)
print("Data loaded!")
except Exception as e:
print(f"Data load timeout ({e}) — checking viewer anyway")

# Let imagery tiles render
await page.wait_for_timeout(5000)

# Verify viewer is accessible
has_viewer = await page.evaluate("() => !!window._viewer && !!window._viewer.scene")
if not has_viewer:
print("ERROR: Cesium viewer not accessible")
await browser.close()
return

cluster_count = await page.evaluate("""
() => {
const prims = window._viewer.scene.primitives;
let total = 0;
for (let i = 0; i < prims.length; i++) {
try { if (prims.get(i).length) total += prims.get(i).length; } catch(e) {}
}
return total;
}
""")
print(f"Points on globe: {cluster_count}")

# Full 360° rotation spread across all frames
rotation_per_frame = (2 * math.pi) / num_frames

print("Capturing frames...")
for i in range(num_frames):
await page.evaluate(f"""
() => {{
window._viewer.scene.camera.rotate(
Cesium.Cartesian3.UNIT_Z,
-{rotation_per_frame}
);
}}
""")

# Wait for Cesium to render the frame
await page.evaluate("""
() => new Promise(resolve => {
window._viewer.scene.requestRender();
requestAnimationFrame(() => requestAnimationFrame(resolve));
})
""")
await page.wait_for_timeout(40)

frame_path = os.path.join(frame_dir, f"frame_{i:04d}.png")
await page.screenshot(path=frame_path)

if (i + 1) % 30 == 0 or i == 0:
print(f" Frame {i+1}/{num_frames}")

await browser.close()

print(f"\nAll {num_frames} frames captured. Stitching...")

# ms per frame for the target duration
ms_per_frame = int((duration_sec / num_frames) * 1000)

# Animated WebP via img2webp (ffmpeg's libwebp_anim doesn't produce
# proper multi-frame WebP reliably)
frame_glob = os.path.join(frame_dir, "frame_*.png")
cmd_webp = (
f'img2webp -loop 0 -lossy -q {quality} -d {ms_per_frame} '
f'{frame_glob} -o "{output_path}"'
)
os.system(cmd_webp)

# Animated GIF fallback via ffmpeg
fps = num_frames / duration_sec
gif_path = output_path.replace('.webp', '.gif')
cmd_gif = (
f'ffmpeg -y -framerate {fps} -i "{frame_dir}/frame_%04d.png" '
f'-vf "scale={width}:-1:flags=lanczos,split[s0][s1];'
f'[s0]palettegen=max_colors=64[p];[s1][p]paletteuse=dither=bayer" '
f'-loop 0 "{gif_path}" 2>/dev/null'
)
os.system(cmd_gif)

# Static fallback frame
static_path = output_path.replace('.webp', '_static.png')
shutil.copy(os.path.join(frame_dir, "frame_0000.png"), static_path)

# Report
print("\nOutput files:")
for f in [output_path, gif_path, static_path]:
if os.path.exists(f):
size_mb = os.path.getsize(f) / 1024 / 1024
print(f" {os.path.basename(f)}: {size_mb:.1f} MB")

shutil.rmtree(frame_dir)
print(f"\nDone! Copy {output_path} to assets/isamples_globe.webp to deploy.")


def main():
parser = argparse.ArgumentParser(description="Capture rotating globe animation")
parser.add_argument("--frames", type=int, default=120, help="Number of frames (default: 120)")
parser.add_argument("--duration", type=float, default=15.0, help="Animation duration in seconds (default: 15)")
parser.add_argument("--output", default="/tmp/isamples_globe.webp", help="Output path")
parser.add_argument("--width", type=int, default=800, help="Width in pixels (default: 800)")
parser.add_argument("--height", type=int, default=500, help="Height in pixels (default: 500)")
parser.add_argument("--quality", type=int, default=40, help="WebP quality 0-100 (default: 40)")
parser.add_argument("--url", default="http://localhost:8765/globe_capture.html", help="Page URL")
args = parser.parse_args()

asyncio.run(capture_globe(
num_frames=args.frames,
duration_sec=args.duration,
output_path=args.output,
width=args.width,
height=args.height,
quality=args.quality,
url=args.url
))


if __name__ == "__main__":
main()
121 changes: 121 additions & 0 deletions tools/globe_capture.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://cesium.com/downloads/cesiumjs/releases/1.124/Build/Cesium/Cesium.js"></script>
<link href="https://cesium.com/downloads/cesiumjs/releases/1.124/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
<style>
html, body, #cesiumContainer {
margin: 0; padding: 0;
width: 100%; height: 100%;
overflow: hidden;
background: #000;
}
</style>
</head>
<body>
<div id="cesiumContainer"></div>
<script type="module">
import * as duckdb from 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.29.0/+esm';

// Ion token from progressive_globe.qmd
Cesium.Ion.defaultAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIwNzk3NjkyMy1iNGI1LTRkN2UtODRiMy04OTYwYWE0N2M3ZTkiLCJpZCI6Njk1MTcsImlhdCI6MTYzMzU0MTQ3N30.e70dpNzOCDRLDGxRguQCC-tRzGzA-23Xgno5lNgCeB4';

let viewer;
try {
viewer = new Cesium.Viewer("cesiumContainer", {
timeline: false,
animation: false,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
infoBox: false,
selectionIndicator: false,
fullscreenButton: false,
requestRenderMode: false
});

// Remove credits / logo for clean capture
try {
viewer.cesiumWidget.creditContainer.style.display = "none";
} catch(e) {
try { viewer._cesiumWidget._creditContainer.style.display = "none"; } catch(e2) {}
}

// Expose viewer globally for Playwright
window._viewer = viewer;
console.log("Viewer created successfully");
} catch(e) {
console.error("Viewer creation failed:", e);
window._viewerError = e.message;
}

// Set initial camera: equator-centered, matching progressive_globe default view
const globalRect = Cesium.Rectangle.fromDegrees(-180, -60, 180, 80);
viewer.camera.setView({ destination: globalRect });

// Load H3 cluster data from R2
const R2 = "https://pub-a18234d962364c22a50c787b7ca09fa5.r2.dev";

async function loadData() {
const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
const worker_url = URL.createObjectURL(
new Blob([`importScripts("${bundle.mainWorker}");`], { type: "text/javascript" })
);
const worker = new Worker(worker_url);
const logger = new duckdb.ConsoleLogger();
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
const conn = await db.connect();

// Load res4 data for zoomed-out view
const res4 = await conn.query(`
SELECT h3_cell, dominant_source, sample_count, center_lat, center_lng
FROM read_parquet('${R2}/isamples_202601_h3_summary_res4.parquet')
`);

const data = res4.toArray().map(r => ({
h3: r.h3_cell,
source: r.dominant_source,
count: Number(r.sample_count),
lat: Number(r.center_lat),
lon: Number(r.center_lng)
}));

// Color by source
const sourceColors = {
SESAR: Cesium.Color.fromCssColorString("#2196F3"),
OPENCONTEXT: Cesium.Color.fromCssColorString("#FF9800"),
GEOME: Cesium.Color.fromCssColorString("#4CAF50"),
SMITHSONIAN: Cesium.Color.fromCssColorString("#E91E63")
};

const points = new Cesium.PointPrimitiveCollection();
viewer.scene.primitives.add(points);

const scalar = new Cesium.NearFarScalar(1.5e2, 1.5, 8.0e6, 0.5);

for (const d of data) {
const sz = Math.min(2 + Math.log2(d.count + 1) * 1.2, 14);
points.add({
position: Cesium.Cartesian3.fromDegrees(d.lon, d.lat),
pixelSize: sz,
color: sourceColors[d.source] || Cesium.Color.WHITE,
scaleByDistance: scalar
});
}

console.log(`Loaded ${data.length} clusters`);
window._dataLoaded = true;
}

loadData().catch(e => {
console.error("Data load failed:", e);
window._dataLoaded = true; // proceed anyway with just the globe
});
</script>
</body>
</html>
Loading