32blogby StudioMitsu

Transmite video HLS con FFmpeg y un CDN

Aprende a generar segmentos HLS con FFmpeg y servirlos mediante un CDN con streaming adaptativo. Cubre playlists m3u8, codificación multi-bitrate y configuración CORS.

11 min read
FFmpegHLSCDNvideo-streamingAdaptive Bitrate
Contenido

Cuando quieres servir video a través de un CDN, la primera pregunta siempre es: ¿en qué formato debería estar la salida? Servir un MP4 directamente parece el camino fácil, pero eso obliga a todos los espectadores a un solo bitrate sin importar la velocidad de su conexión. También hace que el caché del CDN sea menos eficiente porque la búsqueda genera solicitudes de rango directamente contra el origen.

HLS (HTTP Live Streaming) resuelve ambos problemas. Divide el video en segmentos pequeños y los sirve como archivos HTTP simples — exactamente lo que los CDN están diseñados para cachear. Agrega FFmpeg a la mezcla y puedes convertir cualquier archivo fuente a HLS con un solo comando.

Esta guía recorre el pipeline completo: codificación HLS de un solo bitrate, bitrate adaptativo (ABR) con una playlist maestra, subida a S3 y distribución a través de CloudFront, configuración CORS y reproducción en el navegador con hls.js.

Qué es HLS

HLS (HTTP Live Streaming) es un protocolo de streaming desarrollado por Apple. Funciona dividiendo un video en segmentos pequeños (archivos .ts, típicamente de unos pocos segundos cada uno) y proporcionando un archivo índice llamado playlist (.m3u8) que lista todos los segmentos en orden.

Comparado con servir un MP4 simple, HLS ofrece tres ventajas principales:

  • Caché a nivel de segmento: Cada segmento es una solicitud HTTP independiente, lo que los CDN cachean con alta eficiencia
  • Bitrate adaptativo (ABR): El reproductor cambia de nivel de calidad en tiempo real según el ancho de banda disponible (cuando se usa una playlist maestra)
  • Búsqueda eficiente: El reproductor solo descarga el segmento que contiene la marca de tiempo solicitada, haciendo que navegar por videos largos sea rápido

Hay dos tipos de playlists. Una playlist de medios lista los segmentos para un solo nivel de calidad. Una playlist maestra lista múltiples playlists de medios, una por nivel de calidad — ese es el punto de entrada cuando ABR está en uso.

Codificación HLS de un solo bitrate

Comencemos con el caso más simple: convertir un archivo de video a HLS con un solo bitrate.

bash
ffmpeg -i input.mp4 \
  -c:v libx264 \
  -preset fast \
  -crf 22 \
  -c:a aac \
  -b:a 128k \
  -hls_time 6 \
  -hls_list_size 0 \
  -hls_segment_filename "output/segment_%04d.ts" \
  output/index.m3u8

Esto es lo que hace cada flag relevante:

FlagPropósito
-hls_time 6Duración objetivo del segmento en segundos. De 4 a 10 segundos es común para VOD
-hls_list_size 0Mantener todos los segmentos en la playlist (el streaming en vivo usa un valor diferente)
-hls_segment_filenamePatrón de ruta para los archivos de segmento de salida
-crf 22Nivel de calidad — menor es mejor calidad y mayor tamaño de archivo. El rango práctico es 18–28
-preset fastBalance entre velocidad de codificación y compresión. slow genera archivos más pequeños, fast ejecuta más rápido

Después de ejecutar el comando, el directorio output/ contendrá:

output/
├── index.m3u8          # Archivo de playlist
├── segment_0000.ts     # Primer segmento (~6 segundos)
├── segment_0001.ts
├── segment_0002.ts
└── ...

Sube este directorio a cualquier host estático o CDN y estará listo para reproducir.

Construye streams de bitrate adaptativo (ABR)

Con un solo bitrate, los espectadores con conexiones lentas reciben el mismo archivo que los de conexiones rápidas — lo que provoca buffering para algunos y desperdicio de ancho de banda para otros. ABR permite que el reproductor mida la velocidad de conexión y seleccione automáticamente el mejor nivel de calidad para cada espectador.

Codifica tres niveles de calidad por separado:

bash
# Calidad baja (360p, ~500 kbps)
ffmpeg -i input.mp4 \
  -c:v libx264 -preset fast -crf 28 \
  -vf "scale=640:360" \
  -c:a aac -b:a 96k \
  -hls_time 6 -hls_list_size 0 \
  -hls_segment_filename "output/360p/segment_%04d.ts" \
  output/360p/index.m3u8

# Calidad media (720p, ~1500 kbps)
ffmpeg -i input.mp4 \
  -c:v libx264 -preset fast -crf 23 \
  -vf "scale=1280:720" \
  -c:a aac -b:a 128k \
  -hls_time 6 -hls_list_size 0 \
  -hls_segment_filename "output/720p/segment_%04d.ts" \
  output/720p/index.m3u8

