מיסגור מחדש של וידאו 360° ב-Flutter: היטל כדורי, keyframes מסוג קווטרניון, וייצוא ב-GPU
איך הפכנו צילום 360° גולמי בפורמט equirectangular לווידאו שטוח, ממוסגר וחלק – כולו בתוך אפליקציית Flutter.
הבעיה שאף אחד לא מזהיר מפניה
לווידאו רגיל יש דבר אחד ברור להראות לכם: המסגרת שהמצלמה כיוונה אליה. לווידאו 360° אין דבר כזה. המצלמה הקליטה הכל — כדור מלא של פיקסלים המאוחסנים כמלבן equirectangular שטוח (תמונת 2:1 שבה ציר ה-X הוא קו אורך וציר ה-Y הוא קו רוחב). לפני שתוכלו לשים את הצילום הזה ב-Instagram, YouTube, או במסך טלפון, מישהו צריך לענות על שאלה שהמצלמה סירבה במכוון לענות עליה:
לאן הצופה צריך להסתכל, ומתי?
"מיסגור מחדש" (Reframing) הוא הפעולה של הטסת מצלמה וירטואלית דרך הכדור הזה לאורך זמן — הזזה אופקית (panning), הטיה (tilting), זום (zooming) — ורינדור וידאו שטוח רגיל ביחס 16:9 (או 9:16, או 2.35:1). זו התכונה שבבסיס GoPro Player, Insta360 Studio, והפלאגין GoPro VR Reframe של Adobe Premiere.
בנינו את זה כולו ב-Flutter. פוסט זה סוקר את החלקים שהיו קשים באמת: מתמטיקת ההיטל (projection math), אינטרפולציה ללא gimbal lock, התפר ±180°, וצינור ייצוא GPU שצריך לשרוד מפענחי Android אמיתיים.
זרימת העבודה מקצה לקצה:
לכידה/ייבוא וידאו 360
→ המרה (transcode) לפרוקסי תצוגה מקדימה קל משקל
→ המשתמש מקיש / מצייר / מזהה אוטומטית נושא
→ מעקב אובייקטים (object tracking) מייצר נתיב
→ יצירה + החלקת keyframes
→ אינטרפולציית SLERP של כיוון המצלמה עבור כל פריימ (תצוגה מקדימה חיה)
→ ייצוא GPU מקורי לווידאו שטוח
→ מעבר FFmpeg לאחר (תיקון moov atom + A/V sync)
מבט מהיר על הארכיטקטורה
שמרנו על המערכת בשלוש שכבות נקיות כך שהמתמטיקה ניתנת לבדיקה בנפרד מ-Flutter:
| שכבה | ממוקם ב | אחריות |
|---|---|---|
| Models | lib/data/models/reframe/ | SphericalCoordinates/Quaternion, CameraOrientation/MotionConstraints, ReframeKeyframe, ReframeProject |
| Services | lib/services/reframe/ | SphericalProjection (ray casting), CoordinateConverter, KeyframeInterpolator/Generator/Smoother, ReframeExportService |
| Presentation | lib/presentation/.../reframe/ | Riverpod ReframeNotifier, the native OpenGL Spherical360Player, bbox-drawing overlay, keyframe timeline |
כלל הזהב: כל הטריגונומטריה היא Dart טהור ללא ייבואי Flutter. המשמעות היא שכל פונקציית היטל ואינטרפולציה ניתנת לבדיקת יחידה (unit-testable) ללא עץ widget, ואותה מתמטיקה מניעה הן את התצוגה המקדימה החיה והן את הייצוא.
1. לב העניין: equirectangular ⟷ כדורי
הכל מתחיל במיפוי אחד. פריימ equirectangular הוא פשוט מלבן שבו:
- X (0 → רוחב) סורק את קו האורך (yaw) מ-−180° ל- +180°
- Y (0 → גובה) סורק את קו הרוחב (pitch) מ- +90° (למעלה/זניט) ל- −90° (למטה/נאדיר)
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 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), ואנו משתמשים בו באופן קבוע כדי לצייר מלבן תוחם (bounding box) שעוקבים אחריו בחזרה על הנגן.
"הקש על מה שאתה רואה" — הטלת קרן פרספקטיבה (perspective ray casting)
המשתמש לא מקיים אינטראקציה עם המלבן ה-equirectangular. הוא מקיים אינטראקציה עם תצוגת פרספקטיבה מרונדרת. לכן, כאשר הוא מקיש על נקודה בנגן, עלינו להטיל קרן דרך מצלמת pinhole וירטואלית ולמצוא איזה כיוון על הכדור הם פגעו בו. זהו 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 נראה פרנואידי, אבל סחף נקודה צפה (floating-point drift) יחזיר לכם 1.0000001 ו-asin יחזיר NaN — ו-NaN יחיד ירעיל כל פריימ במורד הזרם. קיבוע הגנתי סביב פונקציות טריגונומטריות הפוכות הוא נושא חוזר בבסיס קוד זה.
2. תנועת מצלמה חלקה: קווטרניונים, לא זוויות אוילר
הגישה הנאיבית היא לאחסן yaw/pitch בכל keyframe ולבצע אינטרפולציה ליניארית של המספרים. זה נראה נורא. אינטרפולציה של זוויות אוילר גורמת ל-gimbal lock, מהירות זוויתית לא אחידה, וקפיצות מכוערות ליד הקטבים.
אנו מבצעים אינטרפולציה של כיוון כקווטרניון באמצעות 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 מייצגים את אותו כיוון. אם המכפלה הסקלרית (dot product) שלילית, אתם מנטרלים קלט אחד או שהמצלמה שלכם לוקחת את הדרך הארוכה סביב הכדור.
- גיבוי LERP כמעט מקביל — כאשר שני keyframes כמעט זהים, sinTheta0 → 0 ואתם מחלקים כמעט באפס. מעבר ל-LERP רגיל מעל dot > 0.9995 מונע את ההתפוצצות.
החלטת עיצוב עדינה אך חשובה: אנו מבצעים SLERP לכיוון, אבל LERP ל-FOV/זום ו-angle-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.
Easing: מיוצר בעבודת יד, במעבר הנכנס
כל keyframe מכיל את עקומת ה-easing עבור המעבר אליו. העקומות נכתבות ידנית כך שהן זהות בכל פלטפורמה:
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 — יצירת keyframe צוברת 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 — שחזור bounding box. כאשר המשתמש מצייר תיבה שחוצה את התפר, ארבע הפינות מדווחות על yaws כמו +170°, +175°, −175°, −170°. SphericalProjection מזהה זאת (כל פינה > 90° וכל פינה < −90°) ובונה מחדש bbox "בטוח לתפר" עם yaw רציף על ידי הזזת הערכים השליליים ב- +360° לפני מדידת הטווח שלו.
שכבה 4 — הנגן החי מחליק לעבר המטרה שלו באמצעות shortestYawDelta כך שגרירה לאורך התפר לעולם לא גורמת לקפיצות פתאומיות.
אם אתם זוכרים דבר אחד על בניית כלי 360: התפר אינו מקרה קצה (edge case), הוא עניין חוצה רוחב (cross-cutting concern). תקצבו אותו בכל מקום שבו מופיע yaw.
4. לגרום לזה להרגיש כמו Insta360/GoPro
אינטרפולציה חלקה נותנת לכם מצלמה נכונה מבחינה טכנית. היא לא נותנת לכם מצלמה טובה. שלושה רכיבים של "הנדסת תחושה" סוגרים את הפער הזה.
אילוצי תנועה — הגבלת מהירות ותאוצה. נתוני מעקב גולמיים רועדים (jittery). אנו מעבירים keyframes דרך MotionConstraints, אשר מגביל את מהירות וחדות תנועת המצלמה הווירטואלית (לדוגמה: maxYawSpeed = 120°/s, maxPitchSpeed = 90°/s, maxAcceleration = 180°/s²), עם הגדרות קבועות מראש (presets) של cinematic(), responsive() ו-production(). זהו ההבדל בין "מצלמת אבטחה שעוקבת אחרי זבוב" לבין "מפעיל עם יד יציבה".
FOV אדפטיבי — זום להתאמת הנושא. אנו מודדים את הגודל הזוויתי של הנושא במרחב equirectangular ובוחרים 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% מרוחב ה-bbox קדימה בכיוון התנועה, כך שהנושא לא יהיה צמוד לקצה שאליו הוא מתקדם. וכאשר זיהוי הפנים נכשל, איננו קופצים למרכז הגוף — אנו מבצעים LERP ממרכז הפנים האחרון הידוע למרכז הגוף במשך כ-10 פריימים.
5. טריק שני הווידאו: פרוקסי תצוגה מקדימה מול מקור ברזולוציה מלאה
זוהי ההחלטה הארכיטקטונית שהופכת את העניין כולו לשמיש בטלפונים אמיתיים.
קובץ GoPro/Insta360 360° הוא לעיתים קרובות 4096×2048 HEVC. מפענחי Android תקציביים נחנקים בניסיון להפעיל זאת בצורה חלקה, קל וחומר תוך כדי הפעלת מעקב אובייקטים (object tracking) מעל. לכן אנו מריצים מעקב ותצוגה מקדימה חיה על פרוקסי שהומר (~1440×720, libx264 -preset veryfast -crf 28), ושומרים את המקור ברזולוציה מלאה לייצוא הסופי בלבד.
// "יותר אמין מ-MediaMetadataRetriever עבור סרטוני 360°
// אשר משתמשים לעיתים קרובות בקודקים לא שגרתיים (HEVC, ברזולוציה גבוהה)"
final maxSafeWidth = 2048, maxSafeHeight = 1080;
// 2:1 4K (4096×2048) אינו בטוח -> מפעיל transcodeForPlayback()
המלכוד שאתם חייבים לטפל בו: bounding boxes של keyframe מחושבים בקואורדינטות פרוקסי וחייבים לעבור סקאלה לקואורדינטות המקוריות לפני הייצוא. אם תבצעו את הסקאלה הזו באופן שגוי, הייצוא שלכם ימסגר אזור שונה במקצת מזה שהמשתמש צפה בו בתצוגה המקדימה. (אנו משתמשים גם ב-ffprobe, לא ב-MediaMetadataRetriever, עבור מטא נתונים וחילוץ פריימ ראשון — המאחזר המובנה של Android לא יציב (flaky) עם הקודקים הלא שגרתיים שמצלמות 360 מפיקות).
6. ייצוא GPU: OpenGL + MediaCodec מעל MethodChannel
היטול מחדש של כל פריימ מכדור 4K דרך פילטר v360 של FFmpeg על מעבד טלפון איטי מדי. (אנו כן שומרים את מחרוזת v360 — היא מתעדת בצורה מסודרת את סמנטיקת ההיטל, v360=e:flat:... שמשמעותה "equirectangular פנימה, שטוח החוצה" — אבל זה לא הנתיב החי).
הייצוא האמיתי מעביר את ה-keyframes לרנדרר (renderer) מקורי של 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 ב-*מרכז* תמונת ה-equirectangular (u = 0.5); רנדרר ה-OpenGL ממקם yaw = 0 ב-u = 0.75. לכן כל yaw של keyframe ממופה מחדש ב-−90° (ונעטף מחדש ל-±180°) במעבר דרך הערוץ — מיושם *סימטרית* הן לערוץ הייצוא והן לערוץ הנגן החי, כך שהתצוגה המקדימה והייצוא תואמים.
- מספר keyframe מנוון. אפס keyframes → סינתזה של שניים זהים מהכיוון ההתחלתי (ייצוא סטטי). keyframe אחד → שכפולו. הרנדרר תמיד רוצה לפחות התחלה וסוף.
- חיתוך (Trimming) משנה את בסיס חותמות הזמן. ייצוא חתוך מזיז כל keyframe ב-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 משלו לכל תצוגה. ה-Dart Spherical360PlayerController משקף yaw/pitch/roll/fov ומיישם את אותה הסטת −90° לפני השליחה.
פרטי התחושה שהופכים אותו לנעים:
- החלקה בלתי תלויה בקצב הפריימים — factor = 1 - exp(-speed * dt) כך שה-easing נראה זהה ב-60fps וב-90fps, עם yaw בטוח-לתפר בדרך הקצרה ביותר מובנה.
- מומנטום/אינרציה לאחר הטלה, עם דעיכה מבוססת חיכוך — מושבת במהלך ההשמעה, מכיוון שבמהלך ההשמעה המצלמה חייבת לציית ל-keyframes, לא להקפצה האחרונה של המשתמש.
- GestureDetector אחד שפעולת onScaleUpdate שלו מבצעת pan ו-pinch-zoom יחד; לחיצה כפולה מחליפה בין הפעלה/השהיה. FOV מוגבל ל-30°–120°, pitch ל-±90°, בגבול הבקר.
תיקון UX קטן אך משמעותי: לפני המעבר מ"מעקב" ל"יצירת keyframes", אנו מכניסים עיכוב של 120 מילישניות כדי ש-Flutter יצייר בפועל "100%". בלעדיו, פריימ ההתקדמות הסופי ושינוי הסטטוס נוחתים באותה אצווה של microtask והמשתמש לעולם לא רואה השלמה.
מכונת מצבים, בקצרה
כל זה מתואם על ידי Riverpod ReframeNotifier המניע enum סטטוס:
idle → loadingVideo → transcoding
→ (detecting | selectingObject | drawingBboxOnPlayer | autoDetectingOnPlayer)
→ tracking → generatingKeyframes → editing → exporting → completed | error
נקודות כניסה מרובות מזינות את אותו עוקב: הקש-לזיהוי (YOLO overlay), bbox ידני, או מחוות הציור-ישירות-על-הנגן בסגנון Insta360. כולם מתכנסים ל-KeyframeGenerator.production().generate(...) → KeyframeSmoother.smooth(...), ומשם האינטרפולטור מניע כל פריימ בתצוגה מקדימה ופריימ מיוצא.
לקחים שנלמדו
- שמרו על המתמטיקה טהורה. אין ייבואי Flutter בשכבת ההיטל/אינטרפולציה. זו הסיבה היחידה שהדבר היה ניתן לבדיקה.
- SLERP קווטרניון לכיוון; LERP לסקלרים. אל תבצעו אינטרפולציה לזוויות אוילר, ואל תבצעו SLERP לזום שלכם.
- תפר ±180° הוא עניין חוצה רוחב, לא מקרה קצה (edge case). הוא מופיע מחדש בפרימיטיבים, יצירה, היטל, והנגן.
- התאימו את המוסכמות שלכם פעם אחת, באופן סימטרי. הסטת yaw של −90° בין Dart ל-OpenGL מיושמת בשני גבולות הערוצים כך שתצוגה מקדימה == ייצוא.
- פרוקסי לאינטראקציה, מקור לפלט. רק זכרו לשנות קנה מידה לקואורדינטות שלכם בין השניים.
- "רינדור הושלם" ≠ "מתנגן נכון." תקצבו מעבר FFmpeg מאוחר לתיקון מיקום moov atom ו-audio PTS, ובדקו לפני שתוסיפו דגלי שמע.
- "תחושה" היא תכונה. אילוצי תנועה, FOV אדפטיבי, והובלת תנועה הם מה שמפריד בין "ממוסגר מחדש טכנית" לבין "נראה כאילו מקצוען צילם את זה".
התוצאה: עורך מיסגור מחדש (reframe editor) של 360° מובנה במכשיר — הקישו על נושא, תנו לו לעקוב, כווננו את ה-keyframes, וייצאו וידאו שטוח ונקי — בנוי ב-Flutter עם שכבת GPU מקורית דקה המבצעת רק את העבודה ש-Dart לא יכול לבצע מספיק מהר.

