Pembingkaian Semula Video 360° dalam Flutter: Unjuran Sfera, Keyframe Quaternion, dan Eksport GPU
Bagaimana kami mengubah rakaman 360° equirectangular mentah menjadi video rata, berbingkai, dan licin — sepenuhnya di dalam aplikasi Flutter.
Masalah yang tidak diberitahu kepada anda
Video biasa mempunyai satu perkara yang jelas untuk ditunjukkan kepada anda: bingkai yang diarahkan oleh kamera. Video 360° tidak mempunyai perkara sedemikian. Kamera merekodkan segala-galanya — sfera penuh piksel yang disimpan sebagai segi empat tepat equirectangular rata (imej 2:1 di mana paksi X adalah longitud dan paksi Y adalah latitud). Sebelum anda boleh memuat naik rakaman itu ke Instagram, YouTube, atau skrin telefon, seseorang perlu menjawab soalan yang sengaja enggan dijawab oleh kamera:
Ke mana seharusnya penonton melihat, dan bila?
"Reframing" adalah tindakan menerbangkan kamera maya melalui sfera itu dari masa ke masa — menyorot (panning), mencondongkan (tilting), mengezum — dan mengeluarkan video rata 16:9 (atau 9:16, atau 2.35:1) biasa. Ini adalah ciri yang menjadi asas kepada GoPro Player, Insta360 Studio, dan plugin GoPro VR Reframe Adobe Premiere.
Kami membangunkannya sepenuhnya dalam Flutter. Catatan ini adalah tinjauan bahagian-bahagian yang benar-benar sukar: matematik unjuran, interpolasi bebas gimbal lock, jahitan ±180°, dan saluran paip eksport GPU yang perlu berfungsi dengan dekoder Android sebenar.
Aliran hujung-ke-hujung:
tangkap/import video 360
→ transcode proksi pratonton ringan
→ pengguna mengetuk / melukis / mengesan subjek secara automatik
→ penjejakan objek menghasilkan laluan
→ hasilkan + lancarkan keyframe
→ SLERP-interpolasi orientasi kamera setiap bingkai (pratonton langsung)
→ eksport GPU asli ke video rata
→ FFmpeg post-pass (moov atom + A/V sync fix)
Sekilas Pandang Seni Bina
Kami mengekalkan sistem dalam tiga lapisan yang jelas supaya matematik boleh diuji secara berasingan daripada Flutter:
| Lapisan | Berada di | Tanggungjawab |
|---|---|---|
| Model | lib/data/models/reframe/ | SphericalCoordinates/Quaternion, CameraOrientation/MotionConstraints, ReframeKeyframe, ReframeProject |
| Perkhidmatan | lib/services/reframe/ | SphericalProjection (ray casting), CoordinateConverter, KeyframeInterpolator/Generator/Smoother, ReframeExportService |
| Pembentangan | lib/presentation/.../reframe/ | Riverpod ReframeNotifier, the native OpenGL Spherical360Player, bbox-drawing overlay, keyframe timeline |
Peraturan emas: semua trigonometri adalah Dart tulen tanpa import Flutter. Ini bermakna setiap fungsi unjuran dan interpolasi boleh diuji unit tanpa pokok widget, dan matematik yang sama menggerakkan kedua-dua pratonton langsung dan eksport.
1. Inti Pati: equirectangular ⟷ sfera
Semuanya bermula dengan satu pemetaan. Bingkai equirectangular hanyalah segi empat tepat di mana:
- X (0 → lebar) menyapu longitud (yaw) dari −180° hingga +180°
- Y (0 → tinggi) menyapu latitud (pitch) dari +90° (atas/zenith) hingga −90° (bawah/nadir)
SphericalCoordinates sengaja kecil — hanya yaw dan pitch. Roll, FOV, dan zoom adalah milik kamera, bukan arah. Berikut adalah penukaran yang menjadi asas keseluruhan ciri:
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),
);
}
Songsangan adalah imej cermin (normalizedX = yaw/360 + 0.5), dan kami menggunakannya secara berterusan untuk melukis semula kotak pembatas yang dijejaki ke atas pemain.
"Ketuk apa yang anda lihat" — perspective ray casting
Pengguna tidak berinteraksi dengan segi empat tepat equirectangular. Mereka berinteraksi dengan paparan perspektif yang dirender. Jadi apabila mereka mengetuk satu titik pada pemain, kita perlu melancarkan pancaran melalui kamera lubang jarum maya dan mencari arah mana pada sfera yang mereka kenakan. Itulah 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;
Penggunaan .clamp(-1.0, 1.0) sebelum asin kelihatan paranoid, tetapi sisihan floating-point akan memberikan anda 1.0000001 dan asin akan mengembalikan NaN — dan satu NaN akan merosakkan setiap bingkai hiliran. Penyekat defensif di sekitar trig songsang adalah tema berulang dalam pangkalan kod ini.
2. Pergerakan Kamera yang Licin: Quaternion, bukan Sudut Euler
Pendekatan naif adalah menyimpan yaw/pitch pada setiap keyframe dan menginterpolasi nombor secara linear. Ia kelihatan mengerikan. Interpolasi sudut Euler menyebabkan gimbal lock, halaju sudut tidak seragam, dan 'snap' yang tidak elok berhampiran kutub.
Kami menginterpolasi orientasi sebagai quaternion menggunakan SLERP (interpolasi linear sfera). Setiap arah menukar kepada quaternion melalui konvensyen 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);
}
Dan SLERP itu sendiri mempunyai dua injap keselamatan gred pengeluaran yang diperlukan oleh setiap implementasi:
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
y: a.y + t*(b.y-a.y), z: a.z + 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();
}
Dua butiran yang tidak boleh dirunding:
- Hemisfera terpendek — q dan −q mewakili orientasi yang sama. Jika dot product adalah negatif, anda menidakkan satu input atau kamera anda akan mengambil jalan yang lebih jauh mengelilingi sfera.
- Pengembalian LERP hampir selari — apabila dua keyframe hampir sama, sinTheta0 → 0 dan anda membahagi dengan hampir sifar. Menggunakan LERP biasa apabila dot > 0.9995 mengelakkan 'blow-up'.
Keputusan reka bentuk yang halus tetapi penting: kami SLERP arah, tetapi LERP FOV/zoom dan angle-LERP roll. Interpolasi sfera adalah alat yang tepat untuk arah; zoom hanyalah skalar dan sepatutnya bergerak secara linear:
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);
💡 Apabila menukar kembali dari quaternion ke yaw/pitch, kami menggunakan atan2 dan bukannya asin "untuk kestabilan angka (mengelakkan isu domain asin)." Selepas SLERP, quaternion mungkin sedikit ternormal, dan atan2 merosot dengan baik di mana asin membuang NaN.
Easing: buatan tangan, pada transisi masuk
Setiap keyframe memiliki lengkung easing untuk transisi ke dalamnya. Lengkung-lengkung ini ditulis secara manual supaya ia adalah sama pada setiap platform:
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. Jahitan ±180°: pepijat yang menghantui setiap ciri 360
Inilah perangkapnya. Yaw berada dalam bulatan: +179° dan −179° adalah 2° berasingan, bukan 358°. Seketika subjek anda melintasi bahagian belakang sfera, matematik naif akan "snap" kamera separuh jalan mengelilingi dunia. Isu tunggal ini muncul pada empat lapisan sistem yang berbeza, dan setiap satunya memerlukan penyelesaiannya sendiri.
Lapisan 1 — primitif. Dua pembantu kecil yang digunakan oleh keseluruhan pangkalan kod:
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;
}
Lapisan 2 — penjanaan keyframe mengumpul yaw berterusan (tanpa batas) supaya SLERP sentiasa menginterpolasi jalan terpendek. Daripada menyekat setiap sampel kepada ±180°, kami menjumlahkan delta terpendek dan membiarkan nilai berjalan melepasi 180° dengan sengaja:
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);
Lapisan 3 — pembinaan semula kotak pembatas (bounding-box). Apabila pengguna melukis kotak yang merentangi jahitan, empat penjuru melaporkan yaw seperti +170°, +175°, −175°, −170°. SphericalProjection mengesan ini (mana-mana penjuru > 90° dan mana-mana penjuru < −90°) dan membina semula "bbox selamat jahitan" yaw berterusan dengan menganjakkan nilai negatif sebanyak +360° sebelum mengukur rentangnya.
Lapisan 4 — pemain langsung melicinkan ke arah sasarannya menggunakan shortestYawDelta supaya menyeret melintasi jahitan tidak pernah tersentak.
Jika anda ingat satu perkara tentang membina alatan 360: jahitan bukanlah kes pinggir, ia adalah isu menyeluruh. Sediakan peruntukan untuknya di mana sahaja yaw muncul.
4. Menjadikannya terasa seperti Insta360/GoPro
Interpolasi licin memberikan anda kamera yang betul dari segi teknikal. Ia tidak memberikan anda kamera yang baik. Tiga elemen "kejuruteraan rasa" menutup jurang itu.
Kekangan pergerakan — hadkan halaju dan pecutan. Data penjejakan mentah adalah tidak stabil (jittery). Kami melalukan keyframe melalui MotionConstraints, yang menghadkan kelajuan dan kekasaran kamera maya boleh bergerak (cth. maxYawSpeed = 120°/s, maxPitchSpeed = 90°/s, maxAcceleration = 180°/s²), dengan pratetap cinematic(), responsive(), dan production(). Ini adalah perbezaan antara "kamera keselamatan menjejaki lalat" dan "pengendali dengan tangan yang stabil."
FOV adaptif — zum untuk memuatkan subjek. Kami mengukur saiz sudut subjek dalam ruang equirectangular dan memilih FOV supaya ia memenuhi pecahan sasaran bingkai:
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°
Motion lead + face→body blend. Kami menolak pembingkaian kira-kira 15% daripada lebar bbox ke hadapan dalam arah perjalanan, supaya subjek tidak tersepit di tepi yang mereka tuju. Dan apabila pengesanan muka terhenti, kami tidak serta-merta beralih ke pusat badan — kami melakukan lerp dari pusat muka yang terakhir diketahui ke pusat badan dalam ~10 bingkai.
5. Helah dua video: proksi pratonton berbanding asal resolusi penuh
Ini adalah keputusan seni bina yang menjadikan keseluruhan perkara boleh digunakan pada telefon sebenar.
Fail 360° GoPro/Insta360 selalunya 4096×2048 HEVC. Dekoder Android bajet sukar untuk memainkan semula itu dengan lancar, apatah lagi semasa menjalankan penjejakan objek di atasnya. Jadi kami menjalankan penjejakan dan pratonton langsung pada proksi transcoded (~1440×720, libx264 -preset veryfast -crf 28), dan menyimpan asal resolusi penuh hanya untuk eksport akhir.
// "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()
Perkara yang perlu anda kendalikan: kotak pembatas keyframe dikira dalam koordinat proksi dan mesti diskalakan ke koordinat asal sebelum eksport. Jika penskalaan ini salah, bingkai eksport anda akan menunjukkan kawasan yang sedikit berbeza daripada yang dipratontonkan oleh pengguna. (Kami juga menggunakan ffprobe, bukan MediaMetadataRetriever, untuk metadata dan pengekstrakkan bingkai pertama — retriever terbina dalam Android tidak stabil pada codec luar biasa yang dikeluarkan oleh kamera 360.)
6. Eksport GPU: OpenGL + MediaCodec melalui MethodChannel
Menayangkan semula setiap bingkai sfera 4K melalui penapis v360 FFmpeg pada CPU telefon terlalu perlahan. (Kami memang menyimpan rentetan v360 — ia mendokumentasikan semantik unjuran dengan kemas, v360=e:flat:... bermaksud "equirectangular masuk, rata keluar" — tetapi ia bukan laluan langsung.)
Eksport sebenar menyerahkan keyframe kepada renderer OpenGL ES + MediaCodec asli melalui 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
});
Kami mengawal ini di sebalik pemeriksaan keupayaan (Android API 21+, pengekod H.264 perkakasan, OpenGL ES 3.0) dan menstrimkan kemajuan kembali melalui EventChannel. Beberapa masalah sukar yang ditemui:
- Ofset yaw −90°. Konvensyen Dart kami meletakkan yaw = 0 di pusat imej equirectangular (u = 0.5); renderer OpenGL meletakkan yaw = 0 pada u = 0.75. Jadi setiap keyframe yaw dipetakan semula sebanyak −90° (dan dibalut semula kepada ±180°) semasa melintasi saluran — diterapkan secara simetri kepada kedua-dua saluran eksport dan saluran pemain langsung, supaya pratonton dan eksport bersetuju.
- Kiraan keyframe merosot. Sifar keyframe → sintesiskan dua yang serupa dari orientasi awal (eksport statik). Satu keyframe → duplikasikannya. Renderer sentiasa mahu sekurang-kurangnya permulaan dan akhir.
- Pangkasan mengubah suai cap masa. Eksport yang dipangkas mengalihkan setiap keyframe sebanyak t − trimStartMs.
FFmpeg post-pass yang tidak dijangka
Anda mungkin berfikir eksport GPU adalah garisan penamat. Tidak — kerana MediaMuxer Android menulis moov atom di akhir fail dan boleh membawa ofset audio PTS sumber. Hasilnya: ExoPlayer (dan video_player Flutter) membekukan gambar sementara jam terus berjalan. Jadi selepas GPU pass, FFmpeg melakukan pembersihan yang cepat dan tanpa kehilangan:
// 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
Dan satu masalah kecil yang brutal menjaganya: kami mencari trek audio terlebih dahulu, kerana meluluskan bendera audio kepada video yang tiada trek audio membuat FFmpeg tergantung selama-lamanya pada sesetengah peranti. Keseluruhan post-pass dibalut dalam had masa 60 saat dengan pembatalan jika tamat masa.
7. Pratonton Langsung juga adalah Pemain Asli
Pratonton dalam editor bukanlah CustomPaint Flutter — ia adalah permukaan OpenGL asli yang dibenamkan melalui AndroidView (dengan viewType seperti 'your_app/spherical360player'), dengan saluran method/event per-view sendiri. Spherical360PlayerController Dart mencerminkan yaw/pitch/roll/fov dan menerapkan ofset −90° yang sama sebelum dihantar.
Butiran rasa yang menjadikannya menyenangkan:
- Penlancaran bebas kadar bingkai — faktor = 1 - exp(-speed * dt) supaya easing kelihatan sama pada 60fps dan 90fps, dengan yaw laluan terpendek yang selamat jahitan.
- Momentum/inersia selepas melibas, dengan pereputan berasaskan geseran — dilumpuhkan semasa main semula, kerana semasa main semula kamera mesti mematuhi keyframe, bukan "flick" terakhir pengguna.
- Satu GestureDetector yang onScaleUpdate melakukan pan dan pinch-zoom bersama; ketukan dua kali menogol main/jeda. FOV ditetapkan kepada 30°–120°, pitch kepada ±90°, pada sempadan pengawal.
Satu pembetulan UX yang kecil tetapi penting: sebelum bertukar daripada "tracking" kepada "generating keyframes," kami menyelitkan kelewatan 120 ms supaya Flutter benar-benar melukis "100%." Tanpa itu, bingkai kemajuan akhir dan perubahan status mendarat dalam kumpulan microtask yang sama dan pengguna tidak pernah melihat penyiapan.
Mesin keadaan, secara ringkas
Semua ini diselaraskan oleh Riverpod ReframeNotifier yang mengendalikan enum status:
idle → loadingVideo → transcoding
→ (detecting | selectingObject | drawingBboxOnPlayer | autoDetectingOnPlayer)
→ tracking → generatingKeyframes → editing → exporting → completed | error
Pelbagai titik masuk menyalurkan kepada penjejak yang sama: tap-to-detect (YOLO overlay), bbox manual, atau isyarat draw-directly-on-the-player ala Insta360. Semuanya bertemu di KeyframeGenerator.production().generate(...) → KeyframeSmoother.smooth(...), dan dari sana interpolator menggerakkan setiap bingkai yang dipratonton dan dieksport.
Pengajaran yang Dipelajari
- Kekalkan matematik tulen. Tiada import Flutter dalam lapisan unjuran/interpolasi. Itulah satu-satunya sebab ini boleh diuji.
- Quaternion SLERP untuk arah; LERP untuk skalar. Jangan menginterpolasi sudut Euler, dan jangan SLERP zoom anda.
- Jahitan ±180° adalah isu menyeluruh, bukan kes pinggir. Ia muncul semula dalam primitif, penjanaan, unjuran, dan pemain.
- Selaraskan konvensyen anda sekali, secara simetri. Ofset yaw −90° antara Dart dan OpenGL diterapkan pada kedua-dua sempadan saluran supaya pratonton == eksport.
- Proksi untuk interaksi, asal untuk output. Ingatlah untuk menskalakan koordinat anda antara kedua-duanya.
- "Selesai rendering" ≠"bermain dengan betul." Sediakan peruntukan untuk FFmpeg post-pass untuk membetulkan penempatan moov atom dan audio PTS, dan uji sebelum anda menambah bendera audio.
- "Rasa" adalah ciri. Kekangan pergerakan, FOV adaptif, dan motion-lead adalah yang membezakan antara "dibingkaikan semula secara teknikal" dan "kelihatan seperti ditembak oleh profesional."
Hasilnya: editor pembingkaian semula 360° sepenuhnya pada peranti — ketuk subjek, biarkannya menjejak, laraskan keyframe, dan eksport video rata yang bersih — dibina dalam Flutter dengan lapisan GPU asli nipis yang hanya melakukan kerja yang tidak dapat dilakukan oleh Dart dengan cukup pantas.

