在 Flutter 中重构 360° 视频:球面投影、四元数关键帧和 GPU 导出
我们如何在 Flutter 应用中,将原始的等距柱状 360° 视频素材转换为流畅、带画框的平面视频。
一个无人警示的问题
一个普通视频会明确地展示一件事:摄像机对准的画面。而 360° 视频则没有这样的画面。摄像机记录了一切——一个完整的像素球体,以扁平的等距柱状矩形(一个 2:1 的图像,其中 X 轴是经度,Y 轴是纬度)形式存储。在将这些素材放到 Instagram、YouTube 或手机屏幕上之前,必须有人回答一个摄像机刻意回避的问题:
观众应该看哪里,以及何时看?
“Reframing”(重构)是指在时间推移中,让一个虚拟摄像机穿梭于该球体——平移、倾斜、缩放——并渲染出常规的 16:9(或 9:16,或 2.35:1)平面视频。这是 GoPro Player、Insta360 Studio 和 Adobe Premiere 的 GoPro VR Reframe 插件的核心功能。
我们完全在 Flutter 中构建了它。这篇博文将带您了解真正困难的部分:投影数学、无万向锁插值、±180° 接缝,以及必须在真实 Android 解码器中存活的 GPU 导出管道。
端到端流程:
捕获/导入 360 视频
→ 转码轻量级预览代理
→ 用户点击/绘制/自动检测目标
→ 对象跟踪生成路径
→ 生成 + 平滑关键帧
→ 对每帧相机方向进行 SLERP 插值(实时预览)
→ 原生 GPU 导出为平面视频
→ FFmpeg 后处理(moov atom + 音视频同步修复)
架构一览
我们将系统保持在三个清晰的层级,以便数学逻辑可以独立于 Flutter 进行测试:
| 层 | 所在位置 | 职责 |
|---|---|---|
| Models | lib/data/models/reframe/ | SphericalCoordinates/Quaternion, CameraOrientation/MotionConstraints, ReframeKeyframe, ReframeProject |
| Services | lib/services/reframe/ | SphericalProjection (光线投射), CoordinateConverter, KeyframeInterpolator/Generator/Smoother, ReframeExportService |
| Presentation | lib/presentation/.../reframe/ | Riverpod ReframeNotifier, native OpenGL Spherical360Player, bbox 绘制叠加层, 关键帧时间轴 |
黄金法则:所有三角学计算都是纯 Dart 实现,不依赖任何 Flutter 导入。这意味着每个投影和插值函数都可以在没有 widget tree 的情况下进行单元测试,并且相同的数学逻辑同时驱动实时预览和导出。
1. 核心:等距柱状 ⟷ 球面
一切都始于一个映射。等距柱状帧只是一个矩形,其中:
- X 轴(0 → width)从 −180° 到 +180° 扫描经度 (yaw)
- Y 轴(0 → height)从 +90°(上/天顶)到 −90°(下/天底)扫描纬度 (pitch)
`SphericalCoordinates` 被刻意设计得很小——只有 `yaw` 和 `pitch`。`Roll`、`FOV` 和 `zoom` 属于相机,而非方向。以下是整个功能所依赖的转换:
factory SphericalCoordinates.fromEquirectangular({
required double x, required double y,
required double videoWidth, required double videoHeight,
}) {
final normalizedX = x / videoWidth;
final normalizedY = y / videoHeight;
// X 轴映射到 yaw:0->-180°,0.5->0°,1.0->180°
final yaw = (normalizedX - 0.5) * 360.0;
// Y 轴映射到 pitch:0->90°(顶部),0.5->0°(水平线),1.0->-90°(底部)
final pitch = (0.5 - normalizedY) * 180.0;
return SphericalCoordinates(
yaw: yaw.clamp(-180.0, 180.0),
pitch: pitch.clamp(-90.0, 90.0),
);
}
反向转换是镜像(`normalizedX = yaw/360 + 0.5`),我们不断使用它将跟踪的 bounding box 重新绘制到播放器上。
“所见即所得”——透视光线投射
用户不是与等距柱状矩形交互,而是与渲染的透视视图交互。因此,当他们在播放器上点击一个点时,我们需要通过一个虚拟针孔相机投射一条光线,并找到他们在球体上击中的方向。这就是 `SphericalProjection.screenToSpherical`:
final ndcX = (2.0 * screenPoint.dx / playerSize.width) - 1.0;
final ndcY = 1.0 - (2.0 * screenPoint
.dy / playerSize.height);
final aspectRatio = playerSize.width / playerSize.height;
final tanHalfFov = math.tan(cameraFov * math.pi / 360.0); // half-angle
double rayX = ndcX * aspectRatio * tanHalfFov;
double rayY = ndcY * tanHalfFov;
double rayZ = 1.0;
// ...归一化,然后将光线绕 X 轴旋转 pitch,绕 Y 轴旋转 yaw...
final sphericalYaw = math.atan2(finalRayX, finalRayZ) * 180.0 / math.pi;
final sphericalPitch = math.asin(finalRayY.clamp(-1.0, 1.0)) * 180.0 / math.pi;
在 `asin` 之前的 `.clamp(-1.0, 1.0)` 看起来有些过度谨慎,但浮点数漂移可能会导致结果是 1.0000001,而 `asin` 将返回 `NaN`——一个 `NaN` 会污染所有下游帧。这种对反三角函数进行防御性钳位是此代码库中反复出现的主题。
2. 平滑相机运动:使用四元数,而非欧拉角
朴素的方法是在每个关键帧存储 `yaw`/`pitch` 并进行线性插值。这看起来很糟糕。欧拉角插值会导致万向锁、非均匀角速度,以及在极点附近出现不自然的跳动。
我们使用 SLERP(球面线性插值)将方向作为四元数进行插值。每个方向通过 ZYX 欧拉约定转换为四元数:
Quaternion toQuaternion() {
final yawRad = yaw * math.pi / 180.0;
final pitchRad = pitch * math.pi / 180.0;
final cy = math.cos(yawRad * 0.5); final sy = math.sin(yawRad * 0.5);
final cp = math.cos(pitchRad * 0.5); final sp = math.sin(pitchRad * 0.5);
return Quaternion(w: cy * cp, x: cy * sp, y: sy * cp, z: -sy * sp);
}
`SLERP` 本身具有每个实现所需的两个生产级安全阀:
static Quaternion slerp(Quaternion a, Quaternion b, double t) {
a = a.normalize(); b = b.normalize();
var dotProduct = a.dot(b);
if (dotProduct < 0.0) { b = -b; dotProduct = -dotProduct; } // 选择最短半球路径
if (dotProduct > 0.9995) { // 近平行 -> 退回到 LERP
return Quaternion(w: a.w + t*(b.w-a.w), x: a.x + t*(b
.x-a.x),
y: a.y + t*(b.y-a.y), z: a.z + t*(b
.z-a.z)).normalize();
}
final theta0 = math.acos(dotProduct);
final theta = theta0 * t;
final sinTheta = math.sin(theta);
final sinTheta0 = math.sin(theta0);
final s0 = math.cos(theta) - dotProduct * sinTheta / sinTheta0;
final s1 = sinTheta / sinTheta0;
return Quaternion(w: s0*a.w + s1*b.w, /* ...*/).normalize();
}
两个不可或缺的细节:
- 最短半球路径 — `q` 和 `-q` 代表相同的方向。如果点积为负,你需要对一个输入取反,否则你的相机将绕球体走更长的路径。
- 近平行 LERP 回退 — 当两个关键帧几乎相同时,`sinTheta0` 趋近于 0,你将除以一个接近零的值。当 `dot > 0.9995` 时回退到纯 `LERP` 可以避免这种爆炸性问题。
一个微妙但重要的设计决策:我们对方向进行 SLERP,但对 FOV/zoom 进行 LERP,并对 roll 进行角度 LERP。球面插值是处理方向的正确工具;`zoom` 只是一个标量,应该线性移动:
final qInterp = Quaternion.slerp(from.direction.toQuaternion(), to.direction.toQuaternion(), t);
final interpDirection = SphericalCoordinates.fromQuaternion(qInterp).normalize();
final interpFov = _lerp(from.fov, to.fov, t);
final interpRoll = _lerpAngle(from.roll, to.roll, t); // 跨越 ±180 度边界
final interpZoom = _lerp(from.zoom, to.zoom, t);
💡 当从 `Quaternion` 转换回 `yaw`/`pitch` 时,我们使用 `atan2` 而非 `asin`,“以提高数值稳定性(避免 `asin` 的域问题)”。在 `SLERP` 之后,`Quaternion` 可能会轻微地“非归一化”,而 `atan2` 在 `asin` 抛出 `NaN` 的情况下能够优雅地降级处理。
缓动:手动实现,作用于传入过渡
每个关键帧都拥有进入该关键帧的过渡缓动曲线。这些曲线是手动编写的,因此它们在每个平台上都是一致的:
case KeyframeEasing.linear: return t;
case KeyframeEasing.easeInOut: return t * t * (3 - 2 * t); // smoothstep S 形曲线
case KeyframeEasing.easeIn: return t * t; // 二次曲线
case KeyframeEasing.easeOut: return 1 - (1 - t) * (1 - t);
3. ±180° 接缝:困扰所有 360 功能的 Bug
这就是陷阱所在。`Yaw` 存在于一个圆上:+179° 和 −179° 相差 2°,而不是 358°。一旦你的拍摄对象穿过球体的背面,朴素的数学计算会使相机瞬间跳到世界的另一边。这个问题在系统的四个不同层级中出现,每个层级都需要自己的解决方案。
层 1 — 基本类型。整个代码库依赖的两个小辅助函数:
static double normalizeYaw(double yaw) {
while (yaw > 180) { yaw -= 360; }
while (yaw < -180) { yaw += 360; }
return yaw;
}
// “对于接缝安全跟踪至关重要...避免 180° 跳动”
static double shortestYawDelta(double from, double to) {
double delta = to - from;
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
return delta;
}
层 2 — 关键帧生成累积连续(无界)的 `yaw`,这样 `SLERP` 总是采用最短路径插值。我们不是将每个样本钳位到 ±180°,而是累加最短的 `deltas`,并有意让运行值漂移超过 180°:
final normalizedContinuousYaw = SphericalCoordinates.normalizeYaw(continuousYaw);
final yawDelta = SphericalCoordinates.shortestYawDelta(normalizedContinuousYaw, rawCoords.yaw);
continuousYaw = continuousYaw + yawDelta; // 可能有意超过 ±180 度
coords = SphericalCoordinates(yaw: continuousYaw, pitch: rawCoords.pitch);
层 3 — bounding box 重建。当用户绘制一个跨越接缝的框时,四个角报告的 `yaw` 值可能像 +170°、+175°、−175°、−170°。`SphericalProjection` 会检测到这种情况(任意角 > 90° 且 任意角 < −90°),并通过将负值偏移 +360° 来重建一个连续 `yaw` 的“接缝安全” `bbox`,然后再测量其跨度。
层 4 — 实时播放器使用 `shortestYawDelta` 平滑地朝目标移动,因此拖动穿过接缝时永不急动。
如果你在构建 360 工具时只记住一件事:接缝不是一个边缘情况,它是一个横切关注点。在任何出现 `yaw` 的地方都要考虑它。
4. 打造 Insta360/GoPro 般的体验
平滑插值能让你获得一个技术上正确的相机,但它无法提供一个良好的体验。三项“体验工程”弥补了这一差距。
运动约束 — 限制速度和加速度。原始跟踪数据是抖动的。我们通过 `MotionConstraints` 处理关键帧,它限制了虚拟相机移动的速度和突兀程度(例如,`maxYawSpeed = 120°/s`,`maxPitchSpeed = 90°/s`,`maxAcceleration = 180°/s²`),并提供 `cinematic()`、`responsive()` 和 `production()` 预设。这就是“安全摄像头追踪苍蝇”与“稳健操作员”之间的区别。
自适应 FOV — 缩放以适应主体。我们在等距柱状空间中测量主体的角大小,并选择一个 `FOV`,使其填充目标帧比例:
final angularWidth = (bboxWidth / videoWidth) * 360.0;
final angularHeight = (bboxHeight / videoHeight) * 180.0;
final objectAngularSize = math.max(angularWidth, angularHeight);
final idealFov = objectAngularSize / targetFillRatio; // targetFillRatio 约等于 0.4
return idealFov.clamp(minFov, maxFov); // 45°–120°
运动引导 + 人脸→身体融合。我们将画面在运动方向上向前推移 `bbox` 宽度的约 15%,这样主体就不会被固定在他们走向的边缘。当人脸检测丢失时,我们不会立即跳到身体中心——而是在大约 10 帧内,从最后已知的人脸中心平滑地 `LERP` 到身体中心。
5. 双视频技巧:预览代理与全分辨率原始视频
这是使整个系统在真实手机上可用的架构决策。
一个 360° GoPro/Insta360 文件通常是 4096×2048 HEVC。预算级 Android 解码器在尝试流畅播放时会卡顿,更不用说同时运行对象跟踪了。因此,我们在转码代理上运行跟踪和实时预览(约 1440×720,`libx264 -preset veryfast -crf 28`),并仅将全分辨率原始文件保留用于最终导出。
// “对于 360° 视频比 MediaMetadataRetriever 更可靠
// (通常使用不常见的编解码器,如 HEVC、高分辨率)”
final maxSafeWidth = 2048, maxSafeHeight = 1080;
// 2:1 4K (4096×2048) 不安全 -> 触发 transcodeForPlayback()
你必须处理的陷阱是:关键帧 `bounding box` 在代理坐标系中计算,并且在导出前必须按比例放大到原始坐标系。如果这个缩放不正确,你的导出帧将与用户预览的区域略有不同。(我们还使用 `ffprobe` 而不是 `MediaMetadataRetriever` 来获取元数据和提取第一帧——Android 内置的 `retriever` 在处理 360 相机输出的不常见编解码器时不稳定。)
6. GPU 导出:通过 MethodChannel 实现 OpenGL + MediaCodec
在手机 CPU 上通过 FFmpeg 的 `v360 filter` 重新投影 4K 球体的每一帧速度太慢了。(我们确实保留了 `v360` 字符串——它清晰地记录了投影语义,`v360=e:flat:...` 表示“等距柱状输入,平面输出”——但这并不是实际的执行路径。)
实际的导出通过 `MethodChannel` 将关键帧传递给原生 OpenGL ES + MediaCodec 渲染器:
final result = await _methodChannel.invokeMethod('exportVideoGPU', {
'videoPath': resolvedVideoPath,
'outputPath': outputPath,
'keyframes': keyframesList,
'initialOrientation': initialOrientationMap,
'width': settings.exportWidth,
'height': settings.exportHeight,
'fps': settings.outputFps.toInt(),
'includeAudio': settings.includeAudio,
if (isTrimmed) 'trimStartMs': trimStartMs,
if (isTrimmed) 'trimEndMs': trimEndMs
,
});
我们通过能力检查(Android API 21+、硬件 H.264 编码器、OpenGL ES 3.0)来控制此功能,并通过 `EventChannel` 流式传输进度。以下是一些来之不易的“陷阱”:
- −90° 的 yaw 偏移。我们的 Dart 约定将 `yaw = 0` 设置在等距柱状图像的中心(u = 0.5);而 OpenGL 渲染器将 `yaw = 0` 设置在 u = 0.75。因此,每个关键帧的 `yaw` 在跨越通道时都会被重新映射 −90°(并重新包裹到 ±180°)——对称地应用于导出通道和实时播放器通道,以确保预览和导出结果一致。
- 退化的关键帧数量。零关键帧 → 从初始方向合成两个相同的关键帧(静态导出)。一个关键帧 → 复制它。渲染器总是需要至少一个开始和一个结束关键帧。
- 裁剪重新基准时间戳。裁剪导出将每个关键帧移动 `t - trimStartMs`。
无人预料的 FFmpeg 后处理
你可能认为 GPU 导出就是终点。但它不是——因为 Android 的 `MediaMuxer` 将 `moov atom` 写在文件的末尾,并且可能会携带源视频的音频 `PTS` 偏移。结果是:`ExoPlayer`(和 Flutter 的 `video_player`)在时钟继续运行的同时画面冻结。因此,在 GPU 处理之后,FFmpeg 会进行一次快速、无损的清理:
// 复制视频(不重新编码 -> 零质量损失),重新编码音频以修复 PTS,
// 将 moov atom 移到文件头部以便即时播放。
-c:v copy -af "aresample=async=1:first_pts=0" -movflags +faststart
还有一个残酷的小陷阱需要注意:我们首先探测是否存在音轨,因为向没有音轨的视频传递音频标志会导致 FFmpeg 在某些设备上永久挂起。整个后处理都被一个 60 秒的超时封装,并在超时时取消。
7. 实时预览也是一个原生播放器
编辑器内的预览不是一个 Flutter `CustomPaint`——它是一个通过 `AndroidView` 嵌入的原生 OpenGL 表面(`viewType` 类似于 `'your_app/spherical360player'`),拥有自己的每个视图的 `method`/`event channel`。Dart `Spherical360PlayerController` 镜像 `yaw`/`pitch`/`roll`/`fov`,并在发送前应用相同的 −90° 偏移。
使其体验愉悦的细节:
- 帧率无关平滑 — `factor = 1 - exp(-speed * dt)`,因此缓动在 60fps 和 90fps 下看起来一致,并内置了接缝安全的最短路径 `yaw`。
- 惯性/动量(在甩动后,带有基于摩擦的衰减)— 在播放期间禁用,因为播放期间相机必须遵循关键帧,而不是用户上次的轻弹。
- 一个 GestureDetector,其 `onScaleUpdate` 同时处理平移和捏合缩放;双击可切换播放/暂停。`FOV` 在控制器边界处钳位到 30°–120°,`pitch` 钳位到 ±90°。
一个虽小但很重要的用户体验修复:在从“跟踪”切换到“生成关键帧”之前,我们插入了 120 毫秒的延迟,这样 Flutter 就能实际绘制“100%”。否则,最终的进度帧和状态更改会落在同一个微任务批次中,用户就看不到完成状态。
状态机,简述
所有这些都由一个驱动状态枚举的 `Riverpod ReframeNotifier` 协调:
空闲 → 加载视频 → 转码
→ (检测 | 选择对象 | 在播放器上绘制 `bbox` | 在播放器上自动检测)
→ 跟踪 → 生成关键帧 → 编辑 → 导出 → 完成 | 错误
多个入口点馈入同一个跟踪器:点击检测(`YOLO` 叠加层)、手动 `bbox`,或 Insta360 风格的直接在播放器上绘制手势。它们都汇聚到 `KeyframeGenerator.production().generate(...) → KeyframeSmoother.smooth(...)`,然后插值器驱动每个预览和导出的帧。
经验总结
- 保持数学纯粹。在投影/插值层中不引入 Flutter 导入。这是它可测试的唯一原因。
- 方向使用 Quaternion SLERP;标量使用 LERP。不要插值欧拉角,也不要对 `zoom` 进行 `SLERP`。
- ±180° 接缝是一个横切关注点,而不是一个边缘情况。它出现在基本类型、生成、投影和播放器中。
- 一次性、对称地协调你的约定。Dart 和 OpenGL 之间的 −90° `yaw` 偏移在两个通道边界处应用,因此预览与导出一致。
- 交互使用代理,输出使用原始文件。只需记住在两者之间缩放你的坐标。
- “渲染完成”≠“播放正常”。预算 `FFmpeg` 后处理以修复 `moov atom` 位置和音频 `PTS`,并在添加音频标志之前进行探测。
- 体验即功能。运动约束、自适应 `FOV` 和运动引导是区分“技术上重构”与“看起来像专业人士拍摄”的关键。
结果是:一个完全在设备上运行的 360° 重构编辑器——点击一个主体,让它跟踪,调整关键帧,然后导出一个清晰的平面视频——使用 Flutter 构建,仅在 Dart 无法足够快地完成工作时,才使用薄薄的原生 GPU 层。

