Remotion × MapTiler 地图解说动画架构完全指南:时序模型、河流电光绘制与固定地图底板
这是一份针对 Remotion 开源仓库中 remotion-maps 技能的深度技术解析。它以「Map Explainer(地图解说)」动画为对象,讲解如何在 MapTiler Planet 矢量底图上,用 React 编写一段"河流从源头一路绘制、途经多个国家时依次点亮国境线→填充→文字标签"的叙事型地图动画。读完本文,你将掌握一套可复用的架构:基于秒的时序模型、provider 矢量直绘与自定义 GeoJSON 两种动画模式的选择、带"电光绘笔头"的河流揭示算法、逐国多段国境线安全切片,以及避免 headless 渲染抖动的"固定地图底板 + CSS 相机"方案。文章正文以 map-explainer-architecture.md 为骨架,源码佐证均取自本仓库该技能目录。
1. 整体架构一览:从一份参考文档到可运行的组件集
本技能目录位于 packages/skills/skills/remotion-maps/techniques/maptiler/,核心组件与文档如下:
- TECHNIQUE.md:总览性方法文档,定义来源选择、绘制河流、运动稳定等要点;
- assets/RiverReveal.tsx:主组件,实现"河流揭示 + 电光笔头 + 逐国序列 + 标签投影"全流程;
- assets/MapTilerVectorElement.ts:面向 MapTiler Planet provider 矢量图层的复用封装;
- assets/CountryLabel.tsx:可复用的示例标签(HTML overlay);
- assets/tokens.ts:示例调色板与时长常量;
- assets/example-Root.tsx:最小 Composition 脚手架;
- assets/sample-data/:示例河流坐标与生成的国家元数据;
- scripts/prep-geo.mjs:地理数据预处理管线;
- references/:含数据源选型、geo 预处理、本架构参考与渲染稳定性四篇分文档。
需要强调的是:文档中提供的数值(时长、颜色、触发时间)是示例值,不是一套生产级样式系统。自定义几何示例由 RiverReveal.tsx + CountryLabel.tsx + tokens.ts 组成,provider 矢量设置位于 MapTilerVectorElement.ts,两种模式的取舍详见 map-data-sources.md。消费方项目应替换全部示例几何、命名、时间与视觉 token——"不要把一个源工程的调色板带进另一个制作"。
渲染入口示例(见 example-Root.tsx)是一个 12 秒 @30fps 的 1920×1080 Composition;渲染命令为 bunx remotion render src/index.ts MapExplainer out.mp4 --gl=angle --concurrency=1 --timeout=120000。
2. 逐帧渲染链路(render harness):delayRender / idle / triggerRepaint 循环
MapTiler 地图实例只在组件挂载时初始化一次(用 ref 守卫 started),因为真正常用的资源是"单例地图 + 每帧命令式更新",而非反复创建实例。初始化时传入:
const m = new maptilersdk.Map({
container: ref.current,
style: maptilersdk.MapStyle.BASIC,
center: END.center,
zoom: Math.max(START.zoom, END.zoom), // 固定 plate 以最大 zoom 渲染,见 §7
pitch: END.pitch, bearing: END.bearing,
interactive: false, fadeDuration: 0,
canvasContextAttributes: {preserveDrawingBuffer: true},
});
随后在 map.on('load') 中完成三件事:清理底图杂乱(详见 references/map-geo-prep.md:移除 symbol 标签层与 /other border/i 的内层行政边界,并用 CSS 隐藏 .maplibregl-ctrl-bottom-left、.maptiler-logo 等控件);添加 sources 与 layers;然后等待 map.once('idle') → continueRender(handle) 释放首帧 delayRender。
从 RiverReveal.tsx 可以看到每一帧的完整模式:
const h = delayRender(`frame A ${frame}`);
const t = frame / fps; // 帧 → 秒
// …按 t 计算 reveal、切片 setData、setPaintProperty…
map.once('idle', () => continueRender(h));
map.triggerRepaint();
即:
delayRender → setData / setPaintProperty → map.once('idle', continueRender) → triggerRepaint
其中两个关键选项的语义是:
preserveDrawingBuffer: true:保证 Remotion 截图能抓取 WebGL canvas 内容;若为 false,绘制缓冲在合成后可能被清空,截图会得到空白地图。--gl=angle:渲染 WebGL 使用 ANGLE 后端,是 headless 抓帧的推荐参数(渲染稳定性细节见 render-stability.md)。
地图就绪后,不要通过状态驱动每一帧的 paint,而是用 useCurrentFrame() 在 effect 里命令式更新;CSS 过渡、浏览器计时器、requestAnimationFrame 都不适合驱动 Remotion 逐帧动画。
另外有一个易踩的坑:构造 MapLibre/MapTiler 图层对象时,必须省略不存在的可选属性。例如写 ...(layer.filter ? {filter: layer.filter} : {}) 而非 filter: undefined——一个 undefined filter 会悄悄压制该图层,而单独创建的 halo / border 图层仍在渲染,最终表现为"国家填充缺失、深色标记光晕空转无彩色核心"。这条原则已被封装进 MapTilerVectorElement.ts 的 addLayer 组装逻辑中。
3. 时序模型:以"秒"为纲,节拍长度由序列推导
与"按揭示单位(reveal-units)推进"的做法不同,本文档的模型一切都以秒为坐标:t = frame / fps。河流在一个时间窗口内完成绘制;每个国家在"河流抵达它"的时刻触发,随后运行一段固定长度的序列。所谓"节拍(beat)",就是"最后一个国家的完整序列刚好结束 + 收尾"所需的总时长,由序列自动推导。
核心常量(示例值):
const RIVER_START = 0.3, RIVER_END = 8.0; // 河流在 [0.3s, 8.0s] 窗口内绘制
const BORDER_S = 2.5, FILL_S = 1.0, LABEL_S = 0.7; // 每国三阶段序列,时长恒定
const trigger = (c) => RIVER_START + META[c].stop * (RIVER_END - RIVER_START);
// 节拍长度 = max_c( trigger(c) + BORDER_S + FILL_S + LABEL_S ) + 尾部
const reveal = interpolate(t, [RIVER_START, RIVER_END], [0,1],
{ …clamp, easing: Easing.bezier(0.645, 0.045, 0.355, 1) });
说明:
META[c].stop是该国在整个河流路径上的弧长比例——即河流进入该国的位置,由scripts/prep-geo.mjs预先生成(见 map-geo-prep.md 的stop小节:逐点行走河流、取第一个落入该国的点)。- 在 tokens.ts 中,
DUR.mapExplainer = 12 * VIDEO.fps(12 秒 @30fps)即示例节拍;若最后一个国家在河流抵达后需要更多余量,就调大durationInFrames。 - 河流揭示用的缓动是
Easing.bezier(0.645, 0.045, 0.355, 1)(近似 easeInOutSine 的缓入缓出)。
"恒定时长"是一条必须遵守的纪律:国境线的绘制进度应当由"自触发以来的本地时间"驱动(lt = t - trigger(c)),而不是由 reveal 的某个分片驱动。若把国境绘制绑定在河流 reveal 比例上,复杂或很长的国境线会在 reveal 的后段被压缩成零点几秒内闪完,视觉上完全失控。
4. 数据源二选一:provider 矢量 vs 自定义 GeoJSON
动手生产 GeoJSON 之前,先查 MapTiler Planet 是否已把该要素作为可过滤的矢量数据暴露。决策规则(完整矩阵见 map-data-sources.md):
- MapTiler 矢量层:要素已存在于 provider 的
source-layer、其属性支持编辑级精确过滤、且 provider 几何在编辑上可接受; - 自定义 GeoJSON:要素缺失、是规划/历史/争议/修正/私有数据、或者需要有序几何做确定性绘制;
- 混合模式:普通地理上下文(水系、道路)用 provider 矢量,主张性证据用自定义 GeoJSON,并保持两者视觉区分(记录来源与生效日期)。
要点:淡入淡出、颜色、宽度、模糊、圆角、fill-opacity、feature-state 高亮都可以放心在 provider 图层上逐帧动画;但不要把瓦片化的折线当作一条全局有序路径——矢量瓦片在瓦片边界处切分要素,source-to-end 的"方向性绘制"在 provider 层上不存在可靠全局顺序。方向承载语义的绘制,必须先把要素提取、校验、排序后烘焙为 GeoJSON。
4.1 provider 模式:直接原地动画 MapTiler Planet 要素
封装在 MapTilerVectorElement.ts:addMapTilerVectorElement 首次调用时以 https://api.maptiler.com/tiles/v3/tiles.json?key=${apiKey} 添加 maptiler-planet vector source(默认 id 常量 SOURCE_ID),再以 source-layer + 精确 filter 添加 story 图层;setVectorElementPaint 将若干 paint 属性一次性写入图层。示例:
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});
它对 line / fill / circle / symbol 四类图层都有效,无需把 provider 几何复制进项目。注意读取当前 MapTiler Planet schema 再选 waterway、water、transportation、boundary、landcover、poi 等源层与字段(可用性与 zoom 区间随 schema 版本变化);feature-state 仅当 provider 提供稳定 ID 且跨瓦片选择确定时才使用。provider 对齐不等于正确——在把 provider 要素当作证据展示前,仍要用编辑源核对属性和几何。
5. 河流绘制 + "电光笔头":自定义折线的距离切片
在自定义模式下,河流是一根有序的 turf.lineString,预先按公里数度量(lineKm = turf.length(line))。每帧先用 reveal 计算已画长度,再做两次 turf.lineSliceAlong:一次喂给主体河流 source,一次喂给"电光笔头" source。
"电光(electricity)"的本质:白热笔头领先于绘制前沿——已画线段末端最后约 3% 长度的部分,单独放在一组高亮 + 辉光图层里,河流完整后在河口处淡出。
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); // 到达河口后 0.5s 淡出
map.setPaintProperty("river-headglow", "line-opacity", 0.85 * headFade);
map.setPaintProperty("river-head", "line-opacity", headFade);
Math.max(0.001, …) 的下限避免了长度为 0 的 GeoJSON 线段触发 setData 异常。图层自底向上(见 RiverReveal.tsx 与 tokens.ts):
| 顺序 | 图层 id | 语义 | 示例视觉 |
|---|---|---|---|
| 1 | river-glow |
电蓝辉光 | #49C6FF,宽 11,opacity 0.32,blur 6 |
| 2 | river-line |
冰冷内核 | #E8F7FF(COLORS.river),宽 3 |
| 3 | river-headglow |
笔头辉光 | rgba(120,225,255,.95),宽 16,blur 9,opacity 0.85×fade |
| 4 | river-head |
白热笔头 | #FFFFFF,宽 4.5,opacity = fade |
刻意不加深色描边(casing)——明亮的冰冷内核在任何填充色之上都能自己"读"出来,加暗描边反而污染视觉。所有线层使用 line-cap: round / line-join: round 以获得平滑的绘制笔触。
6. 逐国动画:国境绘制 → 填充绽放 → 标签升起
每个国家在河流抵达时触发,进入三段串行阶段。注意编辑取舍:国境线使用该国颜色的深色阶(COUNTRY_DARK)——电光只属于河流,不放在国境上,否则国境会喧宾夺主。
const lt = t - trigger(c); // 自触发起的本地秒数
// 1) 恒定 BORDER_S 内完成整条 source 国境的绘制,多段安全
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));
// 2) 国境完成之后填充绽放(opacity 先超调再回落)
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) 填充之后标签升起
const lp = clamp01((lt - BORDER_S - FILL_S) / LABEL_S);
填充的"超调再回落"由输入 [0, 0.6, 1] 与输出 [0, FILL_OPACITY*1.25, FILL_OPACITY] 的插值实现:opacity 冲到目标值 1.25 倍后回落到 FILL_OPACITY(示例 0.5,见 tokens.ts),缓动为 Easing.bezier(1/3, 1, 2/3, 1)(easeOutBack),产生"颜料绽开"的弹性感。
6.1 多段国境线的核心算法 sliceBorder
sliceBorder(d, fromKm, toKm) 将一条完整、可能多段的国境,以 MultiLineString 形式揭示 [fromKm, toKm] 区间:对每段按累计长度切片,不跨 gap 造连接、不受视口裁剪。源码实现于 RiverReveal.tsx,并在架构文档中以伪码给出:
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 } };
};
前置准备在组件里一次性完成:把 META[c].border(每国完整外环,country-meta.json 中生成)映射为若干 turf.lineString 线段,逐一求 turf.length 得 segLen,再滚动累加出累计起点 cum 与总长 total(DRAW 结构)。由于切片以累计长度为标尺,国境是多段、甚至跑出画面,都不会出现"跨缺口乱连"或"视口裁剪导致只有可见段被画"的问题——数据源层面已用 turf.booleanPointInPolygon + "保留每个外部环的完整源几何"保证完整性(见 map-geo-prep.md 的 border 小节:永远不要把国家或双边国境裁剪到取景 bbox,也不要丢弃屏幕外线段)。
填充、国境与河流的取色统一收口在产物的本地 token 文件(示例即 tokens.ts)——示例值只是示范,不应把源工程调色板原样带进另一个制作。
7. 标签:每帧投影的 HTML 覆盖层
标签用 React HTML 覆盖层而非地图 symbol 图层,从而获得完整排版控制(字重、字距、阴影、字体均可自定义)。CountryLabel 只是示例的"accent-rule + rise-and-fade"样式:居中锚定、上移淡入、accent 短横线从中心向两侧 scaleX 展开,并整体 pointerEvents: none;所有字体与视觉参数通过 CSS 变量(--map-label-font/-weight/-size/-tracking/-color/-shadow)给出中性回退,最终由消费方项目注入(见 CountryLabel.tsx)。
定位方式是每帧把锚点投影为屏幕像素并存入 state:
const p = map.project(META[c].anchor); // lngLat → 屏幕像素(跟随活相机)
pos[c] = { x: p.x, y: p.y, reveal: lp };
setLabels(pos); // 重渲染覆盖层;effect 依赖中排除 labels
实现上的关键点:
map.project(lngLat)给出该经纬度在当前相机下的屏幕坐标,天然贴合"活相机"视图;setLabels每帧写入新数组以触发覆盖层重渲染,但第二个绘制 effect 的依赖数组刻意不含labels,避免因标签状态变化造成绘制循环;- 在固定 plate 模式下(见 §8),坐标还要叠加 plate 变换:
x: p.x * plateScale + plateX(见 RiverReveal.tsx); - 锚点
META[c].anchor由prep-geo.mjs用**极点(pole of inaccessibility)**算法生成——在多边形内做 46×46 网格采样、取到边界距离最大的点(并按每国 story bbox 裁剪、叠加人工 nudge)。不要用质心(centroid),质心会被拉向边缘。
覆盖层渲染在顶层 <AbsoluteFill style={{pointerEvents:'none'}}> 内,逐国家映射 CountryLabel,仅当该国 labels[c] 已就绪时挂载。
8. 相机:任何运动都用"固定地图底板",不做逐帧 jumpTo
架构文档引用 render-stability.md 并给出硬性结论:做移动 2D 运镜时,不要每帧调用 map.jumpTo()。headless 渲染中逐帧 jumpTo 会让 MapTiler 的 hillshade 甚至卫星影像发生 shimmer / jitter——这是渲染器逐帧重采样所致,与瓦片加载、网络、标签都无关,瓦片重试和缓动调整解决不了它。
正确姿势是固定地图底板(fixed map plate):
- 把 MapTiler 画布一次性渲染在最大所需 zoom 的超大容器里(1920×1080 下安全默认是 3840×2160,1080×1920 竖版约 2700×3840);不要盲目用 3×——1920×1080 × 3 会到 5760px,超出 Chromium 常见 4096px 的 WebGL 渲染缓冲上限,被静默降采样后 CSS 放大反而更糊;
- 地图相机保持静止(pitch / bearing 恒定);
- 每帧算出审批过的目标 center/zoom,用 CSS
translate+scale平移缩放画布; - 对每个投影的 HTML 覆盖层施加完全相同的变换;
- GeoJSON data 与 paint 属性仍然逐帧命令式动画——只有渲染器相机被冻结。
其中 map.project + 指数缩放是关键:
const projected = map.project(camera.center); // 相机中心投影
const scale = 2 ** (camera.zoom - baseZoom); // baseZoom = max(起止 zoom)
const plate = {
transform: `translate(${width / 2 - projected.x * scale}px, ${height / 2 - projected.y * scale}px) scale(${scale})`,
transformOrigin: "0 0",
};
其余运维细节:
- 底板的渲染 zoom 取任一相机途经点的最大 zoom(含中间点与 hold 相机),保证 CSS scale 永远 ≤ 1(plate 不会被放大);
- plate 的居中基准是相机路径地理范围的中点而非终点,以最小化所需外扩(overscan);
- 最大的画布边控制在 4096px 以内,除非实测过更大的
MAX_RENDERBUFFER_SIZE;一条路径放不进底板就拆成两段固定 plate 加一个明确的编辑过渡,不要为了清晰度赌一张超大画布; - 区分两类失败:重复 shimmer = 活跃渲染器仍在移动;CSS 推进中瓦片持续发虚 = 底板规格不足 / 被内部降采样 / 被放大超过 1;
- 验收必须渲染一段短 MP4(而非只看 Studio 预览),在相机移动中检查静态地形纹理与卫星细节;底层地图细节一旦抖动就改用固定 plate,不要当作"轻微预览瑕疵"放行。
9. 把架构落地到新场景:替换清单与调参旋钮
综合四篇参考文档,将这套架构复用到新的"地图解说"制作时,按下面的清单逐项替换:
- 数据源决策(map-data-sources.md):该要素在 provider 中且可精确过滤 → 用
MapTilerVectorElement;否则用prep-geo.mjs烘焙 GeoJSON。 - Geo 预处理(map-geo-prep.md):调"要哪些国家"(country 列表 + 对应 polygon GeoJSON)、"标签落在哪个区域"(
ANCHOR_BBOX[country])、"标签往哪挪"(NUDGE[country],lng/lat 偏移)、"画哪条国境"(完整命名源几何,绝不用可见范围)、"何时点亮"(由stop派生)。 - 时序(本架构文档 §3):调
RIVER_START / RIVER_END、BORDER_S / FILL_S / LABEL_S;节拍长度 = 最后一个国家的完整序列 + 尾部。 - token(tokens.ts):各国填充色、深色国境阶、河流电光色与透明度、
FILL_OPACITY。 - 相机(render-stability.md):批准 center/zoom 路径 → 换算 plate 尺寸与 transform。
- 渲染验收:
bunx remotion render src/index.ts MapExplainer out.mp4 --gl=angle --concurrency=1 --timeout=120000,重点核验相机运动期间底图无抖动、笔头淡出干净、多段国境无跨缺口连画。
10. 本文要点速览
- 每帧渲染 =
delayRender → setData/setPaintProperty → once('idle') → continueRender → triggerRepaint,配preserveDrawingBuffer:true与--gl=angle; - 时序全部以秒驱动,节拍由序列反推;国境/填充/标签采用自触发起的恒定时长,绝不挂在河流 reveal 的分片上;
- "电光" = 白热笔头(末端 3% 的独立 line 切片层)在绘制中高亮、河口处 0.5s 淡出;
- 瓦片几何无全局顺序:方向性绘制一律烘焙成有序 GeoJSON,逐段累计长度切片成
MultiLineString; - 填充用 easeOutBack 型 opacity 超调回落在 0.25s 内产生"绽开";
- 标签 = 每帧
map.project(anchor)到屏幕坐标的 HTML 覆盖层,effect 依赖排除 labels 避免死循环; - 移动相机永不逐帧
jumpTo,而是超大固定 plate + CSS transform,投影坐标与 plate 变换同源叠加; - 任何示例数值与颜色都只是示范,生产环境必须换成自己的 token、字体与编辑源核对过的数据。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00