MicrocosmWorksInnovando y Arquitectando el Cosmos Digital
Acerca deContacto
MicrocosmWorksInnovando y Arquitectando el Cosmos Digital

Ofreciendo soluciones de TI que importan. Nos apasiona la tecnología, la seguridad y ayudar a las empresas a crecer a través de una infraestructura de TI confiable e innovadora.

[email protected]
+91 7011868196
New Delhi, India

Centro de Crecimiento de IA

Centro de IAInnovación para StartupsAcelerador Empresarial

Soluciones

Todas las SolucionesAplicaciones de Bienestar y FitnessPlataforma de Video con IADesarrollo de Agentes de IA

Recursos

PerspectivasGuías de la IndustriaPlanos de Casos de UsoPatrones de ArquitecturaEstudios de Caso

Compañía

Sobre NosotrosContactoNuestro Trabajo

Servicios

Consultoría DigitalInfraestructura en la NubeDesarrollo SaaSDesarrollo de IATecnología de Video
Desarrollo ERPPersonalización de ZohoDesarrollo de OdooIntegración de SalesforceDesarrollo de CRM Personalizado
Integración de QuickBooksSoluciones IoTDesarrollo de Blockchain
Consultoría de CiberseguridadSoporte IT - L3

© 2026 MicrocosmWorks. Todos los derechos reservados.

Política de PrivacidadTérminos de Servicio
Volver a Perspectivas
IoT Development

Reencuadre de Video 360 en Flutter: Proyección Esférica, Fotogramas Clave Quaternion y Exportación GPU

Reencuadre de material equirectangular 360 en Flutter usando proyección esférica, fotogramas clave Quaternion y exportación acelerada por GPU.

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

Reencuadre de Video 360° en Flutter: Proyección Esférica, Fotogramas Clave Quaternion y Exportación GPU

Cómo convertimos material de archivo equirectangular 360° en video plano, encuadrado y fluido, completamente dentro de una aplicación Flutter.

El problema del que nadie te advierte

Un video normal tiene una cosa obvia que mostrarte: el encuadre al que apuntaba la cámara. Un video 360° no tiene tal cosa. La cámara grabó todo — una esfera completa de píxeles almacenada como un rectángulo equirectangular plano (una imagen 2:1 donde el eje X es la longitud y el eje Y es la latitud). Antes de que puedas poner ese metraje en Instagram, YouTube o en la pantalla de un teléfono, alguien tiene que responder a una pregunta que la cámara se negó deliberadamente a responder:

¿Hacia dónde debería mirar el espectador y cuándo?

El "reencuadre" es el acto de volar una cámara virtual a través de esa esfera a lo largo del tiempo — paneando, inclinando, haciendo zoom — y renderizar un video plano normal de 16:9 (o 9:16, o 2.35:1). Esta es la característica en la que se basan GoPro Player, Insta360 Studio y el plugin GoPro VR Reframe de Adobe Premiere.

Lo construimos completamente en Flutter. Esta publicación es un recorrido por las partes que fueron realmente difíciles: las matemáticas de proyección, la interpolación sin bloqueo de gimbal, la costura de ±180° y un pipeline de exportación GPU que tiene que sobrevivir a los decodificadores Android reales.

El flujo de extremo a extremo:

capturar/importar video 360
  → transcodificar un proxy ligero de vista previa
  → el usuario toca / dibuja / detecta automáticamente un sujeto
  → el seguimiento de objetos produce una ruta
  → generar + suavizar fotogramas clave
  → interpolación SLERP de la orientación de la cámara por fotograma (vista previa en vivo)
  → exportación GPU nativa a un video plano
  → post-procesamiento FFmpeg (átomo moov + corrección de sincronización A/V)

Arquitectura de un vistazo

Mantuvimos el sistema en tres capas limpias para que las matemáticas sean comprobables de forma aislada de Flutter:

CapaUbicaciónResponsabilidad
Modeloslib/data/models/reframe/SphericalCoordinates/Quaternion, CameraOrientation/MotionConstraints, ReframeKeyframe, ReframeProject
Servicioslib/services/reframe/SphericalProjection (ray casting), CoordinateConverter, KeyframeInterpolator/Generator/Smoother, ReframeExportService
Presentaciónlib/presentation/.../reframe/Riverpod ReframeNotifier, el Spherical360Player nativo OpenGL, superposición de dibujo bbox, línea de tiempo de fotogramas clave

La regla de oro: toda la trigonometría es Dart puro sin importaciones de Flutter. Esto significa que cada función de proyección e interpolación es comprobable con pruebas unitarias sin un árbol de widgets, y las mismas matemáticas impulsan tanto la vista previa en vivo como la exportación.

1. El corazón de todo: equirectangular ⟷ esférico