# Calidad alta (1080p, ~3000 kbps)
ffmpeg -i input.mp4 \
  -c:v libx264 -preset fast -crf 20 \
  -vf "scale=1920:1080" \
  -c:a aac -b:a 192k \
  -hls_time 6 -hls_list_size 0 \
  -hls_segment_filename "output/1080p/segment_%04d.ts" \
  output/1080p/index.m3u8

Una vez que las tres codificaciones terminen, crea la playlist maestra manualmente:

m3u8
#EXTM3U
#EXT-X-VERSION:3

#EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=640x360,CODECS="avc1.42e01e,mp4a.40.2"
360p/index.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=1500000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2"
720p/index.m3u8

#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
1080p/index.m3u8

Guarda esto como output/master.m3u8. El reproductor carga este archivo primero, luego cambia entre las playlists de medios en tiempo real según el ancho de banda medido. La estructura final del directorio se ve así:

output/
├── master.m3u8         # Playlist maestra (punto de entrada del reproductor)
├── 360p/
│   ├── index.m3u8
│   ├── segment_0000.ts
│   └── ...
├── 720p/
│   ├── index.m3u8
│   └── ...
└── 1080p/
    ├── index.m3u8
    └── ...

Subida a un CDN

La configuración más común es S3 (o un almacenamiento compatible con S3 como Cloudflare R2) como origen, con CloudFront o Cloudflare al frente para caché y distribución global.

Sube los archivos con la CLI de AWS, aplicando diferentes headers de caché para playlists y segmentos:

bash
# Subir playlists m3u8 — deshabilitar caché para que los reproductores siempre obtengan la última versión
aws s3 sync output/ s3://your-bucket-name/videos/sample/ \
  --exclude "*" \
  --include "*.m3u8" \
  --content-type "application/vnd.apple.mpegurl" \
  --cache-control "no-cache, no-store"

# Subir segmentos ts — cachear agresivamente ya que los archivos de segmento nunca cambian
aws s3 sync output/ s3://your-bucket-name/videos/sample/ \
  --exclude "*" \
  --include "*.ts" \
  --content-type "video/mp2t" \
  --cache-control "public, max-age=31536000, immutable"

La estrategia de caché importa aquí:

  • Playlists m3u8: Usa no-cache. Una playlist puede cambiar si se agregan segmentos (en vivo) o el video se recodifica. Quieres que los reproductores siempre obtengan una copia fresca.
  • Segmentos ts: Usa max-age=31536000 (un año). Los nombres de archivo de los segmentos contienen un número de secuencia que nunca cambia, así que el contenido es inmutable — deja que el CDN lo cachee indefinidamente.

Si usas CloudFront, configura comportamientos de caché separados para *.m3u8 y *.ts para aplicar estas políticas de forma independiente.

Configuración de CORS y seguridad

Cuando hls.js carga segmentos desde un CDN en un origen diferente al de tu sitio web, el navegador aplica CORS. Sin los headers correctos, el navegador bloqueará cada solicitud de segmento y el video no se reproducirá.

Aplica una regla CORS al bucket S3:

bash
# Crear el archivo de configuración y aplicarlo
cat > cors-config.json << 'EOF'
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedOrigins": ["https://your-site.com"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]
EOF

aws s3api put-bucket-cors \
  --bucket your-bucket-name \
  --cors-configuration file://cors-config.json

Para CloudFront, agrega una política de headers de respuesta que incluya Access-Control-Allow-Origin. En la consola de AWS, ve a CloudFront → Response Headers Policies → Create policy y agrega los headers CORS ahí.

En cuanto a la seguridad, mantén el bucket S3 privado y usa Origin Access Control (OAC) para que solo CloudFront pueda leer de él. Para contenido de pago o autenticado, las URLs firmadas o cookies firmadas de CloudFront te permiten restringir el acceso a espectadores específicos.

Reproducción de HLS en el navegador con hls.js

Safari e iOS soportan HLS de forma nativa a través del elemento <video>. Chrome y Firefox requieren hls.js. El componente a continuación maneja ambos casos:

tsx
"use client";

import { useEffect, useRef } from "react";
import Hls from "hls.js";

interface HlsPlayerProps {
  src: string;
  poster?: string;
}

