Compare commits
8 Commits
0ec73959a7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 571ec05d76 | |||
| fbb7ab2d90 | |||
| 1e361bde93 | |||
| b30a0b9b95 | |||
| c464c5f708 | |||
| a0c9d7947b | |||
| 966dd3f209 | |||
| 93e67bd27e |
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: remotion-best-practices
|
||||
description: Router for all Remotion skills
|
||||
metadata:
|
||||
tags: remotion, video, react, animation, composition
|
||||
---
|
||||
|
||||
## New project setup
|
||||
|
||||
If no Remotion project currently exists, load [Create a new Remotion project](remotion-create/REFERENCE.md)
|
||||
|
||||
## React Markup Best Practices
|
||||
|
||||
If you are writing Remotion React Markup, load [Remotion Markup Best Practices](remotion-markup/REFERENCE.md)
|
||||
|
||||
## Maps
|
||||
|
||||
For static maps, animated routes and markers, geographic explainers, Mapbox, MapLibre, MapTiler, GeoJSON, or 3D geographic flyovers, load [Remotion Maps](remotion-maps/REFERENCE.md).
|
||||
|
||||
## Multimedia
|
||||
|
||||
For achieving multimedia tasks in the browser, such as trimming, cropping videos, or getting metadata from them, load [Remotion Multimedia](remotion-multimedia/REFERENCE.md)
|
||||
|
||||
## Improving Interactivity
|
||||
|
||||
By structuring the Remotion markup well, we can allow users to interactively change things in the Studio and write back to code. If relevant: [Interactivity Best Practices](remotion-interactivity/REFERENCE.md)
|
||||
|
||||
## Rendering
|
||||
|
||||
For advanced rendering beyond simple `npx remotion render`, see: [Rendering Best Practices](remotion-render/REFERENCE.md)
|
||||
|
||||
## Captions
|
||||
|
||||
When working with Captions, load [Remotion Captions](remotion-captions/REFERENCE.md).
|
||||
|
||||
## Creating a SaaS, automation or application
|
||||
|
||||
Use the [Remotion SaaS skill](remotion-saas/REFERENCE.md) for knowledge about Remotion-powered SaaS apps, such as `<Player>`, rendering on Lambda, Vercel, Cloudflare, via Express.js, client-side rendering, or for finding the right SaaS template.
|
||||
|
||||
## Looking up Remotion APIs and documentation
|
||||
|
||||
To find and read current Remotion documentation, load [Remotion Docs](remotion-docs/REFERENCE.md).
|
||||
|
||||
## Upgrading
|
||||
|
||||
To upgrade Remotion, related packages, compatible Mediabunny packages, and installed Remotion Agent Skills, load [Remotion Upgrade](remotion-upgrade/REFERENCE.md).
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: remotion-captions
|
||||
description: Transcribing, displaying and animating captions
|
||||
metadata:
|
||||
tags: subtitles, captions, remotion, json
|
||||
---
|
||||
|
||||
All captions must be processed in JSON. The captions must use the [`Caption`](https://www.remotion.dev/docs/captions/caption.md) type which is the following:
|
||||
|
||||
```ts
|
||||
import type { Caption } from "@remotion/captions";
|
||||
```
|
||||
|
||||
This is the definition:
|
||||
|
||||
```ts
|
||||
type Caption = {
|
||||
text: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
timestampMs: number | null;
|
||||
confidence: number | null;
|
||||
};
|
||||
```
|
||||
|
||||
## Generating captions
|
||||
|
||||
To transcribe video and audio files to generate captions, load the [transcribe-captions.md](transcribe-captions.md) file for more instructions.
|
||||
|
||||
## Displaying captions
|
||||
|
||||
To display captions in your video, load the [display-captions.md](display-captions.md) file for more instructions.
|
||||
|
||||
## Importing captions
|
||||
|
||||
To import captions from a .srt file, load the [import-srt-captions.md](import-srt-captions.md) file for more instructions.
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
name: display-captions
|
||||
description: Displaying captions in Remotion with TikTok-style pages and word highlighting
|
||||
metadata:
|
||||
tags: captions, subtitles, display, tiktok, highlight
|
||||
---
|
||||
|
||||
# Displaying captions in Remotion
|
||||
|
||||
This guide explains how to display captions in Remotion, assuming you already have captions in the [`Caption`](https://www.remotion.dev/docs/captions/caption) format.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Read [Transcribing audio](transcribe-captions.md) for how to generate captions.
|
||||
|
||||
First, the [`@remotion/captions`](https://www.remotion.dev/docs/captions) package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/captions
|
||||
```
|
||||
|
||||
## Fetching captions
|
||||
|
||||
First, fetch your captions JSON file. Use [`useDelayRender()`](https://www.remotion.dev/docs/use-delay-render) to hold the render until the captions are loaded:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AbsoluteFill, staticFile, useDelayRender } from "remotion";
|
||||
import type { Caption } from "@remotion/captions";
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const [captions, setCaptions] = useState<Caption[] | null>(null);
|
||||
const { delayRender, continueRender, cancelRender } = useDelayRender();
|
||||
const [handle] = useState(() => delayRender());
|
||||
|
||||
const fetchCaptions = useCallback(async () => {
|
||||
try {
|
||||
// Assuming captions.json is in the public/ folder.
|
||||
const response = await fetch(staticFile("captions123.json"));
|
||||
const data = await response.json();
|
||||
setCaptions(data);
|
||||
continueRender(handle);
|
||||
} catch (e) {
|
||||
cancelRender(e);
|
||||
}
|
||||
}, [continueRender, cancelRender, handle]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCaptions();
|
||||
}, [fetchCaptions]);
|
||||
|
||||
if (!captions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AbsoluteFill>{/* Render captions here */}</AbsoluteFill>;
|
||||
};
|
||||
```
|
||||
|
||||
## Creating pages
|
||||
|
||||
Use `createTikTokStyleCaptions()` to group captions into pages. The `combineTokensWithinMilliseconds` option controls how many words appear at once:
|
||||
|
||||
```tsx
|
||||
import { useMemo } from "react";
|
||||
import { createTikTokStyleCaptions } from "@remotion/captions";
|
||||
import type { Caption } from "@remotion/captions";
|
||||
|
||||
// How often captions should switch (in milliseconds)
|
||||
// Higher values = more words per page
|
||||
// Lower values = fewer words (more word-by-word)
|
||||
const SWITCH_CAPTIONS_EVERY_MS = 1200;
|
||||
|
||||
const { pages } = useMemo(() => {
|
||||
return createTikTokStyleCaptions({
|
||||
captions,
|
||||
combineTokensWithinMilliseconds: SWITCH_CAPTIONS_EVERY_MS,
|
||||
});
|
||||
}, [captions]);
|
||||
```
|
||||
|
||||
## Rendering with Sequences
|
||||
|
||||
Map over the pages and render each one in a `<Sequence>`. Calculate the start frame and duration from the page timing:
|
||||
|
||||
```tsx
|
||||
import { Sequence, useVideoConfig, AbsoluteFill } from "remotion";
|
||||
import type { TikTokPage } from "@remotion/captions";
|
||||
|
||||
const CaptionedContent: React.FC = () => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{pages.map((page, index) => {
|
||||
const nextPage = pages[index + 1] ?? null;
|
||||
const startFrame = (page.startMs / 1000) * fps;
|
||||
const endFrame = Math.min(
|
||||
nextPage ? (nextPage.startMs / 1000) * fps : Infinity,
|
||||
startFrame + (SWITCH_CAPTIONS_EVERY_MS / 1000) * fps,
|
||||
);
|
||||
const durationInFrames = endFrame - startFrame;
|
||||
|
||||
if (durationInFrames <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sequence
|
||||
key={index}
|
||||
from={startFrame}
|
||||
durationInFrames={durationInFrames}
|
||||
>
|
||||
<CaptionPage page={page} />
|
||||
</Sequence>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## White-space preservation
|
||||
|
||||
The captions are whitespace sensitive. You should include spaces in the `text` field before each word. Use `whiteSpace: "pre"` to preserve the whitespace in the captions.
|
||||
|
||||
## Separate component for captions
|
||||
|
||||
Put captioning logic in a separate component.
|
||||
Make a new file for it.
|
||||
|
||||
## Word highlighting
|
||||
|
||||
A caption page contains `tokens` which you can use to highlight the currently spoken word:
|
||||
|
||||
```tsx
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import type { TikTokPage } from "@remotion/captions";
|
||||
|
||||
const HIGHLIGHT_COLOR = "#39E508";
|
||||
|
||||
const CaptionPage: React.FC<{ page: TikTokPage }> = ({ page }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Current time relative to the start of the sequence
|
||||
const currentTimeMs = (frame / fps) * 1000;
|
||||
// Convert to absolute time by adding the page start
|
||||
const absoluteTimeMs = page.startMs + currentTimeMs;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
<div style={{ fontSize: 80, fontWeight: "bold", whiteSpace: "pre" }}>
|
||||
{page.tokens.map((token) => {
|
||||
const isActive =
|
||||
token.fromMs <= absoluteTimeMs && token.toMs > absoluteTimeMs;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={token.fromMs}
|
||||
style={{ color: isActive ? HIGHLIGHT_COLOR : "white" }}
|
||||
>
|
||||
{token.text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Display captions alongside video content
|
||||
|
||||
By default, put the captions alongside the video content, so the captions are in sync.
|
||||
For each video, make a new captions JSON file.
|
||||
|
||||
```tsx
|
||||
<AbsoluteFill>
|
||||
<Video src={staticFile("video.mp4")} />
|
||||
<CaptionPage page={page} />
|
||||
</AbsoluteFill>
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: import-srt-captions
|
||||
description: Importing .srt subtitle files into Remotion using @remotion/captions
|
||||
metadata:
|
||||
tags: captions, subtitles, srt, import, parse
|
||||
---
|
||||
|
||||
# Importing .srt subtitles into Remotion
|
||||
|
||||
If you have an existing `.srt` subtitle file, you can import it into Remotion using `parseSrt()` from `@remotion/captions`.
|
||||
|
||||
If you don't have a .srt file, read [Transcribing audio](transcribe-captions.md) for how to generate captions instead.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/captions package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/captions # If project uses npm
|
||||
bunx remotion add @remotion/captions # If project uses bun
|
||||
yarn remotion add @remotion/captions # If project uses yarn
|
||||
pnpm exec remotion add @remotion/captions # If project uses pnpm
|
||||
```
|
||||
|
||||
## Reading an .srt file
|
||||
|
||||
Use `staticFile()` to reference an `.srt` file in your `public` folder, then fetch and parse it:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AbsoluteFill, staticFile, useDelayRender } from "remotion";
|
||||
import { parseSrt } from "@remotion/captions";
|
||||
import type { Caption } from "@remotion/captions";
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const [captions, setCaptions] = useState<Caption[] | null>(null);
|
||||
const { delayRender, continueRender, cancelRender } = useDelayRender();
|
||||
const [handle] = useState(() => delayRender());
|
||||
|
||||
const fetchCaptions = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(staticFile("subtitles.srt"));
|
||||
const text = await response.text();
|
||||
const { captions: parsed } = parseSrt({ input: text });
|
||||
setCaptions(parsed);
|
||||
continueRender(handle);
|
||||
} catch (e) {
|
||||
cancelRender(e);
|
||||
}
|
||||
}, [continueRender, cancelRender, handle]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCaptions();
|
||||
}, [fetchCaptions]);
|
||||
|
||||
if (!captions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AbsoluteFill>{/* Use captions here */}</AbsoluteFill>;
|
||||
};
|
||||
```
|
||||
|
||||
Remote URLs are also supported - you can `fetch()` a remote file via URL instead of using `staticFile()`.
|
||||
|
||||
## Using imported captions
|
||||
|
||||
Once parsed, the captions are in the `Caption` format and can be used with all `@remotion/captions` utilities.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: transcribe-captions
|
||||
description: Transcribing audio to generate captions in Remotion
|
||||
metadata:
|
||||
tags: captions, transcribe, whisper, audio, speech-to-text
|
||||
---
|
||||
|
||||
# Transcribing audio
|
||||
|
||||
To transcribe audio to generate captions in Remotion, you can use the [`transcribe()`](https://www.remotion.dev/docs/install-whisper-cpp/transcribe) function from the [`@remotion/install-whisper-cpp`](https://www.remotion.dev/docs/install-whisper-cpp) package.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/install-whisper-cpp package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/install-whisper-cpp
|
||||
```
|
||||
|
||||
## Transcribing
|
||||
|
||||
Make a Node.js script to download Whisper.cpp and a model, and transcribe the audio.
|
||||
|
||||
```ts
|
||||
import path from "path";
|
||||
import {
|
||||
downloadWhisperModel,
|
||||
installWhisperCpp,
|
||||
transcribe,
|
||||
toCaptions,
|
||||
} from "@remotion/install-whisper-cpp";
|
||||
import fs from "fs";
|
||||
|
||||
const to = path.join(process.cwd(), "whisper.cpp");
|
||||
|
||||
await installWhisperCpp({
|
||||
to,
|
||||
version: "1.5.5",
|
||||
});
|
||||
|
||||
await downloadWhisperModel({
|
||||
model: "medium.en",
|
||||
folder: to,
|
||||
});
|
||||
|
||||
// Convert the audio to a 16KHz wav file first if needed:
|
||||
// import {execSync} from 'child_process';
|
||||
// execSync('ffmpeg -i /path/to/audio.mp4 -ar 16000 /path/to/audio.wav -y');
|
||||
|
||||
const whisperCppOutput = await transcribe({
|
||||
model: "medium.en",
|
||||
whisperPath: to,
|
||||
whisperCppVersion: "1.5.5",
|
||||
inputPath: "/path/to/audio123.wav",
|
||||
tokenLevelTimestamps: true,
|
||||
});
|
||||
|
||||
// Optional: Apply our recommended postprocessing
|
||||
const { captions } = toCaptions({
|
||||
whisperCppOutput,
|
||||
});
|
||||
|
||||
// Write it to the public/ folder so it can be fetched from Remotion
|
||||
fs.writeFileSync("captions123.json", JSON.stringify(captions, null, 2));
|
||||
```
|
||||
|
||||
Transcribe each clip individually and create multiple JSON files.
|
||||
|
||||
See [Displaying captions](display-captions.md) for how to display the captions in Remotion.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: remotion-create
|
||||
description: Create a new Remotion video
|
||||
metadata:
|
||||
tags: remotion
|
||||
---
|
||||
|
||||
These are instructions for making a new Remotion project and composition.
|
||||
If this is not the next task, see [Remotion Best Practices](../SKILL.md)
|
||||
|
||||
## Scaffold a project
|
||||
|
||||
If a project already exists, skip this.
|
||||
Ensure Node.js and Git is installed, and the current folder is appropriate for starting a new project.
|
||||
|
||||
Scaffold one using:
|
||||
|
||||
```bash
|
||||
npx create-video@latest --yes --blank --no-tailwind my-video
|
||||
cd my-video
|
||||
npm i
|
||||
```
|
||||
|
||||
Replace `my-video` with a suitable project name.
|
||||
|
||||
## Designing a video
|
||||
|
||||
Keep the scaffold and add React Markup. Follow [Remotion React Markup Best Practices](../remotion-markup/REFERENCE.md) and [Video Layout Rules](video-layout.md) for video-first layout and text sizing guidance.
|
||||
|
||||
## Interactivity Best Practices
|
||||
|
||||
By structuring the React Markup following [Remotion Interactivity Best Practices](../remotion-interactivity/REFERENCE.md), you allow the user to make edits in the Studio which write back to code.
|
||||
|
||||
## TailwindCSS
|
||||
|
||||
If Tailwind is requested, see [tailwind.md](tailwind.md) for using TailwindCSS in Remotion.
|
||||
|
||||
## Starting preview
|
||||
|
||||
```bash
|
||||
npx remotion studio --no-open
|
||||
```
|
||||
|
||||
This will start a long-running process and print the server URL for the preview.
|
||||
If server is already started, it will print the URL.
|
||||
You can visit a specific composition by navigating to `/[composition-id]`, for example `http://localhost:3000/MapAnimation`.
|
||||
|
||||
## Follow-up
|
||||
|
||||
The video creation process has finished.
|
||||
For follow-up prompts, use [Remotion Best Practices](../SKILL.md)
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: tailwind
|
||||
description: Using TailwindCSS in Remotion.
|
||||
metadata:
|
||||
---
|
||||
|
||||
You can and should use TailwindCSS in Remotion, if TailwindCSS is installed in the project.
|
||||
|
||||
Don't use `transition-*` or `animate-*` classes - always animate using the `useCurrentFrame()` hook.
|
||||
|
||||
Tailwind must be installed and enabled first in a Remotion project - see https://www.remotion.dev/docs/tailwind.
|
||||
@@ -0,0 +1,9 @@
|
||||
You are designing a video, not a webpage.
|
||||
|
||||
- Decide what the viewer should notice first in each scene. Build the frame around that one thing.
|
||||
- Keep important content inside a generous safe area. For 1080px-wide videos, keep key text at least 80px from the sides and 100px from the top and bottom.
|
||||
- Do not add redundant elements.
|
||||
- For 1080px-wide compositions, use these rough minimums:
|
||||
- Main headline: 84px
|
||||
- Important supporting text: 44px
|
||||
- Scale those values with the composition width.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: remotion-docs
|
||||
description: Search Remotion documentation
|
||||
metadata:
|
||||
tags: remotion, docs, documentation, search
|
||||
---
|
||||
|
||||
This skill teaches you how to discover and read current Remotion documentation.
|
||||
If this is not relevant, load [Remotion Best Practices](../SKILL.md) instead.
|
||||
|
||||
## Searching the docs
|
||||
|
||||
Use the Algolia search API to find relevant documentation pages:
|
||||
|
||||
```
|
||||
POST https://plsduol1ca-dsn.algolia.net/1/indexes/*/queries?x-algolia-api-key=3e42dbd4f895fe93ff5cf40d860c4a85&x-algolia-application-id=PLSDUOL1CA
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
{
|
||||
"requests": [
|
||||
{
|
||||
"query": "<your search query>",
|
||||
"indexName": "remotion",
|
||||
"params": "attributesToRetrieve=[\"hierarchy.lvl0\",\"hierarchy.lvl1\",\"hierarchy.lvl2\",\"url\"]&hitsPerPage=10"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each hit contains a `url` field pointing to the documentation page.
|
||||
|
||||
## Fetching a page as Markdown
|
||||
|
||||
Append `.md` to any Remotion docs URL to retrieve its Markdown source (saves tokens):
|
||||
|
||||
```
|
||||
https://www.remotion.dev/docs/use-video-config.md
|
||||
https://www.remotion.dev/docs/sequence.md
|
||||
https://www.remotion.dev/docs/lambda/rendermediaonlambda.md
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Search Algolia for the concept or API you need.
|
||||
2. Pick the most relevant URL(s) from the results.
|
||||
3. Fetch each URL with the `.md` suffix.
|
||||
4. Implement using the current documentation rather than memorized API knowledge.
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
name: remotion-interactivity
|
||||
description: Structure Remotion markup for interactivity
|
||||
metadata:
|
||||
tags: remotion, interactivity, studio, visual mode
|
||||
---
|
||||
|
||||
By writing Remotion markup in a specific way, the Remotion Studio is able to recognize the structure of the code and makes it interactive:
|
||||
|
||||
- Allowing items to be selected by clicking on them
|
||||
- Allowing drag+drop, resizing and rotation
|
||||
- Editing the CSS styles
|
||||
- Making keyframes and easing values editable
|
||||
|
||||
If the markup is too complex for the Studio to make it interactive, then the values become grayed out.
|
||||
|
||||
## Make an HTML element interactive using `Interactive`
|
||||
|
||||
Every HTML and SVG element such as `<div>` can be turned interactive using `Interactive`:
|
||||
|
||||
```tsx title="Interactive elements"
|
||||
<Interactive.Div name="Greeting card" style={{fontSize: 80, padding: 24}}>
|
||||
Hello
|
||||
</Interactive.Div>
|
||||
```
|
||||
|
||||
This allows styles and keyframes to be set in the Studio. Be sensible, if a component has many elements, the timeline might get messy.
|
||||
|
||||
## Give interactive elements a descriptive name
|
||||
|
||||
Add a `name` prop to elements to make them easily identifyable.
|
||||
|
||||
```tsx title="Interactive names"
|
||||
<>
|
||||
<Interactive.Div name="Hero title" style={{fontSize: 80}}>
|
||||
Launch day
|
||||
</Interactive.Div>
|
||||
<Img name="Avatar" src="https://remotion.media/image.jpeg" />
|
||||
<Video name="Background" src="https://remotion.media/video.mp4" />
|
||||
<Sequence name="Title">
|
||||
Launch day
|
||||
</Sequence>
|
||||
</>
|
||||
```
|
||||
|
||||
## Keep all CSS styles inline
|
||||
|
||||
The best way is to just pass a plain object to `style` - no referring to constants, no object spreading, no math.
|
||||
|
||||
```tsx title="Interactive example"
|
||||
<Interactive.Div
|
||||
style={{
|
||||
fontSize: 80,
|
||||
color: 'red',
|
||||
}}
|
||||
>
|
||||
Hello World!
|
||||
</Interactive.Div>
|
||||
```
|
||||
|
||||
```tsx title="❌ Bad for interactivity"
|
||||
const baseStyle = useMemo(() => {
|
||||
return {
|
||||
fontSize: 12 // ❌ Non-inline styles are not supported
|
||||
}
|
||||
}, []);
|
||||
|
||||
<Interactive.Div
|
||||
style={{
|
||||
...baseStyle, // ❌ Spreading is not supported
|
||||
color: RED, // ❌ Referring to constants is not supported
|
||||
scale: frame * 10 // ❌ Math is not supported
|
||||
}}
|
||||
>
|
||||
Hello World!
|
||||
</Interactive.Div>
|
||||
```
|
||||
|
||||
## Animate using `interpolate()`
|
||||
|
||||
Write animations as inline `interpolate()` calls on the property that changes.
|
||||
The output range, easing, extrapolation and `output` property should use hardcoded values.
|
||||
|
||||
The input range may additionally use `durationInFrames`, `fps`, `width` and `height` destructured directly from `useVideoConfig()`. Bare identifiers such as `durationInFrames`, multiplication with a number such as `2 * fps` or `fps * 2`, and subtraction of a number such as `durationInFrames - 1` are supported.
|
||||
|
||||
```tsx title="Inline values"
|
||||
const {fps, durationInFrames} = useVideoConfig();
|
||||
|
||||
// 👍 Inline values can be standardized and keyframed
|
||||
<Interactive.Div
|
||||
name="Product card"
|
||||
style={{
|
||||
color: 'white',
|
||||
fontSize: 80,
|
||||
scale: interpolate(frame, [0, fps], [0, 1], {
|
||||
easing: Easing.spring({damping: 200}),
|
||||
output: 'perceptual-scale',
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp'
|
||||
}),
|
||||
rotate: interpolate(frame, [0, 1 * fps], ['0deg', '20deg'], {
|
||||
easing: Easing.spring({damping: 200}),
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp'
|
||||
}),
|
||||
translate: interpolate(frame, [durationInFrames - 30, durationInFrames], ['0px 0px', '0px 120px'], {
|
||||
easing: Easing.spring({damping: 200}),
|
||||
output: 'perceptual-scale',
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp'
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
```tsx title="❌ Bad interactivity"
|
||||
const translateY = interpolate(frame, [0, 30], [0, 120]); // ❌ Math should be directly in the markup
|
||||
|
||||
<Interactive.Div
|
||||
name="Product card"
|
||||
style={{
|
||||
translate: translateY, // ❌ Only inline interpolate() calls are supported,
|
||||
rotate: interpolate(frame, [start, start + 10], [0, Math.PI]), // ❌ Cannot use math with arbitrary variables, cannot use constants
|
||||
scale: interpolate(anyVariable, [0, 30], [0, 1]) // ❌ Can only interpret the `frame` variable.
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Use `scale`, `translate`, `rotate` CSS properties
|
||||
|
||||
Avoid the `transform` CSS property.
|
||||
If possible, use `scale`, `rotate` and `translate` instead because only they are interactively editable.
|
||||
|
||||
## Keep composition metadata inline
|
||||
|
||||
When scaffolding a composition, keep `width`, `height`, `fps`, `durationInFrames` and `defaultProps` inline and make no type assertions.
|
||||
|
||||
The Props editor can save visual edits back to your code when `defaultProps` is an inline object literal on `<Composition>` or `<Still>`.
|
||||
|
||||
```tsx
|
||||
// 👍 Static values are in <Composition>, dynamic values are in calculateMetadata()
|
||||
const calculateMetadata = useMemo(async () => {
|
||||
const dimensions = await getDimensions(); // just an example
|
||||
return {width: dimensions.width, height: dimensions.height};
|
||||
});
|
||||
|
||||
<Composition
|
||||
id="my-video"
|
||||
component={MyComponent}
|
||||
durationInFrames={150}
|
||||
fps={30}
|
||||
calculateMetadata={calculateMetadata}
|
||||
defaultProps={{title: 'Hello', color: '#0b84ff'}}
|
||||
/>
|
||||
```
|
||||
|
||||
```tsx title="Negative examples"
|
||||
const defaultProps = {title: 'Hello', color: '#0b84ff'}; // ❌ Don't extract defaultProps, must be inline
|
||||
const calculateMetadata = useMemo(() => {
|
||||
// ❌ Unnecessary because no calculation is being done,
|
||||
return {durationInFrames: 150, fps: 30, width: 1920, height: 1080};
|
||||
});
|
||||
|
||||
<Composition
|
||||
id="my-video"
|
||||
component={MyComponent}
|
||||
calculateMetadata={calculateMetadata}
|
||||
defaultProps={{
|
||||
title: 'Hello',
|
||||
} as Props} // ❌ Don't have type assertions, instead type MyComponent correctly
|
||||
/>
|
||||
```
|
||||
|
||||
Use only `calculateMetadata()` for the part of the metadata that is dynamic.
|
||||
|
||||
## Effects should be inline too
|
||||
|
||||
The effects array should not be computed.
|
||||
The same rules for setting keyframes as `interpolate()` apply too here: All values should also be hardcoded: Input range, output range, easing, extrapolation, `output` property.
|
||||
|
||||
```tsx title="Effects"
|
||||
// 👍 Parameters are inline and the array shape is stable
|
||||
<CanvasImage
|
||||
src={src}
|
||||
width={1280}
|
||||
height={720}
|
||||
effects={[
|
||||
radialProgressiveBlur({
|
||||
center: [0.5, 0.5],
|
||||
width: 1.2,
|
||||
height: 0.8,
|
||||
start: 0.2,
|
||||
disabled: true,
|
||||
rotation: interpolate(frame, [0, 120], [0, 180]),
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
|
||||
const center = [0.5, 0.5] as const;
|
||||
const rotation = frame * 1.5;
|
||||
|
||||
<CanvasImage
|
||||
src={src}
|
||||
width={1280}
|
||||
height={720}
|
||||
// ❌ Conditional effect is not animateable
|
||||
effects={enabled ? [
|
||||
radialProgressiveBlur({
|
||||
// ❌ Not inline
|
||||
center,
|
||||
rotation,
|
||||
}),
|
||||
] : []}
|
||||
/>
|
||||
```
|
||||
|
||||
Render separate elements if one version should have effects and another should not.
|
||||
|
||||
## Making your own component interactive
|
||||
|
||||
To make a custom userland component interactive, use:
|
||||
[Make a component interactive](https://www.remotion.dev/docs/studio/make-component-interactive.md)
|
||||
|
||||
## Video editing
|
||||
|
||||
If a Remotion component mainly consists of video and audio clips, see [Video editing](../remotion-markup/video-editing.md) for best practices on how to structure Remotion markup so the clips are interactively editable in the timeline.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: remotion-maps
|
||||
description: Remotion Map animation knowledge
|
||||
---
|
||||
|
||||
# Remotion Maps
|
||||
|
||||
Choose exactly one technique from the intended shot, then load only that technique's `TECHNIQUE.md`.
|
||||
Every technique directory is self-contained and may be removed without breaking the others.
|
||||
|
||||
## [Static map](techniques/static-map/TECHNIQUE.md)
|
||||
|
||||
- Requires you grab a satellite image and mount it in a `<Img>` tag, and animate on top
|
||||
|
||||
## [Mapbox](techniques/mapbox/TECHNIQUE.md)
|
||||
|
||||
- Requires a Mapbox key
|
||||
- Nicer styles by default
|
||||
- Map can display a round globe when zoomed out
|
||||
- Includes nice 3D buildings such as the Eiffel tower
|
||||
|
||||
## [MapLibre](techniques/maplibre/TECHNIQUE.md)
|
||||
|
||||
- Requires no API key, fully free
|
||||
- Does not include 3D building
|
||||
|
||||
## [MapTiler](techniques/maptiler/TECHNIQUE.md)
|
||||
|
||||
- Uses MapTiler
|
||||
- Annotations can be drawn on top of geographic features: borders, rivers, labels
|
||||
|
||||
## [CesiumJS](techniques/cesium/TECHNIQUE.md)
|
||||
|
||||
- Flythroughs through terrain and mountains
|
||||
- "Flight simulator" perspective
|
||||
@@ -0,0 +1,89 @@
|
||||
# CesiumJS — 3D flyovers in Remotion
|
||||
|
||||
Instructions for achieving map animations with "flight-simulator" perspective in Remotion.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Data | Use for |
|
||||
| ----------- | ----------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `landscape` | MapTiler `terrain-quantized-mesh-v2` + `satellite-v2` | Mountains, gorges, rivers, coastlines and rural routes |
|
||||
| `city` | Google Photorealistic 3D Tiles | Cities, architecture and recognizable landmarks |
|
||||
|
||||
Do not use footprint extrusions for city flyovers. They produce crude building blocks rather than
|
||||
textured architecture.
|
||||
|
||||
## Credentials
|
||||
|
||||
For `landscape`, set:
|
||||
|
||||
```text
|
||||
REMOTION_MAPTILER_KEY=...
|
||||
```
|
||||
|
||||
Create a MapTiler key at https://cloud.maptiler.com/account/keys/.
|
||||
|
||||
For `city`, set:
|
||||
|
||||
```text
|
||||
REMOTION_GOOGLE_MAPS_API_KEY=...
|
||||
```
|
||||
|
||||
Create a billing-enabled Google Map Tiles API key by following
|
||||
https://developers.google.com/maps/documentation/tile/get-api-key. Enable the **Map Tiles API** and
|
||||
restrict the key to that API. Ensure its application restriction permits local headless Remotion
|
||||
requests.
|
||||
|
||||
## Build the flight
|
||||
|
||||
1. Copy `assets/CesiumFlythrough.tsx`, a path JSON and `assets/example-Root.tsx` into the Remotion
|
||||
project, or import the component directly.
|
||||
2. Supply the camera route as `[longitude, latitude][]`. Use only meaningful control points; do not hand-author dozens of tiny corrections.
|
||||
3. Leave `pathSmoothingPasses={3}` initially. The component applies repeated Chaikin corner cutting, turning straight-then-corner input into a continuous swerve. Increase to `4` for a softer route or reduce to `2` when the camera must follow a tight corridor.
|
||||
4. Set absolute camera altitudes for the location. City cameras normally fly lower than landscape
|
||||
cameras.
|
||||
5. Render a middle-frame still before rendering the full video.
|
||||
|
||||
```tsx
|
||||
<CesiumFlythrough
|
||||
mode="city"
|
||||
path={cameraPath}
|
||||
pathSmoothingPasses={3}
|
||||
altitudeStart={700}
|
||||
altitudeEnd={500}
|
||||
lookAheadKm={0.7}
|
||||
travelKm={4.5}
|
||||
/>
|
||||
```
|
||||
|
||||
## Camera behavior
|
||||
|
||||
Walk the smoothed curve by arc length for constant ground speed. Aim at a real point farther along
|
||||
the curve rather than its next vertex. Derive roll from the change in look-ahead bearing so the
|
||||
camera banks into a turn instead of twitching left and right.
|
||||
|
||||
For landscape routes, `scripts/prep-cesium-path.mjs` also clips, resamples, smooths and dampens a
|
||||
GeoJSON centerline before the component applies its final curve smoothing.
|
||||
|
||||
|
||||
## Mechanics
|
||||
|
||||
- Set `viewer.useDefaultRenderLoop = false`.
|
||||
- Call `viewer.render()`, never `scene.render()`, while settling.
|
||||
- Use `preserveDrawingBuffer: true`.
|
||||
- Gate initialization and every frame with `delayRender`.
|
||||
- Drive camera animation from `useCurrentFrame()`; do not use CSS transitions or browser-timed animation.
|
||||
- Settle landscapes on `globe.tilesLoaded` and cities on `tileset.tilesLoaded`.
|
||||
- Keep all provider attribution visible.
|
||||
- Record the source and effective date of custom or disputed geography.
|
||||
- Inspect rendered pixels, not only Studio playback, at every required aspect ratio.
|
||||
- Read `references/3d-flyover-architecture.md` for camera math, `references/3d-data-sources.md` for provider details, and `references/3d-troubleshooting.md` for blank, coarse, unauthorized or timed-out renders.
|
||||
|
||||
## Files
|
||||
|
||||
- `assets/CesiumFlythrough.tsx` — reusable two-mode component.
|
||||
- `assets/flight-path.ts` — dependency-free Chaikin route smoothing.
|
||||
- `assets/example-Root.tsx` — landscape and city compositions.
|
||||
- `assets/cesium-path.json` — sample landscape route.
|
||||
- `assets/city-path.json` — sample city route.
|
||||
- `assets/sample-river.geojson` — sample path-preparation input.
|
||||
- `scripts/prep-cesium-path.mjs` — dependency-free landscape route preparation.
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
import React, {useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
cancelRender,
|
||||
continueRender,
|
||||
delayRender,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import terrainPath from './cesium-path.json';
|
||||
import {smoothFlightPath, type LngLat} from './flight-path';
|
||||
|
||||
export type FlyoverMode = 'landscape' | 'city';
|
||||
export type {LngLat} from './flight-path';
|
||||
|
||||
export type CesiumFlythroughProps = {
|
||||
mode?: FlyoverMode;
|
||||
path?: LngLat[];
|
||||
pathSmoothingPasses?: number;
|
||||
altitudeStart?: number;
|
||||
altitudeEnd?: number;
|
||||
lookAheadKm?: number;
|
||||
travelKm?: number;
|
||||
pitchFromNadir?: number;
|
||||
verticalExaggeration?: number;
|
||||
maximumScreenSpaceError?: number;
|
||||
};
|
||||
|
||||
const MAPTILER_KEY = process.env.REMOTION_MAPTILER_KEY;
|
||||
const GOOGLE_MAPS_API_KEY = process.env.REMOTION_GOOGLE_MAPS_API_KEY;
|
||||
const CESIUM_VER = '1.143';
|
||||
const CDN = `https://cesium.com/downloads/cesiumjs/releases/${CESIUM_VER}/Build/Cesium/`;
|
||||
const R = 6371;
|
||||
const MAX_BANK = 0.13;
|
||||
const BANK_GAIN = 0.6;
|
||||
|
||||
const havKm = (a: number[], b: number[]) => {
|
||||
const r = Math.PI / 180;
|
||||
const dLat = (b[1] - a[1]) * r;
|
||||
const dLng = (b[0] - a[0]) * r;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(a[1] * r) * Math.cos(b[1] * r) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
};
|
||||
|
||||
const makePathWalker = (path: LngLat[]) => {
|
||||
if (path.length < 2)
|
||||
throw new Error('Flyover path needs at least two points');
|
||||
const cumulative = [0];
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
cumulative.push(cumulative[i - 1] + havKm(path[i - 1], path[i]));
|
||||
}
|
||||
const lengthKm = cumulative[cumulative.length - 1];
|
||||
const along = (km: number): LngLat => {
|
||||
const d = Math.max(0, Math.min(lengthKm, km));
|
||||
let i = 1;
|
||||
while (i < cumulative.length && cumulative[i] < d) i++;
|
||||
if (i >= cumulative.length) return path[path.length - 1];
|
||||
const segmentLength = cumulative[i] - cumulative[i - 1] || 1;
|
||||
const t = (d - cumulative[i - 1]) / segmentLength;
|
||||
return [
|
||||
path[i - 1][0] + (path[i][0] - path[i - 1][0]) * t,
|
||||
path[i - 1][1] + (path[i][1] - path[i - 1][1]) * t,
|
||||
];
|
||||
};
|
||||
return {along, lengthKm};
|
||||
};
|
||||
|
||||
const bearing = (a: number[], b: number[]) => {
|
||||
const r = Math.PI / 180;
|
||||
const y = Math.sin((b[0] - a[0]) * r) * Math.cos(b[1] * r);
|
||||
const x =
|
||||
Math.cos(a[1] * r) * Math.sin(b[1] * r) -
|
||||
Math.sin(a[1] * r) * Math.cos(b[1] * r) * Math.cos((b[0] - a[0]) * r);
|
||||
return Math.atan2(y, x);
|
||||
};
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
const clamp = (v: number, lo: number, hi: number) =>
|
||||
Math.max(lo, Math.min(hi, v));
|
||||
|
||||
const loadCesium = () =>
|
||||
new Promise<any>((resolve, reject) => {
|
||||
if ((window as any).Cesium) return resolve((window as any).Cesium);
|
||||
(window as any).CESIUM_BASE_URL = CDN;
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet';
|
||||
css.href = `${CDN}Widgets/widgets.css`;
|
||||
document.head.appendChild(css);
|
||||
const script = document.createElement('script');
|
||||
script.src = `${CDN}Cesium.js`;
|
||||
script.onload = () => resolve((window as any).Cesium);
|
||||
script.onerror = () =>
|
||||
reject(new Error(`Failed to load CesiumJS ${CESIUM_VER}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
export const CesiumFlythrough: React.FC<CesiumFlythroughProps> = ({
|
||||
mode = 'landscape',
|
||||
path = terrainPath as LngLat[],
|
||||
pathSmoothingPasses = 3,
|
||||
altitudeStart = 4600,
|
||||
altitudeEnd = 4300,
|
||||
lookAheadKm = 1.5,
|
||||
travelKm = 13,
|
||||
pitchFromNadir = 76,
|
||||
verticalExaggeration = 1.1,
|
||||
maximumScreenSpaceError = 8,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const started = useRef(false);
|
||||
const viewerRef = useRef<any>(null);
|
||||
const tilesetRef = useRef<any>(null);
|
||||
const frame = useCurrentFrame();
|
||||
const {durationInFrames, fps, width, height} = useVideoConfig();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [handle] = useState(() =>
|
||||
delayRender(`cesium init: ${mode}`, {timeoutInMilliseconds: 120000}),
|
||||
);
|
||||
const walker = useMemo(
|
||||
() => makePathWalker(smoothFlightPath(path, pathSmoothingPasses)),
|
||||
[path, pathSmoothingPasses],
|
||||
);
|
||||
|
||||
const setCamera = (C: any, viewer: any, progress: number) => {
|
||||
const maxTravel = Math.max(0, walker.lengthKm - lookAheadKm * 2);
|
||||
const cameraDistance = Math.min(travelKm, maxTravel) * progress;
|
||||
const camera = walker.along(cameraDistance);
|
||||
const aim = walker.along(cameraDistance + lookAheadKm);
|
||||
const aim2 = walker.along(cameraDistance + lookAheadKm * 2);
|
||||
const heading = bearing(camera, aim);
|
||||
let headingDelta = bearing(aim, aim2) - heading;
|
||||
while (headingDelta > Math.PI) headingDelta -= 2 * Math.PI;
|
||||
while (headingDelta < -Math.PI) headingDelta += 2 * Math.PI;
|
||||
viewer.camera.setView({
|
||||
destination: C.Cartesian3.fromDegrees(
|
||||
camera[0],
|
||||
camera[1],
|
||||
lerp(altitudeStart, altitudeEnd, progress),
|
||||
),
|
||||
orientation: {
|
||||
heading,
|
||||
pitch: C.Math.toRadians(-(90 - pitchFromNadir)),
|
||||
roll: clamp(headingDelta * BANK_GAIN, -MAX_BANK, MAX_BANK),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const tilesAreLoaded = (viewer: any) => {
|
||||
if (mode === 'landscape') return viewer.scene.globe.tilesLoaded;
|
||||
return Boolean(tilesetRef.current?.tilesLoaded);
|
||||
};
|
||||
|
||||
const settle = (viewer: any) =>
|
||||
new Promise<void>((resolve) => {
|
||||
let stable = 0;
|
||||
let ticks = 0;
|
||||
const tick = () => {
|
||||
viewer.render();
|
||||
ticks++;
|
||||
stable = tilesAreLoaded(viewer) ? stable + 1 : 0;
|
||||
if (stable > 8 || ticks > 600) {
|
||||
viewer.render();
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(tick, 8);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (started.current) return;
|
||||
started.current = true;
|
||||
(async () => {
|
||||
if (mode === 'landscape' && !MAPTILER_KEY) {
|
||||
throw new Error(
|
||||
'Set REMOTION_MAPTILER_KEY. Create a key at https://cloud.maptiler.com/account/keys/',
|
||||
);
|
||||
}
|
||||
if (mode === 'city' && !GOOGLE_MAPS_API_KEY) {
|
||||
throw new Error(
|
||||
'Set REMOTION_GOOGLE_MAPS_API_KEY. Create a Map Tiles API key at https://developers.google.com/maps/documentation/tile/get-api-key',
|
||||
);
|
||||
}
|
||||
if (mode === 'city' && durationInFrames / fps > 30) {
|
||||
throw new Error(
|
||||
'Google Photorealistic 3D Tiles compositions must not exceed 30 seconds',
|
||||
);
|
||||
}
|
||||
|
||||
const C = await loadCesium();
|
||||
const viewer = new C.Viewer(containerRef.current, {
|
||||
baseLayer: false,
|
||||
baseLayerPicker: false,
|
||||
geocoder: false,
|
||||
homeButton: false,
|
||||
sceneModePicker: false,
|
||||
navigationHelpButton: false,
|
||||
animation: false,
|
||||
timeline: false,
|
||||
fullscreenButton: false,
|
||||
infoBox: false,
|
||||
selectionIndicator: false,
|
||||
contextOptions: {webgl: {preserveDrawingBuffer: true}},
|
||||
});
|
||||
if (mode === 'landscape') {
|
||||
viewer.imageryLayers.addImageryProvider(
|
||||
new C.UrlTemplateImageryProvider({
|
||||
url: `https://api.maptiler.com/tiles/satellite-v2/{z}/{x}/{y}.jpg?key=${MAPTILER_KEY}`,
|
||||
maximumLevel: 20,
|
||||
}),
|
||||
);
|
||||
viewer.terrainProvider = await C.CesiumTerrainProvider.fromUrl(
|
||||
`https://api.maptiler.com/tiles/terrain-quantized-mesh-v2/?key=${MAPTILER_KEY}`,
|
||||
{requestVertexNormals: true},
|
||||
);
|
||||
viewer.creditDisplay.addStaticCredit(
|
||||
new C.Credit(
|
||||
'<a href="https://www.maptiler.com/copyright/" target="_blank">© MapTiler</a>',
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (mode === 'city') {
|
||||
viewer.scene.globe.show = false;
|
||||
const tileset = await C.Cesium3DTileset.fromUrl(
|
||||
`https://tile.googleapis.com/v1/3dtiles/root.json?key=${GOOGLE_MAPS_API_KEY}`,
|
||||
{
|
||||
showCreditsOnScreen: true,
|
||||
maximumScreenSpaceError,
|
||||
},
|
||||
);
|
||||
viewer.scene.primitives.add(tileset);
|
||||
tilesetRef.current = tileset;
|
||||
}
|
||||
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
viewer.scene.skyAtmosphere.show = true;
|
||||
viewer.scene.fog.enabled = true;
|
||||
viewer.scene.globe.enableLighting = false;
|
||||
viewer.scene.verticalExaggeration = verticalExaggeration;
|
||||
(window as any).__CESIUM_FLYOVER__ = {C, mode};
|
||||
viewerRef.current = viewer;
|
||||
setCamera(C, viewer, 0);
|
||||
await settle(viewer);
|
||||
setReady(true);
|
||||
continueRender(handle);
|
||||
})().catch((error) => cancelRender(error));
|
||||
}, [durationInFrames, fps, handle, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const frameHandle = delayRender(`cesium ${mode} frame ${frame}`, {
|
||||
timeoutInMilliseconds: 60000,
|
||||
});
|
||||
const C = (window as any).__CESIUM_FLYOVER__.C;
|
||||
const viewer = viewerRef.current;
|
||||
const progress = durationInFrames <= 1 ? 0 : frame / (durationInFrames - 1);
|
||||
setCamera(C, viewer, progress);
|
||||
settle(viewer).then(() => continueRender(frameHandle));
|
||||
}, [ready, frame, durationInFrames, mode]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: '#000'}}>
|
||||
<div ref={containerRef} style={{width, height, position: 'absolute'}} />
|
||||
{mode === 'city' ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
right: 24,
|
||||
color: 'white',
|
||||
font: '500 18px/1.2 sans-serif',
|
||||
textShadow: '0 1px 4px black',
|
||||
}}
|
||||
>
|
||||
For promotional purposes only
|
||||
</div>
|
||||
) : null}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
[
|
||||
[94.986139893537, 29.76417312959953],
|
||||
[94.98637054463002, 29.764163024004816],
|
||||
[94.98661427422437, 29.764153025681516],
|
||||
[94.9868698103708, 29.764142908388347],
|
||||
[94.98713600736019, 29.764132461562934],
|
||||
[94.98741179569139, 29.76412145291623],
|
||||
[94.9876962466277, 29.764109663422126],
|
||||
[94.98798853462426, 29.764096898390772],
|
||||
[94.98828790834106, 29.764082995679242],
|
||||
[94.98859364523021, 29.764067827511347],
|
||||
[94.98890509685035, 29.764051279216236],
|
||||
[94.98922167960325, 29.76403324759024],
|
||||
[94.98954294512464, 29.76401367774424],
|
||||
[94.9898685550894, 29.76399255290906],
|
||||
[94.99019820264337, 29.76396985787546],
|
||||
[94.9905316178847, 29.763945606334815],
|
||||
[94.9908685732739, 29.763919866576575],
|
||||
[94.99120886105722, 29.763892700951896],
|
||||
[94.99155229116181, 29.7638641665054],
|
||||
[94.99189872300363, 29.76383429811986],
|
||||
[94.99224811692223, 29.763803085651237],
|
||||
[94.99260053337049, 29.763770509641105],
|
||||
[94.99295601118384, 29.76373658086857],
|
||||
[94.99331456742908, 29.763701322339223],
|
||||
[94.99367598745215, 29.763664830647013],
|
||||
[94.99404000156176, 29.763627219082974],
|
||||
[94.99440635968021, 29.763588592698778],
|
||||
[94.99477481502787, 29.76354905236459],
|
||||
[94.9951451006731, 29.763508748648892],
|
||||
[94.99578766204738, 29.763462125646818],
|
||||
[94.99643710580968, 29.763412119743368],
|
||||
[94.99709296623192, 29.763358988682928],
|
||||
[94.99775479298779, 29.763302979031952],
|
||||
[94.99842214521965, 29.763244327404465],
|
||||
[94.99909470569231, 29.763183258547627],
|
||||
[94.9997722961385, 29.76312010454948],
|
||||
[95.00045474013422, 29.763055184243758],
|
||||
[95.00114189582168, 29.762988795623112],
|
||||
[95.00183368970049, 29.762921207778405],
|
||||
[95.0025300799095, 29.76285270023514],
|
||||
[95.00323103443773, 29.762783549665084],
|
||||
[95.00393658318124, 29.76271400155497],
|
||||
[95.00464681949704, 29.76264432424487],
|
||||
[95.00536183331896, 29.76257477740286],
|
||||
[95.00608171146906, 29.762505612813378],
|
||||
[95.00680653793428, 29.762437075077955],
|
||||
[95.00753639864557, 29.762369398844733],
|
||||
[95.00827137837749, 29.762302811854063],
|
||||
[95.00901153006761, 29.762237519590354],
|
||||
[95.00975680776318, 29.76217368790719],
|
||||
[95.01050716875933, 29.762111478518566],
|
||||
[95.0112626067996, 29.76205102042169],
|
||||
[95.01202312290016, 29.761992432502932],
|
||||
[95.01278871942189, 29.76193583156792],
|
||||
[95.01355940108247, 29.761881333165324],
|
||||
[95.01433525277555, 29.761829093407865],
|
||||
[95.01511636205572, 29.761779224166457],
|
||||
[95.01590278872715, 29.76173180995877],
|
||||
[95.0166868810408, 29.76168612055982],
|
||||
[95.01746871725406, 29.761642332250204],
|
||||
[95.0182483756254, 29.76160062131038],
|
||||
[95.01902596675926, 29.761561177619345],
|
||||
[95.019801682618, 29.761524238306453],
|
||||
[95.02057570378254, 29.76149004865798],
|
||||
[95.02134818878243, 29.761458838563573],
|
||||
[95.02211921065114, 29.761430763930672],
|
||||
[95.02288888930805, 29.761405973069504],
|
||||
[95.02365734467304, 29.761384614290225],
|
||||
[95.02442469666667, 29.761366835902948],
|
||||
[95.02519095257038, 29.761352731197437],
|
||||
[95.02595602043016, 29.761342344990513],
|
||||
[95.02671980829304, 29.761335722099044],
|
||||
[95.02748221022355, 29.76133286485609],
|
||||
[95.02824308111781, 29.761333684556867],
|
||||
[95.02900227020879, 29.761338090875398],
|
||||
[95.02975960629327, 29.76134599002589],
|
||||
[95.03051480114966, 29.76135728786741],
|
||||
[95.03126749207338, 29.761371921751238],
|
||||
[95.03201723576402, 29.761389829130785],
|
||||
[95.03276363398774, 29.761410893146387],
|
||||
[95.03350632277824, 29.761434973117545],
|
||||
[95.03424521675927, 29.76146183394706],
|
||||
[95.03498037607913, 29.761491215377145],
|
||||
[95.035711917426, 29.761522866578012],
|
||||
[95.03643998610582, 29.761556530192124],
|
||||
[95.03716475930274, 29.761591886465347],
|
||||
[95.03788653744915, 29.761628566185358],
|
||||
[95.03860571883365, 29.76166618469001],
|
||||
[95.03932258585421, 29.76170436456548],
|
||||
[95.04003738892985, 29.76174273062808],
|
||||
[95.04075025083816, 29.76178094997509],
|
||||
[95.0414610559133, 29.76181870813213],
|
||||
[95.04216961904287, 29.761855638407084],
|
||||
[95.04287591713418, 29.761891331889718],
|
||||
[95.04357986124926, 29.761925397709096],
|
||||
[95.04428137757625, 29.761957488479126],
|
||||
[95.04498041854592, 29.76198725048925],
|
||||
[95.04567682817188, 29.762014317811357],
|
||||
[95.04637032968634, 29.762038376323694],
|
||||
[95.04706059416637, 29.762059099770216],
|
||||
[95.04774726432963, 29.762076162373777],
|
||||
[95.0484300050609, 29.762089241174117],
|
||||
[95.04910871445378, 29.762098042845082],
|
||||
[95.04978328153793, 29.762102280852183],
|
||||
[95.05045359964508, 29.76210167552205],
|
||||
[95.05111966253706, 29.76209602004179],
|
||||
[95.05178153406797, 29.762085106891018],
|
||||
[95.05243927503516, 29.762068731547185],
|
||||
[95.05309287649075, 29.762046750046103],
|
||||
[95.0537422946664, 29.762019099359485],
|
||||
[95.05438748396233, 29.761985721609257],
|
||||
[95.05502839823626, 29.76194655859307],
|
||||
[95.05566488201883, 29.761901557942874],
|
||||
[95.05629656919591, 29.761850805510885],
|
||||
[95.05692313234233, 29.761794428680094],
|
||||
[95.0575442743842, 29.761732580860688],
|
||||
[95.0581596433054, 29.761665348138973],
|
||||
[95.05876888708963, 29.761592816601354],
|
||||
[95.05937164047754, 29.761515065256333],
|
||||
[95.05996759228947, 29.761432144038746],
|
||||
[95.06055643134583, 29.761344102883623],
|
||||
[95.06113793491645, 29.761251010034922],
|
||||
[95.06171214230999, 29.761153046649387],
|
||||
[95.06227907948092, 29.761050389948267],
|
||||
[95.06283877238438, 29.760943217152814],
|
||||
[95.06339127381165, 29.760831691663462],
|
||||
[95.06393669678933, 29.760716002374487],
|
||||
[95.06447520396281, 29.76059636241658],
|
||||
[95.06500695797821, 29.760472984920362],
|
||||
[95.06553212847378, 29.760346104258282],
|
||||
[95.0660508986937, 29.760216015040477],
|
||||
[95.06656344194985, 29.760083025494016],
|
||||
[95.06707002648291, 29.75994741412334],
|
||||
[95.06757112978441, 29.759809440866206],
|
||||
[95.06806722979658, 29.759669344181514],
|
||||
[95.06855878142073, 29.759527354008487],
|
||||
[95.06904617625263, 29.759383697903765],
|
||||
[95.06952978875404, 29.75923861533451],
|
||||
[95.07000993080214, 29.759092371156385],
|
||||
[95.07048679175114, 29.758945227682446],
|
||||
[95.07096044787545, 29.75879742836973],
|
||||
[95.07143096114058, 29.758649219939148],
|
||||
[95.0718984144853, 29.758500858117706],
|
||||
[95.07236278188816, 29.7583525823887],
|
||||
[95.07282412506945, 29.758204644682806],
|
||||
[95.07328275575983, 29.75805728963777],
|
||||
[95.07373905641253, 29.757910760813612],
|
||||
[95.07419366476313, 29.757765217208473],
|
||||
[95.07464735762416, 29.757620800921966],
|
||||
[95.07510085345471, 29.757477677507868],
|
||||
[95.07555467766437, 29.757336168750566],
|
||||
[95.07600940811365, 29.757196598118206],
|
||||
[95.07646537399586, 29.75705931280275],
|
||||
[95.0769228207089, 29.756924677394206],
|
||||
[95.07738220221682, 29.756793075496777],
|
||||
[95.07784401968648, 29.75666487927172],
|
||||
[95.07830874492939, 29.756540496044202],
|
||||
[95.07877690647402, 29.756420332181715],
|
||||
[95.07924898851259, 29.75630478841808],
|
||||
[95.07972503722137, 29.756194196891926],
|
||||
[95.08020511490892, 29.756088882533362],
|
||||
[95.0806891403115, 29.75598918720403],
|
||||
[95.08117690920173, 29.75589531641115],
|
||||
[95.08166835685006, 29.755807414283098],
|
||||
[95.08216335868525, 29.75572566824862],
|
||||
[95.08266177736347, 29.755650250535062],
|
||||
[95.08316349491537, 29.75558120546187],
|
||||
[95.08366839703267, 29.755518567048284],
|
||||
[95.08417639041532, 29.75546236367944],
|
||||
[95.08468755179344, 29.755412571989652],
|
||||
[95.08520236790308, 29.755369015997044],
|
||||
[95.08572134650697, 29.75533148313236],
|
||||
[95.0862450098908, 29.755299740305322],
|
||||
[95.08677390781156, 29.75527358808779],
|
||||
[95.08730861289804, 29.755252808551163],
|
||||
[95.08784970300886, 29.755237175354623],
|
||||
[95.08839755885471, 29.755226476280296],
|
||||
[95.08895256438073, 29.755220494902016],
|
||||
[95.089514993805, 29.755219019351184],
|
||||
[95.09008489086116, 29.7552217729881],
|
||||
[95.09066229928165, 29.75522847917293],
|
||||
[95.09124726342738, 29.755238870403907],
|
||||
[95.09183976966378, 29.755252726598844],
|
||||
[95.09243978867215, 29.75526985018834],
|
||||
[95.09304723439128, 29.755290065097345],
|
||||
[95.09366184648091, 29.75531326126932],
|
||||
[95.09428337414857, 29.755339326723966],
|
||||
[95.09491171647382, 29.755368102171502],
|
||||
[95.09554680939114, 29.75539940595205],
|
||||
[95.09618846028675, 29.755433126230766],
|
||||
[95.09683632409143, 29.75546923335011],
|
||||
[95.09749004228215, 29.75550775219342],
|
||||
[95.09814924669702, 29.755548754655436],
|
||||
[95.0988136358514, 29.755592374644042],
|
||||
[95.09948290826172, 29.755638746066982],
|
||||
[95.1001568080277, 29.75568798494813],
|
||||
[95.10083517891061, 29.75574017449467],
|
||||
[95.10151797539464, 29.75579531974171],
|
||||
[95.1022051519645, 29.755853425724286],
|
||||
[95.10289664375364, 29.755914488290983],
|
||||
[95.10359258928374, 29.755978570204803],
|
||||
[95.10429306658138, 29.756045742645284],
|
||||
[95.10499798654989, 29.756116067070348],
|
||||
[95.10570717321444, 29.75618958016383],
|
||||
[95.10642032295875, 29.75626636089044],
|
||||
[95.10713711848761, 29.756346492746122],
|
||||
[95.10785729025027, 29.75643004436662],
|
||||
[95.10858043622838, 29.75651688160893],
|
||||
[95.10930611534633, 29.75660684892344],
|
||||
[95.11003407093642, 29.756699686157475],
|
||||
[95.11076414467685, 29.756795123665622],
|
||||
[95.1114960914754, 29.756892893838618],
|
||||
[95.11222971288655, 29.756992700238396],
|
||||
[95.1129649452222, 29.75709420093414],
|
||||
[95.11370170201873, 29.75719709582746],
|
||||
[95.11443986012442, 29.757301125099488],
|
||||
[95.11517935555177, 29.7574061249826],
|
||||
[95.11592009761476, 29.757511940391662],
|
||||
[95.11666232015585, 29.75761835330566],
|
||||
[95.11740624233691, 29.757725222810585],
|
||||
[95.11815181076378, 29.75783253698326],
|
||||
[95.11889913308254, 29.757940178669305],
|
||||
[95.11964841222917, 29.758048000558695],
|
||||
[95.12039993276164, 29.758155854652614],
|
||||
[95.12115399312397, 29.75826357999786],
|
||||
[95.1219108497459, 29.75837102690515],
|
||||
[95.1226706293697, 29.758478081829693],
|
||||
[95.12343325561484, 29.758584686897258],
|
||||
[95.12419857627997, 29.758690818430438],
|
||||
[95.12496638982559, 29.75879646083105],
|
||||
[95.12573649209446, 29.7589015818837],
|
||||
[95.12650863318528, 29.75900618637391],
|
||||
[95.12728253192327, 29.759110282431784],
|
||||
[95.12805798566751, 29.75921386162033],
|
||||
[95.12883477913662, 29.759316919371038],
|
||||
[95.12961273528985, 29.759419445656786],
|
||||
[95.13039166301432, 29.759521458216526],
|
||||
[95.13117134363408, 29.759622963323043],
|
||||
[95.13195155721434, 29.759723948972997],
|
||||
[95.13273211930314, 29.759824349786275],
|
||||
[95.13351286507051, 29.759924061406032],
|
||||
[95.13428648274362, 29.76002351015658],
|
||||
[95.1350533298064, 29.760122544307915],
|
||||
[95.1358137532818, 29.760221010686855],
|
||||
[95.13656784419986, 29.760318809952974],
|
||||
[95.13731567503761, 29.76041584848236],
|
||||
[95.1380573752464, 29.760511990985425],
|
||||
[95.13879310931623, 29.76060704815151],
|
||||
[95.13952306473344, 29.760700794123938],
|
||||
[95.14024751710419, 29.7607929520901],
|
||||
[95.14096671686433, 29.760883206230545],
|
||||
[95.14168092332761, 29.76097123202995],
|
||||
[95.14239029599311, 29.761056732470486],
|
||||
[95.14309486625548, 29.761139484506582],
|
||||
[95.14379454089128, 29.761219462160696],
|
||||
[95.14448922306659, 29.76129664046317],
|
||||
[95.14517882147311, 29.76137100941381],
|
||||
[95.14586312132259, 29.761442532574584],
|
||||
[95.14654188135431, 29.761511168945386],
|
||||
[95.14721489938523, 29.761576901934955],
|
||||
[95.14788204807155, 29.761639772673718],
|
||||
[95.14854319868547, 29.76169982923195],
|
||||
[95.14919822095698, 29.761757127412878],
|
||||
[95.14984699116548, 29.76181173598209],
|
||||
[95.15048960377052, 29.761863851723042],
|
||||
[95.15112617637942, 29.761913696049017],
|
||||
[95.15175681293137, 29.761961561816054],
|
||||
[95.15238148064795, 29.762007691856205],
|
||||
[95.15300009096235, 29.762052309028526],
|
||||
[95.1533539374323, 29.762086082676035],
|
||||
[95.15370650809008, 29.762118928860186],
|
||||
[95.15405759583123, 29.762150875711065],
|
||||
[95.15440704121897, 29.76218191319173],
|
||||
[95.1547548321985, 29.762211967913583],
|
||||
[95.15510097264206, 29.762240955288657],
|
||||
[95.15544525415498, 29.762268829619945],
|
||||
[95.15578751039571, 29.76229553455492],
|
||||
[95.15612769998111, 29.762320941845203],
|
||||
[95.15646565712565, 29.762344985615734],
|
||||
[95.156801142589, 29.76236761294652],
|
||||
[95.1571338046714, 29.76238883654028],
|
||||
[95.15746323934037, 29.76240869370805],
|
||||
[95.15778903282519, 29.762427217766984],
|
||||
[95.15811077257146, 29.762444433666733],
|
||||
[95.15842800577592, 29.762460368791256],
|
||||
[95.15874028837707, 29.76247502889767],
|
||||
[95.15904716834646, 29.76248842410003],
|
||||
[95.1593481499996, 29.762500590502704],
|
||||
[95.15964271930007, 29.76251153961251],
|
||||
[95.1599303416933, 29.76252129267978],
|
||||
[95.16021045104225, 29.762529892932115],
|
||||
[95.16048243037355, 29.762537389252305],
|
||||
[95.16074559672008, 29.762543832974302],
|
||||
[95.1609993202866, 29.762549265122786],
|
||||
[95.16124294069552, 29.762553753099137],
|
||||
[95.16147571071544, 29.762557390653928],
|
||||
[95.16169677155058, 29.762560323342807]
|
||||
]
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
[
|
||||
[-74.0135, 40.7047],
|
||||
[-74.0102, 40.7104],
|
||||
[-74.0074, 40.7167],
|
||||
[-74.0047, 40.7232],
|
||||
[-74.0017, 40.7297],
|
||||
[-73.9988, 40.7362],
|
||||
[-73.9954, 40.7429],
|
||||
[-73.9917, 40.7496],
|
||||
[-73.9882, 40.7563],
|
||||
[-73.9848, 40.7631]
|
||||
]
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import {Composition} from 'remotion';
|
||||
import {CesiumFlythrough, type CesiumFlythroughProps} from './CesiumFlythrough';
|
||||
import cityPath from './city-path.json';
|
||||
|
||||
export const RemotionRoot: React.FC = () => (
|
||||
<>
|
||||
<Composition
|
||||
id="LandscapeFlyover"
|
||||
component={CesiumFlythrough}
|
||||
defaultProps={{mode: 'landscape'} satisfies CesiumFlythroughProps}
|
||||
durationInFrames={24 * 30}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
<Composition
|
||||
id="CityFlyover"
|
||||
component={CesiumFlythrough}
|
||||
defaultProps={
|
||||
{
|
||||
mode: 'city',
|
||||
path: cityPath as [number, number][],
|
||||
altitudeStart: 700,
|
||||
altitudeEnd: 500,
|
||||
lookAheadKm: 0.7,
|
||||
travelKm: 4.5,
|
||||
pitchFromNadir: 72,
|
||||
verticalExaggeration: 1,
|
||||
maximumScreenSpaceError: 6,
|
||||
} satisfies CesiumFlythroughProps
|
||||
}
|
||||
durationInFrames={18 * 30}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
export type LngLat = [number, number];
|
||||
|
||||
// Chaikin corner cutting turns a sparse route into a continuous curve. Repeated passes round
|
||||
// direction changes into deliberate swerves instead of left-right heading bumps.
|
||||
export const smoothFlightPath = (source: LngLat[], passes = 3): LngLat[] => {
|
||||
if (source.length < 2)
|
||||
throw new Error('Flyover path needs at least two points');
|
||||
|
||||
// Keep adjacent longitudes continuous for routes that cross the antimeridian.
|
||||
const unwrapped: LngLat[] = [source[0]];
|
||||
for (let index = 1; index < source.length; index++) {
|
||||
const [lng, lat] = source[index];
|
||||
const previous = unwrapped[index - 1][0];
|
||||
let adjusted = lng;
|
||||
while (adjusted - previous > 180) adjusted -= 360;
|
||||
while (adjusted - previous < -180) adjusted += 360;
|
||||
unwrapped.push([adjusted, lat]);
|
||||
}
|
||||
|
||||
let curve = unwrapped;
|
||||
for (let pass = 0; pass < Math.max(0, passes); pass++) {
|
||||
const next: LngLat[] = [curve[0]];
|
||||
for (let i = 0; i < curve.length - 1; i++) {
|
||||
const a = curve[i];
|
||||
const b = curve[i + 1];
|
||||
next.push(
|
||||
[a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25],
|
||||
[a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75],
|
||||
);
|
||||
}
|
||||
next.push(curve[curve.length - 1]);
|
||||
curve = next;
|
||||
}
|
||||
return curve;
|
||||
};
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"name": "Yarlung Tsangpo (OSM, gorge way)"},
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [
|
||||
[94.8794306, 29.5453548],
|
||||
[94.880058, 29.5500259],
|
||||
[94.8804545, 29.5529773],
|
||||
[94.8872806, 29.559007],
|
||||
[94.8902386, 29.5645817],
|
||||
[94.8945618, 29.5651505],
|
||||
[94.8985437, 29.5703839],
|
||||
[94.8978611, 29.5758448],
|
||||
[94.9077237, 29.5843086],
|
||||
[94.9107706, 29.5872382],
|
||||
[94.9136749, 29.5879042],
|
||||
[94.9208424, 29.5864253],
|
||||
[94.9243692, 29.5890419],
|
||||
[94.9243692, 29.5945028],
|
||||
[94.9268508, 29.597544],
|
||||
[94.9331942, 29.6000111],
|
||||
[94.9350913, 29.6024165],
|
||||
[94.9359736, 29.6042869],
|
||||
[94.9354367, 29.6064452],
|
||||
[94.9350634, 29.608155],
|
||||
[94.9343462, 29.6094434],
|
||||
[94.9295695, 29.6113703],
|
||||
[94.9288061, 29.6124782],
|
||||
[94.9268721, 29.6194181],
|
||||
[94.9273809, 29.6276608],
|
||||
[94.9274409, 29.6286334],
|
||||
[94.9263783, 29.6303683],
|
||||
[94.9250518, 29.6304537],
|
||||
[94.922744, 29.6299103],
|
||||
[94.9170292, 29.6256472],
|
||||
[94.9144757, 29.6243553],
|
||||
[94.9115439, 29.6243318],
|
||||
[94.9008302, 29.6273853],
|
||||
[94.8972905, 29.6276085],
|
||||
[94.8897112, 29.6252949],
|
||||
[94.8827398, 29.6253184],
|
||||
[94.8806998, 29.6268686],
|
||||
[94.8777241, 29.6317051],
|
||||
[94.8768493, 29.6342436],
|
||||
[94.8768641, 29.6346894],
|
||||
[94.8769277, 29.6365972],
|
||||
[94.8775677, 29.6391137],
|
||||
[94.8804943, 29.6418187],
|
||||
[94.8821969, 29.6438716],
|
||||
[94.8877263, 29.6456718],
|
||||
[94.8903366, 29.6468971],
|
||||
[94.8959561, 29.6481151],
|
||||
[94.9032857, 29.6528729],
|
||||
[94.9113869, 29.6568592],
|
||||
[94.9162734, 29.6587881],
|
||||
[94.9182022, 29.6625172],
|
||||
[94.9203882, 29.670104],
|
||||
[94.9197453, 29.6744761],
|
||||
[94.9192644, 29.6754058],
|
||||
[94.9178164, 29.6782052],
|
||||
[94.915116, 29.6816771],
|
||||
[94.9094581, 29.6810342],
|
||||
[94.9043145, 29.6796197],
|
||||
[94.9018712, 29.6806484],
|
||||
[94.8976278, 29.6812914],
|
||||
[94.8909411, 29.6856634],
|
||||
[94.8896552, 29.6890068],
|
||||
[94.892227, 29.6990368],
|
||||
[94.8978849, 29.704952],
|
||||
[94.9017426, 29.7100956],
|
||||
[94.9061206, 29.7112788],
|
||||
[94.9065005, 29.7113815],
|
||||
[94.9225743, 29.7192255],
|
||||
[94.9264023, 29.7212652],
|
||||
[94.9267629, 29.7213678],
|
||||
[94.9341474, 29.723469],
|
||||
[94.9360823, 29.7245705],
|
||||
[94.9357152, 29.7273333],
|
||||
[94.9344915, 29.7289804],
|
||||
[94.9323502, 29.7303263],
|
||||
[94.9298621, 29.73339],
|
||||
[94.9294542, 29.734665],
|
||||
[94.9298213, 29.7382244],
|
||||
[94.9306755, 29.739157],
|
||||
[94.9340021, 29.7439794],
|
||||
[94.9355331, 29.7464083],
|
||||
[94.9364901, 29.7488664],
|
||||
[94.9372115, 29.749423],
|
||||
[94.937775, 29.7498579],
|
||||
[94.939019, 29.7500527],
|
||||
[94.9407117, 29.7501944],
|
||||
[94.9429346, 29.7513629],
|
||||
[94.947778, 29.7543307],
|
||||
[94.9541921, 29.755984],
|
||||
[94.9574551, 29.7555414],
|
||||
[94.9603715, 29.7553644],
|
||||
[94.9629516, 29.755488],
|
||||
[94.9676317, 29.757489],
|
||||
[94.9709355, 29.7587814],
|
||||
[94.9737019, 29.7603278],
|
||||
[94.9738572, 29.7603796],
|
||||
[94.9844009, 29.7638941],
|
||||
[94.9852725, 29.7649069],
|
||||
[94.9861359, 29.7675568],
|
||||
[94.9898232, 29.7694497],
|
||||
[94.9917263, 29.7691954],
|
||||
[94.9966421, 29.7688099],
|
||||
[95.0005707, 29.7670075],
|
||||
[95.0007566, 29.7668999],
|
||||
[95.0028203, 29.7657056],
|
||||
[95.0080406, 29.7614995],
|
||||
[95.0092963, 29.7609572],
|
||||
[95.0138378, 29.7589957],
|
||||
[95.0160159, 29.7574362],
|
||||
[95.0194618, 29.7553902],
|
||||
[95.0222306, 29.7524106],
|
||||
[95.024336, 29.7498815],
|
||||
[95.0273932, 29.7487547],
|
||||
[95.0324981, 29.7493557],
|
||||
[95.0339552, 29.7496724],
|
||||
[95.0346257, 29.7499916],
|
||||
[95.035882, 29.7507411],
|
||||
[95.0375964, 29.7530966],
|
||||
[95.0386203, 29.754818],
|
||||
[95.0383895, 29.7567272],
|
||||
[95.038166, 29.7590432],
|
||||
[95.0395792, 29.7618349],
|
||||
[95.0411334, 29.7631575],
|
||||
[95.0420532, 29.7639402],
|
||||
[95.0467825, 29.7661735],
|
||||
[95.0495101, 29.7673453],
|
||||
[95.0510063, 29.7681933],
|
||||
[95.0516577, 29.7683919],
|
||||
[95.0560621, 29.7692331],
|
||||
[95.0577259, 29.7690047],
|
||||
[95.0588453, 29.7684695],
|
||||
[95.0650773, 29.767807],
|
||||
[95.0705737, 29.761782],
|
||||
[95.0720258, 29.7599696],
|
||||
[95.0736121, 29.7540856],
|
||||
[95.0754147, 29.751782],
|
||||
[95.0758224, 29.751042],
|
||||
[95.0760619, 29.750314],
|
||||
[95.0759483, 29.7495284],
|
||||
[95.0751984, 29.7480135],
|
||||
[95.0728911, 29.7466988],
|
||||
[95.0724585, 29.7445327],
|
||||
[95.0732804, 29.7429802],
|
||||
[95.0734102, 29.740789],
|
||||
[95.0731362, 29.7386729],
|
||||
[95.0744382, 29.7374902],
|
||||
[95.075294, 29.7367129],
|
||||
[95.079535, 29.7361346],
|
||||
[95.081745, 29.736994],
|
||||
[95.083005, 29.737484],
|
||||
[95.0833905, 29.7430745],
|
||||
[95.0844421, 29.7452715],
|
||||
[95.0871532, 29.7473749],
|
||||
[95.0918361, 29.7499864],
|
||||
[95.0931923, 29.7511573],
|
||||
[95.0943202, 29.7514189],
|
||||
[95.0965266, 29.7510558],
|
||||
[95.0991656, 29.7494408],
|
||||
[95.1029871, 29.7472998],
|
||||
[95.1038668, 29.7453591],
|
||||
[95.1063039, 29.7417406],
|
||||
[95.1084814, 29.741365],
|
||||
[95.111726, 29.7426671],
|
||||
[95.1138489, 29.7452914],
|
||||
[95.1152135, 29.7465013],
|
||||
[95.1154175, 29.7473556],
|
||||
[95.1158661, 29.7480506],
|
||||
[95.1167533, 29.7482586],
|
||||
[95.1179004, 29.7491572],
|
||||
[95.119434, 29.7515181],
|
||||
[95.1195997, 29.7517319],
|
||||
[95.1214023, 29.7535598],
|
||||
[95.1239692, 29.754987],
|
||||
[95.1260602, 29.7556005],
|
||||
[95.1284973, 29.7561388],
|
||||
[95.1311663, 29.7559002],
|
||||
[95.1323477, 29.7559385],
|
||||
[95.1344757, 29.7564723],
|
||||
[95.1350155, 29.7583672],
|
||||
[95.1382746, 29.7617848],
|
||||
[95.1400377, 29.7646815],
|
||||
[95.1410982, 29.7654339],
|
||||
[95.1437689, 29.7649144],
|
||||
[95.1469991, 29.7640131],
|
||||
[95.1503617, 29.7622343],
|
||||
[95.1506908, 29.7620602],
|
||||
[95.1532721, 29.7600322],
|
||||
[95.1565312, 29.7590432],
|
||||
[95.1584059, 29.7594063],
|
||||
[95.1589059, 29.7599317],
|
||||
[95.1595043, 29.7612399],
|
||||
[95.1611602, 29.7632119],
|
||||
[95.1635565, 29.7646136],
|
||||
[95.1700434, 29.7649144],
|
||||
[95.1730828, 29.7644136],
|
||||
[95.1748094, 29.7636814],
|
||||
[95.1765005, 29.7628339],
|
||||
[95.1794885, 29.7589784],
|
||||
[95.1830673, 29.7576787],
|
||||
[95.1848212, 29.7580682],
|
||||
[95.1869103, 29.7594603]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Flyover data sources
|
||||
|
||||
## Landscape
|
||||
|
||||
Use MapTiler for both layers:
|
||||
|
||||
- `terrain-quantized-mesh-v2`: elevation encoded as Cesium quantized-mesh terrain.
|
||||
- `satellite-v2`: raster satellite imagery draped on that mesh.
|
||||
|
||||
CesiumJS renders the layers; it does not supply the data. Set `REMOTION_MAPTILER_KEY`.
|
||||
|
||||
Create a key:
|
||||
|
||||
- https://cloud.maptiler.com/account/keys/
|
||||
|
||||
Official documentation:
|
||||
|
||||
- https://docs.maptiler.com/cesium/
|
||||
- https://docs.maptiler.com/schema-raster/terrain-3d/
|
||||
|
||||
## City
|
||||
|
||||
Use Google Photorealistic 3D Tiles. Google supplies one high-resolution 3D mesh already textured
|
||||
with imagery. Disable the Cesium globe and do not add MapTiler terrain or satellite beneath it. Set
|
||||
`REMOTION_GOOGLE_MAPS_API_KEY`.
|
||||
|
||||
Create and configure a key:
|
||||
|
||||
- https://developers.google.com/maps/documentation/tile/get-api-key
|
||||
|
||||
Enable the Map Tiles API in a billing-enabled Google Cloud project and restrict the key to that API.
|
||||
The application restriction must permit the local headless Remotion request.
|
||||
|
||||
Official documentation:
|
||||
|
||||
- https://developers.google.com/maps/documentation/tile/3d-tiles
|
||||
- https://developers.google.com/maps/documentation/tile/policies
|
||||
|
||||
## Why not extruded buildings
|
||||
|
||||
OSM-, Overture- and vector-tile building products primarily provide footprints, approximate heights
|
||||
and optional roof attributes. They are useful for analytical or stylized maps, but they do not
|
||||
provide the textured architecture required for a cinematic city flyover.
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# 3D Flyover — architecture reference
|
||||
|
||||
Deep detail behind `TECHNIQUE.md`: provider loading, the camera-path pipeline, per-frame camera math, and
|
||||
the proven terrain values. Both landscape and city modes have been forward-tested through Remotion.
|
||||
|
||||
## 1. Provider initialization
|
||||
|
||||
Create the Viewer with `baseLayer: false`, UI widgets disabled, and
|
||||
`contextOptions.webgl.preserveDrawingBuffer: true`. Never hide the credit display.
|
||||
|
||||
### Landscape
|
||||
|
||||
Add MapTiler `satellite-v2` with `UrlTemplateImageryProvider`, then load
|
||||
`terrain-quantized-mesh-v2` with `CesiumTerrainProvider.fromUrl({requestVertexNormals: true})`.
|
||||
MapTiler supplies both datasets; no Cesium ion token is required.
|
||||
|
||||
### City
|
||||
|
||||
Do not add MapTiler. Hide the globe, then add:
|
||||
|
||||
```ts
|
||||
viewer.scene.globe.show = false;
|
||||
const tileset = await Cesium.Cesium3DTileset.fromUrl(
|
||||
`https://tile.googleapis.com/v1/3dtiles/root.json?key=${GOOGLE_KEY}`,
|
||||
{showCreditsOnScreen: true, maximumScreenSpaceError: 4},
|
||||
);
|
||||
viewer.scene.primitives.add(tileset);
|
||||
```
|
||||
|
||||
Lower `maximumScreenSpaceError` improves refinement at substantial download/render cost. Start at
|
||||
`4` for a hero landmark and `6–8` for wider urban shots. Enforce the Google 30-second promotional
|
||||
video ceiling in the component.
|
||||
|
||||
Load Cesium from the CDN after setting `window.CESIUM_BASE_URL`; the tested version is `1.143`.
|
||||
|
||||
## 2. The camera path — structure & generation
|
||||
|
||||
Four properties matter, in order:
|
||||
|
||||
1. **Continuous curvature** — the camera must flow through curves, never "fly straight, snap to a new
|
||||
heading, fly straight." The component applies three passes of **Chaikin corner cutting** to every
|
||||
supplied path. Each pass replaces a segment with quarter and three-quarter points, rounding a
|
||||
corner into a curve. Three passes are the default; four is softer, two is tighter.
|
||||
2. **Constant ground speed** — precompute cumulative distance along the rounded curve and interpolate
|
||||
by arc length. Do not animate by source-point index; unequal source spacing creates speed bumps.
|
||||
3. **Minimized amplitude** — for detailed landscape centerlines, dampen the prepared path toward its
|
||||
straight start→end chord by a fixed fraction `DAMP` (0 = dead straight, 1 = full river). This is
|
||||
the single swerve-amplitude knob.
|
||||
4. **Enough length** — `PATHKM ≥ TRAVEL_KM + 2·LOOK_AHEAD_KM` so the look-ahead aim never clamps.
|
||||
|
||||
`../scripts/prep-cesium-path.mjs` does: clip a window of the source centerline → resample to even
|
||||
arc-length spacing (0.1 km) → moving-average smooth (±2.8 km window, 2 passes) → dampen toward the chord
|
||||
(`DAMP=0.45`). Validate with the heading-delta probe it prints — deltas should be small and change
|
||||
_gradually_ (water-wars: `3,0,-1,-3,-4,1,7,9,5,-5,-12,-7,…`). Big jumps = corners = bad.
|
||||
|
||||
The component then applies Chaikin smoothing to this prepared route, or directly to a short
|
||||
hand-authored city route. Keep city control points sparse and intentional; smoothing cannot rescue a
|
||||
zig-zagging route that crosses the subject repeatedly.
|
||||
|
||||
**Source data:** OSM via Overpass (~0.3 km vertex spacing) — Natural Earth is too coarse for inner
|
||||
gorges. overpass-api.de is often busy → mirror `overpass.kumi.systems`.
|
||||
|
||||
## 3. Camera animation per frame (position, heading, pitch, bank)
|
||||
|
||||
Walk the path by **arc length** (precompute cumulative distances once). Every frame:
|
||||
|
||||
- **Position** = the point at `dCam` km along the path, altitude `lerp(ALT_START, ALT_END, prog)`.
|
||||
- **Heading** = bearing from the camera point to a **real point `LOOK_AHEAD_KM` further along the same
|
||||
path**. A far aim averages wiggle → smooth heading; on a curved path it leads into the bend, so the
|
||||
heading turns gently with the path. (A local-tangent aim spins the camera at every kink — don't.)
|
||||
- **Pitch** = constant. We keep a MapLibre-style param `PITCH_FROM_NADIR` (90 = horizon), then convert:
|
||||
**Cesium pitch = `-(90 - PITCH_FROM_NADIR)`** (Cesium: 0 = horizon, -90 = straight down). 76° → -14°.
|
||||
- **Bank (roll)** = lean _into_ the turn — the helicopter tell. Measure turn rate as the bearing change
|
||||
between `aim` and a point `2·LOOK_AHEAD_KM` ahead; `roll = clamp(dH · BANK_GAIN, ±MAX_BANK)`. Because
|
||||
the path is smooth, `dH` changes gradually → the bank eases in and out, never jerks.
|
||||
|
||||
```ts
|
||||
const setCamera = (C, viewer, prog) => {
|
||||
const dCam = Math.min(TRAVEL_KM, PATHKM - LOOK_AHEAD_KM * 2) * prog;
|
||||
const cam = alongPath(dCam); // arc-length point
|
||||
const aim = alongPath(dCam + LOOK_AHEAD_KM); // heading target (real point on the path)
|
||||
const aim2 = alongPath(dCam + LOOK_AHEAD_KM * 2); // turn-rate probe → bank
|
||||
const heading = bearing(cam, aim);
|
||||
let dH = bearing(aim, aim2) - heading; while (dH > Math.PI) dH -= 2*Math.PI; while (dH < -Math.PI) dH += 2*Math.PI;
|
||||
viewer.camera.setView({
|
||||
destination: C.Cartesian3.fromDegrees(cam[0], cam[1], lerp(ALT_START, ALT_END, prog)),
|
||||
orientation: { heading, pitch: C.Math.toRadians(-(90 - PITCH_FROM_NADIR)), roll: clamp(dH * BANK_GAIN, -MAX_BANK, MAX_BANK) },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
> **Cesium vs MapLibre conventions (gotcha):** Cesium heading is radians, 0 = north, clockwise. Pitch
|
||||
> 0 = horizon, negative = down (MapLibre is the inverse). Roll positive = bank right; tune the sign by eye.
|
||||
|
||||
## 4. The feel — proven water-wars values
|
||||
|
||||
| Param | Value | Meaning |
|
||||
| ------------------------ | ---------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `TRAVEL_KM` | 13 | How far the camera travels. **Speed = `TRAVEL_KM / durationSeconds`.** |
|
||||
| duration | 24 s (720 f @30) | 13 km / 24 s ≈ **0.54 km/s** — a slow, peaceful glide. 8 s felt "extremely rushed". |
|
||||
| `ALT_START → ALT_END` | 4600 → 4300 m ASL | Absolute (terrain-independent). Inside the corridor walls → fly _through_, not over. |
|
||||
| `LOOK_AHEAD_KM` | 1.5 | Heading smoothness vs responsiveness. |
|
||||
| `PITCH_FROM_NADIR` | 76° | Stare-ahead down the corridor (90 = level). → Cesium -14°. |
|
||||
| `MAX_BANK` / `BANK_GAIN` | 0.13 rad (~7.5°) / 0.6 | Helicopter lean into turns. |
|
||||
| `verticalExaggeration` | 1.1 | Subtle terrain drama. |
|
||||
| `DAMP` (prep) | 0.45 | Swerve amount: higher = weavier, lower = straighter. |
|
||||
|
||||
**A slow camera renders fast.** At 0.54 km/s the camera moves ~18 m/frame, so tiles stay cached and each
|
||||
`settle()` returns almost immediately; the 720-frame render completed in one pass (no chunk-rendering).
|
||||
|
||||
## 5. The complete component
|
||||
|
||||
The full, runnable component is `../assets/CesiumFlythrough.tsx` — read it directly. Its shape:
|
||||
|
||||
- `loadCesium()` — inject `CESIUM_BASE_URL` + the CDN `Cesium.js`, resolve when loaded.
|
||||
- init effect — build the Viewer (§1), `setCamera(…, 0)`, `await settle(viewer)`, `continueRender`.
|
||||
- `settle(viewer)` — loop `viewer.render()` until `globe.tilesLoaded` for landscapes or
|
||||
`tileset.tilesLoaded` for cities is stable for ~8 ticks (cap ~600).
|
||||
- per-frame effect — `delayRender({timeoutInMilliseconds: 60000})` → `setCamera(prog)` → `settle()` → `continueRender`.
|
||||
|
||||
## 6. Render
|
||||
|
||||
```bash
|
||||
bunx remotion still src/index.ts <Comp> out.png --frame=N --gl=angle --timeout=180000 # validate framing/bank first
|
||||
bunx remotion render src/index.ts <Comp> out.mp4 --gl=angle --concurrency=1 --timeout=180000
|
||||
```
|
||||
|
||||
`--gl=angle` is mandatory. Use `--concurrency=1`; `settle()` already serializes tile loading.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# 3D Flyover — troubleshooting
|
||||
|
||||
## The headless dead-end (why we render through Remotion)
|
||||
|
||||
Cesium's globe **will not draw in a standalone headless Playwright/Chromium** harness. Verified on
|
||||
Apple M4 (ANGLE Metal active, WebGL working): the skybox/stars render, but the globe surface produces
|
||||
**zero draw commands** (`scene.frameState.commandList.length === 0`), `globe.tilesLoaded` never goes
|
||||
true, and frames come back as the black starfield. No network failures; `sampleTerrainMostDetailed`
|
||||
succeeds (terrain data is reachable). Dead-ends tried, all failed:
|
||||
|
||||
- default render loop, manual `scene.render()`, manual `viewer.render()`, headed mode (context-destroyed).
|
||||
|
||||
**What works:** render Cesium **through Remotion** — same headless Chrome, but driven by Remotion's frame
|
||||
loop with these four non-negotiables:
|
||||
|
||||
1. `useDefaultRenderLoop = false` — drive frames by hand.
|
||||
2. Per frame call **`viewer.render()`**, NOT `scene.render()`. `viewer.render()` does the full frame
|
||||
(`initializeFrame` → tile streaming → render); `scene.render()` skips frame-init, so tiles never
|
||||
advance and the globe never appears. **This is the single most important line.**
|
||||
3. `contextOptions: { webgl: { preserveDrawingBuffer: true } }` so Remotion's screenshot captures pixels.
|
||||
4. Gate init + every frame with `delayRender(…, {timeoutInMilliseconds})` — tile loading can exceed Remotion's
|
||||
default; use 60–120 s.
|
||||
|
||||
The standalone `flythrough.html` / `render.mjs` / `probe.mjs` from the original spike are kept only as the
|
||||
record of this dead-end. The canonical render path is the Remotion component
|
||||
(`../assets/CesiumFlythrough.tsx`).
|
||||
|
||||
## Symptom → fix
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Frames are black with stars | Globe not drawing (headless Playwright, or `scene.render()` used) | Render through Remotion; use `viewer.render()`; `useDefaultRenderLoop=false`. |
|
||||
| Screenshots blank/transparent | No `preserveDrawingBuffer` | `contextOptions:{ webgl:{ preserveDrawingBuffer:true } }`. |
|
||||
| "delayRender timed out" | Cold tiles exceed the default | `delayRender(…, {timeoutInMilliseconds: 120000})` + `--timeout=180000`. |
|
||||
| Globe is a dark/navy sphere | Imagery layer didn't attach | `baseLayer:false` then `viewer.imageryLayers.addImageryProvider(...)`. |
|
||||
| High-pitch frame shows a void/starfield above the horizon | No atmosphere | `viewer.scene.skyAtmosphere.show = true`. |
|
||||
| 403 on tiles in headless | Domain-locked MapTiler key | Use an **unrestricted** key. |
|
||||
| Google root tileset returns 403 | Map Tiles API disabled, billing absent, wrong key, or application restriction blocks local headless rendering | Enable Map Tiles API and billing; restrict the key to that API while allowing the Remotion request. |
|
||||
| Google scene shows a duplicate/competing surface | MapTiler or the Cesium globe is still enabled | Do not add MapTiler; set `viewer.scene.globe.show=false`. |
|
||||
| Google mesh remains coarse | Screen-space error is too high or the capture starts before refinement | Lower `maximumScreenSpaceError`; settle on `tileset.tilesLoaded`. |
|
||||
| WebGL unavailable / software renderer | Missing GL flag | Render with `--gl=angle`. |
|
||||
| Camera looks at sky / ground, not terrain | Pitch sign / convention | Cesium pitch 0 = horizon, negative = down (inverse of MapLibre); `-(90 - PITCH_FROM_NADIR)`. |
|
||||
| Aim/turn-probe clamps near the end | Path too short | `PATHKM ≥ TRAVEL_KM + 2·LOOK_AHEAD_KM`; raise `WINDOW_KM` in prep. |
|
||||
| Path feels like straight-then-corner | Douglas-Peucker simplification | Use resample → moving-average smooth (see architecture §2), not `turf.simplify`. |
|
||||
| Camera bumps left and right instead of swerving | Sparse route vertices are still being followed as straight segments | Keep `pathSmoothingPasses={3}`; use sparse intentional control points and arc-length movement. |
|
||||
|
||||
## Gotchas checklist
|
||||
|
||||
- **`viewer.render()`, never `scene.render()`** per frame. The single biggest trap.
|
||||
- Cesium loads from **CDN**; set `window.CESIUM_BASE_URL` _before_ injecting the script.
|
||||
- `preserveDrawingBuffer: true` or screenshots are blank.
|
||||
- `delayRender` uses `timeoutInMilliseconds`; set it to at least 60,000.
|
||||
- Terrain: `baseLayer:false`, then add MapTiler imagery.
|
||||
- Google: no MapTiler, hide the globe, retain `showCreditsOnScreen:true`.
|
||||
- Always `skyAtmosphere.show = true`.
|
||||
- Validate the path's heading-delta probe and render one **still** (framing + bank) before the full mp4.
|
||||
- Cesium pitch/roll conventions are inverted vs MapLibre.
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
// Camera-path generator for the Cesium flythrough. Turns a river/route centerline GeoJSON into a LONG,
|
||||
// CONTINUOUSLY-curving camera path so the camera banks through smooth flowing curves (no
|
||||
// straight-then-corner). Method: clip → resample to even spacing → moving-average smooth (inherently
|
||||
// continuous curvature) → dampen lateral deviation toward the straight chord (dials swerve amplitude).
|
||||
// No Douglas-Peucker (that concentrates curvature at sparse control points → corners).
|
||||
//
|
||||
// RUN (out of the box, against the shipped sample):
|
||||
// node prep-cesium-path.mjs
|
||||
// → reads assets/sample-river.geojson (override: node prep-cesium-path.mjs <input.geojson> <output.json>)
|
||||
// → writes assets/cesium-path.json (then import that JSON in CesiumFlythrough.tsx, or copy it
|
||||
// into your Remotion project's src/geo/ and adjust the import)
|
||||
//
|
||||
// ADAPT for a new location: change START (a point ON your centerline where the corridor opens),
|
||||
// WINDOW_KM, and DAMP/SMOOTH below. Input must be a single LineString feature (features[0].geometry).
|
||||
|
||||
import {readFileSync, writeFileSync, mkdirSync} from 'fs';
|
||||
import {dirname, resolve} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const IN = process.argv[2] || resolve(__dir, '../assets/sample-river.geojson');
|
||||
const OUT = process.argv[3] || resolve(__dir, '../assets/cesium-path.json');
|
||||
const havKm = (a, b) => {
|
||||
const R = 6371,
|
||||
r = Math.PI / 180,
|
||||
dLat = (b[1] - a[1]) * r,
|
||||
dLng = (b[0] - a[0]) * r;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(a[1] * r) * Math.cos(b[1] * r) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
};
|
||||
|
||||
const gorge = JSON.parse(readFileSync(IN, 'utf8')).features[0].geometry
|
||||
.coordinates;
|
||||
|
||||
// ADAPT: clip ~24 km of river from the reach where the flythrough opens. START must be a point ON the
|
||||
// centerline (the script snaps to the nearest vertex). The sample's opening is the Yarlung gorge:
|
||||
const START = [94.968, 29.757];
|
||||
let s0 = 0,
|
||||
best = Infinity;
|
||||
gorge.forEach((p, i) => {
|
||||
const d = havKm(p, START);
|
||||
if (d < best) {
|
||||
best = d;
|
||||
s0 = i;
|
||||
}
|
||||
});
|
||||
const WINDOW_KM = 30; // clip to ~end of gorge data; smoothing+dampening shrink it to the usable corridor
|
||||
const clip = [];
|
||||
for (let i = s0, acc = 0; i < gorge.length; i++) {
|
||||
if (i > s0) acc += havKm(gorge[i - 1], gorge[i]);
|
||||
if (acc > WINDOW_KM) break;
|
||||
clip.push(gorge[i]);
|
||||
}
|
||||
|
||||
// Resample to even arc-length spacing so curvature is distributed evenly along the path.
|
||||
const STEP_KM = 0.1;
|
||||
const resample = (coords) => {
|
||||
const out = [coords[0].slice()];
|
||||
let carry = 0,
|
||||
from = coords[0];
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
let segLen = havKm(from, coords[i]);
|
||||
while (carry + segLen >= STEP_KM) {
|
||||
const t = (STEP_KM - carry) / segLen;
|
||||
const np = [
|
||||
from[0] + (coords[i][0] - from[0]) * t,
|
||||
from[1] + (coords[i][1] - from[1]) * t,
|
||||
];
|
||||
out.push(np);
|
||||
from = np;
|
||||
segLen = havKm(from, coords[i]);
|
||||
carry = 0;
|
||||
}
|
||||
carry += segLen;
|
||||
from = coords[i];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// Moving-average smoothing — inherently continuous (no kinks). Window in points; repeat for extra glass.
|
||||
const smoothMA = (coords, w, passes) => {
|
||||
let c = coords;
|
||||
for (let p = 0; p < passes; p++) {
|
||||
c = c.map((_, i) => {
|
||||
let sx = 0,
|
||||
sy = 0,
|
||||
n = 0;
|
||||
for (
|
||||
let j = Math.max(0, i - w);
|
||||
j <= Math.min(c.length - 1, i + w);
|
||||
j++
|
||||
) {
|
||||
sx += c[j][0];
|
||||
sy += c[j][1];
|
||||
n++;
|
||||
}
|
||||
return [sx / n, sy / n];
|
||||
});
|
||||
}
|
||||
return c;
|
||||
};
|
||||
|
||||
const SMOOTH_W = 28; // ±2.8 km window — turns meanders into smooth flowing curves
|
||||
const SMOOTH_PASSES = 2;
|
||||
const DAMP = 0.45; // keep 45% of the (already-smooth) deviation → gentle, continuous swerve
|
||||
|
||||
const even = resample(clip);
|
||||
const sm = smoothMA(even, SMOOTH_W, SMOOTH_PASSES);
|
||||
|
||||
const lat0 = (sm[0][1] * Math.PI) / 180;
|
||||
const kx = 111.32 * Math.cos(lat0),
|
||||
ky = 110.57;
|
||||
const toXY = (p) => [(p[0] - sm[0][0]) * kx, (p[1] - sm[0][1]) * ky];
|
||||
const toLL = (xy) => [sm[0][0] + xy[0] / kx, sm[0][1] + xy[1] / ky];
|
||||
const A = toXY(sm[0]),
|
||||
B = toXY(sm[sm.length - 1]);
|
||||
const AB = [B[0] - A[0], B[1] - A[1]],
|
||||
len2 = AB[0] ** 2 + AB[1] ** 2;
|
||||
const path = sm.map((p) => {
|
||||
const P = toXY(p);
|
||||
const t = ((P[0] - A[0]) * AB[0] + (P[1] - A[1]) * AB[1]) / len2;
|
||||
const proj = [A[0] + t * AB[0], A[1] + t * AB[1]];
|
||||
return toLL([
|
||||
proj[0] + (P[0] - proj[0]) * DAMP,
|
||||
proj[1] + (P[1] - proj[1]) * DAMP,
|
||||
]);
|
||||
});
|
||||
|
||||
mkdirSync(dirname(OUT), {recursive: true});
|
||||
writeFileSync(OUT, JSON.stringify(path));
|
||||
|
||||
let len = 0;
|
||||
for (let i = 1; i < path.length; i++) len += havKm(path[i - 1], path[i]);
|
||||
console.log(
|
||||
`cesium-path: clip ${clip.length} → resample ${even.length} → smooth → ${path.length} pts · ${len.toFixed(1)} km`,
|
||||
);
|
||||
const bear = (a, b) => {
|
||||
const r = Math.PI / 180;
|
||||
const y = Math.sin((b[0] - a[0]) * r) * Math.cos(b[1] * r);
|
||||
const x =
|
||||
Math.cos(a[1] * r) * Math.sin(b[1] * r) -
|
||||
Math.sin(a[1] * r) * Math.cos(b[1] * r) * Math.cos((b[0] - a[0]) * r);
|
||||
return (Math.atan2(y, x) * 180) / Math.PI;
|
||||
};
|
||||
// heading sampled every ~1.5 km — should change gradually & continuously (no big jumps = no corners)
|
||||
const stepPts = Math.round(1.5 / STEP_KM);
|
||||
let prev = null,
|
||||
hs = [];
|
||||
for (let i = 0; i + stepPts < path.length; i += stepPts) {
|
||||
const h = bear(path[i], path[i + stepPts]);
|
||||
if (prev !== null) {
|
||||
let d = h - prev;
|
||||
while (d > 180) d -= 360;
|
||||
while (d < -180) d += 360;
|
||||
hs.push(d.toFixed(0));
|
||||
}
|
||||
prev = h;
|
||||
}
|
||||
console.log(` heading deltas every 1.5km (deg): ${hs.join(', ')}`);
|
||||
@@ -0,0 +1,449 @@
|
||||
---
|
||||
name: maps-mapbox
|
||||
description: Make deterministic Remotion 2D map animations with Mapbox GL JS and Turf. Use when the user chooses Mapbox for animated routes, map markers, labels, camera movement, or Mapbox styles.
|
||||
metadata:
|
||||
tags: map, map animation, mapbox, turf, geojson, route animation
|
||||
---
|
||||
|
||||
Use Mapbox GL JS for rendering maps in Remotion when the user wants Mapbox styles or higher-fidelity map visuals and has a Mapbox access token. Use Turf for geospatial operations such as great-circle routes, distances, slicing lines, and positions along routes.
|
||||
|
||||
Use this technique only when the user has a Mapbox access token and wants Mapbox styles or data.
|
||||
|
||||
## Core rules
|
||||
|
||||
- Prefer `@turf/turf` for geospatial work. Do not hand-roll distance, great-circle, route slicing, or coordinate interpolation unless the user explicitly needs a custom non-geodesic effect.
|
||||
- Use GeoJSON sources and Mapbox layers for lines, markers, and labels. Avoid DOM `Marker` elements unless the user specifically asks for HTML markers.
|
||||
- Keep the live map camera static by default. Before moving it on every frame, read [moving-map stability](references/render-stability.md). Prefer a fixed map plate for satellite imagery, hillshade, or a modest 2D reframe.
|
||||
- Use a live per-frame camera only after rendering a short MP4 and checking for shimmer. This 2D technique does not provide genuine terrain, pitch, bearing, or banking.
|
||||
- Disable non-deterministic map behavior: `interactive: false`, `fadeDuration: 0`.
|
||||
- Drive animation from `useCurrentFrame()`; do not use CSS transitions or browser-timed animation.
|
||||
- Use `delayRender()` / `continueRender()` around map loading and per-frame map updates.
|
||||
- Set `preserveDrawingBuffer: true` and render WebGL with `bunx remotion ... --gl=angle`.
|
||||
- Before continuing the initial render, add sources/layers, apply the frame-0 camera with `jumpTo()` or `setFreeCameraOptions()`, then wait for `idle`.
|
||||
- Do not add a `mapInstance.remove()` cleanup function; it can interfere with Remotion's render lifecycle.
|
||||
- Use Mapbox style URLs such as `mapbox://styles/mapbox/standard` or a user-provided custom style.
|
||||
- Do not install `@types/mapbox-gl`; Mapbox GL JS ships its own types.
|
||||
- Keep required provider attribution visible and verify current provider terms before rendering.
|
||||
- Record the source and effective date of custom or disputed geography.
|
||||
- Inspect rendered pixels, not only Studio playback, at every required aspect ratio.
|
||||
|
||||
Coordinates in Mapbox, Turf, and GeoJSON are `[longitude, latitude]`.
|
||||
|
||||
```ts
|
||||
const zurich: [number, number] = [8.5417, 47.3769];
|
||||
const newYork: [number, number] = [-74.006, 40.7128];
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install Mapbox GL JS and Turf with the project's package manager.
|
||||
|
||||
```bash
|
||||
npm i mapbox-gl @turf/turf
|
||||
```
|
||||
|
||||
```bash
|
||||
bun i mapbox-gl @turf/turf
|
||||
```
|
||||
|
||||
```bash
|
||||
yarn add mapbox-gl @turf/turf
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm i mapbox-gl @turf/turf
|
||||
```
|
||||
|
||||
Import the Mapbox CSS once in the component or an app-level stylesheet:
|
||||
|
||||
```ts
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
```
|
||||
|
||||
Mapbox requires a public access token. Prefer passing it as an input prop or reading it from an environment variable that is available to the bundled Remotion code.
|
||||
|
||||
```ts
|
||||
const mapboxAccessToken = process.env.REMOTION_MAPBOX_TOKEN;
|
||||
|
||||
if (!mapboxAccessToken) {
|
||||
throw new Error('Set REMOTION_MAPBOX_TOKEN to render Mapbox maps.');
|
||||
}
|
||||
```
|
||||
|
||||
## Basic map example
|
||||
|
||||
```tsx
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {AbsoluteFill, useDelayRender, useVideoConfig} from 'remotion';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
|
||||
const zurich: [number, number] = [8.5417, 47.3769];
|
||||
|
||||
const mapboxAccessToken = process.env.REMOTION_MAPBOX_TOKEN;
|
||||
|
||||
if (!mapboxAccessToken) {
|
||||
throw new Error('Set REMOTION_MAPBOX_TOKEN to render Mapbox maps.');
|
||||
}
|
||||
|
||||
export const MyComposition = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const {delayRender, continueRender} = useDelayRender();
|
||||
const {width, height} = useVideoConfig();
|
||||
const [loadingHandle] = useState(() => delayRender('Loading Mapbox map'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapInstance = new mapboxgl.Map({
|
||||
accessToken: mapboxAccessToken,
|
||||
container: containerRef.current,
|
||||
style: 'mapbox://styles/mapbox/standard',
|
||||
center: zurich,
|
||||
zoom: 7,
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {
|
||||
preserveDrawingBuffer: true,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.on('load', () => {
|
||||
mapInstance.jumpTo({center: zurich, zoom: 7});
|
||||
mapInstance.once('idle', () => {
|
||||
continueRender(loadingHandle);
|
||||
});
|
||||
});
|
||||
}, [continueRender, loadingHandle]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<div ref={containerRef} style={{width, height, position: 'absolute'}} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Animated examples should keep the loaded map in React state and skip per-frame updates until that state is set.
|
||||
|
||||
## Animated flight route example
|
||||
|
||||
This example shows the recommended pattern for route animations:
|
||||
|
||||
- Turf creates the route and markers.
|
||||
- Turf slices the route for line reveal animation.
|
||||
- Mapbox renders the route with GeoJSON sources and layers.
|
||||
- The camera uses `jumpTo()` with animated center, zoom, bearing, and pitch.
|
||||
- Frame 0 is prepared before `continueRender()`.
|
||||
|
||||
```tsx
|
||||
import * as turf from '@turf/turf';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Easing,
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useDelayRender,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import mapboxgl, {type GeoJSONSource, type Map} from 'mapbox-gl';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
|
||||
const zurich: [number, number] = [8.5417, 47.3769];
|
||||
const newYork: [number, number] = [-74.006, 40.7128];
|
||||
|
||||
const mapboxAccessToken = process.env.REMOTION_MAPBOX_TOKEN;
|
||||
|
||||
if (!mapboxAccessToken) {
|
||||
throw new Error('Set REMOTION_MAPBOX_TOKEN to render Mapbox maps.');
|
||||
}
|
||||
|
||||
const greatCircleLine = (from: [number, number], to: [number, number]) => {
|
||||
const route = turf.greatCircle(from, to, {npoints: 100});
|
||||
|
||||
if (route.geometry.type === 'LineString') {
|
||||
return turf.lineString(route.geometry.coordinates);
|
||||
}
|
||||
|
||||
// Great-circle routes crossing the antimeridian can become MultiLineString.
|
||||
// Keep the example valid by choosing the longest segment.
|
||||
const longestSegment = route.geometry.coordinates.reduce((longest, segment) => {
|
||||
return segment.length > longest.length ? segment : longest;
|
||||
});
|
||||
|
||||
return turf.lineString(longestSegment);
|
||||
};
|
||||
|
||||
const targetRoute = greatCircleLine(zurich, newYork);
|
||||
const targetRouteDistance = turf.length(targetRoute);
|
||||
|
||||
const cityMarkers = turf.featureCollection([
|
||||
turf.point(zurich, {name: 'Zurich'}),
|
||||
turf.point(newYork, {name: 'New York'}),
|
||||
]);
|
||||
|
||||
const clampProgress = (progress: number) => Math.min(1, Math.max(0, progress));
|
||||
|
||||
const distanceAlong = (totalDistance: number, progress: number) => {
|
||||
// Keep the route non-empty at progress 0; Turf can error on zero-length slices.
|
||||
return Math.max(0.001, totalDistance * clampProgress(progress));
|
||||
};
|
||||
|
||||
const getPartialTargetRoute = (progress: number) => {
|
||||
return turf.lineSliceAlong(
|
||||
targetRoute,
|
||||
0,
|
||||
distanceAlong(targetRouteDistance, progress),
|
||||
);
|
||||
};
|
||||
|
||||
const getCameraOptions = (progress: number) => {
|
||||
const target = turf.along(
|
||||
targetRoute,
|
||||
distanceAlong(targetRouteDistance, progress),
|
||||
).geometry.coordinates as [number, number];
|
||||
|
||||
return {
|
||||
center: target,
|
||||
zoom: interpolate(progress, [0, 0.5, 1], [7, 2.4, 8], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
}),
|
||||
bearing: interpolate(progress, [0, 1], [-20, 35], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
}),
|
||||
pitch: interpolate(progress, [0, 0.25, 0.75, 1], [25, 55, 55, 30], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const MyComposition = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const frame = useCurrentFrame();
|
||||
const {delayRender, continueRender} = useDelayRender();
|
||||
const {durationInFrames, height, width} = useVideoConfig();
|
||||
const [map, setMap] = useState<Map | null>(null);
|
||||
const [loadingHandle] = useState(() => delayRender('Loading Mapbox map'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapInstance = new mapboxgl.Map({
|
||||
accessToken: mapboxAccessToken,
|
||||
container: containerRef.current,
|
||||
style: 'mapbox://styles/mapbox/standard',
|
||||
center: zurich,
|
||||
zoom: 7,
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {
|
||||
preserveDrawingBuffer: true,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.on('load', () => {
|
||||
mapInstance.addSource('trace', {
|
||||
type: 'geojson',
|
||||
data: getPartialTargetRoute(0),
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'trace-line',
|
||||
type: 'line',
|
||||
source: 'trace',
|
||||
layout: {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#111111',
|
||||
'line-width': 7,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.addSource('city-markers', {
|
||||
type: 'geojson',
|
||||
data: cityMarkers,
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'city-marker-dots',
|
||||
type: 'circle',
|
||||
source: 'city-markers',
|
||||
paint: {
|
||||
'circle-color': '#f03b20',
|
||||
'circle-radius': 12,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': 4,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'city-marker-labels',
|
||||
type: 'symbol',
|
||||
source: 'city-markers',
|
||||
layout: {
|
||||
'text-allow-overlap': true,
|
||||
'text-anchor': 'top',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-offset': [0, 0.9],
|
||||
'text-size': 28,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#111111',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 3,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.jumpTo(getCameraOptions(0));
|
||||
mapInstance.once('idle', () => {
|
||||
setMap(mapInstance);
|
||||
continueRender(loadingHandle);
|
||||
});
|
||||
});
|
||||
}, [continueRender, loadingHandle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = delayRender('Rendering Mapbox frame');
|
||||
const timelineProgress = interpolate(frame, [0, durationInFrames - 1], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
const travelProgress = interpolate(timelineProgress, [0.2, 0.82], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
});
|
||||
const trace = map.getSource('trace') as GeoJSONSource | undefined;
|
||||
|
||||
trace?.setData(getPartialTargetRoute(travelProgress));
|
||||
map.jumpTo(getCameraOptions(travelProgress));
|
||||
|
||||
map.once('idle', () => continueRender(handle));
|
||||
// Force an idle event even if the camera parameters are unchanged from the previous frame.
|
||||
map.triggerRepaint();
|
||||
}, [continueRender, delayRender, durationInFrames, frame, map]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: '#e8eef3'}}>
|
||||
<div ref={containerRef} style={{height, position: 'absolute', width}} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Camera guidance
|
||||
|
||||
For a validated live-camera route animation, animate `center`, `zoom`, `bearing`, and `pitch` with `jumpTo()`:
|
||||
|
||||
```ts
|
||||
map.jumpTo({
|
||||
center,
|
||||
zoom,
|
||||
bearing,
|
||||
pitch,
|
||||
});
|
||||
```
|
||||
|
||||
Keep route progress and camera progress separate if the camera needs to lead, lag, zoom out, or zoom back in. For cinematic 3D camera moves, load the 3D flyover branch from the parent skill.
|
||||
|
||||
## Lines
|
||||
|
||||
Use GeoJSON sources for lines. Unless the user asks, do not add glow effects or extra decorative points.
|
||||
|
||||
For geodesic flight routes, use Turf:
|
||||
|
||||
```ts
|
||||
const line = greatCircleLine(start, end);
|
||||
const distance = turf.length(line);
|
||||
const partialLine = turf.lineSliceAlong(
|
||||
line,
|
||||
0,
|
||||
// Keep the route non-empty at progress 0.
|
||||
Math.max(0.001, distance * progress),
|
||||
);
|
||||
```
|
||||
|
||||
For a visually straight line on the map, use a simple GeoJSON `LineString` between the two points instead of `greatCircle()`.
|
||||
|
||||
## Markers and labels
|
||||
|
||||
Use map-native GeoJSON layers for markers and labels:
|
||||
|
||||
```tsx
|
||||
mapInstance.addSource('markers', {
|
||||
type: 'geojson',
|
||||
data: turf.featureCollection([
|
||||
turf.point([-118.2437, 34.0522], {name: 'Los Angeles'}),
|
||||
]),
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'marker-dots',
|
||||
type: 'circle',
|
||||
source: 'markers',
|
||||
paint: {
|
||||
'circle-color': '#f03b20',
|
||||
'circle-radius': 12,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': 4,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'marker-labels',
|
||||
type: 'symbol',
|
||||
source: 'markers',
|
||||
layout: {
|
||||
'text-allow-overlap': true,
|
||||
'text-anchor': 'top',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-offset': [0, 0.9],
|
||||
'text-size': 28,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#111111',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 3,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Make marker sizes and label font sizes large enough for the composition resolution.
|
||||
|
||||
## Styles
|
||||
|
||||
Default to Mapbox Standard:
|
||||
|
||||
```ts
|
||||
style: 'mapbox://styles/mapbox/standard'
|
||||
```
|
||||
|
||||
If the user requests another style, use any valid Mapbox style URL.
|
||||
|
||||
## Rendering
|
||||
|
||||
For WebGL map renders, prefer single concurrency and ANGLE:
|
||||
|
||||
```bash
|
||||
bunx remotion render [composition-id] out/video.mp4 --gl=angle --concurrency=1
|
||||
```
|
||||
|
||||
Use the equivalent package runner for the project. In npm projects, use `npx`; in Bun projects, use `bunx`.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Moving Map Render Stability
|
||||
|
||||
Read this reference before building a moving 2D MapTiler scene or diagnosing a wavering Remotion render.
|
||||
|
||||
## Symptom and cause
|
||||
|
||||
If basemap detail shimmers or jitters during a pan/zoom, the likely cause is per-frame `map.jumpTo()`.
|
||||
Headless MapTiler capture can resample both vector hillshade and satellite imagery differently frame to
|
||||
frame. Tile retries, easing changes, and label changes do not solve that renderer effect.
|
||||
|
||||
## Required pattern: fixed map plate
|
||||
|
||||
For any 2D pan/zoom:
|
||||
|
||||
1. Render the MapTiler canvas once at the largest required zoom in an oversized container. Size it from the camera route and keep each dimension below the browser's reliable WebGL render-buffer limit (commonly 4096 px). Do **not** blindly use 3×: a 1920×1080 composition becomes 5760 px wide and Chromium may silently downsample it, causing visible pixelation during the CSS zoom.
|
||||
2. Keep the map's camera static.
|
||||
3. For each frame, calculate the approved target centre/zoom, then move the canvas with CSS `translate` + `scale`.
|
||||
4. Apply the same transform to every projected HTML overlay.
|
||||
5. Continue to animate GeoJSON data and paint properties imperatively; only the renderer camera is frozen.
|
||||
|
||||
Keep pitch and bearing constant. Use a 3D engine such as Cesium for genuine changing pitch/bearing or a terrain flythrough.
|
||||
|
||||
```ts
|
||||
const baseZoom = Math.max(start.zoom, end.zoom);
|
||||
const map = new maptilersdk.Map({
|
||||
container,
|
||||
style,
|
||||
center: end.center,
|
||||
zoom: baseZoom,
|
||||
pitch: end.pitch ?? 0,
|
||||
bearing: end.bearing ?? 0,
|
||||
interactive: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {preserveDrawingBuffer: true},
|
||||
});
|
||||
|
||||
// Per Remotion frame. `camera` is the approved centre/zoom interpolation.
|
||||
const projected = map.project(camera.center);
|
||||
const scale = 2 ** (camera.zoom - baseZoom);
|
||||
const plate = {
|
||||
transform: `translate(${width / 2 - projected.x * scale}px, ${height / 2 - projected.y * scale}px) scale(${scale})`,
|
||||
transformOrigin: "0 0",
|
||||
};
|
||||
|
||||
// Convert label projection with exactly the same plate transform.
|
||||
const labelX = labelPoint.x * scale + width / 2 - projected.x * scale;
|
||||
const labelY = labelPoint.y * scale + height / 2 - projected.y * scale;
|
||||
```
|
||||
|
||||
### Plate sizing and sharpness
|
||||
|
||||
- Render at the maximum zoom reached by **any** camera waypoint, including intermediate or hold cameras. The CSS scale should never exceed `1`; otherwise the plate is being enlarged.
|
||||
- Centre the frozen map on the midpoint of the camera route's geographic extent, not automatically on the final camera. This minimizes required overscan.
|
||||
- Keep the largest canvas dimension at or below 4096 px unless the actual render environment has been tested with a larger `MAX_RENDERBUFFER_SIZE`.
|
||||
- For 1920×1080, a 3840×2160 plate is a safe default. For 1080×1920, use approximately 2700×3840 when the route needs extra horizontal pan room.
|
||||
- If the route cannot fit within that plate at the required zoom, split the shot into two fixed plates with a deliberate editorial transition. Do not trade sharpness for one enormous canvas.
|
||||
- Distinguish failure modes: repeating shimmer means the live renderer is moving; steadily soft tiles during a CSS push means the fixed plate is underspecified, internally downsampled, or being scaled above `1`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Render a short MP4, not only a Studio preview.
|
||||
- Inspect static terrain texture and satellite detail while the camera moves.
|
||||
- If any underlying map detail wavers, use the fixed map plate. Do not approve it as a minor preview artefact.
|
||||
- Render WebGL with `--gl=angle`, `preserveDrawingBuffer:true`, and conservative concurrency (`1`) while validating.
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
---
|
||||
name: maps-maplibre
|
||||
description: Make deterministic Remotion 2D map animations with MapLibre GL JS and Turf. Use when the user chooses MapLibre for animated routes, map markers, labels, and camera movement.
|
||||
metadata:
|
||||
tags: map, map animation, maplibre, turf, geojson, route animation
|
||||
---
|
||||
|
||||
Use MapLibre GL JS for rendering maps in Remotion. Use Turf for geospatial operations such as great-circle routes, distances, slicing lines, and positions along routes.
|
||||
|
||||
## Core rules
|
||||
|
||||
- Prefer `@turf/turf` for geospatial work. Do not hand-roll distance, great-circle, route slicing, or coordinate interpolation unless the user explicitly needs a custom non-geodesic effect.
|
||||
- Use GeoJSON sources and MapLibre layers for lines, markers, and labels. Avoid DOM `Marker` elements unless the user specifically asks for HTML markers.
|
||||
- Keep the live map camera static by default. Before moving it on every frame, read [moving-map stability](references/render-stability.md). Prefer a fixed map plate for satellite imagery, hillshade, or a modest 2D reframe.
|
||||
- Use a live per-frame camera only after rendering a short MP4 and checking for shimmer. This 2D technique does not provide genuine terrain, pitch, bearing, or banking.
|
||||
- Disable non-deterministic map behavior: `interactive: false`, `fadeDuration: 0`.
|
||||
- Drive animation from `useCurrentFrame()`; do not use CSS transitions or browser-timed animation.
|
||||
- Use `delayRender()` / `continueRender()` around map loading and per-frame map updates.
|
||||
- Set `preserveDrawingBuffer: true` and render WebGL with `bunx remotion ... --gl=angle`.
|
||||
- Before continuing the initial render, add sources/layers, apply the frame-0 camera with `jumpTo()`, then wait for `idle`.
|
||||
- Do not add a `mapInstance.remove()` cleanup function; it can interfere with Remotion's render lifecycle.
|
||||
- Use standard MapLibre style JSON URLs and layer/source APIs.
|
||||
- Do not install `@types/maplibre-gl`; MapLibre ships its own types.
|
||||
- Keep required provider attribution visible and verify the current terms of the chosen style and tile providers before rendering.
|
||||
- Record the source and effective date of custom or disputed geography.
|
||||
- Inspect rendered pixels, not only Studio playback, at every required aspect ratio.
|
||||
|
||||
Coordinates in MapLibre, Turf, and GeoJSON are `[longitude, latitude]`.
|
||||
|
||||
```ts
|
||||
const zurich: [number, number] = [8.5417, 47.3769];
|
||||
const newYork: [number, number] = [-74.006, 40.7128];
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install MapLibre and Turf with the project's package manager.
|
||||
|
||||
```bash
|
||||
npm i maplibre-gl @turf/turf
|
||||
```
|
||||
|
||||
```bash
|
||||
bun i maplibre-gl @turf/turf
|
||||
```
|
||||
|
||||
```bash
|
||||
yarn add maplibre-gl @turf/turf
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm i maplibre-gl @turf/turf
|
||||
```
|
||||
|
||||
Import the MapLibre CSS once in the component or an app-level stylesheet:
|
||||
|
||||
```ts
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
```
|
||||
|
||||
## Basic map example
|
||||
|
||||
```tsx
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {AbsoluteFill, useDelayRender, useVideoConfig} from 'remotion';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
const zurich: [number, number] = [8.5417, 47.3769];
|
||||
|
||||
export const MyComposition = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const {delayRender, continueRender} = useDelayRender();
|
||||
const {width, height} = useVideoConfig();
|
||||
const [loadingHandle] = useState(() => delayRender('Loading map'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapInstance = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: 'https://demotiles.maplibre.org/style.json',
|
||||
center: zurich,
|
||||
zoom: 7,
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {
|
||||
preserveDrawingBuffer: true,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.on('load', () => {
|
||||
mapInstance.jumpTo({center: zurich, zoom: 7});
|
||||
mapInstance.once('idle', () => {
|
||||
continueRender(loadingHandle);
|
||||
});
|
||||
});
|
||||
}, [continueRender, loadingHandle]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<div ref={containerRef} style={{width, height, position: 'absolute'}} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Animated examples should keep the loaded map in React state and skip per-frame updates until that state is set.
|
||||
|
||||
## Animated flight route example
|
||||
|
||||
This example shows the recommended pattern for route animations:
|
||||
|
||||
- Turf creates the route and markers.
|
||||
- Turf slices the route for line reveal animation.
|
||||
- The camera has a separate route from the target route.
|
||||
- MapLibre's `calculateCameraOptionsFromTo()` is used for camera movement.
|
||||
- Frame 0 is prepared before `continueRender()`.
|
||||
|
||||
```tsx
|
||||
import * as turf from '@turf/turf';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Easing,
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useDelayRender,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import maplibregl, {type GeoJSONSource, type Map} from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
const zurich: [number, number] = [8.5417, 47.3769];
|
||||
const newYork: [number, number] = [-74.006, 40.7128];
|
||||
|
||||
const greatCircleLine = (from: [number, number], to: [number, number]) => {
|
||||
const route = turf.greatCircle(from, to, {npoints: 100});
|
||||
|
||||
if (route.geometry.type === 'LineString') {
|
||||
return turf.lineString(route.geometry.coordinates);
|
||||
}
|
||||
|
||||
// Great-circle routes crossing the antimeridian can become MultiLineString.
|
||||
// Keep the example valid by choosing the longest segment.
|
||||
const longestSegment = route.geometry.coordinates.reduce((longest, segment) => {
|
||||
return segment.length > longest.length ? segment : longest;
|
||||
});
|
||||
|
||||
return turf.lineString(longestSegment);
|
||||
};
|
||||
|
||||
const targetRoute = greatCircleLine(zurich, newYork);
|
||||
const targetRouteDistance = turf.length(targetRoute);
|
||||
|
||||
const cameraRoute = greatCircleLine(zurich, newYork);
|
||||
const cameraRouteDistance = turf.length(cameraRoute);
|
||||
|
||||
const cityMarkers = turf.featureCollection([
|
||||
turf.point(zurich, {name: 'Zurich'}),
|
||||
turf.point(newYork, {name: 'New York'}),
|
||||
]);
|
||||
|
||||
const clampProgress = (progress: number) => Math.min(1, Math.max(0, progress));
|
||||
|
||||
const distanceAlong = (totalDistance: number, progress: number) => {
|
||||
// Keep the route non-empty at progress 0; Turf can error on zero-length slices.
|
||||
return Math.max(0.001, totalDistance * clampProgress(progress));
|
||||
};
|
||||
|
||||
const getPartialTargetRoute = (progress: number) => {
|
||||
return turf.lineSliceAlong(
|
||||
targetRoute,
|
||||
0,
|
||||
distanceAlong(targetRouteDistance, progress),
|
||||
);
|
||||
};
|
||||
|
||||
const getCameraOptions = (
|
||||
map: Map,
|
||||
progress: number,
|
||||
cameraAltitudeMeters: number,
|
||||
cameraLatitudeOffset: number,
|
||||
) => {
|
||||
const target = turf.along(
|
||||
targetRoute,
|
||||
distanceAlong(targetRouteDistance, progress),
|
||||
).geometry.coordinates;
|
||||
const camera = turf.along(
|
||||
cameraRoute,
|
||||
distanceAlong(cameraRouteDistance, progress),
|
||||
).geometry.coordinates;
|
||||
|
||||
return map.calculateCameraOptionsFromTo(
|
||||
new maplibregl.LngLat(camera[0], camera[1] - cameraLatitudeOffset),
|
||||
cameraAltitudeMeters,
|
||||
new maplibregl.LngLat(target[0], target[1]),
|
||||
);
|
||||
};
|
||||
|
||||
export const MyComposition = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const frame = useCurrentFrame();
|
||||
const {delayRender, continueRender} = useDelayRender();
|
||||
const {durationInFrames, height, width} = useVideoConfig();
|
||||
const [map, setMap] = useState<Map | null>(null);
|
||||
const [loadingHandle] = useState(() => delayRender('Loading MapLibre map'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapInstance = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: 'https://demotiles.maplibre.org/style.json',
|
||||
center: zurich,
|
||||
zoom: 7,
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {
|
||||
preserveDrawingBuffer: true,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.on('load', () => {
|
||||
mapInstance.addSource('trace', {
|
||||
type: 'geojson',
|
||||
data: getPartialTargetRoute(0),
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'trace-line',
|
||||
type: 'line',
|
||||
source: 'trace',
|
||||
layout: {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#111111',
|
||||
'line-width': 7,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.addSource('city-markers', {
|
||||
type: 'geojson',
|
||||
data: cityMarkers,
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'city-marker-dots',
|
||||
type: 'circle',
|
||||
source: 'city-markers',
|
||||
paint: {
|
||||
'circle-color': '#f03b20',
|
||||
'circle-radius': 12,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': 4,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'city-marker-labels',
|
||||
type: 'symbol',
|
||||
source: 'city-markers',
|
||||
layout: {
|
||||
'text-allow-overlap': true,
|
||||
'text-anchor': 'top',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-offset': [0, 0.9],
|
||||
'text-size': 28,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#111111',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 3,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.jumpTo(getCameraOptions(mapInstance, 0, 180000, 1.1));
|
||||
mapInstance.once('idle', () => {
|
||||
setMap(mapInstance);
|
||||
continueRender(loadingHandle);
|
||||
});
|
||||
});
|
||||
}, [continueRender, loadingHandle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = delayRender('Rendering MapLibre frame');
|
||||
const timelineProgress = interpolate(frame, [0, durationInFrames - 1], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
const travelProgress = interpolate(timelineProgress, [0.2, 0.82], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
});
|
||||
const cameraAltitudeMeters = interpolate(
|
||||
timelineProgress,
|
||||
[0, 0.28, 0.74, 1],
|
||||
[180000, 2200000, 2200000, 180000],
|
||||
{
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
},
|
||||
);
|
||||
const cameraLatitudeOffset = interpolate(
|
||||
timelineProgress,
|
||||
[0, 0.28, 0.74, 1],
|
||||
[1.1, 8, 8, 1.1],
|
||||
{
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
},
|
||||
);
|
||||
const trace = map.getSource('trace') as GeoJSONSource | undefined;
|
||||
|
||||
trace?.setData(getPartialTargetRoute(travelProgress));
|
||||
map.jumpTo(
|
||||
getCameraOptions(
|
||||
map,
|
||||
travelProgress,
|
||||
cameraAltitudeMeters,
|
||||
cameraLatitudeOffset,
|
||||
),
|
||||
);
|
||||
|
||||
map.once('idle', () => continueRender(handle));
|
||||
// Force an idle event even if the camera parameters are unchanged from the previous frame.
|
||||
map.triggerRepaint();
|
||||
}, [continueRender, delayRender, durationInFrames, frame, map]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: '#e8eef3'}}>
|
||||
<div ref={containerRef} style={{height, position: 'absolute', width}} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Camera guidance
|
||||
|
||||
Use MapLibre's camera helper for camera movement:
|
||||
|
||||
```ts
|
||||
map.calculateCameraOptionsFromTo(cameraLngLat, cameraAltitudeMeters, targetLngLat);
|
||||
```
|
||||
|
||||
A good pattern is to keep two concepts separate:
|
||||
|
||||
- `targetRoute`: where the animated line is and where the camera looks.
|
||||
- `cameraRoute`: where the camera moves.
|
||||
|
||||
Then use Turf to read positions from both routes for the same progress value:
|
||||
|
||||
```ts
|
||||
const target = turf.along(targetRoute, targetDistance * progress).geometry.coordinates;
|
||||
const camera = turf.along(cameraRoute, cameraDistance * progress).geometry.coordinates;
|
||||
|
||||
map.jumpTo(
|
||||
map.calculateCameraOptionsFromTo(
|
||||
new maplibregl.LngLat(camera[0], camera[1]),
|
||||
cameraAltitudeMeters,
|
||||
new maplibregl.LngLat(target[0], target[1]),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
For zoom-out / travel / zoom-in animations, animate travel progress separately from camera altitude. Camera altitude is measured in meters. This avoids heavy custom camera math.
|
||||
|
||||
## Lines
|
||||
|
||||
Use GeoJSON sources for lines. Unless the user asks, do not add glow effects or extra decorative points.
|
||||
|
||||
For geodesic flight routes, use Turf:
|
||||
|
||||
```ts
|
||||
const line = greatCircleLine(start, end);
|
||||
const distance = turf.length(line);
|
||||
const partialLine = turf.lineSliceAlong(
|
||||
line,
|
||||
0,
|
||||
// Keep the route non-empty at progress 0.
|
||||
Math.max(0.001, distance * progress),
|
||||
);
|
||||
```
|
||||
|
||||
For a visually straight line on the map, use a simple GeoJSON `LineString` between the two points instead of `greatCircle()`.
|
||||
|
||||
## Markers and labels
|
||||
|
||||
Use map-native GeoJSON layers for markers and labels:
|
||||
|
||||
```tsx
|
||||
mapInstance.addSource('markers', {
|
||||
type: 'geojson',
|
||||
data: turf.featureCollection([
|
||||
turf.point([-118.2437, 34.0522], {name: 'Los Angeles'}),
|
||||
]),
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'marker-dots',
|
||||
type: 'circle',
|
||||
source: 'markers',
|
||||
paint: {
|
||||
'circle-color': '#f03b20',
|
||||
'circle-radius': 12,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': 4,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'marker-labels',
|
||||
type: 'symbol',
|
||||
source: 'markers',
|
||||
layout: {
|
||||
'text-allow-overlap': true,
|
||||
'text-anchor': 'top',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-offset': [0, 0.9],
|
||||
'text-size': 28,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#111111',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 3,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Make marker sizes and label font sizes large enough for the composition resolution.
|
||||
|
||||
## Styles
|
||||
|
||||
Default to the stock MapLibre demo style:
|
||||
|
||||
```ts
|
||||
style: 'https://demotiles.maplibre.org/style.json'
|
||||
```
|
||||
|
||||
If the user requests another style, use any valid MapLibre style JSON URL.
|
||||
|
||||
## Rendering
|
||||
|
||||
For WebGL map renders, prefer single concurrency and ANGLE:
|
||||
|
||||
```bash
|
||||
bunx remotion render [composition-id] out/video.mp4 --gl=angle --concurrency=1
|
||||
```
|
||||
|
||||
Use the equivalent package runner for the project. In npm projects, use `npx`; in Bun projects, use `bunx`.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Moving Map Render Stability
|
||||
|
||||
Read this reference before building a moving 2D MapTiler scene or diagnosing a wavering Remotion render.
|
||||
|
||||
## Symptom and cause
|
||||
|
||||
If basemap detail shimmers or jitters during a pan/zoom, the likely cause is per-frame `map.jumpTo()`.
|
||||
Headless MapTiler capture can resample both vector hillshade and satellite imagery differently frame to
|
||||
frame. Tile retries, easing changes, and label changes do not solve that renderer effect.
|
||||
|
||||
## Required pattern: fixed map plate
|
||||
|
||||
For any 2D pan/zoom:
|
||||
|
||||
1. Render the MapTiler canvas once at the largest required zoom in an oversized container. Size it from the camera route and keep each dimension below the browser's reliable WebGL render-buffer limit (commonly 4096 px). Do **not** blindly use 3×: a 1920×1080 composition becomes 5760 px wide and Chromium may silently downsample it, causing visible pixelation during the CSS zoom.
|
||||
2. Keep the map's camera static.
|
||||
3. For each frame, calculate the approved target centre/zoom, then move the canvas with CSS `translate` + `scale`.
|
||||
4. Apply the same transform to every projected HTML overlay.
|
||||
5. Continue to animate GeoJSON data and paint properties imperatively; only the renderer camera is frozen.
|
||||
|
||||
Keep pitch and bearing constant. Use a 3D engine such as Cesium for genuine changing pitch/bearing or a terrain flythrough.
|
||||
|
||||
```ts
|
||||
const baseZoom = Math.max(start.zoom, end.zoom);
|
||||
const map = new maptilersdk.Map({
|
||||
container,
|
||||
style,
|
||||
center: end.center,
|
||||
zoom: baseZoom,
|
||||
pitch: end.pitch ?? 0,
|
||||
bearing: end.bearing ?? 0,
|
||||
interactive: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {preserveDrawingBuffer: true},
|
||||
});
|
||||
|
||||
// Per Remotion frame. `camera` is the approved centre/zoom interpolation.
|
||||
const projected = map.project(camera.center);
|
||||
const scale = 2 ** (camera.zoom - baseZoom);
|
||||
const plate = {
|
||||
transform: `translate(${width / 2 - projected.x * scale}px, ${height / 2 - projected.y * scale}px) scale(${scale})`,
|
||||
transformOrigin: "0 0",
|
||||
};
|
||||
|
||||
// Convert label projection with exactly the same plate transform.
|
||||
const labelX = labelPoint.x * scale + width / 2 - projected.x * scale;
|
||||
const labelY = labelPoint.y * scale + height / 2 - projected.y * scale;
|
||||
```
|
||||
|
||||
### Plate sizing and sharpness
|
||||
|
||||
- Render at the maximum zoom reached by **any** camera waypoint, including intermediate or hold cameras. The CSS scale should never exceed `1`; otherwise the plate is being enlarged.
|
||||
- Centre the frozen map on the midpoint of the camera route's geographic extent, not automatically on the final camera. This minimizes required overscan.
|
||||
- Keep the largest canvas dimension at or below 4096 px unless the actual render environment has been tested with a larger `MAX_RENDERBUFFER_SIZE`.
|
||||
- For 1920×1080, a 3840×2160 plate is a safe default. For 1080×1920, use approximately 2700×3840 when the route needs extra horizontal pan room.
|
||||
- If the route cannot fit within that plate at the required zoom, split the shot into two fixed plates with a deliberate editorial transition. Do not trade sharpness for one enormous canvas.
|
||||
- Distinguish failure modes: repeating shimmer means the live renderer is moving; steadily soft tiles during a CSS push means the fixed plate is underspecified, internally downsampled, or being scaled above `1`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Render a short MP4, not only a Studio preview.
|
||||
- Inspect static terrain texture and satellite detail while the camera moves.
|
||||
- If any underlying map detail wavers, use the fixed map plate. Do not approve it as a minor preview artefact.
|
||||
- Render WebGL with `--gl=angle`, `preserveDrawingBuffer:true`, and conservative concurrency (`1`) while validating.
|
||||
@@ -0,0 +1,80 @@
|
||||
# MapTiler maps in Remotion
|
||||
|
||||
MapTiler is a good solution for map animations where geographics features should be drawn as annotations on top of the map: Country borders, rivers, labels for POIs.
|
||||
|
||||
## MapTiler SDK (`@maptiler/sdk`)
|
||||
|
||||
Draw the basemap plus MapTiler Planet vector layers and custom GeoJSON into a WebGL canvas. Default styled-vector starting point: `MapStyle.BASIC`; satellite is an equally valid choice.
|
||||
|
||||
## Remotion
|
||||
|
||||
Imperatively update `setData`/`setPaintProperty`.
|
||||
|
||||
Use `jumpTo` only for a static shot, or a fixed map plate for any pan/zoom.
|
||||
Gate with [`delayRender`](https://www.remotion.dev/docs/delay-render.md) until `map.once('idle')`.
|
||||
|
||||
Use `preserveDrawingBuffer:true`.
|
||||
|
||||
Render labels as positioned [`<Interactive.Div>`](https://www.remotion.dev/docs/interactive.md) elements.
|
||||
|
||||
Env `REMOTION_MAPTILER_KEY` (unrestricted). Init the map once (ref guard); update imperatively per frame.
|
||||
|
||||
When constructing MapLibre/MapTiler layer objects, omit optional properties that are absent.
|
||||
In particular, use `...(layer.filter ? {filter: layer.filter} : {})`; do not pass `filter: undefined`. An undefined filter can suppress the layer while separately created halo or border layers continue rendering, producing missing country fills and dark marker halos with no coloured cores.
|
||||
|
||||
Drive animation from `useCurrentFrame()` rather than CSS transitions or browser timers.
|
||||
|
||||
## Choose the source for each map element
|
||||
|
||||
Do not begin by manufacturing GeoJSON. First check whether MapTiler Planet already exposes the element as filtered vector data.
|
||||
|
||||
### MapTiler vector
|
||||
|
||||
Use it when the feature exists in a provider `source-layer`, its attributes support an exact filter, and provider geometry is editorially acceptable.
|
||||
|
||||
### Hybrid
|
||||
|
||||
Use it when ordinary geographic context can come from MapTiler while the claim depends on custom
|
||||
evidence.
|
||||
|
||||
Animate each layer according to its source and meaning.
|
||||
|
||||
MapTiler vector features remain split across tiles. Do not use them for a semantic start-to-end line draw; extract, verify, order, and bake that element to GeoJSON first. Read **`references/map-data-sources.md`** and reuse **`assets/MapTilerVectorElement.ts`** for provider-layer setup and per-frame paint updates.
|
||||
|
||||
## Motion stability
|
||||
|
||||
**Do not call `map.jumpTo()` on every Remotion frame when the camera moves.** In headless capture it can make both MapTiler hillshade **and satellite imagery** shimmer/jitter, even when the source tiles load correctly. This is renderer resampling, not a data, network, or label problem.
|
||||
|
||||
For the implementation, read **`references/render-stability.md`** before building or debugging any moving map. It contains the fixed-map-plate recipe, diagnostics, and render checks.
|
||||
|
||||
- Use the live MapTiler camera only for a static shot.
|
||||
- Keep pitch and bearing constant for a fixed plate. This technique does not implement a genuine changing 3D camera.
|
||||
- Verify the moving preview and a short rendered MP4 before approving a beat. If any basemap detail wavers, switch to the fixed-plate pattern; do not try to solve it with tile retries or camera easing.
|
||||
|
||||
## Drawing rivers
|
||||
|
||||
Use `turf.lineSliceAlong(line, 0, lineKm*reveal)` to draw rivers.
|
||||
|
||||
## Source selection
|
||||
|
||||
Use MapTiler vector layers for suitable provider features and custom GeoJSON for story-specific or ordered geometry. If the beat needs country-entry triggers or a progressive line draw, run `scripts/prep-geo.mjs` to bake `country-meta.json`, `borders.geojson`, and the ordered line. Details → `references/map-data-sources.md` and `references/map-geo-prep.md`.
|
||||
|
||||
## Keep it minmal
|
||||
|
||||
Strip clutter on `load`: remove `symbol` layers (place labels) and `/other border/i` (admin-1 inner borders); hide the logo via CSS. Keep country + disputed borders.
|
||||
|
||||
## Files
|
||||
|
||||
Use as reference:
|
||||
|
||||
- `assets/RiverReveal.tsx` — the main component.
|
||||
- `assets/MapTilerVectorElement.ts` — filtered MapTiler Planet elements.
|
||||
- `assets/CountryLabel.tsx` — reusable example label.
|
||||
- `assets/tokens.ts` — example palette and durations.
|
||||
- `assets/example-Root.tsx` — minimal composition scaffold.
|
||||
- `assets/sample-data/` — example route and generated country metadata.
|
||||
- `scripts/prep-geo.mjs` — geo pipeline.
|
||||
- `references/map-explainer-architecture.md` — timing model and implementation.
|
||||
- `references/map-data-sources.md` — provider vector versus custom GeoJSON selection.
|
||||
- `references/map-geo-prep.md` — basemap stripping and geo preparation.
|
||||
- `references/render-stability.md` — camera motion and stable headless renders.
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import {Easing, interpolate} from 'remotion';
|
||||
|
||||
// Reusable country label. Supply typography and final values from the consuming project; the CSS custom
|
||||
// properties below provide neutral fallbacks. Positioned by its centre (x,y in screen px).
|
||||
export const CountryLabel: React.FC<{
|
||||
name: string;
|
||||
color: string;
|
||||
reveal: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}> = ({name, color, reveal, x, y}) => {
|
||||
const e = interpolate(reveal, [0, 1], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.3333333333333333, 1, 0.6666666666666666, 1),
|
||||
});
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
opacity: e,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `translateY(${(1 - e) * 16}px)`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* accent rule — draws out from the centre in the country's colour */}
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: color,
|
||||
transform: `scaleX(${e})`,
|
||||
boxShadow: `0 0 10px ${color}`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--map-label-font, system-ui, sans-serif)',
|
||||
fontWeight: 'var(--map-label-weight, 600)',
|
||||
fontSize: 'var(--map-label-size, 34px)',
|
||||
letterSpacing: 'var(--map-label-tracking, 0.16em)',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--map-label-color, #ffffff)',
|
||||
textShadow: 'var(--map-label-shadow, 0 2px 18px rgba(0,0,0,0.9))',
|
||||
marginTop: 13,
|
||||
paddingLeft: 'var(--map-label-tracking, 0.16em)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
// Surface an existing MapTiler Planet feature without maintaining duplicate GeoJSON.
|
||||
// Paint animation is deterministic; geometry slicing is not. For a source-to-end line draw,
|
||||
// bake the selected feature to ordered GeoJSON and use RiverReveal.tsx instead.
|
||||
|
||||
type VectorLayerType = 'fill' | 'line' | 'circle' | 'symbol';
|
||||
|
||||
export type MapTilerVectorElement = {
|
||||
id: string;
|
||||
sourceLayer: string;
|
||||
type: VectorLayerType;
|
||||
filter?: unknown[];
|
||||
minzoom?: number;
|
||||
maxzoom?: number;
|
||||
layout?: Record<string, unknown>;
|
||||
paint: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const SOURCE_ID = 'maptiler-planet';
|
||||
|
||||
export const addMapTilerVectorElement = (
|
||||
map: any,
|
||||
apiKey: string,
|
||||
element: MapTilerVectorElement,
|
||||
beforeId?: string,
|
||||
) => {
|
||||
if (!map.getSource(SOURCE_ID)) {
|
||||
map.addSource(SOURCE_ID, {
|
||||
type: 'vector',
|
||||
url: `https://api.maptiler.com/tiles/v3/tiles.json?key=${apiKey}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (map.getLayer(element.id)) return;
|
||||
|
||||
map.addLayer(
|
||||
{
|
||||
id: element.id,
|
||||
type: element.type,
|
||||
source: SOURCE_ID,
|
||||
'source-layer': element.sourceLayer,
|
||||
...(element.filter ? {filter: element.filter} : {}),
|
||||
...(element.minzoom === undefined ? {} : {minzoom: element.minzoom}),
|
||||
...(element.maxzoom === undefined ? {} : {maxzoom: element.maxzoom}),
|
||||
...(element.layout ? {layout: element.layout} : {}),
|
||||
paint: element.paint,
|
||||
},
|
||||
beforeId,
|
||||
);
|
||||
};
|
||||
|
||||
export const setVectorElementPaint = (
|
||||
map: any,
|
||||
layerId: string,
|
||||
paint: Record<string, unknown>,
|
||||
) => {
|
||||
for (const [property, value] of Object.entries(paint)) {
|
||||
map.setPaintProperty(layerId, property, value);
|
||||
}
|
||||
};
|
||||
|
||||
// Example:
|
||||
//
|
||||
// addMapTilerVectorElement(map, process.env.REMOTION_MAPTILER_KEY!, {
|
||||
// id: "story-river",
|
||||
// sourceLayer: "waterway",
|
||||
// type: "line",
|
||||
// filter: [
|
||||
// "all",
|
||||
// ["==", ["get", "class"], "river"],
|
||||
// ["==", ["coalesce", ["get", "name_en"], ["get", "name"]], "Yarlung Tsangpo"],
|
||||
// ],
|
||||
// layout: {"line-cap": "round", "line-join": "round"},
|
||||
// paint: {"line-color": "#E8F7FF", "line-width": 3, "line-opacity": 0},
|
||||
// });
|
||||
//
|
||||
// Per Remotion frame:
|
||||
// setVectorElementPaint(map, "story-river", {
|
||||
// "line-opacity": reveal,
|
||||
// "line-width": 2 + reveal * 2,
|
||||
// });
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
import * as maptilersdk from '@maptiler/sdk';
|
||||
import * as turf from '@turf/turf';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import '@maptiler/sdk/dist/maptiler-sdk.css';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Easing,
|
||||
continueRender,
|
||||
delayRender,
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import {CountryLabel} from './CountryLabel';
|
||||
import countryMeta from './sample-data/country-meta.json';
|
||||
import flowCoords from './sample-data/yarlung-flow.json';
|
||||
import {COLORS, COUNTRY, COUNTRY_DARK, FILL_OPACITY} from './tokens';
|
||||
|
||||
// Sample route reveal. Replace the imported sample geometry, names, timing, and visual tokens in the
|
||||
// consuming production. The renderer stays static; approved centre/zoom motion is a CSS plate transform.
|
||||
|
||||
maptilersdk.config.apiKey = process.env.REMOTION_MAPTILER_KEY as string;
|
||||
|
||||
const line = turf.lineString(flowCoords as [number, number][]);
|
||||
const lineKm = turf.length(line);
|
||||
|
||||
const START = {
|
||||
center: [89.6, 27.7] as [number, number],
|
||||
zoom: 4.75,
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
};
|
||||
const END = {
|
||||
center: [90.2, 27.0] as [number, number],
|
||||
zoom: 5.05,
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
};
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
||||
|
||||
const ORDER = ['china', 'india', 'bangladesh'] as const;
|
||||
type Country = (typeof ORDER)[number];
|
||||
const META = countryMeta as Record<
|
||||
Country,
|
||||
{stop: number; anchor: [number, number]; border: [number, number][][]}
|
||||
>;
|
||||
const countryPolygons = {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: ORDER.map((country) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {country},
|
||||
geometry: {
|
||||
type: 'MultiPolygon' as const,
|
||||
coordinates: META[country].border.map((ring) => [ring]),
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
// Pre-build each country's border as ordered segments with cumulative lengths (for the multi-segment draw).
|
||||
const DRAW = Object.fromEntries(
|
||||
ORDER.map((c) => {
|
||||
const segLines = META[c].border.map((s) => turf.lineString(s));
|
||||
const segLen = segLines.map((l) => turf.length(l));
|
||||
const cum: number[] = [];
|
||||
let acc = 0;
|
||||
for (const L of segLen) {
|
||||
cum.push(acc);
|
||||
acc += L;
|
||||
}
|
||||
return [c, {segLines, segLen, cum, total: acc}];
|
||||
}),
|
||||
) as Record<
|
||||
Country,
|
||||
{segLines: any[]; segLen: number[]; cum: number[]; total: number}
|
||||
>;
|
||||
|
||||
// Reveal the portion of the border between fromKm and toKm as a MultiLineString (no joins across gaps).
|
||||
const sliceBorder = (
|
||||
d: (typeof DRAW)[Country],
|
||||
fromKm: number,
|
||||
toKm: number,
|
||||
) => {
|
||||
const out: number[][][] = [];
|
||||
for (let i = 0; i < d.segLines.length; i++) {
|
||||
const start = d.cum[i],
|
||||
end = start + d.segLen[i];
|
||||
const a = Math.max(fromKm, start),
|
||||
b = Math.min(toKm, end);
|
||||
if (b - a <= 0.0008) continue;
|
||||
out.push(
|
||||
turf.lineSliceAlong(d.segLines[i], a - start, b - start).geometry
|
||||
.coordinates,
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: {type: 'MultiLineString' as const, coordinates: out},
|
||||
};
|
||||
};
|
||||
const EMPTY = {
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: {type: 'MultiLineString' as const, coordinates: [] as number[][][]},
|
||||
};
|
||||
|
||||
// --- Timing (seconds). River draws over [RIVER_START, RIVER_END]; each country triggers when the river
|
||||
// reaches it (stop · span), then runs border → fill → label. Beat length is derived from these. ---
|
||||
const RIVER_START = 0.3;
|
||||
const RIVER_END = 8.0;
|
||||
const BORDER_S = 2.5;
|
||||
const FILL_S = 1.0;
|
||||
const LABEL_S = 0.7;
|
||||
const trigger = (c: Country) =>
|
||||
RIVER_START + META[c].stop * (RIVER_END - RIVER_START);
|
||||
|
||||
export const RiverReveal: React.FC = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const started = useRef(false);
|
||||
const frame = useCurrentFrame();
|
||||
const {fps, durationInFrames, width, height} = useVideoConfig();
|
||||
const [map, setMap] = useState<any>(null);
|
||||
const [labels, setLabels] = useState<
|
||||
Record<string, {x: number; y: number; reveal: number}>
|
||||
>({});
|
||||
const [plate, setPlate] = useState({x: 0, y: 0, scale: 1});
|
||||
const [handle] = useState(() => delayRender('maptiler init A'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || started.current) return;
|
||||
started.current = true;
|
||||
const m = new maptilersdk.Map({
|
||||
container: ref.current,
|
||||
style: maptilersdk.MapStyle.BASIC,
|
||||
center: END.center,
|
||||
zoom: Math.max(START.zoom, END.zoom),
|
||||
pitch: END.pitch,
|
||||
bearing: END.bearing,
|
||||
interactive: false,
|
||||
attributionControl: true,
|
||||
navigationControl: false,
|
||||
geolocateControl: false,
|
||||
maptilerLogo: true,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {preserveDrawingBuffer: true},
|
||||
} as any);
|
||||
|
||||
m.on('load', () => {
|
||||
// Strip basemap labels (symbols) AND the inner admin-1 borders ('Other border[ dash]',
|
||||
// admin_level 3–10) to cut basemap clutter. Keep country + disputed borders.
|
||||
for (const l of m.getStyle().layers as any[])
|
||||
if (l.type === 'symbol' || /other border/i.test(l.id))
|
||||
m.removeLayer(l.id);
|
||||
|
||||
m.addSource('countries', {type: 'geojson', data: countryPolygons});
|
||||
for (const c of ORDER) {
|
||||
m.addLayer({
|
||||
id: `fill-${c}`,
|
||||
type: 'fill',
|
||||
source: 'countries',
|
||||
filter: ['==', ['get', 'country'], c],
|
||||
paint: {'fill-color': COUNTRY[c], 'fill-opacity': 0},
|
||||
});
|
||||
}
|
||||
// Per country: just the border that draws on, settled to a darker shade of the country colour
|
||||
// (the electricity now lives on the river, not the borders).
|
||||
for (const c of ORDER) {
|
||||
m.addSource(`trail-${c}`, {type: 'geojson', data: EMPTY});
|
||||
m.addLayer({
|
||||
id: `trail-${c}`,
|
||||
type: 'line',
|
||||
source: `trail-${c}`,
|
||||
layout: {'line-cap': 'round', 'line-join': 'round'},
|
||||
paint: {
|
||||
'line-color': COUNTRY_DARK[c],
|
||||
'line-width': 2,
|
||||
'line-opacity': 0.95,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const seed = turf.lineSliceAlong(
|
||||
line,
|
||||
0,
|
||||
Math.max(0.001, lineKm * 0.001),
|
||||
);
|
||||
m.addSource('river', {type: 'geojson', data: seed});
|
||||
m.addSource('river-head', {type: 'geojson', data: seed});
|
||||
// Electric water: soft blue glow → icy core → white-hot leading head with its own glow.
|
||||
m.addLayer({
|
||||
id: 'river-glow',
|
||||
type: 'line',
|
||||
source: 'river',
|
||||
layout: {'line-cap': 'round', 'line-join': 'round'},
|
||||
paint: {
|
||||
'line-color': '#49C6FF',
|
||||
'line-width': 11,
|
||||
'line-opacity': 0.32,
|
||||
'line-blur': 6,
|
||||
},
|
||||
});
|
||||
m.addLayer({
|
||||
id: 'river-line',
|
||||
type: 'line',
|
||||
source: 'river',
|
||||
layout: {'line-cap': 'round', 'line-join': 'round'},
|
||||
paint: {'line-color': COLORS.river, 'line-width': 3},
|
||||
});
|
||||
m.addLayer({
|
||||
id: 'river-headglow',
|
||||
type: 'line',
|
||||
source: 'river-head',
|
||||
layout: {'line-cap': 'round', 'line-join': 'round'},
|
||||
paint: {
|
||||
'line-color': COLORS.riverHeadGlow,
|
||||
'line-width': 16,
|
||||
'line-opacity': 0,
|
||||
'line-blur': 9,
|
||||
},
|
||||
});
|
||||
m.addLayer({
|
||||
id: 'river-head',
|
||||
type: 'line',
|
||||
source: 'river-head',
|
||||
layout: {'line-cap': 'round', 'line-join': 'round'},
|
||||
paint: {
|
||||
'line-color': COLORS.riverHead,
|
||||
'line-width': 4.5,
|
||||
'line-opacity': 0,
|
||||
},
|
||||
});
|
||||
|
||||
m.once('idle', () => {
|
||||
setMap(m);
|
||||
continueRender(handle);
|
||||
});
|
||||
});
|
||||
}, [handle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const h = delayRender(`frame A ${frame}`);
|
||||
const t = frame / fps; // seconds
|
||||
const tt = interpolate(frame, [0, durationInFrames - 1], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
// River draw
|
||||
const reveal = interpolate(t, [RIVER_START, RIVER_END], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
});
|
||||
const riverDrawnKm = lineKm * reveal;
|
||||
(map.getSource('river') as any)?.setData(
|
||||
turf.lineSliceAlong(line, 0, Math.max(0.001, riverDrawnKm)),
|
||||
);
|
||||
// Electric draw-head leading the river: white-hot core + glow, fading out once the river completes.
|
||||
const riverHeadKm = lineKm * 0.03;
|
||||
(map.getSource('river-head') as any)?.setData(
|
||||
turf.lineSliceAlong(
|
||||
line,
|
||||
Math.max(0, riverDrawnKm - riverHeadKm),
|
||||
Math.max(0.001, riverDrawnKm),
|
||||
),
|
||||
);
|
||||
let riverHeadFade = 0;
|
||||
if (reveal > 0.002 && reveal < 0.999) riverHeadFade = 1;
|
||||
else if (reveal >= 0.999)
|
||||
riverHeadFade = 1 - clamp01((t - RIVER_END) / 0.5);
|
||||
map.setPaintProperty(
|
||||
'river-headglow',
|
||||
'line-opacity',
|
||||
0.85 * riverHeadFade,
|
||||
);
|
||||
map.setPaintProperty('river-head', 'line-opacity', riverHeadFade);
|
||||
|
||||
const camera = {
|
||||
center: [
|
||||
lerp(START.center[0], END.center[0], tt),
|
||||
lerp(START.center[1], END.center[1], tt),
|
||||
] as [number, number],
|
||||
zoom: lerp(START.zoom, END.zoom, tt),
|
||||
};
|
||||
const cameraPoint = map.project(camera.center);
|
||||
const plateScale = 2 ** (camera.zoom - Math.max(START.zoom, END.zoom));
|
||||
const plateX = width / 2 - cameraPoint.x * plateScale;
|
||||
const plateY = height / 2 - cameraPoint.y * plateScale;
|
||||
|
||||
const pos: Record<string, {x: number; y: number; reveal: number}> = {};
|
||||
for (const c of ORDER) {
|
||||
const d = DRAW[c];
|
||||
const lt = t - trigger(c); // local seconds since this country triggered
|
||||
|
||||
// 1) border draws on (constant duration), settling to a darker shade — no electric head
|
||||
const bp = interpolate(clamp01(lt / BORDER_S), [0, 1], [0, 1], {
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
});
|
||||
(map.getSource(`trail-${c}`) as any)?.setData(
|
||||
bp <= 0 ? EMPTY : sliceBorder(d, 0, d.total * bp),
|
||||
);
|
||||
|
||||
// 2) fill blooms in (overshoot then settle) after the border completes
|
||||
const fp = clamp01((lt - BORDER_S) / FILL_S);
|
||||
const fo = interpolate(
|
||||
fp,
|
||||
[0, 0.6, 1],
|
||||
[0, FILL_OPACITY * 1.25, FILL_OPACITY],
|
||||
{
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.3333333333333333, 1, 0.6666666666666666, 1),
|
||||
},
|
||||
);
|
||||
map.setPaintProperty(`fill-${c}`, 'fill-opacity', fp <= 0 ? 0 : fo);
|
||||
|
||||
// 3) label rises in after the fill
|
||||
const lp = clamp01((lt - BORDER_S - FILL_S) / LABEL_S);
|
||||
const p = map.project(META[c].anchor);
|
||||
pos[c] = {
|
||||
x: p.x * plateScale + plateX,
|
||||
y: p.y * plateScale + plateY,
|
||||
reveal: lp,
|
||||
};
|
||||
}
|
||||
setLabels(pos);
|
||||
|
||||
setPlate({x: plateX, y: plateY, scale: plateScale});
|
||||
map.once('idle', () => continueRender(h));
|
||||
map.triggerRepaint();
|
||||
}, [map, frame, fps, durationInFrames, width, height]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: COLORS.bg}}>
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
width: width * 2,
|
||||
height: height * 2,
|
||||
position: 'absolute',
|
||||
transform: `translate(${plate.x}px, ${plate.y}px) scale(${plate.scale})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
/>
|
||||
<AbsoluteFill style={{pointerEvents: 'none'}}>
|
||||
{ORDER.map((c) =>
|
||||
labels[c] ? (
|
||||
<CountryLabel
|
||||
key={c}
|
||||
name={c.toUpperCase()}
|
||||
color={COUNTRY[c]}
|
||||
reveal={labels[c].reveal}
|
||||
x={labels[c].x}
|
||||
y={labels[c].y}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// Minimal Remotion scaffold for the map explainer. In a Remotion project (`bunx create-video@latest`,
|
||||
// blank template), this is `src/Root.tsx`; `src/index.ts` is just `registerRoot(RemotionRoot)`.
|
||||
//
|
||||
// The Composition sets DURATION (the beat) and dimensions. The component reads durationInFrames / width /
|
||||
// height from useVideoConfig(). The beat must be long enough for the last country's full sequence after
|
||||
// the river reaches it. See references/map-explainer-architecture.md §2.
|
||||
// Render with: bunx remotion render src/index.ts MapExplainer out.mp4 --gl=angle --concurrency=1 --timeout=120000
|
||||
|
||||
import React from 'react';
|
||||
import {Composition} from 'remotion';
|
||||
import {RiverReveal} from './RiverReveal'; // → src/components/RiverReveal.tsx in your project
|
||||
|
||||
export const RemotionRoot: React.FC = () => (
|
||||
<Composition
|
||||
id="MapExplainer"
|
||||
component={RiverReveal}
|
||||
durationInFrames={12 * 30} // 12 s @ 30 fps — raise if a later country needs more room after the river arrives
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
);
|
||||
+7535
File diff suppressed because it is too large
Load Diff
+605
@@ -0,0 +1,605 @@
|
||||
[
|
||||
[84.144, 29.565],
|
||||
[84.171, 29.577],
|
||||
[84.22500000000001, 29.559],
|
||||
[84.246, 29.541],
|
||||
[84.297, 29.544],
|
||||
[84.321, 29.532],
|
||||
[84.411, 29.544],
|
||||
[84.426, 29.529],
|
||||
[84.45, 29.535],
|
||||
[84.468, 29.523],
|
||||
[84.492, 29.523],
|
||||
[84.531, 29.490000000000002],
|
||||
[84.507, 29.466],
|
||||
[84.501, 29.445],
|
||||
[84.513, 29.385],
|
||||
[84.495, 29.379],
|
||||
[84.483, 29.361],
|
||||
[84.486, 29.34],
|
||||
[84.519, 29.316],
|
||||
[84.51, 29.292],
|
||||
[84.522, 29.286],
|
||||
[84.525, 29.265],
|
||||
[84.57600000000001, 29.253],
|
||||
[84.58800000000001, 29.259],
|
||||
[84.60300000000001, 29.247],
|
||||
[84.615, 29.265],
|
||||
[84.63, 29.268],
|
||||
[84.624, 29.283],
|
||||
[84.651, 29.286],
|
||||
[84.669, 29.259],
|
||||
[84.732, 29.25],
|
||||
[84.741, 29.235],
|
||||
[84.771, 29.25],
|
||||
[84.777, 29.241],
|
||||
[84.843, 29.223],
|
||||
[84.864, 29.232],
|
||||
[84.879, 29.202],
|
||||
[84.9, 29.199],
|
||||
[84.909, 29.181],
|
||||
[84.933, 29.184],
|
||||
[84.96900000000001, 29.163],
|
||||
[85.023, 29.187],
|
||||
[85.071, 29.259],
|
||||
[85.122, 29.274],
|
||||
[85.167, 29.322],
|
||||
[85.185, 29.310000000000002],
|
||||
[85.26, 29.319],
|
||||
[85.284, 29.301000000000002],
|
||||
[85.34400000000001, 29.289],
|
||||
[85.35600000000001, 29.265],
|
||||
[85.413, 29.262],
|
||||
[85.431, 29.271],
|
||||
[85.449, 29.247],
|
||||
[85.464, 29.244],
|
||||
[85.521, 29.25],
|
||||
[85.551, 29.265],
|
||||
[85.611, 29.235],
|
||||
[85.617, 29.193],
|
||||
[85.662, 29.172],
|
||||
[85.686, 29.172],
|
||||
[85.69500000000001, 29.184],
|
||||
[85.71600000000001, 29.172],
|
||||
[85.72800000000001, 29.187],
|
||||
[85.764, 29.181],
|
||||
[85.761, 29.193],
|
||||
[85.776, 29.202],
|
||||
[85.788, 29.196],
|
||||
[85.797, 29.22],
|
||||
[85.809, 29.211000000000002],
|
||||
[85.848, 29.211000000000002],
|
||||
[85.863, 29.193],
|
||||
[85.881, 29.193],
|
||||
[85.917, 29.172],
|
||||
[85.968, 29.175],
|
||||
[86.004, 29.163],
|
||||
[86.09400000000001, 29.175],
|
||||
[86.10000000000001, 29.166],
|
||||
[86.133, 29.172],
|
||||
[86.148, 29.166],
|
||||
[86.193, 29.178],
|
||||
[86.223, 29.202],
|
||||
[86.268, 29.193],
|
||||
[86.295, 29.208000000000002],
|
||||
[86.34, 29.205000000000002],
|
||||
[86.412, 29.217000000000002],
|
||||
[86.427, 29.211000000000002],
|
||||
[86.43, 29.199],
|
||||
[86.47800000000001, 29.196],
|
||||
[86.496, 29.22],
|
||||
[86.514, 29.211000000000002],
|
||||
[86.52, 29.235],
|
||||
[86.529, 29.238],
|
||||
[86.535, 29.217000000000002],
|
||||
[86.559, 29.196],
|
||||
[86.607, 29.202],
|
||||
[86.616, 29.184],
|
||||
[86.658, 29.193],
|
||||
[86.67, 29.205000000000002],
|
||||
[86.682, 29.199],
|
||||
[86.7, 29.208000000000002],
|
||||
[86.736, 29.205000000000002],
|
||||
[86.778, 29.187],
|
||||
[86.805, 29.196],
|
||||
[86.82300000000001, 29.184],
|
||||
[86.84100000000001, 29.19],
|
||||
[86.901, 29.181],
|
||||
[86.949, 29.163],
|
||||
[87.027, 29.172],
|
||||
[87.039, 29.148],
|
||||
[87.063, 29.136],
|
||||
[87.084, 29.142],
|
||||
[87.12, 29.175],
|
||||
[87.162, 29.142],
|
||||
[87.21000000000001, 29.142],
|
||||
[87.237, 29.157],
|
||||
[87.249, 29.136],
|
||||
[87.297, 29.127],
|
||||
[87.303, 29.115000000000002],
|
||||
[87.366, 29.121000000000002],
|
||||
[87.459, 29.097],
|
||||
[87.486, 29.115000000000002],
|
||||
[87.546, 29.109],
|
||||
[87.56700000000001, 29.121000000000002],
|
||||
[87.59400000000001, 29.121000000000002],
|
||||
[87.60600000000001, 29.133],
|
||||
[87.654, 29.139],
|
||||
[87.666, 29.13],
|
||||
[87.684, 29.133],
|
||||
[87.687, 29.157],
|
||||
[87.669, 29.181],
|
||||
[87.675, 29.205000000000002],
|
||||
[87.705, 29.217000000000002],
|
||||
[87.714, 29.235],
|
||||
[87.732, 29.238],
|
||||
[87.732, 29.253],
|
||||
[87.756, 29.283],
|
||||
[87.753, 29.301000000000002],
|
||||
[87.789, 29.304000000000002],
|
||||
[87.795, 29.328],
|
||||
[87.81, 29.337],
|
||||
[87.849, 29.331],
|
||||
[87.87, 29.349],
|
||||
[87.906, 29.343],
|
||||
[87.924, 29.349],
|
||||
[87.97800000000001, 29.388],
|
||||
[87.996, 29.376],
|
||||
[88.014, 29.379],
|
||||
[88.017, 29.37],
|
||||
[88.125, 29.367],
|
||||
[88.137, 29.337],
|
||||
[88.161, 29.331],
|
||||
[88.176, 29.337],
|
||||
[88.188, 29.325],
|
||||
[88.2, 29.328],
|
||||
[88.221, 29.367],
|
||||
[88.245, 29.367],
|
||||
[88.287, 29.349],
|
||||
[88.299, 29.358],
|
||||
[88.365, 29.316],
|
||||
[88.41, 29.322],
|
||||
[88.422, 29.316],
|
||||
[88.443, 29.325],
|
||||
[88.458, 29.349],
|
||||
[88.503, 29.361],
|
||||
[88.536, 29.334],
|
||||
[88.563, 29.328],
|
||||
[88.596, 29.349],
|
||||
[88.629, 29.346],
|
||||
[88.641, 29.334],
|
||||
[88.671, 29.34],
|
||||
[88.71000000000001, 29.331],
|
||||
[88.72800000000001, 29.343],
|
||||
[88.791, 29.337],
|
||||
[88.818, 29.352],
|
||||
[88.86, 29.319],
|
||||
[88.884, 29.334],
|
||||
[88.908, 29.316],
|
||||
[88.923, 29.331],
|
||||
[88.962, 29.328],
|
||||
[88.971, 29.349],
|
||||
[89.007, 29.361],
|
||||
[89.019, 29.352],
|
||||
[89.034, 29.358],
|
||||
[89.08500000000001, 29.322],
|
||||
[89.136, 29.316],
|
||||
[89.16, 29.343],
|
||||
[89.181, 29.337],
|
||||
[89.211, 29.349],
|
||||
[89.235, 29.379],
|
||||
[89.253, 29.373],
|
||||
[89.265, 29.385],
|
||||
[89.289, 29.385],
|
||||
[89.307, 29.376],
|
||||
[89.349, 29.379],
|
||||
[89.397, 29.358],
|
||||
[89.45100000000001, 29.355],
|
||||
[89.46000000000001, 29.334],
|
||||
[89.58, 29.358],
|
||||
[89.595, 29.346],
|
||||
[89.61, 29.355],
|
||||
[89.631, 29.346],
|
||||
[89.661, 29.349],
|
||||
[89.679, 29.364],
|
||||
[89.757, 29.295],
|
||||
[89.787, 29.292],
|
||||
[89.808, 29.310000000000002],
|
||||
[89.85300000000001, 29.322],
|
||||
[89.931, 29.319],
|
||||
[89.985, 29.343],
|
||||
[90.015, 29.337],
|
||||
[90.072, 29.352],
|
||||
[90.156, 29.355],
|
||||
[90.168, 29.346],
|
||||
[90.20100000000001, 29.349],
|
||||
[90.22500000000001, 29.331],
|
||||
[90.273, 29.337],
|
||||
[90.276, 29.328],
|
||||
[90.342, 29.313],
|
||||
[90.378, 29.295],
|
||||
[90.435, 29.241],
|
||||
[90.477, 29.256],
|
||||
[90.492, 29.25],
|
||||
[90.51, 29.256],
|
||||
[90.522, 29.247],
|
||||
[90.54, 29.262],
|
||||
[90.621, 29.277],
|
||||
[90.642, 29.301000000000002],
|
||||
[90.663, 29.298000000000002],
|
||||
[90.681, 29.313],
|
||||
[90.684, 29.328],
|
||||
[90.705, 29.34],
|
||||
[90.729, 29.328],
|
||||
[90.747, 29.337],
|
||||
[90.765, 29.328],
|
||||
[90.768, 29.310000000000002],
|
||||
[90.756, 29.295],
|
||||
[90.771, 29.277],
|
||||
[90.855, 29.283],
|
||||
[90.897, 29.319],
|
||||
[90.933, 29.310000000000002],
|
||||
[90.95100000000001, 29.295],
|
||||
[90.993, 29.322],
|
||||
[91.035, 29.295],
|
||||
[91.065, 29.316],
|
||||
[91.116, 29.325],
|
||||
[91.131, 29.313],
|
||||
[91.173, 29.325],
|
||||
[91.194, 29.286],
|
||||
[91.233, 29.274],
|
||||
[91.296, 29.289],
|
||||
[91.308, 29.277],
|
||||
[91.34700000000001, 29.28],
|
||||
[91.374, 29.292],
|
||||
[91.395, 29.28],
|
||||
[91.449, 29.286],
|
||||
[91.479, 29.268],
|
||||
[91.512, 29.28],
|
||||
[91.533, 29.271],
|
||||
[91.554, 29.289],
|
||||
[91.587, 29.292],
|
||||
[91.602, 29.271],
|
||||
[91.62, 29.265],
|
||||
[91.659, 29.271],
|
||||
[91.674, 29.259],
|
||||
[91.71000000000001, 29.268],
|
||||
[91.74, 29.259],
|
||||
[91.782, 29.274],
|
||||
[91.839, 29.268],
|
||||
[91.869, 29.283],
|
||||
[91.98, 29.262],
|
||||
[92.007, 29.232],
|
||||
[92.07000000000001, 29.289],
|
||||
[92.115, 29.283],
|
||||
[92.154, 29.289],
|
||||
[92.196, 29.244],
|
||||
[92.22, 29.253],
|
||||
[92.235, 29.244],
|
||||
[92.277, 29.244],
|
||||
[92.295, 29.229],
|
||||
[92.319, 29.226],
|
||||
[92.376, 29.226],
|
||||
[92.397, 29.241],
|
||||
[92.406, 29.262],
|
||||
[92.433, 29.25],
|
||||
[92.46600000000001, 29.253],
|
||||
[92.529, 29.172],
|
||||
[92.529, 29.133],
|
||||
[92.553, 29.148],
|
||||
[92.598, 29.145],
|
||||
[92.589, 29.121000000000002],
|
||||
[92.61, 29.097],
|
||||
[92.619, 29.115000000000002],
|
||||
[92.658, 29.121000000000002],
|
||||
[92.685, 29.139],
|
||||
[92.697, 29.127],
|
||||
[92.676, 29.112000000000002],
|
||||
[92.679, 29.103],
|
||||
[92.709, 29.109],
|
||||
[92.739, 29.067],
|
||||
[92.772, 29.091],
|
||||
[92.775, 29.073],
|
||||
[92.796, 29.082],
|
||||
[92.808, 29.061],
|
||||
[92.82000000000001, 29.067],
|
||||
[92.82300000000001, 29.082],
|
||||
[92.82900000000001, 29.076],
|
||||
[92.85300000000001, 29.082],
|
||||
[92.85600000000001, 29.073],
|
||||
[92.88, 29.073],
|
||||
[92.904, 29.058],
|
||||
[92.934, 29.067],
|
||||
[92.952, 29.049],
|
||||
[92.973, 29.061],
|
||||
[92.988, 29.043],
|
||||
[92.997, 29.049],
|
||||
[93.066, 29.043],
|
||||
[93.081, 29.094],
|
||||
[93.117, 29.115000000000002],
|
||||
[93.123, 29.136],
|
||||
[93.138, 29.139],
|
||||
[93.15, 29.127],
|
||||
[93.147, 29.094],
|
||||
[93.165, 29.064],
|
||||
[93.165, 29.043],
|
||||
[93.153, 29.037],
|
||||
[93.153, 29.025000000000002],
|
||||
[93.162, 29.016000000000002],
|
||||
[93.21300000000001, 29.025000000000002],
|
||||
[93.22800000000001, 29.019000000000002],
|
||||
[93.23100000000001, 29.001],
|
||||
[93.261, 28.992],
|
||||
[93.285, 29.001],
|
||||
[93.312, 28.998],
|
||||
[93.315, 29.016000000000002],
|
||||
[93.348, 29.046],
|
||||
[93.375, 29.052],
|
||||
[93.393, 29.094],
|
||||
[93.429, 29.109],
|
||||
[93.447, 29.106],
|
||||
[93.438, 29.13],
|
||||
[93.48, 29.175],
|
||||
[93.492, 29.163],
|
||||
[93.54, 29.178],
|
||||
[93.57000000000001, 29.166],
|
||||
[93.627, 29.172],
|
||||
[93.63, 29.151],
|
||||
[93.645, 29.145],
|
||||
[93.675, 29.151],
|
||||
[93.681, 29.163],
|
||||
[93.699, 29.16],
|
||||
[93.702, 29.142],
|
||||
[93.735, 29.127],
|
||||
[93.75, 29.136],
|
||||
[93.75, 29.148],
|
||||
[93.78, 29.154],
|
||||
[93.789, 29.124000000000002],
|
||||
[93.825, 29.118000000000002],
|
||||
[93.834, 29.124000000000002],
|
||||
[93.831, 29.142],
|
||||
[93.894, 29.13],
|
||||
[93.912, 29.145],
|
||||
[93.903, 29.166],
|
||||
[93.909, 29.178],
|
||||
[93.94500000000001, 29.184],
|
||||
[93.95400000000001, 29.196],
|
||||
[94.017, 29.193],
|
||||
[94.035, 29.205000000000002],
|
||||
[94.176, 29.196],
|
||||
[94.251, 29.262],
|
||||
[94.305, 29.271],
|
||||
[94.302, 29.292],
|
||||
[94.33200000000001, 29.316],
|
||||
[94.35000000000001, 29.316],
|
||||
[94.389, 29.337],
|
||||
[94.401, 29.361],
|
||||
[94.419, 29.37],
|
||||
[94.434, 29.406000000000002],
|
||||
[94.542, 29.448],
|
||||
[94.581, 29.481],
|
||||
[94.656, 29.490000000000002],
|
||||
[94.70700000000001, 29.463],
|
||||
[94.818, 29.493000000000002],
|
||||
[94.875, 29.541],
|
||||
[94.923, 29.61],
|
||||
[94.926, 29.622],
|
||||
[94.887, 29.634],
|
||||
[94.917, 29.67],
|
||||
[94.893, 29.697],
|
||||
[94.905, 29.715],
|
||||
[94.956, 29.757],
|
||||
[95.124, 29.763],
|
||||
[95.124, 29.781000000000002],
|
||||
[95.09100000000001, 29.814],
|
||||
[95.11200000000001, 29.868000000000002],
|
||||
[95.13, 29.88],
|
||||
[95.175, 29.895],
|
||||
[95.196, 29.892],
|
||||
[95.223, 29.868000000000002],
|
||||
[95.286, 29.868000000000002],
|
||||
[95.304, 29.856],
|
||||
[95.307, 29.838],
|
||||
[95.283, 29.82],
|
||||
[95.289, 29.811],
|
||||
[95.385, 29.772000000000002],
|
||||
[95.379, 29.718],
|
||||
[95.397, 29.688000000000002],
|
||||
[95.388, 29.595],
|
||||
[95.403, 29.562],
|
||||
[95.427, 29.538],
|
||||
[95.43900000000001, 29.478],
|
||||
[95.43, 29.451],
|
||||
[95.313, 29.331],
|
||||
[95.256, 29.289],
|
||||
[95.202, 29.28],
|
||||
[95.04, 29.175],
|
||||
[95.001, 29.169],
|
||||
[95.004, 29.139],
|
||||
[94.908, 29.052],
|
||||
[94.902, 29.016000000000002],
|
||||
[94.869, 28.998],
|
||||
[94.839, 28.956],
|
||||
[94.788, 28.935000000000002],
|
||||
[94.773, 28.854],
|
||||
[94.794, 28.824],
|
||||
[94.914, 28.821],
|
||||
[94.923, 28.812],
|
||||
[94.911, 28.794],
|
||||
[94.92, 28.752],
|
||||
[94.977, 28.713],
|
||||
[94.983, 28.674],
|
||||
[94.998, 28.683],
|
||||
[95.031, 28.617],
|
||||
[95.09700000000001, 28.539],
|
||||
[95.10300000000001, 28.506],
|
||||
[95.09100000000001, 28.455000000000002],
|
||||
[95.09700000000001, 28.419],
|
||||
[95.013, 28.326],
|
||||
[94.992, 28.287],
|
||||
[94.992, 28.242],
|
||||
[95.019, 28.215],
|
||||
[95.031, 28.173000000000002],
|
||||
[95.06700000000001, 28.155],
|
||||
[95.145, 28.149],
|
||||
[95.211, 28.179000000000002],
|
||||
[95.277, 28.155],
|
||||
[95.292, 28.116],
|
||||
[95.316, 28.089000000000002],
|
||||
[95.355, 28.071],
|
||||
[95.379, 28.071],
|
||||
[95.385, 28.059],
|
||||
[95.382, 27.951],
|
||||
[95.4, 27.936],
|
||||
[95.412, 27.882],
|
||||
[95.379, 27.843],
|
||||
[95.349, 27.837],
|
||||
[95.325, 27.810000000000002],
|
||||
[95.325, 27.78],
|
||||
[95.298, 27.762],
|
||||
[95.289, 27.717000000000002],
|
||||
[95.253, 27.657],
|
||||
[95.211, 27.666],
|
||||
[95.151, 27.624000000000002],
|
||||
[95.139, 27.63],
|
||||
[95.10300000000001, 27.609],
|
||||
[94.992, 27.6],
|
||||
[94.944, 27.57],
|
||||
[94.917, 27.573],
|
||||
[94.869, 27.504],
|
||||
[94.803, 27.495],
|
||||
[94.803, 27.48],
|
||||
[94.785, 27.471],
|
||||
[94.794, 27.456],
|
||||
[94.767, 27.435000000000002],
|
||||
[94.767, 27.414],
|
||||
[94.73100000000001, 27.402],
|
||||
[94.71900000000001, 27.372],
|
||||
[94.69800000000001, 27.36],
|
||||
[94.69800000000001, 27.333000000000002],
|
||||
[94.683, 27.324],
|
||||
[94.677, 27.294],
|
||||
[94.635, 27.291],
|
||||
[94.629, 27.273],
|
||||
[94.602, 27.255],
|
||||
[94.596, 27.231],
|
||||
[94.584, 27.228],
|
||||
[94.587, 27.177],
|
||||
[94.596, 27.162],
|
||||
[94.584, 27.147000000000002],
|
||||
[94.566, 27.150000000000002],
|
||||
[94.554, 27.102],
|
||||
[94.518, 27.105],
|
||||
[94.449, 27.060000000000002],
|
||||
[94.44, 27.018],
|
||||
[94.407, 26.985],
|
||||
[94.35900000000001, 26.97],
|
||||
[94.287, 26.919],
|
||||
[94.197, 26.925],
|
||||
[94.164, 26.898],
|
||||
[94.146, 26.865000000000002],
|
||||
[94.125, 26.856],
|
||||
[94.113, 26.868000000000002],
|
||||
[94.134, 26.907],
|
||||
[94.113, 26.913],
|
||||
[94.077, 26.886],
|
||||
[93.927, 26.832],
|
||||
[93.864, 26.766000000000002],
|
||||
[93.753, 26.733],
|
||||
[93.657, 26.715],
|
||||
[93.618, 26.724],
|
||||
[93.56700000000001, 26.751],
|
||||
[93.441, 26.769000000000002],
|
||||
[93.411, 26.76],
|
||||
[93.369, 26.718],
|
||||
[93.288, 26.742],
|
||||
[93.249, 26.724],
|
||||
[93.183, 26.673000000000002],
|
||||
[93.12, 26.646],
|
||||
[93.087, 26.655],
|
||||
[93.018, 26.646],
|
||||
[92.898, 26.655],
|
||||
[92.883, 26.652],
|
||||
[92.874, 26.625],
|
||||
[92.83500000000001, 26.607],
|
||||
[92.733, 26.613],
|
||||
[92.676, 26.601],
|
||||
[92.658, 26.592000000000002],
|
||||
[92.616, 26.523],
|
||||
[92.595, 26.517],
|
||||
[92.508, 26.517],
|
||||
[92.43, 26.535],
|
||||
[92.391, 26.532],
|
||||
[92.244, 26.472],
|
||||
[92.229, 26.442],
|
||||
[92.202, 26.454],
|
||||
[92.172, 26.442],
|
||||
[92.124, 26.403000000000002],
|
||||
[92.06700000000001, 26.373],
|
||||
[92.07000000000001, 26.325],
|
||||
[92.058, 26.304000000000002],
|
||||
[91.971, 26.271],
|
||||
[91.932, 26.271],
|
||||
[91.899, 26.241],
|
||||
[91.878, 26.235],
|
||||
[91.854, 26.241],
|
||||
[91.665, 26.166],
|
||||
[91.608, 26.172],
|
||||
[91.542, 26.145],
|
||||
[91.497, 26.139],
|
||||
[91.377, 26.172],
|
||||
[91.287, 26.166],
|
||||
[91.197, 26.202],
|
||||
[91.134, 26.214000000000002],
|
||||
[91.062, 26.187],
|
||||
[90.96300000000001, 26.175],
|
||||
[90.888, 26.136],
|
||||
[90.849, 26.13],
|
||||
[90.807, 26.154],
|
||||
[90.741, 26.163],
|
||||
[90.681, 26.19],
|
||||
[90.58200000000001, 26.205000000000002],
|
||||
[90.507, 26.229],
|
||||
[90.474, 26.226],
|
||||
[90.435, 26.178],
|
||||
[90.402, 26.16],
|
||||
[90.345, 26.148],
|
||||
[90.309, 26.124],
|
||||
[90.22500000000001, 26.106],
|
||||
[90.177, 26.076],
|
||||
[89.955, 26.01],
|
||||
[89.904, 25.941],
|
||||
[89.84700000000001, 25.89],
|
||||
[89.81400000000001, 25.815],
|
||||
[89.736, 25.692],
|
||||
[89.724, 25.632],
|
||||
[89.697, 25.572],
|
||||
[89.709, 25.482],
|
||||
[89.697, 25.398],
|
||||
[89.67, 25.323],
|
||||
[89.7, 25.266000000000002],
|
||||
[89.703, 25.242],
|
||||
[89.685, 25.2],
|
||||
[89.661, 25.173000000000002],
|
||||
[89.625, 25.074],
|
||||
[89.613, 24.96],
|
||||
[89.613, 24.936],
|
||||
[89.658, 24.87],
|
||||
[89.673, 24.792],
|
||||
[89.706, 24.75],
|
||||
[89.769, 24.549],
|
||||
[89.748, 24.372],
|
||||
[89.754, 24.282],
|
||||
[89.733, 24.225],
|
||||
[89.742, 24.201],
|
||||
[89.739, 24.123],
|
||||
[89.697, 24.015],
|
||||
[89.7, 23.958000000000002],
|
||||
[89.727, 23.883],
|
||||
[89.787, 23.796],
|
||||
[89.85600000000001, 23.748],
|
||||
[89.919, 23.664],
|
||||
[89.985, 23.643],
|
||||
[90.144, 23.544],
|
||||
[90.249, 23.463]
|
||||
]
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
// Example tokens only. Replace every visual value with production-local tokens.
|
||||
export const COLORS = {
|
||||
bg: '#101315',
|
||||
// Electric water — a near-white icy core with a blue glow and a white-hot draw-head (the "electricity"
|
||||
// travels along the river as it draws on). No dark casing.
|
||||
river: '#E8F7FF', // bright icy core
|
||||
riverGlow: 'rgba(73,198,255,0.5)', // electric-blue glow
|
||||
riverHead: '#FFFFFF', // white-hot leading head
|
||||
riverHeadGlow: 'rgba(120,225,255,0.95)',
|
||||
border: '#f5f2ed', // neutral cream country borders/labels over the colored fills
|
||||
cream: '#f5f0eb',
|
||||
} as const;
|
||||
|
||||
// Example progressive fill tokens. Rename these keys and replace values for each production.
|
||||
export const COUNTRY = {
|
||||
china: '#D4A853',
|
||||
india: '#5B8A8A',
|
||||
bangladesh: '#C07B57',
|
||||
} as const;
|
||||
// Darker shade of each country colour — the settled border line (the bright COUNTRY colour is the
|
||||
// glowing draw-head that leads the animation).
|
||||
export const COUNTRY_DARK = {
|
||||
china: '#9A7530',
|
||||
india: '#3C5C5C',
|
||||
bangladesh: '#855239',
|
||||
} as const;
|
||||
export const FILL_OPACITY = 0.5;
|
||||
|
||||
export const VIDEO = {width: 1920, height: 1080, fps: 30} as const;
|
||||
|
||||
// Beat durations (seconds → frames at VIDEO.fps)
|
||||
export const DUR = {
|
||||
mapExplainer: 12 * VIDEO.fps,
|
||||
} as const;
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# Map element data sources
|
||||
|
||||
Choose the source independently for every story element. A single map can—and often should—mix
|
||||
provider vectors with custom geodata.
|
||||
|
||||
## Decision rule
|
||||
|
||||
Use a MapTiler Planet vector layer when the feature already exists there, its attributes support an
|
||||
editorially precise filter, and provider geometry is acceptable for the claim. Use custom GeoJSON when
|
||||
the feature is absent, proposed, historical, disputed, corrected, privately sourced, or needs ordered
|
||||
geometry for a deterministic draw.
|
||||
|
||||
| Requirement | MapTiler vector layer | Custom GeoJSON |
|
||||
| --------------------------------------------------------------------------- | --------------------- | ------------------------------------------- |
|
||||
| Roads, waterways, water, boundaries, land cover, or other standard context | Prefer | Use only when provider data is insufficient |
|
||||
| Basemap-consistent geometry without a duplicate local dataset | Prefer | No |
|
||||
| Proposed, historical, classified, corrected, or production-specific element | No | Prefer |
|
||||
| Fade, colour, width, radius, blur, or fill-opacity animation | Yes | Yes |
|
||||
| Feature-state highlight when a stable feature ID exists | Yes | Yes |
|
||||
| Deterministic source-to-end line draw or perimeter draw | Bake first | Prefer |
|
||||
| Geometry editing, morphing, clipping, or exact sequencing | No | Prefer |
|
||||
|
||||
Provider alignment is not proof of correctness. Inspect the attributes and geometry against the
|
||||
editorial source before presenting a provider feature as evidence.
|
||||
|
||||
## MapTiler vector mode
|
||||
|
||||
MapTiler Planet is a vector tile source. Add it once, then build story layers with a
|
||||
`source-layer` and an exact attribute filter. Read the current MapTiler Planet schema before choosing
|
||||
layer names or fields.
|
||||
|
||||
Common layer categories include `waterway`, `water`, `transportation`, `boundary`, `landcover`, and
|
||||
`poi`; availability, fields, and zoom ranges vary by schema version.
|
||||
|
||||
```ts
|
||||
import {addMapTilerVectorElement, setVectorElementPaint} from "./MapTilerVectorElement";
|
||||
|
||||
addMapTilerVectorElement(map, process.env.REMOTION_MAPTILER_KEY!, {
|
||||
id: "story-river",
|
||||
sourceLayer: "waterway",
|
||||
type: "line",
|
||||
filter: [
|
||||
"all",
|
||||
["==", ["get", "class"], "river"],
|
||||
["==", ["coalesce", ["get", "name_en"], ["get", "name"]], "Yarlung Tsangpo"],
|
||||
],
|
||||
layout: {"line-cap": "round", "line-join": "round"},
|
||||
paint: {
|
||||
"line-color": "#E8F7FF",
|
||||
"line-width": 3,
|
||||
"line-opacity": 0,
|
||||
},
|
||||
});
|
||||
|
||||
setVectorElementPaint(map, "story-river", {
|
||||
"line-opacity": reveal,
|
||||
"line-width": 2 + reveal * 2,
|
||||
});
|
||||
```
|
||||
|
||||
Animate provider features by changing paint properties from the Remotion frame: opacity, colour,
|
||||
width, blur, fill opacity, circle radius, or symbol opacity. Use feature state only when the source
|
||||
provides stable IDs and the selection remains deterministic across tiles.
|
||||
|
||||
Do not treat a tiled line as one ordered path. Vector tiles split features at tile boundaries, so a
|
||||
source-to-mouth or start-to-end draw has no reliable global order. If that motion carries meaning,
|
||||
extract and verify the complete feature, order it once, save it as GeoJSON, and use the custom mode.
|
||||
|
||||
## Custom geodata mode
|
||||
|
||||
Use the bundled `../assets/RiverReveal.tsx` and `../scripts/prep-geo.mjs` pattern for custom GeoJSON. This mode owns
|
||||
the exact geometry and can slice it by distance, calculate entry triggers, draw complete borders, and
|
||||
produce deterministic sequences.
|
||||
|
||||
Custom mode is mandatory when:
|
||||
|
||||
- the feature is not in the provider dataset;
|
||||
- the story uses a proposed route, planned tunnel, historical boundary, disputed interpretation, or
|
||||
non-public dataset;
|
||||
- provider geometry was editorially corrected;
|
||||
- motion must travel through the geometry in a verified order;
|
||||
- a complete off-screen boundary matters and a viewport query would silently crop it.
|
||||
|
||||
## Hybrid mode
|
||||
|
||||
Use provider vectors for ordinary contextual features and custom GeoJSON for the specific claim. For
|
||||
example: MapTiler waterways and roads as aligned context; a custom proposed tunnel, dam site, disputed
|
||||
boundary, or verified evacuation area as the highlighted evidence.
|
||||
|
||||
Keep provider and custom layers visually distinct when they carry different evidentiary weight. Record
|
||||
the source and effective date of every custom layer in the production notes.
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Map Explainer — architecture reference
|
||||
|
||||
Deep detail behind `TECHNIQUE.md`: the timing model, the river reveal + electric head, the per-country
|
||||
sequence, and label projection. The supplied values are examples, not a production style system.
|
||||
The custom-geometry example is `../assets/RiverReveal.tsx` +
|
||||
`../assets/CountryLabel.tsx` +
|
||||
`../assets/tokens.ts`. Provider-vector setup is in
|
||||
`../assets/MapTilerVectorElement.ts`; choose between
|
||||
the two modes with `data-sources.md`.
|
||||
|
||||
## 1. The render harness (per frame)
|
||||
|
||||
Init the MapTiler map once (ref guard). On `load`: strip clutter (see `geo-prep.md`), add sources/layers,
|
||||
wait for `once('idle') → continueRender`. Per frame:
|
||||
|
||||
```
|
||||
delayRender → setData/setPaintProperty → map.once('idle', continueRender) → triggerRepaint
|
||||
```
|
||||
|
||||
`preserveDrawingBuffer:true` so Remotion's screenshot captures the canvas. Render `--gl=angle`.
|
||||
For an animated camera, read `render-stability.md`: the MapTiler renderer remains static and a CSS plate
|
||||
transform supplies the camera choreography.
|
||||
|
||||
## 2. Timing model — time-based; beat length derived from the sequences
|
||||
|
||||
Everything keys off **seconds** (`t = frame / fps`), not reveal-units. The river draws over a window;
|
||||
each country **triggers when the river reaches it** and runs a fixed sequence. The beat is exactly as
|
||||
long as the sequences need.
|
||||
|
||||
```ts
|
||||
const RIVER_START = 0.3, RIVER_END = 8.0; // river draws over this window
|
||||
const BORDER_S = 2.5, FILL_S = 1.0, LABEL_S = 0.7; // per-country sequence (constant durations)
|
||||
const trigger = (c) => RIVER_START + META[c].stop * (RIVER_END - RIVER_START); // river-arrival time
|
||||
// beat length = max over c of (trigger(c) + BORDER_S + FILL_S + LABEL_S) + tail
|
||||
const reveal = interpolate(t, [RIVER_START, RIVER_END], [0,1], { ...clamp, easing: Easing.bezier(0.645, 0.045, 0.355, 1) });
|
||||
```
|
||||
|
||||
**Constant durations matter:** drive the border draw by _time since trigger_, not a slice of the reveal —
|
||||
otherwise complex or long borders flash by in a fraction of a second.
|
||||
|
||||
## 3. Provider-vector animation
|
||||
|
||||
MapTiler Planet elements can be animated directly in place. Filter an exact `source-layer` feature,
|
||||
initialize its paint in the hidden or neutral state, then update paint properties from the Remotion frame.
|
||||
This works for line, fill, circle, and symbol layers without copying provider geometry into the project.
|
||||
|
||||
Do not assume tiled geometry has a global order. Provider features are split at tile boundaries: opacity,
|
||||
colour, width, blur, radius, fill, and feature-state changes are reliable; semantic start-to-end line
|
||||
draws are not. Bake ordered GeoJSON when the direction of the draw carries meaning.
|
||||
|
||||
## 4. Custom line animation — reveal + electric draw-head
|
||||
|
||||
The "electricity" is a **white-hot head** leading the draw — the last few % of the drawn line in its own
|
||||
bright + glow layers, faded out once the river completes.
|
||||
|
||||
```ts
|
||||
const riverDrawnKm = lineKm * reveal;
|
||||
map.getSource("river").setData(turf.lineSliceAlong(line, 0, Math.max(0.001, riverDrawnKm)));
|
||||
const headKm = lineKm * 0.03;
|
||||
map.getSource("river-head").setData(turf.lineSliceAlong(line, Math.max(0, riverDrawnKm - headKm), Math.max(0.001, riverDrawnKm)));
|
||||
let headFade = 0;
|
||||
if (reveal > 0.002 && reveal < 0.999) headFade = 1;
|
||||
else if (reveal >= 0.999) headFade = 1 - clamp01((t - RIVER_END) / 0.5); // fade out at the mouth
|
||||
map.setPaintProperty("river-headglow", "line-opacity", 0.85 * headFade);
|
||||
map.setPaintProperty("river-head", "line-opacity", headFade);
|
||||
```
|
||||
|
||||
Layers, bottom→top: `river-glow` (electric blue `#49C6FF`, w11, op0.32, blur6) → `river-line`
|
||||
(icy core `#E8F7FF`, w3) → `river-headglow` (`rgba(120,225,255,.95)`, w16, blur9) → `river-head`
|
||||
(white `#FFFFFF`, w4.5). **No dark casing** — the bright icy core reads over every fill on its own.
|
||||
|
||||
## 5. Country animation — border draws → fill blooms → label rises
|
||||
|
||||
Triggered by river arrival, each country runs three sequential phases. The border is a **darker shade**
|
||||
of the country colour (the electricity is on the river, not here).
|
||||
|
||||
```ts
|
||||
const lt = t - trigger(c); // local seconds since trigger
|
||||
// 1) complete source border draws on over a constant BORDER_S, multi-segment-safe
|
||||
const bp = interpolate(clamp01(lt / BORDER_S), [0,1], [0,1], { easing: Easing.bezier(0.645, 0.045, 0.355, 1) });
|
||||
map.getSource(`trail-${c}`).setData(sliceBorder(DRAW[c], 0, DRAW[c].total * bp)); // COUNTRY_DARK line
|
||||
// 2) fill blooms in (opacity overshoots, then settles) after the border completes
|
||||
const fp = clamp01((lt - BORDER_S) / FILL_S);
|
||||
const fo = interpolate(fp, [0, 0.6, 1], [0, FILL_OPACITY * 1.25, FILL_OPACITY], { ...clamp, easing: Easing.bezier(0.3333333333333333, 1, 0.6666666666666666, 1) });
|
||||
map.setPaintProperty(`fill-${c}`, "fill-opacity", fp <= 0 ? 0 : fo);
|
||||
// 3) label rises in after the fill
|
||||
const lp = clamp01((lt - BORDER_S - FILL_S) / LABEL_S);
|
||||
```
|
||||
|
||||
`sliceBorder(d, fromKm, toKm)` reveals a portion of a complete (possibly multi-segment) border as a
|
||||
MultiLineString, slicing each segment by cumulative length — no joins across gaps and no viewport crop:
|
||||
|
||||
```ts
|
||||
const sliceBorder = (d, fromKm, toKm) => {
|
||||
const out = [];
|
||||
for (let i = 0; i < d.segLines.length; i++) {
|
||||
const start = d.cum[i], end = start + d.segLen[i];
|
||||
const a = Math.max(fromKm, start), b = Math.min(toKm, end);
|
||||
if (b - a <= 0.0008) continue;
|
||||
out.push(turf.lineSliceAlong(d.segLines[i], a - start, b - start).geometry.coordinates);
|
||||
}
|
||||
return { type:"Feature", properties:{}, geometry:{ type:"MultiLineString", coordinates: out } };
|
||||
};
|
||||
```
|
||||
|
||||
Choose fill, border, and river colours in the production's local token file. The bundled token values are
|
||||
examples only; do not carry a source project's palette into another production.
|
||||
|
||||
## 6. Labels — HTML overlay, projected each frame
|
||||
|
||||
Labels are React, not map symbols (full typography control). `CountryLabel` is an example accent-rule,
|
||||
rise-and-fade treatment; select the typeface and final values in the production.
|
||||
Positioned by projecting the anchor to screen pixels **every frame**, stored in state:
|
||||
|
||||
```ts
|
||||
const p = map.project(META[c].anchor); // lngLat → screen px (respects the live camera)
|
||||
pos[c] = { x: p.x, y: p.y, reveal: lp };
|
||||
setLabels(pos); // re-render the overlay; effect deps exclude `labels`
|
||||
```
|
||||
|
||||
`CountryLabel` shows the mechanics: uppercase region name, short accent divider, rise/fade entrance,
|
||||
and `pointerEvents:none`. Select font, weight, size, spacing, contrast, and colour from the production's
|
||||
own type and palette system.
|
||||
|
||||
## 7. Camera — fixed map plate for any movement
|
||||
|
||||
Read `render-stability.md`. Do not use per-frame `map.jumpTo()` for a moving 2D shot; it can shimmer in
|
||||
headless renders even on satellite imagery. Interpolate the intended camera for the CSS plate transform,
|
||||
while keeping the MapTiler renderer static.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# Map Explainer — basemap & geo prep
|
||||
|
||||
How the basemap is cleaned and how `../scripts/prep-geo.mjs` bakes the per-country data the component reads.
|
||||
|
||||
## Basemap styling — strip the clutter
|
||||
|
||||
On `load`, remove the basemap's labels and inner admin borders so only your geography reads:
|
||||
|
||||
```ts
|
||||
for (const l of m.getStyle().layers as any[])
|
||||
if (l.type === "symbol" || /other border/i.test(l.id)) m.removeLayer(l.id);
|
||||
```
|
||||
|
||||
- `type === "symbol"` → every place/water/road **label** (the "MapTiler labels"). Gone.
|
||||
- Inner admin-border layer IDs vary by style. Inspect the loaded style, remove state/province/district
|
||||
layers as needed, and retain only the context borders the production requires.
|
||||
- Logo/attribution: `maptilerLogo:false` + `attributionControl:false` aren't always enough — also hide
|
||||
via CSS in the component:
|
||||
```tsx
|
||||
<style>{`.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-attrib,.maptiler-logo{display:none!important}`}</style>
|
||||
```
|
||||
|
||||
## `../scripts/prep-geo.mjs` → outputs
|
||||
|
||||
Reads a routed river GeoJSON + country polygon GeoJSONs; writes:
|
||||
|
||||
- **River line** — simplified for a smooth draw. For a braided river, route one source→mouth path through
|
||||
the network first: greedy endpoint-chaining bounces between parallel channels. → `src/geo/river-flow.json`.
|
||||
- **`public/geo/borders.geojson`** — each country's polygon tagged `{country: name}` (one source,
|
||||
filtered per country for the fills).
|
||||
- **`src/geo/country-meta.json`** — per country `{ stop, anchor, border }`.
|
||||
|
||||
### `stop` — when a country lights up
|
||||
|
||||
Walk the river points; first point inside a country (`turf.booleanPointInPolygon`) = the arc-length
|
||||
fraction where the river **enters** it. Drives the trigger time. The headwaters country = 0.
|
||||
|
||||
### `anchor` — label centre via pole of inaccessibility
|
||||
|
||||
The most-interior point of the country (clipped to a per-country **story bbox** so a big country
|
||||
centres in the relevant region, not its far bulge), then a small operator **nudge**. Pole = grid-sample
|
||||
inside the polygon, keep the point with max distance to the boundary. **Centroids get pulled to edges —
|
||||
don't use them.**
|
||||
|
||||
```js
|
||||
const pole = (poly) => {
|
||||
const bb = turf.bbox(poly), edge = turf.polygonToLine(poly), N = 46;
|
||||
let best = null, bestD = -1;
|
||||
for (let i = 0; i <= N; i++) for (let j = 0; j <= N; j++) {
|
||||
const p = turf.point([bb[0]+(bb[2]-bb[0])*i/N, bb[1]+(bb[3]-bb[1])*j/N]);
|
||||
if (!turf.booleanPointInPolygon(p, poly)) continue;
|
||||
const d = turf.pointToLineDistance(p, edge);
|
||||
if (d > bestD) { bestD = d; best = p.geometry.coordinates; }
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const ANCHOR_BBOX = { china:[82,27,96,32], india:[76,14,99,31], bangladesh:[86,20,93,27] }; // story regions
|
||||
const NUDGE = { china:[0,0.6], india:[-1.0,0], bangladesh:[0,-0.6] }; // operator-directed
|
||||
```
|
||||
|
||||
### `border` — complete source geometry
|
||||
|
||||
Preserve every exterior ring from the named country source. Never clip a country or bilateral border to
|
||||
the framed bbox and never discard an off-screen segment: the geometry may leave the frame naturally.
|
||||
The renderer handles a MultiLineString by cumulative length, so it remains one timed reveal without
|
||||
inventing joins across gaps.
|
||||
|
||||
## Tuning the geo prep for a new scenario
|
||||
|
||||
| Want | Knob |
|
||||
| --------------------------------- | -------------------------------------------------------------------- |
|
||||
| Which countries | the country list in `prep-geo.mjs` (+ supply their polygon GeoJSONs) |
|
||||
| Label centred in the right region | `ANCHOR_BBOX[country]` (the story bbox) |
|
||||
| Nudge a label | `NUDGE[country]` (lng, lat offset) |
|
||||
| What border is drawn | the complete named source geometry; never the visible extent |
|
||||
| When each lights up | derived from `stop` — depends on the river geometry |
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Moving Map Render Stability
|
||||
|
||||
Read this reference before building a moving 2D MapTiler scene or diagnosing a wavering Remotion render.
|
||||
|
||||
## Symptom and cause
|
||||
|
||||
If basemap detail shimmers or jitters during a pan/zoom, the likely cause is per-frame `map.jumpTo()`.
|
||||
Headless MapTiler capture can resample both vector hillshade and satellite imagery differently frame to
|
||||
frame. Tile retries, easing changes, and label changes do not solve that renderer effect.
|
||||
|
||||
## Required pattern: fixed map plate
|
||||
|
||||
For any 2D pan/zoom:
|
||||
|
||||
1. Render the MapTiler canvas once at the largest required zoom in an oversized container. Size it from the camera route and keep each dimension below the browser's reliable WebGL render-buffer limit (commonly 4096 px). Do **not** blindly use 3×: a 1920×1080 composition becomes 5760 px wide and Chromium may silently downsample it, causing visible pixelation during the CSS zoom.
|
||||
2. Keep the map's camera static.
|
||||
3. For each frame, calculate the approved target centre/zoom, then move the canvas with CSS `translate` + `scale`.
|
||||
4. Apply the same transform to every projected HTML overlay.
|
||||
5. Continue to animate GeoJSON data and paint properties imperatively; only the renderer camera is frozen.
|
||||
|
||||
Keep pitch and bearing constant. Use a 3D engine such as Cesium for genuine changing pitch/bearing or a terrain flythrough.
|
||||
|
||||
```ts
|
||||
const baseZoom = Math.max(start.zoom, end.zoom);
|
||||
const map = new maptilersdk.Map({
|
||||
container,
|
||||
style,
|
||||
center: end.center,
|
||||
zoom: baseZoom,
|
||||
pitch: end.pitch ?? 0,
|
||||
bearing: end.bearing ?? 0,
|
||||
interactive: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {preserveDrawingBuffer: true},
|
||||
});
|
||||
|
||||
// Per Remotion frame. `camera` is the approved centre/zoom interpolation.
|
||||
const projected = map.project(camera.center);
|
||||
const scale = 2 ** (camera.zoom - baseZoom);
|
||||
const plate = {
|
||||
transform: `translate(${width / 2 - projected.x * scale}px, ${height / 2 - projected.y * scale}px) scale(${scale})`,
|
||||
transformOrigin: "0 0",
|
||||
};
|
||||
|
||||
// Convert label projection with exactly the same plate transform.
|
||||
const labelX = labelPoint.x * scale + width / 2 - projected.x * scale;
|
||||
const labelY = labelPoint.y * scale + height / 2 - projected.y * scale;
|
||||
```
|
||||
|
||||
### Plate sizing and sharpness
|
||||
|
||||
- Render at the maximum zoom reached by **any** camera waypoint, including intermediate or hold cameras. The CSS scale should never exceed `1`; otherwise the plate is being enlarged.
|
||||
- Centre the frozen map on the midpoint of the camera route's geographic extent, not automatically on the final camera. This minimizes required overscan.
|
||||
- Keep the largest canvas dimension at or below 4096 px unless the actual render environment has been tested with a larger `MAX_RENDERBUFFER_SIZE`.
|
||||
- For 1920×1080, a 3840×2160 plate is a safe default. For 1080×1920, use approximately 2700×3840 when the route needs extra horizontal pan room.
|
||||
- If the route cannot fit within that plate at the required zoom, split the shot into two fixed plates with a deliberate editorial transition. Do not trade sharpness for one enormous canvas.
|
||||
- Distinguish failure modes: repeating shimmer means the live renderer is moving; steadily soft tiles during a CSS push means the fixed plate is underspecified, internally downsampled, or being scaled above `1`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Render a short MP4, not only a Studio preview.
|
||||
- Inspect static terrain texture and satellite detail while the camera moves.
|
||||
- If any underlying map detail wavers, use the fixed map plate. Do not approve it as a minor preview artefact.
|
||||
- Render WebGL with `--gl=angle`, `preserveDrawingBuffer:true`, and conservative concurrency (`1`) while validating.
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
// Geo prep for the map-explainer. Bakes the data the component reads:
|
||||
// out/river-flow.json -> the river draw-on line (→ copy to your Remotion project's src/geo/)
|
||||
// out/country-meta.json -> per-country { stop, anchor, border } (→ src/geo/)
|
||||
// out/borders.geojson -> country polygons, each tagged {country} (→ public/geo/ — loaded via staticFile)
|
||||
//
|
||||
// RUN: node prep-geo.mjs (needs YOUR geodata — see CONFIG; the source polygons are too large to ship,
|
||||
// so the skill ships the OUTPUTS in assets/sample-data/ instead.)
|
||||
//
|
||||
// RIVER INPUT MUST BE ONE CLEAN LINESTRING, source → mouth (features[0].geometry is a LineString). If your
|
||||
// OSM river comes as many ways / braided channels, ROUTE it into a single line FIRST — a graph
|
||||
// shortest-path from source node to mouth node. Do NOT greedily chain by nearest endpoint: it bounces
|
||||
// between parallel channels. (That routing step is a prerequisite, not part of this script.)
|
||||
|
||||
import {readFileSync, writeFileSync, mkdirSync} from 'fs';
|
||||
import {dirname, resolve} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
|
||||
if (process.argv.includes('--help')) {
|
||||
console.log(
|
||||
'Configure COUNTRIES, RIVER, BORDER, label bounds, and output paths in this script, then run: bun scripts/prep-geo.mjs',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const turf = await import('@turf/turf');
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dir, '..');
|
||||
const geo = resolve(root, '../geodata'); // ADAPT: where your input GeoJSON lives
|
||||
const read = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
||||
|
||||
// ===== CONFIG — edit for your river + countries =====
|
||||
const COUNTRIES = ['china', 'india', 'bangladesh']; // ORDERED headwaters → mouth; first = source (stop 0)
|
||||
const RIVER = resolve(geo, 'focus-rivers/yarlung-brahmaputra-full-osm.geojson'); // a single clean source→mouth LineString
|
||||
const BORDER = (name) => resolve(geo, `project-borders/${name}.geojson`); // one polygon file per country, named <country>.geojson
|
||||
const FRAME_BBOX = [76, 14, 104, 33.5]; // [W,S,E,N] visible extent — fallback for label anchoring only
|
||||
const ANCHOR_BBOX = {
|
||||
china: [82, 27, 96, 32],
|
||||
india: [76, 14, 99, 31],
|
||||
bangladesh: [86, 20, 93, 27],
|
||||
}; // [W,S,E,N] per-country label "story region"
|
||||
const NUDGE = {china: [0, 0.6], india: [-1.0, 0], bangladesh: [0, -0.6]}; // [lng,lat] label nudge
|
||||
const RIVER_SIMPLIFY_TOL = 0.006; // degrees — smooths the draw-on (bigger = simpler)
|
||||
const OUT_RIVER = resolve(root, 'out/river-flow.json');
|
||||
const OUT_META = resolve(root, 'out/country-meta.json');
|
||||
const OUT_BORDERS = resolve(root, 'out/borders.geojson');
|
||||
// =====================================================
|
||||
|
||||
const havKm = (a, b) => {
|
||||
const R = 6371,
|
||||
r = Math.PI / 180;
|
||||
const dLat = (b[1] - a[1]) * r,
|
||||
dLng = (b[0] - a[0]) * r;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(a[1] * r) * Math.cos(b[1] * r) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
};
|
||||
|
||||
// --- River draw-on line: take the clean routed LineString, strip a dangling final hop, simplify so the
|
||||
// wide-zoom draw-on reads as one smooth thread (no bezier — meander overshoot on a long river). ---
|
||||
const routed = read(RIVER).features[0].geometry.coordinates;
|
||||
let end = routed.length;
|
||||
while (end > 2 && havKm(routed[end - 2], routed[end - 1]) > 15) end--; // drop a final cross-braid jump if present
|
||||
const flow = turf.simplify(turf.lineString(routed.slice(0, end)), {
|
||||
tolerance: RIVER_SIMPLIFY_TOL,
|
||||
highQuality: true,
|
||||
}).geometry.coordinates;
|
||||
|
||||
// --- Borders + country fills (one source, filtered per country in the component) ---
|
||||
const borders = {type: 'FeatureCollection', features: []};
|
||||
const polys = {};
|
||||
for (const name of COUNTRIES) {
|
||||
const fc = read(BORDER(name));
|
||||
polys[name] = fc;
|
||||
for (const f of fc.features) {
|
||||
f.properties = {...(f.properties || {}), country: name};
|
||||
borders.features.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reveal stops: arc-length fraction of `flow` where the river first ENTERS each country. The first
|
||||
// country (headwaters) is the source, so its stop is 0; the rest are computed. ---
|
||||
const flowKm = turf.length(turf.lineString(flow));
|
||||
const insideCountry = (pt, name) =>
|
||||
polys[name].features.some((f) => turf.booleanPointInPolygon(pt, f));
|
||||
const stops = {};
|
||||
COUNTRIES.forEach((c, i) => {
|
||||
stops[c] = i === 0 ? 0 : 1;
|
||||
}); // 0 = headwaters; 1 = sentinel until entered
|
||||
let acc = 0;
|
||||
for (let i = 0; i < flow.length; i++) {
|
||||
if (i > 0) acc += havKm(flow[i - 1], flow[i]);
|
||||
const frac = acc / (flowKm || 1);
|
||||
const pt = turf.point(flow[i]);
|
||||
for (const c of COUNTRIES)
|
||||
if (stops[c] === 1 && insideCountry(pt, c)) stops[c] = frac;
|
||||
}
|
||||
|
||||
// --- Per-country meta: anchor = pole of inaccessibility of the visible landmass (centred, away from
|
||||
// borders/edges) within the country's story region + a NUDGE; border = every exterior ring from the
|
||||
// complete named source. Never crop a country or bilateral boundary to the viewport. ---
|
||||
const biggestPoly = (geom) => {
|
||||
const rings =
|
||||
geom.type === 'MultiPolygon' ? geom.coordinates : [geom.coordinates];
|
||||
let best = null,
|
||||
bestA = -1;
|
||||
for (const c of rings) {
|
||||
const p = turf.polygon(c);
|
||||
const a = turf.area(p);
|
||||
if (a > bestA) {
|
||||
bestA = a;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const largestPolygon = (fc) => {
|
||||
let best = null,
|
||||
bestA = -1;
|
||||
for (const f of fc.features) {
|
||||
const p = biggestPoly(f.geometry),
|
||||
a = turf.area(p);
|
||||
if (a > bestA) {
|
||||
bestA = a;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const completeExteriorSegments = (fc) => {
|
||||
const segments = [];
|
||||
for (const feature of fc.features) {
|
||||
const polygons =
|
||||
feature.geometry.type === 'MultiPolygon'
|
||||
? feature.geometry.coordinates
|
||||
: [feature.geometry.coordinates];
|
||||
for (const polygon of polygons)
|
||||
if (polygon[0]?.length > 1) segments.push(polygon[0]);
|
||||
}
|
||||
return segments;
|
||||
};
|
||||
const poleOfInaccessibility = (poly) => {
|
||||
const bb = turf.bbox(poly),
|
||||
boundary = turf.polygonToLine(poly),
|
||||
N = 46;
|
||||
let best = null,
|
||||
bestD = -1;
|
||||
for (let i = 0; i <= N; i++)
|
||||
for (let j = 0; j <= N; j++) {
|
||||
const lng = bb[0] + ((bb[2] - bb[0]) * i) / N,
|
||||
lat = bb[1] + ((bb[3] - bb[1]) * j) / N;
|
||||
const pt = turf.point([lng, lat]);
|
||||
if (!turf.booleanPointInPolygon(pt, poly)) continue;
|
||||
const d = turf.pointToLineDistance(pt, boundary);
|
||||
if (d > bestD) {
|
||||
bestD = d;
|
||||
best = [lng, lat];
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const countryMeta = {};
|
||||
for (const name of COUNTRIES) {
|
||||
const poly = largestPolygon(polys[name]);
|
||||
const storyRegion = biggestPoly(
|
||||
turf.bboxClip(poly, ANCHOR_BBOX[name] || FRAME_BBOX).geometry,
|
||||
);
|
||||
const pole = poleOfInaccessibility(storyRegion);
|
||||
const segs = completeExteriorSegments(polys[name]);
|
||||
const nudge = NUDGE[name] || [0, 0];
|
||||
countryMeta[name] = {
|
||||
stop: stops[name],
|
||||
anchor: [pole[0] + nudge[0], pole[1] + nudge[1]],
|
||||
border: segs,
|
||||
};
|
||||
}
|
||||
|
||||
mkdirSync(dirname(OUT_RIVER), {recursive: true});
|
||||
writeFileSync(OUT_RIVER, JSON.stringify(flow));
|
||||
writeFileSync(OUT_META, JSON.stringify(countryMeta));
|
||||
writeFileSync(OUT_BORDERS, JSON.stringify(borders));
|
||||
console.log(
|
||||
'river:',
|
||||
flow.length,
|
||||
'pts ·',
|
||||
flowKm.toFixed(0),
|
||||
'km · entry stops',
|
||||
JSON.stringify(stops),
|
||||
);
|
||||
for (const n of COUNTRIES) {
|
||||
const km = countryMeta[n].border.reduce(
|
||||
(s, seg) => s + turf.length(turf.lineString(seg)),
|
||||
0,
|
||||
);
|
||||
console.log(
|
||||
` ${n}: anchor ${countryMeta[n].anchor.map((v) => v.toFixed(2)).join(',')} · border ${km.toFixed(0)} km`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
"\nNext: copy out/river-flow.json + out/country-meta.json → your project's src/geo/ ; out/borders.geojson → public/geo/",
|
||||
);
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: remotion-maps-static
|
||||
description: Create a deterministic static locator map in Remotion when neither the camera nor geographic data animates.
|
||||
---
|
||||
|
||||
# Static map
|
||||
|
||||
Use a static image when the map only provides location context. This is the smallest, fastest, and
|
||||
most deterministic map technique.
|
||||
|
||||
## Build
|
||||
|
||||
1. Export or request a map image at the composition's final aspect ratio and at least its rendered
|
||||
pixel dimensions.
|
||||
2. Store the image in the Remotion project's `public/` directory.
|
||||
3. Render it with `CanvasImage` and `staticFile()`.
|
||||
4. Add labels or markers as ordinary Remotion elements if they remain fixed.
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import {AbsoluteFill, CanvasImage, staticFile} from 'remotion';
|
||||
|
||||
export const StaticMap: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<CanvasImage
|
||||
src={staticFile('locator-map.png')}
|
||||
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Overlays and Interactivity
|
||||
|
||||
Follow [Remotion Interactivity](../../../remotion-interactivity/REFERENCE.md) best practices and [Remotion Markup Best practices](../../../remotion-markup/REFERENCE.md) for elements.
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: 3d
|
||||
description: 3D content in Remotion using Three.js and React Three Fiber.
|
||||
metadata:
|
||||
tags: 3d, three, threejs
|
||||
---
|
||||
|
||||
# Using Three.js and React Three Fiber in Remotion
|
||||
|
||||
Follow React Three Fiber and Three.js best practices.
|
||||
Only the following Remotion-specific rules need to be followed:
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the `@remotion/three` package needs to be installed.
|
||||
If it is not, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/three # If project uses npm
|
||||
bunx remotion add @remotion/three # If project uses bun
|
||||
yarn remotion add @remotion/three # If project uses yarn
|
||||
pnpm exec remotion add @remotion/three # If project uses pnpm
|
||||
```
|
||||
|
||||
## Using ThreeCanvas
|
||||
|
||||
You MUST wrap 3D content in `<ThreeCanvas>` and include proper lighting.
|
||||
`<ThreeCanvas>` MUST have a `width` and `height` prop.
|
||||
|
||||
```tsx
|
||||
import { ThreeCanvas } from "@remotion/three";
|
||||
import { useVideoConfig } from "remotion";
|
||||
|
||||
const { width, height } = useVideoConfig();
|
||||
|
||||
<ThreeCanvas width={width} height={height}>
|
||||
<ambientLight intensity={0.4} />
|
||||
<directionalLight position={[5, 5, 5]} intensity={0.8} />
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 32, 32]} />
|
||||
<meshStandardMaterial color="red" />
|
||||
</mesh>
|
||||
</ThreeCanvas>;
|
||||
```
|
||||
|
||||
## No animations not driven by `useCurrentFrame()`
|
||||
|
||||
Shaders, models etc MUST NOT animate by themselves.
|
||||
No animations are allowed unless they are driven by `useCurrentFrame()`.
|
||||
Otherwise, it will cause flickering during rendering.
|
||||
|
||||
Using `useFrame()` from `@react-three/fiber` is forbidden.
|
||||
|
||||
## Animate using `useCurrentFrame()`
|
||||
|
||||
Use `useCurrentFrame()` to perform animations.
|
||||
|
||||
```tsx
|
||||
const frame = useCurrentFrame();
|
||||
const rotationY = frame * 0.02;
|
||||
|
||||
<mesh rotation={[0, rotationY, 0]}>
|
||||
<boxGeometry args={[2, 2, 2]} />
|
||||
<meshStandardMaterial color="#4a9eff" />
|
||||
</mesh>;
|
||||
```
|
||||
|
||||
## Using `<Sequence>` inside `<ThreeCanvas>`
|
||||
|
||||
The `layout` prop of any `<Sequence>` inside a `<ThreeCanvas>` must be set to `none`.
|
||||
|
||||
```tsx
|
||||
import { Sequence } from "remotion";
|
||||
import { ThreeCanvas } from "@remotion/three";
|
||||
|
||||
const { width, height } = useVideoConfig();
|
||||
|
||||
<ThreeCanvas width={width} height={height}>
|
||||
<Sequence layout="none">
|
||||
<mesh>
|
||||
<boxGeometry args={[2, 2, 2]} />
|
||||
<meshStandardMaterial color="#4a9eff" />
|
||||
</mesh>
|
||||
</Sequence>
|
||||
</ThreeCanvas>;
|
||||
```
|
||||
@@ -0,0 +1,315 @@
|
||||
---
|
||||
name: remotion-markup
|
||||
description: Content, animation and effects best practices
|
||||
metadata:
|
||||
tags: remotion, react, markup
|
||||
---
|
||||
|
||||
This is guidance for writing Remotion React Markup.
|
||||
If this is not relevant, load [Remotion Best Practices](../SKILL.md) instead.
|
||||
|
||||
## General rules
|
||||
|
||||
Animate properties using `useCurrentFrame()` and `interpolate()`.
|
||||
|
||||
Use `Easing.bezier()` to customize timing, including jumpy or overshooting motion.
|
||||
Use `Easing.spring()` if you want spring animations.
|
||||
|
||||
Structure your markup according to [Remotion Interactivity Best Practices](../remotion-interactivity/REFERENCE.md)
|
||||
|
||||
```tsx
|
||||
import { useCurrentFrame, Easing, interpolate, Interactive } from "remotion";
|
||||
|
||||
export const FadeIn = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
return (
|
||||
<Interactive.Div
|
||||
name="Title"
|
||||
style={{
|
||||
opacity: interpolate(frame, [0, 60], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
extrapolateLeft: "clamp",
|
||||
easing: Easing.bezier(0.16, 1, 0.3, 1),
|
||||
}),
|
||||
}}
|
||||
>
|
||||
Hello World!
|
||||
</Interactive.Div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Keep the `interpolate()` call inline in the `style` prop.
|
||||
Prefer `scale`, `translate`, `rotate` CSS properties over `transform`.
|
||||
|
||||
```tsx
|
||||
// 👍 Inline editable keyframes and transform shorthands
|
||||
style={{
|
||||
scale: interpolate(frame, [0, 100], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.spring({damping: 200}),
|
||||
output: 'perceptual-scale' // For `scale` animations, use "output: 'perceptual-scale'"
|
||||
}),
|
||||
translate: interpolate(frame, [0, 100], ["0px 0px", "100px 100px"], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.spring({damping: 200}),
|
||||
}),
|
||||
rotate: interpolate(frame, [0, 100], ["20deg", "90deg"], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.spring({damping: 200}),
|
||||
}),
|
||||
}}
|
||||
|
||||
// 👎 Hidden values and transform strings become harder to edit in Studio
|
||||
const scale = interpolate(frame, [0, 100], [0, 1]);
|
||||
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
```
|
||||
|
||||
CSS transitions or animations are FORBIDDEN - they will not render correctly.
|
||||
Tailwind animation class names are FORBIDDEN - they will not render correctly.
|
||||
|
||||
Place assets in the `public/` folder at your project root.
|
||||
|
||||
Use `staticFile()` to reference files from the `public/` folder.
|
||||
|
||||
Add video and audio using `@remotion/media`.
|
||||
Add images using the `<CanvasImage>` component.
|
||||
Use `staticFile()` for files in `public/` or pass a remote URL directly:
|
||||
|
||||
```tsx
|
||||
import { Audio, Video } from "@remotion/media";
|
||||
import { staticFile, CanvasImage } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return (
|
||||
<>
|
||||
<Video src={staticFile("video.mp4")} style={{ opacity: 0.5 }} />
|
||||
<Audio src={staticFile("audio.mp3")} />
|
||||
<CanvasImage src={staticFile("logo.png")} style={{ width: 100, height: 100 }} />
|
||||
<Video src="https://remotion.media/video.mp4" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Cropping
|
||||
|
||||
See [cropping.md](cropping.md) for supported components, crop props, Studio
|
||||
controls, animation, and custom component support.
|
||||
|
||||
To delay content wrap it in `<Sequence>` and use `from`.
|
||||
To limit the duration of an element, use `durationInFrames` of `<Sequence>`.
|
||||
`<Sequence>` by default is an absolute fill covering the scene.
|
||||
For inline content, use `layout="none"`.
|
||||
|
||||
```tsx
|
||||
const Main = () => {
|
||||
const {fps} = useVideoConfig();
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Background />
|
||||
<AbsoluteFill>
|
||||
<Sequence name="Title" from={30} durationInFrames={60} layout="none">
|
||||
<Title />
|
||||
</Sequence>
|
||||
<Sequence name="Subtitle" from={60} durationInFrames={60} layout="none">
|
||||
<Subtitle />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
}
|
||||
|
||||
export const Title = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
return (
|
||||
<Interactive.Div
|
||||
name="Label"
|
||||
style={{
|
||||
opacity: interpolate(frame, [0, 60], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
extrapolateLeft: "clamp",
|
||||
easing: Easing.bezier(0.16, 1, 0.3, 1),
|
||||
}),
|
||||
fontSize: 88
|
||||
}}
|
||||
>
|
||||
Title
|
||||
</Interactive.Div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Subtitle = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
return (
|
||||
<Interactive.Div
|
||||
name="Subtitle"
|
||||
style={{
|
||||
opacity: interpolate(frame, [0, 60], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
extrapolateLeft: "clamp",
|
||||
easing: Easing.bezier(0.16, 1, 0.3, 1),
|
||||
}),
|
||||
fontSize: 32
|
||||
}}
|
||||
>
|
||||
Subtitle
|
||||
</Interactive.Div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Maps
|
||||
|
||||
See [Remotion Maps](remotion-maps/REFERENCE.md) for choosing a map technique.
|
||||
|
||||
## Text highlights and annotations
|
||||
|
||||
See [text-highlights.md](text-highlights.md) for text highlights (highlight markers), circles, underlines, strike-throughs, crossed-off text, boxes, and brackets.
|
||||
|
||||
## Voiceover
|
||||
|
||||
See [voiceover.md](voiceover.md) for adding AI-generated voiceover to Remotion compositions using ElevenLabs TTS.
|
||||
|
||||
## Trimming
|
||||
|
||||
See [trimming.md](trimming.md) for trimming patterns - cutting the beginning or end of animations.
|
||||
|
||||
## Embedding Videos
|
||||
|
||||
See [embedding-videos.md](embedding-videos.md) for advanced knowledge about embedding videos - trimming, volume, speed, looping, pitch.
|
||||
|
||||
## Video editing
|
||||
|
||||
See [video-editing.md](video-editing.md) for structuring editable video timelines in Remotion Studio.
|
||||
|
||||
## Embedding Audio
|
||||
|
||||
See [audio.md](audio.md) for advanced audio features like trimming, volume, speed, pitch.
|
||||
|
||||
## Transitions
|
||||
|
||||
See [transitions.md](transitions.md) for scene transition patterns.
|
||||
|
||||
## Visual and pixel effects
|
||||
|
||||
When creating a visual effect, consider whether it is feasible using CSS and HTML, or whether a shader is needed. Order or preference:
|
||||
|
||||
1. Normal Remotion/HTML/CSS/SVG/filter/blend/mask animation
|
||||
2. An effect applied to the element directly (`<Video>`, `<Img>`), or by wrapping the content in [`<HtmlInCanvas>`](html-in-canvas.md), which also accepts `effects`:
|
||||
|
||||
- A listed effect via [effects.md](effects.md)
|
||||
- A custom `createEffect()` via [effects.md](effects.md) when no preset is available.
|
||||
|
||||
## 3D content
|
||||
|
||||
See [3d.md](3d.md) for 3D content in Remotion using Three.js and React Three Fiber.
|
||||
|
||||
## Sound effects
|
||||
|
||||
When needing to use sound effects, load the [./sfx.md](./sfx.md) file for more information.
|
||||
|
||||
## Audio visualization
|
||||
|
||||
When needing to visualize audio (spectrum bars, waveforms, bass-reactive effects), load the [./audio-visualization.md](./audio-visualization.md) file for more information.
|
||||
|
||||
## Maps
|
||||
|
||||
For static maps, animated routes and markers, geographic explainers, Mapbox, MapLibre, MapTiler, GeoJSON, or 3D geographic flyovers, load [Remotion Maps](remotion-maps/REFERENCE.md).
|
||||
|
||||
## Captions
|
||||
|
||||
When dealing with captions or subtitles, load the [Remotion Captions](../remotion-captions/REFERENCE.md) skill for more information.
|
||||
|
||||
## Google Fonts
|
||||
|
||||
Is the recommended way to load fonts in Remotion. See [google-fonts.md](google-fonts.md) for how to load Google Fonts.
|
||||
|
||||
## Local fonts
|
||||
|
||||
See [local-fonts.md](local-fonts.md) for how to load local fonts.
|
||||
|
||||
## GIFs
|
||||
|
||||
See [gifs.md](gifs.md) for how to display GIFs synchronized with Remotion's timeline.
|
||||
|
||||
## Advanced Images
|
||||
|
||||
See [images.md](images.md) for sizing and positioning images, dynamic image paths, and getting image dimensions.
|
||||
|
||||
## Lottie animations
|
||||
|
||||
See [lottie.md](lottie.md) for embedding Lottie animations in Remotion.
|
||||
|
||||
## Advanced timing
|
||||
|
||||
See [timing.md](timing.md) for advanced timing with `interpolate` and Bézier easing, and springs.
|
||||
|
||||
## Parameterized videos
|
||||
|
||||
See [parameters.md](parameters.md) for making a composition parametrizable by adding a Zod schema.
|
||||
|
||||
## Measuring DOM nodes
|
||||
|
||||
See [measuring-dom-nodes.md](measuring-dom-nodes.md) for measuring DOM element dimensions in Remotion.
|
||||
|
||||
## Measuring text
|
||||
|
||||
See [measuring-text.md](measuring-text.md) for measuring text dimensions, fitting text to containers, and checking overflow.
|
||||
|
||||
## Using FFmpeg
|
||||
|
||||
For some video operations, such as trimming videos or detecting silence, FFmpeg should be used. Load the [./ffmpeg.md](./ffmpeg.md) file for more information.
|
||||
|
||||
## Silence detection
|
||||
|
||||
When needing to detect and trim silent segments from video or audio files, load the [./silence-detection.md](./silence-detection.md) file.
|
||||
|
||||
## Dynamic duration, dimensions and data
|
||||
|
||||
See [calculate-metadata.md](calculate-metadata.md) for dynamically set composition duration, dimensions, and props.
|
||||
|
||||
## Advanced compositions
|
||||
|
||||
See [compositions.md](compositions.md) for how to define stills, folders, default props and for how to nest compositions.
|
||||
|
||||
## Advanced sequencing
|
||||
|
||||
See [sequencing.md](sequencing.md) for more sequencing patterns - delay, trim, limit duration of items.
|
||||
|
||||
## Install modules
|
||||
|
||||
Use `npx remotion add` to add new packages with the right version:
|
||||
|
||||
```
|
||||
npx remotion add @remotion/media
|
||||
```
|
||||
|
||||
This goes for `@remotion/*` packages, `mediabunny`, `@mediabunny/*`, and `zod`.
|
||||
|
||||
## Previewing markup
|
||||
|
||||
This will start a long-running process and print the server URL for the preview.
|
||||
If server is already started, it will print the URL.
|
||||
You can visit a specific composition by navigating to `/[composition-id]`, for example `http://localhost:3000/MapAnimation`.
|
||||
|
||||
## Optional: one-frame render check
|
||||
|
||||
You can render a single frame with the CLI to sanity-check layout, colors, or timing.
|
||||
Skip it for trivial edits, pure refactors, or when you already have enough confidence from Studio or prior renders.
|
||||
|
||||
```bash
|
||||
npx remotion still [composition-id] --scale=0.25 --frame=30
|
||||
```
|
||||
|
||||
At 30 fps, `--frame=30` is the one-second mark (`--frame` is zero-based).
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
name: audio-visualization
|
||||
description: Audio visualization patterns - spectrum bars, waveforms, bass-reactive effects
|
||||
metadata:
|
||||
tags: audio, visualization, spectrum, waveform, bass, music, audiogram, frequency
|
||||
---
|
||||
|
||||
# Audio Visualization in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/media-utils
|
||||
```
|
||||
|
||||
## Loading Audio Data
|
||||
|
||||
Use `useWindowedAudioData()` (https://www.remotion.dev/docs/use-windowed-audio-data) to load audio data:
|
||||
|
||||
```tsx
|
||||
import { useWindowedAudioData } from "@remotion/media-utils";
|
||||
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
|
||||
src: staticFile("podcast.wav"),
|
||||
frame,
|
||||
fps,
|
||||
windowInSeconds: 30,
|
||||
});
|
||||
```
|
||||
|
||||
## Spectrum Bar Visualization
|
||||
|
||||
Use `visualizeAudio()` (https://www.remotion.dev/docs/visualize-audio) to get frequency data for bar charts:
|
||||
|
||||
```tsx
|
||||
import { useWindowedAudioData, visualizeAudio } from "@remotion/media-utils";
|
||||
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
|
||||
src: staticFile("music.mp3"),
|
||||
frame,
|
||||
fps,
|
||||
windowInSeconds: 30,
|
||||
});
|
||||
|
||||
if (!audioData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frequencies = visualizeAudio({
|
||||
fps,
|
||||
frame,
|
||||
audioData,
|
||||
numberOfSamples: 256,
|
||||
optimizeFor: "speed",
|
||||
dataOffsetInSeconds,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "flex-end", height: 200 }}>
|
||||
{frequencies.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: `${v * 100}%`,
|
||||
backgroundColor: "#0b84f3",
|
||||
margin: "0 1px",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
- `numberOfSamples` must be power of 2 (32, 64, 128, 256, 512, 1024)
|
||||
- Values range 0-1; left of array = bass, right = highs
|
||||
- Use `optimizeFor: "speed"` for Lambda or high sample counts
|
||||
|
||||
**Important:** When passing `audioData` to child components, also pass the `frame` from the parent. Do not call `useCurrentFrame()` in each child - this causes discontinuous visualization when children are inside `<Sequence>` with offsets.
|
||||
|
||||
## Waveform Visualization
|
||||
|
||||
Use `visualizeAudioWaveform()` (https://www.remotion.dev/docs/media-utils/visualize-audio-waveform) with `createSmoothSvgPath()` (https://www.remotion.dev/docs/media-utils/create-smooth-svg-path) for oscilloscope-style displays:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createSmoothSvgPath,
|
||||
useWindowedAudioData,
|
||||
visualizeAudioWaveform,
|
||||
} from "@remotion/media-utils";
|
||||
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const { width, fps } = useVideoConfig();
|
||||
const HEIGHT = 200;
|
||||
|
||||
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
|
||||
src: staticFile("voice.wav"),
|
||||
frame,
|
||||
fps,
|
||||
windowInSeconds: 30,
|
||||
});
|
||||
|
||||
if (!audioData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const waveform = visualizeAudioWaveform({
|
||||
fps,
|
||||
frame,
|
||||
audioData,
|
||||
numberOfSamples: 256,
|
||||
windowInSeconds: 0.5,
|
||||
dataOffsetInSeconds,
|
||||
});
|
||||
|
||||
const path = createSmoothSvgPath({
|
||||
points: waveform.map((y, i) => ({
|
||||
x: (i / (waveform.length - 1)) * width,
|
||||
y: HEIGHT / 2 + (y * HEIGHT) / 2,
|
||||
})),
|
||||
});
|
||||
|
||||
return (
|
||||
<svg width={width} height={HEIGHT}>
|
||||
<path d={path} fill="none" stroke="#0b84f3" strokeWidth={2} />
|
||||
</svg>
|
||||
);
|
||||
```
|
||||
|
||||
## Bass-Reactive Effects
|
||||
|
||||
Extract low frequencies for beat-reactive animations:
|
||||
|
||||
```tsx
|
||||
const frequencies = visualizeAudio({
|
||||
fps,
|
||||
frame,
|
||||
audioData,
|
||||
numberOfSamples: 128,
|
||||
optimizeFor: "speed",
|
||||
dataOffsetInSeconds,
|
||||
});
|
||||
|
||||
const lowFrequencies = frequencies.slice(0, 32);
|
||||
const bassIntensity =
|
||||
lowFrequencies.reduce((sum, v) => sum + v, 0) / lowFrequencies.length;
|
||||
|
||||
const scale = 1 + bassIntensity * 0.5;
|
||||
const opacity = Math.min(0.6, bassIntensity * 0.8);
|
||||
```
|
||||
|
||||
## Volume-Based Waveform
|
||||
|
||||
Use `getWaveformPortion()` (https://www.remotion.dev/docs/get-waveform-portion) when you need simplified volume data instead of frequency spectrum:
|
||||
|
||||
```tsx
|
||||
import { getWaveformPortion } from "@remotion/media-utils";
|
||||
import { useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const currentTimeInSeconds = frame / fps;
|
||||
|
||||
const waveform = getWaveformPortion({
|
||||
audioData,
|
||||
startTimeInSeconds: currentTimeInSeconds,
|
||||
durationInSeconds: 5,
|
||||
numberOfSamples: 50,
|
||||
});
|
||||
|
||||
// Returns array of { index, amplitude } objects (amplitude: 0-1)
|
||||
waveform.map((bar) => (
|
||||
<div key={bar.index} style={{ height: bar.amplitude * 100 }} />
|
||||
));
|
||||
```
|
||||
|
||||
## Postprocessing
|
||||
|
||||
Low frequencies naturally dominate. Apply logarithmic scaling for visual balance:
|
||||
|
||||
```tsx
|
||||
const minDb = -100;
|
||||
const maxDb = -30;
|
||||
|
||||
const scaled = frequencies.map((value) => {
|
||||
const db = 20 * Math.log10(value);
|
||||
return (db - minDb) / (maxDb - minDb);
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: audio
|
||||
description: Using audio and sound in Remotion - importing, trimming, volume, speed, pitch
|
||||
metadata:
|
||||
tags: audio, media, trim, volume, speed, loop, pitch, mute, sound, sfx
|
||||
---
|
||||
|
||||
# Using audio in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/media package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/media
|
||||
```
|
||||
|
||||
## Importing Audio
|
||||
|
||||
Use `<Audio>` from `@remotion/media` to add audio to your composition.
|
||||
|
||||
```tsx
|
||||
import { Audio } from "@remotion/media";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <Audio src={staticFile("audio.mp3")} />;
|
||||
};
|
||||
```
|
||||
|
||||
Remote URLs are also supported:
|
||||
|
||||
```tsx
|
||||
<Audio src="https://remotion.media/audio.mp3" />
|
||||
```
|
||||
|
||||
By default, audio plays from the start, at full volume and full length.
|
||||
Multiple audio tracks can be layered by adding multiple `<Audio>` components.
|
||||
|
||||
## Trimming
|
||||
|
||||
Use `trimBefore` and `trimAfter` to remove portions of the audio. Values are in frames.
|
||||
|
||||
```tsx
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
trimBefore={2 * fps} // Skip the first 2 seconds
|
||||
trimAfter={10 * fps} // End at the 10 second mark
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
The audio still starts playing at the beginning of the composition - only the specified portion is played.
|
||||
|
||||
## Delaying
|
||||
|
||||
Wrap the audio in a `<Sequence>` to delay when it starts:
|
||||
|
||||
```tsx
|
||||
import { Sequence, staticFile } from "remotion";
|
||||
import { Audio } from "@remotion/media";
|
||||
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Sequence from={1 * fps}>
|
||||
<Audio src={staticFile("audio.mp3")} />
|
||||
</Sequence>
|
||||
);
|
||||
```
|
||||
|
||||
The audio will start playing after 1 second.
|
||||
|
||||
## Volume
|
||||
|
||||
Set a static volume (0 to 1):
|
||||
|
||||
```tsx
|
||||
<Audio src={staticFile("audio.mp3")} volume={0.5} />
|
||||
```
|
||||
|
||||
Or use a callback for dynamic volume based on the current frame:
|
||||
|
||||
```tsx
|
||||
import { interpolate } from "remotion";
|
||||
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
volume={(f) =>
|
||||
interpolate(f, [0, 1 * fps], [0, 1], { extrapolateRight: "clamp" })
|
||||
}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
The value of `f` starts at 0 when the audio begins to play, not the composition frame.
|
||||
|
||||
## Muting
|
||||
|
||||
Use `muted` to silence the audio. It can be set dynamically:
|
||||
|
||||
```tsx
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
muted={frame >= 2 * fps && frame <= 4 * fps} // Mute between 2s and 4s
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## Speed
|
||||
|
||||
Use `playbackRate` to change the playback speed:
|
||||
|
||||
```tsx
|
||||
<Audio src={staticFile("audio.mp3")} playbackRate={2} /> {/* 2x speed */}
|
||||
<Audio src={staticFile("audio.mp3")} playbackRate={0.5} /> {/* Half speed */}
|
||||
```
|
||||
|
||||
Reverse playback is not supported.
|
||||
|
||||
## Looping
|
||||
|
||||
Use `loop` to loop the audio indefinitely:
|
||||
|
||||
```tsx
|
||||
<Audio src={staticFile("audio.mp3")} loop />
|
||||
```
|
||||
|
||||
Use `loopVolumeCurveBehavior` to control how the frame count behaves when looping:
|
||||
|
||||
- `"repeat"`: Frame count resets to 0 each loop (default)
|
||||
- `"extend"`: Frame count continues incrementing
|
||||
|
||||
```tsx
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
loop
|
||||
loopVolumeCurveBehavior="extend"
|
||||
volume={(f) => interpolate(f, [0, 300], [1, 0])} // Fade out over multiple loops
|
||||
/>
|
||||
```
|
||||
|
||||
## Pitch
|
||||
|
||||
Use `toneFrequency` to adjust the pitch without affecting speed. Values range from 0.01 to 2:
|
||||
|
||||
```tsx
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
toneFrequency={1.5} // Higher pitch
|
||||
/>
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
toneFrequency={0.8} // Lower pitch
|
||||
/>
|
||||
```
|
||||
|
||||
Pitch shifting only works during server-side rendering, not in the Remotion Studio preview or in the `<Player />`.
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
name: calculate-metadata
|
||||
description: Dynamically set composition duration, dimensions, and props
|
||||
metadata:
|
||||
tags: calculateMetadata, duration, dimensions, props, dynamic
|
||||
---
|
||||
|
||||
# Using calculateMetadata
|
||||
|
||||
Use `calculateMetadata` on a `<Composition>` to dynamically set duration, dimensions, and transform props before rendering.
|
||||
Use it when metadata depends on input props, fetched data, or asset metadata.
|
||||
For static dimensions, duration, FPS, and initial props, inline the values on `<Composition>` instead.
|
||||
|
||||
```tsx
|
||||
<Composition
|
||||
id="MyComp"
|
||||
component={MyComponent}
|
||||
durationInFrames={300}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{ videoSrc: "https://remotion.media/video.mp4" }}
|
||||
calculateMetadata={calculateMetadata}
|
||||
/>
|
||||
```
|
||||
|
||||
## Setting duration based on a video
|
||||
|
||||
Use the [`getVideoDuration`](../remotion-multimedia/get-video-duration.md) and [`getVideoDimensions`](../remotion-multimedia/get-video-dimensions.md) skills to get the video duration and dimensions:
|
||||
|
||||
```tsx
|
||||
import { CalculateMetadataFunction } from "remotion";
|
||||
import { getVideoDuration } from "./get-video-duration";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
const durationInSeconds = await getVideoDuration(props.videoSrc);
|
||||
|
||||
return {
|
||||
durationInFrames: Math.ceil(durationInSeconds * 30),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Matching dimensions of a video
|
||||
|
||||
Use the [`getVideoDimensions`](../remotion-multimedia/get-video-dimensions.md) skill to get the video dimensions:
|
||||
|
||||
```tsx
|
||||
import { CalculateMetadataFunction } from "remotion";
|
||||
import { getVideoDuration } from "./get-video-duration";
|
||||
import { getVideoDimensions } from "./get-video-dimensions";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
const dimensions = await getVideoDimensions(props.videoSrc);
|
||||
|
||||
return {
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Setting duration based on multiple videos
|
||||
|
||||
```tsx
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
const metadataPromises = props.videos.map((video) =>
|
||||
getVideoDuration(video.src),
|
||||
);
|
||||
const allMetadata = await Promise.all(metadataPromises);
|
||||
|
||||
const totalDuration = allMetadata.reduce(
|
||||
(sum, durationInSeconds) => sum + durationInSeconds,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
durationInFrames: Math.ceil(totalDuration * 30),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Setting a default outName
|
||||
|
||||
Set the default output filename based on props:
|
||||
|
||||
```tsx
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
return {
|
||||
defaultOutName: `video-${props.id}`, // .mp4 is added automatically
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Transforming props
|
||||
|
||||
Fetch data or transform props before rendering:
|
||||
|
||||
```tsx
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
abortSignal,
|
||||
}) => {
|
||||
const response = await fetch(props.dataUrl, { signal: abortSignal });
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
props: {
|
||||
...props,
|
||||
fetchedData: data,
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
The `abortSignal` cancels stale requests when props change in the Studio.
|
||||
|
||||
## Return value
|
||||
|
||||
All fields are optional. Returned values override the `<Composition>` props:
|
||||
|
||||
- `durationInFrames`: Number of frames
|
||||
- `width`: Composition width in pixels
|
||||
- `height`: Composition height in pixels
|
||||
- `fps`: Frames per second
|
||||
- `props`: Transformed props passed to the component
|
||||
- `defaultOutName`: Default output filename
|
||||
- `defaultCodec`: Default codec for rendering
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
name: compositions
|
||||
description: Defining compositions, stills, folders, default props and dynamic metadata
|
||||
metadata:
|
||||
tags: composition, still, folder, props, metadata
|
||||
---
|
||||
|
||||
A `<Composition>` defines the component, width, height, fps and duration of a renderable video.
|
||||
|
||||
## Default Props and scaffold metadata
|
||||
|
||||
Pass `defaultProps` to provide initial values for your component.
|
||||
Values must be JSON-serializable (`Date`, `Map`, `Set`, and `staticFile()` are supported).
|
||||
Use `defaultProps` for composition-wide values that should be visible and editable before the video renders.
|
||||
|
||||
For Studio editing, keep `defaultProps` as an inline object literal on `<Composition>` or `<Still>`.
|
||||
Do not store it in a variable, import it, spread it, create it with a helper, or wrap it in `satisfies`.
|
||||
When scaffolding, keep the component and `<Composition>` registration in the same file so `width`, `height`, `fps`, `durationInFrames`, and `defaultProps` are visible next to the code that uses them.
|
||||
Use `type` declarations for props rather than `interface` to ensure `defaultProps` type safety.
|
||||
|
||||
```tsx
|
||||
type Props = {
|
||||
readonly title: string;
|
||||
};
|
||||
|
||||
export const MyComposition = ({ title }: Props) => <h1>{title}</h1>;
|
||||
|
||||
const defaultProps = { title: "Hello World" };
|
||||
|
||||
// 👍 Inline metadata and defaults
|
||||
<Composition
|
||||
id="MyComposition"
|
||||
component={MyComposition}
|
||||
durationInFrames={100}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1080}
|
||||
defaultProps={{ title: "Hello World" }}
|
||||
/>;
|
||||
|
||||
// 👎 Hidden defaults cannot be saved back by Studio
|
||||
<Composition
|
||||
id="OtherComposition"
|
||||
component={MyComposition}
|
||||
durationInFrames={100}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1080}
|
||||
defaultProps={defaultProps}
|
||||
/>;
|
||||
```
|
||||
|
||||
## Folders
|
||||
|
||||
Use `<Folder>` to organize compositions in the sidebar.
|
||||
Folder names can only contain letters, numbers, and hyphens.
|
||||
|
||||
```tsx
|
||||
import { Composition, Folder } from "remotion";
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<>
|
||||
<Folder name="Marketing">
|
||||
<Composition id="Promo" /* ... */ />
|
||||
<Composition id="Ad" /* ... */ />
|
||||
</Folder>
|
||||
<Folder name="Social">
|
||||
<Folder name="Instagram">
|
||||
<Composition id="Story" /* ... */ />
|
||||
<Composition id="Reel" /* ... */ />
|
||||
</Folder>
|
||||
</Folder>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Stills
|
||||
|
||||
Use `<Still>` for single-frame images. It does not require `durationInFrames` or `fps`.
|
||||
|
||||
```tsx
|
||||
import { Still } from "remotion";
|
||||
import { Thumbnail } from "./Thumbnail";
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<Still id="Thumbnail" component={Thumbnail} width={1280} height={720} />
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Dynamic duration, width, and height
|
||||
|
||||
Use [`calculateMetadata`](./calculate-metadata.md) to make dimensions, duration, or props dynamic based on input props, fetched data, or asset metadata.
|
||||
|
||||
## Nesting compositions within another
|
||||
|
||||
To add a composition within another composition, you can use the `<Sequence>` component with a `width` and `height` prop to specify the size of the composition.
|
||||
|
||||
```tsx
|
||||
<AbsoluteFill>
|
||||
<Sequence width={COMPOSITION_WIDTH} height={COMPOSITION_HEIGHT}>
|
||||
<CompositionComponent />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
# Cropping
|
||||
|
||||
Preferably, the `cropLeft`, `cropRight`, `cropTop` and `cropBottom` props are used to crop content.
|
||||
It allows for interactively dragging the components and adapting the outlines in the canvas to the crop.
|
||||
|
||||
The following components support `crop*` props:
|
||||
|
||||
- `<Sequence>` from `remotion`, when `layout="absolute-fill"`
|
||||
- `<CanvasImage>` from `remotion`
|
||||
- `<Img>` from `remotion`
|
||||
- `<AnimatedImage>` from `remotion`
|
||||
- `<HtmlInCanvas>` from `remotion`
|
||||
- `<Solid>` from `remotion`
|
||||
- `<Video>` from `@remotion/media`
|
||||
- `<Gif>` from `@remotion/gif`
|
||||
- `<RemotionRiveCanvas>` from `@remotion/rive`
|
||||
|
||||
Crop values are ratios between `0` and `1`.
|
||||
A value of `0` applies no crop on that edge.
|
||||
A value of `1` is a full crop.
|
||||
Keep [Interactivity Best Practices](../remotion-interactivity/REFERENCE.md) also for cropping, to keep it editable and keyframable.
|
||||
|
||||
```tsx
|
||||
<CanvasImage
|
||||
src={staticFile("photo.png")}
|
||||
cropLeft={interpolate(frame, [0, 30], [0, 0.25], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
})}
|
||||
cropBottom={0.1}
|
||||
/>
|
||||
```
|
||||
|
||||
Do not use `clipPath` together with crop props.
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
name: effects
|
||||
description: Canvas/WebGL visual effects for Remotion using effects arrays and createEffect().
|
||||
metadata:
|
||||
tags: effects, visual-effects, webgl, canvas, video, create-effect
|
||||
---
|
||||
|
||||
Use this rule only when the top-level skill lists an effect that matches the requested look, or when the user asks to create a reusable custom effect.
|
||||
|
||||
Docs: https://www.remotion.dev/docs/effects
|
||||
Custom effect docs: https://www.remotion.dev/docs/create-effect
|
||||
|
||||
## Usage
|
||||
|
||||
Install the package that provides the chosen effect:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/effects
|
||||
```
|
||||
|
||||
Effects are functions passed to the `effects` prop of canvas-based components such as `<Video>` from `@remotion/media`, `<Solid>`, `<CanvasImage>`, and `<HtmlInCanvas>`.
|
||||
|
||||
```tsx
|
||||
import {Video} from '@remotion/media';
|
||||
import {blur} from '@remotion/effects/blur';
|
||||
|
||||
<Video src="https://remotion.media/video.mp4" effects={[blur({radius: 8})]} />;
|
||||
```
|
||||
|
||||
Use the effect docs for exact props and imports. Most `@remotion/effects` imports use `@remotion/effects/<effect-slug>`; `uvTranslate()` and `xyTranslate()` use `@remotion/effects/translate`.
|
||||
|
||||
These effects use WebGL2. During renders, enable WebGL with:
|
||||
|
||||
```ts
|
||||
import {Config} from '@remotion/cli/config';
|
||||
|
||||
Config.setChromiumOpenGlRenderer('angle');
|
||||
```
|
||||
|
||||
## Available effects
|
||||
|
||||
`brightness()`, `contrast()`, `colorKey()`, `duotone()`, `grayscale()`, `hue()`, `invert()`, `saturation()`, `tint()`, `linearGradient()`, `linearGradientTint()`, `thermalVision()`, `blur()`, `linearProgressiveBlur()`, `radialProgressiveBlur()`, `zoomBlur()`, `dropShadow()`, `glow()`, `lightTrail()`, `evolve()`, `venetianBlinds()`, `mirror()`, `scale()`, `uvTranslate()`, `xyTranslate()`, `barrelDistortion()`, `chromaticAberration()`, `fisheye()`, `cornerPin()`, `wave()`, `burlap()`, `emboss()`, `dotGrid()`, `halftone()`, `noise()`, `noiseDisplacement()`, `paper()`, `roughenEdges()`, `pattern()`, `pixelate()`, `pixelDissolve()`, `scanlines()`, `speckle()`, `shine()`, `shrinkwrap()`, `vignette()`, `contourLines()`, `checkerboard()`, `halftoneLinearGradient()`, `gridlines()`, `whiteNoise()`, `tvSignalOff()`, `lines()`, `rings()`, `waves()`, `zigzag()`, `lightLeak()`, `starburst()`.
|
||||
|
||||
Example:
|
||||
|
||||
```tsx
|
||||
import {brightness} from "@remotion/effects";
|
||||
|
||||
<Video src="https://remotion.media/video.mp4" effects={[brightness({})]} />;
|
||||
```
|
||||
|
||||
## Custom effects
|
||||
|
||||
Use `createEffect()` from `remotion` when the user wants a reusable effect factory that works in the same `effects` array as `@remotion/effects`.
|
||||
|
||||
Prefer a custom effect over `<HtmlInCanvas onPaint>` when the transformation should be reusable, parameterized, editable in Studio, or stackable with other effects.
|
||||
|
||||
For quick project-specific effects, keep the effect next to the composition, for example `src/effects/palette-map.ts`. For library effects intended for `@remotion/effects`, follow the repository's `add-effect` skill instead.
|
||||
|
||||
`createEffect()` expects:
|
||||
|
||||
- `type`: stable reverse-DNS identifier, for example `com.example.paletteMap`.
|
||||
- `label`: Studio label, commonly `paletteMap()`.
|
||||
- `documentationLink`: URL or `null`.
|
||||
- `backend`: `"2d"`, `"webgl2"` or `"webgpu"`.
|
||||
- `calculateKey(params)`: stable string containing every resolved parameter that changes output.
|
||||
- `setup(target)`: create reusable backend state, or return `null`.
|
||||
- `apply({source, target, width, height, params, state, flipSourceY})`: draw the transformed result into `target`.
|
||||
- `cleanup(state)`: free resources created by `setup()`.
|
||||
- `schema`: an `InteractivitySchema` for Studio controls. `disabled` is added automatically.
|
||||
- `validateParams(params)`: throw on missing or invalid values.
|
||||
|
||||
Use `backend: "2d"` for simple pixel, filter, drawImage, or image-data effects. Use WebGL2 only when shader math or GPU performance is needed; during renders, enable WebGL as shown above.
|
||||
|
||||
```ts
|
||||
import {createEffect, type InteractivitySchema} from 'remotion';
|
||||
|
||||
type MyEffectParams = {
|
||||
readonly amount?: number;
|
||||
};
|
||||
|
||||
const myEffectSchema = {
|
||||
amount: {
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
default: 1,
|
||||
description: 'Amount',
|
||||
},
|
||||
} as const satisfies InteractivitySchema;
|
||||
|
||||
const resolve = (params: MyEffectParams) => ({
|
||||
amount: params.amount ?? 1,
|
||||
});
|
||||
|
||||
export const myEffect = createEffect<MyEffectParams, null>({
|
||||
type: 'com.example.myEffect',
|
||||
label: 'myEffect()',
|
||||
documentationLink: null,
|
||||
backend: '2d',
|
||||
calculateKey: (params) => {
|
||||
const {amount} = resolve(params);
|
||||
return `my-effect-${amount}`;
|
||||
},
|
||||
setup: () => null,
|
||||
apply: ({source, target, width, height, params}) => {
|
||||
const ctx = target.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Could not get a 2D context for myEffect().');
|
||||
}
|
||||
|
||||
const {amount} = resolve(params);
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.filter = `opacity(${amount * 100}%)`;
|
||||
ctx.drawImage(source, 0, 0, width, height);
|
||||
ctx.filter = 'none';
|
||||
},
|
||||
cleanup: () => undefined,
|
||||
schema: myEffectSchema,
|
||||
validateParams: ({amount = 1}) => {
|
||||
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0 || amount > 1) {
|
||||
throw new TypeError('amount must be a number between 0 and 1');
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
For a WebGL2 effect, compile/link shaders in `setup()`, keep the program, fullscreen quad, texture, and uniform locations in state, upload `source` in `apply()`, and free GPU resources in `cleanup()`. Minimal shape:
|
||||
|
||||
```ts
|
||||
import {createEffect, type InteractivitySchema} from 'remotion';
|
||||
|
||||
type RgbShiftParams = {
|
||||
readonly amount?: number;
|
||||
};
|
||||
|
||||
type RgbShiftState = {
|
||||
readonly gl: WebGL2RenderingContext;
|
||||
readonly program: WebGLProgram;
|
||||
readonly vao: WebGLVertexArrayObject;
|
||||
readonly vbo: WebGLBuffer;
|
||||
readonly texture: WebGLTexture;
|
||||
readonly uSource: WebGLUniformLocation | null;
|
||||
readonly uOffset: WebGLUniformLocation | null;
|
||||
};
|
||||
|
||||
const rgbShiftSchema = {
|
||||
amount: {
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 80,
|
||||
step: 1,
|
||||
default: 12,
|
||||
description: 'Amount',
|
||||
},
|
||||
} as const satisfies InteractivitySchema;
|
||||
|
||||
export const rgbShift = createEffect<RgbShiftParams, RgbShiftState>({
|
||||
type: 'com.example.rgbShift',
|
||||
label: 'rgbShift()',
|
||||
documentationLink: null,
|
||||
backend: 'webgl2',
|
||||
calculateKey: ({amount = 12}) => `rgb-shift-${amount}`,
|
||||
setup: (target) => {
|
||||
const gl = target.getContext('webgl2', {
|
||||
premultipliedAlpha: true,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
if (!gl) {
|
||||
throw new Error('Could not get a WebGL2 context for rgbShift().');
|
||||
}
|
||||
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
|
||||
|
||||
// Compile/link shaders, create a fullscreen quad VAO/VBO, create a
|
||||
// CLAMP_TO_EDGE RGBA texture, and get uSource/uOffset uniform locations.
|
||||
return createRgbShiftState(gl);
|
||||
},
|
||||
apply: ({source, width, height, params, state, flipSourceY}) => {
|
||||
const amount = params.amount ?? 12;
|
||||
const {gl} = state;
|
||||
|
||||
gl.viewport(0, 0, width, height);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipSourceY);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, state.texture);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
source as TexImageSource,
|
||||
);
|
||||
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.useProgram(state.program);
|
||||
if (state.uSource) gl.uniform1i(state.uSource, 0);
|
||||
if (state.uOffset) gl.uniform2f(state.uOffset, amount / width, 0);
|
||||
gl.bindVertexArray(state.vao);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
},
|
||||
cleanup: ({gl, program, vao, vbo, texture}) => {
|
||||
gl.deleteTexture(texture);
|
||||
gl.deleteBuffer(vbo);
|
||||
gl.deleteProgram(program);
|
||||
gl.deleteVertexArray(vao);
|
||||
},
|
||||
schema: rgbShiftSchema,
|
||||
validateParams: ({amount = 12}) => {
|
||||
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0 || amount > 80) {
|
||||
throw new TypeError('amount must be a number between 0 and 80');
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
For a complete 2D and WebGL2 pair, see `packages/example/src/EffectsTestbed/sample-posterize-2d.ts` and `packages/example/src/EffectsTestbed/sample-rgb-shift-webgl.ts`.
|
||||
|
||||
Use the returned factory in an `effects` array:
|
||||
|
||||
```tsx
|
||||
import {CanvasImage, staticFile} from 'remotion';
|
||||
import {myEffect} from './effects/my-effect';
|
||||
|
||||
export const MyComp: React.FC = () => {
|
||||
return (
|
||||
<CanvasImage
|
||||
src={staticFile('image.png')}
|
||||
effects={[myEffect({amount: 0.8})]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
When generating a custom effect, also:
|
||||
|
||||
- Include `disabled?: boolean` only through the returned factory; do not add it to the custom params type or schema.
|
||||
- Validate required parameters at factory-call time with `validateParams`.
|
||||
- Include all defaults in both `schema` and the `resolve()` helper.
|
||||
- Reset mutable 2D context state such as `filter`, `globalAlpha`, transforms, and compositing after drawing.
|
||||
- Preserve alpha unless the requested effect intentionally changes transparency.
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
name: embedding-videos
|
||||
description: Embedding videos in Remotion - trimming, volume, speed, looping, pitch
|
||||
metadata:
|
||||
tags: video, media, trim, volume, speed, loop, pitch
|
||||
---
|
||||
|
||||
# Using videos in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/media package needs to be installed.
|
||||
If it is not, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/media # If project uses npm
|
||||
bunx remotion add @remotion/media # If project uses bun
|
||||
yarn remotion add @remotion/media # If project uses yarn
|
||||
pnpm exec remotion add @remotion/media # If project uses pnpm
|
||||
```
|
||||
|
||||
Use `<Video>` from `@remotion/media` to embed videos into your composition.
|
||||
|
||||
```tsx
|
||||
import { Video } from "@remotion/media";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <Video src={staticFile("video.mp4")} />;
|
||||
};
|
||||
```
|
||||
|
||||
Remote URLs are also supported:
|
||||
|
||||
```tsx
|
||||
<Video src="https://remotion.media/video.mp4" />
|
||||
```
|
||||
|
||||
## Trimming
|
||||
|
||||
Use `trimBefore` and `trimAfter` to remove portions of the video. Values are in seconds.
|
||||
|
||||
```tsx
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
trimBefore={2 * fps} // Skip the first 2 seconds
|
||||
trimAfter={10 * fps} // End at the 10 second mark
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## Delaying
|
||||
|
||||
Wrap the video in a `<Sequence>` to delay when it appears:
|
||||
|
||||
```tsx
|
||||
import { Sequence, staticFile } from "remotion";
|
||||
import { Video } from "@remotion/media";
|
||||
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Sequence from={1 * fps}>
|
||||
<Video src={staticFile("video.mp4")} />
|
||||
</Sequence>
|
||||
);
|
||||
```
|
||||
|
||||
The video will appear after 1 second.
|
||||
|
||||
## Sizing and Position
|
||||
|
||||
Use the `style` prop to control size and position:
|
||||
|
||||
```tsx
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
style={{
|
||||
width: 500,
|
||||
height: 300,
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 50,
|
||||
}}
|
||||
objectFit="cover"
|
||||
/>
|
||||
```
|
||||
|
||||
## Volume
|
||||
|
||||
Set a static volume (0 to 1):
|
||||
|
||||
```tsx
|
||||
<Video src={staticFile("video.mp4")} volume={0.5} />
|
||||
```
|
||||
|
||||
Or use a callback for dynamic volume based on the current frame:
|
||||
|
||||
```tsx
|
||||
import { interpolate } from "remotion";
|
||||
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
volume={(f) =>
|
||||
interpolate(f, [0, 1 * fps], [0, 1], { extrapolateRight: "clamp" })
|
||||
}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
Use `muted` to silence the video entirely:
|
||||
|
||||
```tsx
|
||||
<Video src={staticFile("video.mp4")} muted />
|
||||
```
|
||||
|
||||
## Speed
|
||||
|
||||
Use `playbackRate` to change the playback speed:
|
||||
|
||||
```tsx
|
||||
<Video src={staticFile("video.mp4")} playbackRate={2} /> {/* 2x speed */}
|
||||
<Video src={staticFile("video.mp4")} playbackRate={0.5} /> {/* Half speed */}
|
||||
```
|
||||
|
||||
Reverse playback is not supported.
|
||||
|
||||
## Looping
|
||||
|
||||
Use `loop` to loop the video indefinitely:
|
||||
|
||||
```tsx
|
||||
<Video src={staticFile("video.mp4")} loop />
|
||||
```
|
||||
|
||||
Use `loopVolumeCurveBehavior` to control how the frame count behaves when looping:
|
||||
|
||||
- `"repeat"`: Frame count resets to 0 each loop (for `volume` callback)
|
||||
- `"extend"`: Frame count continues incrementing
|
||||
|
||||
```tsx
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
loop
|
||||
loopVolumeCurveBehavior="extend"
|
||||
volume={(f) => interpolate(f, [0, 300], [1, 0])} // Fade out over multiple loops
|
||||
/>
|
||||
```
|
||||
|
||||
## Pitch
|
||||
|
||||
Use `toneFrequency` to adjust the pitch without affecting speed. Values range from 0.01 to 2:
|
||||
|
||||
```tsx
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
toneFrequency={1.5} // Higher pitch
|
||||
/>
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
toneFrequency={0.8} // Lower pitch
|
||||
/>
|
||||
```
|
||||
|
||||
Pitch shifting only works during server-side rendering, not in the Remotion Studio preview or in the `<Player />`.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: ffmpeg
|
||||
description: Using FFmpeg and FFprobe in Remotion
|
||||
metadata:
|
||||
tags: ffmpeg, ffprobe, video, trimming
|
||||
---
|
||||
|
||||
## FFmpeg in Remotion
|
||||
|
||||
`ffmpeg` and `ffprobe` do not need to be installed. They are available via the `npx remotion ffmpeg` and `npx remotion ffprobe`:
|
||||
|
||||
```bash
|
||||
npx remotion ffmpeg -i input.mp4 output.mp3
|
||||
npx remotion ffprobe input.mp4
|
||||
```
|
||||
|
||||
### Trimming videos
|
||||
|
||||
You have 2 options for trimming videos:
|
||||
|
||||
1. **Preferred**: Use the `trimBefore` and `trimAfter` props of the `<Video>` component. This is non-destructive, requires no re-encoding, and you can change the trim at any time.
|
||||
|
||||
```tsx
|
||||
import {Video} from '@remotion/media';
|
||||
|
||||
<Video src={staticFile('video.mp4')} trimBefore={5 * fps} trimAfter={10 * fps} />;
|
||||
```
|
||||
|
||||
2. Use the FFmpeg command line. You MUST re-encode the video to avoid frozen frames at the start of the video. Only use this if you need a standalone trimmed file (e.g. for upload or external use).
|
||||
|
||||
```bash
|
||||
# Re-encodes from the exact frame
|
||||
npx remotion ffmpeg -ss 00:00:05 -i public/input.mp4 -to 00:00:10 -c:v libx264 -c:a aac public/output.mp4
|
||||
```
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: gif
|
||||
description: Displaying GIFs, APNG, AVIF and WebP in Remotion
|
||||
metadata:
|
||||
tags: gif, animation, images, animated, apng, avif, webp
|
||||
---
|
||||
|
||||
# Using Animated images in Remotion
|
||||
|
||||
## Basic usage
|
||||
|
||||
Use `<AnimatedImage>` to display a GIF, APNG, AVIF or WebP image synchronized with Remotion's timeline:
|
||||
|
||||
```tsx
|
||||
import { AnimatedImage, staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return (
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} />
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Remote URLs are also supported (must have CORS enabled):
|
||||
|
||||
```tsx
|
||||
<AnimatedImage
|
||||
src="https://example.com/animation.gif"
|
||||
width={500}
|
||||
height={500}
|
||||
/>
|
||||
```
|
||||
|
||||
## Sizing and fit
|
||||
|
||||
Control how the image fills its container with the `fit` prop:
|
||||
|
||||
```tsx
|
||||
// Stretch to fill (default)
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="fill" />
|
||||
|
||||
// Maintain aspect ratio, fit inside container
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="contain" />
|
||||
|
||||
// Fill container, crop if needed
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="cover" />
|
||||
```
|
||||
|
||||
## Playback speed
|
||||
|
||||
Use `playbackRate` to control the animation speed:
|
||||
|
||||
```tsx
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={2} /> {/* 2x speed */}
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={0.5} /> {/* Half speed */}
|
||||
```
|
||||
|
||||
## Looping behavior
|
||||
|
||||
Control what happens when the animation finishes:
|
||||
|
||||
```tsx
|
||||
// Loop indefinitely (default)
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="loop" />
|
||||
|
||||
// Play once, show final frame
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="pause-after-finish" />
|
||||
|
||||
// Play once, then clear canvas
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="clear-after-finish" />
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
Use the `style` prop for additional CSS (use `width` and `height` props for sizing):
|
||||
|
||||
```tsx
|
||||
<AnimatedImage
|
||||
src={staticFile("animation.gif")}
|
||||
width={500}
|
||||
height={500}
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 50,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Getting GIF duration
|
||||
|
||||
Use `getGifDurationInSeconds()` from `@remotion/gif` to get the duration of a GIF.
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/gif
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { getGifDurationInSeconds } from "@remotion/gif";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
const duration = await getGifDurationInSeconds(staticFile("animation.gif"));
|
||||
console.log(duration); // e.g. 2.5
|
||||
```
|
||||
|
||||
This is useful for setting the composition duration to match the GIF:
|
||||
|
||||
```tsx
|
||||
import { getGifDurationInSeconds } from "@remotion/gif";
|
||||
import { staticFile, CalculateMetadataFunction } from "remotion";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction = async () => {
|
||||
const duration = await getGifDurationInSeconds(staticFile("animation.gif"));
|
||||
return {
|
||||
durationInFrames: Math.ceil(duration * 30),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Alternative
|
||||
|
||||
If `<AnimatedImage>` does not work (only supported in Chrome and Firefox), you can use `<Gif>` from `@remotion/gif` instead.
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/gif # If project uses npm
|
||||
bunx remotion add @remotion/gif # If project uses bun
|
||||
yarn remotion add @remotion/gif # If project uses yarn
|
||||
pnpm exec remotion add @remotion/gif # If project uses pnpm
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { Gif } from "@remotion/gif";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <Gif src={staticFile("animation.gif")} width={500} height={500} />;
|
||||
};
|
||||
```
|
||||
|
||||
The `<Gif>` component has the same props as `<AnimatedImage>` but only supports GIF files.
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: fonts
|
||||
description: Loading Google Fonts and local fonts in Remotion
|
||||
metadata:
|
||||
tags: fonts, google-fonts, typography, text
|
||||
---
|
||||
|
||||
# Using fonts in Remotion
|
||||
|
||||
## Google Fonts with @remotion/google-fonts
|
||||
|
||||
The recommended way to use Google Fonts. It's type-safe and automatically blocks rendering until the font is ready.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
First, the @remotion/google-fonts package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/google-fonts # If project uses npm
|
||||
bunx remotion add @remotion/google-fonts # If project uses bun
|
||||
yarn remotion add @remotion/google-fonts # If project uses yarn
|
||||
pnpm exec remotion add @remotion/google-fonts # If project uses pnpm
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Lobster";
|
||||
|
||||
const { fontFamily } = loadFont();
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <div style={{ fontFamily }}>Hello World</div>;
|
||||
};
|
||||
```
|
||||
|
||||
Preferrably, specify only needed weights and subsets to reduce file size:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Roboto";
|
||||
|
||||
const { fontFamily } = loadFont("normal", {
|
||||
weights: ["400", "700"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
```
|
||||
|
||||
## Using in components
|
||||
|
||||
Call `loadFont()` at the top level of your component or in a separate file that's imported early:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Montserrat";
|
||||
|
||||
const { fontFamily } = loadFont("normal", {
|
||||
weights: ["400", "700"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const Title: React.FC<{ text: string }> = ({ text }) => {
|
||||
return (
|
||||
<h1
|
||||
style={{
|
||||
fontFamily,
|
||||
fontSize: 80,
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,119 @@
|
||||
# Using `<HtmlInCanvas>` in Remotion
|
||||
|
||||
Renders children into a `<canvas>` so you can post-process them with the Canvas 2D API or WebGL.
|
||||
|
||||
Only works in Chrome 149+ with the `chrome://flags/#canvas-draw-element` flag enabled.
|
||||
Give the user a notice.
|
||||
|
||||
## Nesting
|
||||
|
||||
`<HtmlInCanvas>` components may be nested in Chrome 152.0.7944.0 and later.
|
||||
Older Chrome versions support a single `<HtmlInCanvas>`, but do not correctly paint nested HTML-in-canvas subtrees.
|
||||
|
||||
## Enabling WebGL during renders
|
||||
|
||||
If you make use of WebGL during renders, you need to enable it:
|
||||
|
||||
From the CLI:
|
||||
|
||||
```bash
|
||||
npx remotion render --gl=angle
|
||||
```
|
||||
|
||||
Set it as the default for Studio and CLI (advised):
|
||||
|
||||
```ts
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
Config.setChromiumOpenGlRenderer("angle");
|
||||
```
|
||||
|
||||
## Basic usage
|
||||
|
||||
By default, draws to canvas with no effect applied:
|
||||
|
||||
```tsx
|
||||
import { HtmlInCanvas } from "remotion";
|
||||
|
||||
export const MyComp = () => {
|
||||
return (
|
||||
<HtmlInCanvas width={1280} height={720}>
|
||||
<div style={{ fontSize: 80 }}>Hello</div>
|
||||
</HtmlInCanvas>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 2D effect with `onPaint`
|
||||
|
||||
`onPaint` runs whenever the content updates. Call `ctx.drawElementImage(elementImage, 0, 0)` to draw the captured DOM, and assign the returned transform to `element.style.transform` so DOM selection still aligns with the painted output.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
AbsoluteFill,
|
||||
HtmlInCanvas,
|
||||
type HtmlInCanvasOnPaint,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export const Blur = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height, fps } = useVideoConfig();
|
||||
|
||||
const onPaint: HtmlInCanvasOnPaint = useCallback(
|
||||
({ canvas, element, elementImage }) => {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Failed to acquire 2D context");
|
||||
|
||||
const blurPx = 4 + 18 * (0.5 + 0.5 * Math.sin((frame / fps) * Math.PI));
|
||||
|
||||
ctx.reset();
|
||||
ctx.filter = `blur(${blurPx}px)`;
|
||||
const transform = ctx.drawElementImage(elementImage, 0, 0);
|
||||
element.style.transform = transform.toString();
|
||||
},
|
||||
[frame, fps],
|
||||
);
|
||||
|
||||
return (
|
||||
<HtmlInCanvas width={width} height={height} onPaint={onPaint}>
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center", fontSize: 120 }}>
|
||||
<h1>Hello</h1>
|
||||
</AbsoluteFill>
|
||||
</HtmlInCanvas>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## WebGL effects
|
||||
|
||||
For WebGL, set up the context, program, and texture in `onInit` and return a cleanup function. Inside `onPaint`, upload the captured DOM with `gl.texElementImage2D(...)` and draw.
|
||||
|
||||
```tsx
|
||||
const onInit: HtmlInCanvasOnInit = useCallback(({ canvas }) => {
|
||||
const gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true });
|
||||
if (!gl) {
|
||||
throw new Error(
|
||||
"WebGL2 unavailable. Try rendering with the --gl=angle option. See https://remotion.dev/docs/gl-options.",
|
||||
);
|
||||
}
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
||||
// compile program, create texture, set up VAO...
|
||||
return () => {
|
||||
// delete program, texture, buffers...
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onPaint: HtmlInCanvasOnPaint = useCallback(({ elementImage }) => {
|
||||
gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, elementImage);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}, []);
|
||||
```
|
||||
|
||||
For a fully working minimal example, see https://github.com/remotion-dev/remotion/blob/main/packages/docs/components/demos/HtmlInCanvasDocsDemoWebGL.tsx.
|
||||
|
||||
## Async `onPaint`
|
||||
|
||||
`onPaint` may be `async`. Remotion holds the frame open via `delayRender()` until the promise resolves. Useful for multi-pass effects with `createImageBitmap`.
|
||||
@@ -0,0 +1,71 @@
|
||||
## Sizing and positioning
|
||||
|
||||
Use the `style` prop to control size and position:
|
||||
|
||||
```tsx
|
||||
<Img
|
||||
src={staticFile("photo.png")}
|
||||
style={{
|
||||
width: 500,
|
||||
height: 300,
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 50,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Dynamic image paths
|
||||
|
||||
Use template literals for dynamic file references:
|
||||
|
||||
```tsx
|
||||
import { Img, staticFile, useCurrentFrame } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Image sequence
|
||||
<Img src={staticFile(`frames/frame${frame}.png`)} />
|
||||
|
||||
// Selecting based on props
|
||||
<Img src={staticFile(`avatars/${props.userId}.png`)} />
|
||||
|
||||
// Conditional images
|
||||
<Img src={staticFile(`icons/${isActive ? "active" : "inactive"}.svg`)} />
|
||||
```
|
||||
|
||||
This pattern is useful for:
|
||||
|
||||
- Image sequences (frame-by-frame animations)
|
||||
- User-specific avatars or profile images
|
||||
- Theme-based icons
|
||||
- State-dependent graphics
|
||||
|
||||
## Getting image dimensions
|
||||
|
||||
Use `getImageDimensions()` to get the dimensions of an image:
|
||||
|
||||
```tsx
|
||||
import { getImageDimensions, staticFile } from "remotion";
|
||||
|
||||
const { width, height } = await getImageDimensions(staticFile("photo.png"));
|
||||
```
|
||||
|
||||
This is useful for calculating aspect ratios or sizing compositions:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
getImageDimensions,
|
||||
staticFile,
|
||||
CalculateMetadataFunction,
|
||||
} from "remotion";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction = async () => {
|
||||
const { width, height } = await getImageDimensions(staticFile("photo.png"));
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: light-leaks
|
||||
description: Light leak overlay effects for Remotion using @remotion/light-leaks.
|
||||
metadata:
|
||||
tags: light-leaks, overlays, effects, transitions
|
||||
---
|
||||
|
||||
## Light Leaks
|
||||
|
||||
This only works from Remotion 4.0.415 and up. Use `npx remotion versions` to check your Remotion version and `npx remotion upgrade` to upgrade your Remotion version.
|
||||
|
||||
`<LightLeak>` from `@remotion/light-leaks` renders a WebGL-based light leak effect. It reveals during the first half of its duration and retracts during the second half.
|
||||
|
||||
Typically used inside a `<TransitionSeries.Overlay>` to play over the cut point between two scenes. See the **transitions** rule for `<TransitionSeries>` and overlay usage.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/light-leaks
|
||||
```
|
||||
|
||||
## Basic usage with TransitionSeries
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries } from "@remotion/transitions";
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Overlay durationInFrames={30}>
|
||||
<LightLeak />
|
||||
</TransitionSeries.Overlay>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneB />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>;
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
- `durationInFrames?` — defaults to the parent sequence/composition duration. The effect reveals during the first half and retracts during the second half.
|
||||
- `seed?` — determines the shape of the light leak pattern. Different seeds produce different patterns. Default: `0`.
|
||||
- `hueShift?` — rotates the hue in degrees (`0`–`360`). Default: `0` (yellow-to-orange). `120` = green, `240` = blue.
|
||||
|
||||
## Customizing the look
|
||||
|
||||
```tsx
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
// Blue-tinted light leak with a different pattern
|
||||
<LightLeak seed={5} hueShift={240} />;
|
||||
|
||||
// Green-tinted light leak
|
||||
<LightLeak seed={2} hueShift={120} />;
|
||||
```
|
||||
|
||||
## Standalone usage
|
||||
|
||||
`<LightLeak>` can also be used outside of `<TransitionSeries>`, for example as a decorative overlay in any composition:
|
||||
|
||||
```tsx
|
||||
import { AbsoluteFill } from "remotion";
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
const MyComp: React.FC = () => (
|
||||
<AbsoluteFill>
|
||||
<MyContent />
|
||||
<LightLeak durationInFrames={60} seed={3} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,65 @@
|
||||
For local font files, use the `@remotion/fonts` package.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
First, install @remotion/fonts:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/fonts # If project uses npm
|
||||
bunx remotion add @remotion/fonts # If project uses bun
|
||||
yarn remotion add @remotion/fonts # If project uses yarn
|
||||
pnpm exec remotion add @remotion/fonts # If project uses pnpm
|
||||
```
|
||||
|
||||
### Loading a local font
|
||||
|
||||
Place your font file in the `public/` folder and use `loadFont()`:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/fonts";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
await loadFont({
|
||||
family: "MyFont",
|
||||
url: staticFile("MyFont-Regular.woff2"),
|
||||
});
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <div style={{ fontFamily: "MyFont" }}>Hello World</div>;
|
||||
};
|
||||
```
|
||||
|
||||
### Loading multiple weights
|
||||
|
||||
Load each weight separately with the same family name:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/fonts";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
await Promise.all([
|
||||
loadFont({
|
||||
family: "Inter",
|
||||
url: staticFile("Inter-Regular.woff2"),
|
||||
weight: "400",
|
||||
}),
|
||||
loadFont({
|
||||
family: "Inter",
|
||||
url: staticFile("Inter-Bold.woff2"),
|
||||
weight: "700",
|
||||
}),
|
||||
]);
|
||||
```
|
||||
|
||||
### Available options
|
||||
|
||||
```tsx
|
||||
loadFont({
|
||||
family: "MyFont", // Required: name to use in CSS
|
||||
url: staticFile("font.woff2"), // Required: font file URL
|
||||
format: "woff2", // Optional: auto-detected from extension
|
||||
weight: "400", // Optional: font weight
|
||||
style: "normal", // Optional: normal or italic
|
||||
display: "block", // Optional: font-display behavior
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: lottie
|
||||
description: Embedding Lottie animations in Remotion.
|
||||
metadata:
|
||||
category: Animation
|
||||
---
|
||||
|
||||
# Using Lottie Animations in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/lottie package needs to be installed.
|
||||
If it is not, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/lottie # If project uses npm
|
||||
bunx remotion add @remotion/lottie # If project uses bun
|
||||
yarn remotion add @remotion/lottie # If project uses yarn
|
||||
pnpm exec remotion add @remotion/lottie # If project uses pnpm
|
||||
```
|
||||
|
||||
## Displaying a Lottie file
|
||||
|
||||
To import a Lottie animation:
|
||||
|
||||
- Fetch the Lottie asset
|
||||
- Wrap the loading process in `delayRender()` and `continueRender()`
|
||||
- Save the animation data in a state
|
||||
- Render the Lottie animation using the `Lottie` component from the `@remotion/lottie` package
|
||||
|
||||
```tsx
|
||||
import { Lottie, LottieAnimationData } from "@remotion/lottie";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cancelRender, continueRender, delayRender } from "remotion";
|
||||
|
||||
export const MyAnimation = () => {
|
||||
const [handle] = useState(() => delayRender("Loading Lottie animation"));
|
||||
|
||||
const [animationData, setAnimationData] =
|
||||
useState<LottieAnimationData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("https://assets4.lottiefiles.com/packages/lf20_zyquagfl.json")
|
||||
.then((data) => data.json())
|
||||
.then((json) => {
|
||||
setAnimationData(json);
|
||||
continueRender(handle);
|
||||
})
|
||||
.catch((err) => {
|
||||
cancelRender(err);
|
||||
});
|
||||
}, [handle]);
|
||||
|
||||
if (!animationData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Lottie animationData={animationData} />;
|
||||
};
|
||||
```
|
||||
|
||||
## Styling and animating
|
||||
|
||||
Lottie supports the `style` prop to allow styles and animations:
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<Lottie animationData={animationData} style={{ width: 400, height: 400 }} />
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: measuring-dom-nodes
|
||||
description: Measuring DOM element dimensions in Remotion
|
||||
metadata:
|
||||
tags: measure, layout, dimensions, getBoundingClientRect, scale
|
||||
---
|
||||
|
||||
# Measuring DOM nodes in Remotion
|
||||
|
||||
Remotion applies a `scale()` transform to the video container, which affects values from `getBoundingClientRect()`. Use `useCurrentScale()` to get correct measurements.
|
||||
|
||||
## Measuring element dimensions
|
||||
|
||||
```tsx
|
||||
import { useCurrentScale } from "remotion";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const scale = useCurrentScale();
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
setDimensions({
|
||||
width: rect.width / scale,
|
||||
height: rect.height / scale,
|
||||
});
|
||||
}, [scale]);
|
||||
|
||||
return <div ref={ref}>Content to measure</div>;
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: measuring-text
|
||||
description: Measuring text dimensions, fitting text to containers, and checking overflow
|
||||
metadata:
|
||||
tags: measure, text, layout, dimensions, fitText, fillTextBox
|
||||
---
|
||||
|
||||
# Measuring text in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install @remotion/layout-utils if it is not already installed:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/layout-utils
|
||||
```
|
||||
|
||||
## Measuring text dimensions
|
||||
|
||||
Use `measureText()` to calculate the width and height of text:
|
||||
|
||||
```tsx
|
||||
import { measureText } from "@remotion/layout-utils";
|
||||
|
||||
const { width, height } = measureText({
|
||||
text: "Hello World",
|
||||
fontFamily: "Arial",
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
});
|
||||
```
|
||||
|
||||
Results are cached - duplicate calls return the cached result.
|
||||
|
||||
## Fitting text to a width
|
||||
|
||||
Use `fitText()` to find the optimal font size for a container:
|
||||
|
||||
```tsx
|
||||
import { fitText } from "@remotion/layout-utils";
|
||||
|
||||
const { fontSize } = fitText({
|
||||
text: "Hello World",
|
||||
withinWidth: 600,
|
||||
fontFamily: "Inter",
|
||||
fontWeight: "bold",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: Math.min(fontSize, 80), // Cap at 80px
|
||||
fontFamily: "Inter",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Hello World
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## Checking text overflow
|
||||
|
||||
Use `fillTextBox()` to check if text exceeds a box:
|
||||
|
||||
```tsx
|
||||
import { fillTextBox } from "@remotion/layout-utils";
|
||||
|
||||
const box = fillTextBox({ maxBoxWidth: 400, maxLines: 3 });
|
||||
|
||||
const words = ["Hello", "World", "This", "is", "a", "test"];
|
||||
for (const word of words) {
|
||||
const { exceedsBox } = box.add({
|
||||
text: word + " ",
|
||||
fontFamily: "Arial",
|
||||
fontSize: 24,
|
||||
});
|
||||
if (exceedsBox) {
|
||||
// Text would overflow, handle accordingly
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
**Load fonts first:** Only call measurement functions after fonts are loaded.
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Inter";
|
||||
|
||||
const { fontFamily, waitUntilDone } = loadFont("normal", {
|
||||
weights: ["400"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
waitUntilDone().then(() => {
|
||||
// Now safe to measure
|
||||
const { width } = measureText({
|
||||
text: "Hello",
|
||||
fontFamily,
|
||||
fontSize: 32,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Use validateFontIsLoaded:** Catch font loading issues early:
|
||||
|
||||
```tsx
|
||||
measureText({
|
||||
text: "Hello",
|
||||
fontFamily: "MyCustomFont",
|
||||
fontSize: 32,
|
||||
validateFontIsLoaded: true, // Throws if font not loaded
|
||||
});
|
||||
```
|
||||
|
||||
**Match font properties:** Use the same properties for measurement and rendering:
|
||||
|
||||
```tsx
|
||||
const fontStyle = {
|
||||
fontFamily: "Inter",
|
||||
fontSize: 32,
|
||||
fontWeight: "bold" as const,
|
||||
letterSpacing: "0.5px",
|
||||
};
|
||||
|
||||
const { width } = measureText({
|
||||
text: "Hello",
|
||||
...fontStyle,
|
||||
});
|
||||
|
||||
return <div style={fontStyle}>Hello</div>;
|
||||
```
|
||||
|
||||
**Avoid padding and border:** Use `outline` instead of `border` to prevent layout differences:
|
||||
|
||||
```tsx
|
||||
<div style={{ outline: "2px solid red" }}>Text</div>
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: parameters
|
||||
description: Make a video parametrizable by adding a Zod schema
|
||||
metadata:
|
||||
tags: parameters, zod, schema
|
||||
---
|
||||
|
||||
To make a video parametrizable, a Zod schema can be added to a composition.
|
||||
|
||||
First, `zod` must be installed .
|
||||
|
||||
Search the project for lockfiles and run the correct command depending on the package manager:
|
||||
|
||||
If `package-lock.json` is found, use the following command:
|
||||
|
||||
```bash
|
||||
npm i zod
|
||||
```
|
||||
|
||||
If `bun.lockb` is found, use the following command:
|
||||
|
||||
```bash
|
||||
bun i zod
|
||||
```
|
||||
|
||||
If `yarn.lock` is found, use the following command:
|
||||
|
||||
```bash
|
||||
yarn add zod
|
||||
```
|
||||
|
||||
If `pnpm-lock.yaml` is found, use the following command:
|
||||
|
||||
```bash
|
||||
pnpm i zod
|
||||
```
|
||||
|
||||
Then, a Zod schema can be defined alongside the component:
|
||||
|
||||
```tsx title="src/MyComposition.tsx"
|
||||
import { z } from "zod";
|
||||
|
||||
export const MyCompositionSchema = z.object({
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
const MyComponent: React.FC<z.infer<typeof MyCompositionSchema>> = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>{props.title}</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
In the root file, the schema can be passed to the composition:
|
||||
|
||||
```tsx title="src/Root.tsx"
|
||||
import { Composition } from "remotion";
|
||||
import { MycComponent, MyCompositionSchema } from "./MyComposition";
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<Composition
|
||||
id="MyComposition"
|
||||
component={MyComponent}
|
||||
durationInFrames={100}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1080}
|
||||
defaultProps={{ title: "Hello World" }}
|
||||
schema={MyCompositionSchema}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Now, the user can edit the parameter visually in the sidebar.
|
||||
|
||||
All schemas that are supported by Zod are supported by Remotion.
|
||||
|
||||
Remotion requires that the top-level type is a z.object(), because the collection of props of a React component is always an object.
|
||||
|
||||
## Color picker
|
||||
|
||||
For adding a color picker, use `zColor()` from `@remotion/zod-types`.
|
||||
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/zod-types # If project uses npm
|
||||
bunx remotion add @remotion/zod-types # If project uses bun
|
||||
yarn remotion add @remotion/zod-types # If project uses yarn
|
||||
pnpm exec remotion add @remotion/zod-types # If project uses pnpm
|
||||
```
|
||||
|
||||
Then import `zColor` from `@remotion/zod-types`:
|
||||
|
||||
```tsx
|
||||
import { zColor } from "@remotion/zod-types";
|
||||
```
|
||||
|
||||
Then use it in the schema:
|
||||
|
||||
```tsx
|
||||
export const MyCompositionSchema = z.object({
|
||||
color: zColor(),
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: remotion-maps
|
||||
description: Remotion Map animation knowledge
|
||||
---
|
||||
|
||||
# Remotion Maps
|
||||
|
||||
Choose exactly one technique from the intended shot, then load only that technique's `TECHNIQUE.md`.
|
||||
Every technique directory is self-contained and may be removed without breaking the others.
|
||||
|
||||
## [Static map](techniques/static-map/TECHNIQUE.md)
|
||||
|
||||
- Requires you grab a satellite image and mount it in a `<Img>` tag, and animate on top
|
||||
|
||||
## [Mapbox](techniques/mapbox/TECHNIQUE.md)
|
||||
|
||||
- Requires a Mapbox key
|
||||
- Nicer styles by default
|
||||
- Map can display a round globe when zoomed out
|
||||
- Includes nice 3D buildings such as the Eiffel tower
|
||||
|
||||
## [MapLibre](techniques/maplibre/TECHNIQUE.md)
|
||||
|
||||
- Requires no API key, fully free
|
||||
- Does not include 3D building
|
||||
|
||||
## [MapTiler](techniques/maptiler/TECHNIQUE.md)
|
||||
|
||||
- Uses MapTiler
|
||||
- Annotations can be drawn on top of geographic features: borders, rivers, labels
|
||||
|
||||
## [CesiumJS](techniques/cesium/TECHNIQUE.md)
|
||||
|
||||
- Flythroughs through terrain and mountains
|
||||
- "Flight simulator" perspective
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
# CesiumJS — 3D flyovers in Remotion
|
||||
|
||||
Instructions for achieving map animations with "flight-simulator" perspective in Remotion.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Data | Use for |
|
||||
| ----------- | ----------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `landscape` | MapTiler `terrain-quantized-mesh-v2` + `satellite-v2` | Mountains, gorges, rivers, coastlines and rural routes |
|
||||
| `city` | Google Photorealistic 3D Tiles | Cities, architecture and recognizable landmarks |
|
||||
|
||||
Do not use footprint extrusions for city flyovers. They produce crude building blocks rather than
|
||||
textured architecture.
|
||||
|
||||
## Credentials
|
||||
|
||||
For `landscape`, set:
|
||||
|
||||
```text
|
||||
REMOTION_MAPTILER_KEY=...
|
||||
```
|
||||
|
||||
Create a MapTiler key at https://cloud.maptiler.com/account/keys/.
|
||||
|
||||
For `city`, set:
|
||||
|
||||
```text
|
||||
REMOTION_GOOGLE_MAPS_API_KEY=...
|
||||
```
|
||||
|
||||
Create a billing-enabled Google Map Tiles API key by following
|
||||
https://developers.google.com/maps/documentation/tile/get-api-key. Enable the **Map Tiles API** and
|
||||
restrict the key to that API. Ensure its application restriction permits local headless Remotion
|
||||
requests.
|
||||
|
||||
## Build the flight
|
||||
|
||||
1. Copy `assets/CesiumFlythrough.tsx`, a path JSON and `assets/example-Root.tsx` into the Remotion
|
||||
project, or import the component directly.
|
||||
2. Supply the camera route as `[longitude, latitude][]`. Use only meaningful control points; do not hand-author dozens of tiny corrections.
|
||||
3. Leave `pathSmoothingPasses={3}` initially. The component applies repeated Chaikin corner cutting, turning straight-then-corner input into a continuous swerve. Increase to `4` for a softer route or reduce to `2` when the camera must follow a tight corridor.
|
||||
4. Set absolute camera altitudes for the location. City cameras normally fly lower than landscape
|
||||
cameras.
|
||||
5. Render a middle-frame still before rendering the full video.
|
||||
|
||||
```tsx
|
||||
<CesiumFlythrough
|
||||
mode="city"
|
||||
path={cameraPath}
|
||||
pathSmoothingPasses={3}
|
||||
altitudeStart={700}
|
||||
altitudeEnd={500}
|
||||
lookAheadKm={0.7}
|
||||
travelKm={4.5}
|
||||
/>
|
||||
```
|
||||
|
||||
## Camera behavior
|
||||
|
||||
Walk the smoothed curve by arc length for constant ground speed. Aim at a real point farther along
|
||||
the curve rather than its next vertex. Derive roll from the change in look-ahead bearing so the
|
||||
camera banks into a turn instead of twitching left and right.
|
||||
|
||||
For landscape routes, `scripts/prep-cesium-path.mjs` also clips, resamples, smooths and dampens a
|
||||
GeoJSON centerline before the component applies its final curve smoothing.
|
||||
|
||||
|
||||
## Mechanics
|
||||
|
||||
- Set `viewer.useDefaultRenderLoop = false`.
|
||||
- Call `viewer.render()`, never `scene.render()`, while settling.
|
||||
- Use `preserveDrawingBuffer: true`.
|
||||
- Gate initialization and every frame with `delayRender`.
|
||||
- Drive camera animation from `useCurrentFrame()`; do not use CSS transitions or browser-timed animation.
|
||||
- Settle landscapes on `globe.tilesLoaded` and cities on `tileset.tilesLoaded`.
|
||||
- Keep all provider attribution visible.
|
||||
- Record the source and effective date of custom or disputed geography.
|
||||
- Inspect rendered pixels, not only Studio playback, at every required aspect ratio.
|
||||
- Read `references/3d-flyover-architecture.md` for camera math, `references/3d-data-sources.md` for provider details, and `references/3d-troubleshooting.md` for blank, coarse, unauthorized or timed-out renders.
|
||||
|
||||
## Files
|
||||
|
||||
- `assets/CesiumFlythrough.tsx` — reusable two-mode component.
|
||||
- `assets/flight-path.ts` — dependency-free Chaikin route smoothing.
|
||||
- `assets/example-Root.tsx` — landscape and city compositions.
|
||||
- `assets/cesium-path.json` — sample landscape route.
|
||||
- `assets/city-path.json` — sample city route.
|
||||
- `assets/sample-river.geojson` — sample path-preparation input.
|
||||
- `scripts/prep-cesium-path.mjs` — dependency-free landscape route preparation.
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
import React, {useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
cancelRender,
|
||||
continueRender,
|
||||
delayRender,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import terrainPath from './cesium-path.json';
|
||||
import {smoothFlightPath, type LngLat} from './flight-path';
|
||||
|
||||
export type FlyoverMode = 'landscape' | 'city';
|
||||
export type {LngLat} from './flight-path';
|
||||
|
||||
export type CesiumFlythroughProps = {
|
||||
mode?: FlyoverMode;
|
||||
path?: LngLat[];
|
||||
pathSmoothingPasses?: number;
|
||||
altitudeStart?: number;
|
||||
altitudeEnd?: number;
|
||||
lookAheadKm?: number;
|
||||
travelKm?: number;
|
||||
pitchFromNadir?: number;
|
||||
verticalExaggeration?: number;
|
||||
maximumScreenSpaceError?: number;
|
||||
};
|
||||
|
||||
const MAPTILER_KEY = process.env.REMOTION_MAPTILER_KEY;
|
||||
const GOOGLE_MAPS_API_KEY = process.env.REMOTION_GOOGLE_MAPS_API_KEY;
|
||||
const CESIUM_VER = '1.143';
|
||||
const CDN = `https://cesium.com/downloads/cesiumjs/releases/${CESIUM_VER}/Build/Cesium/`;
|
||||
const R = 6371;
|
||||
const MAX_BANK = 0.13;
|
||||
const BANK_GAIN = 0.6;
|
||||
|
||||
const havKm = (a: number[], b: number[]) => {
|
||||
const r = Math.PI / 180;
|
||||
const dLat = (b[1] - a[1]) * r;
|
||||
const dLng = (b[0] - a[0]) * r;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(a[1] * r) * Math.cos(b[1] * r) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
};
|
||||
|
||||
const makePathWalker = (path: LngLat[]) => {
|
||||
if (path.length < 2)
|
||||
throw new Error('Flyover path needs at least two points');
|
||||
const cumulative = [0];
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
cumulative.push(cumulative[i - 1] + havKm(path[i - 1], path[i]));
|
||||
}
|
||||
const lengthKm = cumulative[cumulative.length - 1];
|
||||
const along = (km: number): LngLat => {
|
||||
const d = Math.max(0, Math.min(lengthKm, km));
|
||||
let i = 1;
|
||||
while (i < cumulative.length && cumulative[i] < d) i++;
|
||||
if (i >= cumulative.length) return path[path.length - 1];
|
||||
const segmentLength = cumulative[i] - cumulative[i - 1] || 1;
|
||||
const t = (d - cumulative[i - 1]) / segmentLength;
|
||||
return [
|
||||
path[i - 1][0] + (path[i][0] - path[i - 1][0]) * t,
|
||||
path[i - 1][1] + (path[i][1] - path[i - 1][1]) * t,
|
||||
];
|
||||
};
|
||||
return {along, lengthKm};
|
||||
};
|
||||
|
||||
const bearing = (a: number[], b: number[]) => {
|
||||
const r = Math.PI / 180;
|
||||
const y = Math.sin((b[0] - a[0]) * r) * Math.cos(b[1] * r);
|
||||
const x =
|
||||
Math.cos(a[1] * r) * Math.sin(b[1] * r) -
|
||||
Math.sin(a[1] * r) * Math.cos(b[1] * r) * Math.cos((b[0] - a[0]) * r);
|
||||
return Math.atan2(y, x);
|
||||
};
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
const clamp = (v: number, lo: number, hi: number) =>
|
||||
Math.max(lo, Math.min(hi, v));
|
||||
|
||||
const loadCesium = () =>
|
||||
new Promise<any>((resolve, reject) => {
|
||||
if ((window as any).Cesium) return resolve((window as any).Cesium);
|
||||
(window as any).CESIUM_BASE_URL = CDN;
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet';
|
||||
css.href = `${CDN}Widgets/widgets.css`;
|
||||
document.head.appendChild(css);
|
||||
const script = document.createElement('script');
|
||||
script.src = `${CDN}Cesium.js`;
|
||||
script.onload = () => resolve((window as any).Cesium);
|
||||
script.onerror = () =>
|
||||
reject(new Error(`Failed to load CesiumJS ${CESIUM_VER}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
export const CesiumFlythrough: React.FC<CesiumFlythroughProps> = ({
|
||||
mode = 'landscape',
|
||||
path = terrainPath as LngLat[],
|
||||
pathSmoothingPasses = 3,
|
||||
altitudeStart = 4600,
|
||||
altitudeEnd = 4300,
|
||||
lookAheadKm = 1.5,
|
||||
travelKm = 13,
|
||||
pitchFromNadir = 76,
|
||||
verticalExaggeration = 1.1,
|
||||
maximumScreenSpaceError = 8,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const started = useRef(false);
|
||||
const viewerRef = useRef<any>(null);
|
||||
const tilesetRef = useRef<any>(null);
|
||||
const frame = useCurrentFrame();
|
||||
const {durationInFrames, fps, width, height} = useVideoConfig();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [handle] = useState(() =>
|
||||
delayRender(`cesium init: ${mode}`, {timeoutInMilliseconds: 120000}),
|
||||
);
|
||||
const walker = useMemo(
|
||||
() => makePathWalker(smoothFlightPath(path, pathSmoothingPasses)),
|
||||
[path, pathSmoothingPasses],
|
||||
);
|
||||
|
||||
const setCamera = (C: any, viewer: any, progress: number) => {
|
||||
const maxTravel = Math.max(0, walker.lengthKm - lookAheadKm * 2);
|
||||
const cameraDistance = Math.min(travelKm, maxTravel) * progress;
|
||||
const camera = walker.along(cameraDistance);
|
||||
const aim = walker.along(cameraDistance + lookAheadKm);
|
||||
const aim2 = walker.along(cameraDistance + lookAheadKm * 2);
|
||||
const heading = bearing(camera, aim);
|
||||
let headingDelta = bearing(aim, aim2) - heading;
|
||||
while (headingDelta > Math.PI) headingDelta -= 2 * Math.PI;
|
||||
while (headingDelta < -Math.PI) headingDelta += 2 * Math.PI;
|
||||
viewer.camera.setView({
|
||||
destination: C.Cartesian3.fromDegrees(
|
||||
camera[0],
|
||||
camera[1],
|
||||
lerp(altitudeStart, altitudeEnd, progress),
|
||||
),
|
||||
orientation: {
|
||||
heading,
|
||||
pitch: C.Math.toRadians(-(90 - pitchFromNadir)),
|
||||
roll: clamp(headingDelta * BANK_GAIN, -MAX_BANK, MAX_BANK),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const tilesAreLoaded = (viewer: any) => {
|
||||
if (mode === 'landscape') return viewer.scene.globe.tilesLoaded;
|
||||
return Boolean(tilesetRef.current?.tilesLoaded);
|
||||
};
|
||||
|
||||
const settle = (viewer: any) =>
|
||||
new Promise<void>((resolve) => {
|
||||
let stable = 0;
|
||||
let ticks = 0;
|
||||
const tick = () => {
|
||||
viewer.render();
|
||||
ticks++;
|
||||
stable = tilesAreLoaded(viewer) ? stable + 1 : 0;
|
||||
if (stable > 8 || ticks > 600) {
|
||||
viewer.render();
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(tick, 8);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (started.current) return;
|
||||
started.current = true;
|
||||
(async () => {
|
||||
if (mode === 'landscape' && !MAPTILER_KEY) {
|
||||
throw new Error(
|
||||
'Set REMOTION_MAPTILER_KEY. Create a key at https://cloud.maptiler.com/account/keys/',
|
||||
);
|
||||
}
|
||||
if (mode === 'city' && !GOOGLE_MAPS_API_KEY) {
|
||||
throw new Error(
|
||||
'Set REMOTION_GOOGLE_MAPS_API_KEY. Create a Map Tiles API key at https://developers.google.com/maps/documentation/tile/get-api-key',
|
||||
);
|
||||
}
|
||||
if (mode === 'city' && durationInFrames / fps > 30) {
|
||||
throw new Error(
|
||||
'Google Photorealistic 3D Tiles compositions must not exceed 30 seconds',
|
||||
);
|
||||
}
|
||||
|
||||
const C = await loadCesium();
|
||||
const viewer = new C.Viewer(containerRef.current, {
|
||||
baseLayer: false,
|
||||
baseLayerPicker: false,
|
||||
geocoder: false,
|
||||
homeButton: false,
|
||||
sceneModePicker: false,
|
||||
navigationHelpButton: false,
|
||||
animation: false,
|
||||
timeline: false,
|
||||
fullscreenButton: false,
|
||||
infoBox: false,
|
||||
selectionIndicator: false,
|
||||
contextOptions: {webgl: {preserveDrawingBuffer: true}},
|
||||
});
|
||||
if (mode === 'landscape') {
|
||||
viewer.imageryLayers.addImageryProvider(
|
||||
new C.UrlTemplateImageryProvider({
|
||||
url: `https://api.maptiler.com/tiles/satellite-v2/{z}/{x}/{y}.jpg?key=${MAPTILER_KEY}`,
|
||||
maximumLevel: 20,
|
||||
}),
|
||||
);
|
||||
viewer.terrainProvider = await C.CesiumTerrainProvider.fromUrl(
|
||||
`https://api.maptiler.com/tiles/terrain-quantized-mesh-v2/?key=${MAPTILER_KEY}`,
|
||||
{requestVertexNormals: true},
|
||||
);
|
||||
viewer.creditDisplay.addStaticCredit(
|
||||
new C.Credit(
|
||||
'<a href="https://www.maptiler.com/copyright/" target="_blank">© MapTiler</a>',
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (mode === 'city') {
|
||||
viewer.scene.globe.show = false;
|
||||
const tileset = await C.Cesium3DTileset.fromUrl(
|
||||
`https://tile.googleapis.com/v1/3dtiles/root.json?key=${GOOGLE_MAPS_API_KEY}`,
|
||||
{
|
||||
showCreditsOnScreen: true,
|
||||
maximumScreenSpaceError,
|
||||
},
|
||||
);
|
||||
viewer.scene.primitives.add(tileset);
|
||||
tilesetRef.current = tileset;
|
||||
}
|
||||
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
viewer.scene.skyAtmosphere.show = true;
|
||||
viewer.scene.fog.enabled = true;
|
||||
viewer.scene.globe.enableLighting = false;
|
||||
viewer.scene.verticalExaggeration = verticalExaggeration;
|
||||
(window as any).__CESIUM_FLYOVER__ = {C, mode};
|
||||
viewerRef.current = viewer;
|
||||
setCamera(C, viewer, 0);
|
||||
await settle(viewer);
|
||||
setReady(true);
|
||||
continueRender(handle);
|
||||
})().catch((error) => cancelRender(error));
|
||||
}, [durationInFrames, fps, handle, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const frameHandle = delayRender(`cesium ${mode} frame ${frame}`, {
|
||||
timeoutInMilliseconds: 60000,
|
||||
});
|
||||
const C = (window as any).__CESIUM_FLYOVER__.C;
|
||||
const viewer = viewerRef.current;
|
||||
const progress = durationInFrames <= 1 ? 0 : frame / (durationInFrames - 1);
|
||||
setCamera(C, viewer, progress);
|
||||
settle(viewer).then(() => continueRender(frameHandle));
|
||||
}, [ready, frame, durationInFrames, mode]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: '#000'}}>
|
||||
<div ref={containerRef} style={{width, height, position: 'absolute'}} />
|
||||
{mode === 'city' ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
right: 24,
|
||||
color: 'white',
|
||||
font: '500 18px/1.2 sans-serif',
|
||||
textShadow: '0 1px 4px black',
|
||||
}}
|
||||
>
|
||||
For promotional purposes only
|
||||
</div>
|
||||
) : null}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
[
|
||||
[94.986139893537, 29.76417312959953],
|
||||
[94.98637054463002, 29.764163024004816],
|
||||
[94.98661427422437, 29.764153025681516],
|
||||
[94.9868698103708, 29.764142908388347],
|
||||
[94.98713600736019, 29.764132461562934],
|
||||
[94.98741179569139, 29.76412145291623],
|
||||
[94.9876962466277, 29.764109663422126],
|
||||
[94.98798853462426, 29.764096898390772],
|
||||
[94.98828790834106, 29.764082995679242],
|
||||
[94.98859364523021, 29.764067827511347],
|
||||
[94.98890509685035, 29.764051279216236],
|
||||
[94.98922167960325, 29.76403324759024],
|
||||
[94.98954294512464, 29.76401367774424],
|
||||
[94.9898685550894, 29.76399255290906],
|
||||
[94.99019820264337, 29.76396985787546],
|
||||
[94.9905316178847, 29.763945606334815],
|
||||
[94.9908685732739, 29.763919866576575],
|
||||
[94.99120886105722, 29.763892700951896],
|
||||
[94.99155229116181, 29.7638641665054],
|
||||
[94.99189872300363, 29.76383429811986],
|
||||
[94.99224811692223, 29.763803085651237],
|
||||
[94.99260053337049, 29.763770509641105],
|
||||
[94.99295601118384, 29.76373658086857],
|
||||
[94.99331456742908, 29.763701322339223],
|
||||
[94.99367598745215, 29.763664830647013],
|
||||
[94.99404000156176, 29.763627219082974],
|
||||
[94.99440635968021, 29.763588592698778],
|
||||
[94.99477481502787, 29.76354905236459],
|
||||
[94.9951451006731, 29.763508748648892],
|
||||
[94.99578766204738, 29.763462125646818],
|
||||
[94.99643710580968, 29.763412119743368],
|
||||
[94.99709296623192, 29.763358988682928],
|
||||
[94.99775479298779, 29.763302979031952],
|
||||
[94.99842214521965, 29.763244327404465],
|
||||
[94.99909470569231, 29.763183258547627],
|
||||
[94.9997722961385, 29.76312010454948],
|
||||
[95.00045474013422, 29.763055184243758],
|
||||
[95.00114189582168, 29.762988795623112],
|
||||
[95.00183368970049, 29.762921207778405],
|
||||
[95.0025300799095, 29.76285270023514],
|
||||
[95.00323103443773, 29.762783549665084],
|
||||
[95.00393658318124, 29.76271400155497],
|
||||
[95.00464681949704, 29.76264432424487],
|
||||
[95.00536183331896, 29.76257477740286],
|
||||
[95.00608171146906, 29.762505612813378],
|
||||
[95.00680653793428, 29.762437075077955],
|
||||
[95.00753639864557, 29.762369398844733],
|
||||
[95.00827137837749, 29.762302811854063],
|
||||
[95.00901153006761, 29.762237519590354],
|
||||
[95.00975680776318, 29.76217368790719],
|
||||
[95.01050716875933, 29.762111478518566],
|
||||
[95.0112626067996, 29.76205102042169],
|
||||
[95.01202312290016, 29.761992432502932],
|
||||
[95.01278871942189, 29.76193583156792],
|
||||
[95.01355940108247, 29.761881333165324],
|
||||
[95.01433525277555, 29.761829093407865],
|
||||
[95.01511636205572, 29.761779224166457],
|
||||
[95.01590278872715, 29.76173180995877],
|
||||
[95.0166868810408, 29.76168612055982],
|
||||
[95.01746871725406, 29.761642332250204],
|
||||
[95.0182483756254, 29.76160062131038],
|
||||
[95.01902596675926, 29.761561177619345],
|
||||
[95.019801682618, 29.761524238306453],
|
||||
[95.02057570378254, 29.76149004865798],
|
||||
[95.02134818878243, 29.761458838563573],
|
||||
[95.02211921065114, 29.761430763930672],
|
||||
[95.02288888930805, 29.761405973069504],
|
||||
[95.02365734467304, 29.761384614290225],
|
||||
[95.02442469666667, 29.761366835902948],
|
||||
[95.02519095257038, 29.761352731197437],
|
||||
[95.02595602043016, 29.761342344990513],
|
||||
[95.02671980829304, 29.761335722099044],
|
||||
[95.02748221022355, 29.76133286485609],
|
||||
[95.02824308111781, 29.761333684556867],
|
||||
[95.02900227020879, 29.761338090875398],
|
||||
[95.02975960629327, 29.76134599002589],
|
||||
[95.03051480114966, 29.76135728786741],
|
||||
[95.03126749207338, 29.761371921751238],
|
||||
[95.03201723576402, 29.761389829130785],
|
||||
[95.03276363398774, 29.761410893146387],
|
||||
[95.03350632277824, 29.761434973117545],
|
||||
[95.03424521675927, 29.76146183394706],
|
||||
[95.03498037607913, 29.761491215377145],
|
||||
[95.035711917426, 29.761522866578012],
|
||||
[95.03643998610582, 29.761556530192124],
|
||||
[95.03716475930274, 29.761591886465347],
|
||||
[95.03788653744915, 29.761628566185358],
|
||||
[95.03860571883365, 29.76166618469001],
|
||||
[95.03932258585421, 29.76170436456548],
|
||||
[95.04003738892985, 29.76174273062808],
|
||||
[95.04075025083816, 29.76178094997509],
|
||||
[95.0414610559133, 29.76181870813213],
|
||||
[95.04216961904287, 29.761855638407084],
|
||||
[95.04287591713418, 29.761891331889718],
|
||||
[95.04357986124926, 29.761925397709096],
|
||||
[95.04428137757625, 29.761957488479126],
|
||||
[95.04498041854592, 29.76198725048925],
|
||||
[95.04567682817188, 29.762014317811357],
|
||||
[95.04637032968634, 29.762038376323694],
|
||||
[95.04706059416637, 29.762059099770216],
|
||||
[95.04774726432963, 29.762076162373777],
|
||||
[95.0484300050609, 29.762089241174117],
|
||||
[95.04910871445378, 29.762098042845082],
|
||||
[95.04978328153793, 29.762102280852183],
|
||||
[95.05045359964508, 29.76210167552205],
|
||||
[95.05111966253706, 29.76209602004179],
|
||||
[95.05178153406797, 29.762085106891018],
|
||||
[95.05243927503516, 29.762068731547185],
|
||||
[95.05309287649075, 29.762046750046103],
|
||||
[95.0537422946664, 29.762019099359485],
|
||||
[95.05438748396233, 29.761985721609257],
|
||||
[95.05502839823626, 29.76194655859307],
|
||||
[95.05566488201883, 29.761901557942874],
|
||||
[95.05629656919591, 29.761850805510885],
|
||||
[95.05692313234233, 29.761794428680094],
|
||||
[95.0575442743842, 29.761732580860688],
|
||||
[95.0581596433054, 29.761665348138973],
|
||||
[95.05876888708963, 29.761592816601354],
|
||||
[95.05937164047754, 29.761515065256333],
|
||||
[95.05996759228947, 29.761432144038746],
|
||||
[95.06055643134583, 29.761344102883623],
|
||||
[95.06113793491645, 29.761251010034922],
|
||||
[95.06171214230999, 29.761153046649387],
|
||||
[95.06227907948092, 29.761050389948267],
|
||||
[95.06283877238438, 29.760943217152814],
|
||||
[95.06339127381165, 29.760831691663462],
|
||||
[95.06393669678933, 29.760716002374487],
|
||||
[95.06447520396281, 29.76059636241658],
|
||||
[95.06500695797821, 29.760472984920362],
|
||||
[95.06553212847378, 29.760346104258282],
|
||||
[95.0660508986937, 29.760216015040477],
|
||||
[95.06656344194985, 29.760083025494016],
|
||||
[95.06707002648291, 29.75994741412334],
|
||||
[95.06757112978441, 29.759809440866206],
|
||||
[95.06806722979658, 29.759669344181514],
|
||||
[95.06855878142073, 29.759527354008487],
|
||||
[95.06904617625263, 29.759383697903765],
|
||||
[95.06952978875404, 29.75923861533451],
|
||||
[95.07000993080214, 29.759092371156385],
|
||||
[95.07048679175114, 29.758945227682446],
|
||||
[95.07096044787545, 29.75879742836973],
|
||||
[95.07143096114058, 29.758649219939148],
|
||||
[95.0718984144853, 29.758500858117706],
|
||||
[95.07236278188816, 29.7583525823887],
|
||||
[95.07282412506945, 29.758204644682806],
|
||||
[95.07328275575983, 29.75805728963777],
|
||||
[95.07373905641253, 29.757910760813612],
|
||||
[95.07419366476313, 29.757765217208473],
|
||||
[95.07464735762416, 29.757620800921966],
|
||||
[95.07510085345471, 29.757477677507868],
|
||||
[95.07555467766437, 29.757336168750566],
|
||||
[95.07600940811365, 29.757196598118206],
|
||||
[95.07646537399586, 29.75705931280275],
|
||||
[95.0769228207089, 29.756924677394206],
|
||||
[95.07738220221682, 29.756793075496777],
|
||||
[95.07784401968648, 29.75666487927172],
|
||||
[95.07830874492939, 29.756540496044202],
|
||||
[95.07877690647402, 29.756420332181715],
|
||||
[95.07924898851259, 29.75630478841808],
|
||||
[95.07972503722137, 29.756194196891926],
|
||||
[95.08020511490892, 29.756088882533362],
|
||||
[95.0806891403115, 29.75598918720403],
|
||||
[95.08117690920173, 29.75589531641115],
|
||||
[95.08166835685006, 29.755807414283098],
|
||||
[95.08216335868525, 29.75572566824862],
|
||||
[95.08266177736347, 29.755650250535062],
|
||||
[95.08316349491537, 29.75558120546187],
|
||||
[95.08366839703267, 29.755518567048284],
|
||||
[95.08417639041532, 29.75546236367944],
|
||||
[95.08468755179344, 29.755412571989652],
|
||||
[95.08520236790308, 29.755369015997044],
|
||||
[95.08572134650697, 29.75533148313236],
|
||||
[95.0862450098908, 29.755299740305322],
|
||||
[95.08677390781156, 29.75527358808779],
|
||||
[95.08730861289804, 29.755252808551163],
|
||||
[95.08784970300886, 29.755237175354623],
|
||||
[95.08839755885471, 29.755226476280296],
|
||||
[95.08895256438073, 29.755220494902016],
|
||||
[95.089514993805, 29.755219019351184],
|
||||
[95.09008489086116, 29.7552217729881],
|
||||
[95.09066229928165, 29.75522847917293],
|
||||
[95.09124726342738, 29.755238870403907],
|
||||
[95.09183976966378, 29.755252726598844],
|
||||
[95.09243978867215, 29.75526985018834],
|
||||
[95.09304723439128, 29.755290065097345],
|
||||
[95.09366184648091, 29.75531326126932],
|
||||
[95.09428337414857, 29.755339326723966],
|
||||
[95.09491171647382, 29.755368102171502],
|
||||
[95.09554680939114, 29.75539940595205],
|
||||
[95.09618846028675, 29.755433126230766],
|
||||
[95.09683632409143, 29.75546923335011],
|
||||
[95.09749004228215, 29.75550775219342],
|
||||
[95.09814924669702, 29.755548754655436],
|
||||
[95.0988136358514, 29.755592374644042],
|
||||
[95.09948290826172, 29.755638746066982],
|
||||
[95.1001568080277, 29.75568798494813],
|
||||
[95.10083517891061, 29.75574017449467],
|
||||
[95.10151797539464, 29.75579531974171],
|
||||
[95.1022051519645, 29.755853425724286],
|
||||
[95.10289664375364, 29.755914488290983],
|
||||
[95.10359258928374, 29.755978570204803],
|
||||
[95.10429306658138, 29.756045742645284],
|
||||
[95.10499798654989, 29.756116067070348],
|
||||
[95.10570717321444, 29.75618958016383],
|
||||
[95.10642032295875, 29.75626636089044],
|
||||
[95.10713711848761, 29.756346492746122],
|
||||
[95.10785729025027, 29.75643004436662],
|
||||
[95.10858043622838, 29.75651688160893],
|
||||
[95.10930611534633, 29.75660684892344],
|
||||
[95.11003407093642, 29.756699686157475],
|
||||
[95.11076414467685, 29.756795123665622],
|
||||
[95.1114960914754, 29.756892893838618],
|
||||
[95.11222971288655, 29.756992700238396],
|
||||
[95.1129649452222, 29.75709420093414],
|
||||
[95.11370170201873, 29.75719709582746],
|
||||
[95.11443986012442, 29.757301125099488],
|
||||
[95.11517935555177, 29.7574061249826],
|
||||
[95.11592009761476, 29.757511940391662],
|
||||
[95.11666232015585, 29.75761835330566],
|
||||
[95.11740624233691, 29.757725222810585],
|
||||
[95.11815181076378, 29.75783253698326],
|
||||
[95.11889913308254, 29.757940178669305],
|
||||
[95.11964841222917, 29.758048000558695],
|
||||
[95.12039993276164, 29.758155854652614],
|
||||
[95.12115399312397, 29.75826357999786],
|
||||
[95.1219108497459, 29.75837102690515],
|
||||
[95.1226706293697, 29.758478081829693],
|
||||
[95.12343325561484, 29.758584686897258],
|
||||
[95.12419857627997, 29.758690818430438],
|
||||
[95.12496638982559, 29.75879646083105],
|
||||
[95.12573649209446, 29.7589015818837],
|
||||
[95.12650863318528, 29.75900618637391],
|
||||
[95.12728253192327, 29.759110282431784],
|
||||
[95.12805798566751, 29.75921386162033],
|
||||
[95.12883477913662, 29.759316919371038],
|
||||
[95.12961273528985, 29.759419445656786],
|
||||
[95.13039166301432, 29.759521458216526],
|
||||
[95.13117134363408, 29.759622963323043],
|
||||
[95.13195155721434, 29.759723948972997],
|
||||
[95.13273211930314, 29.759824349786275],
|
||||
[95.13351286507051, 29.759924061406032],
|
||||
[95.13428648274362, 29.76002351015658],
|
||||
[95.1350533298064, 29.760122544307915],
|
||||
[95.1358137532818, 29.760221010686855],
|
||||
[95.13656784419986, 29.760318809952974],
|
||||
[95.13731567503761, 29.76041584848236],
|
||||
[95.1380573752464, 29.760511990985425],
|
||||
[95.13879310931623, 29.76060704815151],
|
||||
[95.13952306473344, 29.760700794123938],
|
||||
[95.14024751710419, 29.7607929520901],
|
||||
[95.14096671686433, 29.760883206230545],
|
||||
[95.14168092332761, 29.76097123202995],
|
||||
[95.14239029599311, 29.761056732470486],
|
||||
[95.14309486625548, 29.761139484506582],
|
||||
[95.14379454089128, 29.761219462160696],
|
||||
[95.14448922306659, 29.76129664046317],
|
||||
[95.14517882147311, 29.76137100941381],
|
||||
[95.14586312132259, 29.761442532574584],
|
||||
[95.14654188135431, 29.761511168945386],
|
||||
[95.14721489938523, 29.761576901934955],
|
||||
[95.14788204807155, 29.761639772673718],
|
||||
[95.14854319868547, 29.76169982923195],
|
||||
[95.14919822095698, 29.761757127412878],
|
||||
[95.14984699116548, 29.76181173598209],
|
||||
[95.15048960377052, 29.761863851723042],
|
||||
[95.15112617637942, 29.761913696049017],
|
||||
[95.15175681293137, 29.761961561816054],
|
||||
[95.15238148064795, 29.762007691856205],
|
||||
[95.15300009096235, 29.762052309028526],
|
||||
[95.1533539374323, 29.762086082676035],
|
||||
[95.15370650809008, 29.762118928860186],
|
||||
[95.15405759583123, 29.762150875711065],
|
||||
[95.15440704121897, 29.76218191319173],
|
||||
[95.1547548321985, 29.762211967913583],
|
||||
[95.15510097264206, 29.762240955288657],
|
||||
[95.15544525415498, 29.762268829619945],
|
||||
[95.15578751039571, 29.76229553455492],
|
||||
[95.15612769998111, 29.762320941845203],
|
||||
[95.15646565712565, 29.762344985615734],
|
||||
[95.156801142589, 29.76236761294652],
|
||||
[95.1571338046714, 29.76238883654028],
|
||||
[95.15746323934037, 29.76240869370805],
|
||||
[95.15778903282519, 29.762427217766984],
|
||||
[95.15811077257146, 29.762444433666733],
|
||||
[95.15842800577592, 29.762460368791256],
|
||||
[95.15874028837707, 29.76247502889767],
|
||||
[95.15904716834646, 29.76248842410003],
|
||||
[95.1593481499996, 29.762500590502704],
|
||||
[95.15964271930007, 29.76251153961251],
|
||||
[95.1599303416933, 29.76252129267978],
|
||||
[95.16021045104225, 29.762529892932115],
|
||||
[95.16048243037355, 29.762537389252305],
|
||||
[95.16074559672008, 29.762543832974302],
|
||||
[95.1609993202866, 29.762549265122786],
|
||||
[95.16124294069552, 29.762553753099137],
|
||||
[95.16147571071544, 29.762557390653928],
|
||||
[95.16169677155058, 29.762560323342807]
|
||||
]
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
[
|
||||
[-74.0135, 40.7047],
|
||||
[-74.0102, 40.7104],
|
||||
[-74.0074, 40.7167],
|
||||
[-74.0047, 40.7232],
|
||||
[-74.0017, 40.7297],
|
||||
[-73.9988, 40.7362],
|
||||
[-73.9954, 40.7429],
|
||||
[-73.9917, 40.7496],
|
||||
[-73.9882, 40.7563],
|
||||
[-73.9848, 40.7631]
|
||||
]
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import {Composition} from 'remotion';
|
||||
import {CesiumFlythrough, type CesiumFlythroughProps} from './CesiumFlythrough';
|
||||
import cityPath from './city-path.json';
|
||||
|
||||
export const RemotionRoot: React.FC = () => (
|
||||
<>
|
||||
<Composition
|
||||
id="LandscapeFlyover"
|
||||
component={CesiumFlythrough}
|
||||
defaultProps={{mode: 'landscape'} satisfies CesiumFlythroughProps}
|
||||
durationInFrames={24 * 30}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
<Composition
|
||||
id="CityFlyover"
|
||||
component={CesiumFlythrough}
|
||||
defaultProps={
|
||||
{
|
||||
mode: 'city',
|
||||
path: cityPath as [number, number][],
|
||||
altitudeStart: 700,
|
||||
altitudeEnd: 500,
|
||||
lookAheadKm: 0.7,
|
||||
travelKm: 4.5,
|
||||
pitchFromNadir: 72,
|
||||
verticalExaggeration: 1,
|
||||
maximumScreenSpaceError: 6,
|
||||
} satisfies CesiumFlythroughProps
|
||||
}
|
||||
durationInFrames={18 * 30}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
export type LngLat = [number, number];
|
||||
|
||||
// Chaikin corner cutting turns a sparse route into a continuous curve. Repeated passes round
|
||||
// direction changes into deliberate swerves instead of left-right heading bumps.
|
||||
export const smoothFlightPath = (source: LngLat[], passes = 3): LngLat[] => {
|
||||
if (source.length < 2)
|
||||
throw new Error('Flyover path needs at least two points');
|
||||
|
||||
// Keep adjacent longitudes continuous for routes that cross the antimeridian.
|
||||
const unwrapped: LngLat[] = [source[0]];
|
||||
for (let index = 1; index < source.length; index++) {
|
||||
const [lng, lat] = source[index];
|
||||
const previous = unwrapped[index - 1][0];
|
||||
let adjusted = lng;
|
||||
while (adjusted - previous > 180) adjusted -= 360;
|
||||
while (adjusted - previous < -180) adjusted += 360;
|
||||
unwrapped.push([adjusted, lat]);
|
||||
}
|
||||
|
||||
let curve = unwrapped;
|
||||
for (let pass = 0; pass < Math.max(0, passes); pass++) {
|
||||
const next: LngLat[] = [curve[0]];
|
||||
for (let i = 0; i < curve.length - 1; i++) {
|
||||
const a = curve[i];
|
||||
const b = curve[i + 1];
|
||||
next.push(
|
||||
[a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25],
|
||||
[a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75],
|
||||
);
|
||||
}
|
||||
next.push(curve[curve.length - 1]);
|
||||
curve = next;
|
||||
}
|
||||
return curve;
|
||||
};
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"name": "Yarlung Tsangpo (OSM, gorge way)"},
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [
|
||||
[94.8794306, 29.5453548],
|
||||
[94.880058, 29.5500259],
|
||||
[94.8804545, 29.5529773],
|
||||
[94.8872806, 29.559007],
|
||||
[94.8902386, 29.5645817],
|
||||
[94.8945618, 29.5651505],
|
||||
[94.8985437, 29.5703839],
|
||||
[94.8978611, 29.5758448],
|
||||
[94.9077237, 29.5843086],
|
||||
[94.9107706, 29.5872382],
|
||||
[94.9136749, 29.5879042],
|
||||
[94.9208424, 29.5864253],
|
||||
[94.9243692, 29.5890419],
|
||||
[94.9243692, 29.5945028],
|
||||
[94.9268508, 29.597544],
|
||||
[94.9331942, 29.6000111],
|
||||
[94.9350913, 29.6024165],
|
||||
[94.9359736, 29.6042869],
|
||||
[94.9354367, 29.6064452],
|
||||
[94.9350634, 29.608155],
|
||||
[94.9343462, 29.6094434],
|
||||
[94.9295695, 29.6113703],
|
||||
[94.9288061, 29.6124782],
|
||||
[94.9268721, 29.6194181],
|
||||
[94.9273809, 29.6276608],
|
||||
[94.9274409, 29.6286334],
|
||||
[94.9263783, 29.6303683],
|
||||
[94.9250518, 29.6304537],
|
||||
[94.922744, 29.6299103],
|
||||
[94.9170292, 29.6256472],
|
||||
[94.9144757, 29.6243553],
|
||||
[94.9115439, 29.6243318],
|
||||
[94.9008302, 29.6273853],
|
||||
[94.8972905, 29.6276085],
|
||||
[94.8897112, 29.6252949],
|
||||
[94.8827398, 29.6253184],
|
||||
[94.8806998, 29.6268686],
|
||||
[94.8777241, 29.6317051],
|
||||
[94.8768493, 29.6342436],
|
||||
[94.8768641, 29.6346894],
|
||||
[94.8769277, 29.6365972],
|
||||
[94.8775677, 29.6391137],
|
||||
[94.8804943, 29.6418187],
|
||||
[94.8821969, 29.6438716],
|
||||
[94.8877263, 29.6456718],
|
||||
[94.8903366, 29.6468971],
|
||||
[94.8959561, 29.6481151],
|
||||
[94.9032857, 29.6528729],
|
||||
[94.9113869, 29.6568592],
|
||||
[94.9162734, 29.6587881],
|
||||
[94.9182022, 29.6625172],
|
||||
[94.9203882, 29.670104],
|
||||
[94.9197453, 29.6744761],
|
||||
[94.9192644, 29.6754058],
|
||||
[94.9178164, 29.6782052],
|
||||
[94.915116, 29.6816771],
|
||||
[94.9094581, 29.6810342],
|
||||
[94.9043145, 29.6796197],
|
||||
[94.9018712, 29.6806484],
|
||||
[94.8976278, 29.6812914],
|
||||
[94.8909411, 29.6856634],
|
||||
[94.8896552, 29.6890068],
|
||||
[94.892227, 29.6990368],
|
||||
[94.8978849, 29.704952],
|
||||
[94.9017426, 29.7100956],
|
||||
[94.9061206, 29.7112788],
|
||||
[94.9065005, 29.7113815],
|
||||
[94.9225743, 29.7192255],
|
||||
[94.9264023, 29.7212652],
|
||||
[94.9267629, 29.7213678],
|
||||
[94.9341474, 29.723469],
|
||||
[94.9360823, 29.7245705],
|
||||
[94.9357152, 29.7273333],
|
||||
[94.9344915, 29.7289804],
|
||||
[94.9323502, 29.7303263],
|
||||
[94.9298621, 29.73339],
|
||||
[94.9294542, 29.734665],
|
||||
[94.9298213, 29.7382244],
|
||||
[94.9306755, 29.739157],
|
||||
[94.9340021, 29.7439794],
|
||||
[94.9355331, 29.7464083],
|
||||
[94.9364901, 29.7488664],
|
||||
[94.9372115, 29.749423],
|
||||
[94.937775, 29.7498579],
|
||||
[94.939019, 29.7500527],
|
||||
[94.9407117, 29.7501944],
|
||||
[94.9429346, 29.7513629],
|
||||
[94.947778, 29.7543307],
|
||||
[94.9541921, 29.755984],
|
||||
[94.9574551, 29.7555414],
|
||||
[94.9603715, 29.7553644],
|
||||
[94.9629516, 29.755488],
|
||||
[94.9676317, 29.757489],
|
||||
[94.9709355, 29.7587814],
|
||||
[94.9737019, 29.7603278],
|
||||
[94.9738572, 29.7603796],
|
||||
[94.9844009, 29.7638941],
|
||||
[94.9852725, 29.7649069],
|
||||
[94.9861359, 29.7675568],
|
||||
[94.9898232, 29.7694497],
|
||||
[94.9917263, 29.7691954],
|
||||
[94.9966421, 29.7688099],
|
||||
[95.0005707, 29.7670075],
|
||||
[95.0007566, 29.7668999],
|
||||
[95.0028203, 29.7657056],
|
||||
[95.0080406, 29.7614995],
|
||||
[95.0092963, 29.7609572],
|
||||
[95.0138378, 29.7589957],
|
||||
[95.0160159, 29.7574362],
|
||||
[95.0194618, 29.7553902],
|
||||
[95.0222306, 29.7524106],
|
||||
[95.024336, 29.7498815],
|
||||
[95.0273932, 29.7487547],
|
||||
[95.0324981, 29.7493557],
|
||||
[95.0339552, 29.7496724],
|
||||
[95.0346257, 29.7499916],
|
||||
[95.035882, 29.7507411],
|
||||
[95.0375964, 29.7530966],
|
||||
[95.0386203, 29.754818],
|
||||
[95.0383895, 29.7567272],
|
||||
[95.038166, 29.7590432],
|
||||
[95.0395792, 29.7618349],
|
||||
[95.0411334, 29.7631575],
|
||||
[95.0420532, 29.7639402],
|
||||
[95.0467825, 29.7661735],
|
||||
[95.0495101, 29.7673453],
|
||||
[95.0510063, 29.7681933],
|
||||
[95.0516577, 29.7683919],
|
||||
[95.0560621, 29.7692331],
|
||||
[95.0577259, 29.7690047],
|
||||
[95.0588453, 29.7684695],
|
||||
[95.0650773, 29.767807],
|
||||
[95.0705737, 29.761782],
|
||||
[95.0720258, 29.7599696],
|
||||
[95.0736121, 29.7540856],
|
||||
[95.0754147, 29.751782],
|
||||
[95.0758224, 29.751042],
|
||||
[95.0760619, 29.750314],
|
||||
[95.0759483, 29.7495284],
|
||||
[95.0751984, 29.7480135],
|
||||
[95.0728911, 29.7466988],
|
||||
[95.0724585, 29.7445327],
|
||||
[95.0732804, 29.7429802],
|
||||
[95.0734102, 29.740789],
|
||||
[95.0731362, 29.7386729],
|
||||
[95.0744382, 29.7374902],
|
||||
[95.075294, 29.7367129],
|
||||
[95.079535, 29.7361346],
|
||||
[95.081745, 29.736994],
|
||||
[95.083005, 29.737484],
|
||||
[95.0833905, 29.7430745],
|
||||
[95.0844421, 29.7452715],
|
||||
[95.0871532, 29.7473749],
|
||||
[95.0918361, 29.7499864],
|
||||
[95.0931923, 29.7511573],
|
||||
[95.0943202, 29.7514189],
|
||||
[95.0965266, 29.7510558],
|
||||
[95.0991656, 29.7494408],
|
||||
[95.1029871, 29.7472998],
|
||||
[95.1038668, 29.7453591],
|
||||
[95.1063039, 29.7417406],
|
||||
[95.1084814, 29.741365],
|
||||
[95.111726, 29.7426671],
|
||||
[95.1138489, 29.7452914],
|
||||
[95.1152135, 29.7465013],
|
||||
[95.1154175, 29.7473556],
|
||||
[95.1158661, 29.7480506],
|
||||
[95.1167533, 29.7482586],
|
||||
[95.1179004, 29.7491572],
|
||||
[95.119434, 29.7515181],
|
||||
[95.1195997, 29.7517319],
|
||||
[95.1214023, 29.7535598],
|
||||
[95.1239692, 29.754987],
|
||||
[95.1260602, 29.7556005],
|
||||
[95.1284973, 29.7561388],
|
||||
[95.1311663, 29.7559002],
|
||||
[95.1323477, 29.7559385],
|
||||
[95.1344757, 29.7564723],
|
||||
[95.1350155, 29.7583672],
|
||||
[95.1382746, 29.7617848],
|
||||
[95.1400377, 29.7646815],
|
||||
[95.1410982, 29.7654339],
|
||||
[95.1437689, 29.7649144],
|
||||
[95.1469991, 29.7640131],
|
||||
[95.1503617, 29.7622343],
|
||||
[95.1506908, 29.7620602],
|
||||
[95.1532721, 29.7600322],
|
||||
[95.1565312, 29.7590432],
|
||||
[95.1584059, 29.7594063],
|
||||
[95.1589059, 29.7599317],
|
||||
[95.1595043, 29.7612399],
|
||||
[95.1611602, 29.7632119],
|
||||
[95.1635565, 29.7646136],
|
||||
[95.1700434, 29.7649144],
|
||||
[95.1730828, 29.7644136],
|
||||
[95.1748094, 29.7636814],
|
||||
[95.1765005, 29.7628339],
|
||||
[95.1794885, 29.7589784],
|
||||
[95.1830673, 29.7576787],
|
||||
[95.1848212, 29.7580682],
|
||||
[95.1869103, 29.7594603]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Flyover data sources
|
||||
|
||||
## Landscape
|
||||
|
||||
Use MapTiler for both layers:
|
||||
|
||||
- `terrain-quantized-mesh-v2`: elevation encoded as Cesium quantized-mesh terrain.
|
||||
- `satellite-v2`: raster satellite imagery draped on that mesh.
|
||||
|
||||
CesiumJS renders the layers; it does not supply the data. Set `REMOTION_MAPTILER_KEY`.
|
||||
|
||||
Create a key:
|
||||
|
||||
- https://cloud.maptiler.com/account/keys/
|
||||
|
||||
Official documentation:
|
||||
|
||||
- https://docs.maptiler.com/cesium/
|
||||
- https://docs.maptiler.com/schema-raster/terrain-3d/
|
||||
|
||||
## City
|
||||
|
||||
Use Google Photorealistic 3D Tiles. Google supplies one high-resolution 3D mesh already textured
|
||||
with imagery. Disable the Cesium globe and do not add MapTiler terrain or satellite beneath it. Set
|
||||
`REMOTION_GOOGLE_MAPS_API_KEY`.
|
||||
|
||||
Create and configure a key:
|
||||
|
||||
- https://developers.google.com/maps/documentation/tile/get-api-key
|
||||
|
||||
Enable the Map Tiles API in a billing-enabled Google Cloud project and restrict the key to that API.
|
||||
The application restriction must permit the local headless Remotion request.
|
||||
|
||||
Official documentation:
|
||||
|
||||
- https://developers.google.com/maps/documentation/tile/3d-tiles
|
||||
- https://developers.google.com/maps/documentation/tile/policies
|
||||
|
||||
## Why not extruded buildings
|
||||
|
||||
OSM-, Overture- and vector-tile building products primarily provide footprints, approximate heights
|
||||
and optional roof attributes. They are useful for analytical or stylized maps, but they do not
|
||||
provide the textured architecture required for a cinematic city flyover.
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# 3D Flyover — architecture reference
|
||||
|
||||
Deep detail behind `TECHNIQUE.md`: provider loading, the camera-path pipeline, per-frame camera math, and
|
||||
the proven terrain values. Both landscape and city modes have been forward-tested through Remotion.
|
||||
|
||||
## 1. Provider initialization
|
||||
|
||||
Create the Viewer with `baseLayer: false`, UI widgets disabled, and
|
||||
`contextOptions.webgl.preserveDrawingBuffer: true`. Never hide the credit display.
|
||||
|
||||
### Landscape
|
||||
|
||||
Add MapTiler `satellite-v2` with `UrlTemplateImageryProvider`, then load
|
||||
`terrain-quantized-mesh-v2` with `CesiumTerrainProvider.fromUrl({requestVertexNormals: true})`.
|
||||
MapTiler supplies both datasets; no Cesium ion token is required.
|
||||
|
||||
### City
|
||||
|
||||
Do not add MapTiler. Hide the globe, then add:
|
||||
|
||||
```ts
|
||||
viewer.scene.globe.show = false;
|
||||
const tileset = await Cesium.Cesium3DTileset.fromUrl(
|
||||
`https://tile.googleapis.com/v1/3dtiles/root.json?key=${GOOGLE_KEY}`,
|
||||
{showCreditsOnScreen: true, maximumScreenSpaceError: 4},
|
||||
);
|
||||
viewer.scene.primitives.add(tileset);
|
||||
```
|
||||
|
||||
Lower `maximumScreenSpaceError` improves refinement at substantial download/render cost. Start at
|
||||
`4` for a hero landmark and `6–8` for wider urban shots. Enforce the Google 30-second promotional
|
||||
video ceiling in the component.
|
||||
|
||||
Load Cesium from the CDN after setting `window.CESIUM_BASE_URL`; the tested version is `1.143`.
|
||||
|
||||
## 2. The camera path — structure & generation
|
||||
|
||||
Four properties matter, in order:
|
||||
|
||||
1. **Continuous curvature** — the camera must flow through curves, never "fly straight, snap to a new
|
||||
heading, fly straight." The component applies three passes of **Chaikin corner cutting** to every
|
||||
supplied path. Each pass replaces a segment with quarter and three-quarter points, rounding a
|
||||
corner into a curve. Three passes are the default; four is softer, two is tighter.
|
||||
2. **Constant ground speed** — precompute cumulative distance along the rounded curve and interpolate
|
||||
by arc length. Do not animate by source-point index; unequal source spacing creates speed bumps.
|
||||
3. **Minimized amplitude** — for detailed landscape centerlines, dampen the prepared path toward its
|
||||
straight start→end chord by a fixed fraction `DAMP` (0 = dead straight, 1 = full river). This is
|
||||
the single swerve-amplitude knob.
|
||||
4. **Enough length** — `PATHKM ≥ TRAVEL_KM + 2·LOOK_AHEAD_KM` so the look-ahead aim never clamps.
|
||||
|
||||
`../scripts/prep-cesium-path.mjs` does: clip a window of the source centerline → resample to even
|
||||
arc-length spacing (0.1 km) → moving-average smooth (±2.8 km window, 2 passes) → dampen toward the chord
|
||||
(`DAMP=0.45`). Validate with the heading-delta probe it prints — deltas should be small and change
|
||||
_gradually_ (water-wars: `3,0,-1,-3,-4,1,7,9,5,-5,-12,-7,…`). Big jumps = corners = bad.
|
||||
|
||||
The component then applies Chaikin smoothing to this prepared route, or directly to a short
|
||||
hand-authored city route. Keep city control points sparse and intentional; smoothing cannot rescue a
|
||||
zig-zagging route that crosses the subject repeatedly.
|
||||
|
||||
**Source data:** OSM via Overpass (~0.3 km vertex spacing) — Natural Earth is too coarse for inner
|
||||
gorges. overpass-api.de is often busy → mirror `overpass.kumi.systems`.
|
||||
|
||||
## 3. Camera animation per frame (position, heading, pitch, bank)
|
||||
|
||||
Walk the path by **arc length** (precompute cumulative distances once). Every frame:
|
||||
|
||||
- **Position** = the point at `dCam` km along the path, altitude `lerp(ALT_START, ALT_END, prog)`.
|
||||
- **Heading** = bearing from the camera point to a **real point `LOOK_AHEAD_KM` further along the same
|
||||
path**. A far aim averages wiggle → smooth heading; on a curved path it leads into the bend, so the
|
||||
heading turns gently with the path. (A local-tangent aim spins the camera at every kink — don't.)
|
||||
- **Pitch** = constant. We keep a MapLibre-style param `PITCH_FROM_NADIR` (90 = horizon), then convert:
|
||||
**Cesium pitch = `-(90 - PITCH_FROM_NADIR)`** (Cesium: 0 = horizon, -90 = straight down). 76° → -14°.
|
||||
- **Bank (roll)** = lean _into_ the turn — the helicopter tell. Measure turn rate as the bearing change
|
||||
between `aim` and a point `2·LOOK_AHEAD_KM` ahead; `roll = clamp(dH · BANK_GAIN, ±MAX_BANK)`. Because
|
||||
the path is smooth, `dH` changes gradually → the bank eases in and out, never jerks.
|
||||
|
||||
```ts
|
||||
const setCamera = (C, viewer, prog) => {
|
||||
const dCam = Math.min(TRAVEL_KM, PATHKM - LOOK_AHEAD_KM * 2) * prog;
|
||||
const cam = alongPath(dCam); // arc-length point
|
||||
const aim = alongPath(dCam + LOOK_AHEAD_KM); // heading target (real point on the path)
|
||||
const aim2 = alongPath(dCam + LOOK_AHEAD_KM * 2); // turn-rate probe → bank
|
||||
const heading = bearing(cam, aim);
|
||||
let dH = bearing(aim, aim2) - heading; while (dH > Math.PI) dH -= 2*Math.PI; while (dH < -Math.PI) dH += 2*Math.PI;
|
||||
viewer.camera.setView({
|
||||
destination: C.Cartesian3.fromDegrees(cam[0], cam[1], lerp(ALT_START, ALT_END, prog)),
|
||||
orientation: { heading, pitch: C.Math.toRadians(-(90 - PITCH_FROM_NADIR)), roll: clamp(dH * BANK_GAIN, -MAX_BANK, MAX_BANK) },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
> **Cesium vs MapLibre conventions (gotcha):** Cesium heading is radians, 0 = north, clockwise. Pitch
|
||||
> 0 = horizon, negative = down (MapLibre is the inverse). Roll positive = bank right; tune the sign by eye.
|
||||
|
||||
## 4. The feel — proven water-wars values
|
||||
|
||||
| Param | Value | Meaning |
|
||||
| ------------------------ | ---------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `TRAVEL_KM` | 13 | How far the camera travels. **Speed = `TRAVEL_KM / durationSeconds`.** |
|
||||
| duration | 24 s (720 f @30) | 13 km / 24 s ≈ **0.54 km/s** — a slow, peaceful glide. 8 s felt "extremely rushed". |
|
||||
| `ALT_START → ALT_END` | 4600 → 4300 m ASL | Absolute (terrain-independent). Inside the corridor walls → fly _through_, not over. |
|
||||
| `LOOK_AHEAD_KM` | 1.5 | Heading smoothness vs responsiveness. |
|
||||
| `PITCH_FROM_NADIR` | 76° | Stare-ahead down the corridor (90 = level). → Cesium -14°. |
|
||||
| `MAX_BANK` / `BANK_GAIN` | 0.13 rad (~7.5°) / 0.6 | Helicopter lean into turns. |
|
||||
| `verticalExaggeration` | 1.1 | Subtle terrain drama. |
|
||||
| `DAMP` (prep) | 0.45 | Swerve amount: higher = weavier, lower = straighter. |
|
||||
|
||||
**A slow camera renders fast.** At 0.54 km/s the camera moves ~18 m/frame, so tiles stay cached and each
|
||||
`settle()` returns almost immediately; the 720-frame render completed in one pass (no chunk-rendering).
|
||||
|
||||
## 5. The complete component
|
||||
|
||||
The full, runnable component is `../assets/CesiumFlythrough.tsx` — read it directly. Its shape:
|
||||
|
||||
- `loadCesium()` — inject `CESIUM_BASE_URL` + the CDN `Cesium.js`, resolve when loaded.
|
||||
- init effect — build the Viewer (§1), `setCamera(…, 0)`, `await settle(viewer)`, `continueRender`.
|
||||
- `settle(viewer)` — loop `viewer.render()` until `globe.tilesLoaded` for landscapes or
|
||||
`tileset.tilesLoaded` for cities is stable for ~8 ticks (cap ~600).
|
||||
- per-frame effect — `delayRender({timeoutInMilliseconds: 60000})` → `setCamera(prog)` → `settle()` → `continueRender`.
|
||||
|
||||
## 6. Render
|
||||
|
||||
```bash
|
||||
bunx remotion still src/index.ts <Comp> out.png --frame=N --gl=angle --timeout=180000 # validate framing/bank first
|
||||
bunx remotion render src/index.ts <Comp> out.mp4 --gl=angle --concurrency=1 --timeout=180000
|
||||
```
|
||||
|
||||
`--gl=angle` is mandatory. Use `--concurrency=1`; `settle()` already serializes tile loading.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# 3D Flyover — troubleshooting
|
||||
|
||||
## The headless dead-end (why we render through Remotion)
|
||||
|
||||
Cesium's globe **will not draw in a standalone headless Playwright/Chromium** harness. Verified on
|
||||
Apple M4 (ANGLE Metal active, WebGL working): the skybox/stars render, but the globe surface produces
|
||||
**zero draw commands** (`scene.frameState.commandList.length === 0`), `globe.tilesLoaded` never goes
|
||||
true, and frames come back as the black starfield. No network failures; `sampleTerrainMostDetailed`
|
||||
succeeds (terrain data is reachable). Dead-ends tried, all failed:
|
||||
|
||||
- default render loop, manual `scene.render()`, manual `viewer.render()`, headed mode (context-destroyed).
|
||||
|
||||
**What works:** render Cesium **through Remotion** — same headless Chrome, but driven by Remotion's frame
|
||||
loop with these four non-negotiables:
|
||||
|
||||
1. `useDefaultRenderLoop = false` — drive frames by hand.
|
||||
2. Per frame call **`viewer.render()`**, NOT `scene.render()`. `viewer.render()` does the full frame
|
||||
(`initializeFrame` → tile streaming → render); `scene.render()` skips frame-init, so tiles never
|
||||
advance and the globe never appears. **This is the single most important line.**
|
||||
3. `contextOptions: { webgl: { preserveDrawingBuffer: true } }` so Remotion's screenshot captures pixels.
|
||||
4. Gate init + every frame with `delayRender(…, {timeoutInMilliseconds})` — tile loading can exceed Remotion's
|
||||
default; use 60–120 s.
|
||||
|
||||
The standalone `flythrough.html` / `render.mjs` / `probe.mjs` from the original spike are kept only as the
|
||||
record of this dead-end. The canonical render path is the Remotion component
|
||||
(`../assets/CesiumFlythrough.tsx`).
|
||||
|
||||
## Symptom → fix
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Frames are black with stars | Globe not drawing (headless Playwright, or `scene.render()` used) | Render through Remotion; use `viewer.render()`; `useDefaultRenderLoop=false`. |
|
||||
| Screenshots blank/transparent | No `preserveDrawingBuffer` | `contextOptions:{ webgl:{ preserveDrawingBuffer:true } }`. |
|
||||
| "delayRender timed out" | Cold tiles exceed the default | `delayRender(…, {timeoutInMilliseconds: 120000})` + `--timeout=180000`. |
|
||||
| Globe is a dark/navy sphere | Imagery layer didn't attach | `baseLayer:false` then `viewer.imageryLayers.addImageryProvider(...)`. |
|
||||
| High-pitch frame shows a void/starfield above the horizon | No atmosphere | `viewer.scene.skyAtmosphere.show = true`. |
|
||||
| 403 on tiles in headless | Domain-locked MapTiler key | Use an **unrestricted** key. |
|
||||
| Google root tileset returns 403 | Map Tiles API disabled, billing absent, wrong key, or application restriction blocks local headless rendering | Enable Map Tiles API and billing; restrict the key to that API while allowing the Remotion request. |
|
||||
| Google scene shows a duplicate/competing surface | MapTiler or the Cesium globe is still enabled | Do not add MapTiler; set `viewer.scene.globe.show=false`. |
|
||||
| Google mesh remains coarse | Screen-space error is too high or the capture starts before refinement | Lower `maximumScreenSpaceError`; settle on `tileset.tilesLoaded`. |
|
||||
| WebGL unavailable / software renderer | Missing GL flag | Render with `--gl=angle`. |
|
||||
| Camera looks at sky / ground, not terrain | Pitch sign / convention | Cesium pitch 0 = horizon, negative = down (inverse of MapLibre); `-(90 - PITCH_FROM_NADIR)`. |
|
||||
| Aim/turn-probe clamps near the end | Path too short | `PATHKM ≥ TRAVEL_KM + 2·LOOK_AHEAD_KM`; raise `WINDOW_KM` in prep. |
|
||||
| Path feels like straight-then-corner | Douglas-Peucker simplification | Use resample → moving-average smooth (see architecture §2), not `turf.simplify`. |
|
||||
| Camera bumps left and right instead of swerving | Sparse route vertices are still being followed as straight segments | Keep `pathSmoothingPasses={3}`; use sparse intentional control points and arc-length movement. |
|
||||
|
||||
## Gotchas checklist
|
||||
|
||||
- **`viewer.render()`, never `scene.render()`** per frame. The single biggest trap.
|
||||
- Cesium loads from **CDN**; set `window.CESIUM_BASE_URL` _before_ injecting the script.
|
||||
- `preserveDrawingBuffer: true` or screenshots are blank.
|
||||
- `delayRender` uses `timeoutInMilliseconds`; set it to at least 60,000.
|
||||
- Terrain: `baseLayer:false`, then add MapTiler imagery.
|
||||
- Google: no MapTiler, hide the globe, retain `showCreditsOnScreen:true`.
|
||||
- Always `skyAtmosphere.show = true`.
|
||||
- Validate the path's heading-delta probe and render one **still** (framing + bank) before the full mp4.
|
||||
- Cesium pitch/roll conventions are inverted vs MapLibre.
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
// Camera-path generator for the Cesium flythrough. Turns a river/route centerline GeoJSON into a LONG,
|
||||
// CONTINUOUSLY-curving camera path so the camera banks through smooth flowing curves (no
|
||||
// straight-then-corner). Method: clip → resample to even spacing → moving-average smooth (inherently
|
||||
// continuous curvature) → dampen lateral deviation toward the straight chord (dials swerve amplitude).
|
||||
// No Douglas-Peucker (that concentrates curvature at sparse control points → corners).
|
||||
//
|
||||
// RUN (out of the box, against the shipped sample):
|
||||
// node prep-cesium-path.mjs
|
||||
// → reads assets/sample-river.geojson (override: node prep-cesium-path.mjs <input.geojson> <output.json>)
|
||||
// → writes assets/cesium-path.json (then import that JSON in CesiumFlythrough.tsx, or copy it
|
||||
// into your Remotion project's src/geo/ and adjust the import)
|
||||
//
|
||||
// ADAPT for a new location: change START (a point ON your centerline where the corridor opens),
|
||||
// WINDOW_KM, and DAMP/SMOOTH below. Input must be a single LineString feature (features[0].geometry).
|
||||
|
||||
import {readFileSync, writeFileSync, mkdirSync} from 'fs';
|
||||
import {dirname, resolve} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const IN = process.argv[2] || resolve(__dir, '../assets/sample-river.geojson');
|
||||
const OUT = process.argv[3] || resolve(__dir, '../assets/cesium-path.json');
|
||||
const havKm = (a, b) => {
|
||||
const R = 6371,
|
||||
r = Math.PI / 180,
|
||||
dLat = (b[1] - a[1]) * r,
|
||||
dLng = (b[0] - a[0]) * r;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(a[1] * r) * Math.cos(b[1] * r) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
};
|
||||
|
||||
const gorge = JSON.parse(readFileSync(IN, 'utf8')).features[0].geometry
|
||||
.coordinates;
|
||||
|
||||
// ADAPT: clip ~24 km of river from the reach where the flythrough opens. START must be a point ON the
|
||||
// centerline (the script snaps to the nearest vertex). The sample's opening is the Yarlung gorge:
|
||||
const START = [94.968, 29.757];
|
||||
let s0 = 0,
|
||||
best = Infinity;
|
||||
gorge.forEach((p, i) => {
|
||||
const d = havKm(p, START);
|
||||
if (d < best) {
|
||||
best = d;
|
||||
s0 = i;
|
||||
}
|
||||
});
|
||||
const WINDOW_KM = 30; // clip to ~end of gorge data; smoothing+dampening shrink it to the usable corridor
|
||||
const clip = [];
|
||||
for (let i = s0, acc = 0; i < gorge.length; i++) {
|
||||
if (i > s0) acc += havKm(gorge[i - 1], gorge[i]);
|
||||
if (acc > WINDOW_KM) break;
|
||||
clip.push(gorge[i]);
|
||||
}
|
||||
|
||||
// Resample to even arc-length spacing so curvature is distributed evenly along the path.
|
||||
const STEP_KM = 0.1;
|
||||
const resample = (coords) => {
|
||||
const out = [coords[0].slice()];
|
||||
let carry = 0,
|
||||
from = coords[0];
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
let segLen = havKm(from, coords[i]);
|
||||
while (carry + segLen >= STEP_KM) {
|
||||
const t = (STEP_KM - carry) / segLen;
|
||||
const np = [
|
||||
from[0] + (coords[i][0] - from[0]) * t,
|
||||
from[1] + (coords[i][1] - from[1]) * t,
|
||||
];
|
||||
out.push(np);
|
||||
from = np;
|
||||
segLen = havKm(from, coords[i]);
|
||||
carry = 0;
|
||||
}
|
||||
carry += segLen;
|
||||
from = coords[i];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// Moving-average smoothing — inherently continuous (no kinks). Window in points; repeat for extra glass.
|
||||
const smoothMA = (coords, w, passes) => {
|
||||
let c = coords;
|
||||
for (let p = 0; p < passes; p++) {
|
||||
c = c.map((_, i) => {
|
||||
let sx = 0,
|
||||
sy = 0,
|
||||
n = 0;
|
||||
for (
|
||||
let j = Math.max(0, i - w);
|
||||
j <= Math.min(c.length - 1, i + w);
|
||||
j++
|
||||
) {
|
||||
sx += c[j][0];
|
||||
sy += c[j][1];
|
||||
n++;
|
||||
}
|
||||
return [sx / n, sy / n];
|
||||
});
|
||||
}
|
||||
return c;
|
||||
};
|
||||
|
||||
const SMOOTH_W = 28; // ±2.8 km window — turns meanders into smooth flowing curves
|
||||
const SMOOTH_PASSES = 2;
|
||||
const DAMP = 0.45; // keep 45% of the (already-smooth) deviation → gentle, continuous swerve
|
||||
|
||||
const even = resample(clip);
|
||||
const sm = smoothMA(even, SMOOTH_W, SMOOTH_PASSES);
|
||||
|
||||
const lat0 = (sm[0][1] * Math.PI) / 180;
|
||||
const kx = 111.32 * Math.cos(lat0),
|
||||
ky = 110.57;
|
||||
const toXY = (p) => [(p[0] - sm[0][0]) * kx, (p[1] - sm[0][1]) * ky];
|
||||
const toLL = (xy) => [sm[0][0] + xy[0] / kx, sm[0][1] + xy[1] / ky];
|
||||
const A = toXY(sm[0]),
|
||||
B = toXY(sm[sm.length - 1]);
|
||||
const AB = [B[0] - A[0], B[1] - A[1]],
|
||||
len2 = AB[0] ** 2 + AB[1] ** 2;
|
||||
const path = sm.map((p) => {
|
||||
const P = toXY(p);
|
||||
const t = ((P[0] - A[0]) * AB[0] + (P[1] - A[1]) * AB[1]) / len2;
|
||||
const proj = [A[0] + t * AB[0], A[1] + t * AB[1]];
|
||||
return toLL([
|
||||
proj[0] + (P[0] - proj[0]) * DAMP,
|
||||
proj[1] + (P[1] - proj[1]) * DAMP,
|
||||
]);
|
||||
});
|
||||
|
||||
mkdirSync(dirname(OUT), {recursive: true});
|
||||
writeFileSync(OUT, JSON.stringify(path));
|
||||
|
||||
let len = 0;
|
||||
for (let i = 1; i < path.length; i++) len += havKm(path[i - 1], path[i]);
|
||||
console.log(
|
||||
`cesium-path: clip ${clip.length} → resample ${even.length} → smooth → ${path.length} pts · ${len.toFixed(1)} km`,
|
||||
);
|
||||
const bear = (a, b) => {
|
||||
const r = Math.PI / 180;
|
||||
const y = Math.sin((b[0] - a[0]) * r) * Math.cos(b[1] * r);
|
||||
const x =
|
||||
Math.cos(a[1] * r) * Math.sin(b[1] * r) -
|
||||
Math.sin(a[1] * r) * Math.cos(b[1] * r) * Math.cos((b[0] - a[0]) * r);
|
||||
return (Math.atan2(y, x) * 180) / Math.PI;
|
||||
};
|
||||
// heading sampled every ~1.5 km — should change gradually & continuously (no big jumps = no corners)
|
||||
const stepPts = Math.round(1.5 / STEP_KM);
|
||||
let prev = null,
|
||||
hs = [];
|
||||
for (let i = 0; i + stepPts < path.length; i += stepPts) {
|
||||
const h = bear(path[i], path[i + stepPts]);
|
||||
if (prev !== null) {
|
||||
let d = h - prev;
|
||||
while (d > 180) d -= 360;
|
||||
while (d < -180) d += 360;
|
||||
hs.push(d.toFixed(0));
|
||||
}
|
||||
prev = h;
|
||||
}
|
||||
console.log(` heading deltas every 1.5km (deg): ${hs.join(', ')}`);
|
||||
+449
@@ -0,0 +1,449 @@
|
||||
---
|
||||
name: maps-mapbox
|
||||
description: Make deterministic Remotion 2D map animations with Mapbox GL JS and Turf. Use when the user chooses Mapbox for animated routes, map markers, labels, camera movement, or Mapbox styles.
|
||||
metadata:
|
||||
tags: map, map animation, mapbox, turf, geojson, route animation
|
||||
---
|
||||
|
||||
Use Mapbox GL JS for rendering maps in Remotion when the user wants Mapbox styles or higher-fidelity map visuals and has a Mapbox access token. Use Turf for geospatial operations such as great-circle routes, distances, slicing lines, and positions along routes.
|
||||
|
||||
Use this technique only when the user has a Mapbox access token and wants Mapbox styles or data.
|
||||
|
||||
## Core rules
|
||||
|
||||
- Prefer `@turf/turf` for geospatial work. Do not hand-roll distance, great-circle, route slicing, or coordinate interpolation unless the user explicitly needs a custom non-geodesic effect.
|
||||
- Use GeoJSON sources and Mapbox layers for lines, markers, and labels. Avoid DOM `Marker` elements unless the user specifically asks for HTML markers.
|
||||
- Keep the live map camera static by default. Before moving it on every frame, read [moving-map stability](references/render-stability.md). Prefer a fixed map plate for satellite imagery, hillshade, or a modest 2D reframe.
|
||||
- Use a live per-frame camera only after rendering a short MP4 and checking for shimmer. This 2D technique does not provide genuine terrain, pitch, bearing, or banking.
|
||||
- Disable non-deterministic map behavior: `interactive: false`, `fadeDuration: 0`.
|
||||
- Drive animation from `useCurrentFrame()`; do not use CSS transitions or browser-timed animation.
|
||||
- Use `delayRender()` / `continueRender()` around map loading and per-frame map updates.
|
||||
- Set `preserveDrawingBuffer: true` and render WebGL with `bunx remotion ... --gl=angle`.
|
||||
- Before continuing the initial render, add sources/layers, apply the frame-0 camera with `jumpTo()` or `setFreeCameraOptions()`, then wait for `idle`.
|
||||
- Do not add a `mapInstance.remove()` cleanup function; it can interfere with Remotion's render lifecycle.
|
||||
- Use Mapbox style URLs such as `mapbox://styles/mapbox/standard` or a user-provided custom style.
|
||||
- Do not install `@types/mapbox-gl`; Mapbox GL JS ships its own types.
|
||||
- Keep required provider attribution visible and verify current provider terms before rendering.
|
||||
- Record the source and effective date of custom or disputed geography.
|
||||
- Inspect rendered pixels, not only Studio playback, at every required aspect ratio.
|
||||
|
||||
Coordinates in Mapbox, Turf, and GeoJSON are `[longitude, latitude]`.
|
||||
|
||||
```ts
|
||||
const zurich: [number, number] = [8.5417, 47.3769];
|
||||
const newYork: [number, number] = [-74.006, 40.7128];
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install Mapbox GL JS and Turf with the project's package manager.
|
||||
|
||||
```bash
|
||||
npm i mapbox-gl @turf/turf
|
||||
```
|
||||
|
||||
```bash
|
||||
bun i mapbox-gl @turf/turf
|
||||
```
|
||||
|
||||
```bash
|
||||
yarn add mapbox-gl @turf/turf
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm i mapbox-gl @turf/turf
|
||||
```
|
||||
|
||||
Import the Mapbox CSS once in the component or an app-level stylesheet:
|
||||
|
||||
```ts
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
```
|
||||
|
||||
Mapbox requires a public access token. Prefer passing it as an input prop or reading it from an environment variable that is available to the bundled Remotion code.
|
||||
|
||||
```ts
|
||||
const mapboxAccessToken = process.env.REMOTION_MAPBOX_TOKEN;
|
||||
|
||||
if (!mapboxAccessToken) {
|
||||
throw new Error('Set REMOTION_MAPBOX_TOKEN to render Mapbox maps.');
|
||||
}
|
||||
```
|
||||
|
||||
## Basic map example
|
||||
|
||||
```tsx
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {AbsoluteFill, useDelayRender, useVideoConfig} from 'remotion';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
|
||||
const zurich: [number, number] = [8.5417, 47.3769];
|
||||
|
||||
const mapboxAccessToken = process.env.REMOTION_MAPBOX_TOKEN;
|
||||
|
||||
if (!mapboxAccessToken) {
|
||||
throw new Error('Set REMOTION_MAPBOX_TOKEN to render Mapbox maps.');
|
||||
}
|
||||
|
||||
export const MyComposition = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const {delayRender, continueRender} = useDelayRender();
|
||||
const {width, height} = useVideoConfig();
|
||||
const [loadingHandle] = useState(() => delayRender('Loading Mapbox map'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapInstance = new mapboxgl.Map({
|
||||
accessToken: mapboxAccessToken,
|
||||
container: containerRef.current,
|
||||
style: 'mapbox://styles/mapbox/standard',
|
||||
center: zurich,
|
||||
zoom: 7,
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {
|
||||
preserveDrawingBuffer: true,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.on('load', () => {
|
||||
mapInstance.jumpTo({center: zurich, zoom: 7});
|
||||
mapInstance.once('idle', () => {
|
||||
continueRender(loadingHandle);
|
||||
});
|
||||
});
|
||||
}, [continueRender, loadingHandle]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<div ref={containerRef} style={{width, height, position: 'absolute'}} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Animated examples should keep the loaded map in React state and skip per-frame updates until that state is set.
|
||||
|
||||
## Animated flight route example
|
||||
|
||||
This example shows the recommended pattern for route animations:
|
||||
|
||||
- Turf creates the route and markers.
|
||||
- Turf slices the route for line reveal animation.
|
||||
- Mapbox renders the route with GeoJSON sources and layers.
|
||||
- The camera uses `jumpTo()` with animated center, zoom, bearing, and pitch.
|
||||
- Frame 0 is prepared before `continueRender()`.
|
||||
|
||||
```tsx
|
||||
import * as turf from '@turf/turf';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Easing,
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useDelayRender,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import mapboxgl, {type GeoJSONSource, type Map} from 'mapbox-gl';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
|
||||
const zurich: [number, number] = [8.5417, 47.3769];
|
||||
const newYork: [number, number] = [-74.006, 40.7128];
|
||||
|
||||
const mapboxAccessToken = process.env.REMOTION_MAPBOX_TOKEN;
|
||||
|
||||
if (!mapboxAccessToken) {
|
||||
throw new Error('Set REMOTION_MAPBOX_TOKEN to render Mapbox maps.');
|
||||
}
|
||||
|
||||
const greatCircleLine = (from: [number, number], to: [number, number]) => {
|
||||
const route = turf.greatCircle(from, to, {npoints: 100});
|
||||
|
||||
if (route.geometry.type === 'LineString') {
|
||||
return turf.lineString(route.geometry.coordinates);
|
||||
}
|
||||
|
||||
// Great-circle routes crossing the antimeridian can become MultiLineString.
|
||||
// Keep the example valid by choosing the longest segment.
|
||||
const longestSegment = route.geometry.coordinates.reduce((longest, segment) => {
|
||||
return segment.length > longest.length ? segment : longest;
|
||||
});
|
||||
|
||||
return turf.lineString(longestSegment);
|
||||
};
|
||||
|
||||
const targetRoute = greatCircleLine(zurich, newYork);
|
||||
const targetRouteDistance = turf.length(targetRoute);
|
||||
|
||||
const cityMarkers = turf.featureCollection([
|
||||
turf.point(zurich, {name: 'Zurich'}),
|
||||
turf.point(newYork, {name: 'New York'}),
|
||||
]);
|
||||
|
||||
const clampProgress = (progress: number) => Math.min(1, Math.max(0, progress));
|
||||
|
||||
const distanceAlong = (totalDistance: number, progress: number) => {
|
||||
// Keep the route non-empty at progress 0; Turf can error on zero-length slices.
|
||||
return Math.max(0.001, totalDistance * clampProgress(progress));
|
||||
};
|
||||
|
||||
const getPartialTargetRoute = (progress: number) => {
|
||||
return turf.lineSliceAlong(
|
||||
targetRoute,
|
||||
0,
|
||||
distanceAlong(targetRouteDistance, progress),
|
||||
);
|
||||
};
|
||||
|
||||
const getCameraOptions = (progress: number) => {
|
||||
const target = turf.along(
|
||||
targetRoute,
|
||||
distanceAlong(targetRouteDistance, progress),
|
||||
).geometry.coordinates as [number, number];
|
||||
|
||||
return {
|
||||
center: target,
|
||||
zoom: interpolate(progress, [0, 0.5, 1], [7, 2.4, 8], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
}),
|
||||
bearing: interpolate(progress, [0, 1], [-20, 35], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
}),
|
||||
pitch: interpolate(progress, [0, 0.25, 0.75, 1], [25, 55, 55, 30], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const MyComposition = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const frame = useCurrentFrame();
|
||||
const {delayRender, continueRender} = useDelayRender();
|
||||
const {durationInFrames, height, width} = useVideoConfig();
|
||||
const [map, setMap] = useState<Map | null>(null);
|
||||
const [loadingHandle] = useState(() => delayRender('Loading Mapbox map'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapInstance = new mapboxgl.Map({
|
||||
accessToken: mapboxAccessToken,
|
||||
container: containerRef.current,
|
||||
style: 'mapbox://styles/mapbox/standard',
|
||||
center: zurich,
|
||||
zoom: 7,
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {
|
||||
preserveDrawingBuffer: true,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.on('load', () => {
|
||||
mapInstance.addSource('trace', {
|
||||
type: 'geojson',
|
||||
data: getPartialTargetRoute(0),
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'trace-line',
|
||||
type: 'line',
|
||||
source: 'trace',
|
||||
layout: {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#111111',
|
||||
'line-width': 7,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.addSource('city-markers', {
|
||||
type: 'geojson',
|
||||
data: cityMarkers,
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'city-marker-dots',
|
||||
type: 'circle',
|
||||
source: 'city-markers',
|
||||
paint: {
|
||||
'circle-color': '#f03b20',
|
||||
'circle-radius': 12,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': 4,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'city-marker-labels',
|
||||
type: 'symbol',
|
||||
source: 'city-markers',
|
||||
layout: {
|
||||
'text-allow-overlap': true,
|
||||
'text-anchor': 'top',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-offset': [0, 0.9],
|
||||
'text-size': 28,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#111111',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 3,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.jumpTo(getCameraOptions(0));
|
||||
mapInstance.once('idle', () => {
|
||||
setMap(mapInstance);
|
||||
continueRender(loadingHandle);
|
||||
});
|
||||
});
|
||||
}, [continueRender, loadingHandle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = delayRender('Rendering Mapbox frame');
|
||||
const timelineProgress = interpolate(frame, [0, durationInFrames - 1], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
const travelProgress = interpolate(timelineProgress, [0.2, 0.82], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
});
|
||||
const trace = map.getSource('trace') as GeoJSONSource | undefined;
|
||||
|
||||
trace?.setData(getPartialTargetRoute(travelProgress));
|
||||
map.jumpTo(getCameraOptions(travelProgress));
|
||||
|
||||
map.once('idle', () => continueRender(handle));
|
||||
// Force an idle event even if the camera parameters are unchanged from the previous frame.
|
||||
map.triggerRepaint();
|
||||
}, [continueRender, delayRender, durationInFrames, frame, map]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: '#e8eef3'}}>
|
||||
<div ref={containerRef} style={{height, position: 'absolute', width}} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Camera guidance
|
||||
|
||||
For a validated live-camera route animation, animate `center`, `zoom`, `bearing`, and `pitch` with `jumpTo()`:
|
||||
|
||||
```ts
|
||||
map.jumpTo({
|
||||
center,
|
||||
zoom,
|
||||
bearing,
|
||||
pitch,
|
||||
});
|
||||
```
|
||||
|
||||
Keep route progress and camera progress separate if the camera needs to lead, lag, zoom out, or zoom back in. For cinematic 3D camera moves, load the 3D flyover branch from the parent skill.
|
||||
|
||||
## Lines
|
||||
|
||||
Use GeoJSON sources for lines. Unless the user asks, do not add glow effects or extra decorative points.
|
||||
|
||||
For geodesic flight routes, use Turf:
|
||||
|
||||
```ts
|
||||
const line = greatCircleLine(start, end);
|
||||
const distance = turf.length(line);
|
||||
const partialLine = turf.lineSliceAlong(
|
||||
line,
|
||||
0,
|
||||
// Keep the route non-empty at progress 0.
|
||||
Math.max(0.001, distance * progress),
|
||||
);
|
||||
```
|
||||
|
||||
For a visually straight line on the map, use a simple GeoJSON `LineString` between the two points instead of `greatCircle()`.
|
||||
|
||||
## Markers and labels
|
||||
|
||||
Use map-native GeoJSON layers for markers and labels:
|
||||
|
||||
```tsx
|
||||
mapInstance.addSource('markers', {
|
||||
type: 'geojson',
|
||||
data: turf.featureCollection([
|
||||
turf.point([-118.2437, 34.0522], {name: 'Los Angeles'}),
|
||||
]),
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'marker-dots',
|
||||
type: 'circle',
|
||||
source: 'markers',
|
||||
paint: {
|
||||
'circle-color': '#f03b20',
|
||||
'circle-radius': 12,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': 4,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'marker-labels',
|
||||
type: 'symbol',
|
||||
source: 'markers',
|
||||
layout: {
|
||||
'text-allow-overlap': true,
|
||||
'text-anchor': 'top',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-offset': [0, 0.9],
|
||||
'text-size': 28,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#111111',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 3,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Make marker sizes and label font sizes large enough for the composition resolution.
|
||||
|
||||
## Styles
|
||||
|
||||
Default to Mapbox Standard:
|
||||
|
||||
```ts
|
||||
style: 'mapbox://styles/mapbox/standard'
|
||||
```
|
||||
|
||||
If the user requests another style, use any valid Mapbox style URL.
|
||||
|
||||
## Rendering
|
||||
|
||||
For WebGL map renders, prefer single concurrency and ANGLE:
|
||||
|
||||
```bash
|
||||
bunx remotion render [composition-id] out/video.mp4 --gl=angle --concurrency=1
|
||||
```
|
||||
|
||||
Use the equivalent package runner for the project. In npm projects, use `npx`; in Bun projects, use `bunx`.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Moving Map Render Stability
|
||||
|
||||
Read this reference before building a moving 2D MapTiler scene or diagnosing a wavering Remotion render.
|
||||
|
||||
## Symptom and cause
|
||||
|
||||
If basemap detail shimmers or jitters during a pan/zoom, the likely cause is per-frame `map.jumpTo()`.
|
||||
Headless MapTiler capture can resample both vector hillshade and satellite imagery differently frame to
|
||||
frame. Tile retries, easing changes, and label changes do not solve that renderer effect.
|
||||
|
||||
## Required pattern: fixed map plate
|
||||
|
||||
For any 2D pan/zoom:
|
||||
|
||||
1. Render the MapTiler canvas once at the largest required zoom in an oversized container. Size it from the camera route and keep each dimension below the browser's reliable WebGL render-buffer limit (commonly 4096 px). Do **not** blindly use 3×: a 1920×1080 composition becomes 5760 px wide and Chromium may silently downsample it, causing visible pixelation during the CSS zoom.
|
||||
2. Keep the map's camera static.
|
||||
3. For each frame, calculate the approved target centre/zoom, then move the canvas with CSS `translate` + `scale`.
|
||||
4. Apply the same transform to every projected HTML overlay.
|
||||
5. Continue to animate GeoJSON data and paint properties imperatively; only the renderer camera is frozen.
|
||||
|
||||
Keep pitch and bearing constant. Use a 3D engine such as Cesium for genuine changing pitch/bearing or a terrain flythrough.
|
||||
|
||||
```ts
|
||||
const baseZoom = Math.max(start.zoom, end.zoom);
|
||||
const map = new maptilersdk.Map({
|
||||
container,
|
||||
style,
|
||||
center: end.center,
|
||||
zoom: baseZoom,
|
||||
pitch: end.pitch ?? 0,
|
||||
bearing: end.bearing ?? 0,
|
||||
interactive: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {preserveDrawingBuffer: true},
|
||||
});
|
||||
|
||||
// Per Remotion frame. `camera` is the approved centre/zoom interpolation.
|
||||
const projected = map.project(camera.center);
|
||||
const scale = 2 ** (camera.zoom - baseZoom);
|
||||
const plate = {
|
||||
transform: `translate(${width / 2 - projected.x * scale}px, ${height / 2 - projected.y * scale}px) scale(${scale})`,
|
||||
transformOrigin: "0 0",
|
||||
};
|
||||
|
||||
// Convert label projection with exactly the same plate transform.
|
||||
const labelX = labelPoint.x * scale + width / 2 - projected.x * scale;
|
||||
const labelY = labelPoint.y * scale + height / 2 - projected.y * scale;
|
||||
```
|
||||
|
||||
### Plate sizing and sharpness
|
||||
|
||||
- Render at the maximum zoom reached by **any** camera waypoint, including intermediate or hold cameras. The CSS scale should never exceed `1`; otherwise the plate is being enlarged.
|
||||
- Centre the frozen map on the midpoint of the camera route's geographic extent, not automatically on the final camera. This minimizes required overscan.
|
||||
- Keep the largest canvas dimension at or below 4096 px unless the actual render environment has been tested with a larger `MAX_RENDERBUFFER_SIZE`.
|
||||
- For 1920×1080, a 3840×2160 plate is a safe default. For 1080×1920, use approximately 2700×3840 when the route needs extra horizontal pan room.
|
||||
- If the route cannot fit within that plate at the required zoom, split the shot into two fixed plates with a deliberate editorial transition. Do not trade sharpness for one enormous canvas.
|
||||
- Distinguish failure modes: repeating shimmer means the live renderer is moving; steadily soft tiles during a CSS push means the fixed plate is underspecified, internally downsampled, or being scaled above `1`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Render a short MP4, not only a Studio preview.
|
||||
- Inspect static terrain texture and satellite detail while the camera moves.
|
||||
- If any underlying map detail wavers, use the fixed map plate. Do not approve it as a minor preview artefact.
|
||||
- Render WebGL with `--gl=angle`, `preserveDrawingBuffer:true`, and conservative concurrency (`1`) while validating.
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
---
|
||||
name: maps-maplibre
|
||||
description: Make deterministic Remotion 2D map animations with MapLibre GL JS and Turf. Use when the user chooses MapLibre for animated routes, map markers, labels, and camera movement.
|
||||
metadata:
|
||||
tags: map, map animation, maplibre, turf, geojson, route animation
|
||||
---
|
||||
|
||||
Use MapLibre GL JS for rendering maps in Remotion. Use Turf for geospatial operations such as great-circle routes, distances, slicing lines, and positions along routes.
|
||||
|
||||
## Core rules
|
||||
|
||||
- Prefer `@turf/turf` for geospatial work. Do not hand-roll distance, great-circle, route slicing, or coordinate interpolation unless the user explicitly needs a custom non-geodesic effect.
|
||||
- Use GeoJSON sources and MapLibre layers for lines, markers, and labels. Avoid DOM `Marker` elements unless the user specifically asks for HTML markers.
|
||||
- Keep the live map camera static by default. Before moving it on every frame, read [moving-map stability](references/render-stability.md). Prefer a fixed map plate for satellite imagery, hillshade, or a modest 2D reframe.
|
||||
- Use a live per-frame camera only after rendering a short MP4 and checking for shimmer. This 2D technique does not provide genuine terrain, pitch, bearing, or banking.
|
||||
- Disable non-deterministic map behavior: `interactive: false`, `fadeDuration: 0`.
|
||||
- Drive animation from `useCurrentFrame()`; do not use CSS transitions or browser-timed animation.
|
||||
- Use `delayRender()` / `continueRender()` around map loading and per-frame map updates.
|
||||
- Set `preserveDrawingBuffer: true` and render WebGL with `bunx remotion ... --gl=angle`.
|
||||
- Before continuing the initial render, add sources/layers, apply the frame-0 camera with `jumpTo()`, then wait for `idle`.
|
||||
- Do not add a `mapInstance.remove()` cleanup function; it can interfere with Remotion's render lifecycle.
|
||||
- Use standard MapLibre style JSON URLs and layer/source APIs.
|
||||
- Do not install `@types/maplibre-gl`; MapLibre ships its own types.
|
||||
- Keep required provider attribution visible and verify the current terms of the chosen style and tile providers before rendering.
|
||||
- Record the source and effective date of custom or disputed geography.
|
||||
- Inspect rendered pixels, not only Studio playback, at every required aspect ratio.
|
||||
|
||||
Coordinates in MapLibre, Turf, and GeoJSON are `[longitude, latitude]`.
|
||||
|
||||
```ts
|
||||
const zurich: [number, number] = [8.5417, 47.3769];
|
||||
const newYork: [number, number] = [-74.006, 40.7128];
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install MapLibre and Turf with the project's package manager.
|
||||
|
||||
```bash
|
||||
npm i maplibre-gl @turf/turf
|
||||
```
|
||||
|
||||
```bash
|
||||
bun i maplibre-gl @turf/turf
|
||||
```
|
||||
|
||||
```bash
|
||||
yarn add maplibre-gl @turf/turf
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm i maplibre-gl @turf/turf
|
||||
```
|
||||
|
||||
Import the MapLibre CSS once in the component or an app-level stylesheet:
|
||||
|
||||
```ts
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
```
|
||||
|
||||
## Basic map example
|
||||
|
||||
```tsx
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {AbsoluteFill, useDelayRender, useVideoConfig} from 'remotion';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
const zurich: [number, number] = [8.5417, 47.3769];
|
||||
|
||||
export const MyComposition = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const {delayRender, continueRender} = useDelayRender();
|
||||
const {width, height} = useVideoConfig();
|
||||
const [loadingHandle] = useState(() => delayRender('Loading map'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapInstance = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: 'https://demotiles.maplibre.org/style.json',
|
||||
center: zurich,
|
||||
zoom: 7,
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {
|
||||
preserveDrawingBuffer: true,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.on('load', () => {
|
||||
mapInstance.jumpTo({center: zurich, zoom: 7});
|
||||
mapInstance.once('idle', () => {
|
||||
continueRender(loadingHandle);
|
||||
});
|
||||
});
|
||||
}, [continueRender, loadingHandle]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<div ref={containerRef} style={{width, height, position: 'absolute'}} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Animated examples should keep the loaded map in React state and skip per-frame updates until that state is set.
|
||||
|
||||
## Animated flight route example
|
||||
|
||||
This example shows the recommended pattern for route animations:
|
||||
|
||||
- Turf creates the route and markers.
|
||||
- Turf slices the route for line reveal animation.
|
||||
- The camera has a separate route from the target route.
|
||||
- MapLibre's `calculateCameraOptionsFromTo()` is used for camera movement.
|
||||
- Frame 0 is prepared before `continueRender()`.
|
||||
|
||||
```tsx
|
||||
import * as turf from '@turf/turf';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Easing,
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useDelayRender,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import maplibregl, {type GeoJSONSource, type Map} from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
const zurich: [number, number] = [8.5417, 47.3769];
|
||||
const newYork: [number, number] = [-74.006, 40.7128];
|
||||
|
||||
const greatCircleLine = (from: [number, number], to: [number, number]) => {
|
||||
const route = turf.greatCircle(from, to, {npoints: 100});
|
||||
|
||||
if (route.geometry.type === 'LineString') {
|
||||
return turf.lineString(route.geometry.coordinates);
|
||||
}
|
||||
|
||||
// Great-circle routes crossing the antimeridian can become MultiLineString.
|
||||
// Keep the example valid by choosing the longest segment.
|
||||
const longestSegment = route.geometry.coordinates.reduce((longest, segment) => {
|
||||
return segment.length > longest.length ? segment : longest;
|
||||
});
|
||||
|
||||
return turf.lineString(longestSegment);
|
||||
};
|
||||
|
||||
const targetRoute = greatCircleLine(zurich, newYork);
|
||||
const targetRouteDistance = turf.length(targetRoute);
|
||||
|
||||
const cameraRoute = greatCircleLine(zurich, newYork);
|
||||
const cameraRouteDistance = turf.length(cameraRoute);
|
||||
|
||||
const cityMarkers = turf.featureCollection([
|
||||
turf.point(zurich, {name: 'Zurich'}),
|
||||
turf.point(newYork, {name: 'New York'}),
|
||||
]);
|
||||
|
||||
const clampProgress = (progress: number) => Math.min(1, Math.max(0, progress));
|
||||
|
||||
const distanceAlong = (totalDistance: number, progress: number) => {
|
||||
// Keep the route non-empty at progress 0; Turf can error on zero-length slices.
|
||||
return Math.max(0.001, totalDistance * clampProgress(progress));
|
||||
};
|
||||
|
||||
const getPartialTargetRoute = (progress: number) => {
|
||||
return turf.lineSliceAlong(
|
||||
targetRoute,
|
||||
0,
|
||||
distanceAlong(targetRouteDistance, progress),
|
||||
);
|
||||
};
|
||||
|
||||
const getCameraOptions = (
|
||||
map: Map,
|
||||
progress: number,
|
||||
cameraAltitudeMeters: number,
|
||||
cameraLatitudeOffset: number,
|
||||
) => {
|
||||
const target = turf.along(
|
||||
targetRoute,
|
||||
distanceAlong(targetRouteDistance, progress),
|
||||
).geometry.coordinates;
|
||||
const camera = turf.along(
|
||||
cameraRoute,
|
||||
distanceAlong(cameraRouteDistance, progress),
|
||||
).geometry.coordinates;
|
||||
|
||||
return map.calculateCameraOptionsFromTo(
|
||||
new maplibregl.LngLat(camera[0], camera[1] - cameraLatitudeOffset),
|
||||
cameraAltitudeMeters,
|
||||
new maplibregl.LngLat(target[0], target[1]),
|
||||
);
|
||||
};
|
||||
|
||||
export const MyComposition = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const frame = useCurrentFrame();
|
||||
const {delayRender, continueRender} = useDelayRender();
|
||||
const {durationInFrames, height, width} = useVideoConfig();
|
||||
const [map, setMap] = useState<Map | null>(null);
|
||||
const [loadingHandle] = useState(() => delayRender('Loading MapLibre map'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapInstance = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: 'https://demotiles.maplibre.org/style.json',
|
||||
center: zurich,
|
||||
zoom: 7,
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {
|
||||
preserveDrawingBuffer: true,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.on('load', () => {
|
||||
mapInstance.addSource('trace', {
|
||||
type: 'geojson',
|
||||
data: getPartialTargetRoute(0),
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'trace-line',
|
||||
type: 'line',
|
||||
source: 'trace',
|
||||
layout: {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#111111',
|
||||
'line-width': 7,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.addSource('city-markers', {
|
||||
type: 'geojson',
|
||||
data: cityMarkers,
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'city-marker-dots',
|
||||
type: 'circle',
|
||||
source: 'city-markers',
|
||||
paint: {
|
||||
'circle-color': '#f03b20',
|
||||
'circle-radius': 12,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': 4,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'city-marker-labels',
|
||||
type: 'symbol',
|
||||
source: 'city-markers',
|
||||
layout: {
|
||||
'text-allow-overlap': true,
|
||||
'text-anchor': 'top',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-offset': [0, 0.9],
|
||||
'text-size': 28,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#111111',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 3,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.jumpTo(getCameraOptions(mapInstance, 0, 180000, 1.1));
|
||||
mapInstance.once('idle', () => {
|
||||
setMap(mapInstance);
|
||||
continueRender(loadingHandle);
|
||||
});
|
||||
});
|
||||
}, [continueRender, loadingHandle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = delayRender('Rendering MapLibre frame');
|
||||
const timelineProgress = interpolate(frame, [0, durationInFrames - 1], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
const travelProgress = interpolate(timelineProgress, [0.2, 0.82], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
});
|
||||
const cameraAltitudeMeters = interpolate(
|
||||
timelineProgress,
|
||||
[0, 0.28, 0.74, 1],
|
||||
[180000, 2200000, 2200000, 180000],
|
||||
{
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
},
|
||||
);
|
||||
const cameraLatitudeOffset = interpolate(
|
||||
timelineProgress,
|
||||
[0, 0.28, 0.74, 1],
|
||||
[1.1, 8, 8, 1.1],
|
||||
{
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
},
|
||||
);
|
||||
const trace = map.getSource('trace') as GeoJSONSource | undefined;
|
||||
|
||||
trace?.setData(getPartialTargetRoute(travelProgress));
|
||||
map.jumpTo(
|
||||
getCameraOptions(
|
||||
map,
|
||||
travelProgress,
|
||||
cameraAltitudeMeters,
|
||||
cameraLatitudeOffset,
|
||||
),
|
||||
);
|
||||
|
||||
map.once('idle', () => continueRender(handle));
|
||||
// Force an idle event even if the camera parameters are unchanged from the previous frame.
|
||||
map.triggerRepaint();
|
||||
}, [continueRender, delayRender, durationInFrames, frame, map]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: '#e8eef3'}}>
|
||||
<div ref={containerRef} style={{height, position: 'absolute', width}} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Camera guidance
|
||||
|
||||
Use MapLibre's camera helper for camera movement:
|
||||
|
||||
```ts
|
||||
map.calculateCameraOptionsFromTo(cameraLngLat, cameraAltitudeMeters, targetLngLat);
|
||||
```
|
||||
|
||||
A good pattern is to keep two concepts separate:
|
||||
|
||||
- `targetRoute`: where the animated line is and where the camera looks.
|
||||
- `cameraRoute`: where the camera moves.
|
||||
|
||||
Then use Turf to read positions from both routes for the same progress value:
|
||||
|
||||
```ts
|
||||
const target = turf.along(targetRoute, targetDistance * progress).geometry.coordinates;
|
||||
const camera = turf.along(cameraRoute, cameraDistance * progress).geometry.coordinates;
|
||||
|
||||
map.jumpTo(
|
||||
map.calculateCameraOptionsFromTo(
|
||||
new maplibregl.LngLat(camera[0], camera[1]),
|
||||
cameraAltitudeMeters,
|
||||
new maplibregl.LngLat(target[0], target[1]),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
For zoom-out / travel / zoom-in animations, animate travel progress separately from camera altitude. Camera altitude is measured in meters. This avoids heavy custom camera math.
|
||||
|
||||
## Lines
|
||||
|
||||
Use GeoJSON sources for lines. Unless the user asks, do not add glow effects or extra decorative points.
|
||||
|
||||
For geodesic flight routes, use Turf:
|
||||
|
||||
```ts
|
||||
const line = greatCircleLine(start, end);
|
||||
const distance = turf.length(line);
|
||||
const partialLine = turf.lineSliceAlong(
|
||||
line,
|
||||
0,
|
||||
// Keep the route non-empty at progress 0.
|
||||
Math.max(0.001, distance * progress),
|
||||
);
|
||||
```
|
||||
|
||||
For a visually straight line on the map, use a simple GeoJSON `LineString` between the two points instead of `greatCircle()`.
|
||||
|
||||
## Markers and labels
|
||||
|
||||
Use map-native GeoJSON layers for markers and labels:
|
||||
|
||||
```tsx
|
||||
mapInstance.addSource('markers', {
|
||||
type: 'geojson',
|
||||
data: turf.featureCollection([
|
||||
turf.point([-118.2437, 34.0522], {name: 'Los Angeles'}),
|
||||
]),
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'marker-dots',
|
||||
type: 'circle',
|
||||
source: 'markers',
|
||||
paint: {
|
||||
'circle-color': '#f03b20',
|
||||
'circle-radius': 12,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': 4,
|
||||
},
|
||||
});
|
||||
|
||||
mapInstance.addLayer({
|
||||
id: 'marker-labels',
|
||||
type: 'symbol',
|
||||
source: 'markers',
|
||||
layout: {
|
||||
'text-allow-overlap': true,
|
||||
'text-anchor': 'top',
|
||||
'text-field': ['get', 'name'],
|
||||
'text-offset': [0, 0.9],
|
||||
'text-size': 28,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#111111',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 3,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Make marker sizes and label font sizes large enough for the composition resolution.
|
||||
|
||||
## Styles
|
||||
|
||||
Default to the stock MapLibre demo style:
|
||||
|
||||
```ts
|
||||
style: 'https://demotiles.maplibre.org/style.json'
|
||||
```
|
||||
|
||||
If the user requests another style, use any valid MapLibre style JSON URL.
|
||||
|
||||
## Rendering
|
||||
|
||||
For WebGL map renders, prefer single concurrency and ANGLE:
|
||||
|
||||
```bash
|
||||
bunx remotion render [composition-id] out/video.mp4 --gl=angle --concurrency=1
|
||||
```
|
||||
|
||||
Use the equivalent package runner for the project. In npm projects, use `npx`; in Bun projects, use `bunx`.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Moving Map Render Stability
|
||||
|
||||
Read this reference before building a moving 2D MapTiler scene or diagnosing a wavering Remotion render.
|
||||
|
||||
## Symptom and cause
|
||||
|
||||
If basemap detail shimmers or jitters during a pan/zoom, the likely cause is per-frame `map.jumpTo()`.
|
||||
Headless MapTiler capture can resample both vector hillshade and satellite imagery differently frame to
|
||||
frame. Tile retries, easing changes, and label changes do not solve that renderer effect.
|
||||
|
||||
## Required pattern: fixed map plate
|
||||
|
||||
For any 2D pan/zoom:
|
||||
|
||||
1. Render the MapTiler canvas once at the largest required zoom in an oversized container. Size it from the camera route and keep each dimension below the browser's reliable WebGL render-buffer limit (commonly 4096 px). Do **not** blindly use 3×: a 1920×1080 composition becomes 5760 px wide and Chromium may silently downsample it, causing visible pixelation during the CSS zoom.
|
||||
2. Keep the map's camera static.
|
||||
3. For each frame, calculate the approved target centre/zoom, then move the canvas with CSS `translate` + `scale`.
|
||||
4. Apply the same transform to every projected HTML overlay.
|
||||
5. Continue to animate GeoJSON data and paint properties imperatively; only the renderer camera is frozen.
|
||||
|
||||
Keep pitch and bearing constant. Use a 3D engine such as Cesium for genuine changing pitch/bearing or a terrain flythrough.
|
||||
|
||||
```ts
|
||||
const baseZoom = Math.max(start.zoom, end.zoom);
|
||||
const map = new maptilersdk.Map({
|
||||
container,
|
||||
style,
|
||||
center: end.center,
|
||||
zoom: baseZoom,
|
||||
pitch: end.pitch ?? 0,
|
||||
bearing: end.bearing ?? 0,
|
||||
interactive: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {preserveDrawingBuffer: true},
|
||||
});
|
||||
|
||||
// Per Remotion frame. `camera` is the approved centre/zoom interpolation.
|
||||
const projected = map.project(camera.center);
|
||||
const scale = 2 ** (camera.zoom - baseZoom);
|
||||
const plate = {
|
||||
transform: `translate(${width / 2 - projected.x * scale}px, ${height / 2 - projected.y * scale}px) scale(${scale})`,
|
||||
transformOrigin: "0 0",
|
||||
};
|
||||
|
||||
// Convert label projection with exactly the same plate transform.
|
||||
const labelX = labelPoint.x * scale + width / 2 - projected.x * scale;
|
||||
const labelY = labelPoint.y * scale + height / 2 - projected.y * scale;
|
||||
```
|
||||
|
||||
### Plate sizing and sharpness
|
||||
|
||||
- Render at the maximum zoom reached by **any** camera waypoint, including intermediate or hold cameras. The CSS scale should never exceed `1`; otherwise the plate is being enlarged.
|
||||
- Centre the frozen map on the midpoint of the camera route's geographic extent, not automatically on the final camera. This minimizes required overscan.
|
||||
- Keep the largest canvas dimension at or below 4096 px unless the actual render environment has been tested with a larger `MAX_RENDERBUFFER_SIZE`.
|
||||
- For 1920×1080, a 3840×2160 plate is a safe default. For 1080×1920, use approximately 2700×3840 when the route needs extra horizontal pan room.
|
||||
- If the route cannot fit within that plate at the required zoom, split the shot into two fixed plates with a deliberate editorial transition. Do not trade sharpness for one enormous canvas.
|
||||
- Distinguish failure modes: repeating shimmer means the live renderer is moving; steadily soft tiles during a CSS push means the fixed plate is underspecified, internally downsampled, or being scaled above `1`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Render a short MP4, not only a Studio preview.
|
||||
- Inspect static terrain texture and satellite detail while the camera moves.
|
||||
- If any underlying map detail wavers, use the fixed map plate. Do not approve it as a minor preview artefact.
|
||||
- Render WebGL with `--gl=angle`, `preserveDrawingBuffer:true`, and conservative concurrency (`1`) while validating.
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
# MapTiler maps in Remotion
|
||||
|
||||
MapTiler is a good solution for map animations where geographics features should be drawn as annotations on top of the map: Country borders, rivers, labels for POIs.
|
||||
|
||||
## MapTiler SDK (`@maptiler/sdk`)
|
||||
|
||||
Draw the basemap plus MapTiler Planet vector layers and custom GeoJSON into a WebGL canvas. Default styled-vector starting point: `MapStyle.BASIC`; satellite is an equally valid choice.
|
||||
|
||||
## Remotion
|
||||
|
||||
Imperatively update `setData`/`setPaintProperty`.
|
||||
|
||||
Use `jumpTo` only for a static shot, or a fixed map plate for any pan/zoom.
|
||||
Gate with [`delayRender`](https://www.remotion.dev/docs/delay-render.md) until `map.once('idle')`.
|
||||
|
||||
Use `preserveDrawingBuffer:true`.
|
||||
|
||||
Render labels as positioned [`<Interactive.Div>`](https://www.remotion.dev/docs/interactive.md) elements.
|
||||
|
||||
Env `REMOTION_MAPTILER_KEY` (unrestricted). Init the map once (ref guard); update imperatively per frame.
|
||||
|
||||
When constructing MapLibre/MapTiler layer objects, omit optional properties that are absent.
|
||||
In particular, use `...(layer.filter ? {filter: layer.filter} : {})`; do not pass `filter: undefined`. An undefined filter can suppress the layer while separately created halo or border layers continue rendering, producing missing country fills and dark marker halos with no coloured cores.
|
||||
|
||||
Drive animation from `useCurrentFrame()` rather than CSS transitions or browser timers.
|
||||
|
||||
## Choose the source for each map element
|
||||
|
||||
Do not begin by manufacturing GeoJSON. First check whether MapTiler Planet already exposes the element as filtered vector data.
|
||||
|
||||
### MapTiler vector
|
||||
|
||||
Use it when the feature exists in a provider `source-layer`, its attributes support an exact filter, and provider geometry is editorially acceptable.
|
||||
|
||||
### Hybrid
|
||||
|
||||
Use it when ordinary geographic context can come from MapTiler while the claim depends on custom
|
||||
evidence.
|
||||
|
||||
Animate each layer according to its source and meaning.
|
||||
|
||||
MapTiler vector features remain split across tiles. Do not use them for a semantic start-to-end line draw; extract, verify, order, and bake that element to GeoJSON first. Read **`references/map-data-sources.md`** and reuse **`assets/MapTilerVectorElement.ts`** for provider-layer setup and per-frame paint updates.
|
||||
|
||||
## Motion stability
|
||||
|
||||
**Do not call `map.jumpTo()` on every Remotion frame when the camera moves.** In headless capture it can make both MapTiler hillshade **and satellite imagery** shimmer/jitter, even when the source tiles load correctly. This is renderer resampling, not a data, network, or label problem.
|
||||
|
||||
For the implementation, read **`references/render-stability.md`** before building or debugging any moving map. It contains the fixed-map-plate recipe, diagnostics, and render checks.
|
||||
|
||||
- Use the live MapTiler camera only for a static shot.
|
||||
- Keep pitch and bearing constant for a fixed plate. This technique does not implement a genuine changing 3D camera.
|
||||
- Verify the moving preview and a short rendered MP4 before approving a beat. If any basemap detail wavers, switch to the fixed-plate pattern; do not try to solve it with tile retries or camera easing.
|
||||
|
||||
## Drawing rivers
|
||||
|
||||
Use `turf.lineSliceAlong(line, 0, lineKm*reveal)` to draw rivers.
|
||||
|
||||
## Source selection
|
||||
|
||||
Use MapTiler vector layers for suitable provider features and custom GeoJSON for story-specific or ordered geometry. If the beat needs country-entry triggers or a progressive line draw, run `scripts/prep-geo.mjs` to bake `country-meta.json`, `borders.geojson`, and the ordered line. Details → `references/map-data-sources.md` and `references/map-geo-prep.md`.
|
||||
|
||||
## Keep it minmal
|
||||
|
||||
Strip clutter on `load`: remove `symbol` layers (place labels) and `/other border/i` (admin-1 inner borders); hide the logo via CSS. Keep country + disputed borders.
|
||||
|
||||
## Files
|
||||
|
||||
Use as reference:
|
||||
|
||||
- `assets/RiverReveal.tsx` — the main component.
|
||||
- `assets/MapTilerVectorElement.ts` — filtered MapTiler Planet elements.
|
||||
- `assets/CountryLabel.tsx` — reusable example label.
|
||||
- `assets/tokens.ts` — example palette and durations.
|
||||
- `assets/example-Root.tsx` — minimal composition scaffold.
|
||||
- `assets/sample-data/` — example route and generated country metadata.
|
||||
- `scripts/prep-geo.mjs` — geo pipeline.
|
||||
- `references/map-explainer-architecture.md` — timing model and implementation.
|
||||
- `references/map-data-sources.md` — provider vector versus custom GeoJSON selection.
|
||||
- `references/map-geo-prep.md` — basemap stripping and geo preparation.
|
||||
- `references/render-stability.md` — camera motion and stable headless renders.
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import {Easing, interpolate} from 'remotion';
|
||||
|
||||
// Reusable country label. Supply typography and final values from the consuming project; the CSS custom
|
||||
// properties below provide neutral fallbacks. Positioned by its centre (x,y in screen px).
|
||||
export const CountryLabel: React.FC<{
|
||||
name: string;
|
||||
color: string;
|
||||
reveal: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}> = ({name, color, reveal, x, y}) => {
|
||||
const e = interpolate(reveal, [0, 1], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.3333333333333333, 1, 0.6666666666666666, 1),
|
||||
});
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
opacity: e,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `translateY(${(1 - e) * 16}px)`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* accent rule — draws out from the centre in the country's colour */}
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: color,
|
||||
transform: `scaleX(${e})`,
|
||||
boxShadow: `0 0 10px ${color}`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--map-label-font, system-ui, sans-serif)',
|
||||
fontWeight: 'var(--map-label-weight, 600)',
|
||||
fontSize: 'var(--map-label-size, 34px)',
|
||||
letterSpacing: 'var(--map-label-tracking, 0.16em)',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--map-label-color, #ffffff)',
|
||||
textShadow: 'var(--map-label-shadow, 0 2px 18px rgba(0,0,0,0.9))',
|
||||
marginTop: 13,
|
||||
paddingLeft: 'var(--map-label-tracking, 0.16em)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
// Surface an existing MapTiler Planet feature without maintaining duplicate GeoJSON.
|
||||
// Paint animation is deterministic; geometry slicing is not. For a source-to-end line draw,
|
||||
// bake the selected feature to ordered GeoJSON and use RiverReveal.tsx instead.
|
||||
|
||||
type VectorLayerType = 'fill' | 'line' | 'circle' | 'symbol';
|
||||
|
||||
export type MapTilerVectorElement = {
|
||||
id: string;
|
||||
sourceLayer: string;
|
||||
type: VectorLayerType;
|
||||
filter?: unknown[];
|
||||
minzoom?: number;
|
||||
maxzoom?: number;
|
||||
layout?: Record<string, unknown>;
|
||||
paint: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const SOURCE_ID = 'maptiler-planet';
|
||||
|
||||
export const addMapTilerVectorElement = (
|
||||
map: any,
|
||||
apiKey: string,
|
||||
element: MapTilerVectorElement,
|
||||
beforeId?: string,
|
||||
) => {
|
||||
if (!map.getSource(SOURCE_ID)) {
|
||||
map.addSource(SOURCE_ID, {
|
||||
type: 'vector',
|
||||
url: `https://api.maptiler.com/tiles/v3/tiles.json?key=${apiKey}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (map.getLayer(element.id)) return;
|
||||
|
||||
map.addLayer(
|
||||
{
|
||||
id: element.id,
|
||||
type: element.type,
|
||||
source: SOURCE_ID,
|
||||
'source-layer': element.sourceLayer,
|
||||
...(element.filter ? {filter: element.filter} : {}),
|
||||
...(element.minzoom === undefined ? {} : {minzoom: element.minzoom}),
|
||||
...(element.maxzoom === undefined ? {} : {maxzoom: element.maxzoom}),
|
||||
...(element.layout ? {layout: element.layout} : {}),
|
||||
paint: element.paint,
|
||||
},
|
||||
beforeId,
|
||||
);
|
||||
};
|
||||
|
||||
export const setVectorElementPaint = (
|
||||
map: any,
|
||||
layerId: string,
|
||||
paint: Record<string, unknown>,
|
||||
) => {
|
||||
for (const [property, value] of Object.entries(paint)) {
|
||||
map.setPaintProperty(layerId, property, value);
|
||||
}
|
||||
};
|
||||
|
||||
// Example:
|
||||
//
|
||||
// addMapTilerVectorElement(map, process.env.REMOTION_MAPTILER_KEY!, {
|
||||
// id: "story-river",
|
||||
// sourceLayer: "waterway",
|
||||
// type: "line",
|
||||
// filter: [
|
||||
// "all",
|
||||
// ["==", ["get", "class"], "river"],
|
||||
// ["==", ["coalesce", ["get", "name_en"], ["get", "name"]], "Yarlung Tsangpo"],
|
||||
// ],
|
||||
// layout: {"line-cap": "round", "line-join": "round"},
|
||||
// paint: {"line-color": "#E8F7FF", "line-width": 3, "line-opacity": 0},
|
||||
// });
|
||||
//
|
||||
// Per Remotion frame:
|
||||
// setVectorElementPaint(map, "story-river", {
|
||||
// "line-opacity": reveal,
|
||||
// "line-width": 2 + reveal * 2,
|
||||
// });
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
import * as maptilersdk from '@maptiler/sdk';
|
||||
import * as turf from '@turf/turf';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import '@maptiler/sdk/dist/maptiler-sdk.css';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Easing,
|
||||
continueRender,
|
||||
delayRender,
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import {CountryLabel} from './CountryLabel';
|
||||
import countryMeta from './sample-data/country-meta.json';
|
||||
import flowCoords from './sample-data/yarlung-flow.json';
|
||||
import {COLORS, COUNTRY, COUNTRY_DARK, FILL_OPACITY} from './tokens';
|
||||
|
||||
// Sample route reveal. Replace the imported sample geometry, names, timing, and visual tokens in the
|
||||
// consuming production. The renderer stays static; approved centre/zoom motion is a CSS plate transform.
|
||||
|
||||
maptilersdk.config.apiKey = process.env.REMOTION_MAPTILER_KEY as string;
|
||||
|
||||
const line = turf.lineString(flowCoords as [number, number][]);
|
||||
const lineKm = turf.length(line);
|
||||
|
||||
const START = {
|
||||
center: [89.6, 27.7] as [number, number],
|
||||
zoom: 4.75,
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
};
|
||||
const END = {
|
||||
center: [90.2, 27.0] as [number, number],
|
||||
zoom: 5.05,
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
};
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
||||
|
||||
const ORDER = ['china', 'india', 'bangladesh'] as const;
|
||||
type Country = (typeof ORDER)[number];
|
||||
const META = countryMeta as Record<
|
||||
Country,
|
||||
{stop: number; anchor: [number, number]; border: [number, number][][]}
|
||||
>;
|
||||
const countryPolygons = {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: ORDER.map((country) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {country},
|
||||
geometry: {
|
||||
type: 'MultiPolygon' as const,
|
||||
coordinates: META[country].border.map((ring) => [ring]),
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
// Pre-build each country's border as ordered segments with cumulative lengths (for the multi-segment draw).
|
||||
const DRAW = Object.fromEntries(
|
||||
ORDER.map((c) => {
|
||||
const segLines = META[c].border.map((s) => turf.lineString(s));
|
||||
const segLen = segLines.map((l) => turf.length(l));
|
||||
const cum: number[] = [];
|
||||
let acc = 0;
|
||||
for (const L of segLen) {
|
||||
cum.push(acc);
|
||||
acc += L;
|
||||
}
|
||||
return [c, {segLines, segLen, cum, total: acc}];
|
||||
}),
|
||||
) as Record<
|
||||
Country,
|
||||
{segLines: any[]; segLen: number[]; cum: number[]; total: number}
|
||||
>;
|
||||
|
||||
// Reveal the portion of the border between fromKm and toKm as a MultiLineString (no joins across gaps).
|
||||
const sliceBorder = (
|
||||
d: (typeof DRAW)[Country],
|
||||
fromKm: number,
|
||||
toKm: number,
|
||||
) => {
|
||||
const out: number[][][] = [];
|
||||
for (let i = 0; i < d.segLines.length; i++) {
|
||||
const start = d.cum[i],
|
||||
end = start + d.segLen[i];
|
||||
const a = Math.max(fromKm, start),
|
||||
b = Math.min(toKm, end);
|
||||
if (b - a <= 0.0008) continue;
|
||||
out.push(
|
||||
turf.lineSliceAlong(d.segLines[i], a - start, b - start).geometry
|
||||
.coordinates,
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: {type: 'MultiLineString' as const, coordinates: out},
|
||||
};
|
||||
};
|
||||
const EMPTY = {
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: {type: 'MultiLineString' as const, coordinates: [] as number[][][]},
|
||||
};
|
||||
|
||||
// --- Timing (seconds). River draws over [RIVER_START, RIVER_END]; each country triggers when the river
|
||||
// reaches it (stop · span), then runs border → fill → label. Beat length is derived from these. ---
|
||||
const RIVER_START = 0.3;
|
||||
const RIVER_END = 8.0;
|
||||
const BORDER_S = 2.5;
|
||||
const FILL_S = 1.0;
|
||||
const LABEL_S = 0.7;
|
||||
const trigger = (c: Country) =>
|
||||
RIVER_START + META[c].stop * (RIVER_END - RIVER_START);
|
||||
|
||||
export const RiverReveal: React.FC = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const started = useRef(false);
|
||||
const frame = useCurrentFrame();
|
||||
const {fps, durationInFrames, width, height} = useVideoConfig();
|
||||
const [map, setMap] = useState<any>(null);
|
||||
const [labels, setLabels] = useState<
|
||||
Record<string, {x: number; y: number; reveal: number}>
|
||||
>({});
|
||||
const [plate, setPlate] = useState({x: 0, y: 0, scale: 1});
|
||||
const [handle] = useState(() => delayRender('maptiler init A'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || started.current) return;
|
||||
started.current = true;
|
||||
const m = new maptilersdk.Map({
|
||||
container: ref.current,
|
||||
style: maptilersdk.MapStyle.BASIC,
|
||||
center: END.center,
|
||||
zoom: Math.max(START.zoom, END.zoom),
|
||||
pitch: END.pitch,
|
||||
bearing: END.bearing,
|
||||
interactive: false,
|
||||
attributionControl: true,
|
||||
navigationControl: false,
|
||||
geolocateControl: false,
|
||||
maptilerLogo: true,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {preserveDrawingBuffer: true},
|
||||
} as any);
|
||||
|
||||
m.on('load', () => {
|
||||
// Strip basemap labels (symbols) AND the inner admin-1 borders ('Other border[ dash]',
|
||||
// admin_level 3–10) to cut basemap clutter. Keep country + disputed borders.
|
||||
for (const l of m.getStyle().layers as any[])
|
||||
if (l.type === 'symbol' || /other border/i.test(l.id))
|
||||
m.removeLayer(l.id);
|
||||
|
||||
m.addSource('countries', {type: 'geojson', data: countryPolygons});
|
||||
for (const c of ORDER) {
|
||||
m.addLayer({
|
||||
id: `fill-${c}`,
|
||||
type: 'fill',
|
||||
source: 'countries',
|
||||
filter: ['==', ['get', 'country'], c],
|
||||
paint: {'fill-color': COUNTRY[c], 'fill-opacity': 0},
|
||||
});
|
||||
}
|
||||
// Per country: just the border that draws on, settled to a darker shade of the country colour
|
||||
// (the electricity now lives on the river, not the borders).
|
||||
for (const c of ORDER) {
|
||||
m.addSource(`trail-${c}`, {type: 'geojson', data: EMPTY});
|
||||
m.addLayer({
|
||||
id: `trail-${c}`,
|
||||
type: 'line',
|
||||
source: `trail-${c}`,
|
||||
layout: {'line-cap': 'round', 'line-join': 'round'},
|
||||
paint: {
|
||||
'line-color': COUNTRY_DARK[c],
|
||||
'line-width': 2,
|
||||
'line-opacity': 0.95,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const seed = turf.lineSliceAlong(
|
||||
line,
|
||||
0,
|
||||
Math.max(0.001, lineKm * 0.001),
|
||||
);
|
||||
m.addSource('river', {type: 'geojson', data: seed});
|
||||
m.addSource('river-head', {type: 'geojson', data: seed});
|
||||
// Electric water: soft blue glow → icy core → white-hot leading head with its own glow.
|
||||
m.addLayer({
|
||||
id: 'river-glow',
|
||||
type: 'line',
|
||||
source: 'river',
|
||||
layout: {'line-cap': 'round', 'line-join': 'round'},
|
||||
paint: {
|
||||
'line-color': '#49C6FF',
|
||||
'line-width': 11,
|
||||
'line-opacity': 0.32,
|
||||
'line-blur': 6,
|
||||
},
|
||||
});
|
||||
m.addLayer({
|
||||
id: 'river-line',
|
||||
type: 'line',
|
||||
source: 'river',
|
||||
layout: {'line-cap': 'round', 'line-join': 'round'},
|
||||
paint: {'line-color': COLORS.river, 'line-width': 3},
|
||||
});
|
||||
m.addLayer({
|
||||
id: 'river-headglow',
|
||||
type: 'line',
|
||||
source: 'river-head',
|
||||
layout: {'line-cap': 'round', 'line-join': 'round'},
|
||||
paint: {
|
||||
'line-color': COLORS.riverHeadGlow,
|
||||
'line-width': 16,
|
||||
'line-opacity': 0,
|
||||
'line-blur': 9,
|
||||
},
|
||||
});
|
||||
m.addLayer({
|
||||
id: 'river-head',
|
||||
type: 'line',
|
||||
source: 'river-head',
|
||||
layout: {'line-cap': 'round', 'line-join': 'round'},
|
||||
paint: {
|
||||
'line-color': COLORS.riverHead,
|
||||
'line-width': 4.5,
|
||||
'line-opacity': 0,
|
||||
},
|
||||
});
|
||||
|
||||
m.once('idle', () => {
|
||||
setMap(m);
|
||||
continueRender(handle);
|
||||
});
|
||||
});
|
||||
}, [handle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const h = delayRender(`frame A ${frame}`);
|
||||
const t = frame / fps; // seconds
|
||||
const tt = interpolate(frame, [0, durationInFrames - 1], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
// River draw
|
||||
const reveal = interpolate(t, [RIVER_START, RIVER_END], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
});
|
||||
const riverDrawnKm = lineKm * reveal;
|
||||
(map.getSource('river') as any)?.setData(
|
||||
turf.lineSliceAlong(line, 0, Math.max(0.001, riverDrawnKm)),
|
||||
);
|
||||
// Electric draw-head leading the river: white-hot core + glow, fading out once the river completes.
|
||||
const riverHeadKm = lineKm * 0.03;
|
||||
(map.getSource('river-head') as any)?.setData(
|
||||
turf.lineSliceAlong(
|
||||
line,
|
||||
Math.max(0, riverDrawnKm - riverHeadKm),
|
||||
Math.max(0.001, riverDrawnKm),
|
||||
),
|
||||
);
|
||||
let riverHeadFade = 0;
|
||||
if (reveal > 0.002 && reveal < 0.999) riverHeadFade = 1;
|
||||
else if (reveal >= 0.999)
|
||||
riverHeadFade = 1 - clamp01((t - RIVER_END) / 0.5);
|
||||
map.setPaintProperty(
|
||||
'river-headglow',
|
||||
'line-opacity',
|
||||
0.85 * riverHeadFade,
|
||||
);
|
||||
map.setPaintProperty('river-head', 'line-opacity', riverHeadFade);
|
||||
|
||||
const camera = {
|
||||
center: [
|
||||
lerp(START.center[0], END.center[0], tt),
|
||||
lerp(START.center[1], END.center[1], tt),
|
||||
] as [number, number],
|
||||
zoom: lerp(START.zoom, END.zoom, tt),
|
||||
};
|
||||
const cameraPoint = map.project(camera.center);
|
||||
const plateScale = 2 ** (camera.zoom - Math.max(START.zoom, END.zoom));
|
||||
const plateX = width / 2 - cameraPoint.x * plateScale;
|
||||
const plateY = height / 2 - cameraPoint.y * plateScale;
|
||||
|
||||
const pos: Record<string, {x: number; y: number; reveal: number}> = {};
|
||||
for (const c of ORDER) {
|
||||
const d = DRAW[c];
|
||||
const lt = t - trigger(c); // local seconds since this country triggered
|
||||
|
||||
// 1) border draws on (constant duration), settling to a darker shade — no electric head
|
||||
const bp = interpolate(clamp01(lt / BORDER_S), [0, 1], [0, 1], {
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
});
|
||||
(map.getSource(`trail-${c}`) as any)?.setData(
|
||||
bp <= 0 ? EMPTY : sliceBorder(d, 0, d.total * bp),
|
||||
);
|
||||
|
||||
// 2) fill blooms in (overshoot then settle) after the border completes
|
||||
const fp = clamp01((lt - BORDER_S) / FILL_S);
|
||||
const fo = interpolate(
|
||||
fp,
|
||||
[0, 0.6, 1],
|
||||
[0, FILL_OPACITY * 1.25, FILL_OPACITY],
|
||||
{
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.3333333333333333, 1, 0.6666666666666666, 1),
|
||||
},
|
||||
);
|
||||
map.setPaintProperty(`fill-${c}`, 'fill-opacity', fp <= 0 ? 0 : fo);
|
||||
|
||||
// 3) label rises in after the fill
|
||||
const lp = clamp01((lt - BORDER_S - FILL_S) / LABEL_S);
|
||||
const p = map.project(META[c].anchor);
|
||||
pos[c] = {
|
||||
x: p.x * plateScale + plateX,
|
||||
y: p.y * plateScale + plateY,
|
||||
reveal: lp,
|
||||
};
|
||||
}
|
||||
setLabels(pos);
|
||||
|
||||
setPlate({x: plateX, y: plateY, scale: plateScale});
|
||||
map.once('idle', () => continueRender(h));
|
||||
map.triggerRepaint();
|
||||
}, [map, frame, fps, durationInFrames, width, height]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: COLORS.bg}}>
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
width: width * 2,
|
||||
height: height * 2,
|
||||
position: 'absolute',
|
||||
transform: `translate(${plate.x}px, ${plate.y}px) scale(${plate.scale})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
/>
|
||||
<AbsoluteFill style={{pointerEvents: 'none'}}>
|
||||
{ORDER.map((c) =>
|
||||
labels[c] ? (
|
||||
<CountryLabel
|
||||
key={c}
|
||||
name={c.toUpperCase()}
|
||||
color={COUNTRY[c]}
|
||||
reveal={labels[c].reveal}
|
||||
x={labels[c].x}
|
||||
y={labels[c].y}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// Minimal Remotion scaffold for the map explainer. In a Remotion project (`bunx create-video@latest`,
|
||||
// blank template), this is `src/Root.tsx`; `src/index.ts` is just `registerRoot(RemotionRoot)`.
|
||||
//
|
||||
// The Composition sets DURATION (the beat) and dimensions. The component reads durationInFrames / width /
|
||||
// height from useVideoConfig(). The beat must be long enough for the last country's full sequence after
|
||||
// the river reaches it. See references/map-explainer-architecture.md §2.
|
||||
// Render with: bunx remotion render src/index.ts MapExplainer out.mp4 --gl=angle --concurrency=1 --timeout=120000
|
||||
|
||||
import React from 'react';
|
||||
import {Composition} from 'remotion';
|
||||
import {RiverReveal} from './RiverReveal'; // → src/components/RiverReveal.tsx in your project
|
||||
|
||||
export const RemotionRoot: React.FC = () => (
|
||||
<Composition
|
||||
id="MapExplainer"
|
||||
component={RiverReveal}
|
||||
durationInFrames={12 * 30} // 12 s @ 30 fps — raise if a later country needs more room after the river arrives
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
);
|
||||
+7535
File diff suppressed because it is too large
Load Diff
+605
@@ -0,0 +1,605 @@
|
||||
[
|
||||
[84.144, 29.565],
|
||||
[84.171, 29.577],
|
||||
[84.22500000000001, 29.559],
|
||||
[84.246, 29.541],
|
||||
[84.297, 29.544],
|
||||
[84.321, 29.532],
|
||||
[84.411, 29.544],
|
||||
[84.426, 29.529],
|
||||
[84.45, 29.535],
|
||||
[84.468, 29.523],
|
||||
[84.492, 29.523],
|
||||
[84.531, 29.490000000000002],
|
||||
[84.507, 29.466],
|
||||
[84.501, 29.445],
|
||||
[84.513, 29.385],
|
||||
[84.495, 29.379],
|
||||
[84.483, 29.361],
|
||||
[84.486, 29.34],
|
||||
[84.519, 29.316],
|
||||
[84.51, 29.292],
|
||||
[84.522, 29.286],
|
||||
[84.525, 29.265],
|
||||
[84.57600000000001, 29.253],
|
||||
[84.58800000000001, 29.259],
|
||||
[84.60300000000001, 29.247],
|
||||
[84.615, 29.265],
|
||||
[84.63, 29.268],
|
||||
[84.624, 29.283],
|
||||
[84.651, 29.286],
|
||||
[84.669, 29.259],
|
||||
[84.732, 29.25],
|
||||
[84.741, 29.235],
|
||||
[84.771, 29.25],
|
||||
[84.777, 29.241],
|
||||
[84.843, 29.223],
|
||||
[84.864, 29.232],
|
||||
[84.879, 29.202],
|
||||
[84.9, 29.199],
|
||||
[84.909, 29.181],
|
||||
[84.933, 29.184],
|
||||
[84.96900000000001, 29.163],
|
||||
[85.023, 29.187],
|
||||
[85.071, 29.259],
|
||||
[85.122, 29.274],
|
||||
[85.167, 29.322],
|
||||
[85.185, 29.310000000000002],
|
||||
[85.26, 29.319],
|
||||
[85.284, 29.301000000000002],
|
||||
[85.34400000000001, 29.289],
|
||||
[85.35600000000001, 29.265],
|
||||
[85.413, 29.262],
|
||||
[85.431, 29.271],
|
||||
[85.449, 29.247],
|
||||
[85.464, 29.244],
|
||||
[85.521, 29.25],
|
||||
[85.551, 29.265],
|
||||
[85.611, 29.235],
|
||||
[85.617, 29.193],
|
||||
[85.662, 29.172],
|
||||
[85.686, 29.172],
|
||||
[85.69500000000001, 29.184],
|
||||
[85.71600000000001, 29.172],
|
||||
[85.72800000000001, 29.187],
|
||||
[85.764, 29.181],
|
||||
[85.761, 29.193],
|
||||
[85.776, 29.202],
|
||||
[85.788, 29.196],
|
||||
[85.797, 29.22],
|
||||
[85.809, 29.211000000000002],
|
||||
[85.848, 29.211000000000002],
|
||||
[85.863, 29.193],
|
||||
[85.881, 29.193],
|
||||
[85.917, 29.172],
|
||||
[85.968, 29.175],
|
||||
[86.004, 29.163],
|
||||
[86.09400000000001, 29.175],
|
||||
[86.10000000000001, 29.166],
|
||||
[86.133, 29.172],
|
||||
[86.148, 29.166],
|
||||
[86.193, 29.178],
|
||||
[86.223, 29.202],
|
||||
[86.268, 29.193],
|
||||
[86.295, 29.208000000000002],
|
||||
[86.34, 29.205000000000002],
|
||||
[86.412, 29.217000000000002],
|
||||
[86.427, 29.211000000000002],
|
||||
[86.43, 29.199],
|
||||
[86.47800000000001, 29.196],
|
||||
[86.496, 29.22],
|
||||
[86.514, 29.211000000000002],
|
||||
[86.52, 29.235],
|
||||
[86.529, 29.238],
|
||||
[86.535, 29.217000000000002],
|
||||
[86.559, 29.196],
|
||||
[86.607, 29.202],
|
||||
[86.616, 29.184],
|
||||
[86.658, 29.193],
|
||||
[86.67, 29.205000000000002],
|
||||
[86.682, 29.199],
|
||||
[86.7, 29.208000000000002],
|
||||
[86.736, 29.205000000000002],
|
||||
[86.778, 29.187],
|
||||
[86.805, 29.196],
|
||||
[86.82300000000001, 29.184],
|
||||
[86.84100000000001, 29.19],
|
||||
[86.901, 29.181],
|
||||
[86.949, 29.163],
|
||||
[87.027, 29.172],
|
||||
[87.039, 29.148],
|
||||
[87.063, 29.136],
|
||||
[87.084, 29.142],
|
||||
[87.12, 29.175],
|
||||
[87.162, 29.142],
|
||||
[87.21000000000001, 29.142],
|
||||
[87.237, 29.157],
|
||||
[87.249, 29.136],
|
||||
[87.297, 29.127],
|
||||
[87.303, 29.115000000000002],
|
||||
[87.366, 29.121000000000002],
|
||||
[87.459, 29.097],
|
||||
[87.486, 29.115000000000002],
|
||||
[87.546, 29.109],
|
||||
[87.56700000000001, 29.121000000000002],
|
||||
[87.59400000000001, 29.121000000000002],
|
||||
[87.60600000000001, 29.133],
|
||||
[87.654, 29.139],
|
||||
[87.666, 29.13],
|
||||
[87.684, 29.133],
|
||||
[87.687, 29.157],
|
||||
[87.669, 29.181],
|
||||
[87.675, 29.205000000000002],
|
||||
[87.705, 29.217000000000002],
|
||||
[87.714, 29.235],
|
||||
[87.732, 29.238],
|
||||
[87.732, 29.253],
|
||||
[87.756, 29.283],
|
||||
[87.753, 29.301000000000002],
|
||||
[87.789, 29.304000000000002],
|
||||
[87.795, 29.328],
|
||||
[87.81, 29.337],
|
||||
[87.849, 29.331],
|
||||
[87.87, 29.349],
|
||||
[87.906, 29.343],
|
||||
[87.924, 29.349],
|
||||
[87.97800000000001, 29.388],
|
||||
[87.996, 29.376],
|
||||
[88.014, 29.379],
|
||||
[88.017, 29.37],
|
||||
[88.125, 29.367],
|
||||
[88.137, 29.337],
|
||||
[88.161, 29.331],
|
||||
[88.176, 29.337],
|
||||
[88.188, 29.325],
|
||||
[88.2, 29.328],
|
||||
[88.221, 29.367],
|
||||
[88.245, 29.367],
|
||||
[88.287, 29.349],
|
||||
[88.299, 29.358],
|
||||
[88.365, 29.316],
|
||||
[88.41, 29.322],
|
||||
[88.422, 29.316],
|
||||
[88.443, 29.325],
|
||||
[88.458, 29.349],
|
||||
[88.503, 29.361],
|
||||
[88.536, 29.334],
|
||||
[88.563, 29.328],
|
||||
[88.596, 29.349],
|
||||
[88.629, 29.346],
|
||||
[88.641, 29.334],
|
||||
[88.671, 29.34],
|
||||
[88.71000000000001, 29.331],
|
||||
[88.72800000000001, 29.343],
|
||||
[88.791, 29.337],
|
||||
[88.818, 29.352],
|
||||
[88.86, 29.319],
|
||||
[88.884, 29.334],
|
||||
[88.908, 29.316],
|
||||
[88.923, 29.331],
|
||||
[88.962, 29.328],
|
||||
[88.971, 29.349],
|
||||
[89.007, 29.361],
|
||||
[89.019, 29.352],
|
||||
[89.034, 29.358],
|
||||
[89.08500000000001, 29.322],
|
||||
[89.136, 29.316],
|
||||
[89.16, 29.343],
|
||||
[89.181, 29.337],
|
||||
[89.211, 29.349],
|
||||
[89.235, 29.379],
|
||||
[89.253, 29.373],
|
||||
[89.265, 29.385],
|
||||
[89.289, 29.385],
|
||||
[89.307, 29.376],
|
||||
[89.349, 29.379],
|
||||
[89.397, 29.358],
|
||||
[89.45100000000001, 29.355],
|
||||
[89.46000000000001, 29.334],
|
||||
[89.58, 29.358],
|
||||
[89.595, 29.346],
|
||||
[89.61, 29.355],
|
||||
[89.631, 29.346],
|
||||
[89.661, 29.349],
|
||||
[89.679, 29.364],
|
||||
[89.757, 29.295],
|
||||
[89.787, 29.292],
|
||||
[89.808, 29.310000000000002],
|
||||
[89.85300000000001, 29.322],
|
||||
[89.931, 29.319],
|
||||
[89.985, 29.343],
|
||||
[90.015, 29.337],
|
||||
[90.072, 29.352],
|
||||
[90.156, 29.355],
|
||||
[90.168, 29.346],
|
||||
[90.20100000000001, 29.349],
|
||||
[90.22500000000001, 29.331],
|
||||
[90.273, 29.337],
|
||||
[90.276, 29.328],
|
||||
[90.342, 29.313],
|
||||
[90.378, 29.295],
|
||||
[90.435, 29.241],
|
||||
[90.477, 29.256],
|
||||
[90.492, 29.25],
|
||||
[90.51, 29.256],
|
||||
[90.522, 29.247],
|
||||
[90.54, 29.262],
|
||||
[90.621, 29.277],
|
||||
[90.642, 29.301000000000002],
|
||||
[90.663, 29.298000000000002],
|
||||
[90.681, 29.313],
|
||||
[90.684, 29.328],
|
||||
[90.705, 29.34],
|
||||
[90.729, 29.328],
|
||||
[90.747, 29.337],
|
||||
[90.765, 29.328],
|
||||
[90.768, 29.310000000000002],
|
||||
[90.756, 29.295],
|
||||
[90.771, 29.277],
|
||||
[90.855, 29.283],
|
||||
[90.897, 29.319],
|
||||
[90.933, 29.310000000000002],
|
||||
[90.95100000000001, 29.295],
|
||||
[90.993, 29.322],
|
||||
[91.035, 29.295],
|
||||
[91.065, 29.316],
|
||||
[91.116, 29.325],
|
||||
[91.131, 29.313],
|
||||
[91.173, 29.325],
|
||||
[91.194, 29.286],
|
||||
[91.233, 29.274],
|
||||
[91.296, 29.289],
|
||||
[91.308, 29.277],
|
||||
[91.34700000000001, 29.28],
|
||||
[91.374, 29.292],
|
||||
[91.395, 29.28],
|
||||
[91.449, 29.286],
|
||||
[91.479, 29.268],
|
||||
[91.512, 29.28],
|
||||
[91.533, 29.271],
|
||||
[91.554, 29.289],
|
||||
[91.587, 29.292],
|
||||
[91.602, 29.271],
|
||||
[91.62, 29.265],
|
||||
[91.659, 29.271],
|
||||
[91.674, 29.259],
|
||||
[91.71000000000001, 29.268],
|
||||
[91.74, 29.259],
|
||||
[91.782, 29.274],
|
||||
[91.839, 29.268],
|
||||
[91.869, 29.283],
|
||||
[91.98, 29.262],
|
||||
[92.007, 29.232],
|
||||
[92.07000000000001, 29.289],
|
||||
[92.115, 29.283],
|
||||
[92.154, 29.289],
|
||||
[92.196, 29.244],
|
||||
[92.22, 29.253],
|
||||
[92.235, 29.244],
|
||||
[92.277, 29.244],
|
||||
[92.295, 29.229],
|
||||
[92.319, 29.226],
|
||||
[92.376, 29.226],
|
||||
[92.397, 29.241],
|
||||
[92.406, 29.262],
|
||||
[92.433, 29.25],
|
||||
[92.46600000000001, 29.253],
|
||||
[92.529, 29.172],
|
||||
[92.529, 29.133],
|
||||
[92.553, 29.148],
|
||||
[92.598, 29.145],
|
||||
[92.589, 29.121000000000002],
|
||||
[92.61, 29.097],
|
||||
[92.619, 29.115000000000002],
|
||||
[92.658, 29.121000000000002],
|
||||
[92.685, 29.139],
|
||||
[92.697, 29.127],
|
||||
[92.676, 29.112000000000002],
|
||||
[92.679, 29.103],
|
||||
[92.709, 29.109],
|
||||
[92.739, 29.067],
|
||||
[92.772, 29.091],
|
||||
[92.775, 29.073],
|
||||
[92.796, 29.082],
|
||||
[92.808, 29.061],
|
||||
[92.82000000000001, 29.067],
|
||||
[92.82300000000001, 29.082],
|
||||
[92.82900000000001, 29.076],
|
||||
[92.85300000000001, 29.082],
|
||||
[92.85600000000001, 29.073],
|
||||
[92.88, 29.073],
|
||||
[92.904, 29.058],
|
||||
[92.934, 29.067],
|
||||
[92.952, 29.049],
|
||||
[92.973, 29.061],
|
||||
[92.988, 29.043],
|
||||
[92.997, 29.049],
|
||||
[93.066, 29.043],
|
||||
[93.081, 29.094],
|
||||
[93.117, 29.115000000000002],
|
||||
[93.123, 29.136],
|
||||
[93.138, 29.139],
|
||||
[93.15, 29.127],
|
||||
[93.147, 29.094],
|
||||
[93.165, 29.064],
|
||||
[93.165, 29.043],
|
||||
[93.153, 29.037],
|
||||
[93.153, 29.025000000000002],
|
||||
[93.162, 29.016000000000002],
|
||||
[93.21300000000001, 29.025000000000002],
|
||||
[93.22800000000001, 29.019000000000002],
|
||||
[93.23100000000001, 29.001],
|
||||
[93.261, 28.992],
|
||||
[93.285, 29.001],
|
||||
[93.312, 28.998],
|
||||
[93.315, 29.016000000000002],
|
||||
[93.348, 29.046],
|
||||
[93.375, 29.052],
|
||||
[93.393, 29.094],
|
||||
[93.429, 29.109],
|
||||
[93.447, 29.106],
|
||||
[93.438, 29.13],
|
||||
[93.48, 29.175],
|
||||
[93.492, 29.163],
|
||||
[93.54, 29.178],
|
||||
[93.57000000000001, 29.166],
|
||||
[93.627, 29.172],
|
||||
[93.63, 29.151],
|
||||
[93.645, 29.145],
|
||||
[93.675, 29.151],
|
||||
[93.681, 29.163],
|
||||
[93.699, 29.16],
|
||||
[93.702, 29.142],
|
||||
[93.735, 29.127],
|
||||
[93.75, 29.136],
|
||||
[93.75, 29.148],
|
||||
[93.78, 29.154],
|
||||
[93.789, 29.124000000000002],
|
||||
[93.825, 29.118000000000002],
|
||||
[93.834, 29.124000000000002],
|
||||
[93.831, 29.142],
|
||||
[93.894, 29.13],
|
||||
[93.912, 29.145],
|
||||
[93.903, 29.166],
|
||||
[93.909, 29.178],
|
||||
[93.94500000000001, 29.184],
|
||||
[93.95400000000001, 29.196],
|
||||
[94.017, 29.193],
|
||||
[94.035, 29.205000000000002],
|
||||
[94.176, 29.196],
|
||||
[94.251, 29.262],
|
||||
[94.305, 29.271],
|
||||
[94.302, 29.292],
|
||||
[94.33200000000001, 29.316],
|
||||
[94.35000000000001, 29.316],
|
||||
[94.389, 29.337],
|
||||
[94.401, 29.361],
|
||||
[94.419, 29.37],
|
||||
[94.434, 29.406000000000002],
|
||||
[94.542, 29.448],
|
||||
[94.581, 29.481],
|
||||
[94.656, 29.490000000000002],
|
||||
[94.70700000000001, 29.463],
|
||||
[94.818, 29.493000000000002],
|
||||
[94.875, 29.541],
|
||||
[94.923, 29.61],
|
||||
[94.926, 29.622],
|
||||
[94.887, 29.634],
|
||||
[94.917, 29.67],
|
||||
[94.893, 29.697],
|
||||
[94.905, 29.715],
|
||||
[94.956, 29.757],
|
||||
[95.124, 29.763],
|
||||
[95.124, 29.781000000000002],
|
||||
[95.09100000000001, 29.814],
|
||||
[95.11200000000001, 29.868000000000002],
|
||||
[95.13, 29.88],
|
||||
[95.175, 29.895],
|
||||
[95.196, 29.892],
|
||||
[95.223, 29.868000000000002],
|
||||
[95.286, 29.868000000000002],
|
||||
[95.304, 29.856],
|
||||
[95.307, 29.838],
|
||||
[95.283, 29.82],
|
||||
[95.289, 29.811],
|
||||
[95.385, 29.772000000000002],
|
||||
[95.379, 29.718],
|
||||
[95.397, 29.688000000000002],
|
||||
[95.388, 29.595],
|
||||
[95.403, 29.562],
|
||||
[95.427, 29.538],
|
||||
[95.43900000000001, 29.478],
|
||||
[95.43, 29.451],
|
||||
[95.313, 29.331],
|
||||
[95.256, 29.289],
|
||||
[95.202, 29.28],
|
||||
[95.04, 29.175],
|
||||
[95.001, 29.169],
|
||||
[95.004, 29.139],
|
||||
[94.908, 29.052],
|
||||
[94.902, 29.016000000000002],
|
||||
[94.869, 28.998],
|
||||
[94.839, 28.956],
|
||||
[94.788, 28.935000000000002],
|
||||
[94.773, 28.854],
|
||||
[94.794, 28.824],
|
||||
[94.914, 28.821],
|
||||
[94.923, 28.812],
|
||||
[94.911, 28.794],
|
||||
[94.92, 28.752],
|
||||
[94.977, 28.713],
|
||||
[94.983, 28.674],
|
||||
[94.998, 28.683],
|
||||
[95.031, 28.617],
|
||||
[95.09700000000001, 28.539],
|
||||
[95.10300000000001, 28.506],
|
||||
[95.09100000000001, 28.455000000000002],
|
||||
[95.09700000000001, 28.419],
|
||||
[95.013, 28.326],
|
||||
[94.992, 28.287],
|
||||
[94.992, 28.242],
|
||||
[95.019, 28.215],
|
||||
[95.031, 28.173000000000002],
|
||||
[95.06700000000001, 28.155],
|
||||
[95.145, 28.149],
|
||||
[95.211, 28.179000000000002],
|
||||
[95.277, 28.155],
|
||||
[95.292, 28.116],
|
||||
[95.316, 28.089000000000002],
|
||||
[95.355, 28.071],
|
||||
[95.379, 28.071],
|
||||
[95.385, 28.059],
|
||||
[95.382, 27.951],
|
||||
[95.4, 27.936],
|
||||
[95.412, 27.882],
|
||||
[95.379, 27.843],
|
||||
[95.349, 27.837],
|
||||
[95.325, 27.810000000000002],
|
||||
[95.325, 27.78],
|
||||
[95.298, 27.762],
|
||||
[95.289, 27.717000000000002],
|
||||
[95.253, 27.657],
|
||||
[95.211, 27.666],
|
||||
[95.151, 27.624000000000002],
|
||||
[95.139, 27.63],
|
||||
[95.10300000000001, 27.609],
|
||||
[94.992, 27.6],
|
||||
[94.944, 27.57],
|
||||
[94.917, 27.573],
|
||||
[94.869, 27.504],
|
||||
[94.803, 27.495],
|
||||
[94.803, 27.48],
|
||||
[94.785, 27.471],
|
||||
[94.794, 27.456],
|
||||
[94.767, 27.435000000000002],
|
||||
[94.767, 27.414],
|
||||
[94.73100000000001, 27.402],
|
||||
[94.71900000000001, 27.372],
|
||||
[94.69800000000001, 27.36],
|
||||
[94.69800000000001, 27.333000000000002],
|
||||
[94.683, 27.324],
|
||||
[94.677, 27.294],
|
||||
[94.635, 27.291],
|
||||
[94.629, 27.273],
|
||||
[94.602, 27.255],
|
||||
[94.596, 27.231],
|
||||
[94.584, 27.228],
|
||||
[94.587, 27.177],
|
||||
[94.596, 27.162],
|
||||
[94.584, 27.147000000000002],
|
||||
[94.566, 27.150000000000002],
|
||||
[94.554, 27.102],
|
||||
[94.518, 27.105],
|
||||
[94.449, 27.060000000000002],
|
||||
[94.44, 27.018],
|
||||
[94.407, 26.985],
|
||||
[94.35900000000001, 26.97],
|
||||
[94.287, 26.919],
|
||||
[94.197, 26.925],
|
||||
[94.164, 26.898],
|
||||
[94.146, 26.865000000000002],
|
||||
[94.125, 26.856],
|
||||
[94.113, 26.868000000000002],
|
||||
[94.134, 26.907],
|
||||
[94.113, 26.913],
|
||||
[94.077, 26.886],
|
||||
[93.927, 26.832],
|
||||
[93.864, 26.766000000000002],
|
||||
[93.753, 26.733],
|
||||
[93.657, 26.715],
|
||||
[93.618, 26.724],
|
||||
[93.56700000000001, 26.751],
|
||||
[93.441, 26.769000000000002],
|
||||
[93.411, 26.76],
|
||||
[93.369, 26.718],
|
||||
[93.288, 26.742],
|
||||
[93.249, 26.724],
|
||||
[93.183, 26.673000000000002],
|
||||
[93.12, 26.646],
|
||||
[93.087, 26.655],
|
||||
[93.018, 26.646],
|
||||
[92.898, 26.655],
|
||||
[92.883, 26.652],
|
||||
[92.874, 26.625],
|
||||
[92.83500000000001, 26.607],
|
||||
[92.733, 26.613],
|
||||
[92.676, 26.601],
|
||||
[92.658, 26.592000000000002],
|
||||
[92.616, 26.523],
|
||||
[92.595, 26.517],
|
||||
[92.508, 26.517],
|
||||
[92.43, 26.535],
|
||||
[92.391, 26.532],
|
||||
[92.244, 26.472],
|
||||
[92.229, 26.442],
|
||||
[92.202, 26.454],
|
||||
[92.172, 26.442],
|
||||
[92.124, 26.403000000000002],
|
||||
[92.06700000000001, 26.373],
|
||||
[92.07000000000001, 26.325],
|
||||
[92.058, 26.304000000000002],
|
||||
[91.971, 26.271],
|
||||
[91.932, 26.271],
|
||||
[91.899, 26.241],
|
||||
[91.878, 26.235],
|
||||
[91.854, 26.241],
|
||||
[91.665, 26.166],
|
||||
[91.608, 26.172],
|
||||
[91.542, 26.145],
|
||||
[91.497, 26.139],
|
||||
[91.377, 26.172],
|
||||
[91.287, 26.166],
|
||||
[91.197, 26.202],
|
||||
[91.134, 26.214000000000002],
|
||||
[91.062, 26.187],
|
||||
[90.96300000000001, 26.175],
|
||||
[90.888, 26.136],
|
||||
[90.849, 26.13],
|
||||
[90.807, 26.154],
|
||||
[90.741, 26.163],
|
||||
[90.681, 26.19],
|
||||
[90.58200000000001, 26.205000000000002],
|
||||
[90.507, 26.229],
|
||||
[90.474, 26.226],
|
||||
[90.435, 26.178],
|
||||
[90.402, 26.16],
|
||||
[90.345, 26.148],
|
||||
[90.309, 26.124],
|
||||
[90.22500000000001, 26.106],
|
||||
[90.177, 26.076],
|
||||
[89.955, 26.01],
|
||||
[89.904, 25.941],
|
||||
[89.84700000000001, 25.89],
|
||||
[89.81400000000001, 25.815],
|
||||
[89.736, 25.692],
|
||||
[89.724, 25.632],
|
||||
[89.697, 25.572],
|
||||
[89.709, 25.482],
|
||||
[89.697, 25.398],
|
||||
[89.67, 25.323],
|
||||
[89.7, 25.266000000000002],
|
||||
[89.703, 25.242],
|
||||
[89.685, 25.2],
|
||||
[89.661, 25.173000000000002],
|
||||
[89.625, 25.074],
|
||||
[89.613, 24.96],
|
||||
[89.613, 24.936],
|
||||
[89.658, 24.87],
|
||||
[89.673, 24.792],
|
||||
[89.706, 24.75],
|
||||
[89.769, 24.549],
|
||||
[89.748, 24.372],
|
||||
[89.754, 24.282],
|
||||
[89.733, 24.225],
|
||||
[89.742, 24.201],
|
||||
[89.739, 24.123],
|
||||
[89.697, 24.015],
|
||||
[89.7, 23.958000000000002],
|
||||
[89.727, 23.883],
|
||||
[89.787, 23.796],
|
||||
[89.85600000000001, 23.748],
|
||||
[89.919, 23.664],
|
||||
[89.985, 23.643],
|
||||
[90.144, 23.544],
|
||||
[90.249, 23.463]
|
||||
]
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
// Example tokens only. Replace every visual value with production-local tokens.
|
||||
export const COLORS = {
|
||||
bg: '#101315',
|
||||
// Electric water — a near-white icy core with a blue glow and a white-hot draw-head (the "electricity"
|
||||
// travels along the river as it draws on). No dark casing.
|
||||
river: '#E8F7FF', // bright icy core
|
||||
riverGlow: 'rgba(73,198,255,0.5)', // electric-blue glow
|
||||
riverHead: '#FFFFFF', // white-hot leading head
|
||||
riverHeadGlow: 'rgba(120,225,255,0.95)',
|
||||
border: '#f5f2ed', // neutral cream country borders/labels over the colored fills
|
||||
cream: '#f5f0eb',
|
||||
} as const;
|
||||
|
||||
// Example progressive fill tokens. Rename these keys and replace values for each production.
|
||||
export const COUNTRY = {
|
||||
china: '#D4A853',
|
||||
india: '#5B8A8A',
|
||||
bangladesh: '#C07B57',
|
||||
} as const;
|
||||
// Darker shade of each country colour — the settled border line (the bright COUNTRY colour is the
|
||||
// glowing draw-head that leads the animation).
|
||||
export const COUNTRY_DARK = {
|
||||
china: '#9A7530',
|
||||
india: '#3C5C5C',
|
||||
bangladesh: '#855239',
|
||||
} as const;
|
||||
export const FILL_OPACITY = 0.5;
|
||||
|
||||
export const VIDEO = {width: 1920, height: 1080, fps: 30} as const;
|
||||
|
||||
// Beat durations (seconds → frames at VIDEO.fps)
|
||||
export const DUR = {
|
||||
mapExplainer: 12 * VIDEO.fps,
|
||||
} as const;
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# Map element data sources
|
||||
|
||||
Choose the source independently for every story element. A single map can—and often should—mix
|
||||
provider vectors with custom geodata.
|
||||
|
||||
## Decision rule
|
||||
|
||||
Use a MapTiler Planet vector layer when the feature already exists there, its attributes support an
|
||||
editorially precise filter, and provider geometry is acceptable for the claim. Use custom GeoJSON when
|
||||
the feature is absent, proposed, historical, disputed, corrected, privately sourced, or needs ordered
|
||||
geometry for a deterministic draw.
|
||||
|
||||
| Requirement | MapTiler vector layer | Custom GeoJSON |
|
||||
| --------------------------------------------------------------------------- | --------------------- | ------------------------------------------- |
|
||||
| Roads, waterways, water, boundaries, land cover, or other standard context | Prefer | Use only when provider data is insufficient |
|
||||
| Basemap-consistent geometry without a duplicate local dataset | Prefer | No |
|
||||
| Proposed, historical, classified, corrected, or production-specific element | No | Prefer |
|
||||
| Fade, colour, width, radius, blur, or fill-opacity animation | Yes | Yes |
|
||||
| Feature-state highlight when a stable feature ID exists | Yes | Yes |
|
||||
| Deterministic source-to-end line draw or perimeter draw | Bake first | Prefer |
|
||||
| Geometry editing, morphing, clipping, or exact sequencing | No | Prefer |
|
||||
|
||||
Provider alignment is not proof of correctness. Inspect the attributes and geometry against the
|
||||
editorial source before presenting a provider feature as evidence.
|
||||
|
||||
## MapTiler vector mode
|
||||
|
||||
MapTiler Planet is a vector tile source. Add it once, then build story layers with a
|
||||
`source-layer` and an exact attribute filter. Read the current MapTiler Planet schema before choosing
|
||||
layer names or fields.
|
||||
|
||||
Common layer categories include `waterway`, `water`, `transportation`, `boundary`, `landcover`, and
|
||||
`poi`; availability, fields, and zoom ranges vary by schema version.
|
||||
|
||||
```ts
|
||||
import {addMapTilerVectorElement, setVectorElementPaint} from "./MapTilerVectorElement";
|
||||
|
||||
addMapTilerVectorElement(map, process.env.REMOTION_MAPTILER_KEY!, {
|
||||
id: "story-river",
|
||||
sourceLayer: "waterway",
|
||||
type: "line",
|
||||
filter: [
|
||||
"all",
|
||||
["==", ["get", "class"], "river"],
|
||||
["==", ["coalesce", ["get", "name_en"], ["get", "name"]], "Yarlung Tsangpo"],
|
||||
],
|
||||
layout: {"line-cap": "round", "line-join": "round"},
|
||||
paint: {
|
||||
"line-color": "#E8F7FF",
|
||||
"line-width": 3,
|
||||
"line-opacity": 0,
|
||||
},
|
||||
});
|
||||
|
||||
setVectorElementPaint(map, "story-river", {
|
||||
"line-opacity": reveal,
|
||||
"line-width": 2 + reveal * 2,
|
||||
});
|
||||
```
|
||||
|
||||
Animate provider features by changing paint properties from the Remotion frame: opacity, colour,
|
||||
width, blur, fill opacity, circle radius, or symbol opacity. Use feature state only when the source
|
||||
provides stable IDs and the selection remains deterministic across tiles.
|
||||
|
||||
Do not treat a tiled line as one ordered path. Vector tiles split features at tile boundaries, so a
|
||||
source-to-mouth or start-to-end draw has no reliable global order. If that motion carries meaning,
|
||||
extract and verify the complete feature, order it once, save it as GeoJSON, and use the custom mode.
|
||||
|
||||
## Custom geodata mode
|
||||
|
||||
Use the bundled `../assets/RiverReveal.tsx` and `../scripts/prep-geo.mjs` pattern for custom GeoJSON. This mode owns
|
||||
the exact geometry and can slice it by distance, calculate entry triggers, draw complete borders, and
|
||||
produce deterministic sequences.
|
||||
|
||||
Custom mode is mandatory when:
|
||||
|
||||
- the feature is not in the provider dataset;
|
||||
- the story uses a proposed route, planned tunnel, historical boundary, disputed interpretation, or
|
||||
non-public dataset;
|
||||
- provider geometry was editorially corrected;
|
||||
- motion must travel through the geometry in a verified order;
|
||||
- a complete off-screen boundary matters and a viewport query would silently crop it.
|
||||
|
||||
## Hybrid mode
|
||||
|
||||
Use provider vectors for ordinary contextual features and custom GeoJSON for the specific claim. For
|
||||
example: MapTiler waterways and roads as aligned context; a custom proposed tunnel, dam site, disputed
|
||||
boundary, or verified evacuation area as the highlighted evidence.
|
||||
|
||||
Keep provider and custom layers visually distinct when they carry different evidentiary weight. Record
|
||||
the source and effective date of every custom layer in the production notes.
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Map Explainer — architecture reference
|
||||
|
||||
Deep detail behind `TECHNIQUE.md`: the timing model, the river reveal + electric head, the per-country
|
||||
sequence, and label projection. The supplied values are examples, not a production style system.
|
||||
The custom-geometry example is `../assets/RiverReveal.tsx` +
|
||||
`../assets/CountryLabel.tsx` +
|
||||
`../assets/tokens.ts`. Provider-vector setup is in
|
||||
`../assets/MapTilerVectorElement.ts`; choose between
|
||||
the two modes with `data-sources.md`.
|
||||
|
||||
## 1. The render harness (per frame)
|
||||
|
||||
Init the MapTiler map once (ref guard). On `load`: strip clutter (see `geo-prep.md`), add sources/layers,
|
||||
wait for `once('idle') → continueRender`. Per frame:
|
||||
|
||||
```
|
||||
delayRender → setData/setPaintProperty → map.once('idle', continueRender) → triggerRepaint
|
||||
```
|
||||
|
||||
`preserveDrawingBuffer:true` so Remotion's screenshot captures the canvas. Render `--gl=angle`.
|
||||
For an animated camera, read `render-stability.md`: the MapTiler renderer remains static and a CSS plate
|
||||
transform supplies the camera choreography.
|
||||
|
||||
## 2. Timing model — time-based; beat length derived from the sequences
|
||||
|
||||
Everything keys off **seconds** (`t = frame / fps`), not reveal-units. The river draws over a window;
|
||||
each country **triggers when the river reaches it** and runs a fixed sequence. The beat is exactly as
|
||||
long as the sequences need.
|
||||
|
||||
```ts
|
||||
const RIVER_START = 0.3, RIVER_END = 8.0; // river draws over this window
|
||||
const BORDER_S = 2.5, FILL_S = 1.0, LABEL_S = 0.7; // per-country sequence (constant durations)
|
||||
const trigger = (c) => RIVER_START + META[c].stop * (RIVER_END - RIVER_START); // river-arrival time
|
||||
// beat length = max over c of (trigger(c) + BORDER_S + FILL_S + LABEL_S) + tail
|
||||
const reveal = interpolate(t, [RIVER_START, RIVER_END], [0,1], { ...clamp, easing: Easing.bezier(0.645, 0.045, 0.355, 1) });
|
||||
```
|
||||
|
||||
**Constant durations matter:** drive the border draw by _time since trigger_, not a slice of the reveal —
|
||||
otherwise complex or long borders flash by in a fraction of a second.
|
||||
|
||||
## 3. Provider-vector animation
|
||||
|
||||
MapTiler Planet elements can be animated directly in place. Filter an exact `source-layer` feature,
|
||||
initialize its paint in the hidden or neutral state, then update paint properties from the Remotion frame.
|
||||
This works for line, fill, circle, and symbol layers without copying provider geometry into the project.
|
||||
|
||||
Do not assume tiled geometry has a global order. Provider features are split at tile boundaries: opacity,
|
||||
colour, width, blur, radius, fill, and feature-state changes are reliable; semantic start-to-end line
|
||||
draws are not. Bake ordered GeoJSON when the direction of the draw carries meaning.
|
||||
|
||||
## 4. Custom line animation — reveal + electric draw-head
|
||||
|
||||
The "electricity" is a **white-hot head** leading the draw — the last few % of the drawn line in its own
|
||||
bright + glow layers, faded out once the river completes.
|
||||
|
||||
```ts
|
||||
const riverDrawnKm = lineKm * reveal;
|
||||
map.getSource("river").setData(turf.lineSliceAlong(line, 0, Math.max(0.001, riverDrawnKm)));
|
||||
const headKm = lineKm * 0.03;
|
||||
map.getSource("river-head").setData(turf.lineSliceAlong(line, Math.max(0, riverDrawnKm - headKm), Math.max(0.001, riverDrawnKm)));
|
||||
let headFade = 0;
|
||||
if (reveal > 0.002 && reveal < 0.999) headFade = 1;
|
||||
else if (reveal >= 0.999) headFade = 1 - clamp01((t - RIVER_END) / 0.5); // fade out at the mouth
|
||||
map.setPaintProperty("river-headglow", "line-opacity", 0.85 * headFade);
|
||||
map.setPaintProperty("river-head", "line-opacity", headFade);
|
||||
```
|
||||
|
||||
Layers, bottom→top: `river-glow` (electric blue `#49C6FF`, w11, op0.32, blur6) → `river-line`
|
||||
(icy core `#E8F7FF`, w3) → `river-headglow` (`rgba(120,225,255,.95)`, w16, blur9) → `river-head`
|
||||
(white `#FFFFFF`, w4.5). **No dark casing** — the bright icy core reads over every fill on its own.
|
||||
|
||||
## 5. Country animation — border draws → fill blooms → label rises
|
||||
|
||||
Triggered by river arrival, each country runs three sequential phases. The border is a **darker shade**
|
||||
of the country colour (the electricity is on the river, not here).
|
||||
|
||||
```ts
|
||||
const lt = t - trigger(c); // local seconds since trigger
|
||||
// 1) complete source border draws on over a constant BORDER_S, multi-segment-safe
|
||||
const bp = interpolate(clamp01(lt / BORDER_S), [0,1], [0,1], { easing: Easing.bezier(0.645, 0.045, 0.355, 1) });
|
||||
map.getSource(`trail-${c}`).setData(sliceBorder(DRAW[c], 0, DRAW[c].total * bp)); // COUNTRY_DARK line
|
||||
// 2) fill blooms in (opacity overshoots, then settles) after the border completes
|
||||
const fp = clamp01((lt - BORDER_S) / FILL_S);
|
||||
const fo = interpolate(fp, [0, 0.6, 1], [0, FILL_OPACITY * 1.25, FILL_OPACITY], { ...clamp, easing: Easing.bezier(0.3333333333333333, 1, 0.6666666666666666, 1) });
|
||||
map.setPaintProperty(`fill-${c}`, "fill-opacity", fp <= 0 ? 0 : fo);
|
||||
// 3) label rises in after the fill
|
||||
const lp = clamp01((lt - BORDER_S - FILL_S) / LABEL_S);
|
||||
```
|
||||
|
||||
`sliceBorder(d, fromKm, toKm)` reveals a portion of a complete (possibly multi-segment) border as a
|
||||
MultiLineString, slicing each segment by cumulative length — no joins across gaps and no viewport crop:
|
||||
|
||||
```ts
|
||||
const sliceBorder = (d, fromKm, toKm) => {
|
||||
const out = [];
|
||||
for (let i = 0; i < d.segLines.length; i++) {
|
||||
const start = d.cum[i], end = start + d.segLen[i];
|
||||
const a = Math.max(fromKm, start), b = Math.min(toKm, end);
|
||||
if (b - a <= 0.0008) continue;
|
||||
out.push(turf.lineSliceAlong(d.segLines[i], a - start, b - start).geometry.coordinates);
|
||||
}
|
||||
return { type:"Feature", properties:{}, geometry:{ type:"MultiLineString", coordinates: out } };
|
||||
};
|
||||
```
|
||||
|
||||
Choose fill, border, and river colours in the production's local token file. The bundled token values are
|
||||
examples only; do not carry a source project's palette into another production.
|
||||
|
||||
## 6. Labels — HTML overlay, projected each frame
|
||||
|
||||
Labels are React, not map symbols (full typography control). `CountryLabel` is an example accent-rule,
|
||||
rise-and-fade treatment; select the typeface and final values in the production.
|
||||
Positioned by projecting the anchor to screen pixels **every frame**, stored in state:
|
||||
|
||||
```ts
|
||||
const p = map.project(META[c].anchor); // lngLat → screen px (respects the live camera)
|
||||
pos[c] = { x: p.x, y: p.y, reveal: lp };
|
||||
setLabels(pos); // re-render the overlay; effect deps exclude `labels`
|
||||
```
|
||||
|
||||
`CountryLabel` shows the mechanics: uppercase region name, short accent divider, rise/fade entrance,
|
||||
and `pointerEvents:none`. Select font, weight, size, spacing, contrast, and colour from the production's
|
||||
own type and palette system.
|
||||
|
||||
## 7. Camera — fixed map plate for any movement
|
||||
|
||||
Read `render-stability.md`. Do not use per-frame `map.jumpTo()` for a moving 2D shot; it can shimmer in
|
||||
headless renders even on satellite imagery. Interpolate the intended camera for the CSS plate transform,
|
||||
while keeping the MapTiler renderer static.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# Map Explainer — basemap & geo prep
|
||||
|
||||
How the basemap is cleaned and how `../scripts/prep-geo.mjs` bakes the per-country data the component reads.
|
||||
|
||||
## Basemap styling — strip the clutter
|
||||
|
||||
On `load`, remove the basemap's labels and inner admin borders so only your geography reads:
|
||||
|
||||
```ts
|
||||
for (const l of m.getStyle().layers as any[])
|
||||
if (l.type === "symbol" || /other border/i.test(l.id)) m.removeLayer(l.id);
|
||||
```
|
||||
|
||||
- `type === "symbol"` → every place/water/road **label** (the "MapTiler labels"). Gone.
|
||||
- Inner admin-border layer IDs vary by style. Inspect the loaded style, remove state/province/district
|
||||
layers as needed, and retain only the context borders the production requires.
|
||||
- Logo/attribution: `maptilerLogo:false` + `attributionControl:false` aren't always enough — also hide
|
||||
via CSS in the component:
|
||||
```tsx
|
||||
<style>{`.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-attrib,.maptiler-logo{display:none!important}`}</style>
|
||||
```
|
||||
|
||||
## `../scripts/prep-geo.mjs` → outputs
|
||||
|
||||
Reads a routed river GeoJSON + country polygon GeoJSONs; writes:
|
||||
|
||||
- **River line** — simplified for a smooth draw. For a braided river, route one source→mouth path through
|
||||
the network first: greedy endpoint-chaining bounces between parallel channels. → `src/geo/river-flow.json`.
|
||||
- **`public/geo/borders.geojson`** — each country's polygon tagged `{country: name}` (one source,
|
||||
filtered per country for the fills).
|
||||
- **`src/geo/country-meta.json`** — per country `{ stop, anchor, border }`.
|
||||
|
||||
### `stop` — when a country lights up
|
||||
|
||||
Walk the river points; first point inside a country (`turf.booleanPointInPolygon`) = the arc-length
|
||||
fraction where the river **enters** it. Drives the trigger time. The headwaters country = 0.
|
||||
|
||||
### `anchor` — label centre via pole of inaccessibility
|
||||
|
||||
The most-interior point of the country (clipped to a per-country **story bbox** so a big country
|
||||
centres in the relevant region, not its far bulge), then a small operator **nudge**. Pole = grid-sample
|
||||
inside the polygon, keep the point with max distance to the boundary. **Centroids get pulled to edges —
|
||||
don't use them.**
|
||||
|
||||
```js
|
||||
const pole = (poly) => {
|
||||
const bb = turf.bbox(poly), edge = turf.polygonToLine(poly), N = 46;
|
||||
let best = null, bestD = -1;
|
||||
for (let i = 0; i <= N; i++) for (let j = 0; j <= N; j++) {
|
||||
const p = turf.point([bb[0]+(bb[2]-bb[0])*i/N, bb[1]+(bb[3]-bb[1])*j/N]);
|
||||
if (!turf.booleanPointInPolygon(p, poly)) continue;
|
||||
const d = turf.pointToLineDistance(p, edge);
|
||||
if (d > bestD) { bestD = d; best = p.geometry.coordinates; }
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const ANCHOR_BBOX = { china:[82,27,96,32], india:[76,14,99,31], bangladesh:[86,20,93,27] }; // story regions
|
||||
const NUDGE = { china:[0,0.6], india:[-1.0,0], bangladesh:[0,-0.6] }; // operator-directed
|
||||
```
|
||||
|
||||
### `border` — complete source geometry
|
||||
|
||||
Preserve every exterior ring from the named country source. Never clip a country or bilateral border to
|
||||
the framed bbox and never discard an off-screen segment: the geometry may leave the frame naturally.
|
||||
The renderer handles a MultiLineString by cumulative length, so it remains one timed reveal without
|
||||
inventing joins across gaps.
|
||||
|
||||
## Tuning the geo prep for a new scenario
|
||||
|
||||
| Want | Knob |
|
||||
| --------------------------------- | -------------------------------------------------------------------- |
|
||||
| Which countries | the country list in `prep-geo.mjs` (+ supply their polygon GeoJSONs) |
|
||||
| Label centred in the right region | `ANCHOR_BBOX[country]` (the story bbox) |
|
||||
| Nudge a label | `NUDGE[country]` (lng, lat offset) |
|
||||
| What border is drawn | the complete named source geometry; never the visible extent |
|
||||
| When each lights up | derived from `stop` — depends on the river geometry |
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Moving Map Render Stability
|
||||
|
||||
Read this reference before building a moving 2D MapTiler scene or diagnosing a wavering Remotion render.
|
||||
|
||||
## Symptom and cause
|
||||
|
||||
If basemap detail shimmers or jitters during a pan/zoom, the likely cause is per-frame `map.jumpTo()`.
|
||||
Headless MapTiler capture can resample both vector hillshade and satellite imagery differently frame to
|
||||
frame. Tile retries, easing changes, and label changes do not solve that renderer effect.
|
||||
|
||||
## Required pattern: fixed map plate
|
||||
|
||||
For any 2D pan/zoom:
|
||||
|
||||
1. Render the MapTiler canvas once at the largest required zoom in an oversized container. Size it from the camera route and keep each dimension below the browser's reliable WebGL render-buffer limit (commonly 4096 px). Do **not** blindly use 3×: a 1920×1080 composition becomes 5760 px wide and Chromium may silently downsample it, causing visible pixelation during the CSS zoom.
|
||||
2. Keep the map's camera static.
|
||||
3. For each frame, calculate the approved target centre/zoom, then move the canvas with CSS `translate` + `scale`.
|
||||
4. Apply the same transform to every projected HTML overlay.
|
||||
5. Continue to animate GeoJSON data and paint properties imperatively; only the renderer camera is frozen.
|
||||
|
||||
Keep pitch and bearing constant. Use a 3D engine such as Cesium for genuine changing pitch/bearing or a terrain flythrough.
|
||||
|
||||
```ts
|
||||
const baseZoom = Math.max(start.zoom, end.zoom);
|
||||
const map = new maptilersdk.Map({
|
||||
container,
|
||||
style,
|
||||
center: end.center,
|
||||
zoom: baseZoom,
|
||||
pitch: end.pitch ?? 0,
|
||||
bearing: end.bearing ?? 0,
|
||||
interactive: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {preserveDrawingBuffer: true},
|
||||
});
|
||||
|
||||
// Per Remotion frame. `camera` is the approved centre/zoom interpolation.
|
||||
const projected = map.project(camera.center);
|
||||
const scale = 2 ** (camera.zoom - baseZoom);
|
||||
const plate = {
|
||||
transform: `translate(${width / 2 - projected.x * scale}px, ${height / 2 - projected.y * scale}px) scale(${scale})`,
|
||||
transformOrigin: "0 0",
|
||||
};
|
||||
|
||||
// Convert label projection with exactly the same plate transform.
|
||||
const labelX = labelPoint.x * scale + width / 2 - projected.x * scale;
|
||||
const labelY = labelPoint.y * scale + height / 2 - projected.y * scale;
|
||||
```
|
||||
|
||||
### Plate sizing and sharpness
|
||||
|
||||
- Render at the maximum zoom reached by **any** camera waypoint, including intermediate or hold cameras. The CSS scale should never exceed `1`; otherwise the plate is being enlarged.
|
||||
- Centre the frozen map on the midpoint of the camera route's geographic extent, not automatically on the final camera. This minimizes required overscan.
|
||||
- Keep the largest canvas dimension at or below 4096 px unless the actual render environment has been tested with a larger `MAX_RENDERBUFFER_SIZE`.
|
||||
- For 1920×1080, a 3840×2160 plate is a safe default. For 1080×1920, use approximately 2700×3840 when the route needs extra horizontal pan room.
|
||||
- If the route cannot fit within that plate at the required zoom, split the shot into two fixed plates with a deliberate editorial transition. Do not trade sharpness for one enormous canvas.
|
||||
- Distinguish failure modes: repeating shimmer means the live renderer is moving; steadily soft tiles during a CSS push means the fixed plate is underspecified, internally downsampled, or being scaled above `1`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Render a short MP4, not only a Studio preview.
|
||||
- Inspect static terrain texture and satellite detail while the camera moves.
|
||||
- If any underlying map detail wavers, use the fixed map plate. Do not approve it as a minor preview artefact.
|
||||
- Render WebGL with `--gl=angle`, `preserveDrawingBuffer:true`, and conservative concurrency (`1`) while validating.
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
// Geo prep for the map-explainer. Bakes the data the component reads:
|
||||
// out/river-flow.json -> the river draw-on line (→ copy to your Remotion project's src/geo/)
|
||||
// out/country-meta.json -> per-country { stop, anchor, border } (→ src/geo/)
|
||||
// out/borders.geojson -> country polygons, each tagged {country} (→ public/geo/ — loaded via staticFile)
|
||||
//
|
||||
// RUN: node prep-geo.mjs (needs YOUR geodata — see CONFIG; the source polygons are too large to ship,
|
||||
// so the skill ships the OUTPUTS in assets/sample-data/ instead.)
|
||||
//
|
||||
// RIVER INPUT MUST BE ONE CLEAN LINESTRING, source → mouth (features[0].geometry is a LineString). If your
|
||||
// OSM river comes as many ways / braided channels, ROUTE it into a single line FIRST — a graph
|
||||
// shortest-path from source node to mouth node. Do NOT greedily chain by nearest endpoint: it bounces
|
||||
// between parallel channels. (That routing step is a prerequisite, not part of this script.)
|
||||
|
||||
import {readFileSync, writeFileSync, mkdirSync} from 'fs';
|
||||
import {dirname, resolve} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
|
||||
if (process.argv.includes('--help')) {
|
||||
console.log(
|
||||
'Configure COUNTRIES, RIVER, BORDER, label bounds, and output paths in this script, then run: bun scripts/prep-geo.mjs',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const turf = await import('@turf/turf');
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dir, '..');
|
||||
const geo = resolve(root, '../geodata'); // ADAPT: where your input GeoJSON lives
|
||||
const read = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
||||
|
||||
// ===== CONFIG — edit for your river + countries =====
|
||||
const COUNTRIES = ['china', 'india', 'bangladesh']; // ORDERED headwaters → mouth; first = source (stop 0)
|
||||
const RIVER = resolve(geo, 'focus-rivers/yarlung-brahmaputra-full-osm.geojson'); // a single clean source→mouth LineString
|
||||
const BORDER = (name) => resolve(geo, `project-borders/${name}.geojson`); // one polygon file per country, named <country>.geojson
|
||||
const FRAME_BBOX = [76, 14, 104, 33.5]; // [W,S,E,N] visible extent — fallback for label anchoring only
|
||||
const ANCHOR_BBOX = {
|
||||
china: [82, 27, 96, 32],
|
||||
india: [76, 14, 99, 31],
|
||||
bangladesh: [86, 20, 93, 27],
|
||||
}; // [W,S,E,N] per-country label "story region"
|
||||
const NUDGE = {china: [0, 0.6], india: [-1.0, 0], bangladesh: [0, -0.6]}; // [lng,lat] label nudge
|
||||
const RIVER_SIMPLIFY_TOL = 0.006; // degrees — smooths the draw-on (bigger = simpler)
|
||||
const OUT_RIVER = resolve(root, 'out/river-flow.json');
|
||||
const OUT_META = resolve(root, 'out/country-meta.json');
|
||||
const OUT_BORDERS = resolve(root, 'out/borders.geojson');
|
||||
// =====================================================
|
||||
|
||||
const havKm = (a, b) => {
|
||||
const R = 6371,
|
||||
r = Math.PI / 180;
|
||||
const dLat = (b[1] - a[1]) * r,
|
||||
dLng = (b[0] - a[0]) * r;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(a[1] * r) * Math.cos(b[1] * r) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
};
|
||||
|
||||
// --- River draw-on line: take the clean routed LineString, strip a dangling final hop, simplify so the
|
||||
// wide-zoom draw-on reads as one smooth thread (no bezier — meander overshoot on a long river). ---
|
||||
const routed = read(RIVER).features[0].geometry.coordinates;
|
||||
let end = routed.length;
|
||||
while (end > 2 && havKm(routed[end - 2], routed[end - 1]) > 15) end--; // drop a final cross-braid jump if present
|
||||
const flow = turf.simplify(turf.lineString(routed.slice(0, end)), {
|
||||
tolerance: RIVER_SIMPLIFY_TOL,
|
||||
highQuality: true,
|
||||
}).geometry.coordinates;
|
||||
|
||||
// --- Borders + country fills (one source, filtered per country in the component) ---
|
||||
const borders = {type: 'FeatureCollection', features: []};
|
||||
const polys = {};
|
||||
for (const name of COUNTRIES) {
|
||||
const fc = read(BORDER(name));
|
||||
polys[name] = fc;
|
||||
for (const f of fc.features) {
|
||||
f.properties = {...(f.properties || {}), country: name};
|
||||
borders.features.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reveal stops: arc-length fraction of `flow` where the river first ENTERS each country. The first
|
||||
// country (headwaters) is the source, so its stop is 0; the rest are computed. ---
|
||||
const flowKm = turf.length(turf.lineString(flow));
|
||||
const insideCountry = (pt, name) =>
|
||||
polys[name].features.some((f) => turf.booleanPointInPolygon(pt, f));
|
||||
const stops = {};
|
||||
COUNTRIES.forEach((c, i) => {
|
||||
stops[c] = i === 0 ? 0 : 1;
|
||||
}); // 0 = headwaters; 1 = sentinel until entered
|
||||
let acc = 0;
|
||||
for (let i = 0; i < flow.length; i++) {
|
||||
if (i > 0) acc += havKm(flow[i - 1], flow[i]);
|
||||
const frac = acc / (flowKm || 1);
|
||||
const pt = turf.point(flow[i]);
|
||||
for (const c of COUNTRIES)
|
||||
if (stops[c] === 1 && insideCountry(pt, c)) stops[c] = frac;
|
||||
}
|
||||
|
||||
// --- Per-country meta: anchor = pole of inaccessibility of the visible landmass (centred, away from
|
||||
// borders/edges) within the country's story region + a NUDGE; border = every exterior ring from the
|
||||
// complete named source. Never crop a country or bilateral boundary to the viewport. ---
|
||||
const biggestPoly = (geom) => {
|
||||
const rings =
|
||||
geom.type === 'MultiPolygon' ? geom.coordinates : [geom.coordinates];
|
||||
let best = null,
|
||||
bestA = -1;
|
||||
for (const c of rings) {
|
||||
const p = turf.polygon(c);
|
||||
const a = turf.area(p);
|
||||
if (a > bestA) {
|
||||
bestA = a;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const largestPolygon = (fc) => {
|
||||
let best = null,
|
||||
bestA = -1;
|
||||
for (const f of fc.features) {
|
||||
const p = biggestPoly(f.geometry),
|
||||
a = turf.area(p);
|
||||
if (a > bestA) {
|
||||
bestA = a;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const completeExteriorSegments = (fc) => {
|
||||
const segments = [];
|
||||
for (const feature of fc.features) {
|
||||
const polygons =
|
||||
feature.geometry.type === 'MultiPolygon'
|
||||
? feature.geometry.coordinates
|
||||
: [feature.geometry.coordinates];
|
||||
for (const polygon of polygons)
|
||||
if (polygon[0]?.length > 1) segments.push(polygon[0]);
|
||||
}
|
||||
return segments;
|
||||
};
|
||||
const poleOfInaccessibility = (poly) => {
|
||||
const bb = turf.bbox(poly),
|
||||
boundary = turf.polygonToLine(poly),
|
||||
N = 46;
|
||||
let best = null,
|
||||
bestD = -1;
|
||||
for (let i = 0; i <= N; i++)
|
||||
for (let j = 0; j <= N; j++) {
|
||||
const lng = bb[0] + ((bb[2] - bb[0]) * i) / N,
|
||||
lat = bb[1] + ((bb[3] - bb[1]) * j) / N;
|
||||
const pt = turf.point([lng, lat]);
|
||||
if (!turf.booleanPointInPolygon(pt, poly)) continue;
|
||||
const d = turf.pointToLineDistance(pt, boundary);
|
||||
if (d > bestD) {
|
||||
bestD = d;
|
||||
best = [lng, lat];
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const countryMeta = {};
|
||||
for (const name of COUNTRIES) {
|
||||
const poly = largestPolygon(polys[name]);
|
||||
const storyRegion = biggestPoly(
|
||||
turf.bboxClip(poly, ANCHOR_BBOX[name] || FRAME_BBOX).geometry,
|
||||
);
|
||||
const pole = poleOfInaccessibility(storyRegion);
|
||||
const segs = completeExteriorSegments(polys[name]);
|
||||
const nudge = NUDGE[name] || [0, 0];
|
||||
countryMeta[name] = {
|
||||
stop: stops[name],
|
||||
anchor: [pole[0] + nudge[0], pole[1] + nudge[1]],
|
||||
border: segs,
|
||||
};
|
||||
}
|
||||
|
||||
mkdirSync(dirname(OUT_RIVER), {recursive: true});
|
||||
writeFileSync(OUT_RIVER, JSON.stringify(flow));
|
||||
writeFileSync(OUT_META, JSON.stringify(countryMeta));
|
||||
writeFileSync(OUT_BORDERS, JSON.stringify(borders));
|
||||
console.log(
|
||||
'river:',
|
||||
flow.length,
|
||||
'pts ·',
|
||||
flowKm.toFixed(0),
|
||||
'km · entry stops',
|
||||
JSON.stringify(stops),
|
||||
);
|
||||
for (const n of COUNTRIES) {
|
||||
const km = countryMeta[n].border.reduce(
|
||||
(s, seg) => s + turf.length(turf.lineString(seg)),
|
||||
0,
|
||||
);
|
||||
console.log(
|
||||
` ${n}: anchor ${countryMeta[n].anchor.map((v) => v.toFixed(2)).join(',')} · border ${km.toFixed(0)} km`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
"\nNext: copy out/river-flow.json + out/country-meta.json → your project's src/geo/ ; out/borders.geojson → public/geo/",
|
||||
);
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: remotion-maps-static
|
||||
description: Create a deterministic static locator map in Remotion when neither the camera nor geographic data animates.
|
||||
---
|
||||
|
||||
# Static map
|
||||
|
||||
Use a static image when the map only provides location context. This is the smallest, fastest, and
|
||||
most deterministic map technique.
|
||||
|
||||
## Build
|
||||
|
||||
1. Export or request a map image at the composition's final aspect ratio and at least its rendered
|
||||
pixel dimensions.
|
||||
2. Store the image in the Remotion project's `public/` directory.
|
||||
3. Render it with `CanvasImage` and `staticFile()`.
|
||||
4. Add labels or markers as ordinary Remotion elements if they remain fixed.
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import {AbsoluteFill, CanvasImage, staticFile} from 'remotion';
|
||||
|
||||
export const StaticMap: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<CanvasImage
|
||||
src={staticFile('locator-map.png')}
|
||||
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Overlays and Interactivity
|
||||
|
||||
Follow [Remotion Interactivity](../../../../remotion-interactivity/REFERENCE.md) best practices and [Remotion Markup Best practices](../../../REFERENCE.md) for elements.
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
name: sequencing
|
||||
description: Sequencing patterns for Remotion - delay, trim, limit duration of items
|
||||
metadata:
|
||||
tags: sequence, series, timing, delay, trim
|
||||
---
|
||||
|
||||
Use `<Sequence>` to delay when an element appears in the timeline.
|
||||
|
||||
```tsx
|
||||
const Main = () => {
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Background />
|
||||
<AbsoluteFill>
|
||||
<Sequence name="Title" from={30} durationInFrames={60} layout="none">
|
||||
<Title />
|
||||
</Sequence>
|
||||
<Sequence name="Subtitle" from={60} durationInFrames={60} layout="none">
|
||||
<Subtitle />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
}
|
||||
|
||||
export const Title = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
return (
|
||||
<Interactive.Div
|
||||
name="Label"
|
||||
style={{
|
||||
opacity: interpolate(frame, [0, 60], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
extrapolateLeft: "clamp",
|
||||
easing: Easing.bezier(0.16, 1, 0.3, 1),
|
||||
}),
|
||||
fontSize: 88
|
||||
}}
|
||||
>
|
||||
Title
|
||||
</Interactive.Div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Subtitle = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
return (
|
||||
<Interactive.Div
|
||||
name="Subtitle"
|
||||
style={{
|
||||
opacity: interpolate(frame, [0, 60], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
extrapolateLeft: "clamp",
|
||||
easing: Easing.bezier(0.16, 1, 0.3, 1),
|
||||
}),
|
||||
fontSize: 32
|
||||
}}
|
||||
>
|
||||
Subtitle
|
||||
</Interactive.Div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
This will by default wrap the component in an absolute fill element.
|
||||
If the items should not be wrapped, use the `layout` prop:
|
||||
|
||||
```tsx
|
||||
<Sequence layout="none">
|
||||
<Title />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
## Premounting
|
||||
|
||||
This loads the component in the timeline before it is actually played.
|
||||
Always premount any `<Sequence>`!
|
||||
|
||||
```tsx
|
||||
<Sequence premountFor={1 * fps}>
|
||||
<Title />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
## Series
|
||||
|
||||
Use `<Series>` when elements should play one after another without overlap.
|
||||
|
||||
```tsx
|
||||
import { Series } from "remotion";
|
||||
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={45}>
|
||||
<Intro />
|
||||
</Series.Sequence>
|
||||
<Series.Sequence durationInFrames={60}>
|
||||
<MainContent />
|
||||
</Series.Sequence>
|
||||
<Series.Sequence durationInFrames={30}>
|
||||
<Outro />
|
||||
</Series.Sequence>
|
||||
</Series>;
|
||||
```
|
||||
|
||||
Same as with `<Sequence>`, the items will be wrapped in an absolute fill element by default when using `<Series.Sequence>`, unless the `layout` prop is set to `none`.
|
||||
|
||||
### Series with overlaps
|
||||
|
||||
Use negative offset for overlapping sequences:
|
||||
|
||||
```tsx
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</Series.Sequence>
|
||||
<Series.Sequence offset={-15} durationInFrames={60}>
|
||||
{/* Starts 15 frames before SceneA ends */}
|
||||
<SceneB />
|
||||
</Series.Sequence>
|
||||
</Series>
|
||||
```
|
||||
|
||||
## Frame References Inside Sequences
|
||||
|
||||
Inside a Sequence, `useCurrentFrame()` returns the local frame (starting from 0):
|
||||
|
||||
```tsx
|
||||
<Sequence from={60} durationInFrames={30}>
|
||||
<MyComponent />
|
||||
{/* Inside MyComponent, useCurrentFrame() returns 0-29, not 60-89 */}
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
## Nested Sequences
|
||||
|
||||
Sequences can be nested for complex timing:
|
||||
|
||||
```tsx
|
||||
<Sequence from={0} durationInFrames={120}>
|
||||
<Background />
|
||||
<Sequence from={15} durationInFrames={90} layout="none">
|
||||
<Title />
|
||||
</Sequence>
|
||||
<Sequence from={45} durationInFrames={60} layout="none">
|
||||
<Subtitle />
|
||||
</Sequence>
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
## Nesting compositions within another
|
||||
|
||||
To add a composition within another composition, you can use the `<Sequence>` component with a `width`, `height`, `durationInFrames` prop to specify the size of the composition.
|
||||
This will override the values of `useVideoConfig()` when calling inside that component.
|
||||
|
||||
```tsx
|
||||
<AbsoluteFill>
|
||||
<Sequence width={500} height={500} durationInFrames={100} from={30}>
|
||||
<CompositionComponent />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: sfx
|
||||
description: Including sound effects
|
||||
metadata:
|
||||
tags: sfx, sound, effect, audio
|
||||
---
|
||||
|
||||
To include a sound effect, use the `<Audio>` tag:
|
||||
|
||||
```tsx
|
||||
import { Audio } from "@remotion/sfx";
|
||||
|
||||
<Audio src={"https://remotion.media/whoosh.wav"} />;
|
||||
```
|
||||
|
||||
The following sound effects are available:
|
||||
|
||||
- `https://remotion.media/whoosh.wav`
|
||||
- `https://remotion.media/whip.wav`
|
||||
- `https://remotion.media/page-turn.wav`
|
||||
- `https://remotion.media/switch.wav`
|
||||
- `https://remotion.media/mouse-click.wav`
|
||||
- `https://remotion.media/shutter-modern.wav`
|
||||
- `https://remotion.media/shutter-old.wav`
|
||||
- `https://remotion.media/ding.wav`
|
||||
- `https://remotion.media/bruh.wav`
|
||||
- `https://remotion.media/vine-boom.wav`
|
||||
- `https://remotion.media/windows-xp-error.wav`
|
||||
- `https://remotion.media/fah.wav`
|
||||
- `https://remotion.media/spongebob-fail.wav`
|
||||
- `https://remotion.media/omg-hell-nah.wav`
|
||||
- `https://remotion.media/price-is-right-fail.wav`
|
||||
- `https://remotion.media/romance-meme.wav`
|
||||
- `https://remotion.media/bone-crack.wav`
|
||||
- `https://remotion.media/anime-wow.wav`
|
||||
- `https://remotion.media/yippee.wav`
|
||||
- `https://remotion.media/loading-lag.wav`
|
||||
- `https://remotion.media/wilhelm-scream.wav`
|
||||
- `https://remotion.media/mac-quack.wav`
|
||||
- `https://remotion.media/skedaddle.wav`
|
||||
- `https://remotion.media/snapchat-notification.wav`
|
||||
- `https://remotion.media/nelly-ahh.wav`
|
||||
- `https://remotion.media/sanctuary-guardian-what.wav`
|
||||
- `https://remotion.media/minecraft-hurt.wav`
|
||||
- `https://remotion.media/oh-my-god-vine.wav`
|
||||
- `https://remotion.media/illuminati-confirmed.wav`
|
||||
- `https://remotion.media/dramatic-boomer.wav`
|
||||
- `https://remotion.media/triggered.wav`
|
||||
- `https://remotion.media/record-scratch.wav`
|
||||
|
||||
For more sound effects, search the internet. A good resource is https://github.com/kapishdima/soundcn/tree/main/assets.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: silence-detection
|
||||
description: Adaptive silence detection for video/audio files using FFmpeg loudnorm and silencedetect
|
||||
metadata:
|
||||
tags: silence, detection, trimming, ffmpeg, loudnorm, audio
|
||||
---
|
||||
|
||||
# Adaptive Silence Detection
|
||||
|
||||
Detect silent segments in video or audio files.
|
||||
|
||||
Requires FFmpeg — see [ffmpeg.md](./ffmpeg.md) for how to invoke it in Remotion projects.
|
||||
|
||||
## Step 1: Measure loudness with `loudnorm`
|
||||
|
||||
Use the `loudnorm` filter in JSON mode to get the EBU R128 integrated loudness and gating threshold for each file:
|
||||
|
||||
```bash
|
||||
npx remotion ffmpeg -i public/video.mov -map 0:a -af loudnorm=print_format=json -f null /dev/null
|
||||
```
|
||||
|
||||
As output you will get:
|
||||
- `input_i`: Integrated loudness (dB) — the overall perceived volume
|
||||
- `input_thresh`: EBU R128 gating threshold (dB) — the level below which audio is considered too quiet to count toward loudness measurement
|
||||
|
||||
## Step 2: Detect silences using adaptive threshold
|
||||
|
||||
Pass the `input_thresh` value from step 1 as the `noise` parameter to `silencedetect`:
|
||||
|
||||
```bash
|
||||
npx remotion ffmpeg -i public/video.mov -map 0:a -af "silencedetect=noise=${THRESH}dB:d=0.5" -f null /dev/null
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- `noise`: The threshold below which audio is considered silent. Use `input_thresh` from step 1.
|
||||
- `d`: Minimum silence duration in seconds. `0.5` is a good default.
|
||||
|
||||
## Interpreting the output
|
||||
|
||||
The filter outputs pairs of `silence_start` and `silence_end` timestamps:
|
||||
|
||||
```
|
||||
[silencedetect] silence_start: 0
|
||||
[silencedetect] silence_end: 2.241021 | silence_duration: 2.241021
|
||||
[silencedetect] silence_start: 38.77425
|
||||
[silencedetect] silence_end: 39.619604 | silence_duration: 0.845354
|
||||
```
|
||||
|
||||
## Identifying leading and trailing silence
|
||||
|
||||
- **Leading silence**: Consecutive silence segments starting at or near 0. If the first `silence_start` is > 0.5s, there is no leading silence.
|
||||
- **Trailing silence**: The last silence segment that extends to (or near) the end of the file. Compare the last `silence_end` with the file's total duration.
|
||||
|
||||
When multiple silences are nearly contiguous at the start or end (gap < 0.2s), treat them as a single leading/trailing silence block.
|
||||
|
||||
## Using with Remotion's `<Video>` component
|
||||
|
||||
Apply the detected trim points using `trimBefore` and `trimAfter` (values are in frames):
|
||||
|
||||
```tsx
|
||||
import { Video } from "@remotion/media";
|
||||
import { staticFile, useVideoConfig } from "remotion";
|
||||
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
<Video
|
||||
src={staticFile("video.mov")}
|
||||
trimBefore={Math.floor(leadingEnd * fps)}
|
||||
trimAfter={Math.ceil(trailingStart * fps)}
|
||||
/>
|
||||
```
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: text-highlights
|
||||
description: Animated text highlights and hand-drawn annotations using @remotion/rough-notation.
|
||||
metadata:
|
||||
tags: text, highlights, annotations, circles, rough-notation
|
||||
---
|
||||
|
||||
# Text highlights
|
||||
|
||||
Use `@remotion/rough-notation` to draw animated annotations around or behind text. It supports highlights, circles, underlines, strike-throughs, crossed-off text, boxes, and brackets.
|
||||
|
||||
Docs: https://www.remotion.dev/docs/text-highlights
|
||||
|
||||
Install the package using the same Remotion version as the project:
|
||||
|
||||
```bash
|
||||
bunx remotion add @remotion/rough-notation
|
||||
```
|
||||
|
||||
Choose the component that describes the annotation: `<Highlight>`, `<Circle>`, `<Underline>`, `<StrikeThrough>`, `<CrossedOff>`, `<Box>`, or `<Bracket>`. The component determines both the annotation style and whether it renders behind or on top of the text.
|
||||
|
||||
Drive `progress` from `useCurrentFrame()` so the annotation is deterministic and synchronized with the video:
|
||||
|
||||
```tsx
|
||||
import {Circle, Highlight} from '@remotion/rough-notation';
|
||||
import {interpolate, useCurrentFrame} from 'remotion';
|
||||
|
||||
export const TextAnnotations: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
return (
|
||||
<div style={{fontSize: 80}}>
|
||||
This is{' '}
|
||||
<Highlight
|
||||
color="rgba(255, 236, 79, 0.62)"
|
||||
progress={interpolate(frame, [15, 40], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
})}
|
||||
>
|
||||
important
|
||||
</Highlight>
|
||||
, and this is{' '}
|
||||
<Circle color="#2563eb" progress={interpolate(frame, [15, 40], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
})}>
|
||||
connected
|
||||
</Circle>
|
||||
.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Keep `progress` inline, hardcoded and use `interpolate` for maximum [Studio interactivity](../remotion-interactivity/REFERENCE.md).
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: timing
|
||||
description: Interpolation and timing in Remotion—prefer interpolate with Bézier easing; springs as a specialized option
|
||||
metadata:
|
||||
tags: easing, bezier, interpolation, spring, timing
|
||||
---
|
||||
|
||||
Drive motion with `interpolate()` over an explicit frame range. Prefer `interpolate()` over `spring()` unless the user explicitly asks for physics-based motion. To customize timing, use **`Easing.bezier`**. The four parameters are the same as CSS `cubic-bezier(x1, y1, x2, y2)`.
|
||||
|
||||
A simple linear interpolation is done using the `interpolate` function.
|
||||
|
||||
```ts title="Going from 0 to 1 over 100 frames"
|
||||
import { interpolate } from "remotion";
|
||||
|
||||
const opacity = interpolate(frame, [0, 100], [0, 1]);
|
||||
```
|
||||
|
||||
By default, the values are not clamped, so the value can go outside the range [0, 1].
|
||||
Here is how they can be clamped:
|
||||
|
||||
```ts title="Going from 0 to 1 over 100 frames with extrapolation"
|
||||
const opacity = interpolate(frame, [0, 100], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
extrapolateLeft: "clamp",
|
||||
});
|
||||
```
|
||||
|
||||
## Studio-editable animation patterns
|
||||
|
||||
When an animation should be editable in Remotion Studio, keep the `interpolate()` call directly in the `style` prop and prefer individual CSS transform properties:
|
||||
|
||||
```tsx
|
||||
// 👍 Inline editable keyframes and transform shorthands
|
||||
style={{
|
||||
scale: interpolate(frame, [0, 100], [0, 1]),
|
||||
translate: interpolate(frame, [0, 100], ["0px 0px", "100px 100px"]),
|
||||
rotate: interpolate(frame, [0, 100], ["20deg", "90deg"]),
|
||||
}}
|
||||
|
||||
// 👎 Hidden values and transform strings become computed in Studio
|
||||
const translateY = interpolate(frame, [0, 100], [0, 120]);
|
||||
const rotation = interpolate(frame, [0, 100], [0, 20]);
|
||||
|
||||
style={{
|
||||
transform: `translateY(${translateY}px) rotate(${rotation}deg)`,
|
||||
}}
|
||||
```
|
||||
|
||||
Use `transform` strings only when individual CSS transform properties do not cover the effect, such as `skew()`, `perspective()`, or order-sensitive multi-transform chains.
|
||||
|
||||
## Bézier easing
|
||||
|
||||
Use `Easing.bezier(x1, y1, x2, y2)` inside the `interpolate` options object. The curve is identical in spirit to CSS animations and transitions, which helps when you are stealing timing from the web or from a designer’s spec.
|
||||
|
||||
```ts
|
||||
import { interpolate, Easing } from "remotion";
|
||||
|
||||
const opacity = interpolate(frame, [0, 60], [0, 1], {
|
||||
easing: Easing.bezier(0.16, 1, 0.3, 1),
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
```
|
||||
|
||||
### Examples (copy-paste curves)
|
||||
|
||||
**1. Crisp UI entrance (strong ease-out, no overshoot)** — slows nicely into the rest value; similar to many system “deceleration” curves.
|
||||
|
||||
```tsx
|
||||
const enter = interpolate(frame, [0, 45], [0, 1], {
|
||||
easing: Easing.bezier(0.16, 1, 0.3, 1),
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
```
|
||||
|
||||
**2. Editorial / slow fade (balanced ease-in-out)** — symmetric acceleration and deceleration over a hold-friendly move.
|
||||
|
||||
```tsx
|
||||
const progress = interpolate(frame, [0, 90], [0, 1], {
|
||||
easing: Easing.bezier(0.45, 0, 0.55, 1),
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
```
|
||||
|
||||
**3. Playful overshoot (control point y > 1)** — a little past the target then settles; use sparingly for emphasis.
|
||||
|
||||
```tsx
|
||||
const pop = interpolate(frame, [0, 30], [0, 1], {
|
||||
easing: Easing.bezier(0.34, 1.56, 0.64, 1),
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
```
|
||||
|
||||
## Studio-editable easing curves
|
||||
|
||||
Use an explicit `Easing.bezier()` curve instead of composed presets such as `Easing.inOut(Easing.cubic)`. Explicit Bézier control points remain editable in Remotion Studio.
|
||||
|
||||
```ts
|
||||
import { interpolate, Easing } from "remotion";
|
||||
|
||||
const value1 = interpolate(frame, [0, 100], [0, 1], {
|
||||
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
```
|
||||
|
||||
The default easing is `Easing.linear`.
|
||||
|
||||
### Easing direction for enter/exit animations
|
||||
|
||||
Use an ease-out Bézier curve for enter animations (starts fast, decelerates into place) and an ease-in Bézier curve for exit animations (starts slow, accelerates away). This feels natural because elements arrive with momentum and leave with gravity.
|
||||
|
||||
## Composing interpolations
|
||||
|
||||
When multiple properties share the same timing and do not need Studio keyframe editing (e.g. a slide-in panel and a video shift), avoid duplicating the full interpolation for each property. Instead, create a single normalized progress value (0 to 1) and derive each property from it:
|
||||
|
||||
```tsx
|
||||
const slideIn = interpolate(
|
||||
frame,
|
||||
[slideInStart, slideInStart + slideInDuration],
|
||||
[0, 1],
|
||||
{
|
||||
easing: Easing.bezier(0.22, 1, 0.36, 1),
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
},
|
||||
);
|
||||
const slideOut = interpolate(
|
||||
frame,
|
||||
[slideOutStart, slideOutStart + slideOutDuration],
|
||||
[0, 1],
|
||||
{
|
||||
easing: Easing.bezier(0.333, 0, 0.667, 0),
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
},
|
||||
);
|
||||
const progress = slideIn - slideOut;
|
||||
|
||||
// Derive multiple properties from the same progress
|
||||
const overlayX = interpolate(progress, [0, 1], [100, 0]);
|
||||
const videoX = interpolate(progress, [0, 1], [0, -20]);
|
||||
const opacity = interpolate(progress, [0, 1], [0, 1]);
|
||||
```
|
||||
|
||||
The key idea: separate **timing** (when and how fast) from **mapping** (what values to animate between).
|
||||
|
||||
If the values should be visually keyframed in Studio, prefer inline `interpolate()` calls in the relevant style props, even if it duplicates the timing.
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: transitions
|
||||
description: Scene transitions and overlays for Remotion using TransitionSeries.
|
||||
metadata:
|
||||
tags: transitions, overlays, fade, slide, wipe, scenes
|
||||
---
|
||||
|
||||
## TransitionSeries
|
||||
|
||||
`<TransitionSeries>` arranges scenes and supports two ways to enhance the cut point between them:
|
||||
|
||||
- **Transitions** (`<TransitionSeries.Transition>`) — crossfade, slide, wipe, etc. between two scenes. Shortens the timeline because both scenes play simultaneously during the transition.
|
||||
- **Overlays** (`<TransitionSeries.Overlay>`) — render an effect (e.g. a light leak) on top of the cut point without shortening the timeline.
|
||||
|
||||
Children are absolutely positioned.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/transitions
|
||||
```
|
||||
|
||||
## Transition example
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: 15 })}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneB />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>;
|
||||
```
|
||||
|
||||
## Overlay example
|
||||
|
||||
Any React component can be used as an overlay. For a ready-made effect, see the **light-leaks** rule.
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries } from "@remotion/transitions";
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Overlay durationInFrames={20}>
|
||||
<LightLeak />
|
||||
</TransitionSeries.Overlay>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneB />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>;
|
||||
```
|
||||
|
||||
## Mixing transitions and overlays
|
||||
|
||||
Transitions and overlays can coexist in the same `<TransitionSeries>`, but an overlay cannot be adjacent to a transition or another overlay.
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Overlay durationInFrames={30}>
|
||||
<LightLeak />
|
||||
</TransitionSeries.Overlay>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneB />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: 15 })}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneC />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>;
|
||||
```
|
||||
|
||||
## Transition props
|
||||
|
||||
`<TransitionSeries.Transition>` requires:
|
||||
|
||||
- `presentation` — the visual effect (e.g. `fade()`, `slide()`, `wipe()`).
|
||||
- `timing` — controls speed and easing (e.g. `linearTiming()`, `springTiming()`).
|
||||
|
||||
## Overlay props
|
||||
|
||||
`<TransitionSeries.Overlay>` accepts:
|
||||
|
||||
- `durationInFrames` — how long the overlay is visible (positive integer).
|
||||
- `offset?` — shifts the overlay relative to the cut point center. Positive = later, negative = earlier. Default: `0`.
|
||||
|
||||
## Available transition types
|
||||
|
||||
Import transitions from their respective modules:
|
||||
|
||||
```tsx
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
import { wipe } from "@remotion/transitions/wipe";
|
||||
import { flip } from "@remotion/transitions/flip";
|
||||
import { clockWipe } from "@remotion/transitions/clock-wipe";
|
||||
```
|
||||
|
||||
## Slide transition with direction
|
||||
|
||||
```tsx
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={slide({ direction: "from-left" })}
|
||||
timing={linearTiming({ durationInFrames: 20 })}
|
||||
/>;
|
||||
```
|
||||
|
||||
Directions: `"from-left"`, `"from-right"`, `"from-top"`, `"from-bottom"`
|
||||
|
||||
## Timing options
|
||||
|
||||
```tsx
|
||||
import { linearTiming, springTiming } from "@remotion/transitions";
|
||||
|
||||
// Linear timing - constant speed
|
||||
linearTiming({ durationInFrames: 20 });
|
||||
|
||||
// Spring timing - organic motion
|
||||
springTiming({ config: { damping: 200 }, durationInFrames: 25 });
|
||||
```
|
||||
|
||||
## Duration calculation
|
||||
|
||||
Transitions overlap adjacent scenes, so the total composition length is **shorter** than the sum of all sequence durations. Overlays do **not** affect the total duration.
|
||||
|
||||
For example, with two 60-frame sequences and a 15-frame transition:
|
||||
|
||||
- Without transitions: `60 + 60 = 120` frames
|
||||
- With transition: `60 + 60 - 15 = 105` frames
|
||||
|
||||
Adding an overlay between two other sequences does not change the total.
|
||||
|
||||
### Getting the duration of a transition
|
||||
|
||||
Use the `getDurationInFrames()` method on the timing object:
|
||||
|
||||
```tsx
|
||||
import { linearTiming, springTiming } from "@remotion/transitions";
|
||||
|
||||
const linearDuration = linearTiming({
|
||||
durationInFrames: 20,
|
||||
}).getDurationInFrames({ fps: 30 });
|
||||
// Returns 20
|
||||
|
||||
const springDuration = springTiming({
|
||||
config: { damping: 200 },
|
||||
}).getDurationInFrames({ fps: 30 });
|
||||
// Returns calculated duration based on spring physics
|
||||
```
|
||||
|
||||
For `springTiming` without an explicit `durationInFrames`, the duration depends on `fps` because it calculates when the spring animation settles.
|
||||
|
||||
### Calculating total composition duration
|
||||
|
||||
```tsx
|
||||
import { linearTiming } from "@remotion/transitions";
|
||||
|
||||
const scene1Duration = 60;
|
||||
const scene2Duration = 60;
|
||||
const scene3Duration = 60;
|
||||
|
||||
const timing1 = linearTiming({ durationInFrames: 15 });
|
||||
const timing2 = linearTiming({ durationInFrames: 20 });
|
||||
|
||||
const transition1Duration = timing1.getDurationInFrames({ fps: 30 });
|
||||
const transition2Duration = timing2.getDurationInFrames({ fps: 30 });
|
||||
|
||||
const totalDuration =
|
||||
scene1Duration +
|
||||
scene2Duration +
|
||||
scene3Duration -
|
||||
transition1Duration -
|
||||
transition2Duration;
|
||||
// 60 + 60 + 60 - 15 - 20 = 145 frames
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: trimming
|
||||
description: Trimming patterns for Remotion - cut the beginning or end of animations
|
||||
metadata:
|
||||
tags: sequence, trim, clip, cut, offset
|
||||
---
|
||||
|
||||
Use `<Sequence>` with a negative `from` value to trim the start of an animation.
|
||||
|
||||
## Trim the Beginning
|
||||
|
||||
A negative `from` value shifts time backwards, making the animation start partway through:
|
||||
|
||||
```tsx
|
||||
import { Sequence, useVideoConfig } from "remotion";
|
||||
|
||||
const fps = useVideoConfig();
|
||||
|
||||
<Sequence from={-0.5 * fps}>
|
||||
<MyAnimation />
|
||||
</Sequence>;
|
||||
```
|
||||
|
||||
The animation appears 15 frames into its progress - the first 15 frames are trimmed off.
|
||||
Inside `<MyAnimation>`, `useCurrentFrame()` starts at 15 instead of 0.
|
||||
|
||||
## Trim the End
|
||||
|
||||
Use `durationInFrames` to unmount content after a specified duration:
|
||||
|
||||
```tsx
|
||||
<Sequence durationInFrames={1.5 * fps}>
|
||||
<MyAnimation />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
The animation plays for 45 frames, then the component unmounts.
|
||||
|
||||
## Trim and Delay
|
||||
|
||||
Nest sequences to both trim the beginning and delay when it appears:
|
||||
|
||||
```tsx
|
||||
<Sequence from={30}>
|
||||
<Sequence from={-15}>
|
||||
<MyAnimation />
|
||||
</Sequence>
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
The inner sequence trims 15 frames from the start, and the outer sequence delays the result by 30 frames.
|
||||
@@ -0,0 +1,66 @@
|
||||
Remotion can be used for bare-bones video editing in the Studio. Choose the source structure based on the editing behavior you want:
|
||||
|
||||
- Use independently positioned clips when moving or resizing one clip should not affect any other clip.
|
||||
- Use ripple editing when changing one clip's duration should reposition every clip after it.
|
||||
|
||||
Keep every editable clip as its own authored JSX node. Do not generate editable clips with `.map()` or another programmatic loop.
|
||||
|
||||
## Independently positioned clips
|
||||
|
||||
Place every `<Video>` directly in the composition and hardcode its timing props. `from={0}` may be omitted:
|
||||
|
||||
```tsx
|
||||
<Video src="https://remotion.media/video.mp4" trimBefore={0} durationInFrames={78} />
|
||||
<Video src="https://remotion.media/video.webm" trimBefore={12} from={78} durationInFrames={66} />
|
||||
<Video src="https://remotion.media/video.mp4" trimBefore={72} from={144} durationInFrames={90} />
|
||||
<Video src="https://remotion.media/video.webm" trimBefore={58} from={234} durationInFrames={72} />
|
||||
<Video src="https://remotion.media/video.mp4" trimBefore={180} from={306} durationInFrames={60} />
|
||||
```
|
||||
|
||||
- `from` is the clip's absolute start frame in its parent timeline.
|
||||
- `durationInFrames` is how many frames the clip remains visible.
|
||||
- `trimBefore` is how many source frames are skipped before playback begins.
|
||||
- Each `<Video>` must be a separate JSX node. Add a descriptive `name` when useful in the Studio timeline.
|
||||
- `from`, `durationInFrames`, and `trimBefore` must be hardcoded frame values. Do not compute them.
|
||||
- Import `<Video>` from `@remotion/media`.
|
||||
|
||||
Moving or resizing one of these clips does not reposition later clips. Gaps and overlaps are therefore allowed.
|
||||
|
||||
## Ripple editing with `TransitionSeries`
|
||||
|
||||
“Ripple editing” is the standard video-editing term for changing one clip and automatically shifting everything after it.
|
||||
In Remotion, a `<TransitionSeries>` provides this sequential, cascading timing model while also allowing transitions between clips.
|
||||
|
||||
Read [transitions.md](transitions.md) for transition types, timing options, installation instructions, and composition-duration calculation.
|
||||
|
||||
Keep the markup like this:
|
||||
|
||||
```tsx
|
||||
<TransitionSeries name="Video timeline">
|
||||
<TransitionSeries.Sequence name="Clip 1" durationInFrames={39}>
|
||||
<Video
|
||||
src="https://remotion.media/video.mp4"
|
||||
trimBefore={0}
|
||||
/>
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Sequence name="Clip 2" durationInFrames={45}>
|
||||
<Video
|
||||
src="https://remotion.media/video.webm"
|
||||
trimBefore={8}
|
||||
/>
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Sequence name="Clip 3" durationInFrames={43}>
|
||||
<Video
|
||||
src="https://remotion.media/video.mp4"
|
||||
trimBefore={60}
|
||||
/>
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
```
|
||||
|
||||
- The `<TransitionSeries.Sequence>` is the editable clip row in the Studio timeline.
|
||||
- Dragging its right edge changes `durationInFrames` and repositions every later sequence.
|
||||
- Do not set `from` on `<TransitionSeries.Sequence>`; the series calculates each start frame.
|
||||
- Hardcode all numeric values.
|
||||
- Do not programmatically create multiple `<TransitionSeries.Sequence>` (no `.map`). Each instance must be hard-coded.
|
||||
- Import `<Video>` from `@remotion/media`. Import `<TransitionSeries>` from `@remotion/transitions`. If needing to install: `npx remotion add @remotion/media @remotion/transitions`
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
name: voiceover
|
||||
description: Adding AI-generated voiceover to Remotion compositions using TTS
|
||||
metadata:
|
||||
tags: voiceover, audio, elevenlabs, tts, speech, calculateMetadata, dynamic duration
|
||||
---
|
||||
|
||||
# Adding AI voiceover to a Remotion composition
|
||||
|
||||
Use ElevenLabs TTS to generate speech audio per scene, then use [`calculateMetadata`](./calculate-metadata.md) to dynamically size the composition to match the audio.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
By default this guide uses **ElevenLabs** as the TTS provider (`ELEVENLABS_API_KEY` environment variable). Users may substitute any TTS service that can produce an audio file.
|
||||
|
||||
If the user has not specified a TTS provider, recommend ElevenLabs and ask for their API key.
|
||||
|
||||
Ensure the environment variable is available when running the generation script:
|
||||
|
||||
```bash
|
||||
node --strip-types generate-voiceover.ts
|
||||
```
|
||||
|
||||
## Generating audio with ElevenLabs
|
||||
|
||||
Create a script that reads the config, calls the ElevenLabs API for each scene, and writes MP3 files to the `public/` directory so Remotion can access them via `staticFile()`.
|
||||
|
||||
The core API call for a single scene:
|
||||
|
||||
```ts title="generate-voiceover.ts"
|
||||
const response = await fetch(
|
||||
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"xi-api-key": process.env.ELEVENLABS_API_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "audio/mpeg",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: "Welcome to the show.",
|
||||
model_id: "eleven_multilingual_v2",
|
||||
voice_settings: {
|
||||
stability: 0.5,
|
||||
similarity_boost: 0.75,
|
||||
style: 0.3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const audioBuffer = Buffer.from(await response.arrayBuffer());
|
||||
writeFileSync(`public/voiceover/${compositionId}/${scene.id}.mp3`, audioBuffer);
|
||||
```
|
||||
|
||||
## Dynamic composition duration with calculateMetadata
|
||||
|
||||
Use [`calculateMetadata`](./calculate-metadata.md) to measure the [audio durations](../remotion-multimedia/get-audio-duration.md) and set the composition length accordingly.
|
||||
|
||||
```tsx
|
||||
import { CalculateMetadataFunction, staticFile } from "remotion";
|
||||
import { getAudioDuration } from "./get-audio-duration";
|
||||
|
||||
const FPS = 30;
|
||||
|
||||
const SCENE_AUDIO_FILES = [
|
||||
"voiceover/my-comp/scene-01-intro.mp3",
|
||||
"voiceover/my-comp/scene-02-main.mp3",
|
||||
"voiceover/my-comp/scene-03-outro.mp3",
|
||||
];
|
||||
|
||||
export const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
const durations = await Promise.all(
|
||||
SCENE_AUDIO_FILES.map((file) => getAudioDuration(staticFile(file))),
|
||||
);
|
||||
|
||||
const sceneDurations = durations.map((durationInSeconds) => {
|
||||
return durationInSeconds * FPS;
|
||||
});
|
||||
|
||||
return {
|
||||
durationInFrames: Math.ceil(sceneDurations.reduce((sum, d) => sum + d, 0)),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
The computed `sceneDurations` are passed into the component via a `voiceover` prop so the component knows how long each scene should be.
|
||||
|
||||
If the composition uses [`<TransitionSeries>`](./transitions.md), subtract the overlap from total duration: [./transitions.md#calculating-total-composition-duration](./transitions.md#calculating-total-composition-duration)
|
||||
|
||||
## Rendering audio in the component
|
||||
|
||||
See [audio.md](./audio.md) for more information on how to render audio in the component.
|
||||
|
||||
## Delaying audio start
|
||||
|
||||
See [audio.md#delaying](./audio.md#delaying) for more information on how to delay the audio start.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: remotion-multimedia
|
||||
description: Interacting with Mediabunny
|
||||
metadata:
|
||||
tags: remotion, mediabunny, multimedia, video, audio
|
||||
---
|
||||
|
||||
Mediabunny is a multimedia library for dealing with audio and video in the browser.
|
||||
Here is a compact overview of its capabilities: https://mediabunny.dev/llms.txt
|
||||
|
||||
## Getting audio duration
|
||||
|
||||
See [get-audio-duration.md](get-audio-duration.md) for getting the duration of an audio file in seconds with Mediabunny.
|
||||
|
||||
## Getting video dimensions
|
||||
|
||||
See [get-video-dimensions.md](get-video-dimensions.md) for getting the width and height of a video file with Mediabunny.
|
||||
|
||||
## Getting video duration
|
||||
|
||||
See [get-video-duration.md](get-video-duration.md) for getting the duration of a video file in seconds with Mediabunny.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user