32blogby Studio Mitsu

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.

by omitsu14 min read
FFmpegHLSCDNvideo-streamingAdaptive Bitrate

This article contains affiliate links.

Contenido

Para transmitir video HLS con FFmpeg y un CDN, codifica tu archivo con ffmpeg -i input.mp4 -hls_time 6 -hls_list_size 0 output.m3u8, sube los segmentos a S3 con headers de caché agresivos y reprodúcelos en el navegador con hls.js. Para bitrate adaptativo, codifica a múltiples resoluciones y crea una playlist maestra que apunte a cada nivel de calidad.

Si alguna vez intentaste servir un MP4 directamente desde un CDN, probablemente notaste el problema: todos los usuarios reciben el mismo bitrate sin importar su conexión, y el caché del CDN funciona mal porque cada seek genera range requests contra el origen.

HLS (HTTP Live Streaming) resuelve exactamente eso. El protocolo, definido en RFC 8216, divide el video en segmentos pequeños que se sirven como archivos HTTP normales — justo lo que los CDN saben cachear bien. Con FFmpeg puedes convertir cualquier archivo fuente a HLS con un solo comando.

En esta guía vas a configurar todo el pipeline: codificación HLS básica, bitrate adaptativo (ABR) con playlist maestra, subida a S3 y distribución por 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.

Source VideoMP4 / MOVEncodeFFmpegHLS SegmentationUploadCDNS3 + CloudFrontHTTPBrowserhls.js

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

La lista completa de opciones del muxer HLS está en la documentación oficial de FFmpeg. 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"
.m3u8Playlistno-cacheCDN EdgeCache Layermax-age=1yr.ts SegmentVideo Data

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. Desde la v1.6, hls.js incluye sus propios tipos de TypeScript, así que no necesitas @types/hls.js por separado.

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. Consulta la Guía de codificación GPU para más detalles
  • 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.

Preguntas frecuentes

¿Cuál es la diferencia entre HLS y DASH?

Ambos son protocolos de streaming con bitrate adaptativo que dividen el video en segmentos. HLS fue desarrollado por Apple y usa playlists .m3u8 con segmentos .ts. DASH (Dynamic Adaptive Streaming over HTTP) usa manifiestos .mpd con segmentos .m4s. HLS tiene mayor soporte nativo en navegadores (especialmente iOS/Safari) y es la opción más común para distribución de video general. DASH ofrece algo más de flexibilidad en la elección de códecs, pero necesita un reproductor JavaScript en todas las plataformas.

¿Qué duración de segmento debo usar?

Para VOD (video bajo demanda), entre 4 y 6 segundos funciona bien. Segmentos más cortos (2 segundos) permiten cambios de calidad más rápidos en ABR pero aumentan el número de solicitudes HTTP. Segmentos más largos (10 segundos) reducen el overhead de red pero hacen que la navegación por el video sea menos precisa. La especificación de Apple para HLS recomienda 6 segundos como duración objetivo.

¿Puedo usar HLS para transmisión en vivo con FFmpeg?

Sí. Para streaming en vivo, usa -hls_flags delete_segments con un -hls_list_size distinto de cero (por ejemplo, 5) para que la playlist solo mantenga los segmentos más recientes. También conviene agregar -hls_allow_cache 0 para evitar que los reproductores cacheen segmentos obsoletos. Puedes conectar la entrada de una cámara o captura de pantalla por pipe para transmitir en tiempo real.

¿Necesito CloudFront o puedo usar otro CDN?

Cualquier CDN que sirva archivos estáticos funciona: Cloudflare, Fastly, Akamai, Bunny CDN, etc. Este artículo usa CloudFront como ejemplo porque se integra directamente con S3 y permite configurar políticas de caché por ruta, lo cual es práctico para aplicar headers diferentes a .m3u8 y .ts. Si usas Cloudflare R2 como origen, el CDN integrado de Cloudflare es una opción natural con cero costos de egreso.

¿Por qué los segmentos son .ts en vez de .mp4?

El formato .ts (MPEG Transport Stream) es el formato de segmento predeterminado de HLS y tiene la mayor compatibilidad con reproductores. FFmpeg también soporta segmentos fMP4 (-hls_segment_type fmp4), que producen archivos .m4s. Los segmentos fMP4 son más pequeños y funcionan mejor con códecs modernos como AV1, pero algunos reproductores antiguos no los soportan. Quédate con .ts a menos que tengas una razón específica para cambiar.

¿Cuánto almacenamiento extra requiere ABR?

Aproximadamente 2–3x más que una codificación única a 1080p. Una escalera de tres niveles (360p + 720p + 1080p) almacena la versión a máxima calidad más dos copias a menor bitrate. Las versiones 360p y 720p pesan bastante menos por segundo de video, así que el total es menos de 3x. Para un video de 10 minutos a 1080p (~3 Mbps), calcula unos 250 MB para tres niveles frente a ~140 MB en un solo bitrate.

¿Funciona hls.js en navegadores móviles?

En iOS, Safari reproduce HLS de forma nativa sin necesidad de hls.js. En Android, Chrome soporta HLS a través de hls.js usando Media Source Extensions (MSE). El componente de este artículo detecta primero si hay soporte nativo y solo recurre a hls.js cuando es necesario, así que funciona en todos los navegadores y dispositivos modernos.

Conclusión

El pipeline completo de streaming HLS con FFmpeg y un CDN se resume en 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)

Configurar todo esto lleva más trabajo que subir un MP4 a un servidor, pero la diferencia en producción es real: el caché del CDN se aprovecha al máximo y los usuarios con conexiones lentas ven el video sin cortes en lugar de sufrir buffering constante. Si vas a poner video en producción, arrancar con HLS y un CDN desde el principio es la decisión correcta.

Codificar múltiples niveles de ABR consume mucha CPU. Si no quieres atar tu máquina local, monta un servidor de codificación dedicado en un VPS y ejecuta los tres encodes en paralelo en una máquina remota.

Kamatera

VPS en la nube de grado empresarial con centros de datos globales

  • 13 centros de datos (EE.UU., Europa, Asia, Oriente Medio)
  • Desde $4/mes por 1GB RAM — pago por uso
  • Prueba gratuita de 30 días

Artículos relacionados: