Перекадрування 360° відео в Flutter: сферична проєкція, кватерніонні ключові кадри та експорт за допомогою GPU
Як ми перетворили сирий 360° матеріал у рівнокутовій проєкції на плавне, відкадроване, плоске відео — повністю всередині застосунку Flutter.
Проблема, про яку ніхто не попереджає
Звичайне відео має одну очевидну річ, яку вам показують: кадр, на який дивиться камера. 360° відео такого не має. Камера записала все — повну сферу пікселів, що зберігається як плоский рівнокутний прямокутник (зображення 2:1, де вісь X — довгота, а вісь Y — широта). Перш ніж ви зможете розмістити цей матеріал в Instagram, YouTube або на екрані телефону, хтось має відповісти на питання, на яке камера навмисно відмовилася відповідати:
Куди має дивитися глядач і коли?
"Перекадрування" — це дія, яка полягає у проведенні віртуальної камери через цю сферу з часом — панорамування, нахил, масштабування — та рендеринг звичайного плоского відео 16:9 (або 9:16, або 2.35:1). Це функція, навколо якої побудовані GoPro Player, Insta360 Studio та плагін GoPro VR Reframe для Adobe Premiere.
Ми розробили це повністю на Flutter. Цей допис — огляд частин, які були справді складними: математика проєкції, інтерполяція без ефекту підвісу, шов ±180° та конвеєр експорту за допомогою GPU, який має витримувати справжні Android декодери.
Наскрізний потік:
захоплення/імпорт 360 відео
→ транскодування полегшеного проксі для попереднього перегляду
→ користувач натискає / малює / автоматично виявляє об'єкт
→ відстеження об'єкта створює шлях
→ генерація + згладжування ключових кадрів
→ SLERP-інтерполяція орієнтації камери для кожного кадру (попередній перегляд у реальному часі)
→ нативний експорт за допомогою GPU у плоске відео
→ FFmpeg постобробка (moov atom + виправлення A/V синхронізації)
Архітектура з першого погляду
Ми розділили систему на три чисті шари, щоб математика могла бути протестована ізольовано від Flutter:
| Шар | Розташовано в | Відповідальність |
|---|---|---|
| Моделі | lib/data/models/reframe/ | SphericalCoordinates/Quaternion, CameraOrientation/MotionConstraints, ReframeKeyframe, ReframeProject |
| Сервіси | lib/services/reframe/ | SphericalProjection (відстеження променів), CoordinateConverter, KeyframeInterpolator/Generator/Smoother, ReframeExportService |
| Презентація | lib/presentation/.../reframe/ | Riverpod ReframeNotifier, нативний OpenGL Spherical360Player, накладення для малювання обмежувальних рамок, часова шкала ключових кадрів |
Золоте правило: вся тригонометрія є чистим Dart без імпортів Flutter. Це означає, що кожна функція проєкції та інтерполяції може бути протестована за допомогою юніт-тестів без дерева віджетів, і та сама математика використовується як для попереднього перегляду в реальному часі, так і для експорту.
1. Суть: рівнокутний ⟷ сферичний
Все починається з одного відображення. Рівнокутний кадр — це просто прямокутник, де:
- X (0 → ширина) охоплює довготу (yaw) від −180° до +180°
- Y (0 → висота) охоплює широту (pitch) від +90° (верх/зеніт) до −90° (низ/надир)
SphericalCoordinates навмисно невелика — лише yaw і pitch. Roll, FOV та зум належать до камери, а не до напрямку. Ось перетворення, на якому базується вся функція:
factory SphericalCoordinates.fromEquirectangular({
required double x, required double y,
required double videoWidth, required double videoHeight,
}) {
final normalizedX = x / videoWidth;
final normalizedY = y / videoHeight;
// X maps to yaw: 0->-180°, 0.5->0°, 1.0->180°
final yaw = (normalizedX - 0.5) * 360.0;
// Y maps to pitch: 0->90° (top), 0.5->0° (horizon), 1.0->-90° (bottom)
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), і ми постійно використовуємо його для відображення відстежуваної обмежувальної рамки на плеєрі.
"Натисніть на те, що бачите" — перспективне відстеження променів
Користувач не взаємодіє з рівнокутним прямокутником. Вони взаємодіють з рендереним перспективним видом. Тому, коли вони натискають на точку на плеєрі, нам потрібно пропустити промінь через віртуальну камеру-обскуру та знайти, який напрямок на сфері вони влучили. Це 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;
// ...normalize, rotate the ray by pitch (around X) then yaw (around Y)...
final sphericalYaw = math.atan2(finalRayX, finalRayZ) * 180.0 / math.pi;
final sphericalPitch = math.asin(finalRayY.clamp(-1.0, 1.0)) * 180.0 / math.pi;
.clamp(-1.0, 1.0) перед asin здається параноїдальним, але дрейф числа з плаваючою комою може дати 1.0000001, а asin поверне NaN — і одне значення NaN "отруїть" кожен наступний кадр. Захисне обмеження навколо обернених тригонометричних функцій — це повторювана тема в цій кодовій базі.
2. Плавний рух камери: кватерніони, а не кути Ейлера
Наївний підхід полягає в зберіганні yaw/pitch у кожному ключовому кадрі та лінійній інтерполяції чисел. Виглядає жахливо. Інтерполяція кутів Ейлера викликає гімбал-лок, нерівномірну кутову швидкість і неприємні ривки біля полюсів.
Ми інтерполюємо орієнтацію як кватерніон за допомогою SLERP (сферична лінійна інтерполяція). Кожен напрямок перетворюється на кватерніон за допомогою ZYX Euler конвенції:
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; } // take the shortest hemisphere
if (dotProduct > 0.9995) { // near-parallel -> fall back to 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, і ви ділите майже на нуль. Відкат до звичайного LERP, якщо скалярний добуток > 0.9995, допомагає уникнути переповнення.
Тонке, але важливе дизайнерське рішення: ми SLERP напрямок, але LERP FOV/зум та кут-LERP для roll. Сферична інтерполяція є правильним інструментом для напрямку; зум — це просто скаляр, який має рухатися лінійно:
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); // wraps across ±180
final interpZoom = _lerp(from.zoom, to.zoom, t);
💡 При перетворенні назад з кватерніона на yaw/pitch ми використовуємо atan2 замість asin "для чисельної стабільності (уникає проблем з областю визначення asin)". Після SLERP кватерніон може бути дуже незначно денормалізований, і atan2 поводиться краще, тоді як asin видає NaN.
Згладжування: власноруч, на вхідному переході
Кожен ключовий кадр має криву згладжування для переходу в нього. Криві написані вручну, тому вони ідентичні на кожній платформі:
case KeyframeEasing.linear: return t;
case KeyframeEasing.easeInOut: return t * t * (3 - 2 * t); // smoothstep S-curve
case KeyframeEasing.easeIn: return t * t; // quadratic
case KeyframeEasing.easeOut: return 1 - (1 - t) * (1 - t);
3. Шов ±180°: помилка, яка переслідує кожну функцію 360
Ось пастка. Yaw знаходиться на колі: +179° і −179° віддалені на 2°, а не на 358°. Як тільки ваш об'єкт проходить через задню частину сфери, наївна математика миттєво перекидає камеру на півсвіту. Ця єдина проблема виникає на чотирьох різних рівнях системи, і кожен вимагає власного вирішення.
Рівень 1 — примітиви. Два невеликих помічники, на які спирається вся кодова база:
static double normalizeYaw(double yaw) {
while (yaw > 180) { yaw -= 360; }
while (yaw < -180) { yaw += 360; }
return yaw;
}
// "CRITICAL for seam-safe tracking ... avoids 180° snaps"
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°, ми додаємо найкоротші дельти та дозволяємо поточному значенню навмисно відхилятися за межі 180°:
final normalizedContinuousYaw = SphericalCoordinates.normalizeYaw(continuousYaw);
final yawDelta = SphericalCoordinates.shortestYawDelta(normalizedContinuousYaw, rawCoords.yaw);
continuousYaw = continuousYaw + yawDelta; // may exceed ±180 on purpose
coords = SphericalCoordinates(yaw: continuousYaw, pitch: rawCoords.pitch);
Рівень 3 — реконструкція обмежувальної рамки. Коли користувач малює рамку, що перетинає шов, чотири кути повідомляють yaw, як +170°, +175°, −175°, −170°. SphericalProjection виявляє це (будь-який кут > 90° і будь-який кут < −90°) і відновлює "безпечну для шва" обмежувальну рамку з безперервним yaw, зміщуючи від'ємні значення на +360° перед вимірюванням її діапазону.
Рівень 4 — плеєр у реальному часі згладжує рух до своєї цілі за допомогою shortestYawDelta, тому перетягування через шов ніколи не спричиняє ривків.
Якщо ви пам'ятаєте одну річ про створення інструментів для 360: шов — це не окремий випадок, це наскрізне питання. Заплануйте його скрізь, де з'являється yaw.
4. Як зробити, щоб це відчувалося як Insta360/GoPro
Плавна інтерполяція дає вам технічно правильну камеру. Вона не дає вам хорошу. Три аспекти "інженерії відчуття" заповнюють цю прогалину.
Обмеження руху — обмеження швидкості та прискорення. Сирі дані відстеження нерівномірні. Ми пропускаємо ключові кадри через MotionConstraints, що обмежує швидкість і різкість руху віртуальної камери (наприклад, maxYawSpeed = 120°/с, maxPitchSpeed = 90°/с, maxAcceleration = 180°/с²), з попередньо встановленими значеннями 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°
Випередження руху + змішування обличчя→тіла. Ми зміщуємо кадрування приблизно на 15% ширини обмежувальної рамки вперед у напрямку руху, щоб об'єкт не був притиснутий до краю, до якого він йде. А коли розпізнавання обличчя зникає, ми не переходимо до центру тіла — ми виконуємо LERP від останнього відомого центру обличчя до центру тіла протягом ~10 кадрів.
5. Трюк з двома відео: проксі для попереднього перегляду проти оригінального відео в повній роздільній здатності
Це архітектурне рішення, яке робить всю систему придатною для використання на реальних телефонах.
Файл 360° GoPro/Insta360 часто має роздільну здатність 4096×2048 HEVC. Бюджетні Android декодери задихаються, намагаючись відтворити його плавно, не кажучи вже про запуск відстеження об'єктів зверху. Тому ми запускаємо відстеження та попередній перегляд у реальному часі на транскодованому проксі (~1440×720, libx264 -preset veryfast -crf 28), і зберігаємо оригінал у повній роздільній здатності лише для остаточного експорту.
// "more reliable than MediaMetadataRetriever for 360° videos
// which often use unusual codecs (HEVC, high-res)"
final maxSafeWidth = 2048, maxSafeHeight = 1080;
// 2:1 4K (4096×2048) is unsafe -> trigger transcodeForPlayback()
Пастка, з якою ви повинні впоратися: обмежувальні рамки ключових кадрів обчислюються в координатах проксі та повинні бути масштабовані до оригінальних координат перед експортом. Зробіть це масштабування неправильно, і ваш експорт кадруватиме трохи іншу область, ніж попередньо переглянув користувач. (Ми також використовуємо ffprobe, а не MediaMetadataRetriever, для метаданих та вилучення першого кадру — вбудований ретрівер Android ненадійно працює з незвичайними кодеками, які випускають 360° камери.)
6. Експорт за допомогою GPU: OpenGL + MediaCodec через MethodChannel
Перепроєктування кожного кадру 4K сфери через фільтр v360 FFmpeg на CPU телефону займає занадто багато часу. (Ми зберігаємо рядок v360 — він чітко документує семантику проєкції, v360=e:flat:... означає "рівнокутний вхід, плоский вихід" — але це не живий шлях.)
Справжній експорт передає ключові кадри нативному рендереру OpenGL ES + MediaCodec через MethodChannel:
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. Кілька важко здобутих підводних каменів:
- Зміщення yaw на −90°. Наша конвенція Dart розміщує yaw = 0 в центрі рівнокутного зображення (u = 0.5); рендерер OpenGL розміщує yaw = 0 на u = 0.75. Отже, кожен ключовий кадр yaw перемальовується на −90° (і переобгортається до ±180°) при передачі через канал — симетрично застосовується як до каналу експорту, так і до каналу live-player, тому попередній перегляд та експорт збігаються.
- Вироджені кількості ключових кадрів. Нуль ключових кадрів → синтезувати два ідентичні з початкової орієнтації (статичний експорт). Один ключовий кадр → дублювати його. Рендерер завжди вимагає щонайменше початку та кінця.
- Обрізка перебазовує мітки часу. Обрізаний експорт зміщує кожен ключовий кадр на t − trimStartMs.
Постобробка FFmpeg, яку ніхто не очікує
Ви б подумали, що експорт за допомогою GPU — це фінішна пряма. Це не так — тому що MediaMuxer Android записує moov atom в кінці файлу і може переносити зміщення PTS аудіо джерела. Результат: ExoPlayer (і video_player Flutter) заморожує зображення, поки годинник продовжує працювати. Отже, після проходження GPU, FFmpeg виконує швидке, без втрат, очищення:
// copy video (no re-encode -> zero quality loss), re-encode audio to fix PTS,
// move moov atom to the front for instant playback.
-c:v copy -af "aresample=async=1:first_pts=0"
-movflags +faststart
І жорстока маленька пастка, що її охороняє: ми спочатку перевіряємо наявність аудіодоріжки, тому що передача аудіо прапорів відео, яке не має аудіодоріжки, призводить до зависання FFmpeg на деяких пристроях. Вся постобробка обгорнута в 60-секундний тайм-аут з можливістю скасування за тайм-аутом.
7. Попередній перегляд у реальному часі також є нативним плеєром
Попередній перегляд у редакторі — це не Flutter CustomPaint — це нативна поверхня OpenGL, вбудована через AndroidView (з 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° на межі контролера.
Одне маленьке, але показове UX виправлення: перед перемиканням з "відстеження" на "генерацію ключових кадрів" ми вставляємо затримку в 120 мс, щоб Flutter дійсно відобразив "100%". Без цього, останній кадр прогресу та зміна статусу потрапляють в один і той самий мікрозадачний пакет, і користувач ніколи не бачить завершення.
Коротка інформація про кінцевий автомат
Все це координується Riverpod ReframeNotifier, що керує переліком статусів:
idle → loadingVideo → transcoding
→ (detecting | selectingObject | drawingBboxOnPlayer | autoDetectingOnPlayer)
→ tracking → generatingKeyframes → editing → exporting → completed | error
Кілька точок входу подають дані до одного і того ж трекера: "натисни, щоб виявити" (YOLO накладення), ручне bbox або жест "малюй прямо на плеєрі" в стилі Insta360. Всі вони збігаються до KeyframeGenerator.production().generate(...) → KeyframeSmoother.smooth(...), і звідти інтерполятор керує кожним переглянутим та експортованим кадром.
Отримані уроки
- Зберігайте математику чистою. Жодних імпортів Flutter у шарі проєкції/інтерполяції. Це єдина причина, чому це було тестованим.
- Кватерніонний SLERP для напрямку; LERP для скалярів. Не інтерполюйте кути Ейлера і не застосовуйте SLERP для масштабування.
- Шов ±180° — це наскрізне питання, а не окремий випадок. Він з'являється в примітивах, генерації, проєкції та плеєрі.
- Узгодьте ваші конвенції один раз, симетрично. Зміщення yaw на −90° між Dart та OpenGL застосовується на обох межах каналів, щоб попередній перегляд відповідав експорту.
- Проксі для взаємодії, оригінал для виведення. Просто пам'ятайте про масштабування координат між ними.
- "Рендеринг завершено" ≠ "відтворюється коректно". Заплануйте постобробку FFmpeg, щоб виправити розміщення moov atom та PTS аудіо, і перевірте перед додаванням аудіо прапорів.
- Відчуття — це функція. Обмеження руху, адаптивний FOV та випередження руху — ось що відрізняє "технічно перекадроване" від "знятого професіоналом".
Результат: повністю вбудований 360° редактор перекадрування — натисніть на об'єкт, дозвольте йому відстежуватися, налаштуйте ключові кадри та експортуйте чисте плоске відео — створений на Flutter з тонким нативним шаром GPU, який виконує лише ту роботу, яку Dart не може зробити достатньо швидко.