Todo comienza con una asignación. Un fotograma equirectangular es simplemente un rectángulo donde:

  • X (0 → ancho) abarca la longitud (yaw) de −180° a +180°
  • Y (0 → alto) abarca la latitud (pitch) de +90° (arriba/cenit) a −90° (abajo/nadir)

SphericalCoordinates es deliberadamente pequeño — solo yaw y pitch. Roll, FOV y zoom pertenecen a la cámara, no a la dirección. Aquí está la conversión en la que se basa toda la característica:

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),
  );
}

La inversa es la imagen especular (normalizedX = yaw/360 + 0.5), y la usamos constantemente para dibujar un cuadro delimitador rastreado de nuevo en el reproductor.

"Toca lo que ves" — Ray casting de perspectiva

El usuario no interactúa con el rectángulo equirectangular. Interactúa con una vista en perspectiva renderizada. Así que cuando tocan un punto en el reproductor, necesitamos lanzar un rayo a través de una cámara estenopeica virtual y encontrar qué dirección en la esfera impactaron. Eso es 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;

El .clamp(-1.0, 1.0) antes de asin parece paranoico, pero la deriva de punto flotante te dará 1.0000001 y asin devolverá NaN — y un solo NaN contamina cada fotograma subsiguiente. El recorte defensivo alrededor de las funciones trigonométricas inversas es un tema recurrente en este código base.

2. Movimiento suave de la cámara: cuaterniones, no ángulos de Euler

El enfoque ingenuo es almacenar yaw/pitch en cada fotograma clave e interpolar linealmente los números. Se ve terrible. La interpolación de ángulos de Euler te da bloqueo de gimbal, velocidad angular no uniforme y saltos feos cerca de los polos.

Interpolamos la orientación como un cuaternión usando SLERP (interpolación lineal esférica). Cada dirección se convierte en un cuaternión mediante una convención de Euler 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);
}

Y el propio SLERP tiene las dos válvulas de seguridad de grado de producción que toda implementación necesita:

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();
}

Dos detalles innegociables:

  1. Hemisferio más corto — q y −q representan la misma orientación. Si el producto escalar es negativo, niegas una entrada o tu cámara toma el camino largo alrededor de la esfera.
  2. Respaldo LERP casi paralelo — cuando dos fotogramas clave son casi idénticos, sinTheta0 → 0 y divides por casi cero. Recurrir a LERP simple por encima de dot > 0.9995 evita la explosión.

Una decisión de diseño sutil pero importante: hacemos SLERP para la dirección, pero LERP para el FOV/zoom y LERP angular para el roll. La interpolación esférica es la herramienta adecuada para la dirección; un zoom es solo un escalar y debe moverse linealmente:

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);

💡 Al convertir de nuevo de un cuaternión a yaw/pitch, usamos atan2 en lugar de asin "para estabilidad numérica (evita problemas de dominio de asin)". Después de un SLERP, el cuaternión puede estar muy ligeramente desnormalizado, y atan2 se degrada de manera elegante donde asin lanza NaN.

Easing: hecho a mano, en la transición entrante

Cada fotograma clave posee la curva de easing para la transición hacia él. Las curvas están escritas a mano para que sean idénticas en cada plataforma:

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. La costura de ±180°: el error que persigue a cada función 360

Aquí está la trampa. El yaw vive en un círculo: +179° y −179° están a 2° de distancia, no 358°. En el instante en que tu sujeto cruza la parte posterior de la esfera, las matemáticas ingenuas hacen que la cámara salte la mitad del mundo. Este único problema aparece en cuatro capas diferentes del sistema, y cada una necesita su propia solución.

Capa 1 — primitivas. Dos pequeños ayudantes en los que se apoya todo el código base:

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;
}

Capa 2 — la generación de fotogramas clave acumula un yaw continuo (sin límites) para que SLERP siempre interpole por el camino más corto. En lugar de limitar cada muestra a ±180°, sumamos las deltas más cortas y dejamos que el valor en ejecución se desvíe más allá de 180° a propósito:

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);

Capa 3 — reconstrucción del cuadro delimitador (bounding-box). Cuando el usuario dibuja un bbox que se extiende sobre la costura, las cuatro esquinas reportan yaws como +170°, +175°, −175°, −170°. SphericalProjection detecta esto (cualquier esquina > 90° y cualquier esquina < −90°) y reconstruye un bbox "seguro para la costura" de yaw continuo desplazando los negativos en +360° antes de medir su extensión.

Capa 4 — el reproductor en vivo se suaviza hacia su objetivo usando shortestYawDelta para que arrastrar a través de la costura nunca cause sacudidas.

Si recuerdas una cosa sobre la construcción de herramientas 360: la costura no es un caso extremo, es una preocupación transversal. Presupuesta para ella dondequiera que aparezca el yaw.

