diff --git a/.gitignore b/.gitignore index 1b47f2e..ad28357 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build/ *.egg .eggs/ *.so +*.log # Virtual environments .venv/ diff --git a/Dockerfile b/Dockerfile index 3c0f820..a66a0cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,14 @@ COPY examples/data/ examples/data/ # Create data directories for SQLite + rendered files RUN mkdir -p /app/data /app/server/data/files +# Single source of truth for the bound port. The working-dir config.toml sets a +# different dev port (7777), so without this load_config().server.port would +# disagree with the port uvicorn actually binds below — and headless-screenshot +# self-navigation (_internal_base_url) would target the wrong port. Setting the +# env makes config.server.port == the bound port. Keep this in sync with the +# --port in CMD. +ENV MAPCONTROL_PORT=8000 + EXPOSE 8000 WORKDIR /app/server diff --git a/README.md b/README.md index ceb04b9..498e603 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ [![MapLibre](https://img.shields.io/badge/MapLibre-GL-396CB2?logo=maplibre&logoColor=white)](https://maplibre.org/) [![ESIP](https://img.shields.io/badge/ESIP-Federation-1B6CA8)](https://www.esipfed.org/) +--- +![Demo: Flyover to Matterhorn](docs/output2.gif) --- Ask your AI assistant to *"draw the burn scar over Los Alamos and fly the camera to it"* — and watch it happen live in a browser tab. MapControl is a headless map server with a real-time MapLibre frontend: create maps, add GeoJSON and GeoTIFF overlays, animate the camera, switch basemaps and themes, take screenshots — over a **Python SDK**, a **REST API**, or the **Model Context Protocol** for Claude, Cline, and any other MCP client. @@ -51,7 +53,7 @@ Think of it as the Star Trek computer's map console. You say the words; the map | [`server/`](server/) | FastAPI server — REST API, WebSocket hub, MCP server, GeoTIFF & screenshot services, auth portal | | [`sdk/`](sdk/) | `mapcontrol` — typed Python client SDK | | [`examples/`](examples/) | Runnable demo scripts (shapes, terrain, glyphs, GeoTIFFs) + sample data | -| [`docs/`](docs/) | Guides — MCP integration, LLM context block, MCP Apps field guide, map-engine comparison | +| [`docs/`](docs/) | Guides — MCP integration, LLM context block, MCP Apps field guide, map-engine comparison, Puppeteer animation skills | | [`deploy/`](deploy/) | Deployment helpers (local PyPI index for the SDK) | ## Quick start @@ -71,6 +73,8 @@ services: docker compose up -d ``` +> **Port 8000 already taken on your machine?** (`lsof -i :8000` shows what's using it.) Remap only the *host* side and leave the container port unchanged — `ports: ["8080:8000"]` — then reach it at `http://localhost:8080`. Keep the container on 8000: the image binds 8000 and self-navigates there for screenshots, so changing the container side would break them. + **Verify it's up:** ```bash @@ -202,6 +206,11 @@ python examples/demo_glyphs.py # glyph markers & labels Sample GeoTIFFs live in [`examples/data/`](examples/data/). +Want to drive the map from a **browser** instead of Python — for camera animations, +recorded flythroughs, or screenshot capture? See the reference +**[Puppeteer animation skills](docs/puppeteer-skills/)** (ballistic flyTo tours, 3D terrain +orbits, keyframe stills, frame-sequence recording). + ## Running tests The acceptance suites run inside the same image you deploy — exactly how CI gates every push: diff --git a/docs/output2.gif b/docs/output2.gif new file mode 100644 index 0000000..8a03e23 Binary files /dev/null and b/docs/output2.gif differ diff --git a/docs/puppeteer-skills/README.md b/docs/puppeteer-skills/README.md new file mode 100644 index 0000000..8e35ecd --- /dev/null +++ b/docs/puppeteer-skills/README.md @@ -0,0 +1,75 @@ +# Puppeteer animation skills (reference examples) + +These are **reference skills** — illustrative, copy-and-adapt examples that show how to +drive the MapControl web map with [Puppeteer](https://pptr.dev) to produce animations +for different scenarios. They are documentation, not a shipped/tested package; treat each +`SKILL.md` as a recipe and each `animate.mjs` as a starting point. + +Each skill drives a **live map page** the same way a browser user would: it navigates to a +map URL, waits for the map to be ready, then scripts camera moves. Nothing here reaches +into private server internals — animation goes through the in-page MapLibre map object the +page already publishes. + +## What the page gives you + +The served map page publishes two hooks the moment it is ready (see +[`server/mapcontrol_server/static/esip-contract.js`](../../server/mapcontrol_server/static/esip-contract.js)): + +| Hook | What it is | Use it for | +|---|---|---| +| `window.__esipInternals.map` | the raw **MapLibre GL JS** `Map` instance | camera animation — `flyTo`, `easeTo`, `rotateTo`, `setBearing`, `setPitch` | +| `window.ESIPMap` | the **public command surface** | basemap, visibility, `zoomToAssets`, reading the asset registry | +| `esip:ready` event | fired once the contract is live | knowing when the hooks exist | + +Because animation just calls MapLibre's own camera methods, everything MapLibre supports +is available — including the smooth van Wijk `flyTo` and 3D globe + terrain (the same +terrain/sky path fixed in the server shell). + +## Prerequisites + +```bash +npm install puppeteer +``` + +You also need a **map to point at**. Create one first (any of the usual ways) and grab its +`map_id`: + +```bash +# Minimal: create a map over REST and read back the id +curl -s -X POST http://localhost:8000/api/maps | python3 -c "import sys,json; print(json.load(sys.stdin)['map_id'])" +``` + +or from the Python SDK: + +```python +from mapcontrol import MapControl +session = MapControl("http://localhost:8000").create_map() +print(session.map_id) # feed this to MAP_ID below +``` + +The map URL every skill opens is: + +``` +http://localhost:8000/map/?ui=none +``` + +`ui=none` serves the **naked canvas** (no picker, no draw tools) — the cleanest frame for a +recording. Drop it if you want the chrome. If `user_session` is omitted the page +auto-creates one, which is fine for a throwaway animation. + +## Shared helper + +All skills import [`lib/esip-map.mjs`](lib/esip-map.mjs), a tiny helper that launches a +browser, opens a map URL, and resolves once `window.__esipInternals.map` exists and the +style has loaded. Read it once; the per-skill scripts stay short. + +## The skills + +| Skill | Scenario | +|---|---| +| [`flyto-tour/`](flyto-tour/SKILL.md) | Ballistic **city-to-city tour** — smooth `flyTo` between waypoints | +| [`terrain-orbit/`](terrain-orbit/SKILL.md) | **3D globe orbit** around a peak (Matterhorn) with terrain + sky | +| [`keyframe-screenshots/`](keyframe-screenshots/SKILL.md) | Capture **PNG stills** at scripted keyframes | +| [`record-frames/`](record-frames/SKILL.md) | Capture a **frame sequence** during an animation (→ GIF/MP4) | + +Each folder has a `SKILL.md` (when to use it + the recipe) and a runnable `animate.mjs`. diff --git a/docs/puppeteer-skills/flyto-tour/SKILL.md b/docs/puppeteer-skills/flyto-tour/SKILL.md new file mode 100644 index 0000000..1c84b22 --- /dev/null +++ b/docs/puppeteer-skills/flyto-tour/SKILL.md @@ -0,0 +1,57 @@ +--- +name: flyto-tour +description: Animate a smooth ballistic camera tour across a list of geographic waypoints on a MapControl map using Puppeteer and MapLibre's flyTo. Use when you want a cinematic city-to-city or site-to-site flythrough. +--- + +# Skill: Ballistic flyTo tour + +Fly the camera between a sequence of waypoints with MapLibre's `flyTo` — the smooth +van Wijk zoom-out-then-in arc, so long hops don't tear through tiles. + +## When to use + +- A "world tour" or multi-site flythrough for a demo, header, or explainer. +- Any time you have an ordered list of `[lon, lat, zoom]` stops to visit. + +## Recipe + +1. Open the map with the shared helper and wait until it's ready. +2. For each waypoint, call `flyTo` and `await` `moveend` before the next hop. +3. Tune `speed`/`curve` for how aggressive the arc is; add a short hold at each stop. + +The waypoints below are illustrative — swap in your own. See +[`animate.mjs`](animate.mjs) for the runnable version. + +```js +import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs"; + +const STOPS = [ + { name: "New York", center: [-74.0060, 40.7128], zoom: 12 }, + { name: "London", center: [-0.1276, 51.5074], zoom: 12 }, + { name: "Tokyo", center: [139.6917, 35.6895], zoom: 12 }, + { name: "Sydney", center: [151.2093, -33.8688], zoom: 12 }, +]; + +const { page, close } = await openMap({ mapId: process.env.MAP_ID }); + +for (const stop of STOPS) { + console.log(`→ ${stop.name}`); + await cameraMove(page, "flyTo", { + center: stop.center, + zoom: stop.zoom, + speed: 0.8, // lower = slower, more cinematic + curve: 1.42, // arc "zoom-out" amount + essential: true, + }); + await sleep(1200); // hold on the destination +} + +await close(); +``` + +## Knobs + +- `speed` — animation pace (default ~1.2). Lower is slower/dramatic. +- `curve` — how far the camera zooms out mid-flight for long hops. +- Hold time — the `sleep()` between stops. +- Combine with `terrain-orbit` to arrive and then orbit a destination. diff --git a/docs/puppeteer-skills/flyto-tour/animate.mjs b/docs/puppeteer-skills/flyto-tour/animate.mjs new file mode 100644 index 0000000..6609c1a --- /dev/null +++ b/docs/puppeteer-skills/flyto-tour/animate.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// Reference skill: ballistic flyTo tour across waypoints. +// +// MAP_ID= node animate.mjs +// +// Requires a running server (default http://localhost:8000, override with +// MAPCONTROL_SERVER) and an existing map_id. See ../README.md. + +import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs"; + +const MAP_ID = process.env.MAP_ID; +if (!MAP_ID) { + console.error("Set MAP_ID= (see docs/puppeteer-skills/README.md)"); + process.exit(1); +} + +// Waypoints — swap in your own [lon, lat, zoom] stops. +const STOPS = [ + { name: "New York City", center: [-74.006, 40.7128], zoom: 12 }, + { name: "London", center: [-0.1276, 51.5074], zoom: 12 }, + { name: "Tokyo", center: [139.6917, 35.6895], zoom: 12 }, + { name: "Sydney", center: [151.2093, -33.8688], zoom: 12 }, + { name: "Cape Town", center: [18.4241, -33.9249], zoom: 12 }, +]; + +const { page, close } = await openMap({ mapId: MAP_ID, headless: true }); + +// Start planted on the first stop, then fly the rest. +await cameraMove(page, "jumpTo", { center: STOPS[0].center, zoom: STOPS[0].zoom }); +console.log(`start: ${STOPS[0].name}`); +await sleep(800); + +for (let i = 1; i < STOPS.length; i++) { + const stop = STOPS[i]; + console.log(`fly → ${stop.name}`); + await cameraMove(page, "flyTo", { + center: stop.center, + zoom: stop.zoom, + speed: 0.8, + curve: 1.42, + essential: true, + }); + await sleep(1200); +} + +console.log("tour complete"); +await close(); diff --git a/docs/puppeteer-skills/keyframe-screenshots/SKILL.md b/docs/puppeteer-skills/keyframe-screenshots/SKILL.md new file mode 100644 index 0000000..f143aa9 --- /dev/null +++ b/docs/puppeteer-skills/keyframe-screenshots/SKILL.md @@ -0,0 +1,49 @@ +--- +name: keyframe-screenshots +description: Move a MapControl map camera to a set of scripted keyframes and capture a PNG still at each one using Puppeteer. Use to generate documentation stills or thumbnails, or to visually verify the map renders a given view. +--- + +# Skill: Keyframe screenshots + +Drive the camera to named keyframes and snapshot each. This is the scenario for producing +docs imagery, README thumbnails, or a quick visual regression check (e.g. confirming the 3D +view renders with a clean console after the sky fix). + +## When to use + +- You want a handful of PNG stills of specific views, not a full animation. +- You want to assert "this view renders" in CI without a running human. + +## Recipe + +Capture with Puppeteer's own `page.screenshot()` (browser-side, no server round-trip). See +[`animate.mjs`](animate.mjs). + +```js +import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs"; + +const KEYFRAMES = [ + { name: "matterhorn-3d", center: [7.6586, 45.9763], zoom: 12.5, pitch: 70 }, + { name: "zermatt-town", center: [7.7491, 46.0207], zoom: 14, pitch: 45 }, +]; + +const { page, close } = await openMap({ mapId: process.env.MAP_ID }); + +for (const kf of KEYFRAMES) { + await cameraMove(page, "flyTo", { ...kf, essential: true }); + await sleep(1500); // let tiles finish + await page.screenshot({ path: `${kf.name}.png` }); + console.log(`saved ${kf.name}.png`); +} + +await close(); +``` + +## Notes + +- `page.screenshot()` grabs exactly what the viewport shows — set the viewport in `openMap` + to control output resolution. +- The server also has its own screenshot endpoint + (`POST /api/maps/{map_id}/sessions/{user_session_id}/screenshot`) if you'd rather capture + server-side; this skill stays fully client-side so it needs no session id. +- To turn keyframes into a visual check, compare each PNG against a committed baseline. diff --git a/docs/puppeteer-skills/keyframe-screenshots/animate.mjs b/docs/puppeteer-skills/keyframe-screenshots/animate.mjs new file mode 100644 index 0000000..031e6b4 --- /dev/null +++ b/docs/puppeteer-skills/keyframe-screenshots/animate.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// Reference skill: capture PNG stills at scripted keyframes. +// +// MAP_ID= node animate.mjs +// +// Writes one PNG per keyframe into the current directory. + +import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs"; + +const MAP_ID = process.env.MAP_ID; +if (!MAP_ID) { + console.error("Set MAP_ID= (see docs/puppeteer-skills/README.md)"); + process.exit(1); +} + +const KEYFRAMES = [ + { name: "matterhorn-3d", center: [7.6586, 45.9763], zoom: 12.5, pitch: 70, bearing: 20 }, + { name: "zermatt-town", center: [7.7491, 46.0207], zoom: 14, pitch: 45, bearing: 0 }, + { name: "alps-wide", center: [8.0, 46.2], zoom: 8, pitch: 30, bearing: 0 }, +]; + +const { page, close } = await openMap({ mapId: MAP_ID, headless: true, viewport: [1600, 900] }); + +for (const kf of KEYFRAMES) { + console.log(`framing ${kf.name}`); + await cameraMove(page, "flyTo", { + center: kf.center, + zoom: kf.zoom, + pitch: kf.pitch, + bearing: kf.bearing, + essential: true, + }); + await sleep(1500); // let tiles finish loading before the snap + await page.screenshot({ path: `${kf.name}.png` }); + console.log(`saved ${kf.name}.png`); +} + +await close(); diff --git a/docs/puppeteer-skills/lib/esip-map.mjs b/docs/puppeteer-skills/lib/esip-map.mjs new file mode 100644 index 0000000..054e932 --- /dev/null +++ b/docs/puppeteer-skills/lib/esip-map.mjs @@ -0,0 +1,80 @@ +// Shared helper for the Puppeteer animation skills. +// +// Launches a browser, opens a MapControl map page, and resolves once the map +// is genuinely ready to animate: the page's `window.__esipInternals.map` +// (the raw MapLibre GL JS instance) exists AND its style has loaded. +// +// This is reference/example code — adapt freely. + +import puppeteer from "puppeteer"; + +const DEFAULT_SERVER = process.env.MAPCONTROL_SERVER || "http://localhost:8000"; + +/** + * Open a map and wait until it is ready to animate. + * + * @param {object} opts + * @param {string} opts.mapId - the map_id to open (required) + * @param {string} [opts.server] - server base URL + * @param {boolean} [opts.uiNone] - serve the naked canvas (default true) + * @param {boolean} [opts.headless] - run headless (default true) + * @param {[number, number]} [opts.viewport] - [width, height], default 1280x720 + * @returns {Promise<{browser, page, close}>} + */ +export async function openMap({ + mapId, + server = DEFAULT_SERVER, + uiNone = true, + headless = true, + viewport = [1280, 720], +} = {}) { + if (!mapId) throw new Error("openMap: mapId is required"); + + const browser = await puppeteer.launch({ + headless, + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); + const page = await browser.newPage(); + await page.setViewport({ width: viewport[0], height: viewport[1] }); + + const url = `${server}/map/${mapId}${uiNone ? "?ui=none" : ""}`; + await page.goto(url, { waitUntil: "networkidle2" }); + + // Wait for the map object to be published and its style to finish loading. + await page.waitForFunction( + () => { + const m = window.__esipInternals && window.__esipInternals.map; + return !!m && m.isStyleLoaded(); + }, + { timeout: 30000 }, + ); + + return { + browser, + page, + close: () => browser.close(), + }; +} + +/** + * Run a MapLibre camera call and resolve when the camera comes to rest. + * `method` is any camera method name ('flyTo' | 'easeTo' | 'jumpTo' | ...). + * + * Resolves on the map's 'moveend' event so callers can `await` a move instead + * of guessing a sleep duration. + */ +export async function cameraMove(page, method, options) { + await page.evaluate( + (method, options) => + new Promise((resolve) => { + const map = window.__esipInternals.map; + map.once("moveend", () => resolve()); + map[method](options); + }), + method, + options, + ); +} + +/** Small await-able sleep for pacing between moves. */ +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); diff --git a/docs/puppeteer-skills/record-frames/SKILL.md b/docs/puppeteer-skills/record-frames/SKILL.md new file mode 100644 index 0000000..3c44e64 --- /dev/null +++ b/docs/puppeteer-skills/record-frames/SKILL.md @@ -0,0 +1,63 @@ +--- +name: record-frames +description: Capture a numbered sequence of PNG frames while a MapControl map animates, so the frames can be assembled into a GIF or MP4 with ffmpeg. Use to produce a shareable animated clip (e.g. a header animation) from a Puppeteer-driven camera move. +--- + +# Skill: Record a frame sequence + +Capture frames on a fixed cadence while the camera animates, then hand the sequence to +`ffmpeg` to make a GIF or MP4. This is the scenario for producing an actual animated clip +(a docs header, a social preview) rather than stills. + +## When to use + +- You need a looping GIF/MP4 of a camera move, not a live page. +- You want deterministic frames (grab N frames, one every M ms) you can re-encode. + +## How it works + +Rather than screen-record, this steps the animation in small time slices and calls +`page.screenshot()` for each — giving evenly spaced, artifact-free frames. It pairs well +with `terrain-orbit` (record an orbit) or `flyto-tour` (record a flythrough). + +## Recipe + +See [`animate.mjs`](animate.mjs). Sketch: + +```js +import { openMap, sleep } from "../lib/esip-map.mjs"; + +const { page, close } = await openMap({ mapId: process.env.MAP_ID }); + +// Kick off a non-blocking orbit inside the page, then sample frames from Node. +await page.evaluate(() => { + const map = window.__esipInternals.map; + map.setProjection({ type: "globe" }); + map.jumpTo({ center: [7.6586, 45.9763], zoom: 12.5, pitch: 70 }); +}); + +const FRAMES = 72; // 72 frames * 30ms bearing step ≈ one full turn +for (let i = 0; i < FRAMES; i++) { + await page.evaluate((b) => window.__esipInternals.map.setBearing(b), (i / FRAMES) * 360); + await sleep(60); // let the frame paint + await page.screenshot({ path: `frame_${String(i).padStart(4, "0")}.png` }); +} + +await close(); +``` + +Then encode: + +```bash +# GIF +ffmpeg -framerate 24 -i frame_%04d.png -vf "scale=800:-1:flags=lanczos" orbit.gif +# MP4 +ffmpeg -framerate 24 -i frame_%04d.png -c:v libx264 -pix_fmt yuv420p orbit.mp4 +``` + +## Knobs + +- `FRAMES` × bearing step — total rotation and smoothness. +- `sleep()` per frame — paint budget; raise it if frames look half-drawn. +- `-framerate` on encode — playback speed, independent of capture cadence. +- Swap the in-page move for a `flyTo` path to record a flythrough instead of an orbit. diff --git a/docs/puppeteer-skills/record-frames/animate.mjs b/docs/puppeteer-skills/record-frames/animate.mjs new file mode 100644 index 0000000..bc01e0b --- /dev/null +++ b/docs/puppeteer-skills/record-frames/animate.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Reference skill: capture a numbered frame sequence during an orbit. +// +// MAP_ID= node animate.mjs +// +// Writes frame_0000.png ... into ./frames/. Encode afterwards, e.g.: +// ffmpeg -framerate 24 -i frames/frame_%04d.png -vf scale=800:-1 orbit.gif + +import { mkdir } from "node:fs/promises"; +import { openMap, sleep } from "../lib/esip-map.mjs"; + +const MAP_ID = process.env.MAP_ID; +if (!MAP_ID) { + console.error("Set MAP_ID= (see docs/puppeteer-skills/README.md)"); + process.exit(1); +} + +const MATTERHORN = [7.6586, 45.9763]; +const FRAMES = 72; // one frame per 5° → a full 360° orbit + +await mkdir("frames", { recursive: true }); + +const { page, close } = await openMap({ mapId: MAP_ID, headless: true, viewport: [1280, 720] }); + +// Set up the 3D scene (globe + terrain + sky), framed on the peak. +await page.evaluate((center) => { + const map = window.__esipInternals.map; + map.setProjection({ type: "globe" }); + if (!map.getSource("terrain-dem")) { + map.addSource("terrain-dem", { + type: "raster-dem", + url: "https://demotiles.maplibre.org/terrain-tiles/tiles.json", + tileSize: 256, + }); + } + map.setTerrain({ source: "terrain-dem", exaggeration: 1.5 }); + map.setSky({ "sky-color": "#199EF3", "horizon-color": "#ffffff", "fog-color": "#ffffff" }); + map.jumpTo({ center, zoom: 12.5, pitch: 70, bearing: 0 }); +}, MATTERHORN); + +await sleep(2000); // let terrain tiles settle before the first frame + +console.log(`capturing ${FRAMES} frames`); +for (let i = 0; i < FRAMES; i++) { + const bearing = (i / FRAMES) * 360; + await page.evaluate((b) => window.__esipInternals.map.setBearing(b), bearing); + await sleep(60); // paint budget + const name = `frames/frame_${String(i).padStart(4, "0")}.png`; + await page.screenshot({ path: name }); + if (i % 12 === 0) console.log(` ${i}/${FRAMES}`); +} + +console.log("done — encode frames/ with ffmpeg (see SKILL.md)"); +await close(); diff --git a/docs/puppeteer-skills/terrain-orbit/SKILL.md b/docs/puppeteer-skills/terrain-orbit/SKILL.md new file mode 100644 index 0000000..9f30b6e --- /dev/null +++ b/docs/puppeteer-skills/terrain-orbit/SKILL.md @@ -0,0 +1,69 @@ +--- +name: terrain-orbit +description: Fly to a mountain or landmark, enable 3D globe terrain and sky, then slowly orbit the camera around it using Puppeteer and MapLibre. Use for a dramatic 3D hero shot (e.g. the Matterhorn) for a docs header or demo. +--- + +# Skill: 3D terrain orbit + +Frame a peak in 3D — globe projection, terrain exaggeration, atmospheric sky — then rotate +the camera bearing around it for a slow orbit. This is the "hero shot" scenario. + +## When to use + +- A dramatic 3D flythrough of dramatic relief (the Matterhorn is the canonical subject). +- Any landmark that reads best tilted and rotating rather than flat. + +## How it works + +The map page already supports 3D via the server shell's terrain path (globe projection + +`terrain-dem` source + `setSky`). This skill turns that on through the raw MapLibre map, +tilts the camera (`pitch`), then steps the `bearing` in a loop with `easeTo` to orbit. + +> Terrain here is driven directly on the MapLibre map for a self-contained example. If your +> deployment prefers to flip terrain through the server (so the mode is part of session +> state), send the `set_terrain` event instead and just do the pitch/bearing orbit here. + +## Recipe + +See [`animate.mjs`](animate.mjs). The core: + +```js +import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs"; + +const MATTERHORN = [7.6586, 45.9763]; // lon, lat + +const { page, close } = await openMap({ mapId: process.env.MAP_ID }); + +// Enable globe + terrain + sky, then frame the peak tilted. +await page.evaluate((center) => { + const map = window.__esipInternals.map; + map.setProjection({ type: "globe" }); + if (!map.getSource("terrain-dem")) { + map.addSource("terrain-dem", { + type: "raster-dem", + url: "https://demotiles.maplibre.org/terrain-tiles/tiles.json", + tileSize: 256, + }); + } + map.setTerrain({ source: "terrain-dem", exaggeration: 1.5 }); + map.setSky({ "sky-color": "#199EF3", "horizon-color": "#ffffff", "fog-color": "#ffffff" }); + map.jumpTo({ center, zoom: 12.5, pitch: 70, bearing: 0 }); +}, MATTERHORN); + +await sleep(1500); + +// Orbit: step the bearing a full turn. +for (let bearing = 0; bearing <= 360; bearing += 30) { + await cameraMove(page, "easeTo", { bearing, duration: 1000, essential: true }); +} + +await close(); +``` + +## Knobs + +- `exaggeration` — terrain height multiplier (1.5 is punchy; 1.0 is true-scale). +- `pitch` — camera tilt (0 = top-down, ~70 = dramatic). +- Orbit step / `duration` — smaller steps + shorter durations = smoother spin. +- `zoom` — how tightly you frame the peak. +- Swap `MATTERHORN` for any `[lon, lat]`. diff --git a/docs/puppeteer-skills/terrain-orbit/animate.mjs b/docs/puppeteer-skills/terrain-orbit/animate.mjs new file mode 100644 index 0000000..f5b3d10 --- /dev/null +++ b/docs/puppeteer-skills/terrain-orbit/animate.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// Reference skill: 3D globe terrain orbit around a peak (Matterhorn). +// +// MAP_ID= node animate.mjs +// +// Requires a running server and an existing map_id. See ../README.md. + +import { openMap, cameraMove, sleep } from "../lib/esip-map.mjs"; + +const MAP_ID = process.env.MAP_ID; +if (!MAP_ID) { + console.error("Set MAP_ID= (see docs/puppeteer-skills/README.md)"); + process.exit(1); +} + +const MATTERHORN = [7.6586, 45.9763]; // lon, lat + +const { page, close } = await openMap({ mapId: MAP_ID, headless: true }); + +// Enable globe projection + terrain + atmospheric sky, framed on the peak. +console.log("enabling 3D terrain + sky"); +await page.evaluate((center) => { + const map = window.__esipInternals.map; + map.setProjection({ type: "globe" }); + if (!map.getSource("terrain-dem")) { + map.addSource("terrain-dem", { + type: "raster-dem", + url: "https://demotiles.maplibre.org/terrain-tiles/tiles.json", + tileSize: 256, + }); + } + map.setTerrain({ source: "terrain-dem", exaggeration: 1.5 }); + // MapLibre uses setSky(), NOT a `type: 'sky'` layer (that is Mapbox's API). + map.setSky({ + "sky-color": "#199EF3", + "sky-horizon-blend": 0.5, + "horizon-color": "#ffffff", + "fog-color": "#ffffff", + "fog-ground-blend": 0.5, + }); + map.jumpTo({ center, zoom: 12.5, pitch: 70, bearing: 0 }); +}, MATTERHORN); + +// Let terrain tiles settle before spinning. +await sleep(2000); + +console.log("orbiting"); +for (let bearing = 30; bearing <= 360; bearing += 30) { + await cameraMove(page, "easeTo", { bearing, duration: 1000, essential: true }); +} + +console.log("orbit complete"); +await close(); diff --git a/server/mapcontrol_server/main.py b/server/mapcontrol_server/main.py index fa4a6ef..af02042 100644 --- a/server/mapcontrol_server/main.py +++ b/server/mapcontrol_server/main.py @@ -30,7 +30,7 @@ # MCP layer (Phase 0 of .vision-documents/mcp-compliance-roadmap.md): an in-process # Model Context Protocol server mounted at /mcp (Streamable HTTP). It wraps the # SAME service functions the REST API uses — additive, no separate state. -from .mcp_tools import mcp_server +from .mcp_tools import mcp_server, _internal_base_url # MCP Resources layer (roadmap §6, partial): the map:// resource taxonomy. # Importing the module registers every resource template on mcp_server — @@ -944,6 +944,28 @@ class BasemapPickerControl {{ return (style && style[prop]) || fallback; }} + // Atmospheric sky for 3D/globe terrain modes. MapLibre GL JS has no + // `sky` *layer* type (that is Mapbox's API) — it uses map.setSky(). + // Adding a `type: 'sky'` layer fails style validation and, because the + // error is fired on the map's error event rather than thrown, a + // try/catch around addLayer cannot suppress it. setSky is the correct, + // validation-clean API (MapLibre 5+). + function enableSky() {{ + try {{ + map.setSky({{ + 'sky-color': '#199EF3', + 'sky-horizon-blend': 0.5, + 'horizon-color': '#ffffff', + 'horizon-fog-blend': 0.5, + 'fog-color': '#ffffff', + 'fog-ground-blend': 0.5, + }}); + }} catch(e) {{ /* setSky unsupported in this MapLibre version */ }} + }} + function disableSky() {{ + try {{ map.setSky(undefined); }} catch(e) {{}} + }} + // Compute LngLatBounds from a GeoJSON object function geojsonBounds(geojson) {{ const bounds = new maplibregl.LngLatBounds(); @@ -1833,11 +1855,7 @@ class BasemapPickerControl {{ try {{ map.setProjection({{ type: 'globe' }}); }} catch(e) {{}} ensureTerrainSource(); try {{ map.setTerrain({{ source: 'terrain-dem', exaggeration: 1.5 }}); }} catch(e) {{}} - try {{ - if (!map.getLayer('sky-layer')) {{ - map.addLayer({{ id: 'sky-layer', type: 'sky', paint: {{ 'sky-type': 'atmosphere', 'sky-atmosphere-sun': [0.0, 90.0], 'sky-atmosphere-sun-intensity': 15 }} }}); - }} - }} catch(e) {{}} + enableSky(); }} try {{ map.jumpTo({{ @@ -1911,12 +1929,12 @@ class BasemapPickerControl {{ try {{ map.setProjection({{ type: 'globe' }}); }} catch(e) {{}} ensureTerrainSource(); map.setTerrain({{ source: 'terrain-dem', exaggeration: 1.5 }}); - try {{ if (!map.getLayer('sky-layer')) {{ map.addLayer({{ id: 'sky-layer', type: 'sky', paint: {{ 'sky-type': 'atmosphere', 'sky-atmosphere-sun': [0.0, 90.0], 'sky-atmosphere-sun-intensity': 15 }} }}); }} }} catch(e) {{ /* sky layers not supported in this MapLibre version */ }} + enableSky(); }} else if (snapshot.terrain === '2d') {{ currentTerrain = '2d'; try {{ map.setProjection({{ type: 'mercator' }}); }} catch(e) {{}} map.setTerrain(null); - try {{ if (map.getLayer('sky-layer')) map.removeLayer('sky-layer'); }} catch(e) {{}} + disableSky(); }} // Terrain restore may have changed projection — re-arbitrate // deck-ribbon vs flat-line for any restored arcs. @@ -2090,7 +2108,7 @@ class BasemapPickerControl {{ try {{ map.setProjection({{ type: 'globe' }}); }} catch(e) {{}} ensureTerrainSource(); map.setTerrain({{ source: 'terrain-dem', exaggeration: 1.5 }}); - try {{ if (!map.getLayer('sky-layer')) {{ map.addLayer({{ id: 'sky-layer', type: 'sky', paint: {{ 'sky-type': 'atmosphere', 'sky-atmosphere-sun': [0.0, 90.0], 'sky-atmosphere-sun-intensity': 15 }} }}); }} }} catch(e) {{ /* sky layers not supported */ }} + enableSky(); // Force nadir (straight down) — pitch=0, bearing=0 map.jumpTo({{ pitch: 0, bearing: 0 }}); console.log('Applied default terrain mode: 3D Globe (nadir, pitch=0)'); @@ -2331,8 +2349,8 @@ class BasemapPickerControl {{ try {{ map.setProjection({{ type: 'globe' }}); }} catch(e) {{ console.warn('Globe projection not available:', e.message); }} ensureTerrainSource(); map.setTerrain({{ source: 'terrain-dem', exaggeration: 1.5 }}); - // Add sky layer for atmospheric effect - try {{ if (!map.getLayer('sky-layer')) {{ map.addLayer({{ id: 'sky-layer', type: 'sky', paint: {{ 'sky-type': 'atmosphere', 'sky-atmosphere-sun': [0.0, 90.0], 'sky-atmosphere-sun-intensity': 15 }} }}); }} }} catch(e) {{ /* sky layers not supported in this MapLibre version */ }} + // Atmospheric sky for the 3D view + enableSky(); if (animate) {{ map.easeTo({{ pitch: 60, duration: 1500 }}); }} else {{ @@ -2347,12 +2365,12 @@ class BasemapPickerControl {{ // Remove terrain after animation completes setTimeout(function() {{ map.setTerrain(null); - try {{ if (map.getLayer('sky-layer')) map.removeLayer('sky-layer'); }} catch(e) {{}} + disableSky(); }}, 1600); }} else {{ map.jumpTo({{ pitch: 0, bearing: 0 }}); map.setTerrain(null); - try {{ if (map.getLayer('sky-layer')) map.removeLayer('sky-layer'); }} catch(e) {{}} + disableSky(); }} console.log('Terrain mode: 2D Flat (mercator)'); }} @@ -2599,8 +2617,10 @@ async def take_screenshot( except RuntimeError as e: raise HTTPException(status_code=500, detail=str(e)) else: - # Default path: Playwright headless screenshot - map_url = f"{base_url}/map/{map_id}?user_session={user_session_id}" + # Default path: Playwright headless screenshot. Chromium runs on this + # host and must self-navigate over loopback on the bound port — the + # request base_url may be a public/proxy origin unreachable from here. + map_url = f"{_internal_base_url()}/map/{map_id}?user_session={user_session_id}" try: result = await screenshot_service.take_screenshot_playwright( map_url=map_url, diff --git a/server/mapcontrol_server/mcp_tools.py b/server/mapcontrol_server/mcp_tools.py index 39b054a..9a98fdc 100644 --- a/server/mapcontrol_server/mcp_tools.py +++ b/server/mapcontrol_server/mcp_tools.py @@ -174,6 +174,20 @@ def _public_base_url() -> str: return os.environ.get("MAPCONTROL_PUBLIC_URL", "http://localhost:8000").rstrip("/") +def _internal_base_url() -> str: + """Loopback URL used for server self-navigation (headless screenshots). + + Chromium runs on the same host/container as the server, so it must reach the + service over loopback on the *bound* port. _public_base_url() can be an + external address or reverse-proxy origin (via MAPCONTROL_PUBLIC_URL) that + does not resolve back to this process from inside the container — e.g. + "localhost:8080" inside the container points at the container itself, where + the app may be listening on a different port. Public URLs stay in + _public_base_url() for links handed back to external clients. + """ + return f"http://127.0.0.1:{load_config().server.port}" + + # ─── map:// URI helpers (single source for the resource scheme) ────────────── # Canonical URIs for the Resources layer (mcp_resources.py imports these; they # live here so mcp_resources -> mcp_tools stays a one-way import). @@ -899,7 +913,10 @@ async def take_screenshot( """ await _require_map(map_id) base = _public_base_url() - map_url = f"{base}/map/{map_id}" + # Chromium navigates to the map over loopback (self-navigation), NOT the + # public URL — see _internal_base_url(). `base` is still used below for the + # URLs returned to the client. + map_url = f"{_internal_base_url()}/map/{map_id}" if user_session_id: map_url += f"?user_session={user_session_id}" diff --git a/server/tests/test_screenshot_loopback.py b/server/tests/test_screenshot_loopback.py new file mode 100644 index 0000000..ed820cf --- /dev/null +++ b/server/tests/test_screenshot_loopback.py @@ -0,0 +1,123 @@ +"""Regression: headless screenshots self-navigate over loopback, not the public URL. + +Guards the container-networking bug where `take_screenshot` built the URL that +Chromium navigates to from `_public_base_url()` (MAPCONTROL_PUBLIC_URL). When +the public URL is an external address or reverse-proxy origin, it does not +resolve back to this process from inside the container — e.g. a host that maps +`8080:8000` sets MAPCONTROL_PUBLIC_URL=...:8080, but inside the container the +service listens on 8000, so navigating to :8080 fails and every screenshot +errors out. + +The fix: Chromium self-navigates via `_internal_base_url()` — loopback on the +server's *bound* port — while `_public_base_url()` stays in use for the URLs +returned to external clients. Both screenshot call sites (the MCP +`take_screenshot` tool here in mcp_tools.py and the Playwright fallback in +main.py) share `_internal_base_url()`. + +This test sets an intentionally unreachable public URL, stubs the browser layer +so it records the URL it was asked to open (no real Chromium, no network), and +asserts the tool still succeeds by driving loopback. + +Runnable two ways: + * python tests/test_screenshot_loopback.py (standalone; prints PASS/FAIL, exits 1 on failure) + * pytest tests/test_screenshot_loopback.py (test_screenshot_loopback_url is collected) +""" + +from __future__ import annotations + +import asyncio +import os +import tempfile + +# A public URL that is NOT reachable from inside the container. Must be set +# before importing the app (config/env are read on demand, but this mirrors the +# other gates and keeps the intent obvious). +os.environ["MAPCONTROL_PUBLIC_URL"] = "http://unreachable.invalid:9999" + +# Simulate the container: the image binds this port (Dockerfile ENV +# MAPCONTROL_PORT + --port), while the working-dir config.toml carries a +# different dev port (7777). This asserts the internal URL follows the *bound* +# port, not config.toml — the mismatch that would otherwise silently break +# screenshots in the container. +os.environ["MAPCONTROL_PORT"] = "8000" + +_tmp = tempfile.mkdtemp(prefix="screenshot_loopback_") +os.environ.setdefault("MAPCONTROL_DB_PATH", os.path.join(_tmp, "test.db")) +os.environ.setdefault("MAPCONTROL_FILE_DIR", os.path.join(_tmp, "files")) + +from mapcontrol_server import mcp_tools # noqa: E402 +from mapcontrol_server.config import load_config # noqa: E402 +from mapcontrol_server.services import screenshot_service # noqa: E402 +from mapcontrol_server.services.screenshot_service import ScreenshotResult # noqa: E402 + +UNREACHABLE = "http://unreachable.invalid:9999" + + +async def _run() -> None: + captured: dict[str, str] = {} + + async def fake_playwright(map_url, width=1280, height=720, wait_ms=2000): + # Record the URL Chromium would open, then return a stub result. The + # file need not exist — take_screenshot handles an unreadable PNG. + captured["map_url"] = map_url + return ScreenshotResult( + screenshot_id="sid123", url="/api/files/sid123.png", filename="sid123.png" + ) + + async def fake_require_map(map_id): # avoid needing a real map in the DB + return None + + orig_pw = screenshot_service.take_screenshot_playwright + orig_rm = mcp_tools._require_map + screenshot_service.take_screenshot_playwright = fake_playwright # type: ignore[assignment] + mcp_tools._require_map = fake_require_map # type: ignore[assignment] + try: + result = await mcp_tools.take_screenshot( + map_id="map-123", user_session_id="sess-abc" + ) + finally: + screenshot_service.take_screenshot_playwright = orig_pw # type: ignore[assignment] + mcp_tools._require_map = orig_rm # type: ignore[assignment] + + port = load_config().server.port + nav = captured.get("map_url", "") + + # The tool completed (did not raise) despite the unreachable public URL. + assert result is not None, "take_screenshot returned nothing" + + # The bound port (MAPCONTROL_PORT) wins over config.toml's dev port (7777). + assert port == 8000, f"expected bound port 8000, got {port}" + + # Chromium was pointed at loopback:bound_port, NOT the public URL and NOT + # config.toml's dev port. + expected = f"http://127.0.0.1:{port}/map/map-123?user_session=sess-abc" + assert nav == expected, f"navigation URL was {nav!r}, expected {expected!r}" + assert "unreachable.invalid" not in nav, f"public host leaked into nav URL: {nav!r}" + assert ":7777" not in nav, f"config.toml dev port leaked into nav URL: {nav!r}" + + # The public/internal split is intact: public links still use the public + # base; self-navigation uses loopback. (Both screenshot call sites rely on + # this invariant.) + assert mcp_tools._public_base_url() == UNREACHABLE + assert mcp_tools._internal_base_url() == f"http://127.0.0.1:{port}" + + print(f"screenshot loopback OK: navigated to {nav}") + print("RESULT: ALL PASSED") + + +def test_screenshot_loopback_url() -> None: + """pytest entry point.""" + asyncio.run(_run()) + + +if __name__ == "__main__": + import sys + + try: + asyncio.run(_run()) + except AssertionError as e: + print(f"RESULT: FAILED — {e}") + sys.stdout.flush() + raise SystemExit(1) + sys.stdout.flush() # os._exit skips buffer flush; force it under redirection + os._exit(0)