스킬 불러오는 중
스킬 불러오는 중
iart-ai/map-animation-skills · GitHub
This skill should be used when the user asks to "make a Vox-style map animation", "animate a map zoom", "draw a route on a map", "highlight a country/region", "use Google Earth Studio", "drop pins on a map and animate them", "pan/orbit a camera over a map", or "add map graphics to an explainer video". Covers Earth Studio→After Effects camera import, GeoJSON/SVG vector map motion, pins/routes/highlights/labels, the 12fps stutter cadence, and data-driven Remotion renders.
프로젝트 폴더에서 아래 명령어를 실행하고, 설치할 에이전트를 선택하세요.
npx skills add iart-ai/map-animation-skills --skill map-animation설치 명령을 직접 실행해야 적용됩니다. 지원 에이전트와 필요한 권한·라이선스는 제작자의 안내를 확인하세요.
README.mdreferences/earth-studio-to-ae.md# Google Earth Studio → After Effects — Camera Import Cookbook
The photoreal base-motion path: keyframe a cinematic camera over Google's 3D globe in the browser, export an image sequence plus a 3D camera-track script, and rebuild that exact camera in After Effects so your pins, labels, and routes stick to the right place on the map.
## 1. What Earth Studio exports
Earth Studio (browser tool, earth.google.com/studio) renders two coupled artifacts:
- **Image sequence** — the rendered photoreal frames (JPG or PNG), one per frame, at your chosen resolution/fps. This is your base footage layer.
- **3D camera track (`.jsx`)** — an After Effects script that, when run, rebuilds the camera's position, rotation, and field of view keyframe-for-keyframe, plus **track-point nulls** for any geographic coordinates you marked. This is what lets 2D overlays lock to 3D map locations.
The two are frame-locked: frame N of the image sequence corresponds to frame N of the imported camera. Never resample/retime one without the other or overlays drift.
## 2. Keyframe the camera (in Earth Studio)
Animate slowly and deliberately — this style establishes place, it does not whip.
- Set keyframes on the timeline for **camera position** (latitude, longitude, altitude), **tilt** (pitch), **heading** (rotation), and **field of view**.
- Common moves:
- **Globe → city zoom:** start high altitude over the globe, end low over the target. Pull FOV slightly to flatten at the end.
- **Orbit:** hold position/altitude, sweep heading 30–90° around a landmark.
- **Pan along a border/route:** keep altitude/tilt fixed, move position along the line.
- **Easing:** per keyframe, set ease in/out (Earth Studio's auto-ease is a good start). Avoid linear — it reads robotic. Ease into and out of every hold.
- **Mark coordinate points:** add the lat/long of each place you'll pin/label so they ship as track points in the `.jsx`.
- Keep camera moves smooth at the full project fps (e.g. 24) — the 12fps stutter is applied later to the *overlay*, never to the base camera.
## 3. Export / render settings
- **Render** → set **Resolution** (render larger than final — e.g. 4K for a 1080p edit — so you have room to reframe/scale overlays).
- **Frame rate**: match your edit (24 or 30fps). The base stays smooth at this rate.
- **Format**: image sequence (PNG for alpha/quality, JPG for size).
- **CRITICAL — enable the After Effects / 3D Camera Export option**: check "3D Camera Export" (a.k.a. the After Effects checkbox) so the export bundles the `.jsx` script and the track-point data alongside the frames. Without this you only get footage and cannot rebuild the camera.
- Export. You'll receive the image-sequence folder + the `.jsx` (and a tracking-data file it reads).
## 4. Import into After Effects
1. **Import the image sequence** as footage: File → Import → File, select the first frame, check **"Image Sequence"**, and set the footage's frame rate to match the Earth Studio export (right-click → Interpret Footage → Main → Frame Rate).
2. **Run the camera script**: File → Scripts → **Run Script File…** → select the exported `.jsx`. It builds a new comp (or layers into the current one) containing:
- a **3D camera** with keyframes matching the Earth Studio move,
- the **image-sequence layer** referenced as the background,
- **track-point null layers** for each marked coordinate.
3. Confirm the comp's fps, resolution, and duration match the export. Scrub — the camera should fly exactly as keyframed in the browser.
> If the script can't find the footage, point it at the image-sequence folder when prompted, or pre-import the sequence so it resolves by name.
## 5. Lock overlays to map locations (lat/long ↔ screen space)
The track-point nulls are the bridge from 3D map coordinates to 2D screen space:
- Each track null sits, in 3D space, at the geographic point you marked in Earth Studio. As the camera moves, the null's **screen position** follows the correct spot on the map automatically.
- **Parent** your 2D pin/label/route layers to the relevant track null (or use an expression to read the null's `toComp([0,0,0])` position) so they ride the camera move and stay glued to their location.
- For a coordinate you did *not* mark in Earth Studio, add it back in the browser and re-export, or approximate by parenting to the nearest track null and offsetting.
```jsx
// AE expression on a 2D pin layer's Position: follow a 3D track null's screen position
L = thisComp.layer("Track: Kyiv");
L.toComp([0,0,0]); // 3D null -> 2D comp pixels, every frame, as the camera moves
```
## 6. Build the overlay precomp
Keep all graphics (pins, routes, highlights, labels) in **one precomp** layered over the base footage. This isolates them so the stutter and grade apply only to graphics, not the photoreal plate.
- Pin drop: position keyframes (down-into-place) + scale overshoot; ease out.
- Route: a shape/mask path stroked on via **Trim Paths** (Add → Trim Paths, animate End 0→100%).
- Region highlight: a masked solid or shape over the area, animate opacity/clip in.
- Labels: text layers parented to track nulls; keep them upright (do not inherit map rotation) and inside the title-safe area.
## 7. Apply the 12fps stutter — overlay only
Make the graphics step at 12fps inside the 24fps master. Two equivalent methods:
- **Posterize time:** select the overlay precomp layer → Effect → Time → **Posterize Time**, set Frame Rate to **12**. (Or as an expression on a property: `posterizeTime(12); value`.)
- **Nested 12fps precomp:** set the overlay precomp's own frame rate to 12fps and place it inside the 24fps master comp.
Leave the base footage layer and the 3D camera at the full rate (24fps) — they stay smooth. Stutter the graphics layer only; stuttering the plate looks like dropped frames, not a deliberate style.
## 8. Finish
- Color-grade the base footage (the Earth Studio plate often needs contrast/saturation to feel editorial).
- Add a subtle vignette/atmosphere if the move is cinematic.
- Render the master comp (overlay stuttered, base smooth) to your delivery codec.
## Gotchas
- Forgetting the **3D Camera Export** checkbox → no `.jsx`, no camera, overlays can't track. Re-export.
- Footage frame rate not matching the camera keyframes → progressive overlay drift. Interpret footage to the exact export fps.
- Retiming the footage without retiming the camera (or vice versa) breaks the lock — retime the whole comp together.
- Earth Studio imagery has usage/attribution terms (Google) — keep the on-screen attribution Earth Studio bakes in, or follow its current export terms.
- This skill is named for the technique, not any outlet; the "Vox-style" descriptor is aesthetic shorthand and implies no affiliation.
references/vector-maps.md# Vector Maps — GeoJSON / SVG Cookbook
The code-renderable path: project GeoJSON to SVG, fly a transform-based camera to a lat/long, and drive pins, routes, region highlights, and labels from one coordinates array. Runnable patterns for Remotion (frame-driven) and D3/SVG (web), plus the full 12fps stutter recipe. This is the primary reference for the data-driven, deterministic look.
## 1. Projection: lat/long → screen pixels (the foundation)
Everything shares one projection so the basemap, pins, routes, and labels live in the same coordinate space.
```js
import { geoMercator, geoPath, geoInterpolate } from "d3-geo";
const W = 1920, H = 1080;
const projection = geoMercator().fitSize([W, H], geojson); // fit a feature/collection to the frame
const toPath = geoPath(projection); // GeoJSON feature -> SVG "d"
const px = ([lng, lat]) => projection([lng, lat]); // any coordinate -> [x, y] pixels
```
- **Projection choice:** `geoMercator` for familiar web-map look; `geoEqualEarth`/`geoNaturalEarth1` for honest area on world maps; `geoOrthographic` for a globe (and you can animate `.rotate()` to spin it).
- **`fitSize` / `fitExtent`** frame the map to your composition automatically — prefer it over hand-tuned `scale`/`translate`.
- Get basemap GeoJSON from Natural Earth (countries/states) or world-atlas TopoJSON (convert with `topojson.feature`).
Render the basemap:
```jsx
{geojson.features.map((feat) => (
<path key={feat.id} d={toPath(feat)} fill="#1b2330" stroke="#0b0f14" strokeWidth={0.5} />
))}
```
## 2. The 12fps stutter (quantize the frame)
The signature cadence. Render the comp at 24fps but step graphic motion at 12fps by snapping the frame before you compute any animated value.
```js
import { useCurrentFrame, useVideoConfig, interpolate, spring } from "remotion";
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const STEP = Math.max(1, Math.round(fps / 12)); // 2 at 24fps, ~2-3 at 30fps
const f = Math.floor(frame / STEP) * STEP; // quantized frame — use this for ALL graphic motion
```
Use `f` (not `frame`) everywhere you drive pins/routes/highlights/labels. Derive `STEP` from `fps` so the stutter survives an fps change. Keep any base/background camera on the raw `frame` if you want it smooth; stutter only the overlay layer.
In After Effects the equivalent is **Posterize Time** at 12 (or a 12fps precomp) on the overlay precomp — see `earth-studio-to-ae.md` §7.
## 3. Camera: zoom + pan to a lat/long
The vector "camera" is a transform on the map group. Compute the target's projected pixel, then translate so it centers and scale to zoom in.
```js
const [tx, ty] = px([targetLng, targetLat]);
const scale = interpolate(f, [0, 36], [1, 4], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const x = W / 2 - tx * scale;
const y = H / 2 - ty * scale;
const transform = `translate(${x}px, ${y}px) scale(${scale})`;
```
Wrap the basemap **and** overlays in the same `<g style={{ transform }}>` so they zoom together. To keep label *text* readable while zooming, counter-scale labels by `1/scale` (zoom the map, not the type), or render labels outside the transformed group and place them with `px()` recomputed at the current scale.
For a smooth fly-between (A→B), interpolate center along the great circle and ease the zoom out-then-in:
```js
const mid = geoInterpolate([aLng, aLat], [bLng, bLat])(0.5); // midpoint on the sphere
// zoom out toward mid in the first half, zoom into B in the second half (interpolate scale down then up)
```
## 4. Pins: drop, settle, pulse
Drive from a coordinates array; stagger entrances on the quantized step.
```jsx
const pins = [
{ name: "Kyiv", lng: 30.52, lat: 50.45 },
{ name: "Lviv", lng: 24.03, lat: 49.84 },
];
pins.map((p, i) => {
const local = f - i * STEP * 4; // staggered start, quantized
if (local < 0) return null;
const drop = spring({ frame: local, fps, config: { damping: 12, stiffness: 120 } }); // overshoot -> settle
const [cx, cy] = px([p.lng, p.lat]);
const y = cy - 40 * (1 - drop); // fall into place from above
const pulse = 1 + 0.15 * Math.max(0, Math.sin((local / STEP) * 0.6)); // optional ring
return (
<g key={p.name} transform={`translate(${cx} ${y})`} opacity={Math.min(1, drop * 1.5)}>
<circle r={8 * pulse} fill="none" stroke="#0A84FF" opacity={0.4} />
<circle r={5} fill="#0A84FF" />
</g>
);
});
```
## 5. Routes: draw-on between coordinates
Project each point of the route, join into one SVG path, normalize with `pathLength`, and stroke it on.
```jsx
const route = [[30.52, 50.45], [27.0, 49.0], [24.03, 49.84]]; // [lng, lat] waypoints
const d = route.map((c, i) => (i ? "L" : "M") + px(c).join(" ")).join(" ");
const draw = interpolate(f, [12, 60], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
<path d={d} fill="none" stroke="#FF9F0A" strokeWidth={3}
pathLength={1} strokeDasharray={1} strokeDashoffset={1 - draw} strokeLinecap="round" />
```
- `pathLength={1}` normalizes any route length so one 0→1 value draws it.
- **Curved / flight-path arc:** densify the line first with `geoInterpolate` so a Mercator straight line becomes a proper great-circle curve:
```js
const arc = (a, b, n = 40) => Array.from({ length: n + 1 }, (_, i) => geoInterpolate(a, b)(i / n));
const d = arc([aLng, aLat], [bLng, bLat]).map((c, i) => (i ? "L" : "M") + px(c).join(" ")).join(" ");
```
- **Traveling dot leading the line:** place a circle at `offset-distance: ${draw * 100}%` along the same path (CSS `offset-path`) or sample the path at `draw` with `getPointAtLength`.
## 6. Region highlight: focus + context
Light the target feature, dim the rest. Color = meaning, never reassigned mid-piece.
```jsx
const TARGET = "France";
geojson.features.map((feat) => {
const on = feat.properties.name === TARGET;
const lit = on ? interpolate(f, [0, 12], [0, 1], { extrapolateRight: "clamp" }) : 0;
return (
<path key={feat.id} d={toPath(feat)}
fill={on ? "#0A84FF" : "#1b2330"}
fillOpacity={on ? 0.25 + 0.55 * lit : 0.35}
stroke={on ? "#0A84FF" : "#0b0f14"} strokeWidth={on ? 1.5 : 0.5} />
);
});
```
Alternatives: animate a `clip-path` wipe across the region, or pulse the stroke. Keep non-target regions visible but muted so the viewer keeps geographic context.
## 7. Labels & callouts
Anchor to `px([lng, lat])`, reveal after the pin lands, keep horizontal and inside the safe area.
```jsx
const labelIn = interpolate(f, [18, 30], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
const [lx, ly] = px([p.lng, p.lat]);
<g transform={`translate(${lx} ${ly - 14})`} opacity={labelIn}>
<line x1={0} y1={0} x2={0} y2={-18} stroke="#F5F7FA" strokeWidth={1} /> {/* leader */}
<text y={-24} textAnchor="middle" fontFamily="Inter" fontWeight={600} fontSize={28} fill="#F5F7FA">
{p.name}
</text>
</g>
```
- Never rotate labels with the map; keep type upright and legible.
- Counter-scale by `1/scale` if labels live inside the zoomed group, so text stays a fixed size while the map zooms.
- ≤ a couple of words per pin; use a callout box with a leader line for longer notes.
## 8. Data-driven sequencing (single source of truth)
One ordered `beats` array drives camera targets, pin order, route segments, labels, and total duration.
```jsx
import { Composition, Series } from "remotion";
import { z } from "zod";
export const schema = z.object({
beats: z.array(z.object({
lng: z.number(), lat: z.number(), label: z.string(),
hold: z.number(), zoom: z.number().default(4), route: z.boolean().default(false),
})),
});
const defaultProps = {
beats: [
{ lng: 30.52, lat: 50.45, label: "Kyiv", hold: 48, zoom: 5 },
{ lng: 24.03, lat: 49.84, label: "Lviv", hold: 36, zoom: 6, route: true },
],
};
// Each beat = a Series.Sequence: camera flies to (lng,lat,zoom), pin drops, label pops,
// route draws if `route`. Compute duration from the data, not by hand:
export const calculateMetadata = ({ props }) => ({
durationInFrames: props.beats.reduce((s, b) => s + b.hold, 0),
});
export const Root = () => (
<Composition id="MapSeq" component={MapSeq} fps={24} width={1920} height={1080}
schema={schema} defaultProps={defaultProps} calculateMetadata={calculateMetadata} />
);
```
Swap the `beats` array → a new map sequence with no re-keyframing. Verify ONE representative `beats` set with `remotion still` before batch-rendering many datasets.
## 9. D3 / web variant (no Remotion)
Same projection and recipes, driven by a paused/seekable timeline for headless screenshots:
```js
const projection = d3.geoMercator().fitSize([W, H], geojson);
const path = d3.geoPath(projection);
svg.selectAll("path").data(geojson.features).join("path").attr("d", path);
// Route draw-on with stroke-dashoffset; zoom via d3.zoom().scaleTo / a transform on the <g>.
// Freeze for a screenshot: build a master timeline, then ?t=N -> timeline.pause(); timeline.seek(N).
```
## Gotchas
- **Antimeridian / poles:** Mercator clips near the poles and can tear a feature that crosses ±180°. Use `geoEqualEarth`/`geoNaturalEarth1` for world views, or rotate the projection.
- **Coordinate order is `[lng, lat]`** in GeoJSON and `projection()` — not `[lat, lng]`. Swapping them sends every pin to the wrong place.
- **Determinism:** drive all motion from `f` (the quantized frame); no `Date.now()` / `Math.random()` / timers, or stills won't reproduce and CI renders drift.
- **Label readability while zooming:** counter-scale by `1/scale` or render labels outside the transformed group.
- The "Vox-style" descriptor is aesthetic shorthand for this editorial map look and implies no affiliation; the skill is named for the technique.
---
Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=map-animation-skills&utm_content=skill_footer&utm_term=map-animation)** — the AI motion agent for editable, on-brand motion graphics.
SKILL.md---
name: map-animation
description: This skill should be used when the user asks to "make a Vox-style map animation", "animate a map zoom", "draw a route on a map", "highlight a country/region", "use Google Earth Studio", "drop pins on a map and animate them", "pan/orbit a camera over a map", or "add map graphics to an explainer video". Covers Earth Studio→After Effects camera import, GeoJSON/SVG vector map motion, pins/routes/highlights/labels, the 12fps stutter cadence, and data-driven Remotion renders.
version: 0.1.0
---
# Map Animation
Build editorial, "Vox-style" animated maps: a camera that zooms, orbits, and pans over a base map while location pins drop, routes draw on, regions highlight, and labels call out — all snapped to a deliberate 12fps stutter so motion reads as graphic, not photographic. Two production paths: photoreal base motion from **Google Earth Studio** composited in After Effects, and code-renderable **vector maps** (GeoJSON/SVG) driven from a coordinates array in Remotion or D3.
## When to use
- A documentary/explainer beat that establishes place: zoom from globe to city, orbit a landmark, pan along a border.
- Show movement across geography: a route drawing between two points, a supply chain, a migration, a flight path.
- Highlight a country, state, or region and label it; build a step-by-step geographic walkthrough.
- Turn a list of coordinates/events into a repeatable, data-driven map sequence for video.
## Pick the path
| You need | Path | Output |
|---|---|---|
| Photoreal terrain, real satellite/3D buildings, cinematic camera | **Google Earth Studio → After Effects** | Image sequence + camera `.jsx`, composited in AE |
| Clean editorial vector look, full control, data-driven | **Vector (GeoJSON/SVG)** in Remotion or D3 | Code-rendered MP4/GIF or web SVG |
| Pins / routes / highlights / labels on top of either | **Overlay layer** | AE shape/null layers OR React/SVG components |
Most "Vox-style" pieces combine them: Earth Studio for the base camera move, vector overlays for the pins, routes, and labels on top.
## The signature look: 12fps stutter
The single most recognizable trait of this style is **animating the map graphics at 12fps inside a 24fps edit**. The base footage and audio run at 24 (smooth); the graphic layer — camera-driven pins, route draw-on, label pops — steps at 12. The result reads as intentional, hand-built, "infographic" motion rather than soft video.
- **Math:** at 24fps, hold each graphic-layer value for 2 frames (update on even frames only). At 30fps, hold ~2–3 frames (≈12.5/10fps).
- **After Effects:** apply `posterizeTime(12)` to the layer/comp, OR nest the graphics in a 12fps precomp placed inside the 24fps master.
- **Remotion / code:** snap the frame before computing any animated value:
```js
// Quantize the timeline to 12fps "steps" while the comp renders at 24fps.
const STEP = Math.round(fps / 12); // 2 at 24fps
const f = Math.floor(frame / STEP) * STEP; // use `f`, not `frame`, for graphic motion
const progress = interpolate(f, [0, 48], [0, 1], { extrapolateRight: "clamp" });
```
Keep the *base* footage/camera smooth (24fps) and stutter only the *graphic overlay* — stuttering everything looks like dropped frames, not style.
## Path A — Google Earth Studio → After Effects
Earth Studio is a browser tool that keyframes a camera over Google's 3D globe and exports two things you import into AE: an **image sequence** (the rendered frames) and a **camera-track `.jsx`** script that rebuilds the exact 3D camera in After Effects.
1. **Keyframe in Earth Studio** (browser): set keyframes for camera position (lat/long/altitude), tilt, heading, and field of view. Use the auto-ease or set custom ease per keyframe. Keep moves slow and deliberate — establish, don't whip.
2. **Export → render**: choose the image sequence (JPG/PNG), set resolution and fps, and **enable the "3D Camera Export" / After Effects checkbox** so it ships the `.jsx` and track-point files alongside the frames.
3. **In After Effects**: import the image sequence as footage, then **File → Scripts → Run Script File…** and run the exported `.jsx`. It creates a comp with a 3D camera and null layers whose keyframes match the Earth Studio move frame-for-frame.
4. **Parent overlays to the track**: parent your pin/label null layers to the imported 3D camera/track nulls so they stick to the right map location as the camera moves (the `.jsx` includes track points for any coordinates you marked in Earth Studio).
5. **Apply the stutter** to the overlay precomp only (`posterizeTime(12)`), color-grade the base footage, then render.
See `references/earth-studio-to-ae.md` for the exact keyframe/export settings, the `.jsx` import step, and how track points map screen-space to lat/long.
## Path B — Vector maps (GeoJSON / SVG)
For the clean editorial look, project GeoJSON to SVG paths and animate them. Use a real projection so coordinates land correctly.
```js
import { geoMercator, geoPath } from "d3-geo";
const projection = geoMercator().fitSize([1920, 1080], geojson); // fit map to frame
const toPath = geoPath(projection);
const [x, y] = projection([lng, lat]); // any lat/long → screen pixel
```
Render `geojson.features` as `<path d={toPath(f)} />`. The same `projection([lng,lat])` converts every pin coordinate to a screen position, so pins, routes, and labels all share one coordinate space as the camera (a `scale`/`translate` transform) moves.
**Camera move** in vector land is a CSS/transform zoom-and-pan toward a target lat/long:
```js
const [tx, ty] = projection([targetLng, targetLat]);
const scale = interpolate(f, [0, 36], [1, 4], { extrapolateRight: "clamp" });
// transform-origin at the target so the zoom homes in on it
const transform = `translate(${960 - tx*scale}px, ${540 - ty*scale}px) scale(${scale})`;
```
## Overlays: pins, routes, highlights, labels
**Pin drop** (drops in, settles, optional pulse) — stagger across a coordinates array:
```jsx
const pins = [{ name:"Kyiv", lng:30.52, lat:50.45 }, { name:"Lviv", lng:24.03, lat:49.84 }];
pins.map((p, i) => {
const local = f - i * STEP * 4; // stagger, quantized to the 12fps step
const drop = spring({ frame: local, fps, config:{ damping: 12 } }); // overshoot = settle
const [x, y] = projection([p.lng, p.lat]);
return <Pin key={p.name} x={x} y={y - 40*(1-drop)} opacity={local > 0 ? 1 : 0} />;
});
```
**Route draw-on** — build the path with the projection, then stroke it on with `stroke-dashoffset`:
```js
// route = [[lng,lat], ...]; project each point, join into an SVG path
const d = route.map((c,i) => (i ? "L" : "M") + projection(c).join(" ")).join(" ");
// in the component: pathLength=1, strokeDashoffset = 1 - drawProgress
const draw = interpolate(f, [12, 60], [0, 1], { extrapolateLeft:"clamp", extrapolateRight:"clamp" });
```
`pathLength="1"` normalizes any route length so one progress value (0→1) draws it. Add a traveling dot at `offset-distance` to lead the line. For a great-circle/curved flight path, sample intermediate points with `d3.geoInterpolate(a, b)`.
**Region highlight** — fill/stroke the matched GeoJSON feature, dim the rest (focus + context):
```js
const isTarget = (f) => f.properties.name === "France";
// target: fill accent, opacity 1, stroke up; others: opacity .35
```
Animate the highlight in by interpolating fill opacity, or wipe it with a clip-path mask. Keep color = meaning and never reassign it mid-piece.
**Label callout** — pin label or leader-line box; fade/slide in after its pin lands, anchored to `projection([lng,lat])` so it tracks the camera. Keep labels horizontal and inside the safe area; never let them rotate with the map.
## Data-driven sequencing
Drive the whole sequence from one ordered array so the map narrates itself and re-renders from data:
```jsx
const beats = [
{ lng: 30.52, lat: 50.45, label: "Kyiv", hold: 48 },
{ lng: 24.03, lat: 49.84, label: "Lviv", hold: 36, route: true },
];
// Each beat = a Sequence: camera flies to (lng,lat), pin drops, label pops, route draws if set.
// Total duration = sum(holds); compute it in calculateMetadata, not by hand.
```
One array → camera targets, pin order, route segments, and labels stay in sync. Swap the data, get a new map sequence — no re-keyframing.
## Output checklist
- Camera moves are slow and deliberate (establish, don't whip); eases on every keyframe.
- Graphic overlay stutters at 12fps; base footage/camera stays smooth (24fps).
- Pins/routes/labels stay locked to their lat/long as the camera moves (parented to track / shared projection).
- Routes draw on in reading order; highlights use focus + context (target lit, rest dimmed).
- Labels horizontal, legible, inside the safe area; color grammar consistent.
- Data-driven path: one coordinates/beats array is the single source of truth.
## Deliver & verify (rendered stills → MP4)
> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
The code-renderable path is a Remotion composition — frame-deterministic, so any exact frame renders headlessly with **no seek harness**. Use this tier when the deliverable is an MP4/GIF (vector map, data-driven, exact labels) or batches per dataset; for an Earth Studio + AE comp, the deliverable is the AE project + rendered footage instead, verified by scrubbing the master comp.
**Output contract:**
- A Remotion project with the composition registered (`<Composition>` + zod `schema` + `defaultProps`), all motion frame-driven (no timers / `Date.now()` / `Math.random()`).
- Deliverable = the rendered `out/*.mp4` (plus the project + the `beats`/coordinates array, so the map can be re-rendered from new data).
- The 12fps stutter is computed from `fps` (quantize the frame), not hardcoded — so it survives an fps change.
- Duration data-dependent (sum of beat holds)? compute it in `calculateMetadata`, not by hand.
**Verify loop — render stills → inspect → encode.** Render single frames first (cheap, no encode), inspect them, encode only once the frames are right.
```bash
# Frame-exact stills at start / each beat / end — render with the SHIPPED props (the real beats array)
npx remotion still MapSeq out/f-start.png --frame=0 --props='{...}'
npx remotion still MapSeq out/f-beat2.png --frame=N --props='{...}' # mid a beat's hold
npx remotion still MapSeq out/f-end.png --frame=L --props='{...}' # L = durationInFrames - 1
# Inspect each PNG — FIDELITY: every pin sits on the correct lat/long, the route connects the right
# cities, the highlighted region is the intended one, labels are spelled right and readable. And land
# on TWO adjacent frames inside a move to confirm the overlay holds for 2 frames (the 12fps step),
# while the base camera is still advancing every frame.
# ARTIFACTS: labels off-canvas/clipped, pins drifting off their coordinate, route overshooting,
# missing map tiles/fonts, projection cutoff at the antimeridian.
# Only after the stills check out, encode:
npx remotion render MapSeq out/map.mp4 --props='{...}'
```
- `npx remotion compositions` reads `durationInFrames`/`fps` to pick the end frame and per-beat sample frames.
- **Data-driven / batch:** verify ONE representative `beats` array via stills *before* batch-rendering all datasets — catch a projection/label bug once, not N times.
- **README demo GIF for free:** `npx remotion render MapSeq out/demo.gif --codec=gif`.
**Before you finish:**
1. `npx remotion still` renders cleanly at frame 0, mid each beat, and last — no errors, no missing tiles/fonts.
2. Every pin/route/label lands on the correct projected coordinate at every checked frame (spot-check a known city).
3. Two adjacent overlay frames are identical (12fps step holds) while the base camera advanced — stutter is real and only on the graphic layer.
4. Frame-driven only — no `Date.now()` / `Math.random()` / timers; the **shipped** beats render correctly (not just `defaultProps`).
5. Full MP4 encoded and plays; (optional) GIF rendered for the README.
## Reference files
- `references/earth-studio-to-ae.md` — Google Earth Studio keyframe + export settings, the After Effects `.jsx` camera-import workflow, parenting overlays to track points, lat/long ↔ screen-space mapping, and applying the 12fps stutter to the overlay precomp.
- `references/vector-maps.md` — GeoJSON projection setup, vector camera zoom/pan to a lat/long, pin-drop / route draw-on / region-highlight / label-callout recipes, the data-driven `beats` pattern, and the full 12fps stutter quantization recipe for code and AE.