MicrocosmWorksІнновації та архітектура цифрового космосу
Про насКонтакт
MicrocosmWorksІнновації та архітектура цифрового космосу

Надаємо IT-рішення, які мають значення. Ми захоплені технологіями, безпекою та допомогою бізнесу зростати завдяки надійній, інноваційній IT-інфраструктурі.

[email protected]
+91 7011868196
New Delhi, India

Центр зростання AI

AI HubІнновації для стартапівПрискорювач для підприємств

Рішення

Всі рішенняДодатки для здоров'я та фітнесуAI відео платформаРозробка AI агентів

Ресурси

ІнсайтиГалузеві ПосібникиШаблони ВикористанняАрхітектурні ШаблониКейси

Компанія

Про НасКонтактНаша Робота

Послуги

Цифровий КонсалтингХмарна ІнфраструктураРозробка SaaSРозробка AIВідео Технології
Розробка ERPНалаштування ZohoРозробка OdooІнтеграція SalesforceРозробка Користувацьких CRM
Інтеграція QuickBooksРішення IoTРозробка Блокчейну
Консалтинг з КібербезпекиІТ Підтримка - L3

© 2026 MicrocosmWorks. Усі права захищено.

Політика КонфіденційностіУмови Обслуговування
Назад до інсайтів
IoT Development

Перекадрування 360° відео в Flutter: сферична проєкція, кватерніонні ключові кадри та експорт за допомогою GPU

Перекадрування 360° матеріалу в рівнокутовій проєкції у Flutter за допомогою сферичної проєкції, кватерніонних ключових кадрів та експорту, прискореного GPU.

Saurav Kumar Gupta's image Saurav Kumar Gupta
•
July 10, 2026
•
Оновлено July 24, 2026
•
5 min read
Developer using Flutter to edit and reframe a 360° video with spherical projection.webp
5 min read

Перекадрування 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 не може зробити достатньо швидко.

Flutter360VideoOpenGLQuaternions
Saurav Kumar Gupta's image

Про автора

Saurav Kumar Gupta

AI & Cloud Solutions Expert at MicrocosmWorks

Building innovative AI-powered solutions and helping businesses transform through cutting-edge technology.

Бажаєте дізнатися більше?

Зв'яжіться з нами, щоб обговорити, як ми можемо допомогти впровадити ці рішення для вашого бізнесу.

Зв'яжіться з нами

Часті запитання

Accurate reframing requires handling spherical projection, seamless rotation across the ±180° boundary, smooth camera interpolation, and synchronized GPU rendering to produce stable, cinematic video output.

Comments (0)

Share your thoughts and join the conversation

Leave a Comment

Your email will not be published

No comments yet

Be the first to share your thoughts!