Skip to content

Especificación del Formato .ovm ​

El formato .ovm (Object Video Media) es un estándar multimedia abierto diseñado para vídeo interactivo y multilingüe en la web. Combina contenedores de vídeo acelerados por hardware nativo (MP4 y WebM) con demultiplexación de metadatos a latencia cero mediante peticiones HTTP por rangos (Range requests) y capas de seguimiento temporal continuo a 60 FPS.


Arquitectura del Formato ​

text
+-------------------------------------------------------------------------+
| Capa +1: Capa Dinámica Interactiva de Vídeo                             |
| - Renderizada en tiempo real sobre el lienzo del vídeo en Shadow DOM    |
| - Subtítulos multilingües, botones hotspot dinámicos, tarjetas activas  |
| - Interpolación matemática de seguimiento temporal (tracking) a 60 FPS  |
+-------------------------------------------------------------------------+
| Capa 0: Separador (simbólica, no almacena nada)                         |
+-------------------------------------------------------------------------+
| Capa −1: Fotogramas de vídeo horneados                                  |
| - Contenedor estándar MP4 (H.264/HEVC/AV1) o WebM (VP9/AV1)             |
| - Sin decodificación en JS: el navegador lo gestiona todo               |
| - Reproducción instantánea: no requiere descargar el archivo completo   |
+-------------------------------------------------------------------------+

Las arquitecturas tradicionales de vídeo interactivo obligan a descargar el archivo completo, usar demultiplexores pesados en WebAssembly o alojar archivos JSON externos que se pierden al mover el vídeo. .ovm resuelve esto integrando los metadatos dentro del contenedor y permitiendo su extracción instantánea.


Protocolo de Demuxing a Latencia Cero (HTTP Range Requests) ​

Para iniciar la reproducción inmediatamente sin esperar la descarga de megabytes de vídeo, el reproductor <ovm-player> aplica una estrategia de tres niveles:

text
[Petición video.ovm]
       |
       v
Nivel 1: Sonda de Cabecera (Range: bytes=0-262143)
       |
       +---> ¿Firma "OVM_Metadata" presente?
             |-- SÍ --> Extraer Base64 -> Recortar padding -> Descomprimir Deflate crudo
             |-- NO  --> Pasar a Nivel 2
       |
       v
Nivel 2: Sonda de Cola (Range: bytes=-65536)
       |
       +---> ¿"ovm_version" o "slots" en los últimos 64 KB?
             |-- SÍ --> Parsear JSON balanceado mediante contador de llaves
             |-- NO  --> Pasar a Nivel 3
       |
       v
Nivel 3: Fallback Sidecar (Petición video.ovm.json)
       |
       +---> El servidor no soporta HTTP 206 -> Descargar JSON complementario

Nivel 1: Petición de Cabecera (Primeros 256 KB) ​

El reproductor emite la solicitud inicial:

http
GET /media/demo.ovm HTTP/1.1
Range: bytes=0-262143
  1. Examina los primeros 262.144 bytes buscando la firma ASCII OVM_Metadata (o metadata.ovmdata).
  2. Extrae la secuencia continua en Base64 coincidente con /[A-Za-z0-9+/=]{100,}/.
  3. Recorta iterativamente los bytes sobrantes de padding y descomprime mediante DecompressionStream('deflate-raw').

Nivel 2: Petición de Cola (Últimos 64 KB) ​

Si el software de codificación colocó los metadatos al final del archivo contenedor:

