Convertir videos a HLS y MPEG-DASH con Rust
Usa Rust para ejecutar FFmpeg, convertir un video local a HLS y MPEG-DASH, y servir los archivos completos por HTTP. Este tutorial crea una variante HLS y dos variantes de video DASH y luego reproduce cada formato con ffplay. El código Rust gestiona procesos y archivos; FFmpeg realiza la codificación, por lo que no necesitas encabezados de desarrollo de FFmpeg ni bindings para Rust.
Comprende la salida
HLS usa listas de reproducción .m3u8; DASH usa un manifiesto XML .mpd.
Ambos describen segmentos multimedia que un reproductor obtiene por HTTP. La reproducción con tasa
de bits adaptativa también necesita varias variantes compatibles y un reproductor que seleccione
entre ellas a medida que cambian las condiciones.
El ejemplo HLS empaqueta una sola codificación. El ejemplo DASH ofrece video de 1920 × 1080 y 1280 × 720 con una pista de audio compartida. Crear estos archivos y reproducirlos localmente demuestra el empaquetado y la reproducción, no la adaptación al ancho de banda, una mejora en la latencia de entrega ni un servicio de streaming de producción.
Configuración de un proyecto Rust
Usa Bash en Linux, un conjunto de herramientas estable de Rust de rustup y una
distribución de FFmpeg que contenga ffmpeg, ffprobe y
ffplay. En algunas distribuciones, ffplay es un reproductor empaquetado por separado;
comprueba que esté disponible antes de crear el proyecto. La reproducción necesita una sesión de
escritorio y salida de audio. Esta guía se probó con Rust y Cargo 1.98.1 y con FFmpeg, ffprobe y
ffplay 9.0.1. Estas son versiones probadas, no requisitos mínimos intrínsecos; usa las comprobaciones
de capacidades que se muestran a continuación al evaluar otra versión que siga recibiendo mantenimiento.
En Ubuntu o Debian, el paquete de la distribución es una opción de instalación:
sudo apt-get update && sudo apt-get install -y ffmpeg
Comprueba las versiones y las capacidades. Tu compilación necesita los codificadores
libx264 y AAC, los multiplexores y demultiplexores HLS y DASH, y los filtros
testsrc2, geq y aevalsrc que usa el generador de muestras.
cargo --version && rustc --version &&
ffmpeg -version && ffprobe -version && ffplay -version &&
ffmpeg -encoders && ffmpeg -muxers && ffmpeg -demuxers && ffmpeg -filters
Crea el proyecto fuera de un proyecto Cargo existente o de una configuración
.cargo en un directorio antecesor.
Cargo detecta la configuración en los directorios superiores,
y la creación del proyecto puede incorporar un paquete nuevo a un espacio de trabajo que lo
contenga. Al pegar este bloque, se comprueba ese límite antes de crear archivos y se mantienen
sin cambios el directorio y las opciones de tu shell:
(
set -eu
for tool in cargo rustc ffmpeg ffprobe ffplay; do
command -v "$tool" >/dev/null
done
directory=$(pwd -P)
while :; do
for config in "$directory/Cargo.toml" "$directory/.cargo/config" "$directory/.cargo/config.toml"; do
if [ -e "$config" ] || [ -L "$config" ]; then
printf 'Choose a directory outside this Cargo configuration: %s\n' "$config" >&2
exit 1
fi
done
[ "$directory" = / ] && break
directory=$(dirname "$directory")
done
cargo new --vcs none --edition 2021 video_converter
)
Si video_converter ya existe, elige un directorio nuevo en lugar de eliminar un proyecto
existente. Todos los bloques de shell posteriores se ejecutan desde el directorio superior que
contiene video_converter. Reemplaza el archivo video_converter/Cargo.toml generado por este
manifiesto completo. Las versiones de las dependencias directas están fijadas a la API probada;
conserva el archivo Cargo.lock generado para repetir las compilaciones.
[package]
name = "video_converter"
version = "0.1.0"
edition = "2021"
[workspace]
[dependencies]
anyhow = "=1.0.104"
tokio = { version = "=1.53.2", features = ["macros", "rt-multi-thread"] }
warp = { version = "=0.3.7", default-features = false }
Crea una entrada conocida
Genera un video de 9,375 segundos y 1920 × 1080 a 24 fotogramas por segundo con un tono de audio ascendente. El patrón de prueba en movimiento tiene ocho bloques en blanco y negro en la parte superior; estos codifican el número de fotograma, empezando por cero. Facilitan la identificación de contenido repetido o fuera de orden. La subshell rechaza una entrada existente antes de ejecutar FFmpeg:
(
set -eu
cd video_converter
if [ -e input.mp4 ] || [ -L input.mp4 ]; then
printf 'input.mp4 already exists; use a fresh project for this sample.\n' >&2
exit 1
fi
ffmpeg -hide_banner -loglevel error -nostdin -n \
-f lavfi -i 'testsrc2=size=1920x1080:rate=24:duration=9.375' \
-f lavfi -i 'aevalsrc=0.1*sin(2*PI*(440*t+40*t*t)):s=48000:d=9.375' \
-filter_threads 1 \
-vf "geq=lum='if(lt(Y,120),16+219*mod(floor(N/pow(2,floor(X/240))),2),lum(X,Y))':cb='cb(X,Y)':cr='cr(X,Y)'" \
-c:v libx264 -threads:v 2 -preset fast -crf 18 -pix_fmt yuv420p \
-c:a aac -b:a 128k -shortest input.mp4
)
La reproducción verificada usa esta muestra de 9,375 segundos. Para tu propia fuente, usa un MP4
local válido con un video sin rotar, con píxeles cuadrados, relación de aspecto 16:9 y una resolución
mínima de 1920 × 1080, además de una pista de audio. Esta escala fija de variantes está destinada a
esa entrada; no elige tamaños ni tasas de bits adecuados para cualquier material. Solo se usan el
primer flujo de video y el primer flujo de audio. El conversor normaliza el video a 24 fotogramas por
segundo y yuv420p. Comprueba por separado otras duraciones de clips y otros reproductores.
Integración de FFmpeg con Rust
Command de Rust pasa argumentos
directamente a FFmpeg sin usar una shell. El ejecutor que se muestra a continuación convierte la
ruta de entrada a su forma canónica, exige un directorio de salida nuevo y espera a que termine
cada proceso hijo. Solo informa de un resultado satisfactorio cuando ambas conversiones finalizan
correctamente sin diagnósticos de nivel de error.
Implementación de la conversión de video a HLS y MPEG-DASH
Reemplaza video_converter/src/main.rs por este programa completo:
use anyhow::{ensure, Context, Result};
use std::io::{self, Write};
use std::path::Path;
use std::process::Command;
const COMMON_ARGS: &[&str] = &[
"-hide_banner", "-loglevel", "error", "-nostdin", "-n", "-xerror",
"-threads", "2",
];
const ENCODING_ARGS: &[&str] = &[
"-c:v", "libx264", "-threads:v", "2", "-filter_threads", "1",
"-preset", "medium", "-profile:v", "main", "-pix_fmt", "yuv420p",
"-r", "24", "-g", "96", "-sc_threshold", "0", "-flags", "+cgop",
"-force_key_frames", "expr:gte(t,n_forced*4)",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000",
];
const HLS_ARGS: &[&str] = &[
"-map", "0:v:0", "-map", "0:a:0",
"-crf", "23", "-f", "hls", "-hls_time", "4",
"-hls_playlist_type", "vod", "-hls_segment_filename", "segment_%03d.ts",
"index.m3u8",
];
const DASH_ARGS: &[&str] = &[
"-map", "0:v:0", "-map", "0:v:0", "-map", "0:a:0",
"-filter:v:0", "scale=1920:1080", "-filter:v:1", "scale=1280:720",
"-b:v:0", "2M", "-b:v:1", "1M",
"-f", "dash", "-seg_duration", "4", "-use_template", "1", "-use_timeline", "1",
"-format_options", "use_editlist=0",
"-init_seg_name", "init-$RepresentationID$.m4s",
"-media_seg_name", "chunk-$RepresentationID$-$Number%05d$.m4s",
"-adaptation_sets", "id=0,streams=v id=1,streams=a", "manifest.mpd",
];
fn convert(input: &Path, output: &Path, format_args: &[&str]) -> Result<()> {
std::fs::create_dir(output).context("Use a new output directory")?;
let result = Command::new("ffmpeg")
.args(COMMON_ARGS)
.arg("-i").arg(input)
.args(ENCODING_ARGS)
.args(format_args)
.current_dir(output)
.output()
.with_context(|| format!("Could not launch FFmpeg for {}", input.display()))?;
io::stderr().write_all(&result.stderr)?;
ensure!(result.status.success() && result.stderr.is_empty(),
"FFmpeg failed for {} ({})", input.display(), result.status);
Ok(())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
ensure!(args.len() == 2,
"Usage: cargo run --bin video_converter -- INPUT.mp4 NEW_OUTPUT_DIR");
let input = std::fs::canonicalize(&args[0]).context("Cannot open input")?;
let output = Path::new(&args[1]);
std::fs::create_dir(output).context("Use a new output directory")?;
convert(&input, &output.join("hls"), HLS_ARGS)?;
convert(&input, &output.join("dash"), DASH_ARGS)?;
println!("Conversion complete.");
Ok(())
}
Conversión a HLS
HLS produce hls/index.m3u8 y segmentos MPEG-TS. Abre esta lista de reproducción multimedia
directamente: un ejemplo con una sola variante no necesita una lista de reproducción maestra.
CRF 23 selecciona un control de tasa con calidad constante, por lo que la tasa de bits varía según
el material. No es un objetivo de 2 Mbit/s ni un límite de tasa máxima.
El multiplexor HLS corta en los fotogramas clave después de alcanzar la duración objetivo del segmento. A 24 fotogramas por segundo, el GOP cerrado de 96 fotogramas y los fotogramas clave forzados alinean los límites de cuatro segundos. El segmento final de la muestra es más corto porque 9,375 segundos no es un múltiplo de cuatro.
Conversión a MPEG-DASH
Mapear el video dos veces crea variantes distintas de 1080p y 720p en un conjunto de adaptación de
video; el audio tiene su propio conjunto. El multiplexor DASH escribe
los fragmentos de inicialización, los fragmentos multimedia y su línea de tiempo en
dash/manifest.mpd.
La opción del contenedor MP4 use_editlist=0
desactiva las listas de edición. En el lector DASH de FFmpeg probado, esto evita que se vuelva a
aplicar el recorte inicial de AAC en cada límite de fragmento y que se descarten muestras entre
segmentos.
Los codificadores DASH usan tasas de bits medias objetivo de 2 Mbit/s y 1 Mbit/s a modo de ejemplo. No usan CRF al mismo tiempo, ya que eso seleccionaría un modo de control de tasa diferente en libx264. Estos valores no son límites estrictos de ancho de banda ni una recomendación de calidad para todos los videos. Ambas variantes de video usan la misma programación de fotogramas clave.
Creación de un servidor de streaming sencillo en Rust
Crea el directorio para un ejecutable de servidor independiente:
mkdir video_converter/src/bin
Guarda este programa completo como video_converter/src/bin/server.rs. Sirve el directorio de salida
especificado en la interfaz de loopback y asigna tipos de contenido a los archivos de streaming.
El mensaje que indica que está listo aparece después de enlazar correctamente el puerto; si el
puerto está ocupado, se devuelve un error en su lugar.
use anyhow::{ensure, Result};
use warp::{Filter, Reply};
#[tokio::main(worker_threads = 2)]
async fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
ensure!(args.len() == 2, "Usage: cargo run --bin server -- OUTPUT_DIR PORT");
let directory = std::fs::canonicalize(&args[0])?;
let port: u16 = args[1].parse()?;
let routes = warp::fs::dir(directory).map(|file: warp::fs::File| {
let content_type = match file.path().extension().and_then(|ext| ext.to_str()) {
Some("m3u8") => "application/vnd.apple.mpegurl",
Some("mpd") => "application/dash+xml",
Some("ts") => "video/mp2t",
Some("m4s") => "video/mp4",
_ => return file.into_response(),
};
let mut response = file.into_response();
response.headers_mut().insert(
warp::http::header::CONTENT_TYPE,
warp::http::HeaderValue::from_static(content_type),
);
response
});
let (address, server) = warp::serve(routes)
.try_bind_ephemeral(([127, 0, 0, 1], port))?;
println!("Serving http://{address}/");
server.await;
Ok(())
}
Los fragmentos DASH de audio y de video son contenedores MP4; el MPD describe qué pista contiene cada uno. El servidor y el reproductor usan el mismo origen local, por lo que este ejemplo no necesita una configuración CORS permisiva. No tiene un endpoint de subida ni autenticación.
Prueba de la configuración de streaming
Desde el directorio superior, compila y ejecuta el conversor y luego inicia el servidor solo si la conversión se realiza correctamente:
(
cd video_converter &&
cargo run --bin video_converter -- ./input.mp4 ./output &&
cargo run --bin server -- ./output 3030
)
El conversor muestra Conversion complete. Luego, el servidor muestra su URL. Si el puerto 3030 está ocupado, elige otro puerto en el comando del servidor y úsalo en las URL que aparecen a continuación. No ejecutes otro conversor sobre el mismo directorio mientras lo sirves.
En una segunda terminal, reproduce ambos manifiestos con el reproductor ffplay instalado. El primer reproductor se cierra al finalizar, antes de que se inicie el segundo:
ffplay -autoexit -threads 2 http://127.0.0.1:3030/hls/index.m3u8 &&
ffplay -autoexit -threads 2 http://127.0.0.1:3030/dash/manifest.mpd
Cada reproducción debería mostrar el mismo patrón en movimiento y los bloques cambiantes durante
unos 9,375 segundos, con un tono que sube de forma constante. La entrada generada tiene 225
fotogramas de video. DASH anuncia variantes de video de 1920 × 1080 y 1280 × 720; ffplay elige un
flujo de video, por lo que esta reproducción no demuestra el cambio entre ellas. Cierra un
reproductor con q. Detén el servidor en primer plano con Ctrl+C en su terminal;
los archivos de salida permanecen en el disco.
La falta de un segmento puede detener la reproducción incluso si su lista de reproducción se carga.
Mantén unido todo el árbol de directorios output e inspecciona el video y el audio
reales hasta el final de ambos flujos. Cargar un manifiesto por sí solo no demuestra que haga
referencia al contenido correcto.
Gestiona una conversión fallida
Si el directorio de salida ya existe, la operación falla sin reemplazarlo. Un archivo que falta, la ausencia de una pista de audio, un fallo al iniciar FFmpeg, un código de salida nativo distinto de cero o un error notificado por el decodificador impiden que aparezca el mensaje de finalización y que se ejecute el comando encadenado del servidor. FFmpeg a veces puede recuperar contenido multimedia dañado sin informar de un error; una conversión correcta no demuestra que cualquier fuente estuviera intacta.
Un fallo después de iniciar la codificación puede dejar archivos parciales, incluida una conversión
HLS completa cuando falla DASH. No sirvas ese directorio. Consérvalo para inspeccionarlo y vuelve a
intentar la conversión con un nombre de salida nuevo, como ./output-retry; cuando termine
correctamente, inicia el servidor con ese mismo nombre. Si falla la descarga de dependencias o la
compilación, conserva el proyecto y vuelve a ejecutar el comando de compilación después de resolver
el problema de conectividad o el error del compilador. Los comandos de Cargo compilan antes de
ejecutar, por lo que no se usa un ejecutable antiguo tras una compilación fallida.
Mantén este flujo de trabajo para la conversión y reproducción locales. Para una tarea de Rust distinta que implique varios trabajos de FFmpeg a la vez, consulta la adición simultánea de marcas de agua a videos con Rust.
