用 Remotion + MapTiler 制作可编程地图动画:矢量数据源选择、逐帧揭示绘制与固定图板渲染稳定性实践
本指南以 Remotion 仓库内 remotion-maps 技能包中的 MapTiler 地图动画技法文档(TECHNIQUE.md)为主线,讲解如何用 @maptiler/sdk 在 Remotion 合成中制作"底图 + 地理要素标注"类地图解说动画:先为每个地图元素正确选择数据源(MapTiler Planet 矢量瓦片 / 自备 GeoJSON / 两者混合),再按帧以命令式方式推进河流、国界、国名等元素,最后解决无头渲染环境下相机运动导致的底图抖动问题。读完你将获得一套可直接套用的「河流揭示 + 逐国点亮 + 固定图板运镜」工程范式,并可复用仓库中附带的组件与地理数据预处理脚本。
涉及的可运行示例均位于仓库内
packages/skills/skills/remotion-maps/techniques/maptiler/目录(以下简称"maptiler 示例目录")。仓库内还有配套的cesium、mapbox、maplibre、static-map等技法文档,本文只聚焦 MapTiler 路线。
一、技术选型:MapTiler 适合解决哪一类地图动画
MapTiler 在 Remotion 动画中的定位很明确:地图动画里需要把地理要素当作"画在底图上的注解"来逐帧呈现,例如国境线、河流、POI 地名标注等。它有两套关键能力:
- 通过
@maptiler/sdk把 MapTiler Planet 矢量图层与自定义 GeoJSON 一起画进 WebGL canvas; - 底图样式默认从
MapStyle.BASIC(标准矢量风格)起步,卫星影像(satellite)也是同样有效的选择。
技法文档特别强调了一条工程铁律:先想清楚"每个地图元素的数据从哪里来",再动手造 GeoJSON。MapTiler Planet 作为公开矢量瓦片数据源,已经覆盖了水系、道路、边界、土地利用、POI 等大量要素,能用服务端矢量筛选表达的,就不要本地再维护一份重复数据。
二、地图初始化与 Remotion 接入要点
2.1 环境变量与单例初始化
MapTiler 密钥通过环境变量 REMOTION_MAPTILER_KEY 注入,且应当使用 unrestricted(不限制域名) 类型的密钥,以保证无头渲染/逐帧截取时请求不被拦截。示例组件 RiverReveal.tsx 在模块顶层直接写入 SDK 配置:
import * as maptilersdk from '@maptiler/sdk';
import '@maptiler/sdk/dist/maptiler-sdk.css';
maptilersdk.config.apiKey = process.env.REMOTION_MAPTILER_KEY as string;
useEffect 中通过 useRef 的 guard(started.current)保证 Map 只初始化一次,绝不重复创建。初始化选项里有几个对渲染至关重要的值:
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,
fadeDuration: 0,
canvasContextAttributes: { preserveDrawingBuffer: true },
});
关键点解读(依据 RiverReveal.tsx):
interactive: false:关闭鼠标交互,防止合成过程中任何意外事件打断;fadeDuration: 0:去掉底图自身过渡动画,让一切动画都只由 Remotion 帧驱动;canvasContextAttributes: { preserveDrawingBuffer: true }:必须开启,否则 Remotion 的截帧机制可能读不到 WebGL 画布内容;- 相机(center/zoom/pitch/bearing)先被冻结在固定位置,见后文"固定图板"章节。
2.2 delayRender 门控:等地图真正 idle
WebGL 底图瓦片是异步加载的,Remotion 渲染每一帧时必须在画面就绪后才截图。正确做法是用 Remotion 的 delayRender/continueRender 把"地图首次 load + 首次 idle"挡在渲染流程外:
- 初始化阶段:调用一次
delayRender,监听map.once('load'),在load回调里做图层搭建,再map.once('idle', () => continueRender(handle)); - 之后每一帧:为当前帧再开一个
delayRender,执行完setData/setPaintProperty后,等map.once('idle', () => continueRender(h))并主动map.triggerRepaint()触发重绘。
示例组件里的每帧渲染流水线可概括为:
delayRender → setData / setPaintProperty → map.once('idle', continueRender) → triggerRepaint
useEffect(() => {
if (!map) return;
const h = delayRender(`frame A ${frame}`);
const t = frame / fps; // seconds
// …… 更新 river/trail/fill 等图层数据与 paint 属性 ……
map.once('idle', () => continueRender(h));
map.triggerRepaint();
}, [map, frame, fps, durationInFrames, width, height]);
2.3 帧驱动而非事件/CSS 驱动
动画进度一律取自 useCurrentFrame() 计算出的"当前秒数",不要用 CSS transition、浏览器 timer 或事件回调来驱动画面。这样每一帧的画面只由帧号决定——Remotion 无头渲染才能稳定复现、逐帧截取,也才能支持 seek 预览与跳帧渲染。
2.4 逐帧推进的统一语法:setData / setPaintProperty
动画期间不要重建图层。图层在 load 回调里建好一次,之后每帧只做两件事:改 GeoJSON 数据(map.getSource(id).setData(...))和改绘制属性(map.setPaintProperty(id, prop, value))。这种"命令式更新"是全文所有动效(河流、国界、填色、地名)的共同底层。
三、一个必须规避的 MapLibre 陷阱:缺省属性不要传 undefined
技法文档特别警告:在构造 MapLibre/MapTiler 图层对象时,不存在的可选属性应当整体省略,不要显式传 undefined。尤其是 filter,要写成:
...(layer.filter ? {filter: layer.filter} : {})
而不要写 filter: undefined。原因(有明确的故障症状描述):当 filter 为 undefined 时,它可能会让该图层静默失效,而另外单独创建的 halo(光晕)层、border(边界)层仍在继续渲染,最终表现为:国家填充消失、而 marker 的深色光晕圈还在,却没有彩色核心。仓库中 MapTilerVectorElement.ts 正是按此规范实现的,它同时对 minzoom/maxzoom/layout/filter 逐个做条件展开:
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,
);
四、数据源选择:MapTiler 矢量、自定义 GeoJSON 还是混合
这是整份技法中"先决策后编码"的一步。完整决策规则记录在 references/map-data-sources.md。
4.1 决策表
| 需求 | MapTiler 矢量图层 | 自定义 GeoJSON |
|---|---|---|
| 道路、水系、水域、边界、地表覆盖等标准上下文 | 优先 | 仅在供应商数据不足时使用 |
| 与底图几何一致、无需本地维护重复数据集 | 优先 | 否 |
| 拟议中、历史性、涉密/更正/生产专属的要素 | 否 | 优先 |
| 淡入、颜色、宽度、半径、模糊、填充透明度动画 | 可以 | 可以 |
| 存在稳定要素 ID 时的 feature-state 高亮 | 可以 | 可以 |
| 确定性的起点到终点线绘制/周长绘制 | 先烘焙成 GeoJSON | 优先 |
| 几何编辑、变形、裁剪、精确排序 | 否 | 优先 |
表格下方的提醒也很重要:供应商数据与编辑口径一致并不等于"正确"。在把供应商要素当作事实证据呈现在画面上之前,应当对照编辑来源核查其属性与几何。
4.2 模式一:MapTiler 矢量图层
适用前提:要素已存在于某个 provider source-layer、其属性支持精确筛选、且供应商几何在编辑上可接受。MapTiler Planet 是矢量瓦片源:只添加一次,然后用 source-layer + 精确的属性 filter 建故事图层。常见类别包括 waterway、water、transportation、boundary、landcover、poi 等——但不同 schema 版本的可用字段与缩放区间会变化,动手前应查阅当前 MapTiler Planet schema。
仓库中的可复用封装 MapTilerVectorElement.ts 提供两个方法:
addMapTilerVectorElement(map, apiKey, element, beforeId?):如果maptiler-planet矢量源尚未添加,则先以https://api.maptiler.com/tiles/v3/tiles.json?key=<apiKey>创建 vector source,随后按上面"缺省即省略"的方式添加图层;setVectorElementPaint(map, layerId, paint):逐条执行map.setPaintProperty,用于每帧推进。
典型用法(河流要素,先隐藏后按 reveal 淡入加粗):
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,
},
});
// 每 Remotion 帧:
setVectorElementPaint(map, 'story-river', {
'line-opacity': reveal,
'line-width': 2 + reveal * 2,
});
属性动画(opacity、colour、width、blur、fill opacity、circle radius、symbol opacity)都可以直接改 paint;只有"源提供稳定 ID 且跨瓦片选择确定"时才考虑 feature-state。
矢量瓦片几何没有全局顺序:瓦片在边界处会切碎要素,所以源到河口/起点到终点的"语义化绘制"没有可靠的全局顺序。若这种方向性动效承载叙事含义,应把整条要素抽取、校验、排序后烘焙成 GeoJSON,再走自定义模式。
4.3 模式二:自定义 GeoJSON
下列情形必须用自定义模式(依据 map-data-sources.md):
- 要素不存在于供应商数据集;
- 故事涉及拟议路线、规划隧道、历史边界、争议性解读或非公开数据集;
- 供应商几何被做过编辑更正;
- 动画需要按已验证的顺序穿过几何体;
- 需要绘制完整"屏幕外"边界,而视口查询会静默裁掉它。
自定义模式可配合 RiverReveal.tsx 与 scripts/prep-geo.mjs 直接使用:拥有精确几何,能按距离切分、计算入场触发、画完整边界、产出确定性序列。
4.4 模式三:混合(Hybrid)
当"普通地理上下文来自 MapTiler、而叙事主张依赖自定义证据"时用混合:MapTiler 水系与道路提供对齐的背景,自定义 GeoJSON 承载被高亮的拟议隧道、坝址、争议边界或经过核实的撤离区。每条自定义图层都要在生产记录里登记其数据来源与生效日期,且权证层级不同的图层视觉上要能区分。
五、河流揭示动效:turf 按里程切片 + 电光笔头
河流"逐段画出来"的标准做法,是每帧根据 reveal 进度用 turf 把整条河线切成前缀段:
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').setData(
turf.lineSliceAlong(line, 0, Math.max(0.001, riverDrawnKm)),
);
其中 line 是从 sample-data/yarlung-flow.json 读出的单条 LineString,lineKm = turf.length(line) 是其总里程;RIVER_START = 0.3、RIVER_END = 8.0 秒。
技法强调的"电光感"(electric draw-head)是:在绘制线的最前端放一个白热笔头,占已绘长度的最后约 3%,并带自己的辉光层,河流到河口后 0.5 秒内淡出:
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);
map.setPaintProperty('river-headglow', 'line-opacity', 0.85 * headFade);
map.setPaintProperty('river-head', 'line-opacity', headFade);
从底到顶的图层栈(见 RiverReveal.tsx 与架构文档):
river-glow:电蓝辉光(示例#49C6FF,宽 11,透明度 0.32,模糊 6);river-line:冰白芯线(示例#E8F7FF,宽 3);river-headglow:笔头辉光(示例rgba(120,225,255,.95),宽 16,模糊 9);river-head:白热笔头(#FFFFFF,宽 4.5)。
设计要点:不加深色描边(casing)——明亮的冰白芯线本身在任意填充色上都足够可读。这是与"电光在国界线上"方案相反的取舍:本范例把"电"放在河流上。
六、逐国点亮:边界描绘 → 填充绽放 → 地名升起
示例叙事是河流依次经过中国、印度、孟加拉(ORDER = ['china', 'india', 'bangladesh']),每当河流抵达一国就触发该国动画。完整的时序模型见 references/map-explainer-architecture.md。
6.1 秒为单位的时序模型
所有进度以秒(t = frame / fps)为基准,而不是 reveal 比例。河流在窗口期 [RIVER_START, RIVER_END] 内画完;每个国家的触发时刻是"河流到达该国"的时间:
const RIVER_START = 0.3, RIVER_END = 8.0; // 河流绘制窗口
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);
// beat 总长 = max(trigger(c) + BORDER_S + FILL_S + LABEL_S) + 收尾
"固定时长"是刻意的:国界绘制要按"触发后的时间差"推进,而不是按 reveal 的比例切片——否则复杂/很长的边界会在零点几秒内闪完。
6.2 分阶段驱动
const lt = t - trigger(c); // 自该国触发以来的秒数
// 1) 边界用固定时长 BORDER_S 画完(多段安全)
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) 边界画完后填充绽放(透明度先过冲再回落)
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) 填充完成后地名升起
const lp = clamp01((lt - BORDER_S - FILL_S) / LABEL_S);
多段安全的关键在 sliceBorder(d, fromKm, toKm):把一国完整的(可能含多段的)边界做成 MultiLineString,按累计里程切每一条线段,段间不凭空连接、不裁剪到视口:
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 } };
};
样例里每国的边界线使用该国填充色的深色调(COUNTRY_DARK),因为"电光"已放在河流上,国界不再需要发光笔头。
6.3 地名标注:React HTML 覆盖层,每帧重新投影
标注用 React 元素而非地图 symbol 图层(获得完整排版控制)。样板组件 CountryLabel.tsx 演示了大写国名 + 短强调横线 + 升起淡入的动画:颜色取自国色、pointerEvents: 'none' 避免遮挡;排版全部由 CSS 自定义属性(--map-label-*)提供中性兜底,生产项目应替换为自己的字体体系。
定位逻辑:由于地图相机被冻结(见下节),每帧用 map.project(anchor) 把国家锚点投到屏幕像素,再套用与图板相同的变换,存入 state 驱动覆盖层重渲染:
const p = map.project(META[c].anchor); // lngLat → screen px(基于冻结相机)
pos[c] = { x: p.x * plateScale + plateX, y: p.y * plateScale + plateY, reveal: lp };
setLabels(pos);
技法文档还建议:若需要在合成里支持可交互/悬停的覆盖标注,可选用 Remotion 的 <Interactive.Div> 这类定位元素方案;仓库样例出于"纯渲染动画"的目的,用普通 div + pointerEvents 即可。
七、运镜稳定性:固定图板(fixed map plate)模式
7.1 症状与根因
不要在每一 Remotion 帧上都调用 map.jumpTo() 来移动相机。 无头渲染时,这样做会让 MapTiler 的 hillshade 山体阴影乃至卫星影像发生闪烁/抖动(即使瓦片加载完全正常)。这是渲染器重采样造成的现象,不是数据、网络或标注问题;用瓦片重试、修改缓动或改标注都无法解决。
因此:MapTiler 相机只用于静态镜头;需要平移/缩放时改用"固定图板"。该方案不实现真正变化的 3D 相机——pitch 与 bearing 必须保持恒定;需要真实 3D 俯仰/航线飞越时应改用 Cesium(仓库内有配套的 cesium 技法文档)。
7.2 固定图板配方
完整诊断文档见 references/render-stability.md。任何 2D 平移/缩放的推荐步骤:
- 把 MapTiler canvas 一次性地渲染在超大容器里,取相机路线所需的最大缩放。尺寸要按相机路线计算,且各边不超过浏览器可靠 WebGL 渲染缓冲上限(常见 4096px)。不要盲目用 3 倍:1920×1080 合成变成 5760px 宽后 Chromium 可能静默降采样,CSS 放大时会出现明显像素化。
- 保持地图相机静止。
- 每帧算出批准的相机中心/缩放,再用 CSS
translate+scale移动 canvas。 - 对所有投影 HTML 覆盖层施加同一变换。
- GeoJSON 数据与 paint 属性仍按帧命令式动画,只有渲染器相机被冻结。
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 },
});
// 每 Remotion 帧。camera 是已批准的 center/zoom 插值。
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',
};
// 用同一 plate 变换换算标注投影
const labelX = labelPoint.x * scale + width / 2 - projected.x * scale;
const labelY = labelPoint.y * scale + height / 2 - projected.y * scale;
7.3 图板尺寸与清晰度准则
- 渲染分辨率取任意相机路点(含中间保持相机)达到的最大缩放;CSS scale 永远不应超过
1,否则等于放大图板。 - 冻结的地图应居中在相机路线地理范围的中点,而不是自动对准终点相机——这样所需的 overscan 最小。
- 最大边长保持在 4096px 以下,除非实测环境支持更大的
MAX_RENDERBUFFER_SIZE。 - 1920×1080 的合成用 3840×2160 图板是安全默认;1080×1920 纵向构图在路线需要横向平移空间时约用 2700×3840。
- 路线在所需缩放下放不进图板时,把镜头拆成两个固定图板并用一个刻意的编辑转场衔接,不要用一块巨型画布换清晰度。
7.4 区分失败模式与验收
- 反复闪烁 = 实时渲染器仍在移动;
- CSS 推镜过程中瓦片持续发软 = 固定图板尺寸不足、内部被降采样、或被放大到 scale > 1。
验收流程:只预览 Studio 不够,必须渲染一段短视频 MP4,在相机移动时检查静态地形纹理与卫星细节;只要底层地图细节有晃动,就用固定图板模式,不要当"轻微预览瑕疵"放行。校验渲染时建议 --gl=angle + preserveDrawingBuffer:true + 保守并发(如 1)。
八、保持极简:load 后清理底图噪音
技法标题原文是 "Keep it minimal"。load 回调里应立即剥掉底图装饰,只留下要讲的故事地理:
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':所有地名/水系/道路标注("MapTiler labels")——移除;/other border/i:admin-1 内层行政边界(示例中为 "Other border [dash]"、admin_level 3–10)——移除;保留国家边界与争议边界;- 注意内层边界图层的 ID 因样式而异,应按实际加载的样式检查后处理。
Logo/版权控件:maptilerLogo:false + attributionControl:false 未必够,样例组件里还通过 CSS 兜底隐藏:
<style>{`
.maplibregl-ctrl-bottom-left,
.maplibregl-ctrl-bottom-right,
.maplibregl-ctrl-attrib,
.maptiler-logo { display: none !important; }
`}</style>
(参考实现见 RiverReveal.tsx,更完整的说明见 map-geo-prep.md。)
九、地理数据预处理管线:prep-geo.mjs
当镜头需要"河流入境触发国家"或"渐进式线绘制"时,运行 scripts/prep-geo.mjs 一次性烘焙三类产物(源多边形太大不适合入库,因此该技能在 assets/sample-data/ 里只附带产出物):
| 输出 | 用途 | 建议落点 |
|---|---|---|
river-flow.json |
河流绘制用线(平滑化后的单条 LineString) | 项目 src/geo/ |
country-meta.json |
每国 { stop, anchor, border } |
项目 src/geo/ |
borders.geojson |
各国多边形,标注 {country: name},供 fill 层按国过滤 |
项目 public/geo/ |
脚本前段的 CONFIG 区是主要调参入口(引用 prep-geo.mjs):
const COUNTRIES = ['china', 'india', 'bangladesh']; // 按源区→河口顺序;第一个 stop=0
const RIVER = resolve(geo, 'focus-rivers/yarlung-brahmaputra-full-osm.geojson'); // 单条干净的源→河口 LineString
const ANCHOR_BBOX = {china: [82,27,96,32], india: [76,14,99,31], bangladesh: [86,20,93,27]}; // 各国故事区
const NUDGE = {china: [0,0.6], india: [-1.0,0], bangladesh: [0,-0.6]}; // 标签微调 [lng,lat]
const RIVER_SIMPLIFY_TOL = 0.006; // 度,越大越简
三个派生字段的语义:
stop——国家何时点亮:沿河流点走,第一个落在该国多边形内的点(turf.booleanPointInPolygon)即河流入境的弧长比例,驱动触发时刻;源区国为 0;anchor——标签锚点:用"不可达极点"(pole of inaccessibility,在国境内网格采样、取到边界最远点)而不是质心——质心会被拽到边缘,不要用。极点先被裁剪到该国ANCHOR_BBOX(大国的标签会居中在故事相关区域而非远端凸出部),再做一次编辑向NUDGE微调;border——完整源几何:保留该国家源数据的每一条外环,绝不裁剪到画面 bbox、不丢弃屏外段;几何允许自然离开画面。渲染端按累计里程处理 MultiLineString,因此仍是一次定时揭示,且不会在缺口间凭空连线。
输入前提也写得很清楚:河流输入必须是一条干净的源→河口 LineString;如果 OSM 河流是很多 way / 辫状河道,要先做"源节点→河口节点"的图最短路径路由,不要用最近端点贪心链(会在平行河道间来回弹跳)。
十、Reusable 资产与配色/Timing 的本地化原则
示例目录 assets/ 提供五个可复用的文件,它们的注释反复强调同一原则:只做"参考实现",数值一律不要带进生产。
- RiverReveal.tsx:主组件,示范河流揭示 + 电光笔头 + 逐国序列 + 标签投影的完整骨架;
- MapTilerVectorElement.ts:MapTiler Planet 要素的加层/改 paint 封装;
- CountryLabel.tsx:可复用的地名标签示例;
- tokens.ts:示例配色与时长——如
COLORS.river:'#E8F7FF'、三国色与深色变体COUNTRY_DARK、FILL_OPACITY = 0.5、VIDEO = {width:1920,height:1080,fps:30};注释明确要求用生产项目自己的令牌替换,不要把一个源项目的调色板带进另一个生产; - example-Root.tsx:最小 composition 脚手架。
颜色取舍逻辑可作为设计参考(示例值来自 tokens.ts):河流用接近白色的冰芯 + 电蓝辉光 + 白热笔头且不加深色描边;填充色逐国不同(FILL_OPACITY=0.5);国界用各国色的深色调;国名与边界用中性米色以叠在彩色填充之上。
十一、Composition 编排与渲染命令
example-Root.tsx 演示了最小编排:12 秒 @30fps、1920×1080。durationInFrames 必须长到"最后的国在河流抵达后仍能完整走完 border→fill→label"(见架构文档时序模型);组件通过 useVideoConfig() 读取这些值。
export const RemotionRoot: React.FC = () => (
<Composition
id="MapExplainer"
component={RiverReveal}
durationInFrames={12 * 30} // 12 s @ 30 fps
fps={30}
width={1920}
height={1080}
/>
);
渲染命令(校验阶段)建议显式走 ANGLE 的 WebGL 后端、保守并发并放宽超时:
bunx remotion render src/index.ts MapExplainer out.mp4 --gl=angle --concurrency=1 --timeout=120000
十二、文件速查
| 仓库相对路径 | 作用 |
|---|---|
| TECHNIQUE.md | 本文主体技法文档 |
| assets/RiverReveal.tsx | 主组件:河流揭示 + 逐国点亮 + 标签投影 |
| assets/MapTilerVectorElement.ts | MapTiler Planet 过滤要素加层/改 paint 封装 |
| assets/CountryLabel.tsx | 可复用国家标签示例 |
| assets/tokens.ts | 示例配色与时长 |
| assets/example-Root.tsx | 最小 Composition 脚手架 |
| assets/sample-data/ | 示例河流流线(yarlung-flow.json)与生成的国家元数据(country-meta.json) |
| scripts/prep-geo.mjs | 地理数据预处理管线 |
| references/map-explainer-architecture.md | 时序模型与逐模块实现细节 |
| references/map-data-sources.md | 供应商矢量 vs 自定义 GeoJSON 选择 |
| references/map-geo-prep.md | 底图剥离与地理预处理 |
| references/render-stability.md | 相机运动与稳定无头渲染 |
十三、实战核对清单
把本文落进新合成时,可按以下清单自检:
- 每个地图元素都先对照决策表回答"用 provider 矢量、自备 GeoJSON,还是混合",再决定数据层实现;只有需要确定性方向绘制时才烘焙有序 GeoJSON;
- 图层对象中缺省的 filter/minzoom/maxzoom/layout 一律条件展开,绝不传
undefined; - Map 只初始化一次(ref guard),
delayRender门控到once('idle');每帧同样以 delayRender + idle + continueRender +triggerRepaint()收口; - 所有动效进度来自
useCurrentFrame()换算出的秒数,paint/data 逐帧命令式更新,不做 CSS transition 与 timer; - 相机运动一律走固定图板:冻结相机、最大缩放一次性渲染、CSS translate/scale、覆盖层同一变换、恒定的 pitch/bearing;
load后立刻剥掉 symbol 层与 admin-1 内界、隐藏 logo,只保留叙事地理;- 验收渲染 MP4 而不是只盯 Studio 预览,用
--gl=angle与保守并发,任何底图晃动都回归固定图板而非瓦片重试或相机缓动。
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 StartedRust0632
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