export function HlsPlayer({ src, poster }: HlsPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;

    // Safari has native HLS support — no hls.js needed
    if (video.canPlayType("application/vnd.apple.mpegurl")) {
      video.src = src;
      return;
    }

    if (!Hls.isSupported()) {
      console.error("HLS is not supported in this browser");
      return;
    }

    const hls = new Hls({
      maxBufferLength: 30,
      maxMaxBufferLength: 600,
    });

    hls.loadSource(src);
    hls.attachMedia(video);

    hls.on(Hls.Events.MANIFEST_PARSED, () => {
      video.play().catch(() => {
        // Autoplay may be blocked by browser policy unless the video is muted
      });
    });

    hls.on(Hls.Events.ERROR, (_, data) => {
      if (data.fatal) {
        switch (data.type) {
          case Hls.ErrorTypes.NETWORK_ERROR:
            hls.startLoad(); // Retry on network errors
            break;
          case Hls.ErrorTypes.MEDIA_ERROR:
            hls.recoverMediaError(); // Attempt recovery on decode errors
            break;
          default:
            hls.destroy(); // Unrecoverable error — clean up
        }
      }
    });

    return () => {
      hls.destroy();
    };
  }, [src]);

  return (
    <video
      ref={videoRef}
      controls
      poster={poster}
      style={{ width: "100%", aspectRatio: "16/9" }}
    />
  );
}

Instala hls.js con npm install hls.js. Las versiones recientes incluyen sus propios tipos de TypeScript, así que generalmente no se necesita un paquete separado @types/hls.js.

Usa el componente así:

tsx
// app/video/page.tsx
import { HlsPlayer } from "@/components/HlsPlayer";

export default function VideoPage() {
  return (
    <main>
      <h1>Sample Video</h1>
      <HlsPlayer
        src="https://cdn.your-site.com/videos/sample/master.m3u8"
        poster="https://cdn.your-site.com/videos/sample/thumbnail.jpg"
      />
    </main>
  );
}

Solución de problemas

Aquí están los problemas más comunes y cómo solucionarlos.

Los segmentos devuelven 404 o errores CORS

  • Verifica que las reglas CORS del bucket S3 incluyan los AllowedOrigins correctos
  • Comprueba que la política de headers de respuesta de CloudFront tenga Access-Control-Allow-Origin configurado
  • Confirma que los archivos .ts se subieron con Content-Type: video/mp2t — usar application/octet-stream puede causar problemas con algunos reproductores

La reproducción se detiene a mitad del video

  • La duración del segmento puede ser demasiado larga. Intenta reducir -hls_time a 4 segundos
  • Verifica si el caché del CDN realmente está funcionando. Revisa la tasa de aciertos de caché de CloudFront en las métricas de CloudWatch
  • El bitrate del stream de baja calidad aún puede ser demasiado alto para conexiones lentas. Intenta aumentar el CRF para 360p a 30 o más

Safari no puede reproducir el stream

  • Asegúrate de que los archivos .m3u8 se sirvan con Content-Type: application/vnd.apple.mpegurl
  • Verifica que no haya redirecciones de S3 interfiriendo. Accede a la URL del m3u8 directamente en un navegador para verificar

hls.js lanza MANIFEST_LOAD_ERROR

  • Revisa que la URL de la playlist maestra sea correcta
  • Asegúrate de que la lista de AllowedOrigins en CORS incluya http://localhost:3000 para desarrollo local (agrégalo como una entrada separada junto al dominio de producción)

La codificación es demasiado lenta

  • Cambia -preset a ultrafast para una codificación más rápida a costa del tamaño del archivo
  • Usa codificación por GPU: -c:v h264_nvenc para NVIDIA, -c:v h264_videotoolbox en Mac
  • Ejecuta las tres codificaciones en paralelo usando jobs en segundo plano de bash:
bash
ffmpeg -i input.mp4 -vf "scale=640:360" ... output/360p/index.m3u8 &
ffmpeg -i input.mp4 -vf "scale=1280:720" ... output/720p/index.m3u8 &
ffmpeg -i input.mp4 -vf "scale=1920:1080" ... output/1080p/index.m3u8 &
wait
echo "All encodes finished"

Para una introducción más amplia a los comandos de FFmpeg, consulta el tutorial de uso de FFmpeg.

Conclusión

El pipeline completo de streaming HLS con FFmpeg y un CDN se reduce a cinco pasos:

  1. Codifica con ffmpeg usando -hls_time 6 para producir segmentos y una playlist
  2. Para ABR, codifica tres niveles de calidad (360p / 720p / 1080p) y escribe una playlist maestra a mano
  3. Sube a S3 — no-cache para .m3u8, max-age=31536000 para .ts
  4. Configura CORS tanto en S3 como en CloudFront
  5. Usa hls.js en el navegador (con fallback nativo en Safari)

La configuración requiere más esfuerzo que simplemente poner un MP4 en un servidor, pero la recompensa es significativa: la eficiencia del caché del CDN aumenta y los espectadores con conexiones más lentas obtienen una experiencia fluida en lugar de buffering constante. Si estás poniendo video en producción, empezar con HLS y un CDN desde el primer día vale la pena.