http
GET /media/demo.ovm HTTP/1.1
Range: bytes=-65536
  1. Escanea los últimos 65.536 bytes buscando { seguido de ovm_version o slots.
  2. Extrae el objeto JSON balanceado con un analizador de profundidad que respeta cadenas y caracteres de escape.

Nivel 3: Fallback Sidecar ​

Si el servidor web devuelve HTTP 200 ignorando la cabecera Range (servidor sin soporte de contenido parcial), el cliente solicita automáticamente ${src}.json.


Interpolación de Seguimiento Temporal a 60 FPS ​

Los elementos interactivos en .ovm siguen objetos en movimiento dentro de la escena mediante fotogramas clave cuadriláteros almacenados en slot.tracking_data:

typescript
// Formato: [t, x1, y1, x2, y2, x3, y3, x4, y4]
tracking_data: [
  [0.00, 0.20, 0.30, 0.40, 0.30, 0.40, 0.50, 0.20, 0.50],
  [1.50, 0.35, 0.32, 0.55, 0.32, 0.55, 0.52, 0.35, 0.52]
]

Donde:

  • t: Marca de tiempo del vídeo en segundos.
  • (x1,y1) a (x4,y4): Coordenadas normalizadas ($0.0 \le x, y \le 1.0$) de los 4 vértices del polígono que delimita al objeto.

Algoritmo de Interpolación Lineal (LERP) ​

En cada ciclo de refresco de pantalla durante la reproducción:

  1. Se localizan los fotogramas clave delimitadores $K_{\text{antes}}[0] \le t$ y $K_{\text{después}}[0] > t$.
  2. Se calcula el progreso relativo normalizado: $$p = \operatorname{clamp}\left(\frac{t - t_0}{t_1 - t_0}, 0, 1\right)$$
  3. Se interpolan linealmente las 8 coordenadas del cuadrilátero: $$\operatorname{coord}[i] = K_{\text{antes}}[i] + \left(K_{\text{después}}[i] - K_{\text{antes}}[i]\right) \times p$$
  4. Se calcula el punto central del polígono: $$\operatorname{left} = \left(\frac{x_1 + x_3}{2}\right) \times 100%$$ $$\operatorname{top} = \left(\frac{y_1 + y_3}{2}\right) \times 100%$$
  5. Se posiciona el elemento mediante CSS transform: translate(-50%, -50%).

Esquema Zod Oficial (@oim-media/core) ​

typescript
import { z } from "zod";
import { slotSchema, zoneSchema } from "@oim-media/core";

export const OvmMetadataSchema = z.object({
  ovm_version: z.string().optional(),                // Versión de formato ("1.0")
  version: z.string().optional(),
  ovm_id: z.string().max(128).optional(),            // Identificador global UUID
  image_hash: z.string().max(64).optional(),          // Hash SHA-256 de la pista de vídeo
  immutable_lock: z.boolean().optional(),            // Candado contra modificaciones
  metadata: z.object({
    fps: z.number().min(1).max(120).optional(),      // Tasa de cuadros por segundo
    resolution: z.tuple([z.number(), z.number()]).optional(), // [Ancho, Alto]
  }).optional(),
  zones: z.array(zoneSchema).optional(),             // Catálogo de zonas
  slots: z.array(slotSchema),                        // Slots con seguimiento
  signature: z.string().optional(),                  // Firma criptográfica ECDSA
  key_version: z.string().optional(),
  timestamp: z.string().datetime({ offset: true }).optional(),
}).passthrough();

Ejemplo Completo del Contrato JSON ​

json
{
  "ovm_version": "1.0",
  "ovm_id": "ovm_98a7b6c5d4e3",
  "metadata": {
    "fps": 60,
    "resolution": [1920, 1080]
  },
  "slots": [
    {
      "id": "etiqueta_ponente",
      "name": "Rótulo de Ponente",
      "type": "text",
      "content": {
        "es": "Dra. Elena Vance — Investigadora Principal",
        "en": "Dr. Elena Vance — Lead Researcher"
      },
      "style": {
        "color": "#ffffff",
        "fontSize": "16px",
        "font": "Inter"
      },
      "tracking_data": [
        [0.0, 0.40, 0.20, 0.60, 0.20, 0.60, 0.35, 0.40, 0.35],
        [2.5, 0.45, 0.22, 0.65, 0.22, 0.65, 0.37, 0.45, 0.37],
        [5.0, 0.50, 0.25, 0.70, 0.25, 0.70, 0.40, 0.50, 0.40]
      ]
    }
  ],
  "signature": "MEUCIQ...firma_digital_ecdsa_64_bytes...",
  "key_version": "v1",
  "timestamp": "2026-09-03T03:10:00.000000+00:00"
}

Implementación del Motor de Demuxing en <ovm-player> ​

javascript
async function extractOvmMetadata(src) {
  // 1. Sonda de cabecera (Primeros 256 KB)
  try {
    const resp = await fetch(src, { headers: { Range: "bytes=0-262143" } });
    if (resp.ok || resp.status === 206) {
      const buffer = await resp.arrayBuffer();
      const text = new TextDecoder().decode(new Uint8Array(buffer));
      let idx = text.indexOf("OVM_Metadata");
      if (idx === -1) idx = text.indexOf("metadata.ovmdata");

      if (idx !== -1) {
        const match = text.substring(idx).match(/[A-Za-z0-9+/=]{100,}/);
        if (match) {
          const b64 = match[0];
          for (let i = b64.length; i >= 10; i--) {
            let cand = b64.substring(0, i);
            if (cand.length % 4 !== 0) cand += "=".repeat(4 - (cand.length % 4));
            try {
              const bytes = Uint8Array.from(atob(cand), (c) => c.charCodeAt(0));
              const ds = new DecompressionStream("deflate-raw");
              const writer = ds.writable.getWriter();
              writer.write(bytes);
              writer.close();
              const plain = await new Response(ds.readable).text();
              const parsed = JSON.parse(plain);
              if (parsed.slots || parsed.elements) return parsed;
            } catch (_) {}
          }
        }
      }
    }
  } catch (_) {}

  // 2. Sonda de cola de archivo (Últimos 64 KB)
  try {
    const resp = await fetch(src, { headers: { Range: "bytes=-65536" } });
    if (resp.ok || resp.status === 206) {
      const text = await resp.text();
      for (let i = 0; i < text.length; i++) {
        if (text[i] === "{" && (text.includes("ovm_version") || text.includes("slots"))) {
          let depth = 0, inStr = false;
          for (let j = i; j < text.length; j++) {
            if (text[j] === '"' && text[j - 1] !== "\\") inStr = !inStr;
            if (!inStr) {
              if (text[j] === "{") depth++;
              else if (text[j] === "}" && --depth === 0) {
                return JSON.parse(text.substring(i, j + 1));
              }
            }
          }
        }
      }
    }
  } catch (_) {}

  // 3. Fallback a archivo sidecar .json
  const sidecar = await fetch(src + ".json");
  if (sidecar.ok) return await sidecar.json();

  throw new Error("No se encontraron metadatos OVM");
}