4. Haciendo que se sienta como Insta360/GoPro

La interpolación suave te da una cámara técnicamente correcta. No te da una buena. Tres piezas de "ingeniería de sensaciones" cierran esa brecha.

Restricciones de movimiento — limitar velocidad y aceleración. Los datos de seguimiento brutos son inestables. Pasamos los fotogramas clave a través de MotionConstraints, que limita la velocidad y la brusquedad con que la cámara virtual puede moverse (por ejemplo, maxYawSpeed = 120°/s, maxPitchSpeed = 90°/s, maxAcceleration = 180°/s²), con preajustes cinematic(), responsive() y production(). Esta es la diferencia entre "cámara de seguridad siguiendo una mosca" y "operador con mano firme".

FOV Adaptativo — zoom para encuadrar al sujeto. Medimos el tamaño angular del sujeto en el espacio equirectangular y elegimos un FOV para que llene una fracción objetivo del encuadre:

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°

Anticipación de movimiento + fusión cara→cuerpo. Empujamos el encuadre ~15% del ancho del bbox hacia adelante en la dirección del movimiento, para que el sujeto no quede pegado al borde hacia el que camina. Y cuando la detección facial se pierde, no nos ajustamos al centro del cuerpo — hacemos LERP desde el último centro facial conocido hasta el centro del cuerpo durante ~10 fotogramas.

5. El truco de los dos videos: proxy de vista previa vs. original de resolución completa

Esta es la decisión arquitectónica que hace que todo el sistema sea utilizable en teléfonos reales.

Un archivo 360° de GoPro/Insta360 es a menudo 4096×2048 HEVC. Los decodificadores Android de bajo presupuesto se ahogan al intentar reproducirlo sin problemas, y mucho menos mientras ejecutan el seguimiento de objetos. Así que ejecutamos el seguimiento y la vista previa en vivo en un proxy transcodificado (~1440×720, libx264 -preset veryfast -crf 28), y reservamos el original de resolución completa solo para la exportación final.

// "más fiable que MediaMetadataRetriever para vídeos 360°
//  que a menudo utilizan códecs inusuales (HEVC, alta resolución)"
final maxSafeWidth = 2048, maxSafeHeight = 1080;
// 2:1 4K (4096×2048) es inseguro -> activar transcodeForPlayback()

El detalle que debes manejar: los cuadros delimitadores de los fotogramas clave se calculan en coordenadas de proxy y deben escalarse a las coordenadas originales antes de la exportación. Si este escalado es incorrecto, tu exportación encuadra una región ligeramente diferente a la que el usuario previsualizó. (También usamos ffprobe, no MediaMetadataRetriever, para la extracción de metadatos y el primer fotograma — el recuperador incorporado de Android es inestable con los códecs inusuales que emiten las cámaras 360).

6. Exportación GPU: OpenGL + MediaCodec a través de un MethodChannel

Reproyectar cada fotograma de una esfera 4K a través del filtro v360 de FFmpeg en la CPU de un teléfono es demasiado lento. (Sí mantenemos la cadena v360 — documenta claramente la semántica de proyección, v360=e:flat:... que significa "equirectangular de entrada, plano de salida" — pero no es la ruta en vivo).

La exportación real entrega los fotogramas clave a un renderizador nativo OpenGL ES + MediaCodec a través de un 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
});

Protegemos esto con una verificación de capacidad (Android API 21+, un codificador H.264 de hardware, OpenGL ES 3.0) y transmitimos el progreso de vuelta a través de un EventChannel. Algunos escollos difíciles de superar:

  • El desplazamiento de yaw de −90°. Nuestra convención Dart sitúa el yaw = 0 en el centro de la imagen equirectangular (u = 0.5); el renderizador OpenGL sitúa el yaw = 0 en u = 0.75. Así que cada yaw de fotograma clave se reasigna en −90° (y se vuelve a ajustar a ±180°) al cruzar el canal — aplicado simétricamente tanto al canal de exportación como al canal del reproductor en vivo, para que la vista previa y la exportación coincidan.
  • Recuentos degenerados de fotogramas clave. Cero fotogramas clave → sintetizar dos idénticos a partir de la orientación inicial (exportación estática). Un fotograma clave → duplicarlo. El renderizador siempre necesita al menos un inicio y un final.
  • El recorte reajusta las marcas de tiempo. Una exportación recortada desplaza cada fotograma clave por t − trimStartMs.

El post-procesamiento FFmpeg que nadie espera

Uno pensaría que la exportación GPU es la meta. No lo es — porque MediaMuxer de Android escribe el átomo moov al final del archivo y puede llevar el desplazamiento PTS de audio de la fuente. El resultado: ExoPlayer (y video_player de Flutter) congela la imagen mientras el reloj sigue corriendo. Así que después del paso GPU, FFmpeg realiza una limpieza rápida y sin pérdidas:

// copiar video (sin re-codificación -> cero pérdida de calidad), re-codificar audio para corregir PTS,
// mover el átomo moov al principio para una reproducción instantánea.
-c:v copy -af "aresample=async=1:first_pts=0" -movflags +faststart

Y un pequeño pero brutal inconveniente que lo protege: primero buscamos una pista de audio, porque pasar banderas de audio a un video que no tiene pista de audio hace que FFmpeg se cuelgue indefinidamente en algunos dispositivos. Todo el post-procesamiento está envuelto en un tiempo de espera de 60 segundos con cancelación por tiempo agotado.

7. La vista previa en vivo también es un reproductor nativo

La vista previa en el editor no es un Flutter CustomPaint — es una superficie nativa OpenGL incrustada a través de AndroidView (con un viewType como 'your_app/spherical360player'), con su propio canal de método/evento por vista. El Dart Spherical360PlayerController refleja yaw/pitch/roll/fov y aplica el mismo desplazamiento de −90° antes de enviar.

Los detalles de sensación que lo hacen agradable:

  • Suavizado independiente de la velocidad de fotogramas — factor = 1 - exp(-speed * dt) para que el easing se vea igual a 60fps y 90fps, con el yaw de trayectoria más corta seguro para la costura incorporado.
  • Momento/inercia después de un deslizamiento, con decaimiento basado en fricción — desactivado durante la reproducción, porque durante la reproducción la cámara debe obedecer los fotogramas clave, no el último movimiento del usuario.
  • Un GestureDetector cuyo onScaleUpdate realiza pan y pinch-zoom juntos; un doble toque alterna reproducir/pausar. El FOV se limita a 30°–120°, el pitch a ±90°, en el límite del controlador.

Una pequeña pero reveladora corrección de UX: antes de cambiar de "seguimiento" a "generación de fotogramas clave", insertamos un retraso de 120 ms para que Flutter realmente pinte "100%". Sin él, el fotograma final de progreso y el cambio de estado caen en el mismo lote de microtareas y el usuario nunca ve la finalización.

Máquina de estados, brevemente

Todo esto es coordinado por un Riverpod ReframeNotifier que impulsa una enumeración de estado:

inactivo → cargandoVideo → transcodificando
    → (detectando | seleccionandoObjeto | dibujandoBboxEnReproductor | autoDetectandoEnReproductor)
    → seguimiento → generandoFotogramasClave → editando → exportando → completado | error

Múltiples puntos de entrada alimentan el mismo rastreador: tocar para detectar (superposición YOLO), bbox manual o el gesto de dibujar directamente en el reproductor al estilo Insta360. Todos convergen en KeyframeGenerator.production().generate(...) → KeyframeSmoother.smooth(...), y desde allí el interpolador impulsa cada fotograma previsualizado y exportado.

Lecciones aprendidas

  1. Mantén las matemáticas puras. Sin importaciones de Flutter en la capa de proyección/interpolación. Es la única razón por la que esto fue comprobable.
  2. SLERP de Cuaterniones para dirección; LERP para escalares. No interpoles ángulos de Euler, y no hagas SLERP con tu zoom.
  3. La costura de ±180° es una preocupación transversal, no un caso extremo. Reaparece en primitivas, generación, proyección y el reproductor.
  4. Unifica tus convenciones una vez, simétricamente. El desplazamiento de yaw de −90° entre Dart y OpenGL se aplica en ambos límites del canal para que vista previa == exportación.
  5. Proxy para interacción, original para salida. Solo recuerda escalar tus coordenadas entre ambos.
  6. "Renderizado completado" ≠ "reproduce correctamente". Presupuesta un post-procesamiento FFmpeg para corregir la ubicación del átomo moov y el PTS de audio, y sondea antes de añadir banderas de audio.
  7. La sensación es una característica. Las restricciones de movimiento, el FOV adaptativo y la anticipación de movimiento son lo que separa un "reencuadre técnicamente correcto" de uno que "parece que lo grabó un profesional".

El resultado: un editor de reencuadre 360° completamente en el dispositivo — toca un sujeto, deja que lo siga, ajusta los fotogramas clave y exporta un video plano limpio — construido en Flutter con una delgada capa GPU nativa que realiza solo el trabajo que Dart no puede hacer lo suficientemente rápido.

Flutter360VideoOpenGLQuaternions
Saurav Kumar Gupta's image

Sobre el Autor

Saurav Kumar Gupta

AI & Cloud Solutions Expert at MicrocosmWorks

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

¿Desea saber más?

Contáctenos para discutir cómo podemos ayudarle a implementar estas soluciones para su negocio.

Ponte en Contacto

Preguntas Frecuentes

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!